Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99f9076af8 | |||
| 280d10552c | |||
| 779387938c | |||
| 80d18754e2 | |||
| f26ed14b16 | |||
|
2a956951ab
|
|||
|
7fbff2e9e3
|
|||
|
3e80cdb8a7
|
|||
|
d2871f400d
|
|||
| 04f45af234 | |||
| dd76b0c4a9 | |||
| b6a5b563af | |||
| c921d26be0 | |||
| d693b7c11a | |||
| a108f383ea | |||
| 9204fde2f4 | |||
|
420883bd84
|
|||
|
d6c809daba
|
|||
|
0ae4214158
|
|||
|
2cff39bb51
|
|||
|
fa41c4d6ee
|
|||
|
9c59fc6593
|
|||
|
14dda37b5d
|
|||
|
4b6e2ddedd
|
|||
|
8b98cf15f2
|
|||
|
a9431d8f15
|
|||
|
12fc20bd58
|
|||
|
be70ca02f2
|
|||
|
51ea3ff407
|
|||
|
936287a4b9
|
|||
|
015d1b3617
|
|||
|
827eb65a5d
|
|||
|
55688b3b60
|
|||
|
7a6af0cf76
|
|||
|
a202ca6b2e
|
|||
|
916c7bcc30
|
|||
|
6910d6664e
|
|||
|
f25e96ee33
|
|||
|
017d33a2bc
|
|||
|
3fd8c27e8d
|
|||
|
f74eb415dc
|
|||
|
ce2c805d6e
|
|||
|
2579cf6490
|
|||
|
30036a38c0
|
|||
|
c5d9b2035e
|
|||
|
0fc8fe4c38
|
|||
|
477dd08503
|
|||
|
f3f91ccd36
|
|||
| 23a3d74521 | |||
| 2c85d21a6c | |||
| 2d548652cd |
@@ -15,6 +15,11 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# Weekly mutation test (Sunday 13:00 KST = 04:00 UTC). Only the
|
||||
# ``mutation-broker`` job below responds to ``schedule``; normal push / PR
|
||||
# runs ignore this cron.
|
||||
schedule:
|
||||
- cron: "0 4 * * 0"
|
||||
|
||||
env:
|
||||
RUST_COV_FAIL_UNDER: 80
|
||||
|
||||
@@ -58,7 +58,12 @@ jobs:
|
||||
- name: Ensure tag commit is on main
|
||||
run: |
|
||||
set -eux
|
||||
git fetch origin main --depth=1
|
||||
# Full fetch (no --depth): when the tag commit is a parent of
|
||||
# main's HEAD (release fix-up + follow-up commit on top), a
|
||||
# shallow main fetch grafts at HEAD and `is-ancestor` returns
|
||||
# false even though the tag commit is reachable. checkout step
|
||||
# already used fetch-depth: 0, so a full fetch here is cheap.
|
||||
git fetch origin main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" "origin/main"
|
||||
|
||||
- name: Verify Cargo/Python versions match tag
|
||||
@@ -153,7 +158,50 @@ jobs:
|
||||
- name: Build release session_helper (musl static)
|
||||
run: cargo build --manifest-path rust/Cargo.toml --release -p session_helper --target x86_64-unknown-linux-musl
|
||||
|
||||
- name: Upload to Gitea generic registry
|
||||
- name: Build release workspace (for signed bundle)
|
||||
run: cargo build --manifest-path rust/Cargo.toml --release --workspace
|
||||
|
||||
- name: Import GPG signing subkey
|
||||
env:
|
||||
GPG_SIGNING_SUBKEY: ${{ secrets.GPG_SIGNING_SUBKEY }}
|
||||
GPG_SIGNING_PASSPHRASE: ${{ secrets.GPG_SIGNING_PASSPHRASE }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "${GPG_SIGNING_SUBKEY:-}" ] || [ -z "${GPG_SIGNING_PASSPHRASE:-}" ]; then
|
||||
echo "GPG_SIGNING_SUBKEY / GPG_SIGNING_PASSPHRASE secret missing; failing release publish."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/.gnupg
|
||||
chmod 700 ~/.gnupg
|
||||
# Long cache so the sign + verify round-trip in
|
||||
# sign_release_artifacts.py doesn't trigger a fresh prompt mid-run.
|
||||
{
|
||||
echo "default-cache-ttl 28800"
|
||||
echo "max-cache-ttl 28800"
|
||||
echo "allow-loopback-pinentry"
|
||||
} > ~/.gnupg/gpg-agent.conf
|
||||
echo "pinentry-mode loopback" > ~/.gnupg/gpg.conf
|
||||
gpgconf --kill gpg-agent
|
||||
# Import the signing-only subkey (master comes through as stub).
|
||||
printf '%s' "$GPG_SIGNING_SUBKEY" | base64 -d | gpg --batch --import
|
||||
# Prime the agent with the passphrase so subsequent --detach-sign
|
||||
# calls in sign_release_artifacts.py hit the cache and don't prompt.
|
||||
echo "ci-prime" | gpg --batch --pinentry-mode loopback \
|
||||
--passphrase "$GPG_SIGNING_PASSPHRASE" \
|
||||
--local-user C01DF8180774AC13909B5E52CD1D23365D028C41 \
|
||||
--clearsign > /dev/null
|
||||
gpg --list-secret-keys --with-subkey-fingerprints \
|
||||
C01DF8180774AC13909B5E52CD1D23365D028C41
|
||||
|
||||
- name: Sign release artifacts (SHA256SUMS + .asc)
|
||||
run: python3 scripts/sign_release_artifacts.py
|
||||
|
||||
- name: Create release page + upload signed bundle
|
||||
env:
|
||||
TOKEN: ${{ secrets.TOKEN }}
|
||||
run: python3 scripts/create_gitea_release.py
|
||||
|
||||
- name: Upload session_helper to Gitea generic registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.TOKEN }}
|
||||
GITEA_USERNAME: ${{ secrets.GITEA_USERNAME }}
|
||||
@@ -167,6 +215,4 @@ jobs:
|
||||
python3 scripts/upload_session_helper_to_gitea.py \
|
||||
--platform-tag linux-x86_64 \
|
||||
--binary rust/target/x86_64-unknown-linux-musl/release/session_helper \
|
||||
--package-version "${{ needs.verify-release-tag.outputs.version }}" \
|
||||
--release-tag "${{ needs.verify-release-tag.outputs.tag_name }}" \
|
||||
--release-title "${{ needs.verify-release-tag.outputs.tag_name }}"
|
||||
--package-version "${{ needs.verify-release-tag.outputs.version }}"
|
||||
|
||||
22
README.md
22
README.md
@@ -93,10 +93,12 @@ Example manifest:
|
||||
|
||||
**Current product policy (local vs remote):**
|
||||
|
||||
- **Remote host:** **Linux only.** The remote `session_helper` is still resolved
|
||||
the same way: the **remote** machine downloads a pre-built binary from the
|
||||
Gitea generic registry (`curl` / `wget`), keyed by Linux platform tag and
|
||||
`rust/` revision. No remote `cargo build`.
|
||||
- **Remote host:** **Linux only.** The remote `session_helper` is fetched by the
|
||||
**editor** (not the remote): `local_bridge` downloads the matching binary from
|
||||
the Gitea generic registry into the editor cache, then pushes it to the remote
|
||||
over the existing SSH session. No `curl` / `wget` runs on the remote, and no
|
||||
remote `cargo build`. Binary is keyed by Linux platform tag + workspace
|
||||
semver from `rust/Cargo.toml`.
|
||||
- **Local machine (editor side):** **Linux, macOS, and Windows** are supported for
|
||||
running Sublime + this package. For day-to-day development, treat **`local_bridge`
|
||||
as built on that machine** (`cargo build -p local_bridge`, see *Development*).
|
||||
@@ -114,11 +116,13 @@ Current behavior:
|
||||
session. Python sends NDJSON request envelopes and receives async responses via
|
||||
a background reader thread. Each request gets a unique monotonic `envelope_id`
|
||||
to prevent response mis-routing under concurrency.
|
||||
- **Download-only helper resolution:** `session_helper` is downloaded directly by
|
||||
the remote machine from the Gitea generic registry (no `cargo build` fallback,
|
||||
no local download). The binary is identified by git revision + platform tag and
|
||||
cached at `$HOME/.cache/sessions/helpers/<revision>/session_helper`. If the
|
||||
download fails, the connection fails explicitly.
|
||||
- **Editor-cache helper resolution:** `session_helper` is downloaded by the
|
||||
editor host (`local_bridge`) from the Gitea generic registry into the editor
|
||||
cache, then pushed to the remote over the existing SSH session — `curl` /
|
||||
`wget` never run on the remote. Identified by workspace semver + Linux
|
||||
platform tag, the remote-side cache lives at
|
||||
`$HOME/.cache/sessions/helpers/<revision>/session_helper`. If the editor-side
|
||||
download fails, the connection fails explicitly (no `cargo build` fallback).
|
||||
- **Required handshake fields:** `Handshake.remote_home` and `Handshake.arch` are
|
||||
required (no `Option`, no fallback). The bridge merges helper ensure + launch
|
||||
into a single SSH command to avoid double authentication.
|
||||
|
||||
213
SECURITY.md
Normal file
213
SECURITY.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Security — what Sessions does and doesn't do
|
||||
|
||||
Some endpoint security products have flagged the `local_bridge` / `session_helper`
|
||||
binaries as suspicious when a user opens a Sessions workspace for the first time.
|
||||
This document exists so security reviewers and EDR administrators can write
|
||||
accurate allow rules without reverse-engineering the binaries.
|
||||
|
||||
## Scope
|
||||
|
||||
Sessions is an open-source Sublime Text plugin that lets a user edit files on a
|
||||
remote Linux host over SSH. It ships:
|
||||
|
||||
- A Sublime package (Python) under `sublime/` that talks to:
|
||||
- A workspace-local Rust binary `local_bridge` that speaks a JSON protocol
|
||||
over a Unix socket and spawns `ssh` children to reach the remote host.
|
||||
- A Rust binary `session_helper` that is uploaded to the remote host and serves
|
||||
file/LSP/tool requests over the `local_bridge` SSH pipe.
|
||||
|
||||
Project home: <https://git.teahaven.kr/sublime-rs/sessions>
|
||||
Author: Myeongseon Choi <key262yek@gmail.com>
|
||||
License: MIT
|
||||
|
||||
## What behavior looks like to a scanner
|
||||
|
||||
When a Sessions workspace is first opened the plugin performs two steps that can
|
||||
trigger ransomware-style heuristics on endpoint security products:
|
||||
|
||||
1. **Workspace cache materialization.** The plugin creates (and over the next
|
||||
seconds populates) a directory tree under the user's Sublime cache root
|
||||
(`<Sublime cache>/Sessions/workspaces/<key>/files/...`) that mirrors the
|
||||
remote workspace's layout. For a large project this is hundreds of `mkdir`
|
||||
calls and on-demand file writes from a single process in a short window — the
|
||||
exact shape of a ransomware "encrypt everything" pass.
|
||||
2. **SSH child spawning.** `local_bridge` spawns one long-lived `ssh` child per
|
||||
connected host, and per-Jupyter-session a detached `ssh -N -L` tunnel. Some
|
||||
behavioral engines flag repeated SSH invocations from an unsigned binary as
|
||||
lateral-movement activity.
|
||||
|
||||
These are benign — the plugin is simply caching remote files locally and
|
||||
forwarding ports — but the binaries are unsigned local builds, so they have no
|
||||
reputation credit to offset the heuristic.
|
||||
|
||||
## What the binaries do NOT do
|
||||
|
||||
- Do NOT modify, encrypt, or delete files outside the plugin's own cache root
|
||||
and the user's explicitly opened workspace folders.
|
||||
- Do NOT contact any network endpoint except:
|
||||
- The SSH host(s) the user explicitly connects to.
|
||||
- `127.0.0.1:<forwarded-port>` when the user starts a Jupyter session (the
|
||||
local end of an SSH `-L` forward).
|
||||
- Do NOT read files outside the configured workspace and cache directories.
|
||||
- Do NOT load plugins or code from untrusted sources at runtime.
|
||||
- Do NOT auto-update. Updates are pulled explicitly by the user via Package
|
||||
Control or `git pull`.
|
||||
|
||||
## Writing allow rules
|
||||
|
||||
Binary identity strings (embedded in `local_bridge --version` banner, also
|
||||
visible to `strings`):
|
||||
|
||||
- `local_bridge` and `session_helper` package names
|
||||
- `Long-lived SSH bridge FSM powering the Sessions Sublime plugin.`
|
||||
- `https://git.teahaven.kr/sublime-rs/sessions`
|
||||
- `Myeongseon Choi <key262yek@gmail.com>`
|
||||
|
||||
Path patterns (per OS):
|
||||
|
||||
- **Linux**: binaries live under `<Sublime packages>/Sessions/sublime/sessions/bin/`
|
||||
when shipped via Gitea release, or under `<repo>/rust/target/{debug,release}/`
|
||||
when built from source.
|
||||
- **macOS**: same layout.
|
||||
- **Windows**: same layout (`.exe` suffix on the binaries).
|
||||
|
||||
Directories that Sessions writes to:
|
||||
|
||||
- Sublime cache root (`~/.cache/sublime-text/Cache/Sessions/` on Linux,
|
||||
`~/Library/Caches/Sublime Text/Cache/Sessions/` on macOS, `%LOCALAPPDATA%\Sublime Text\Cache\Sessions\` on Windows).
|
||||
- User Sessions settings under `<Sublime packages>/User/Sessions.sublime-settings`.
|
||||
- `~/.ssh/sessions-*` socket files for the SSH ControlMaster.
|
||||
|
||||
Exec invocations:
|
||||
|
||||
- `ssh <host> ...` (long-lived persistent connection)
|
||||
- `ssh -N -L 127.0.0.1:<local>:127.0.0.1:<remote> <host>` (Jupyter tunnels)
|
||||
|
||||
## Building from source
|
||||
|
||||
All binaries are built from source in CI (`.gitea/workflows/ci.yml`). Release
|
||||
artifacts published under the Gitea project's releases page are byte-for-byte
|
||||
reproducible from the tagged source tree, subject to toolchain (Rust) and target
|
||||
triple being fixed. The CI workflow runs `cargo fmt --check`, `cargo clippy
|
||||
-- -D warnings`, `cargo test --workspace`, and a coverage gate.
|
||||
|
||||
Local build:
|
||||
|
||||
```sh
|
||||
cargo build --manifest-path rust/Cargo.toml --release --workspace
|
||||
```
|
||||
|
||||
## Verifying a Sessions release
|
||||
|
||||
Signed releases ship with two extra files alongside each platform binary
|
||||
bundle:
|
||||
|
||||
- `SHA256SUMS` — one `<hex> <filename>` line per release artifact.
|
||||
- `SHA256SUMS.asc` — ASCII-armored GPG detached signature over `SHA256SUMS`.
|
||||
|
||||
Verification steps:
|
||||
|
||||
```sh
|
||||
# 1. Import the Sessions signing key (one-time).
|
||||
gpg --keyserver keys.openpgp.org \
|
||||
--recv-keys C01DF8180774AC13909B5E52CD1D23365D028C41
|
||||
|
||||
# 2. Verify the signature covers the SHA256SUMS file.
|
||||
gpg --verify SHA256SUMS.asc SHA256SUMS
|
||||
# Look for: "Good signature from Myeongseon Choi <key262yek@gmail.com>"
|
||||
|
||||
# 3. Verify each artifact hash matches the manifest.
|
||||
sha256sum -c SHA256SUMS
|
||||
```
|
||||
|
||||
Signing key details:
|
||||
|
||||
- Owner: Myeongseon Choi <key262yek@gmail.com>
|
||||
- Master key fingerprint (certify): `C01DF8180774AC13909B5E52CD1D23365D028C41`
|
||||
- Signing-only subkey (release artifacts, from v0.6.4):
|
||||
`C6055FB91CA8C0E96B2D488ADC20B3978326B78B` (long key ID `DC20B3978326B78B`)
|
||||
- Published on: <https://keys.openpgp.org>
|
||||
- Also linked from the Gitea profile under the project owner's GPG keys.
|
||||
|
||||
`gpg --verify` against the master fingerprint accepts signatures from any
|
||||
valid subkey of that master, so the verification command above is unchanged
|
||||
across the v0.5.x → v0.6.4+ transition.
|
||||
|
||||
If `gpg --verify` reports "BAD signature" or an unknown key, do not run the
|
||||
binary; open an issue or email the owner.
|
||||
|
||||
### Signing model: master local, subkey in CI (v0.6.4+)
|
||||
|
||||
From v0.6.4 onward, release artifacts are signed by the dedicated
|
||||
**signing-only subkey** above, not the master. The master key (which has
|
||||
certify capability — i.e. the authority to add or revoke subkeys and
|
||||
sign user IDs) **never leaves a trusted workstation** and is not present
|
||||
on any CI runner.
|
||||
|
||||
What this means in practice:
|
||||
|
||||
- Gitea Actions imports only the signing subkey's secret material via the
|
||||
`GPG_SIGNING_SUBKEY` repo secret (base64-encoded `--export-secret-subkeys
|
||||
<SUB>!` output). The master key arrives as a public stub for verification
|
||||
context only.
|
||||
- A CI compromise (leaked secret, malicious workflow change, supply-chain
|
||||
hit on a third-party action) limits the attacker to **signing as the
|
||||
release-artifact identity until the subkey is revoked**. They cannot
|
||||
certify new subkeys, change uid binding signatures, or impersonate the
|
||||
master in any context that requires certification.
|
||||
- Subkey rotation / revocation is therefore independent of master-key
|
||||
rotation. The master's web-of-trust signatures, prior-release signatures,
|
||||
and identity bindings remain valid through a subkey compromise.
|
||||
|
||||
Maintainers producing a signed bundle locally still run
|
||||
`scripts/sign_release_artifacts.py` after `cargo build --release --workspace`;
|
||||
GnuPG will route the sign request through the signing subkey automatically
|
||||
when both keys are present in the keyring.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Send mail to Myeongseon Choi <key262yek@gmail.com>. If you need an encrypted
|
||||
channel, ask in the first message and a PGP key will be exchanged. Please do
|
||||
not file public issues for unpatched vulnerabilities.
|
||||
|
||||
## v0.5.0: bounded mirror burst
|
||||
|
||||
The workspace-open burst is now bounded by three cooperating caps, all tunable
|
||||
from ``Sessions.sublime-settings``. Together they make it structurally
|
||||
impossible for a first-open to produce the high-volume creates-then-deletes
|
||||
signature EDR ransomware rules look for, while still materialising enough of
|
||||
the tree that the sidebar is useful.
|
||||
|
||||
- ``sessions_mirror_max_entries`` (default 1000, down from 5000) — hard cap on
|
||||
total file + directory entries materialised in one mirror run.
|
||||
- ``sessions_mirror_max_dir_fanout`` (default 100) — any single directory with
|
||||
more visible children is left as a stub and recorded for later expansion.
|
||||
Huge trees (``node_modules/``, ``vendor/``, datasets) never get walked on
|
||||
auto runs; the user expands them explicitly via ``Sessions: Expand Deferred
|
||||
Directory`` or the sidebar right-click entry.
|
||||
- ``sessions_mirror_writes_per_second_cap`` (default 40) — token-bucket pacing
|
||||
for every zero-byte placeholder write. Sustained throughput stays well under
|
||||
typical EDR mass-file-write heuristics (often 50–100 ops/s).
|
||||
- Auto-sourced mirror passes now force ``prune_missing = false`` regardless of
|
||||
``sessions_mirror_prune_stale_cache`` unless
|
||||
``sessions_mirror_auto_prune_stale_cache`` is explicitly set true. On connect
|
||||
the plugin therefore creates without deleting — no "encrypt in place" shape.
|
||||
- A consecutive-failure circuit breaker trips after 3 failing writes; when an
|
||||
EDR is actively blocking writes the mirror stops rather than retrying in a
|
||||
hot loop.
|
||||
- ``sessions_shared_cache_root`` lets operators relocate the cache to a
|
||||
filesystem location already blessed by EDR allowlists.
|
||||
|
||||
## Known gotchas for endpoint security reviewers
|
||||
|
||||
- The initial workspace-open burst of file creations is unavoidable — it's the
|
||||
cache mirror. If your EDR supports per-process throttling, `local_bridge` and
|
||||
`session_helper` are the two processes to exempt from mass-file-write rules.
|
||||
- The Rust binaries are currently unsigned. Platform-specific signing is
|
||||
planned (GPG detached signatures for Linux release tarballs, Apple Developer
|
||||
ID for macOS) but not in place for every release yet. Treat the Gitea release
|
||||
page and its SHA256 manifest as the source of truth until signing lands.
|
||||
- The plugin does not bundle or load any third-party LSP / Jupyter / debugpy
|
||||
binaries; installers fetch those via `pip install --user` into the user's
|
||||
own Python environment on the remote host. Nothing is downloaded onto the
|
||||
local machine at runtime.
|
||||
@@ -85,87 +85,14 @@
|
||||
| F9 | 무변경 저장 최적화 | 동일 파일을 수정 없이 2회 이상 저장 | 두 번째 저장부터는 "skipped upload"류 메시지로 원격 write를 건너뛰는지 |
|
||||
| F10 | 재연결 후 project LSP 설정 보존 | `.sublime-project` `settings.LSP` 추가 후 `Reconnect Current Workspace` 실행 | 재연결/재머티리얼라이즈 후에도 `settings.LSP`가 유지되어야 함 |
|
||||
|
||||
### F-보강: 실동작 점검 체크리스트 (직접 검증용)
|
||||
|
||||
아래는 "설치됨으로 보이는데 실제 동작이 불확실"한 경우를 빠르게 분리하는 순서입니다.
|
||||
|
||||
1. **사전 준비**
|
||||
- 테스트용 `.py` 파일 1개를 워크스페이스 내에 준비
|
||||
- `View → Show Console` 열어 둠
|
||||
2. **설치/상태 확인**
|
||||
- `Sessions: Install Remote LSP Server` 실행
|
||||
- `Sessions: Remote LSP Server Status` 실행
|
||||
- 기대: 상태 패널에 installed/missing 목록 + 안내 문구가 보임
|
||||
3. **저장 진단 확인(별도 경로)**
|
||||
- 같은 파일을 일부러 lint 오류 나게 저장
|
||||
- 기대: `sessions_remote_python_tool_pipeline` 경로로 진단/패널 갱신
|
||||
4. **bridge 안정성 확인**
|
||||
- 설치 직후 `Open Remote File` + `Run Remote Python Lint` 실행
|
||||
- 기대: bridge disconnected 경고 없이 연속 동작
|
||||
5. **LSP 설정 보존 확인**
|
||||
- `.sublime-project`에 `settings.LSP` 블록 수동 추가
|
||||
- `Reconnect Current Workspace` 후 파일 재열기
|
||||
- 기대: `settings.LSP`가 남아 있고, Sessions 키만 갱신됨
|
||||
|
||||
실패 시 기록 최소셋:
|
||||
- 실행한 명령 이름(예: Install/Status/Save/Reconnect)
|
||||
- 콘솔의 `[Sessions LSP]` 또는 `[Sessions]` 한 줄
|
||||
- 실패 직전/직후 상태 패널 캡처 1장
|
||||
|
||||
### F-보강: install/probe/remove 오탐 방지 체크리스트
|
||||
|
||||
아래 순서는 `install/remove` 결과와 `probe` 결과가 어긋나는 문제를 최소화하기 위한 표준 점검 순서입니다.
|
||||
|
||||
1. **Install 직후 probe**
|
||||
- `Install Remote LSP Server` 실행
|
||||
- 즉시 `Remote LSP Server Status` 실행
|
||||
- 기대: install 성공 + 같은 서버 id가 `installed`
|
||||
2. **Remove 직후 probe**
|
||||
- `Remove Remote LSP Server` 실행
|
||||
- 즉시 `Remote LSP Server Status` 실행
|
||||
- 기대: remove 성공 + 같은 서버 id가 `missing`
|
||||
3. **Pyright 전용 probe 확인**
|
||||
- probe는 `pyright --version` 기준으로 통과해야 함
|
||||
- `pyright-langserver --version` 계열 오류(예: `Connection input stream is not set`)는 오탐 후보로 분류
|
||||
4. **Ruff probe 확인**
|
||||
- `ruff --version`이 0 종료인지 확인
|
||||
- remove 이후 `command not found`이면 정상 `missing`
|
||||
5. **rust-analyzer probe 확인**
|
||||
- `rust-analyzer --version` + `rustup component list --installed`에서 `rust-analyzer-*` 확인
|
||||
- rustup 경로 이슈 시 install/remove 결과와 probe 결과가 어긋날 수 있음
|
||||
|
||||
### F-보강: Go to Definition 무반응 최소 진단 포인트
|
||||
|
||||
한 번의 재현으로 끊기는 지점을 찾기 위한 최소 로그 포인트:
|
||||
|
||||
1. **브리지 생존 여부**
|
||||
- `bridge.session_reuse` 이후 `bridge.request_done`가 연속으로 찍히는지
|
||||
2. **mirror-sync 상태**
|
||||
- 직전 `mirror-sync`에서 `Broken pipe`, `No active bridge session`이 있었는지
|
||||
3. **LSP probe 상태**
|
||||
- 같은 시점 `Remote LSP Server Status`에서 대상 서버가 `installed`인지
|
||||
4. **워크스페이스 설정 보존**
|
||||
- 재연결 후 `.sublime-project`의 `settings.LSP`가 유지되는지
|
||||
5. **재현 직후 단일 확인**
|
||||
- `Open Remote File` 1회 + `Run Remote Python Lint` 1회를 연속 실행해 bridge/lsp 동시 헬스체크
|
||||
|
||||
---
|
||||
|
||||
## G. 레거시·탐색기
|
||||
## G. 회귀·콘솔
|
||||
|
||||
| # | 시나리오 | 수행 | 확인할 것 |
|
||||
|---|----------|------|-----------|
|
||||
| G1 | 스크래치 탐색기 | **Sessions: Open Remote Directory Explorer (legacy scratch)** | 레거시 경로라도 크래시 없이 목적에 맞게 쓸 수 있는지 |
|
||||
|
||||
---
|
||||
|
||||
## H. 회귀·콘솔
|
||||
|
||||
| # | 시나리오 | 수행 | 확인할 것 |
|
||||
|---|----------|------|-----------|
|
||||
| H1 | 콘솔 | 위 시나리오 수행 중 **View → Show Console** | Sessions 관련 스택 트레이스·반복 예외 없음 |
|
||||
| H2 | 멀티 윈도우 | (해당되면) 창 두 개에서 서로 다른 호스트/같은 호스트 | 세션 혼선·캐시 키 충돌 없음 |
|
||||
| H3 | GitSavvy 등 | README “Troubleshooting”에 나온 `git status` 잡음 | Sessions 기능과 무관한 노이즈인지 구분 가능한지 |
|
||||
| G1 | 콘솔 | 위 시나리오 수행 중 **View → Show Console** | Sessions 관련 스택 트레이스·반복 예외 없음 |
|
||||
| G2 | 멀티 윈도우 | (해당되면) 창 두 개에서 서로 다른 호스트/같은 호스트 | 세션 혼선·캐시 키 충돌 없음 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
387
planning/AGENT_TMUX_LAYOUT.md
Normal file
387
planning/AGENT_TMUX_LAYOUT.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# AGENT_TMUX_LAYOUT — remote agents via tmux, three-group Sublime window
|
||||
|
||||
**Status**: design only. Supersedes the earlier agent-chat / diff-viewer
|
||||
design (which has been dropped — we don't build a chat UI).
|
||||
|
||||
**Depends on**: `PYTHON_RUST_BOUNDARY.md` (no protocol changes here —
|
||||
agents run as plain CLIs over SSH; the bridge stays for file / LSP
|
||||
channels). Interacts with the managed-extension catalog (`kind="agent"`).
|
||||
|
||||
## Why tmux instead of a custom chat UI
|
||||
|
||||
The previous plan was to reimplement a chat widget in Sublime using
|
||||
phantoms, panels, and a custom NDJSON protocol to codex / claude
|
||||
daemons. That is a lot of UI code that reinvents a terminal. It also
|
||||
fragments when the agent CLI updates its protocol.
|
||||
|
||||
Observation: **every serious remote agent already ships a working
|
||||
terminal UI** (codex, claude code, anthropic CLI). Running that UI
|
||||
inside a Terminus pane that is attached to a tmux session on the
|
||||
remote host gives us:
|
||||
|
||||
- the real UX the agent vendor ships, including their slash commands,
|
||||
markdown, syntax, keybindings;
|
||||
- persistence across Sublime restarts (tmux keeps the session and the
|
||||
scrollback);
|
||||
- trivial switching between agents (just attach to a different tmux
|
||||
session) without any protocol layer;
|
||||
- the ability to run multiple agents in parallel, one tmux session
|
||||
each.
|
||||
|
||||
The Sublime side only needs to:
|
||||
|
||||
1. spawn / attach tmux sessions,
|
||||
2. lay out the window into three groups,
|
||||
3. persist and expose the `(workspace, agent)` pairs.
|
||||
|
||||
## Window layout
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────────────────┬─────────────────┐
|
||||
│ │ │ │
|
||||
│ file │ Terminus │ Agent │
|
||||
│ sidebar │ (ssh host │ Session │
|
||||
│ + │ tmux attach -t ...) │ Switcher │
|
||||
│ editor │ │ │
|
||||
│ (group 0) │ (group 1) │ (group 2) │
|
||||
│ │ │ │
|
||||
└──────────────┴──────────────────────────┴─────────────────┘
|
||||
```
|
||||
|
||||
- Sublime's built-in left sidebar (workspace file tree) is still there;
|
||||
our layout only affects the editor area to its right.
|
||||
- Group 0: the normal editor pane. File tabs open here.
|
||||
- Group 1: Terminus view attached to the agent's tmux session. This
|
||||
group is **single-view** — switching agents replaces the view.
|
||||
- Group 2: a read-only Sublime view rendering the switcher. Clicks on
|
||||
a pair entry dispatch `sessions_switch_agent_session`.
|
||||
|
||||
Proposed column widths: `[0.40, 0.40, 0.20]`. The switcher column
|
||||
can collapse to 0.0 via a toggle command when the user wants more
|
||||
editor room.
|
||||
|
||||
## Session naming convention
|
||||
|
||||
```
|
||||
sessions-agent-<workspace_cache_key[:8]>-<agent_id>
|
||||
```
|
||||
|
||||
Example: `sessions-agent-07c4844b-claude`. Agent ids come from the
|
||||
catalog entry (see below).
|
||||
|
||||
`tmux new-session -A -s <name> -- <agent_cmd>` is idempotent: attaches
|
||||
if the session exists, spawns with `<agent_cmd>` if it doesn't. We
|
||||
never `kill-session` implicitly — detach only. Explicit
|
||||
`Sessions: Kill Agent Session` command drives cleanup.
|
||||
|
||||
## Extension catalog entries
|
||||
|
||||
Agents are installed via the existing managed-extension flow. New
|
||||
`kind="agent"` variant:
|
||||
|
||||
```python
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="claude-cli",
|
||||
install_label="Claude Code CLI (remote)",
|
||||
install_argv=("bash", "-lc", _CLAUDE_INSTALL_SCRIPT),
|
||||
remove_argv=("bash", "-lc", _CLAUDE_REMOVE_SCRIPT),
|
||||
probe_argv=("bash", "-lc", "command -v claude && claude --version"),
|
||||
install_cwd=None,
|
||||
kind="agent",
|
||||
)
|
||||
```
|
||||
|
||||
The install scripts are the vendor's official install lines (npm /
|
||||
curl-to-bash / etc.). Probes check `command -v <bin>` + `--version`.
|
||||
We do **not** maintain our own agent binaries.
|
||||
|
||||
## Sub-tracks (parallelisable)
|
||||
|
||||
### D1. Tmux session broker — pure Python, unit-testable
|
||||
|
||||
New module `sublime/sessions/agent_tmux.py`. No Sublime imports at
|
||||
module top.
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class TmuxAgentSession:
|
||||
host_alias: str
|
||||
workspace_cache_key: str
|
||||
agent_id: str
|
||||
session_name: str # "sessions-agent-<ws>-<agent>"
|
||||
attach_argv: list[str] # ["ssh", "<host>", "tmux", "attach", "-t", name]
|
||||
spawn_argv: list[str] # ["ssh", "<host>", "tmux", "new-session", "-A", "-s", name, "--", <agent_cmd>]
|
||||
|
||||
class AgentTmuxBroker:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ssh_command_builder: Callable[[str], list[str]] = ...,
|
||||
run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
||||
): ...
|
||||
|
||||
def plan(self, host_alias, workspace_cache_key, agent_id, agent_cmd) -> TmuxAgentSession: ...
|
||||
|
||||
def is_running(self, host_alias, session_name) -> bool:
|
||||
# ssh host tmux has-session -t <name>
|
||||
...
|
||||
|
||||
def attach_or_spawn(self, session: TmuxAgentSession) -> None:
|
||||
# has-session → attach_argv, else new-session command
|
||||
# Called by the Terminus launcher (D3).
|
||||
...
|
||||
|
||||
def list_sessions(self, host_alias) -> list[str]:
|
||||
# ssh host tmux list-sessions -F '#{session_name}'
|
||||
# Filtered to "sessions-agent-*".
|
||||
...
|
||||
|
||||
def kill(self, host_alias, session_name) -> None:
|
||||
# ssh host tmux kill-session -t <name>
|
||||
...
|
||||
```
|
||||
|
||||
Injectable `run` makes everything unit-testable.
|
||||
|
||||
**[files]** `agent_tmux.py` (new), `test_agent_tmux.py` (new).
|
||||
|
||||
### D2. Three-group window layout
|
||||
|
||||
New module `sublime/sessions/agent_window_layout.py`. Provides one
|
||||
command:
|
||||
|
||||
```python
|
||||
class SessionsAgentLayoutCommand(sublime_plugin.WindowCommand):
|
||||
def run(self, cols=(0.40, 0.80, 1.00)) -> None:
|
||||
self.window.set_layout({
|
||||
"cols": [0.0, cols[0], cols[1], cols[2]],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [[0, 0, 1, 1], [1, 0, 2, 1], [2, 0, 3, 1]],
|
||||
})
|
||||
```
|
||||
|
||||
Plus `SessionsAgentLayoutCollapseSwitcherCommand` that widens to
|
||||
`[0.5, 1.0, 1.0]` (hides group 2). Toggleable via keybind in
|
||||
`Default.sublime-keymap`.
|
||||
|
||||
Persists the active layout in workspace state so reload restores it.
|
||||
|
||||
**[files]** `agent_window_layout.py` (new), `workspace_state.py`
|
||||
(extend with a `layout` field), Default keymap.
|
||||
|
||||
### D3. Terminus launcher
|
||||
|
||||
`sessions_open_agent_terminus`, driven by D1 + D2:
|
||||
|
||||
```python
|
||||
def run(self, host_alias, workspace_cache_key, agent_id, agent_cmd):
|
||||
session = broker.plan(host_alias, workspace_cache_key, agent_id, agent_cmd)
|
||||
broker.attach_or_spawn(session) # ensures tmux session exists
|
||||
# Terminus docs: terminus_open accepts {"cmd": [...], "cwd": str}.
|
||||
self.window.focus_group(1)
|
||||
self.window.run_command("terminus_open", {
|
||||
"shell_cmd": " ".join(shlex.quote(a) for a in session.attach_argv),
|
||||
"cwd": None,
|
||||
"title": f"Agent · {agent_id} · {host_alias}",
|
||||
"pre_window_hooks": [["move_to_group", {"group": 1}]],
|
||||
})
|
||||
```
|
||||
|
||||
Handles the tmux-not-installed case: probe via `ssh host command -v
|
||||
tmux`; if missing, show `Sessions: Install Remote Extension` hint with
|
||||
a one-shot install (tmux goes in the extension catalog too, as
|
||||
`kind="agent"` prerequisite).
|
||||
|
||||
**[files]** `commands.py` (add class), `Sessions.sublime-commands`
|
||||
(palette entry).
|
||||
|
||||
### D4. Switcher view (group 2)
|
||||
|
||||
Group 2 holds a named view (`settings().get("sessions_agent_switcher")
|
||||
== True`). Renders a list like:
|
||||
|
||||
```
|
||||
○ 07c4844b · claude [attached]
|
||||
● a75c7f0f · codex (active)
|
||||
○ a75c7f0f · claude
|
||||
─────────────
|
||||
+ New agent session…
|
||||
```
|
||||
|
||||
Clicks resolved via `on_text_command drag_select` → if the cursor
|
||||
line maps to a pair row, fire `sessions_switch_agent_session
|
||||
{"pair_id": "<cache_key>:<agent_id>"}`.
|
||||
|
||||
Live updates: re-render on D5's pair-change callbacks.
|
||||
|
||||
**[files]** `agent_switcher_view.py` (new), integration hook in
|
||||
`commands.py`.
|
||||
|
||||
### D5. Pair persistence + switch orchestration
|
||||
|
||||
Workspace-level store in `workspace_state.py`:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AgentPair:
|
||||
workspace_cache_key: str
|
||||
agent_id: str
|
||||
created_at: float
|
||||
last_activated_at: float
|
||||
|
||||
def register_agent_pair(pair: AgentPair) -> None: ...
|
||||
def active_agent_pair(workspace_cache_key: str) -> Optional[AgentPair]: ...
|
||||
def list_agent_pairs() -> list[AgentPair]: ...
|
||||
```
|
||||
|
||||
New command `SessionsSwitchAgentSessionCommand`:
|
||||
|
||||
1. Find the target pair.
|
||||
2. If the workspace behind `pair.workspace_cache_key` is not the
|
||||
current active workspace, call existing workspace-switch machinery
|
||||
first (project data swap). File sidebar + editor re-targets follow.
|
||||
3. Attach Terminus in group 1 to the pair's tmux session (D3).
|
||||
4. Refresh switcher view (D4).
|
||||
|
||||
**[files]** `commands.py`, `workspace_state.py`, `agent_switcher_view.py`.
|
||||
|
||||
### D6. Lifecycle + teardown
|
||||
|
||||
- `plugin_unloaded`: detach (Terminus `terminus_keypress ctrl-b d`
|
||||
equivalent) — do **not** kill. tmux keeps the agent alive.
|
||||
- `Sessions: Kill Agent Session` command (palette) — explicit kill of
|
||||
the active pair's tmux session; user confirmation prompt.
|
||||
- `Sessions: Kill All Agent Sessions` — explicit sweep on the
|
||||
currently connected host.
|
||||
|
||||
**[files]** `commands.py`, `agent_tmux.py`.
|
||||
|
||||
### D7. Edit-proposal surfacing in the editor
|
||||
|
||||
**Goal**: when the agent proposes an edit (i.e. calls its edit / write /
|
||||
patch tool), show the proposed change as a diff in the Sublime editor,
|
||||
not only inside the Terminus pane. Apply-on-click is a nice-to-have but
|
||||
not required for the first cut; **just making the proposal visible in
|
||||
the editor surface is the MVP**.
|
||||
|
||||
Three phases, ordered by both effort and fidelity:
|
||||
|
||||
#### Phase 1 — pipe-pane scrollback tail (agent-agnostic, visibility-only)
|
||||
|
||||
Mirror the Terminus/tmux pane to a local file via `tmux pipe-pane`, tail
|
||||
it with a Python watcher, and parse out unified-diff blocks. Render them
|
||||
in a dedicated output panel `Sessions: Agent Proposals` with the file
|
||||
path + hunk text. Clicking a path opens the relevant file (via the
|
||||
existing on-demand fetch listener).
|
||||
|
||||
- Works for any agent that prints a unified diff to the terminal
|
||||
(claude, codex, aider, etc.).
|
||||
- **No apply** — the agent still drives its own confirmation step in
|
||||
the terminal. Our panel is purely informational.
|
||||
- **Brittle**: ANSI colour codes, pager truncation, non-standard diff
|
||||
formats can corrupt the parse. We handle the common case and drop
|
||||
silently on weird input.
|
||||
- **[files]** `agent_proposal_watcher.py` (new) — tail + parse + emit to
|
||||
an output panel; `commands.py` — palette entries for `Sessions: Open
|
||||
Agent Proposals`, `Sessions: Clear Agent Proposals`.
|
||||
- **[testability]** `_parse_unified_diff_stream` is pure string→list;
|
||||
unit-testable with fixture blobs. Tail loop mocked with an in-memory
|
||||
file-like.
|
||||
|
||||
#### Phase 2 — post-apply phantom badge (agent-agnostic, already-applied)
|
||||
|
||||
When the agent writes a file on the remote and our existing `file/watch`
|
||||
fires a change event for an already-open local cache file:
|
||||
|
||||
- Snapshot the buffer before re-fetching.
|
||||
- Compute a line-level diff between the snapshot and the new content.
|
||||
- Decorate the modified hunks with a Sublime phantom / region of the
|
||||
form `🤖 claude · <time>` in a distinct colour scope
|
||||
(`region.bluish markup.agent.changed`).
|
||||
- Fades after 30 seconds or on next edit.
|
||||
|
||||
No user action required; purely a visual cue that "the file just
|
||||
under your cursor changed because of the agent, not you". Works for
|
||||
every agent that writes files on the remote, regardless of how the
|
||||
user approved the change.
|
||||
|
||||
- **[files]** `agent_change_badge.py` (new), hooked into the existing
|
||||
`file/watch` handling in `ssh_file_transport`/`commands`.
|
||||
- **Accepts**: that by the time we render the badge the change is
|
||||
already applied. This is the easiest-to-ship "editor sees the diff"
|
||||
path — the user sees what changed, still in the normal file flow.
|
||||
|
||||
#### Phase 3 — pre-apply preview via Claude Code hooks (claude-specific)
|
||||
|
||||
Claude Code ships first-class support for `PreToolUse` and `PostToolUse`
|
||||
hooks (configured in `.claude/settings.json` on the remote). We install
|
||||
a small shell hook that:
|
||||
|
||||
- On `PreToolUse` for `edit_file` / `write_file` / `str_replace`: write
|
||||
the tool-call JSON to a local Unix socket (forwarded via `ssh -L`
|
||||
control-master) and **wait** for an `approve` / `reject` reply from
|
||||
Sublime before letting the hook return.
|
||||
- Sublime receives the JSON, renders a rich diff preview in the
|
||||
relevant editor view (using Sublime's built-in `diff` syntax or a
|
||||
phantom overlay), and shows floating `Apply` / `Reject` buttons.
|
||||
- User's click sends `approve` / `reject` back through the socket; the
|
||||
hook returns; claude proceeds or aborts the tool call.
|
||||
|
||||
This is the most ambitious variant: editor-native preview, user clicks
|
||||
in Sublime (not in the terminal), claude respects the decision. It is
|
||||
**only claude-specific** — codex / aider / others do not expose
|
||||
equivalent hooks at the time of writing.
|
||||
|
||||
- **[files]** `agent_claude_hook.sh` (shipped hook script, `bash -lc`
|
||||
compatible), `claude_hook_server.py` (new: Unix-socket server inside
|
||||
the Sublime plugin process), `agent_proposal_preview.py` (new:
|
||||
phantom/diff rendering).
|
||||
- **[risks]** hook timeout: if Sublime isn't running or the socket isn't
|
||||
listening, claude waits indefinitely. The hook must have a 10 s
|
||||
default-deny fallback.
|
||||
- **[installer]** add the hook script to the managed-extension catalog
|
||||
under `kind="agent"` alongside the claude CLI install. Sessions drops
|
||||
`.claude/settings.json` on first use if missing.
|
||||
|
||||
**Phase adoption plan**:
|
||||
|
||||
- Phase 1 ships with v0.6.0 alongside the tmux layout (it's
|
||||
agent-agnostic and cheap).
|
||||
- Phase 2 ships in a follow-up (v0.6.1) — needs thoughtful diff
|
||||
colouring that doesn't clash with Sublime's save-status markers.
|
||||
- Phase 3 is gated on demand (v0.7.0 candidate). Users who want
|
||||
apply-from-editor get it; others stay on Phase 1/2.
|
||||
|
||||
## Parallel work plan
|
||||
|
||||
Two agents + one integrator:
|
||||
|
||||
### Agent α (pure-Python, no Sublime)
|
||||
|
||||
Owns D1 (broker) and the tmux / SSH CLI details. Output: tested
|
||||
`agent_tmux.py` + comprehensive unit tests.
|
||||
|
||||
### Agent β (Sublime-facing UI)
|
||||
|
||||
Owns D2 (layout) and D4 (switcher view skeleton with fake pair data).
|
||||
Output: clickable layout + list, no integration yet.
|
||||
|
||||
### Integrator (manual)
|
||||
|
||||
Lands D3 + D5 + D6 + extension catalog entries on top of α+β, wires
|
||||
everything, runs full pytest + manual macOS smoke test.
|
||||
|
||||
Total scope ≈ 600–900 Python LoC + 400 test LoC. No Rust changes. No
|
||||
protocol changes. Release as **v0.6.0** (minor bump — new user-visible
|
||||
feature).
|
||||
|
||||
## Out of scope (do not do here)
|
||||
|
||||
- Agent-specific parsing of output beyond unified diffs (markdown
|
||||
rendering, thinking blocks, etc.). Terminus renders the raw agent
|
||||
UI verbatim. If users want richer output, the agent CLI should
|
||||
provide it. Diff surfacing is the one exception — see D7.
|
||||
- In-Sublime chat widgets / side panels. Explicitly dropped.
|
||||
- Replacing the agent's own in-terminal confirmation flow except via
|
||||
the claude-hook path (D7 Phase 3).
|
||||
- Any change to the local_bridge / session_helper protocol.
|
||||
447
planning/BACKLOG.md
Normal file
447
planning/BACKLOG.md
Normal file
@@ -0,0 +1,447 @@
|
||||
# BACKLOG — parallel tracks
|
||||
|
||||
Work is grouped so that **each track can be picked up by an independent
|
||||
agent / worktree without stepping on another track**. Within a track,
|
||||
tasks are ordered by dependency.
|
||||
|
||||
Reading order: skim Track D first (it's the next major feature), then
|
||||
the three polish tracks (A, B, C) are all independent small wins.
|
||||
|
||||
Legend:
|
||||
|
||||
- **[file]** — primary file(s) the task touches.
|
||||
- **[conflict with]** — tracks that would conflict if parallelised.
|
||||
- **[done-when]** — acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## Track A — Active Python interpreter UX polish
|
||||
|
||||
*All four tasks touch `python_interpreter_registry.py` +
|
||||
`commands.py::SessionsSelectPythonInterpreterCommand` area only. Pick
|
||||
one agent to drive the whole track sequentially; the tasks are too
|
||||
small to parallelise internally but the track as a whole is independent
|
||||
of B / C / D.*
|
||||
|
||||
### A1. Remote folder browser for the interpreter picker
|
||||
|
||||
Currently the manual-entry option is a plain input panel. The user
|
||||
wants an "Open Folder" style browser with autocompletion as they type.
|
||||
|
||||
- **[file]** `python_interpreter_registry.py`, `commands.py`
|
||||
- **[done-when]** Picking "Enter remote path manually…" opens a
|
||||
navigable quick panel starting at `$HOME`. Each item is either a
|
||||
subdirectory (descend), `..` (ascend), or an executable candidate
|
||||
(`python`, `python3`). Typing filters the list. Selecting an
|
||||
executable writes it via `write_active_interpreter`.
|
||||
- **[conflict with]** none.
|
||||
|
||||
### A2. Status-bar indicator styling
|
||||
|
||||
The `py: <short>` indicator is hard to spot in macOS ST4.
|
||||
|
||||
- **[done-when]** Indicator reliably visible on every workspace view;
|
||||
style (prefix, width, truncation rule) tuned by at least one macOS
|
||||
test pass. Consider a fixed-width prefix like `● py:` so the eye
|
||||
catches it.
|
||||
- **[file]** `commands.py::SessionsPythonInterpreterStatusListener`
|
||||
|
||||
### A3. "missing" → "not installed" label rename
|
||||
|
||||
`Sessions: Remote Extension Status` shows entries as "missing" when
|
||||
they are not installed. Users read that as "installed but broken".
|
||||
|
||||
- **[done-when]** Status rendering uses "not installed" / "installed" /
|
||||
"installed but unusable" (the third fires when probe exits non-zero
|
||||
despite the binary being present). No change to install/remove
|
||||
semantics.
|
||||
- **[file]** `commands.py::_remote_extension_install_status_map` and
|
||||
its render helpers.
|
||||
|
||||
### A4. `.sublime-project` settings pollution
|
||||
|
||||
Sessions merges the full LSP `command` argv into
|
||||
`settings.LSP.<client>.command`, exposing bridge paths + socket names
|
||||
to users who inspect the project file.
|
||||
|
||||
- **[done-when]** Only a Sessions-owned sentinel (e.g. an `enabled`
|
||||
flag and a workspace-scope marker) leaks into the project file;
|
||||
actual command is resolved at LSP attach time from in-memory state.
|
||||
- **[file]** `lsp_project_wiring.py`
|
||||
- **[risk]** Breaking LSP attach for existing projects — needs a
|
||||
migration pass for project files written by v0.5.x. Bump settings
|
||||
schema version + migrate on load.
|
||||
|
||||
---
|
||||
|
||||
## Track B — Caching & remote-probe efficiency
|
||||
|
||||
*Focused on reducing repeated SSH exec calls the user sees as UI lag.
|
||||
Touches the install/probe plumbing.*
|
||||
|
||||
### B1. Extension probe result caching
|
||||
|
||||
`Sessions: Remote Extension Status` probes each catalog entry by
|
||||
spawning a remote command per entry, every time it opens. Noticeable
|
||||
lag (seconds) on slow links.
|
||||
|
||||
- **[done-when]** Per-workspace in-memory cache of probe results with
|
||||
a TTL (default 5 min) + explicit refresh command
|
||||
(`Sessions: Refresh Extension Probes`). Install/remove flows
|
||||
invalidate the matching entry's cache row.
|
||||
- **[file]** `commands.py::_remote_extension_install_status_map` +
|
||||
new helper module if it grows large.
|
||||
- **[conflict with]** A3 (they touch the same status render path) —
|
||||
land A3 first, then B1 on top.
|
||||
|
||||
### B2. Hydrate-on-demand for Cargo.toml in mirror cache
|
||||
|
||||
LSP-rust-analyzer / Rust Enhanced try to `cargo metadata` against
|
||||
cache-local `Cargo.toml` files that are still zero-byte placeholders.
|
||||
The console logs `failed to parse manifest` noise and rust-analyzer
|
||||
gives up on the workspace.
|
||||
|
||||
- **[done-when]** When `Cargo.toml` or `Cargo.lock` is a zero-byte
|
||||
placeholder and any LSP / command requests their content (even
|
||||
indirectly via `cargo metadata`), hydrate from remote transparently.
|
||||
Status: probably extend `SessionsOnDemandFetchListener` to also
|
||||
hydrate on a `window_command` hook that fires when LSP starts.
|
||||
- **[file]** `commands.py::SessionsOnDemandFetchListener`,
|
||||
`file_state.py`.
|
||||
|
||||
---
|
||||
|
||||
## Track C — macOS Terminus integration
|
||||
|
||||
*Two small but user-visible items, both gated on verifying how the
|
||||
current Terminus build surfaces Cmd+click / view-reuse on macOS.
|
||||
Unusable to land without a macOS test pass.*
|
||||
|
||||
### C1. VSCode-style hover-activated links in Terminus
|
||||
|
||||
**Design revision** (supersedes v0.4.18's `drag_select`-intercept
|
||||
approach): v0.4.18 tried to filter `drag_select` events by modifier
|
||||
key, which is invisible UX — the user has no idea what's clickable
|
||||
until they guess. VSCode-family editors solve this by **activating**
|
||||
the link on hover: the token becomes underlined / link-coloured when
|
||||
the cursor enters it, and Cmd+click triggers whatever is already
|
||||
"active" under the cursor. The current listener on macOS doesn't fire
|
||||
anyway, so we redesign rather than patch.
|
||||
|
||||
Two-part behaviour:
|
||||
|
||||
1. **Hover activation** — as the mouse moves across a Terminus view,
|
||||
detect the token under the cursor. If it classifies as URL /
|
||||
absolute remote path / grep-style `path:line`, add a region
|
||||
styled with a link-like scope (underline + accent colour). The
|
||||
current region is cleared on the next hover move.
|
||||
2. **Cmd+click activation** — when the modifier is held during a
|
||||
click *inside* an active link region, fire the appropriate
|
||||
handler (open URL via `webbrowser`, open remote path via the
|
||||
on-demand fetch listener, jump to `path:line` once the fetch
|
||||
listener threads encoded positions).
|
||||
|
||||
- **[done-when]** Hovering the mouse over a URL or absolute remote
|
||||
path in a Terminus pane underlines it in real time; Cmd+click on
|
||||
an underlined region resolves the target exactly like the v0.4.18
|
||||
command path (URL → OS browser, path → editor view). Hovering off
|
||||
the token clears the underline. No spurious activation when the
|
||||
modifier is released before the click lands.
|
||||
- **[file]** `terminal_link_click.py` (extend: add an `on_hover`
|
||||
listener + region tracking; keep `classify_terminal_token` and
|
||||
`extract_token_at` helpers unchanged — both still load-bearing).
|
||||
- **[api]** Sublime's `EventListener.on_hover(view, point,
|
||||
hover_zone)` fires for mouse moves over text; `view.add_regions`
|
||||
with a `link` scope + `DRAW_NO_FILL | DRAW_SOLID_UNDERLINE` flags
|
||||
produces the underline. `view.erase_regions` on cursor-leave.
|
||||
Modifier tracking still comes from the existing click event since
|
||||
`on_hover` has no modifier info.
|
||||
- **[diagnostic plan]** before redesigning, log what
|
||||
`on_text_command` actually receives in macOS Terminus — if it's a
|
||||
different command name (e.g. `drag_select_by_index`), fix the
|
||||
click path first so we have a working fallback while hover wiring
|
||||
lands.
|
||||
- **[testability]** hover classification is pure — parametrised
|
||||
tests over token strings stay as-is. Region-add side effects
|
||||
tested with a FakeView recording calls.
|
||||
|
||||
### C2. Persistent Terminus session on workspace re-open
|
||||
|
||||
After switching to another window and back, the Terminus tab spawned
|
||||
by `Sessions: Open Remote Terminal` becomes a fresh session. Users
|
||||
expect their shell history / attached process to persist.
|
||||
|
||||
- **[done-when]** Closing + reopening the workspace (or switching
|
||||
away and back) reuses the same `ssh <host>` session (via
|
||||
`tmux new-session -A -s sessions-term-<host>` or `ssh -S control
|
||||
master`). The terminal view attaches rather than spawns new.
|
||||
- **[file]** `commands.py::SessionsOpenRemoteTerminalCommand`
|
||||
- **[note]** overlaps with Track D's tmux approach — if Track D lands
|
||||
first, C2 collapses into it.
|
||||
|
||||
---
|
||||
|
||||
## Track D — Agent integration via tmux
|
||||
|
||||
*Big new feature. See `AGENT_TMUX_LAYOUT.md` for full design. This
|
||||
section only summarises the parallel sub-tracks; details there.*
|
||||
|
||||
Rather than building a custom chat UI, Sessions runs each remote
|
||||
agent (codex / claude / anthropic CLI / etc.) as a plain CLI inside a
|
||||
named tmux session on the remote host. The Sublime window is split
|
||||
into three groups: `[file editor]` `[Terminus → tmux attach]`
|
||||
`[workspace+agent switcher view]`. Switching a workspace/agent pair
|
||||
retargets all three groups atomically.
|
||||
|
||||
Sub-tracks (parallelisable):
|
||||
|
||||
- **D1. Tmux session broker.** Pure Python (no Sublime dep). Given
|
||||
`(host_alias, workspace_key, agent_cmd)`, returns a tmux session
|
||||
name + commands to start/attach/detach. Idempotent (`tmux new-session
|
||||
-A`). Unit-testable with stubbed `subprocess.run`.
|
||||
- **D2. Three-group window layout.** `window.set_layout(...)` wiring,
|
||||
view targeting per group, restore layout on Sublime restart. Lives
|
||||
in `agent_window.py` (which today holds only the dataclass).
|
||||
- **D3. Agent tmux Terminus launcher.** Given a `TmuxSession` from
|
||||
D1, opens a Terminus view in group 1 running `ssh <alias> tmux
|
||||
attach -t <name>`. Hooks into Terminus's `terminus_open` command.
|
||||
- **D4. Agent session switcher view.** Group 2 view rendering a
|
||||
clickable list of `(workspace, agent)` pairs. On click: emit a
|
||||
`sessions_switch_agent_session` command with the pair id.
|
||||
- **D5. Pair persistence + switching orchestration.** Workspace-level
|
||||
store (`workspace_state.py`) of `(workspace, agent)` pairs; switch
|
||||
command rebinds project data + reattaches the tmux view + refreshes
|
||||
the switcher highlight.
|
||||
- **D6. Lifecycle + teardown.** tmux sessions stay alive on detach;
|
||||
on workspace disconnect we **only detach**, never kill. Explicit
|
||||
`Sessions: Kill Agent Session` command for cleanup.
|
||||
- **D7. Edit-proposal surfacing in the editor.** Agent edits should
|
||||
appear as a diff in Sublime, not only inside the Terminus pane.
|
||||
**Phase 1** (MVP, agent-agnostic): tail `tmux pipe-pane` output,
|
||||
parse unified diffs, render in a `Sessions: Agent Proposals` output
|
||||
panel. **Phase 2**: after a `file/watch` change from the agent,
|
||||
badge the modified hunks with a transient "🤖 <agent>" phantom so
|
||||
the editor shows what just changed. **Phase 3** (claude-only,
|
||||
optional): install a `PreToolUse` hook on the remote that forwards
|
||||
proposed edits over an `ssh -L` Unix socket, renders in-editor
|
||||
preview with Apply/Reject buttons, and replies to the hook to
|
||||
proceed/abort the tool call. See `AGENT_TMUX_LAYOUT.md` §D7 for
|
||||
full spec.
|
||||
|
||||
**Dependency graph**:
|
||||
|
||||
- D1 is the root; D2 is independent.
|
||||
- D3 depends on D1 (session name) + D2 (target group).
|
||||
- D4 depends on nothing but the switcher data shape; can mock the
|
||||
pair list for UI testing.
|
||||
- D5 depends on D1, D2, D3, D4.
|
||||
- D6 depends on D1.
|
||||
- D7 Phase-1 depends on D3 (pipe-pane target); Phase-2 is independent
|
||||
(hooks into existing `file/watch`); Phase-3 depends on D3 plus a
|
||||
new Unix-socket control channel.
|
||||
|
||||
**Parallel plan** (3-agent fan-out):
|
||||
|
||||
- Agent α: D1 + D6 + D7 Phase-1 parser (lifecycle/broker + pure
|
||||
unified-diff parser, no Sublime dep, fully unit-testable).
|
||||
- Agent β: D2 (layout) + D4 (switcher view) + D7 Phase-2 badge
|
||||
scaffold — Sublime-side UI.
|
||||
- Agent γ: extension catalog entries for `tmux`, `claude`, `codex`
|
||||
(`kind="agent"`).
|
||||
|
||||
Then a final 1-agent pass integrates D3 + D5 on top of α+β+γ and
|
||||
wires the Phase-1 output panel + Phase-2 badge renderer into the
|
||||
live plugin.
|
||||
|
||||
---
|
||||
|
||||
## Track W — Windows parity (surfaced by the v0.6.0/v0.6.1 test pass)
|
||||
|
||||
*Several features rely on POSIX assumptions; v0.6.1 patched the
|
||||
obvious blast radius but a proper Windows port needs its own sweep.*
|
||||
|
||||
### W1. PersistentBroker for Windows (LSP stdio multiplex)
|
||||
|
||||
`local_bridge::PersistentBroker` is `#[cfg(unix)]` only — it uses a
|
||||
Unix domain socket for the broker endpoint. On Windows `broker_socket`
|
||||
ships empty and Sessions-managed LSP stdio (pyright / ruff /
|
||||
rust-analyzer over `local_bridge lsp-stdio`) cannot attach. v0.6.1
|
||||
hides the "missing broker_socket" blocker on Windows but the feature
|
||||
is still absent.
|
||||
|
||||
- **[done-when]** On Windows, `PersistentBroker::start` returns a
|
||||
working endpoint (named pipe or `AF_UNIX` on Win10 1803+). The
|
||||
handshake `broker_socket` field is non-empty and LSP stdio
|
||||
attaches for at least pyright.
|
||||
- **[file]** `rust/crates/local_bridge/src/broker.rs` (or wherever
|
||||
`PersistentBroker` lives), `rust/crates/sessions_native` FFI if the
|
||||
Python side needs a new identifier shape.
|
||||
- **[note]** Windows AF_UNIX requires `SOCK_STREAM` + a path under
|
||||
user-writable dir; Python's `socket` module on Windows supports it
|
||||
from 3.9 (we're on 3.8 for Sublime). Named pipes are the safer
|
||||
fallback.
|
||||
|
||||
### W2. Terminus hover listener on Windows
|
||||
|
||||
Sublime Text 4's `on_hover` API behaves identically across platforms
|
||||
for normal views but the Terminus plugin on Windows reports different
|
||||
hover coordinates. v0.5.8's hover-activated link regions do not paint
|
||||
on Windows per the v0.6.0 test pass.
|
||||
|
||||
- **[done-when]** Hovering a URL / abs-path in a Terminus view on
|
||||
Windows underlines it and Ctrl+click activates the handler.
|
||||
- **[file]** `terminal_link_click.py` (add a Terminus-on-Windows
|
||||
probe, log the actual hover zone + point, adapt).
|
||||
- **[diagnostic plan]** temporary logger on the `on_hover` callback
|
||||
dumping `(hover_zone, point, view.settings().get("terminus_view"))`
|
||||
so we can see what Terminus is actually reporting.
|
||||
|
||||
### W3. Persistent Terminus session survives re-open
|
||||
|
||||
`Sessions: Open Remote Terminal` wraps the remote invocation with
|
||||
`tmux new-session -A` but on Windows the child still dies between
|
||||
invocations — the Terminus pane shows "process is terminated with
|
||||
return code 2" on the second open. v0.6.1's `_subprocess_no_window_kwargs`
|
||||
fix addressed the `cmd.exe` flash but the return-code-2 means the
|
||||
underlying ssh.exe is still exiting.
|
||||
|
||||
- **[done-when]** Closing Terminus + re-opening via the palette
|
||||
re-attaches to the same tmux session; `echo $FOO` from the previous
|
||||
session still prints.
|
||||
- **[file]** `commands.py::SessionsOpenRemoteTerminalCommand`,
|
||||
`terminal_tmux_session.py`.
|
||||
- **[diagnostic plan]** capture the exact argv and stderr the Terminus
|
||||
child inherits; likely the `shell_cmd` passed to `terminus_open`
|
||||
needs ConPTY-aware quoting or an explicit `cmd /c` wrapper.
|
||||
|
||||
### W4. Folder browser auto-descend on `/`
|
||||
|
||||
v0.5.7's interpreter folder browser uses `show_quick_panel`, which
|
||||
only supports prefix filtering. Typing a trailing `/` doesn't descend.
|
||||
The user wants auto-descend ("VSCode workspace picker" feel).
|
||||
|
||||
- **[done-when]** Typing into the quick panel and ending a component
|
||||
with `/` automatically refreshes the list with that directory's
|
||||
contents.
|
||||
- **[file]** `python_interpreter_browser.py`,
|
||||
`commands.py::_show_remote_browser_quick_panel`.
|
||||
- **[note]** `show_quick_panel` has an `on_highlight` callback but no
|
||||
per-keystroke hook. Implementation likely needs
|
||||
`show_input_panel(on_change=…)` for the edit experience with a
|
||||
sibling quick panel for candidates — a structural rewrite.
|
||||
|
||||
## Track M — macOS follow-ups (surfaced by the v0.6.1 test pass)
|
||||
|
||||
*Blockers fixed in-pass (agent tmux `-d`, eager hydrate re-run at
|
||||
sync.done, expand-deferred hint, auto-refresh chatter, interpreter
|
||||
picker row order). Remaining items below need their own scope.*
|
||||
|
||||
### M1. Terminus hover: relative paths + better absolute-path detection
|
||||
|
||||
macOS test pass found:
|
||||
- `ls` output file basenames (e.g. `README.md`) are not detected as
|
||||
clickable — the regex only matches absolute paths starting with `/`.
|
||||
- `realpath` output (a real absolute path) didn't trigger either on
|
||||
one repro — worth instrumenting what the hover actually saw.
|
||||
- Hover visual is a box scope, not the spec'd underline (color-scheme
|
||||
dependent; `markup.underline.link` resolves to box in several common
|
||||
macOS themes).
|
||||
- ~1s dwell before hover paints — Sublime's `on_hover_delay_ms` setting
|
||||
default; document it rather than fight it.
|
||||
|
||||
- **[done-when]** (a) relative paths whose target exists in the local
|
||||
cache mirror underline on hover; (b) absolute paths reliably detect
|
||||
across Terminus ANSI-coloured output; (c) document the theme caveat
|
||||
in the v0.6 changelog or settings comment.
|
||||
- **[file]** `terminal_link_click.py`.
|
||||
|
||||
### M2. §4.2 status bar: python version + venv name
|
||||
|
||||
User wants the Python status bar row to show interpreter version
|
||||
(e.g. `3.11.8`) and venv name (e.g. `MIN-T`) rather than just the
|
||||
last three path components. Also: the `py:` indicator persists when
|
||||
switching to a JSON file that has a visible LSP indicator — user
|
||||
expected it to disappear for non-Python files.
|
||||
|
||||
- **[done-when]** Status bar reads `● py: MIN-T (3.11.8)` or similar;
|
||||
indicator hides for files the active interpreter doesn't manage.
|
||||
- **[file]** `python_interpreter_registry.py`, `commands.py` status
|
||||
bar emitter.
|
||||
|
||||
### M3. Remote extension install/probe latency + auto-format race
|
||||
|
||||
User observed: `Install Remote Extension` quick panel opens slowly;
|
||||
repeated installs are equally slow. Separately, `ruff format` on save
|
||||
reformats the file asynchronously; if the user edits another buffer
|
||||
meanwhile, Sublime's "file changed on disk — keep / reload?" prompt
|
||||
fires.
|
||||
|
||||
- **[done-when]** (a) install probe results cache during the Sublime
|
||||
session so the quick panel populates instantly after the first open;
|
||||
(b) save-time auto-format suppresses the "file changed" prompt when
|
||||
the change came from our own pipeline (known-hash check).
|
||||
- **[file]** `managed_remote_extension_catalog.py`, commands that drive
|
||||
`sessions_remote_python_auto_diagnostics_on_save`.
|
||||
|
||||
### M4. Multiple Terminus panes / split / plain close
|
||||
|
||||
User compared to VSCode: wants to open multiple terminals per workspace,
|
||||
split them, and plain-close without the session persisting. Today
|
||||
`Sessions: Open Remote Terminal` returns a single per-host tmux
|
||||
session; re-invoking reattaches instead of spawning a second pane.
|
||||
|
||||
- **[done-when]** Command palette offers "New Remote Terminal Pane"
|
||||
that spawns a second (numbered?) tmux session; a separate close
|
||||
action kills the tmux session rather than just detaching.
|
||||
- **[file]** `terminal_tmux_session.py`, `commands.py`.
|
||||
- **[note]** Overlaps with Track D's agent tmux broker design —
|
||||
factor the "per-workspace tmux session set" concept so terminal +
|
||||
agent share a single backing registry.
|
||||
|
||||
### M5. Jupyter / bridge request-timeout storm on slow SSM hops
|
||||
|
||||
macOS test pass against an EC2 via AWS SSM session manager hit:
|
||||
`helper launch failed: helper response timed out after 120.0s` plus
|
||||
continuous `bridge.request_timeout` on `mirror-sync` (45s),
|
||||
`file/watch` (35s), `file/read` (30s). Subsequent "Sessions
|
||||
disconnected" → reconnect loop.
|
||||
|
||||
Likely environmental (SSM tunnel is slow) but we should:
|
||||
|
||||
- **[done-when]** (a) expose the per-method timeouts as Sessions
|
||||
settings so slow-network users can bump them; (b) back off the
|
||||
auto-refresh loop after N consecutive mirror-sync timeouts instead
|
||||
of re-firing every few seconds.
|
||||
- **[file]** `ssh_runner.py` / `local_bridge` settings surface,
|
||||
`_start_mirror_auto_refresh_loop`.
|
||||
|
||||
### M6. Debugger instruction terminal context
|
||||
|
||||
User observation on §6: the "open SSH tunnel / run debugpy" instructions
|
||||
point the user to run commands in a separate terminal, but there's no
|
||||
guarantee that terminal's PATH / venv matches the Sessions-managed
|
||||
interpreter. The instructions should either spawn the tunnel from
|
||||
within Sublime (driven by the bridge) or at minimum say which shell
|
||||
to open on which host.
|
||||
|
||||
- **[done-when]** Either the setup command auto-opens the tunnel + a
|
||||
debugpy-ready Terminus pane, or the instructions call out the exact
|
||||
`ssh <alias>` they expect the user to have running.
|
||||
|
||||
---
|
||||
|
||||
## Track E — Security / ops (slower cadence)
|
||||
|
||||
*Not blocking. Advisable before any wider distribution.*
|
||||
|
||||
- **E1.** Windows code signing story. EV cert pricing / options.
|
||||
Without this, Windows Defender keeps flagging `local_bridge.exe`
|
||||
even with the current metadata.
|
||||
- **E2.** macOS Developer ID + notarisation for bundled binaries
|
||||
once Sessions is distributed to users outside the owner's machines.
|
||||
- **E3.** Reproducible-build verification against release artefacts.
|
||||
Currently CI builds from the tagged source tree but we don't
|
||||
publish a build attestation.
|
||||
- **E4.** Tighten the release-signing script to also sign individual
|
||||
binaries (detached `.bin.asc`) so a user can verify a binary
|
||||
without the full `SHA256SUMS` round trip. Optional convenience.
|
||||
@@ -1,684 +0,0 @@
|
||||
# Sessions 저장소 심층 진단 보고서
|
||||
|
||||
## 핵심 요약
|
||||
|
||||
본 저장소는 **Sublime Text 패키지(파이썬)**와 **원격 SSH stdio 기반 Rust 브리지/헬퍼 툴킷**을 결합해, “SSH 설정 기반 원격 워크스페이스”를 제공하는 것을 목표로 합니다. 저장소 레이아웃·설치 방식·Rust 바이너리 번들링/업로드 운영 흐름이 README에 비교적 명확히 정리되어 있고, “원격 헬퍼를 `/tmp/sessions/helpers/<version>/session_helper`로 업로드하고, 브리지가 버전 불일치 핸드셰이크를 거절한다” 같은 **프로덕션 지향 가드레일**도 이미 문서화되어 있습니다. citeturn50view0turn35view0turn54view0
|
||||
|
||||
트래커 관점에서, **Phase 0~5 마일스톤은 모두 100%이지만 ‘Open’ 상태로 남아 있고 due date가 비어 있으며**, 현재 열려 있는 4개 이슈(#10, #19, #20, #21)는 **모두 ‘No Milestone’로 분류**되어 있습니다. 즉 “과거 단계(Phase 0~5)는 종료 처리/날짜 관리가 미흡”하고 “현재 진행 작업은 마일스톤 체계 밖에서 움직이는” 상태입니다. 또한 **#19/#20, #21/#22는 제목과 범위가 사실상 중복**으로 보이며(리스트 상 동일 제목), 이는 진행 추적 비용을 증가시키고 진척 신뢰도를 떨어뜨립니다. citeturn58view0turn59view0turn57view0turn18view0
|
||||
|
||||
품질 측면에서, 파이썬/러스트 모두 CI에서 테스트가 수행되며, 파이썬은 `pytest` 기반(테스트 경로 `sublime/tests`, Python ≥3.8)으로 구성되어 있습니다. 다만 **라인/브랜치 커버리지 수치 산출이 CI에 포함되어 있지 않아** “충분성”을 정량으로 말하기 어렵습니다. 테스트 파일은 커맨드·전송·미러·패키징까지 폭넓게 존재하지만, 프로덕션에서 치명적이기 쉬운 **(1) SSH/브리지 프로세스 무한 대기(타임아웃 부재), (2) UI 스레드에서의 동기 원격 호출로 인한 프리징, (3) 원격 `python3 -c` 의존이 깨졌을 때의 복구 UX** 같은 엣지케이스가 현재 설계/테스트 레벨에서 상대적으로 약합니다. citeturn21view0turn25view0turn45view3turn49view0
|
||||
|
||||
개선 우선순위를 강하게 잡아야 하는 영역은 두 가지입니다. 첫째, **프로덕션 차단 패턴(타임아웃 부재, UI 스레드 동기 호출)**은 즉시 제거해야 합니다. 둘째, “원격 `python3 -c` 부트스트랩”은 문서상 임시 단계로 명시되어 있으므로, 계획(#19/#20의 ‘원격 에이전트→에디터 페이로드’ 포함)과 맞물려 **Rust 헬퍼로의 기능 흡수(디렉토리 탐색/툴 실행/에이전트 페이로드 전달)**를 우선 진행하는 것이 ROI가 큽니다. citeturn25view0turn43view0turn50view0turn57view0
|
||||
|
||||
## 조사 범위와 근거
|
||||
|
||||
본 보고서는 저장소의 README, CI 워크플로, 이슈/마일스톤, 핵심 런타임 모듈(파이썬: `commands.py`, `ssh_runner.py`, `ssh_file_transport.py`, `ssh_tool_runtime.py`, `remote_cache_mirror.py`; 러스트: `session_protocol`, `local_bridge`, `session_helper`) 및 주요 테스트 파일을 1차 근거로 삼았습니다. citeturn50view0turn58view0turn59view0turn39view0turn54view0
|
||||
|
||||
다만 다음 정보는 “명시적으로 부재/미설정”이 확인되었습니다.
|
||||
- 마일스톤/이슈 **due date 미설정**(표시상 “No due date”). citeturn58view0turn57view0
|
||||
- 열려 있는 이슈들이 **마일스톤에 할당되지 않음**(필터에 “No milestone”, 개별 이슈에도 “No Milestone”). citeturn59view0turn57view0
|
||||
- CI에서 **커버리지(coverage) 수치 산출/게이트가 없음**(테스트 실행은 있으나 커버리지 측정 도구/업로드가 워크플로에 나타나지 않음). citeturn6view0turn6view1turn21view0
|
||||
- Pull Request 화면상 **PR이 존재하지 않음**(코드 리뷰/머지 흐름 근거가 제한적). citeturn13view0
|
||||
|
||||
## 마일스톤과 이슈 정의 및 진척 진단
|
||||
|
||||
### 마일스톤 요약 표
|
||||
|
||||
아래 표는 저장소 마일스톤 화면에 표시된 값(진척률, open/closed 이슈 수, due date 유무)을 정리한 것입니다. citeturn58view0
|
||||
|
||||
| 마일스톤 | Due date | 상태 | Open issues | Progress |
|
||||
|---|---|---|---:|---:|
|
||||
| Phase 0 - Foundation | 없음 | Open(완료로 보이나 미종결) | 0 | 100% |
|
||||
| Phase 1 - Remote Workspace MVP | 없음 | Open(완료로 보이나 미종결) | 0 | 100% |
|
||||
| Phase 2 - Remote Tooling | 없음 | Open(완료로 보이나 미종결) | 0 | 100% |
|
||||
| Phase 3 - Agent Window Prototype | 없음 | Open(완료로 보이나 미종결) | 0 | 100% |
|
||||
| Phase 4 - Multi-session UI and Git | 없음 | Open(완료로 보이나 미종결) | 0 | 100% |
|
||||
| Phase 5 - Installed Package E2E | 없음 | Open(완료로 보이나 미종결) | 0 | 100% |
|
||||
|
||||
관찰되는 관리 이슈는 다음과 같습니다.
|
||||
|
||||
첫째, “Phase 0~5가 100%인데 Open”은 **마일스톤을 ‘완료 상태’로 쓰기보다 ‘문서/분류 태그’처럼 쓰고 있는** 패턴입니다. 이는 팀 규모가 커질수록 “진짜로 끝난 것과, 다음 작업이 어디에 붙는지”가 흐려집니다. 최소한 **완료된 마일스톤은 Close 처리**하고, 이후 작업은 Phase 6+ 같은 **새 마일스톤을 생성해 연결**하는 쪽이 추적 비용을 낮춥니다. citeturn58view0turn19view1
|
||||
|
||||
둘째, due date 미설정은 “프로젝트가 아직 초기”라면 허용될 수 있으나, 현재 이슈 #21이 “주기적 refresh, 터미널 attach, 우선순위 hydrate, 타이밍 레이스”처럼 다수의 UX/동시성 요구를 담고 있고, #10이 로드맵/진척 근거 역할을 겸하고 있어 **일정·범위 고정점이 없는 상태에서 범위가 계속 커질 위험**이 있습니다. citeturn57view0turn19view0
|
||||
|
||||
### 이슈 정의 품질과 진행성
|
||||
|
||||
현재 Open 이슈는 4개이며(#21, #20, #19, #10), 이들은 모두 “No milestone”입니다. citeturn59view0turn57view0
|
||||
|
||||
이슈 내용의 질 자체는 대체로 좋습니다. 특히 #21은 “Goal / Implementation checklist / Edge cases / Product decisions” 구조로 요구사항·테스트 범위·의사결정이 분리되어 있습니다. citeturn57view0
|
||||
|
||||
다만 진행성 관점에서 다음 문제가 있습니다.
|
||||
|
||||
- **중복 이슈**: #19와 #20은 이슈 리스트에서 동일한 제목(“remote agent → editor payload (SSH JSON envelope)”)을 갖고 있으며, #21 또한 #22(Closed)와 제목/범위가 사실상 동일 축(“explorer-first sync, auto-open flow, SSH terminal attach”)으로 보입니다. 중복 이슈는 “참조 분산”과 “체크리스트 중복 업데이트”를 유발합니다. 최소한 하나를 canonical로 정하고 나머지는 close+링크로 정리하는 것이 바람직합니다. citeturn59view0turn57view0turn16view0
|
||||
- **로드맵 이슈(#10)와 마일스톤 UI 상태의 불일치**: #10 본문에서는 Phase 2~5가 미완으로 남아있는 체크박스가 보이지만, 마일스톤 화면은 Phase 2~5도 100%로 표시됩니다. 동시에 #10의 후속 코멘트에서는 Phase 5 패키징 관련 동기화/완료 커밋과 “새 작업은 별도 이슈(#19/#20/#21/#22)”로 분리되었음을 말합니다. 즉, #10은 “전체 로드맵 문서” 역할을 하면서도 체크박스가 최신과 다르게 남아 있어, 외부 관찰자에게 혼란을 줄 수 있습니다. citeturn19view0turn19view1turn58view0
|
||||
- **마일스톤-이슈 연결 끊김**: Open 이슈들이 어떤 Phase(혹은 신규 Phase 6+)에 속하는지 트래커에서 즉시 읽히지 않습니다. 이는 “마일스톤 = 완료된 과거, 이슈 = 현재 작업”으로 분리되어 있어, 진행률이 트래커 상에서 누적되지 않습니다. citeturn59view0turn58view0
|
||||
|
||||
권고는 간단합니다. “Phase 0~5 마일스톤은 Close”, “현재 작업은 Phase 6(또는 ‘Next’) 마일스톤 생성 후 #19/#20/#21/#22를 재분류”, “중복 이슈 정리”입니다. citeturn58view0turn59view0turn19view1
|
||||
|
||||
## 테스트 및 품질 게이트 평가
|
||||
|
||||
### CI와 테스트 체계 현황
|
||||
|
||||
파이썬은 `pyproject.toml`에서 `pytest`를 사용하고 테스트 경로를 `sublime/tests`로 고정하며, Sublime 호스트 호환을 위해 Python ≥3.8 및 Ruff target-version을 py38로 둡니다. citeturn21view0
|
||||
저장소 Actions에는 “Python Tests / python-tests”, “Rust Tests / rust-tests”가 존재하고, 각각 워크플로 파일로 관리됩니다. citeturn5view0turn6view0turn6view1
|
||||
|
||||
중요한 공백은 **커버리지 수치가 CI에 보이지 않는 점**입니다. 따라서 “테스트가 충분한가?”를 정량으로 말하기 어렵고, 회귀 위험이 큰 영역(SSH/브리지, UI 비동기, 파일 동기화)에서 **커버리지 게이트 부재**가 곧 리스크입니다. citeturn6view0turn6view1
|
||||
|
||||
### 테스트 파일 요약 표
|
||||
|
||||
아래 표는 `sublime/tests` 디렉터리 기준입니다. 커버리지 지표는 CI에 명시가 없어 “N/A”로 표기했습니다. 목적은 (a) 파일명, (b) 테스트 파일이 import하는 대상(일부 파일은 실제 코드 확인) 기준으로 요약했습니다. citeturn61view0turn62view0turn46view0turn47view0turn48view0
|
||||
|
||||
| 테스트 파일 경로 | 목적 | Coverage metric | 누락/약한 엣지케이스(추가 권장) |
|
||||
|---|---|---|---|
|
||||
| sublime/tests/conftest.py | 공통 픽스처/테스트 환경 구성 | N/A | (공통) 타임아웃/스레드 경합 재현용 헬퍼 제공 |
|
||||
| sublime/tests/test_agent_remote_payload.py | 원격 에이전트 JSON 페이로드 파서 검증 | N/A | 스키마 버전 업그레이드/호환(버전 범위) |
|
||||
| sublime/tests/test_agent_window_models.py | 에이전트 윈도우 모델/상태 전이 검증 | N/A | 다중 세션 동시 갱신, 이벤트 순서 뒤집힘 |
|
||||
| sublime/tests/test_build_sublime_package.py | `.sublime-package` 빌드 스크립트/메뉴 JSON 유효성 | N/A | bundle 충돌/권한/대용량 zip 성능, Windows 경로 차이 |
|
||||
| sublime/tests/test_command_palette.py | 팔레트 명령 노출/구성 검증 | N/A | 명령/메뉴 간 불일치(릴리즈 빌드에서 누락) |
|
||||
| sublime/tests/test_commands.py | `commands.py` UI/워크플로 동작(가짜 Window/View) 검증 | N/A | **UI 스레드에서 동기 SSH 호출로 프리징**을 탐지하는 테스트(“원격 호출은 background이어야 함”) citeturn62view0turn43view0 |
|
||||
| sublime/tests/test_compatibility.py | Python 3.8 호환성/마커 회귀 검증 | N/A | Sublime 실제 런타임 차이(내장 모듈/typing) |
|
||||
| sublime/tests/test_connect_workflow.py | Connect → Open Remote Folder 핵심 플로우 | N/A | 인증 만료/재인증, `ssh` 부재, 재시도 UX |
|
||||
| sublime/tests/test_diagnostics_models.py | 진단 모델/표현 변환 | N/A | 비UTF-8 출력, 대량 진단(성능/메모리) |
|
||||
| sublime/tests/test_diagnostics_path_mapping.py | 원격↔로컬 경로 매핑/오류 | N/A | symlink/대소문자/정규화 차이(OS별) |
|
||||
| sublime/tests/test_file_cache_mapping.py | 캐시 경로 매핑(워크스페이스 키 기반) | N/A | 캐시 루트 이동/부분 손상 복구 |
|
||||
| sublime/tests/test_file_cache_policy.py | 열기 정책(최대 바이트, 바이너리 휴리스틱) | N/A | 큰 파일 경계(정확히 limit), “빈 파일” 정책 |
|
||||
| sublime/tests/test_file_pipeline.py | 오픈/세이브 파이프라인 정상/예외 흐름 | N/A | 원격 메타데이터 경합(동시 수정), 재시도 |
|
||||
| sublime/tests/test_local_paths.py | 로컬 경로 레이아웃/플랫폼 태그 | N/A | 권한 불가/공유 스토리지 실패 후 fallback |
|
||||
| sublime/tests/test_metadata_layout.py | 메타데이터 레이아웃/경로 구조 | N/A | JSON 손상/부분 파일 누락 복구 |
|
||||
| sublime/tests/test_metadata_versioning.py | 메타데이터 버저닝/마이그레이션 | N/A | 다운그레이드/미지원 버전 처리 |
|
||||
| sublime/tests/test_plugin_entrypoint.py | `plugin.py` 엔트리포인트 import/노출 검증 | N/A | 릴리즈 패키지에서 import-time 실패(의존 파일 누락) |
|
||||
| sublime/tests/test_project_entry.py | 프로젝트 데이터/설정 키 처리 | N/A | 프로젝트 파일이 부분 손상(“folders” shape 오류) citeturn50view0 |
|
||||
| sublime/tests/test_python_runtime_marker.py | Sublime Python 호스트 버전 마커 검증 | N/A | 플랫폼별 차이(Windows) |
|
||||
| sublime/tests/test_python_toolchain.py | 원격 Python 툴체인 모델/요청 구성 | N/A | 원격 python 부재/다른 인터프리터(`python`만 존재) |
|
||||
| sublime/tests/test_quick_panel_items.py | Quick panel 항목 모델/표시 문자열 | N/A | 매우 긴 경로/유니코드/이모지 |
|
||||
| sublime/tests/test_recent_state.py | 최근 상태/플랫폼 저장소 | N/A | 동시 기록(멀티 윈도우) JSON 경쟁 |
|
||||
| sublime/tests/test_recent_workspace_store.py | 최근 워크스페이스 저장/로드 | N/A | 파일 잠금/부분 쓰기 후 복구 |
|
||||
| sublime/tests/test_recent_workspaces.py | 최근 워크스페이스 UI/정렬 | N/A | 중복 항목 제거/정렬 안정성 |
|
||||
| sublime/tests/test_remote_cache_mirror.py | 원격 트리 미러(BFS, ignore patterns) | N/A | **권한/IOError** 시 누락 경고, **잘못된 globstar 패턴** 캐시/성능 citeturn48view0turn24view0 |
|
||||
| sublime/tests/test_remote_directory_listing.py | 디렉토리 엔트리 정렬/필터링 | N/A | 대용량 디렉토리, symlink loop 표시 정책 |
|
||||
| sublime/tests/test_remote_file_metadata.py | 원격 파일 메타데이터 모델 | N/A | mtime 정밀도/플랫폼별 변환 |
|
||||
| sublime/tests/test_remote_file_transport.py | 원격 파일 전송 모델/요청 | N/A | 브리지 실패 stderr 보존, 핸드셰이크 노이즈 |
|
||||
| sublime/tests/test_remote_fs_operations.py | 원격 FS 동작(읽기/쓰기/스탯) | N/A | 원격 파일이 디렉토리로 바뀜, 저장 중 삭제 |
|
||||
| sublime/tests/test_remote_git_issue9.py | 원격 git 관련 회귀(#9) | N/A | GitSavvy 외 플러그인 상호작용 다양화 |
|
||||
| sublime/tests/test_remote_root_selection.py | Remote root 선택 UX/정규화 | N/A | UI 비동기(로딩 표시), 폴더 탐색 중 연결 끊김 |
|
||||
| sublime/tests/test_remote_tool_execution.py | 원격 도구 실행 결과/진단 파싱 | N/A | **원격 python3 부재**, stdout/stderr 초대형, 타임아웃(SSH 레벨) citeturn49view0 |
|
||||
| sublime/tests/test_remote_tool_wiring.py | ruff/format 등 도구 요청 구성 | N/A | 도구 버전별 출력 포맷 변화 |
|
||||
| sublime/tests/test_runtime_import_smoke.py | 런타임 import smoke(패키지 로딩) | N/A | Sublime 실제 import 순서/지연 로딩 |
|
||||
| sublime/tests/test_sessions_settings_regressions.py | 설정 회귀(메뉴/프로젝트 플래그) | N/A | 설정 파일 손상/값 타입 오류 |
|
||||
| sublime/tests/test_settings_model.py | 설정 모델 타입/기본값 | N/A | 잘못된 타입 입력 시 강건성 |
|
||||
| sublime/tests/test_sidebar_project_folders.py | 사이드바 폴더 merge/remove | N/A | `set_project_data` 타이밍 레이스/실패 복구 citeturn40view2turn43view0 |
|
||||
| sublime/tests/test_ssh_config.py | SSH config 파싱/호스트 항목 | N/A | include/Match 블록/복잡한 ssh_config |
|
||||
| sublime/tests/test_ssh_file_transport.py | SSH 파일 전송(브리지/부트스트랩 JSON) | N/A | subprocess 타임아웃, remote helper 업로드 권한, 원격 MOTD 노이즈 citeturn47view0turn45view3turn35view0 |
|
||||
| sublime/tests/test_ssh_runner.py | SSH 실행 경계(askpass, 에러 포맷) | N/A | **프로세스 무한 대기(타임아웃)**, prompt bridge 파일 레이스 citeturn46view0turn25view0 |
|
||||
| sublime/tests/test_ssh_tool_runtime.py | SSH 기반 tool runtime wrapper | N/A | ssh 자체 타임아웃/끊김, python3 부재, 환경변수 크기 |
|
||||
| sublime/tests/test_workspace_bootstrap.py | 워크스페이스 부트스트랩 계획/프로젝트 생성 | N/A | 부분 생성 후 실패 시 롤백 |
|
||||
| sublime/tests/test_workspace_identity.py | 워크스페이스 ID 안정성 | N/A | ID 충돌(동일 root/다른 host), 해시 알고리즘 변경 |
|
||||
| sublime/tests/test_workspace_materializer.py | 워크스페이스 실체화(파일/폴더 생성) | N/A | 권한 오류/디스크 풀/경로 길이(OS별) |
|
||||
|
||||
### 특히 부족한 엣지케이스 묶음
|
||||
|
||||
기존 테스트는 “모델 변환·정상/오류 페이로드”에 강점이 있으나, 실제 프로덕션에서 장애로 직결되는 아래 케이스는 상대적으로 약해 보입니다(혹은 코드 레벨에서 아직 방어가 부족합니다).
|
||||
|
||||
- **SSH/브리지 프로세스 무한 대기**: 파이썬 `ssh_runner.run_ssh_remote_command()`는 `subprocess.run()` 및 `Popen` 루프에 명시적 타임아웃이 보이지 않습니다. 러스트 `local_bridge` 또한 `Command::new("ssh")`로 child를 띄우지만 타임아웃/kill 정책이 없습니다. 이 경우 네트워크/인증/원격 쉘 상태에 따라 Sublime 전체가 장시간 멈춘 것처럼 느껴질 수 있습니다. citeturn25view0turn26view1turn35view0
|
||||
- **UI 스레드 동기 원격 호출(프리징)**: `commands.py`에는 background thread를 쓰는 흐름(미러 sync, placeholder hydrate)도 있지만, `_browse_remote_directory`, `_open_remote_file_for_workspace`, `_refresh_local_cache_after_format`처럼 원격 호출을 동기 수행하는 부분도 확인됩니다. 이는 “작동은 하지만 UX가 깨지는” 전형적인 프로덕션 차단 패턴입니다. citeturn40view2turn43view0turn39view0
|
||||
- **원격 `python3 -c` 의존 붕괴 시 복구**: 파일 전송/디렉토리 브라우징/툴 실행이 `python3 -c ...`에 의존하는 경로가 다수 존재합니다. 이는 README에서도 “부트스트랩” 성격이 언급된 영역이며, 실제로는 python3가 없는 서버/컨테이너에서 깨질 가능성이 있습니다. citeturn25view0turn45view3turn49view0turn50view0
|
||||
|
||||
## Python→Rust 이전 후보와 잔존 Python 아티팩트
|
||||
|
||||
### Rust로 이전(또는 Rust로 흡수) 우선 후보
|
||||
|
||||
README가 “장기적으로 helper-backed transport로 전환”을 분명히 하고 있고, 현재도 `session_protocol`/`local_bridge`/`session_helper`가 `tree/list`, `file/read`, `file/stat`, `file/write`를 지원하는 첫 런타임을 갖추고 있습니다. 따라서 Python이 담당 중인 “원격 실행/전송 코어”를 Rust로 흡수하는 그림이 자연스럽습니다. citeturn50view0turn54view0turn30view0
|
||||
|
||||
| Python 아티팩트(파일) | 기능 | Rust로 이전 필요성 | Python으로 남길 때 리스크 |
|
||||
|---|---|---|---|
|
||||
| sublime/sessions/ssh_runner.py | 로컬 `ssh` 호출·askpass/prompt bridge·에러 포맷 | **높음**: 결국 “브리지/헬퍼 실행 경계”는 Rust가 잡는 편이 일관됨 | 타임아웃/kill 부재로 무한 대기, 플랫폼별 askpass 스크립트 유지보수 부담 citeturn25view0turn26view1 |
|
||||
| sublime/sessions/ssh_file_transport.py | 디렉토리 listing/파일 read·stat·write. Rust 브리지 호출 + `python3 -c` 부트스트랩 | **매우 높음**: 원격 `python3` 의존 제거가 제품 안정성 핵심 | 원격 python 부재 시 기능 붕괴, 큰 payload(JSON/base64) 처리 비용, subprocess 무한 대기 citeturn45view0turn45view3turn50view0 |
|
||||
| sublime/sessions/ssh_tool_runtime.py | 원격 formatter/linter 실행을 `python3 -c`로 래핑 | **높음**: `session_protocol`에 Exec/Format/Lint capability가 이미 정의됨 | 원격 python 부재, SSH 레벨 타임아웃 부재, 보안적으로 “원격에서 python이 명령 실행” citeturn49view0turn30view0 |
|
||||
| sublime/sessions/remote_cache_mirror.py | 원격 트리(BFS) 미러링/ignore pattern 처리 | **중간~높음**: 대규모 워크스페이스 성능 병목 가능 | 파이썬에서 패턴 컴파일·FS 생성 비용 증가, 오류 삼킴으로 silent desync 가능 citeturn24view0turn40view2 |
|
||||
| sublime/sessions/agent_remote_payload.py | 원격 에이전트의 에디터 프리뷰용 JSON 페이로드 검증 | **중간**: 에이전트가 Rust로 간다면 스키마/검증도 공유하기 쉬움 | 스키마 진화 시 Python/Rust 이중 구현 위험 citeturn37view0turn59view0 |
|
||||
|
||||
### 정상적으로 Python에 남아야 하는 영역
|
||||
|
||||
Sublime 패키지는 호스트가 Python이므로, **UI/커맨드 바인딩/프로젝트 데이터 조작/Quick Panel** 등은 Python에 남는 것이 자연스럽습니다. 예컨대 `plugin.py`는 Sublime 엔트리포인트로, 명령 클래스들을 import/export 합니다. citeturn38view0turn50view0
|
||||
|
||||
다만 “남는다고 해서 현재 구조 그대로가 최선”은 아닙니다. 특히 `commands.py`는 3,000 라인 규모로(UI 스텁/헬퍼 포함) 비대하며, 분할·모듈화가 필요합니다. citeturn39view0turn62view0
|
||||
|
||||
## 프로덕션 차단 및 비효율 패턴, 리팩토링 제안
|
||||
|
||||
### 즉시 차단해야 하는 패턴
|
||||
|
||||
#### SSH/브리지 호출의 타임아웃 부재
|
||||
|
||||
파이썬 `ssh_runner`는 `subprocess.run(..., timeout=...)` 같은 제한이 보이지 않고, prompt-bridge 경로는 `while process.poll() is None:` 루프로 계속 대기합니다. 이 구조는 “사용자가 입력을 하지 않음 / 네트워크 hang / 원격에서 응답 없음” 상황에서 무한 대기로 이어질 수 있습니다. citeturn25view0turn26view1
|
||||
|
||||
러스트 `local_bridge` 또한 `ssh` child를 실행해 handshake/response를 읽지만, 타임아웃/kill 정책이 없고, handshake는 **첫 줄만 읽어 JSON으로 바로 파싱**합니다. 원격 환경에서 stdout에 MOTD/로그인 배너가 섞이면 시작부터 실패할 가능성이 있습니다. citeturn35view0turn36view0turn31view2
|
||||
|
||||
**권장 수정(중요도: 매우 높음)**
|
||||
- (단기) Python `ssh_runner`에 **기본 타임아웃과 kill 정책**을 도입하고, “연결/탐색/파일 전송/툴 실행” 각각의 합리적 기본값을 설정합니다.
|
||||
- (중기) Rust `local_bridge`에서 ssh 프로세스 타임아웃·stdout 노이즈 처리(또는 `ssh` 옵션으로 배너 최소화)를 넣어 “현장 서버 다양성”에 견딜 수 있게 합니다.
|
||||
|
||||
아래는 Python 쪽에 “타임아웃(초) + ssh 옵션(ConnectTimeout/ServerAlive)”을 도입하는 예시 diff입니다(개념 제시). 타임아웃 기본값은 환경에 따라 조정해야 합니다.
|
||||
|
||||
```diff
|
||||
diff --git a/sublime/sessions/ssh_runner.py b/sublime/sessions/ssh_runner.py
|
||||
index abcdef0..1234567 100644
|
||||
--- a/sublime/sessions/ssh_runner.py
|
||||
+++ b/sublime/sessions/ssh_runner.py
|
||||
@@
|
||||
def run_ssh_remote_command(
|
||||
host_alias: str,
|
||||
remote_argv: Sequence[str],
|
||||
*,
|
||||
stdin_text: str = "",
|
||||
disable_connection_reuse: bool = False,
|
||||
+ timeout_s: float = 30.0,
|
||||
) -> SshRunResult:
|
||||
@@
|
||||
- completed = subprocess.run(
|
||||
+ completed = subprocess.run(
|
||||
list(local_argv),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
input=stdin_text,
|
||||
env=env,
|
||||
+ timeout=timeout_s,
|
||||
)
|
||||
@@
|
||||
def _local_ssh_argv(...):
|
||||
- argv = ["ssh", "-o", "BatchMode=no"]
|
||||
+ argv = [
|
||||
+ "ssh",
|
||||
+ "-o", "BatchMode=no",
|
||||
+ "-o", "ConnectTimeout=10",
|
||||
+ "-o", "ServerAliveInterval=15",
|
||||
+ "-o", "ServerAliveCountMax=2",
|
||||
+ ]
|
||||
```
|
||||
|
||||
위 변경은 “연결이 영원히 멈춰있는 상태”를 시스템적으로 차단합니다. 다만 interactive 인증(비밀번호/OTP)에서 너무 짧은 값은 역효과가 날 수 있으므로, **connect flow는 더 긴 timeout_s를 명시**하거나 “prompt가 발생한 경우 타임아웃 연장” 같은 정책이 필요합니다. citeturn25view0turn26view1
|
||||
|
||||
#### UI 스레드에서 동기 원격 호출
|
||||
|
||||
`commands.py`는 일부 경로에서 background thread를 사용하지만(`_run_in_background`), `_browse_remote_directory`, `_open_remote_file_for_workspace`, `_refresh_local_cache_after_format`는 원격 호출을 동기로 수행하는 코드가 확인됩니다. 이는 네트워크 상황이 나쁠 때 Sublime UI 프리징으로 직결됩니다. citeturn40view0turn43view0turn39view0
|
||||
|
||||
**권장 수정(중요도: 매우 높음)**
|
||||
- “원격 I/O는 항상 background”를 강제하는 규칙을 세우고, `commands.py`의 원격 호출 경로를 전수 점검해 `_run_in_background + _set_timeout(완료 콜백)` 패턴으로 통일합니다.
|
||||
|
||||
아래는 `_open_remote_file_for_workspace`를 sidebar placeholder hydrate와 동일한 형태로 비동기화하는 예시 diff입니다(구조 통일 목적).
|
||||
|
||||
```diff
|
||||
diff --git a/sublime/sessions/commands.py b/sublime/sessions/commands.py
|
||||
index abcdef0..1234567 100644
|
||||
--- a/sublime/sessions/commands.py
|
||||
+++ b/sublime/sessions/commands.py
|
||||
@@
|
||||
def _open_remote_file_for_workspace(...):
|
||||
@@
|
||||
- opened = open_remote_file_into_local_cache(
|
||||
- context.recent_entry.host_alias,
|
||||
- remote_absolute_path=normalized_remote_file,
|
||||
- local_cache_path=local_cache_path,
|
||||
- )
|
||||
- if opened.outcome is OpenOutcome.OK:
|
||||
- ...
|
||||
- ...
|
||||
+ host_alias = context.recent_entry.host_alias
|
||||
+
|
||||
+ def work() -> None:
|
||||
+ opened = open_remote_file_into_local_cache(
|
||||
+ host_alias,
|
||||
+ remote_absolute_path=normalized_remote_file,
|
||||
+ local_cache_path=local_cache_path,
|
||||
+ )
|
||||
+
|
||||
+ def finish() -> None:
|
||||
+ if opened.outcome is OpenOutcome.OK:
|
||||
+ if opened.remote_metadata is not None:
|
||||
+ _write_remote_metadata_sidecar(opened.local_cache_path, opened.remote_metadata)
|
||||
+ _open_local_cache_file(window, opened.local_cache_path, editor_group=editor_group)
|
||||
+ _emit_status(ConnectStatus(kind="ready", detail=f"Opened remote file {normalized_remote_file}"))
|
||||
+ return
|
||||
+ if opened.outcome is OpenOutcome.TRANSPORT_ERROR:
|
||||
+ _emit_status(ConnectStatus(kind="disconnected", detail=opened.detail or "Remote file open failed over SSH."))
|
||||
+ return
|
||||
+ ...
|
||||
+
|
||||
+ _set_timeout(finish, 0)
|
||||
+
|
||||
+ _run_in_background(work)
|
||||
```
|
||||
|
||||
이 패턴을 `_browse_remote_directory`(원격 디렉토리 목록 가져오기)와 `_refresh_local_cache_after_format`에도 확장하면, UX 품질이 크게 올라갑니다. citeturn43view0turn40view0
|
||||
|
||||
### Python 부트스트랩 의존 제거를 위한 Rust 이전 설계
|
||||
|
||||
현재 `ssh_file_transport.py`는 Rust 브리지를 우선 사용하되, 실패 시 원격에서 `python3 -c` 스크립트를 실행하는 fallback을 사용합니다. README는 “장기적으로 end-user는 Cargo 없이 번들된 브리지/헬퍼를 사용”한다고 명시합니다. 따라서 “원격 `python3` 의존을 제거하고 Rust 헬퍼 프로토콜로 통합”하는 것이 일관된 로드맵입니다. citeturn45view3turn50view0turn54view0
|
||||
|
||||
이를 위해 `session_protocol`이 이미 정의한 capabilities(ExecCommand/FormatFile/LintFile 등)를 `session_helper`에 단계적으로 구현하고, Python에서는 “요청 구성 + UI 표현”만 남기는 형태가 바람직합니다. citeturn30view0turn54view0turn49view0
|
||||
|
||||
### 리팩토링 관점의 삭제/병합/분할 제안
|
||||
|
||||
사용자 요청(“삭제/병합/분할 등 리팩토링 요소”)을 반영해, 구조적 개선 포인트를 정리합니다.
|
||||
|
||||
#### 분할이 필요한 요소
|
||||
|
||||
- **`sublime/sessions/commands.py` (비대 모듈)**
|
||||
커맨드 클래스, 워크플로 로직, UI 유틸, 상태 저장(connected host), 미러 refresh 루프, 가드레일까지 한 파일에 혼재합니다. 파일 자체가 3,061 라인/100KiB 수준이며 테스트도 별도로 대형(`test_commands.py`)입니다. citeturn39view0turn62view0
|
||||
권장 분할(예시):
|
||||
- `ui_runtime.py`: `_set_timeout`, `_run_in_background`, 패널/quick panel 헬퍼
|
||||
- `connect_flow.py`: connect + host/platform detection + window open
|
||||
- `workspace_flow.py`: open folder, workspace materialize/open
|
||||
- `mirror_sync.py`: mirror 옵션, in-flight dedupe, auto-refresh loop
|
||||
- `remote_file_flow.py`: open/save/hydrate (원격 I/O는 모두 비동기화)
|
||||
- `remote_tool_flow.py`: formatter/linter 실행 + output/diagnostics 적용
|
||||
목표는 “각 파일이 단일 책임을 갖고 테스트도 더 작게 쪼개지는 구조”입니다.
|
||||
|
||||
#### 병합 또는 정리(삭제 포함)가 필요한 요소
|
||||
|
||||
- **중복/분산된 ‘원격 실행’ 경계**
|
||||
현재 원격 작업 경계가 `ssh_runner`(ssh 실행), `ssh_file_transport`(파일 전송), `ssh_tool_runtime`(tool 실행), Rust `local_bridge`(업로드+요청/응답)로 나뉘어 있고, Python fallback이 곳곳에 산재합니다. citeturn25view0turn45view0turn49view0turn35view0
|
||||
권장: Python 측에는 `Transport` 인터페이스(예: `list_dir/read/write/stat/exec_tool`)를 하나 두고, 구현체를 `RustBridgeTransport` / `PythonBootstrapTransport`로 분리하여 호출부가 단일화되게 합니다. 그러면 “삭제/대체”가 쉬워집니다.
|
||||
|
||||
- **이슈 트래커의 중복 이슈 정리(프로세스 리팩토링)**
|
||||
#19/#20, #21/#22 중복은 “설계 변경 시 문서 업데이트 누락”을 유발합니다. 코드보다 먼저 **트래커를 병합/정리**하는 것이 개발 속도를 올립니다. citeturn59view0turn57view0turn16view0
|
||||
|
||||
#### 성능 리팩토링 후보
|
||||
|
||||
- **`remote_cache_mirror.path_matches_mirror_ignore`에서 매 호출마다 globstar 패턴을 컴파일**
|
||||
ignore 패턴이 많고 엔트리가 많을수록 비용이 커질 수 있습니다. mirror run 단위로 패턴을 전처리(“정규식 컴파일 캐시”)해 엔트리당 비용을 줄일 수 있습니다. citeturn24view0turn48view0
|
||||
|
||||
### 모듈 관계 다이어그램
|
||||
|
||||
현재(및 목표) 구조를 그림으로 요약하면 아래와 같습니다.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Sublime UI: plugin.py / commands.py] --> B[Python transport facade]
|
||||
B --> C1[PythonBootstrapTransport]
|
||||
B --> C2[RustBridgeTransport]
|
||||
|
||||
C1 --> D1[ssh_runner.py]
|
||||
D1 --> E1["ssh <host> python3 -c ..."]
|
||||
E1 --> F1[Remote host: python3 runtime]
|
||||
|
||||
C2 --> D2["local_bridge (Rust)"]
|
||||
D2 --> E2["ssh <host> session_helper --stdio"]
|
||||
E2 --> F2["session_helper (Rust)"]
|
||||
F2 --> G2[Remote FS operations]
|
||||
```
|
||||
|
||||
README가 말하는 “end-user는 Cargo 없이 번들된 브리지/헬퍼 사용, python bootstrap은 fallback” 목표에 맞추려면, C1 경로의 책임을 점진적으로 줄이고 C2 경로를 확장하는 전략이 일관됩니다. citeturn50view0turn45view3turn54view0
|
||||
|
||||
## 우선순위 실행 계획
|
||||
|
||||
아래 액션 리스트는 “프로덕션 차단 제거 → Rust 이전 → 구조 리팩토링/테스트 강화” 순으로 제안합니다. Effort는 대략 S(≤1일), M(2~5일), L(1~2주+)로 표기합니다.
|
||||
|
||||
| 우선순위 | 작업 | 기대 효과 | Effort |
|
||||
|---|---|---|---|
|
||||
| P0 | SSH/브리지 호출에 **타임아웃/kill 정책** 도입(Python `ssh_runner`, Rust `local_bridge` 모두) | 무한 대기/프리징 차단(가장 치명적 장애 제거) | M |
|
||||
| P0 | `commands.py`에서 **원격 I/O 동기 호출 전수 제거**(open folder/list dir/open file/save/refresh) | UI 프리징 제거, 체감 품질 급상승 | M |
|
||||
| P0 | 중복 이슈(#19/#20, #21/#22) 정리 + “Next/Phase 6” 마일스톤 신설, Phase 0~5 Close | 추적 신뢰도·우선순위 가시성 개선 | S |
|
||||
| P1 | `ssh_file_transport._execute_rust_bridge_request`에 subprocess timeout + request id 고유화 | 브리지 hang 방지, 디버깅 용이 | S |
|
||||
| P1 | 원격 `python3 -c` 의존 축소: `session_helper`에 “tool/format, tool/lint, exec” 구현 착수 | 원격 python 부재 환경 지원, 보안/성능 개선 | L |
|
||||
| P1 | `remote_cache_mirror` 패턴 컴파일 캐시/에러 보고 강화(권한 오류를 경고로 노출) | 대규모 트리 성능 개선 + silent failure 감소 | M |
|
||||
| P2 | `commands.py` 분할(워크플로/미러/툴/파일/유틸) + transport 인터페이스 도입 | 유지보수성·테스트 용이성 개선 | L |
|
||||
| P2 | CI에 coverage 측정(예: Python `coverage.py`, Rust `cargo llvm-cov`) 추가 및 최소 기준 설정 | 테스트 충분성 정량화, 회귀 방지 | M |
|
||||
| P2 | Rust bridge/헬퍼의 “stdout 노이즈(MOTD)” 내성 강화(핸드셰이크 전 스킵/명확한 에러) | 현장 서버 다양성 대응 | M |
|
||||
|
||||
마이그레이션 타임라인(개념) 예시는 아래와 같습니다.
|
||||
|
||||
```mermaid
|
||||
timeline
|
||||
title Sessions 안정화 및 Python->Rust 이전 로드맵(개념)
|
||||
2026-04 : P0 타임아웃/비동기화로 UX 프리징 제거
|
||||
2026-04 : 트래커 정리(중복 이슈 병합, Phase 0~5 close, Phase 6 생성)
|
||||
2026-05 : Rust helper에 tool/exec 기능 추가, python3 -c 의존 축소
|
||||
2026-05 : commands.py 분할 + transport facade 도입
|
||||
2026-06 : CI coverage 게이트 도입 및 회귀 방지 강화
|
||||
```
|
||||
|
||||
이 계획은 현재 이슈 #21이 명시한 “auto-sync, periodic refresh, 타이밍 레이스, 터미널 attach” 같은 요구를 실제 사용자 경험으로 안전하게 전달하기 위한 최소 조건(프리징/무한대기 제거)을 먼저 충족시키는 데 초점을 둡니다. citeturn57view0turn40view2turn25view0turn50view0
|
||||
|
||||
|
||||
가능해서 바로 시도해봤습니다.
|
||||
git clone --depth 1 https://git.teahaven.kr/sublime-rs/sessions ...를 셸에서 실행했는데, 이 환경에서는 git.teahaven.kr DNS 해석이 실패해서 clone이 안 됐습니다. 이전에는 같은 호스트에 대한 비브라우저 요청이 403으로 막힌 적도 있었고, Cloudflare는 실제로 AI 봇 차단, JS 기반 봇 식별, AI Labyrinth 같은 우회/기만 방어를 제공하고 있어서, “브라우저는 열리는데 셸/스크레이퍼는 막히는” 현상이 충분히 가능합니다. 즉 지금 내 환경의 실패는 public repo라 clone 가능해야 한다와 모순이라기보다, 브라우저 경로와 비브라우저 경로가 다르게 취급되고 있다고 보는 게 맞습니다.
|
||||
그래서 이번에는 clone 대신 Gitea 웹 UI로 현재 상태를 다시 읽고, 그 기준으로 동일 분석을 다시 했습니다. 아래 평가는 방금 이 세션에서 직접 연 최신 repo/issue/planning 화면 기준입니다.
|
||||
먼저 현재 상태 요약
|
||||
지금 프로젝트는 예전보다 확실히 정리됐습니다.
|
||||
가장 큰 변화는 세 가지입니다.
|
||||
첫째, 트래커 위생이 좋아졌습니다.
|
||||
예전에는 완료된 Phase들이 열려 있고 현재 작업이 milestone 밖에 떠 있는 느낌이 강했는데, 지금은 Phase 0~5가 닫히고, Phase 6.2 / 7 / 8 / 9로 열린 마일스톤이 재구성돼 있습니다. 이건 꽤 큰 개선입니다.
|
||||
둘째, 신뢰성 문제를 제품 차원에서 인식하고 문서화하기 시작했습니다.
|
||||
planning 문서에서 Python/Rust 경계를 분명히 적었고, 특히 “요청 단위 오류는 helper 종료 사유가 아니다”라는 세션 생존 불변식을 명시한 점이 좋습니다.
|
||||
셋째, 배포 경로를 실제로 만들기 시작했습니다.
|
||||
최신 커밋은 session_helper를 Gitea generic registry에 publish하고, Sublime 런타임이 거기서 helper를 내려받을 수 있게 연결하는 내용입니다. 다만 중요한 건, 지금 그 publish 액션이 실패 중이라는 점입니다. 즉 방향은 맞지만, 아직 가장 어려운 부분이 안정화되진 않았습니다.
|
||||
현재 프로젝트를 어떻게 봐야 하나
|
||||
지금 이 프로젝트는 “당장 배포 직전 품질이냐”보다,
|
||||
**“장기 배포형 제품으로 수렴하는 구조를 제대로 만들고 있느냐”**로 보는 게 맞습니다.
|
||||
그 기준에서 보면, 이 프로젝트는 현재 이렇게 평가됩니다.
|
||||
제품 방향성: 좋음
|
||||
아키텍처 방향성: 좋아짐
|
||||
핵심 기술 리스크 인식 수준: 많이 개선됨
|
||||
실제 배포 경로의 완성도: 아직 약함
|
||||
장기적으로 커질 때 버틸 구조인가: 반쯤 맞고, 반쯤 아직 위험
|
||||
즉 한 문장으로 말하면:
|
||||
“좋은 프로토타입”에서 “제대로 된 제품 구조”로 넘어가는 입구까지는 왔고, 지금부터는 UI보다 transport·artifact·state model을 굳히는 쪽이 훨씬 중요합니다.
|
||||
카테고리별로 다시 분석
|
||||
1. 배포/유통 파이프라인
|
||||
현재 좋아진 점
|
||||
README와 최신 커밋을 보면, 이제 배포 이야기가 추상적인 수준이 아닙니다.
|
||||
.sublime-package 빌드 스크립트가 있음
|
||||
prebuilt Rust binary를 번들하는 경로가 있음
|
||||
Gitea registry에 helper artifact를 publish하려는 CI가 생김
|
||||
런타임이 같은 registry에서 helper를 다운로드할 수 있게 연결 중임
|
||||
이건 장기 배포 목표 관점에서 아주 중요한 진전입니다.
|
||||
현재 가장 큰 문제
|
||||
하지만 배포 파이프라인에서 제일 어려운 단계가 실제로 빨갛습니다.
|
||||
Python tests: 성공
|
||||
Rust tests: 성공
|
||||
helper publish: 실패
|
||||
이건 의미가 큽니다.
|
||||
지금 상태는 “개발은 된다”에 가깝고, “배포 가능한 artifact 공급망”은 아직 미완성입니다.
|
||||
코드/구조 차원에서 점검할 것
|
||||
이 카테고리에서 제일 먼저 확인해야 하는 건 4개입니다.
|
||||
artifact manifest가 단일 진실원천인지
|
||||
어떤 플랫폼에 어떤 binary가 들어가야 하는지
|
||||
package 번들 / registry 업로드 / runtime lookup이 같은 표를 바라보는지
|
||||
다운로드 무결성 검증
|
||||
지금 보이는 문서만으로는 checksum/signature 검증이 확실히 안 보입니다
|
||||
장기 배포형 제품이면 필수입니다
|
||||
실패 UX
|
||||
helper download 실패
|
||||
remote tag mismatch
|
||||
registry artifact 없음
|
||||
cargo fallback 불가
|
||||
각각이 사용성 좋은 에러로 나와야 합니다
|
||||
지원 매트릭스
|
||||
local platform
|
||||
remote linux target
|
||||
bundled binary 존재 여부
|
||||
이 셋의 조합을 명시적으로 관리해야 합니다
|
||||
판단
|
||||
지금 이 프로젝트에서 가장 우선순위 높은 배포 기술부채는 publish pipeline입니다.
|
||||
여기가 초록색이 되기 전까지는 runtime download 기능을 제품 중심축으로 삼으면 안 됩니다.
|
||||
2. 원격 실행 경계 / transport 설계
|
||||
이건 여전히 프로젝트의 심장입니다.
|
||||
현재 상태
|
||||
planning 문서를 보면 방향은 아주 좋아졌습니다.
|
||||
Python은 얇게
|
||||
Rust는 heavy logic
|
||||
하나의 주 세션 위에 logical channel들을 얹는다
|
||||
새 도구/LSP 추가할 때 top-level method를 계속 늘리지 않는다
|
||||
request-level error는 세션 종료 사유가 아니다
|
||||
timeout/kill/channel supervision은 Rust 책임
|
||||
이건 제품화 방향으로 매우 올바릅니다.
|
||||
왜 이게 중요한가
|
||||
이 프로젝트는 결국 전부 여기에 올라갑니다.
|
||||
tree/list
|
||||
file/read
|
||||
file/write
|
||||
tool exec
|
||||
linter/formatter
|
||||
future LSP
|
||||
future PTY/terminal
|
||||
future agent diff apply
|
||||
따라서 transport가 흔들리면 나머지 기능은 다 같이 흔들립니다.
|
||||
현재 남아 있는 약점
|
||||
문서상 방향과 달리, 구현은 아직 과도기입니다.
|
||||
MVP는 여전히 python3 -c 기반 subprocess tool runner를 씁니다
|
||||
장수명 LSP는 아직 미루고 있습니다
|
||||
channel multiplex는 계획 문서에 있지만 완성된 중심 구현으로 보이지는 않습니다
|
||||
large-file delivery는 아직 one-shot read 한계를 벗어나지 못했고, 그게 #32로 따로 열려 있습니다
|
||||
즉 지금 구조는 **“최종 모델을 알고 있는 MVP”**입니다.
|
||||
그 자체는 괜찮습니다. 다만 이 상태가 오래가면 안 됩니다.
|
||||
추천
|
||||
transport는 지금부터 아래 순서로 고정하는 게 좋습니다.
|
||||
v1: persistent helper session 안정화
|
||||
v1.1: control/file/exec 3채널 정도의 얇은 multiplex
|
||||
v1.2: cancel / deadline / retryable error / partial read 계약
|
||||
v2: lsp:*, pty:* 같은 장수명 채널 추가
|
||||
핵심은 기능 추가보다 envelope 불변식부터 굳히는 것입니다.
|
||||
3. 대용량 파일 / hydrate / 응답성
|
||||
이 부분은 현재 repo가 자기 문제를 정확히 보고 있다는 점이 좋습니다.
|
||||
현재 상태
|
||||
#32가 아주 정확한 문제 정의를 갖고 있습니다.
|
||||
지금 hydrate는 본질적으로 full-file read
|
||||
high latency나 large file에서 timeout budget을 반복 소모
|
||||
perceived responsiveness를 해친다
|
||||
stale stream cancel과 progressive finalization이 필요하다
|
||||
이건 문제 진단이 매우 좋습니다.
|
||||
왜 중요한가
|
||||
이건 단순 성능 문제가 아닙니다.
|
||||
실사용자는 이걸 **“플러그인이 멈춘다”**로 체감합니다.
|
||||
agent window든 multi-session이든 다 좋지만,
|
||||
큰 파일 하나에서 hydrate stall이 반복되면 제품 신뢰가 바로 무너집니다.
|
||||
추천
|
||||
이 이슈는 단순 최적화가 아니라 프로토콜 기능으로 처리해야 합니다.
|
||||
필요한 건:
|
||||
chunked file/read
|
||||
active-tab 우선순위
|
||||
stale read cancel
|
||||
partial visibility 규칙
|
||||
finalization 전까지 diagnostics/apply를 보수적으로 처리
|
||||
특히 “부분 본문을 보여주되 언제 최종 상태로 승격되는지”가 중요합니다.
|
||||
이게 없으면 editor, cache, diagnostics가 서로 엇갈립니다.
|
||||
판단
|
||||
#32는 나중 이슈가 아니라, 사실상 Phase 7~8 경계 핵심 이슈입니다.
|
||||
장기 제품 기준에서는 꽤 앞당겨도 됩니다.
|
||||
4. 동기화 / mirror / multi-window correctness
|
||||
이 카테고리는 현재 이슈 구성이 아주 좋습니다.
|
||||
#27: auto-sync / periodic refresh races / multi-window policy
|
||||
#28: mirror prune safety + cache symlink/permission edges
|
||||
이 두 이슈가 열려 있다는 건, 프로젝트가 이미 **“기능 추가보다 상태 일관성 문제”**를 보기 시작했다는 뜻이라 좋습니다.
|
||||
현재 판단
|
||||
여기서 필요한 건 기능이 아니라 정책입니다.
|
||||
명확히 정해야 할 것:
|
||||
어느 순간에 remote가 authoritative인지
|
||||
어느 순간에 local cache가 authoritative인지
|
||||
multi-window에서 같은 remote file을 누가 소유하는지
|
||||
periodic refresh가 사용자의 로컬 편집을 덮을 수 있는지
|
||||
prune가 partial mirror 상태에서 동작해도 되는지
|
||||
이 카테고리에서 코드로 해야 할 것
|
||||
cache metadata에 hydrate provenance / refresh epoch / truncation marker 넣기
|
||||
symlink/permission error를 조용히 삼키지 않기
|
||||
multi-window ownership rule을 명문화하기
|
||||
“background sync”와 “explicit open/save”의 정책을 분리하기
|
||||
판단
|
||||
이건 장기적으로 매우 중요합니다.
|
||||
Sessions가 단순 remote file opener가 아니라 remote workspace system이 되려면, 바로 이 규칙들이 제품의 신뢰도를 결정합니다.
|
||||
5. Python ↔ Rust 경계
|
||||
이 부분은 현재 문서가 꽤 좋습니다.
|
||||
현재 좋은 점
|
||||
PYTHON_RUST_BOUNDARY.md는 방향성이 명확합니다.
|
||||
Python: command registration, sublime API, UI, settings, thin glue
|
||||
Rust: protocol, workspace identity, remote cache algorithms, SSH helpers, correctness-sensitive logic
|
||||
그리고 migration inventory까지 적혀 있어서, 단순 구호가 아니라 실제 작업표로 쓰고 있습니다.
|
||||
현재 남아 있는 문제
|
||||
문서가 좋은 것과 runtime이 실제로 그렇게 돌아가는 것은 별개입니다.
|
||||
아직은:
|
||||
Python glue가 상당 부분 살아 있고
|
||||
일부 알고리즘은 Rust 구현이 있어도 runtime authoritative는 Python일 가능성이 있고
|
||||
MVP 편의상 Python 경로가 여러 군데 남아 있습니다
|
||||
이건 당연한 과도기지만, 장기 배포 기준에서는 “새 non-trivial logic는 Rust 우선” 원칙을 실제 PR 수준에서 강제해야 합니다.
|
||||
제가 보는 가장 중요한 이동 대상
|
||||
Rust로 빨리 옮기거나 Rust가 authoritative가 되어야 할 건 이쪽입니다.
|
||||
remote tree mirror
|
||||
file read/write transport
|
||||
channel supervision
|
||||
timeout/kill/retry policy
|
||||
agent payload validation / diff apply contract
|
||||
future file conflict rules
|
||||
반대로 Python은 끝까지 남아도 괜찮습니다.
|
||||
command palette
|
||||
panel/output sheet
|
||||
editor region/phantom/annotation
|
||||
settings deserialization
|
||||
user-facing strings
|
||||
판단
|
||||
현재 방향은 맞습니다.
|
||||
다만 문서가 구조를 앞서가고 있고, 구현이 아직 따라오는 중입니다.
|
||||
이건 나쁜 상태는 아니지만, 지금부터는 실제 코드 리뷰 기준도 이 문서에 맞춰야 합니다.
|
||||
6. Agent / diff-centric workflow
|
||||
이건 제품 차별화의 핵심입니다.
|
||||
현재 상태
|
||||
open issue 중에 “diff-centric change review workflow” (#29) 가 있다는 건 매우 중요합니다.
|
||||
그리고 테스트 쪽에서도 agent_remote_payload가 꽤 강화됐습니다.
|
||||
방금 확인한 테스트 기준으로는:
|
||||
schema version 검증
|
||||
kind 검증
|
||||
non-dict / bad schema rejection
|
||||
whitespace-only title/diff rejection
|
||||
stdout JSON decode error 메시지 검증
|
||||
즉 preview contract 자체는 꽤 단단하게 만들고 있는 중입니다.
|
||||
하지만 아직 preview와 product는 다릅니다
|
||||
지금 단계는 “agent가 diff를 제안하면 보여준다”에 가깝고,
|
||||
장기 제품이 되려면 “안전하게 적용한다”까지 가야 합니다.
|
||||
필수 요소는 이겁니다.
|
||||
base content hash
|
||||
target path confinement
|
||||
per-hunk apply / reject
|
||||
stale edit conflict
|
||||
local unsaved buffer와의 충돌 처리
|
||||
binary / huge patch 거절
|
||||
remote path와 local cache path의 안정적 매핑
|
||||
추천
|
||||
이 이슈는 Phase 9에만 두기엔 조금 아깝습니다.
|
||||
왜냐하면 이 프로젝트의 제품 정체성이 바로 여기서 나오기 때문입니다.
|
||||
제 생각엔:
|
||||
Phase 7/8에서 transport·conflict·path safety를 먼저 준비
|
||||
그 위에 Phase 9에서 diff review UX를 완성
|
||||
이 맞습니다.
|
||||
즉 UI 자체는 나중이어도, diff apply contract는 더 먼저 다뤄야 합니다.
|
||||
7. 테스트 / 품질 게이트
|
||||
현재 좋아진 점
|
||||
테스트 폭은 꽤 넓습니다.
|
||||
최근 파일들만 봐도:
|
||||
agent payload
|
||||
commands trace
|
||||
plugin entrypoint
|
||||
diagnostics
|
||||
packaging/menu
|
||||
compatibility
|
||||
python runtime marker
|
||||
등이 이미 들어와 있습니다.
|
||||
즉 “테스트가 없는 프로젝트”는 아닙니다.
|
||||
하지만 현재 부족한 것
|
||||
장기 배포형 제품 기준에서 중요한 건 정상 흐름 unit test 개수가 아니라,
|
||||
실패 모드에 대한 gate입니다.
|
||||
지금 추가로 필요해 보이는 건:
|
||||
artifact publish smoke test
|
||||
runtime helper download integration test
|
||||
helper checksum/manifest validation test
|
||||
latency-injected hydrate test
|
||||
stale cancel / tab switch test
|
||||
multi-window race test
|
||||
symlink / permission / prune regression test
|
||||
reconnect / session recovery test
|
||||
가장 중요한 관찰
|
||||
지금 repo는 Python tests, Rust tests는 녹색인데 publish workflow는 적색입니다.
|
||||
즉 현재 품질 게이트는 “코드 correctness” 쪽은 잡지만
|
||||
“제품 deliverability”는 아직 gate에 잘 걸지 못합니다.
|
||||
판단
|
||||
Phase 9 이름이 “Quality Gates & Scale”인 건 아주 적절합니다.
|
||||
다만 실제 우선순위는 조금 더 앞당겨도 됩니다.
|
||||
8. 보안 / 운영 / 신뢰 경계
|
||||
이건 장기 배포형 제품에서 반드시 분리해서 봐야 합니다.
|
||||
현재 눈에 띄는 지점
|
||||
README 기준으로는 uploaded helper 경로가 버전별 /tmp/sessions/helpers/<version>/session_helper입니다.
|
||||
이 자체가 무조건 나쁘다는 건 아니지만,
|
||||
장기 배포 기준에서는 아래를 점검해야 합니다.
|
||||
디렉터리 권한
|
||||
symlink race
|
||||
helper overwrite 방지
|
||||
cleanup 정책
|
||||
integrity verification
|
||||
mixed-version downgrade/rollback handling
|
||||
문서에는 version mismatch fast-fail은 보입니다. 이건 좋습니다.
|
||||
하지만 artifact authenticity까지 충분히 보이는지는 아직 불명확합니다.
|
||||
또 하나의 운영 리스크
|
||||
현재 README/plan 기준으로 보면, release package에 bundled helper가 없으면 Python SSH bootstrap fallback가 남아 있습니다.
|
||||
장기적으로는 이게 있어도 되지만, 제품 메시지 측면에선 애매합니다.
|
||||
안정성 관점: fallback는 좋아 보임
|
||||
유지보수 관점: 구현 경계가 오래 이중화됨
|
||||
보안 관점: inline execution surface가 남음
|
||||
판단
|
||||
이 프로젝트는 공개 제품으로 갈수록:
|
||||
/tmp 운영 모델 하드닝
|
||||
registry artifact 검증
|
||||
bootstrap fallback 축소
|
||||
remote exec surface 축소
|
||||
이 4개를 반드시 밀어야 합니다.
|
||||
9. 현재 planning의 현실성
|
||||
이번엔 이전보다 훨씬 현실적입니다.
|
||||
좋아진 점
|
||||
지금은 예전보다 훨씬 “순서”가 보입니다.
|
||||
Phase 6.2: remote dev MVP
|
||||
Phase 7: stability hardening
|
||||
Phase 8: Rust transport expansion
|
||||
Phase 9: quality gates & scale
|
||||
이건 정확히 맞는 순서입니다.
|
||||
아직 아쉬운 점
|
||||
다만 open issue 배치를 보면 약간 섞인 부분도 있습니다.
|
||||
#32 large-file streaming은 no milestone
|
||||
#29 diff-centric workflow는 Phase 9
|
||||
#10 roadmap issue가 아직 큰 umbrella 역할
|
||||
즉 구조는 좋아졌지만,
|
||||
제품 차별화 이슈와 기반 안정화 이슈를 어디에 둘지는 조금 더 다듬을 여지가 있습니다.
|
||||
추천
|
||||
roadmap은 앞으로 이렇게 보는 게 더 좋습니다.
|
||||
Track A: Remote Workspace Core
|
||||
Track B: Stability / Correctness
|
||||
Track C: Rust Transport / Channel Model
|
||||
Track D: Diff-centric Agent UX
|
||||
Track E: Packaging / Distribution / Quality Gates
|
||||
milestone 이름은 유지하더라도, 내부 문서에서는 이런 트랙 분해가 더 유용합니다.
|
||||
지금 시점의 종합 판단
|
||||
현재 Sessions는 예전보다 분명히 좋아졌습니다.
|
||||
특히 좋아진 점은:
|
||||
milestone 구조 정리
|
||||
reliability 불변식 명시
|
||||
Python/Rust 경계 문서화
|
||||
VS Code-style channel model 명시
|
||||
agent payload preview contract 강화
|
||||
artifact distribution 방향 구체화
|
||||
반대로 아직 가장 불안한 축은:
|
||||
publish/download artifact 공급망
|
||||
대용량 파일 hydrate 구조
|
||||
sync/refresh/multi-window correctness
|
||||
diff apply의 진짜 안전 계약
|
||||
runtime에서 Python 과도기 경로를 얼마나 빨리 줄일지
|
||||
제가 지금 이 프로젝트를 한 문장으로 요약하면:
|
||||
**“방향성은 맞고, 이제는 기능보다 전달경로와 상태모델을 굳혀야 하는 시점”**입니다.
|
||||
우선순위 제안
|
||||
P0
|
||||
helper publish workflow 초록색 만들기
|
||||
artifact manifest + checksum 검증 넣기
|
||||
#27/#28 우선 정리해서 sync correctness 고정
|
||||
request/session lifecycle invariant를 실제 runtime 전역 규칙으로 강제
|
||||
P1
|
||||
#32 large-file streaming 설계/구현
|
||||
control/file/exec 채널 multiplex v0 도입
|
||||
CLI tool runner를 envelope 모델로 흡수
|
||||
P2
|
||||
diff-centric review contract 완성
|
||||
base hash / stale conflict / per-hunk apply
|
||||
editor phantom/annotation UX 고도화
|
||||
P3
|
||||
multi-session agent window
|
||||
richer terminal/session surfaces
|
||||
long-lived LSP/PTY channel
|
||||
|
||||
---
|
||||
|
||||
## 우선순위 재조정 (로컬 planning 반영, 2026-04-17)
|
||||
|
||||
신규 점검 본문(위 **우선순위 제안** P0~P3)과 저장소 **실제 이슈 번호**를 맞추어, [`GITEA_ISSUES.md`](GITEA_ISSUES.md) **«실행 우선순위 (재조정)»** 를 갱신했다. 요지는 다음과 같다.
|
||||
|
||||
1. **Rust 구현 공백(단발 subprocess·yield 불가 구간)** 을 1차 리스크로 명시하고, **#24** 를 “기능 나열”이 아니라 **런타임 권한 이관의 척추 이슈**로 앞당긴다.
|
||||
2. **배포/아티팩트( publish + manifest/checksum )** 를 P0에 고정해, “코드는 녹색인데 공급망은 적색” 상태를 제품 축에서 분리한다.
|
||||
3. **#27 / #28** 은 멀티플렉스(#31)와 **병행 가능**하나, **상태·정책 문서**를 먼저 고정해 mirror/hydrate/save 레이스 비용을 줄인다.
|
||||
4. **#32** 는 대용량·hydrate 신뢰의 핵심이므로 **No milestone 방치보다 Phase 7~8 경계**에 두는 것을 권장한다(트래커에서 이슈 메타만 조정하면 됨).
|
||||
5. **#29** diff-centric 제품은 **전송·대용량·sync 계약** 이후(P2)로 유지한다.
|
||||
|
||||
Rust 이관의 **웨이브 표**는 [`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md) § *Rust-first migration waves* 에 normative 로 적어 두었다.
|
||||
@@ -1,972 +0,0 @@
|
||||
# Gitea Issue Bootstrap for `Sessions`
|
||||
현재 저장된 Gitea 자격증명으로 저장소/이슈 API 접근이 가능하며, `issue` scope가 포함된 토큰으로 milestone/issue 동기화를 진행할 수 있다.
|
||||
- 저장소: `sublime-rs/sessions`
|
||||
- 인스턴스: [https://git.teahaven.kr/sublime-rs/sessions](https://git.teahaven.kr/sublime-rs/sessions)
|
||||
- 제품 비전 참고: [Cursor 3 - Agents Window](https://cursor.com/blog/cursor-3)
|
||||
|
||||
## Gitea API / 자격 갱신 (에이전트·자동화)
|
||||
|
||||
이슈 생성·상태 변경 등 **REST 쓰기 전에 반드시(MUST)** 저장소 작업 트리에서 **`git pull`** 을 먼저 실행한다. 사용자가 원격과 맞춰 둔 Git HTTPS 자격(또는 PAT 갱신)과 같은 시점의 환경을 에이전트가 쓰게 되며, “pull 후에야 API가 된다”는 관찰과 맞춘다.
|
||||
|
||||
- **권장**: Gitea 사용자 설정에서 **Personal Access Token**을 발급하고(`issue` 등 필요 scope), `Authorization: token <PAT>` 헤더로 `https://git.teahaven.kr/api/v1/...` 를 호출한다.
|
||||
- 인스턴스 설정에 따라 Git이 쓰는 **HTTPS Basic 인증**(`curl -u user:password` — 비밀번호 자리에 PAT 사용 가능)으로 동일 API가 허용되기도 한다. **토큰·비밀번호는 저장소에 커밋하지 않는다.**
|
||||
|
||||
### Milestone에 올려 둔 후속 이슈 (추가)
|
||||
|
||||
- ~~**[#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30)** — **Remote-SSH 수준 개발 MVP** (MVP slice 완료·closed) — 마일스톤 **Phase 6.2** (closed)~~
|
||||
- ~~[#27](https://git.teahaven.kr/sublime-rs/sessions/issues/27) — auto-sync / periodic refresh 경쟁·멀티 윈도우 정책 (Phase 7)~~ **closed**
|
||||
- ~~[#28](https://git.teahaven.kr/sublime-rs/sessions/issues/28) — truncated mirror prune 안전·캐시 symlink/권한 (Phase 7)~~ **closed**
|
||||
- [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) — diff-centric 변경 검토 워크플로 (Phase 9)
|
||||
- ~~[#31](https://git.teahaven.kr/sublime-rs/sessions/issues/31) — Phase 6.3 remote session multiplex + code-server registry (transport)~~ **closed**
|
||||
- [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32) — **Large-file hydrate/streaming 최적화** (대용량·고지연 파일에서 placeholder hydrate 병목 해소)
|
||||
- [#34](https://git.teahaven.kr/sublime-rs/sessions/issues/34) — **Remote LSP 통합 (local_bridge-native)** parent issue
|
||||
- ~~[#33](https://git.teahaven.kr/sublime-rs/sessions/issues/33) — **Persistent helper session 전환** (`local_bridge` one-shot 모델 → 장수명 세션)~~ **closed**
|
||||
- ~~[#25](https://git.teahaven.kr/sublime-rs/sessions/issues/25) — helper session hard-timeout/child kill policy~~ **closed** (Phase 8)
|
||||
- ~~[#19](https://git.teahaven.kr/sublime-rs/sessions/issues/19), [#20](https://git.teahaven.kr/sublime-rs/sessions/issues/20) — remote agent → editor payload (SSH JSON envelope)~~ **closed** (Phase 8)
|
||||
|
||||
---
|
||||
|
||||
## 최근 저장소 작업 요약 (2026-04-22)
|
||||
|
||||
태그 **v0.3.3**, **v0.3.4** 및 그 사이 `main` 커밋에 반영된 내용을 한데 묶는다. Gitea parent는 **[#34](https://git.teahaven.kr/sublime-rs/sessions/issues/34)** (Remote LSP); 세부 이슈는 **#36** (stdio+broker attach), **#35** (initialize/URI/save barrier 등), **#37** (프로젝트·설정 주입)와 대응한다.
|
||||
|
||||
- **Sublime 측 managed LSP:** `lsp_project_wiring.py`가 `.sublime-project`의 `settings.LSP`에 `local_bridge lsp-stdio` 기반 **pyright / rust-analyzer / ruff** 행을 merge(사용자가 `sessions_remote_stdio_managed: false`면 해당 클라이언트는 덮어쓰지 않음). 브리지 핸드셰이크 직후·워크스페이스 활성 시 프로젝트 파일 갱신 + `set_project_data`, 팔레트 **`sessions_diagnose_lsp_workspace`**, LSP definition 계열 post-command 트레이스.
|
||||
- **`local_bridge lsp-stdio`:** attach JSON에 원격 **spawn `argv`/`cwd`** 전달; CLI `--spawn-arg` / `--spawn-cwd`. 첫 JSON-RPC에 `_sessions_lsp_spawn`을 주입해 `session_helper`가 원격 child를 기동.
|
||||
- **URI rewrite (#35 일부):** 로컬 캐시 루트와 원격 워크스페이스 루트의 **`file://` 접두 쌍**을 프로젝트 커맨드에 실어 보내고, persistent broker의 **`broker_lsp_relay_loop`**에서 JSON 전체 문자열을 **에디터→헬퍼(로컬→원격)** / **헬퍼→에디터(원격→로컬)** 로 치환(원격 Pyright가 타 파일·import를 일관되게 보도록).
|
||||
- **관측:** `SESSIONS_BRIDGE_DIAG_LOG`에 `bridge.rust.lsp_stdio_start` / `…_attach_ok` / `…_broker_session` / `…_broker_out`·`…_broker_in` 등 NDJSON 이벤트.
|
||||
- **품질·CI:** `diag_log` 테스트가 병렬 `cargo test`에서 깨지던 문제(전역 `SESSIONS_BRIDGE_DIAG_LOG` + 첫 줄만 검증)를 **기대 `event` 줄 탐색**으로 수정. `main.rs` mutex는 poison 시 `unwrap_or_else(|e| e.into_inner())` 복구, 릴레이 인자는 **`BrokerLspRelayCfg`** struct로 묶어 `clippy::too_many_arguments` 등 allow 제거.
|
||||
|
||||
**아직 남은 #35 스코프 예:** save barrier, on-demand materialization, 초기화 경계 등(참고: [`REMOTE_DEV_MVP_LSP.md`](REMOTE_DEV_MVP_LSP.md)는 MVP C 트랙; stdio relay는 P1.5로 확장 중).
|
||||
|
||||
---
|
||||
|
||||
## 실행 우선순위 (재조정, 2026-04)
|
||||
|
||||
**전제 (신규 점검 [`DEEP-RESEARCH-REPORT.md`](DEEP-RESEARCH-REPORT.md) 반영):** 런타임에서 **Rust가 비어 있거나 단발 subprocess인 구간**은 Python 큐·우선순위만으로는 SSH/브리지 경합을 이기기 어렵다. 따라서 “기능 추가”보다 **전달(artifact)·전송(Rust 권한)·상태(sync) 모델**을 먼저 굳인 뒤, **임시 Python 로직을 Rust 권한으로 이관**하는 순서를 채택한다.
|
||||
|
||||
내부 트랙(마일스톤 이름과 1:1은 아님):
|
||||
|
||||
| 트랙 | 내용 |
|
||||
|------|------|
|
||||
| **A** | Remote workspace core (연결·캐시·미러·파일 I/O) |
|
||||
| **B** | Stability / correctness (#27, #28, 세션 불변식) |
|
||||
| **C** | Rust transport / channel model (#31, #24, `VSCODE_REMOTE_TRANSPORT_MODEL.md`) |
|
||||
| **D** | Diff-centric agent UX (#29) |
|
||||
| **E** | Packaging / distribution / quality gates (helper publish, manifest, checksum) |
|
||||
|
||||
### P0 — crate 통합 + 제품 deliverability + Rust 이관 “척추”
|
||||
|
||||
0. **Crate 통합 (선행):** `agent_remote_payload`와 `remote_cache_mirror`를 **`local_bridge` 내부 모듈로 병합**한다. `workspace_identity`는 cdylib(`sessions_native`) 의존 체인을 얇게 유지하기 위해 **독립 유지**. 결과 워크스페이스: `session_protocol`, `workspace_identity`, `sessions_native`, `session_helper`, `local_bridge` (5 crates). 병합 후 Python `remote_cache_mirror.py` 중복 삭제, Rust mirror 전용 경로 전환.
|
||||
1. **Track E — artifact 공급망:** Gitea generic registry **helper publish 워크플로를 녹색**으로 만들고, **manifest + checksum(또는 서명) 검증**을 런타임 다운로드 경로에 연결한다. (코드 테스트는 녹색이어도 **deliverability 게이트가 적색이면** 이 트랙을 제품 축으로 삼지 않는다는 점검 반영.)
|
||||
2. **Track C / [#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24) — Rust 이관 1차:** 이미 크레이트에 있는 알고리즘·프로토콜을 **런타임 권한으로 승격**한다. 우선순위 예시 (의존 순):
|
||||
- **원격 트리 미러:** `remote_cache_mirror` 알고리즘은 `local_bridge` 내부 모듈로 통합 완료 후, **운송은 Wave 2(#31)와 같이 간다** — 호스트당 **persistent `local_bridge`↔`session_helper` 한 stdio 세션**에 미러를 올리고, **멀티플렉스·deadline·취소** 없이 합치지 않는다([`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md) § *Wave 2 — 미러를 persistent…*). 당분간 **단발 `mirror-cache` 프로세스**는 임시로 남긴 뒤, 봉투 기반 미러 run이 되면 **제거**한다.
|
||||
- **file/read·file/write·tree/list 경로:** Python `ssh_file_transport` 얇은 래어 유지, **정책·타임아웃·재시도·부분 읽기**는 `local_bridge` / `session_helper`가 단일 진실이 되도록 이관·중복 제거.
|
||||
- ~~**Python mirror 중복 삭제:** `remote_cache_mirror.py` 삭제, `commands.py`에서 Rust mirror 전용으로 전환, settings 토글(`sessions_mirror_rust_*`) 제거.~~ **완료.** `remote_cache_mirror.py` 삭제; 타입은 `ssh_file_transport.py`에 유지; 전용 설정 토글 없음.
|
||||
- ~~**Cache-based remote directory open:** 연결 → Rust mirror → sidebar 등록 → 파일 열기 전체 경로에서 Python 전용 transport 없이 동작 확인.~~ **완료.** `_connect_selected_workspace` → `execute_remote_cache_mirror`(bridge subprocess) → sidebar merge 전 경로가 Rust-only. `execute_remote_list_directory`(tree view)도 bridge-only. Python transport fallback 없음 (매개변수 매핑 parity 테스트 추가).
|
||||
3. ~~**Track B — [#27](https://git.teahaven.kr/sublime-rs/sessions/issues/27), [#28](https://git.teahaven.kr/sublime-rs/sessions/issues/28):** auto-sync·주기 refresh·멀티 윈도우·prune 안전을 **정책 문서 + 캐시 메타(epoch / truncation / provenance)** 로 고정한다.~~ **완료.** Rust prune에서 dangling symlink 감지 수정 + 엣지 케이스 테스트 5종; Python-side 멀티 윈도우 cache-key dedup, 주기 refresh vs manual 충돌 방지, truncation 상태 메시지, symlink/directory 정리, hydrate-vs-refresh 경합, ignored-path open 테스트 10종 추가.
|
||||
4. **세션 불변식:** [`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md)의 *request 단위 오류 ≠ 세션 종료*를 **전 경로 회귀 테스트**로 강제한다.
|
||||
|
||||
### P1 — 대용량·멀티플렉스·툴 봉투
|
||||
|
||||
1. **Track A/C — [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32):** large-file hydrate / **chunked file/read**, 활성 탭 우선, stale cancel. **#31 / Phase 6.3** (`control` / `file` / `exec_once` …)와 **설계 분리**하되, 구현 순서상 **멀티플렉스 v0 이후**에 프로토콜 확장을 얹는 것이 자연스럽다. (점검안: Gitea에서 #32를 **No milestone → Phase 7~8 경계**로 올려 가시성 확보 권장.)
|
||||
2. **[#31](https://git.teahaven.kr/sublime-rs/sessions/issues/31)** 원격 세션 멀티플렉스 + code-server registry: 상위 NDJSON method 폭증 없이 **봉투+채널**로 수렴([`VSCODE_REMOTE_TRANSPORT_MODEL.md`](VSCODE_REMOTE_TRANSPORT_MODEL.md)).
|
||||
3. **원격 `python3 -c` 툴 러너:** MVP subprocess 경로를 **envelope 기반 exec 채널**로 흡수할 계획을 #24/#31과 같은 웨이브에 묶는다.
|
||||
|
||||
### P1.5 — Remote LSP: 원격 언어 서버 통합
|
||||
|
||||
기존 exec/once 기반 ruff/pyright CLI 파이프라인(Phase 6.2 MVP)을 **원격 LSP stdio relay**로 전환한다. 합의된 최신 방향은 **standalone sessions-lsp-proxy 제거**이며, 동일 `local_bridge` 바이너리의 `lsp-stdio` 모드가 Sublime LSP endpoint 역할을 수행한다.
|
||||
|
||||
**핵심 계약 (parent: [#34](https://git.teahaven.kr/sublime-rs/sessions/issues/34)):**
|
||||
- Python은 Sublime API/UI/설정 주입만 담당(얇게 유지), Rust가 transport/lifecycle/rewriting 소유.
|
||||
- `local_bridge lsp-stdio` ↔ persistent broker IPC attach (새 SSH 세션 금지).
|
||||
- `session_helper`는 `lsp_stdio` child process supervisor + file/exec ops 제공.
|
||||
- URI/path rewrite, save barrier, on-demand materialization 책임은 `local_bridge`에 둔다.
|
||||
- diagnostics product path에서 legacy CLI fallback 제거(단, install/check/status용 `exec_once`는 유지).
|
||||
|
||||
**실행 이슈 분해:**
|
||||
- [#36](https://git.teahaven.kr/sublime-rs/sessions/issues/36): `local_bridge lsp-stdio` endpoint + broker attach IPC
|
||||
- [#35](https://git.teahaven.kr/sublime-rs/sessions/issues/35): initialize/URI rewrite + save barrier + on-demand materialization
|
||||
- [#37](https://git.teahaven.kr/sublime-rs/sessions/issues/37): host-scoped install/remove manifests + workspace-scoped env/config + `.sublime-project` 주입/가드
|
||||
- 진행 메모(2026-04-22): `materialize_workspace`는 기존 `.sublime-project`의 사용자 `settings.LSP`를 보존 merge. **추가 완료:** 런타임 `lsp_project_wiring` + 핸드셰이크/활성화 시 프로젝트 refresh, managed `local_bridge lsp-stdio` 커맨드(URI 접두 포함), 진단 커맨드·네비게이션 트레이스. **남음:** save barrier·on-demand materialization·guard 규칙 문서화 등 #35 후속.
|
||||
|
||||
### P2 — 제품 차별화 (transport 이후)
|
||||
|
||||
1. **Track D — [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29):** diff-centric 검토·적용 계약(base hash, path confinement, per-hunk, stale 충돌). **#32·전송 계약이 있어야** 에디터·캐시·진단이 엇갈리지 않는다.
|
||||
|
||||
### P3 — 스케일·체험
|
||||
|
||||
- 멀티 세션 agent window, 풍부한 터미널.
|
||||
|
||||
### P0.5 — 실사용 안정화 (2026-04, 진행 중)
|
||||
|
||||
persistent bridge + async multiplexer + download-only helper가 동작하는 현 상태에서 실사용 품질을 올리는 작업.
|
||||
|
||||
1. ~~**Persistent bridge + async multiplexer:** `local_bridge --persistent`, background mirror thread, unique monotonic `envelope_id`, fail-fast handshake timeout.~~ **완료.**
|
||||
2. ~~**Download-only helper resolution:** Gitea generic registry에서 원격 직접 다운로드, `cargo build` fallback 제거, `Handshake.remote_home`/`arch` 필수화, 단일 SSH 명령으로 ensure+launch 통합.~~ **완료.**
|
||||
3. ~~**Reconnect 개선:** background thread + `ssh_prompt_callback`, `reset_bridge_for_host`, 이미 열린 workspace에서도 mirror refresh 트리거.~~ **완료.**
|
||||
4. ~~**Mirror depth uncapping:** `auto_deepen` source를 `_AUTO_MIRROR_DEPTH_SOURCES`에서 제거, deep sync가 `sessions_mirror_max_traversal_depth` (기본 12) 사용.~~ **완료.**
|
||||
5. ~~**Remote file auto-reload (open tabs):** 기본 경로를 `session_helper` watcher 이벤트 push로 전환하고, 누락 감지는 `on_activated_async`에서 활성 탭만 `file/stat` 재검증하는 하이브리드로 간다. dirty buffer는 건너뜀. 기존 `open_file_refresh` 주기 폴링은 fallback/안전망 용도로 축소.~~ **완료.** `open_file_refresh` 폴링 루프 제거, `file/watch`(inotify) + `on_activated_async` fast-path로 전환.
|
||||
6. ~~**LSP-ready on-demand fetch:** mirror BFS에서 ignore된 경로나 workspace 외부 경로(`.uv-python`, stdlib 등)의 파일을 `open_file` 시 on-demand `file/read`로 투명하게 다운로드. 구현:~~ **완료.**
|
||||
- **External path mapper** (`file_state.py`): `local_path_for_external_remote_file` — workspace root 밖 경로를 `cache_root/__extern/<sanitized_path>`에 매핑, 역매핑 지원
|
||||
- **`on_window_command` interceptor** (`commands.py`): `SessionsOnDemandFetchListener` — `open_file` command 가로채기 → cache에 파일 없으면 background fetch 후 open; workspace 외부 경로는 external mapper로 redirect
|
||||
- **Read-only policy**: `__extern` 하위 파일은 `on_post_save`에서 원격 push 차단 (참조 전용)
|
||||
- **Circular intercept 방지**: thread-local flag로 Sessions 자체 `open_file` 호출은 bypass
|
||||
7. ~~**Mirror ignore pattern**: `MIRROR_BUILTIN_IGNORE_PATTERNS`에 `.git`, `node_modules`, `__pycache__`, `.venv`, `target`, `.uv-python`, `.pytest_cache`, `.ruff_cache`, `.pre-commit-cache`, `.mypy_cache`, `.tox`, `.nox`를 기본 포함. 사용자 설정(`sessions_mirror_ignore_patterns`)은 추가 패턴용. ignore는 mirror BFS에만 적용, `file/read`는 임의 경로 가능.~~ **완료.**
|
||||
8. ~~**Save conflict resolution UI**: 원격 파일 변경 감지 시 quick_panel로 Overwrite/Reload/Cancel 선택. `_handle_save_conflict` → `_force_overwrite_remote` / `_reload_from_remote` 분기.~~ **완료.**
|
||||
9. ~~**Wire contract test coverage**: bridge↔Python stdout envelope 공유 fixture(`tests/contracts/bridge_stdout.*`), `local_bridge` Rust serde 테스트, Python 파서 테스트, `session_helper` binary smoke test(stdio lifecycle), `sessions_native` ABI smoke test(C FFI), mirror ignore pattern snapshot test.~~ **완료.** (`7da0316`)
|
||||
|
||||
### 이미 완료·참고만
|
||||
|
||||
- ~~**Phase 6.2 — [#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30)**~~ MVP slice 완료. **마일스톤 Phase 6.2 closed.**
|
||||
- ~~**Phase 8 — [#19](https://git.teahaven.kr/sublime-rs/sessions/issues/19), [#20](https://git.teahaven.kr/sublime-rs/sessions/issues/20), [#25](https://git.teahaven.kr/sublime-rs/sessions/issues/25)**~~ Rust transport expansion 완료. **마일스톤 Phase 8 closed.**
|
||||
- ~~**[#31](https://git.teahaven.kr/sublime-rs/sessions/issues/31)**~~ Phase 6.3 remote session multiplex + code-server registry closed.
|
||||
- ~~**[#33](https://git.teahaven.kr/sublime-rs/sessions/issues/33)**~~ persistent helper 전환 closed.
|
||||
- 에이전트 JSON 페이로드([#20](https://git.teahaven.kr/sublime-rs/sessions/issues/20), closed)는 선행 MVP 아님.
|
||||
|
||||
**정리:** 이전 목록의 “안정화 → 6.3 → #32 → #24” 순서를 **“crate 통합 → 배포·Rust 척추(#24)·Python mirror 제거·sync 정책(#27/#28) → 멀티플렉스·#32 → #29”** 로 바꾼다. 상세 이관 웨이브는 [`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md) § *Rust-first migration waves* 참고.
|
||||
|
||||
---
|
||||
|
||||
이 문서는 다음 작업을 한 번에 올릴 수 있도록 정리한 초안이다.
|
||||
- milestone: 역사적 Phase 0–6.1 + Phase 6.2 (MVP slice 완료) + Phase 7–9 등
|
||||
- parent issue 1개
|
||||
- 세부 실행 subissue 다수
|
||||
## Milestones
|
||||
### 1. `Phase 0 - Foundation`
|
||||
Repository structure, config model, cache identity, and local metadata strategy.
|
||||
### 2. `Phase 1 - Remote Workspace MVP`
|
||||
SSH config based connect flow, session helper lifecycle, file cache, and recent workspaces.
|
||||
### 3. `Phase 2 - Remote Tooling`
|
||||
Remote formatter and linter execution plus diagnostics UX.
|
||||
### 4. `Phase 3 - Agent Window Prototype`
|
||||
First language/toolchain integration and the first agent window UI.
|
||||
### 5. `Phase 4 - Multi-session UI and Git`
|
||||
Diff-centric proposals, multi-session expansion, and remote git / Sublime Merge strategy.
|
||||
### 6. `Phase 5 - Installed Package E2E`
|
||||
Installed-package behavior, real SSH/runtime execution, and workspace picker UX.
|
||||
### 7. `Phase 6 - Remote Directory Explorer Window`
|
||||
Scratch read-only tree + split `set_layout` explorer (#17); secondary to Phase 6.1 for primary browsing UX.
|
||||
### 8. `Phase 6.1 - Native Sidebar Remote Tree`
|
||||
Mirror remote `list_directory` into the workspace cache on disk and register that folder in `.sublime-project` so the built-in sidebar shows the tree (no custom sidebar API).
|
||||
### 9. `Phase 6.2 - Remote SSH-parity dev MVP` (**closed**)
|
||||
**Gitea milestone closed.** Tracking **[#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30)** (closed). MVP slice shipped: save-time ruff → pyright pipeline, ordered `sessions_remote_python_tool_pipeline`, deduped diagnostics. Full stdio LSP relay deferred to P1.5.
|
||||
|
||||
### 10. `Phase 6.3 - Remote session multiplex` (closed)
|
||||
[#31](https://git.teahaven.kr/sublime-rs/sessions/issues/31) closed. Single SSH stdio session with a **versioned envelope** (`channel` + `kind` + `body`); **code server registry** spawns `exec_once` and `lsp_stdio` children per policy.
|
||||
|
||||
### 11. `Phase 7 - Stability Hardening` (**closed**)
|
||||
**Gitea milestone closed.** Sync correctness, prune safety, cache edge cases. Issues resolved: [#27](https://git.teahaven.kr/sublime-rs/sessions/issues/27) (auto-sync/refresh races, multi-window dedup), [#28](https://git.teahaven.kr/sublime-rs/sessions/issues/28) (truncated mirror prune + symlink/permission edges).
|
||||
|
||||
### 12. `Phase 8 - Rust Transport Expansion` (**closed**)
|
||||
**Gitea milestone closed.** All issues resolved: [#19](https://git.teahaven.kr/sublime-rs/sessions/issues/19), [#20](https://git.teahaven.kr/sublime-rs/sessions/issues/20) (agent payload envelope), [#25](https://git.teahaven.kr/sublime-rs/sessions/issues/25) (helper session hard-timeout/kill policy).
|
||||
|
||||
### 13. `Phase 9 - Quality Gates & Scale` (open)
|
||||
Open issues: [#10](https://git.teahaven.kr/sublime-rs/sessions/issues/10) (product roadmap), [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) (diff-centric change review).
|
||||
---
|
||||
## Python / Rust implementation split
|
||||
Normative description: [`planning/PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md). Binding rules: § *Execution Policy > Binding rules* below.
|
||||
|
||||
- **Python (Sublime)**: command registration, `sublime` API, UI, settings load, threading glue. Keep this layer small.
|
||||
- **Rust**: protocol (`session_protocol`), workspace identity (`workspace_identity`), bridge/helper binaries, and **non-UI algorithms** (remote cache mirror, agent payload — now `local_bridge` internal modules). New heavy logic lands in Rust; **do not** keep a second Python implementation of the same contract. Workspace: 5 crates (`session_protocol`, `workspace_identity`, `sessions_native`, `session_helper`, `local_bridge`).
|
||||
- **Tracking issue**: [#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24) (ongoing migration + Python↔Rust binding).
|
||||
---
|
||||
## Execution Policy (AI-first)
|
||||
- This project assumes AI-driven implementation throughput; calendar duration is not a planning constraint.
|
||||
- Do not defer refactors because of schedule pressure; architecture cleanup ships in the same execution wave as feature work.
|
||||
- Prefer "final-state now" over temporary scaffolding:
|
||||
- avoid long-lived bootstrap paths,
|
||||
- converge transport boundaries early,
|
||||
- lock every change with regression tests before closing the related issue.
|
||||
- Planning is dependency-ordered, not date-ordered. Milestones group capability themes, not deadlines.
|
||||
- **Product order (2026-04, 재조정):** Phase 6.2 / [#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30) **shipped**, Phase 7 **closed** (#27/#28 stability hardening), Phase 8 **closed** 이후 순서는 **«실행 우선순위»** — **P0.5 실사용 안정화** (persistent bridge ✓, wire contract tests ✓, stability hardening ✓, auto-reload, on-demand fetch) → **crate 통합** → **Track E** artifact/publish → **#24** Rust 런타임 권한 이관 + Python mirror 제거·전송 강화 → **#32** 대용량 → **#29** diff 제품 (Phase 9).
|
||||
|
||||
### Binding rules (에이전트·자동화 공통)
|
||||
|
||||
아래 규칙은 Cursor, Gitea 자동화, CI 등 **모든 에이전트**에 적용된다.
|
||||
변경하려면 이 섹션을 먼저 갱신하고, 회귀 테스트·릴리스 노트를 동반한다.
|
||||
|
||||
#### R1. Commit on completion
|
||||
- 작업 요청을 실제 코드 변경으로 끝낸 경우, 마지막에 반드시 커밋까지 완료한다.
|
||||
- 커밋 전에는 관련 테스트/체크를 실행해 기본 검증을 마친다.
|
||||
- 커밋 메시지는 변경 이유를 짧고 명확하게 적는다.
|
||||
- 명시적으로 "커밋하지 말라"는 지시가 있으면 그 지시를 우선한다.
|
||||
|
||||
#### R2. Python / Rust: single source of truth
|
||||
- 같은 로직의 Python/Rust 병행 구현을 유지하지 않는다 (폴백 파서, compat shim 금지).
|
||||
- **Rust**: 알고리즘, wire/schema 검증, 정확성 민감 로직. **Python**: Sublime API, 명령, 설정 글루, Rust 호출 — 얇은 위임만.
|
||||
- 이관 시 Rust로 옮기고, **같은 변경 세트**에서 Python 중복을 삭제한다. 장기 "Rust 경로 + Python 경로" 금지.
|
||||
- 규범 상세: [`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md).
|
||||
|
||||
#### R3. Remote file transport: bridge-only
|
||||
- `tree/list`, `file/read`, `file/stat`, `file/write` 경로에 원격 `python3 -c …` SSH 폴백을 새로 추가하거나 되살리지 않는다.
|
||||
- 브리지(`local_bridge` + `session_helper`)를 쓸 수 없으면 `SessionHelperStartError` / `RemoteWriteFileResult` 등 구조화된 실패로 처리한다.
|
||||
- **로컬** 관리용 SSH(`sh -lc`, 홈 디렉터리 확인 등)는 브리지와 무관한 UX 보조로 유지할 수 있다.
|
||||
|
||||
#### R4. Rust crate 분리 vs 통합
|
||||
- `rust/` 아래 작업 시작 전 기존 crate에 넣는 쪽을 먼저 검토한다.
|
||||
- 분리 타당: 바이너리 타깃이 다름, 선택적 의존성 격차, 런타임 격리 요구.
|
||||
- 통합 우선: 항상 함께 버전 오름, 한 제품 내 소비, thin re-export 반복.
|
||||
- 리팩터로 crate를 줄이거나 합칠 때는 [`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md) 바운더리와 충돌하지 않게 갱신.
|
||||
|
||||
#### R5. No backward compatibility — replace, never layer
|
||||
- 이 프로젝트는 **안정 공개 API, 출시 릴리스, 배포된 인스턴스가 없다.** 모든 프로토콜·스키마·인터페이스는 설계 진행 중이다.
|
||||
- struct, enum, 프로토콜 메시지, wire format을 변경할 때 **제자리 교체**한다. `Option`, `#[serde(default)]`, fallback 분기, migration shim을 "혹시 몰라서" 추가하지 않는다.
|
||||
- 필수 필드는 필수로 선언한다. variant 이름이 바뀌면 전체 코드베이스에서 일괄 변경하고, 이전 형태는 **같은 변경 세트**에서 삭제한다.
|
||||
- dead code 경로, compat alias, "old + new" 병렬 구현을 유지하지 않는다.
|
||||
- 테스트는 새 형태에 맞게 **다시 작성**한다. 양쪽 형태를 모두 허용하도록 패치하지 않는다.
|
||||
|
||||
#### R6. 신규 파일 생성 제한 (Python `.py` / Rust crate)
|
||||
- 새 파일·새 crate를 만들기 전에 **기존 모듈 중 같은 관심사를 가진 것이 있는지** 먼저 확인하고, 있으면 그 모듈에 추가한다.
|
||||
- 신규 파일이 필요한 **구체적 이유**(순환 import 방지, 바이너리/런타임 경계, 독립 테스트 필요 등)가 없으면 만들지 않는다.
|
||||
- **사용자 승인**: 새 파일 생성 전 반드시 이유를 설명하고 사용자 승인을 받는다.
|
||||
- 병합 우선 신호: 50줄 미만 + 함수/클래스 3개 이하, 소비자 1–2개, 타입 감싸기/조합만 하는 역할.
|
||||
- 분리 유지 신호: 외부 라이브러리 의존성 차이, 순환 import 회피, Rust 크레이트 1:1 대응, 200줄 이상 + 단일 책임 명확.
|
||||
|
||||
#### R7. 테스트 커버리지·회귀 (Python `sublime/sessions`)
|
||||
|
||||
- **CI 게이트는 바닥일 뿐이다.** 저장소 pre-commit / `pytest --cov-fail-under=80` 은 **최소 통과선**으로만 본다. 변경을 “80%에 맞추기 위해” 얕은 테스트나 한 줄짜리 커버만 얹는 방식은 피한다.
|
||||
- **넉넉한 목표:** 전체 패키지 커버리지는 CI 한도보다 **여유 있게** 유지·상향한다. 리그레션 여지가 큰 모듈(연결·브리지·미러·SSH·프로젝트 상태)은 **가능한 한 넓은 분기**를 테스트로 고정한다.
|
||||
- **신규·이번 변경으로 실질적으로 건드린 코드:** 해당 변경과 함께 들어가는 테스트로 **그 모듈(또는 그 기능 단위) 기준 커버리지 최소 85%** 를 목표로 한다. (파일 단위 `pytest --cov=sublime/sessions/<module>` 로 확인 가능하면 우선한다.)
|
||||
- **엣지 케이스 우선:** 정상 경로만이 아니라 **실패·타임아웃·빈 입력·멀티 윈도우·캐시/상태 불일치·플랫폼 차이(예: Windows vs Unix)** 등 운영에서 터지기 쉬운 경로를 의식적으로 나열하고, 그중 **고비용·고위험**부터 테스트에 반영한다.
|
||||
- **회귀:** R1과 같이, 기능 변경에는 **같은 PR/커밋 세트**에서 실패 가능한 시나리오를 테스트로 잠근 뒤에만 이슈를 닫는다.
|
||||
|
||||
---
|
||||
## Parent Issue
|
||||
### Title
|
||||
`Sessions: product roadmap and execution plan`
|
||||
### Body
|
||||
## Vision
|
||||
`Sessions` starts as a lightweight remote workspace tool for Sublime Text:
|
||||
- SSH config driven connect flow
|
||||
- session-bound remote helper over SSH stdio
|
||||
- local cache for editor compatibility
|
||||
- no persistent remote daemon by default
|
||||
Long-term, it should evolve toward a multi-session `agent window` inspired by Cursor 3's Agents Window:
|
||||
- multiple SSH sessions
|
||||
- a chat/activity-log style center pane
|
||||
- editor and directory browsing on the right
|
||||
- diff-centric review of proposed changes
|
||||
Reference: [Cursor 3 - Agents Window](https://cursor.com/blog/cursor-3)
|
||||
|
||||
**Near-term product gate (before agent-heavy editor):** ship **[#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30)** — remote **LSP + language tooling MVP** so the **current** environment already supports **Remote-SSH–class** daily development; agent-centric flows build on that foundation.
|
||||
## Accepted Product Decisions
|
||||
- Package name: `Sessions`
|
||||
- Remote transport: `ssh ... helper --stdio`
|
||||
- No remote persistent session state by default
|
||||
- Cache identity must be local-host-independent
|
||||
- `~/.ssh/config` is the primary connection source
|
||||
- `.sublime-project` is the editor entry point, but plugin metadata is the source of truth for reconnect behavior
|
||||
## Core Requirements
|
||||
- [x] Connect to Linux hosts using existing SSH config aliases
|
||||
- [x] Open remote roots as repeatable workspaces
|
||||
- [x] Support recent workspace reconnects
|
||||
- [x] Keep file cache and session metadata separate
|
||||
- [x] Allow optional shared cache roots across local machines
|
||||
- [x] Run formatter/linter in the remote environment (baseline commands + diagnostics)
|
||||
- [x] **Remote-SSH-parity dev (MVP slice):** subprocess ruff + pyright pipeline on save/open settings, deduped diagnostics — [#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30) (closed; full stdio LSP later)
|
||||
- [x] Move toward a multi-session `agent window` (shell/UI direction; **not** a substitute for #30 MVP)
|
||||
- [x] Persistent bridge session + async multiplexer (`local_bridge --persistent`, monotonic `envelope_id`)
|
||||
- [x] Download-only helper resolution (Gitea generic registry, no `cargo build` fallback)
|
||||
- [x] Reconnect with SSH prompt handling + fail-fast handshake timeout
|
||||
- [x] Remote file auto-reload for open tabs (`file/watch` + `on_activated_async`)
|
||||
- [x] LSP-ready on-demand fetch (external path mapper + `on_window_command` interceptor)
|
||||
- [x] **Remote LSP integration:** `local_bridge lsp-stdio` endpoint + broker attach IPC, bridge `lsp_stdio` relay, URI rewrite/save barrier/materialization, host-scoped install + workspace-scoped env/config, `.sublime-project` 자동 설정 주입 ([#34](https://git.teahaven.kr/sublime-rs/sessions/issues/34), [#35](https://git.teahaven.kr/sublime-rs/sessions/issues/35), [#36](https://git.teahaven.kr/sublime-rs/sessions/issues/36), [#37](https://git.teahaven.kr/sublime-rs/sessions/issues/37))
|
||||
- [ ] Provide diff-centric change review
|
||||
- [x] Investigate remote git support and possible Sublime Merge integration
|
||||
## Milestones
|
||||
- [x] **Phase 6.2 - Remote SSH-parity dev MVP** — [#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30) (MVP slice closed; extend in new issues if needed)
|
||||
- [ ] **Phase 6.3 - Remote session multiplex** — planning: [`VSCODE_REMOTE_TRANSPORT_MODEL.md`](VSCODE_REMOTE_TRANSPORT_MODEL.md); track with new Gitea issues after #25/#24 alignment
|
||||
- [x] **Phase 7 - Remote LSP** — 원격 언어 서버 stdio relay (pyright, rust-analyzer, ruff), `local_bridge lsp-stdio` endpoint + broker attach IPC, URI rewrite/save barrier/materialization, host-scoped install/workspace-scoped env, 자동 `.sublime-project` 설정 주입
|
||||
- [x] Phase 0 - Foundation
|
||||
- [x] Phase 1 - Remote Workspace MVP
|
||||
- [x] Phase 2 - Remote Tooling
|
||||
- [x] Phase 3 - Agent Window Prototype
|
||||
- [x] Phase 4 - Multi-session UI and Git
|
||||
- [x] Phase 5 - Installed Package E2E
|
||||
- [x] Phase 6.1 - Native sidebar remote tree (cache mirror + project folders) — [#18](https://git.teahaven.kr/sublime-rs/sessions/issues/18) (closed)
|
||||
- [x] Phase 6 - Remote Directory Explorer Window (scratch tree; superseded for primary UX by 6.1)
|
||||
## Detailed Execution Issues
|
||||
- [x] **Phase 6.2:** Remote-SSH-parity dev (LSP + remote language tooling MVP) — [#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30) (closed; MVP slice landed)
|
||||
- [x] Phase 0: repository structure, config model, and shared cache identity
|
||||
- [x] Phase 1: ssh-config workspace connect flow and recent sessions
|
||||
- [x] Phase 1: session helper protocol and lifecycle
|
||||
- [x] Phase 1: remote file cache, open/save pipeline, and conflict handling
|
||||
- [x] Phase 2: remote formatter/linter execution and diagnostics UX
|
||||
- [x] Phase 3: first language/toolchain integration
|
||||
- [x] Phase 3: agent window prototype (session list, activity log, editor split)
|
||||
- [x] Phase 4: remote git bridge and Sublime Merge integration strategy
|
||||
- [x] Phase 5: installed-package runtime validation and SSH execution boundary
|
||||
- [x] Phase 5: remote folder browser and workspace picker UX
|
||||
- [x] Phase 5: helper-backed file transport execution in Sublime
|
||||
- [x] Phase 5: installed-package remote tooling and diagnostics wiring
|
||||
- [x] Phase 5: Rust bridge/helper transport pivot for remote tree and file execution
|
||||
- [x] Phase 5: Rust binary packaging and installation flow
|
||||
- [x] Phase 6: remote directory explorer window (open/close from UI) — [#17](https://git.teahaven.kr/sublime-rs/sessions/issues/17) (closed; scratch+split)
|
||||
- [x] Phase 6.1: native sidebar remote directory (`mirror_tree` + `folders`) — [#18](https://git.teahaven.kr/sublime-rs/sessions/issues/18)
|
||||
- [x] Phase next: remote agent → editor JSON envelope — [#20](https://git.teahaven.kr/sublime-rs/sessions/issues/20) (closed)
|
||||
- [x] Phase next: remote explorer-first UX and session terminal wiring — [#22](https://git.teahaven.kr/sublime-rs/sessions/issues/22)
|
||||
- [x] Phase next: stale cache reconciliation + Terminus panel terminal — [#23](https://git.teahaven.kr/sublime-rs/sessions/issues/23)
|
||||
- [x] P0.5: persistent bridge + async multiplexer + download-only helper + reconnect hardening
|
||||
- [x] P0.5: remote file auto-reload for open tabs
|
||||
- [x] P0.5: LSP-ready on-demand fetch (external path mapper + `on_window_command` interceptor)
|
||||
- [x] P1.5: Remote LSP — `local_bridge lsp-stdio` + broker attach IPC, initialize/URI rewrite + save barrier + materialization, install/remove manifests + `.sublime-project` 자동 주입, diagnostics product path CLI fallback 제거 (#34/#35/#36/#37)
|
||||
- [ ] Phase next: Python-thin / Rust-thick architecture + migrate remote mirror — [#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24)
|
||||
## Current Status
|
||||
- **P0.5 실사용 안정화 (진행 중):** persistent bridge/multiplexer/download-only helper/reconnect/mirror ignore/save conflict UI/wire contract tests 완료; remote file auto-reload + LSP-ready on-demand fetch 구현 완료.
|
||||
- **Phase 7 Stability Hardening:** [#27](https://git.teahaven.kr/sublime-rs/sessions/issues/27), [#28](https://git.teahaven.kr/sublime-rs/sessions/issues/28) 모두 closed. **마일스톤 Phase 7 closed.** Rust prune dangling symlink 수정 + 5종 엣지 테스트; Python multi-window cache-key dedup, 주기 refresh 충돌 방지, truncation 상태 메시지, symlink/dir 정리, hydrate/refresh 경합, ignored-path open 등 10종 테스트 추가.
|
||||
- **Phase 6.2 MVP slice:** [#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30) closed. **마일스톤 Phase 6.2 closed.** 기존 exec/once CLI 파이프라인은 P1.5 Remote LSP stdio relay 완성 시 deprecated → 제거.
|
||||
- **Phase 8 Rust Transport Expansion:** [#19](https://git.teahaven.kr/sublime-rs/sessions/issues/19), [#20](https://git.teahaven.kr/sublime-rs/sessions/issues/20), [#25](https://git.teahaven.kr/sublime-rs/sessions/issues/25) 모두 closed. **마일스톤 Phase 8 closed.**
|
||||
- **Wire contract test coverage (2026-04):** bridge↔Python stdout 공유 fixture 9종, Rust/Python 양측 파서 테스트, `session_helper` binary smoke test, `sessions_native` ABI smoke test, mirror ignore pattern snapshot test 추가 (`7da0316`).
|
||||
- **P1.5 Remote LSP (완료):** parent [#34](https://git.teahaven.kr/sublime-rs/sessions/issues/34) 기준으로 `local_bridge lsp-stdio`/broker attach, URI rewrite/save barrier/materialization, install/remove + `.sublime-project` 주입/보존(merge) 가드까지 반영 완료.
|
||||
- Closed detailed issues: `#2`, `#3`, `#4`, `#5`, `#6`, `#7`, `#8`, `#9`, `#11`, `#12`, `#13`, `#14`, `#16`, `#17`, `#18`, `#19`, `#20`, `#25`, `#27`, `#28`, `#31`, `#33`, `#34`, `#35`, `#36`, `#37`
|
||||
- Closed milestones: Phase 0, Phase 1, Phase 2, Phase 3, Phase 4, Phase 5, Phase 6.2, Phase 7, Phase 8
|
||||
- Open milestones: **Phase 9** (#10, #29)
|
||||
- Phase 0 and Phase 1 are complete at the checklist level and reflected in local/Gitea trackers
|
||||
- Phase 2 through Phase 5 now have concrete Sublime-facing runtime wiring and targeted regression coverage; **Phase 6.2** closes the gap to **full remote dev loop** (LSP + tools), not only packaging/plumbing
|
||||
- Added a Python compile smoke check to pre-commit and CI after a macOS Sublime loading regression exposed parser-sensitive command syntax
|
||||
- Pinned Sublime-facing Python tests and compile checks to a real `Python 3.8` runtime so local validation matches the actual Sublime plugin host more closely
|
||||
- Added an explicit `Sessions.plugin` and runtime-module import smoke test under `Python 3.8`, and hardened Sublime-facing modules against import-time parser and annotation regressions
|
||||
- Restored the direct `Sessions.plugin` entrypoint after forcing the Python 3.8 plugin host, documented that `sublime/` is the actual package root, and added a reproducible `.sublime-package` release build script (`5ebab05`, `d35e834`)
|
||||
- Consolidated the over-split workspace/recent state foundation into `workspace_state.py` and `recent_state.py` so the Sublime runtime carries fewer Python files and simpler imports (`ef589ed`)
|
||||
- Reframed the SSH connect UX around host-first connection and a separate `Open Remote Folder` step so workspace roots are chosen only after a host session exists, which better matches VS Code Remote-SSH expectations and avoids synthetic pre-connect root guesses
|
||||
- Folded the remaining over-split `agent_window_*`, `remote_*`, `file_*`, and `diagnostics_*` model families into four broader modules (`agent_window.py`, `remote.py`, `file_state.py`, `diagnostics.py`) so the Sublime-side Python surface stays closer to package-scale expectations
|
||||
- Installed-package smoke validation has reached the point where `Sessions` commands now load and can be invoked from the Sublime command palette
|
||||
- Phase 5 now has concrete runtime slices in flight: the workspace picker starts from recent valid roots or remote `HOME`, directory browsing is routed through a thin `ssh_runner`/`ssh_file_transport` boundary, and remote-tool prepare/diagnostics adaptation has first installed-package wiring
|
||||
- Added an initial `Open Remote File` command that maps a remote path under the current workspace root into the local cache, opens the mirrored file in Sublime, and surfaces read/policy/transport failures with explicit status copy
|
||||
- Added a matching `Save Remote File` command that probes current remote metadata, reuses existing conflict rules, writes local cache bytes back over SSH, updates the saved baseline metadata sidecar, and surfaces permission/conflict/transport outcomes in status copy
|
||||
- Added the first Rust transport pivot for remote tree/file execution: `session_protocol` now carries explicit `tree/list` and `file/read/stat/write` payloads, `session_helper` and `local_bridge` now have real stdio entrypoints, `ssh_file_transport.py` prefers the Rust bridge with SSH/Python fallback, and the persistent `Sessions Remote Tree` view is back on top of the Rust-backed list path when the bridge is available (`f6f1008`, `bebc020`)
|
||||
- Completed the installed-package packaging path: the connect flow now auto-detects the remote Linux helper target per host, falls back to a quick panel only when detection fails, resolves the remote helper by host-selected Linux target, and splits release archives into distinct `local-bridge/` and `remote-helper/` bundle roots (`68585fb`, `057d1f7`, `3f300bb`, `770f12f`)
|
||||
## Out of Scope for the Initial Iteration
|
||||
- Remote persistent daemons
|
||||
- Remote Windows/macOS targets
|
||||
- Full terminal/port-forwarding product parity with VS Code Remote-SSH
|
||||
- Hiding all remote semantics behind "it just works" abstractions
|
||||
---
|
||||
## Subissues
|
||||
### Issue A
|
||||
#### Title
|
||||
`Phase 0: repository structure, config model, and shared cache identity`
|
||||
#### Body
|
||||
## Goal
|
||||
Lay down the repository, package, and config foundations for `Sessions` without overcommitting to heavyweight remote-daemon architecture.
|
||||
## Implementation Checklist
|
||||
- [x] Create the initial mono-repo structure for:
|
||||
- Sublime package code
|
||||
- Rust local bridge
|
||||
- Rust session helper
|
||||
- docs/planning material
|
||||
- [x] Decide the minimum supported Sublime build and Python environment
|
||||
- [x] Define the canonical package name: `Sessions`
|
||||
- [x] Define the core workspace identity:
|
||||
- remote host identity
|
||||
- remote root
|
||||
- optional profile
|
||||
- [x] Define the cache identity so it is independent of the local machine identity
|
||||
- [x] Split metadata into:
|
||||
- shared cache metadata
|
||||
- local-only runtime/session metadata
|
||||
- [x] Define the settings model:
|
||||
- ssh config usage
|
||||
- recent workspaces
|
||||
- optional shared cache root
|
||||
- language/toolchain-specific settings
|
||||
- [x] Define project file responsibilities vs plugin-owned metadata responsibilities
|
||||
- [x] Define versioning and migration strategy for cache/metadata layout
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Same remote root accessed through different ssh aliases
|
||||
- [x] Same ssh host with multiple remote roots
|
||||
- [x] Remote root renamed or moved on the server
|
||||
- [x] Shared cache root not available on startup
|
||||
- [x] Windows/macOS path normalization differences for the same workspace identity
|
||||
- [x] Cache key collisions caused by hostname aliases, symlinks, or user aliases
|
||||
- [x] Upgrade path when metadata version changes
|
||||
- [x] Empty or malformed `~/.ssh/config`
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Keep initial settings surface minimal and avoid inventing a parallel ssh config format
|
||||
- [x] Treat `.sublime-project` as the editor entry point, but keep plugin metadata as the source of truth
|
||||
- [x] Do not store persistent session/chat state on the remote server
|
||||
- [x] Default to local cache; make shared cache optional, not required
|
||||
## Current Status
|
||||
- Completed in commits: `3210e84`, `27067f3`, `dee70e7`, `7ef0e40`, `5100c7c`, `6cc9d23`, `dd5dc4a`
|
||||
- Done: repo skeleton, Rust bridge/helper crate placeholders, workspace/cache identity, metadata split, settings model, project entry vs plugin metadata boundary, Linux-only Python/Rust CI baselines, stricter Python/Rust documentation standards applied to existing implementation boundaries, repository-wide Ruff enforcement for Google-style docstrings and 88-column formatting, explicit Sublime/Python runtime floor helpers, metadata version reset rules, and shared-cache fallback behavior
|
||||
- Edge/test coverage now explicitly tracks alias/root identity differentiation, remote-root move/rename changes, shared-cache-unavailable startup fallback, local-path-independent workspace identity, alias/symlink-like collision avoidance, metadata version mismatch resets, and empty-or-aliasless SSH config parsing
|
||||
- Remaining: none; issue ready to close once local/remote trackers are synchronized
|
||||
### Issue B
|
||||
#### Title
|
||||
`Phase 1: ssh-config workspace connect flow and recent sessions`
|
||||
#### Body
|
||||
## Goal
|
||||
Make connecting to a remote workspace feel native to Sublime by reusing `~/.ssh/config` and making recent workspaces first-class.
|
||||
## Implementation Checklist
|
||||
- [x] Parse `~/.ssh/config` host aliases safely
|
||||
- [x] Build `Connect Remote Workspace` command
|
||||
- [x] Show host aliases in a quick panel
|
||||
- [x] Allow remote root selection after host connection through a separate `Open Remote Folder` step
|
||||
- [x] Create a local cache root and `.sublime-project` on first connect
|
||||
- [x] Record recent workspaces with:
|
||||
- host alias
|
||||
- remote root
|
||||
- cache identity
|
||||
- last connected time
|
||||
- [x] Build `Open Recent Remote Workspace`
|
||||
- [x] Build `Reconnect Current Workspace`
|
||||
- [x] Surface disconnected state clearly in the UI
|
||||
- [x] Keep recent metadata local-only unless shared metadata is explicitly enabled
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Host alias exists but underlying ssh config is now invalid
|
||||
- [x] Host selected but remote root no longer exists
|
||||
- [x] Host opens but helper startup fails
|
||||
- [x] Recent workspace entry exists but cache directory is missing
|
||||
- [x] Same workspace opened from multiple windows
|
||||
- [x] Current workspace reconnect after sleep / laptop resume
|
||||
- [x] ssh config changes while Sublime is still open
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Prefer `Recent Workspaces` as the fast path after first connect
|
||||
- [x] Preserve a separate `Connect via SSH Config` flow for discovery
|
||||
- [x] Avoid wizard-heavy UI; use short quick panel flows
|
||||
- [x] Show enough metadata in the recent list to disambiguate same-host different-root workspaces
|
||||
- [x] Connect to a host before requiring a workspace root, then let `Open Remote Folder` decide the root
|
||||
## Current Status
|
||||
- Completed in commits: `cf11912`, `7536340`, `30413b7`, `f8ff026`, `53cb1aa`, `0e09a67`, `ac97107`, `b904370`, `7fe0cb1`, `160f1ed`
|
||||
- Done: concrete SSH host alias parsing, local-only recent workspace metadata primitives, host-scoped remote-root candidate modeling, first-connect cache/project path planning, actual cache/project materialization, persisted local recent-workspace storage, a UI-free connect workflow core, local path defaults, quick-panel item models, initial Connect/Open Recent/Reconnect command skeletons, connect preflight validation, explicit ready/warning/disconnected status messaging, multi-window workspace guards, recent-first command palette ordering, and the corrected host-first `Connect -> Open Remote Folder` interaction
|
||||
- Edge/test coverage now includes stale SSH aliases, missing remote roots, helper startup failures, missing cache recovery during reconnect, multi-window collision detection, reconnect-after-resume behavior, and live SSH config reload behavior
|
||||
- Remaining: none; issue ready to close once local/remote trackers are synchronized
|
||||
### Issue C
|
||||
#### Title
|
||||
`Phase 1: session helper protocol and lifecycle`
|
||||
#### Body
|
||||
## Goal
|
||||
Define the lightweight `ssh ... helper --stdio` model that powers remote operations without requiring installation or persistent daemons.
|
||||
## Implementation Checklist
|
||||
- [x] Define transport framing for stdio communication
|
||||
- [x] Choose protocol shape:
|
||||
- newline-delimited JSON
|
||||
- length-prefixed JSON-RPC
|
||||
- [x] Add handshake message with:
|
||||
- helper version
|
||||
- remote platform
|
||||
- capabilities
|
||||
- [x] Define request/response/error envelopes
|
||||
- [x] Define cancellation and timeout semantics
|
||||
- [x] Define logging and trace levels for debugging
|
||||
- [x] Define helper startup command line
|
||||
- [x] Define helper shutdown behavior on stdin close and ssh disconnect
|
||||
- [x] Define retry / reconnect behavior in the local bridge
|
||||
- [x] Decide whether protocol compatibility is strict or feature-negotiated
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Helper exits immediately after startup
|
||||
- [x] Partial writes / partial reads on stdio framing
|
||||
- [x] Long-running operations blocked by a noisy stderr stream
|
||||
- [x] Lost ssh session mid-request
|
||||
- [x] Mismatched helper and bridge versions
|
||||
- [x] Remote shell environment modifies stdout unexpectedly
|
||||
- [x] Cancellation during file transfer or formatter execution
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Prefer one visible "session failed" state over leaking transport-level jargon
|
||||
- [x] Keep protocol logs available but hidden from the normal user flow
|
||||
- [x] Start with session-bound lifecycle only; no background daemon management
|
||||
## Current Status
|
||||
- Completed in commits: `afb4d7f`, `1446742`, `a291a21`, `863880c`
|
||||
- Done: shared `session_protocol` crate, NDJSON framing helpers, handshake payload, request/response/error/cancel/shutdown envelopes, timeout metadata, trace levels, helper startup argv parsing and construction, local-bridge reconnect policy, explicit transport/version compatibility evaluation, session-bound lifecycle documentation, incremental NDJSON frame buffering, noisy-stdout rejection, request cancellation classification, stderr retention policy, and user-facing session-failure summaries
|
||||
- Edge/test coverage now includes helper-exits-immediately startup failure, partial frame reconstruction, noisy stderr retention, lost-SSH mid-request failure summaries, protocol version mismatches, transport mismatches, remote-shell stdout noise rejection, cancellation support for file/tool requests, helper argv parsing failures, and retry-budget exhaustion/backoff behavior
|
||||
- Remaining: none; issue ready to close once local/remote trackers are synchronized
|
||||
### Issue D
|
||||
#### Title
|
||||
`Phase 1: remote file cache, open/save pipeline, and conflict handling`
|
||||
#### Body
|
||||
## Goal
|
||||
Make remote files feel local enough for editing while staying explicit about cache and conflict semantics.
|
||||
## Implementation Checklist
|
||||
- [x] Browse remote directories through the helper
|
||||
- [x] Map remote paths to local cache paths
|
||||
- [x] Open remote files into local cached files
|
||||
- [x] Save local changes back to remote through the helper
|
||||
- [x] Track remote metadata such as mtime and size
|
||||
- [x] Add conflict detection before overwrite
|
||||
- [x] Define cache invalidation rules
|
||||
- [x] Define reload behavior when remote changes outside the current session
|
||||
- [x] Handle binary / large / unsupported file types safely
|
||||
- [x] Define rename / delete / create file semantics for remote operations
|
||||
## Edge Cases and Test Scope
|
||||
- [x] File modified remotely after local open but before local save
|
||||
- [x] File deleted remotely while still open locally
|
||||
- [x] Permission denied on save
|
||||
- [x] Saving to a path that was replaced by a directory
|
||||
- [x] Symlink traversal and symlink loops
|
||||
- [x] Very large file open/save
|
||||
- [x] Unicode paths and spaces in paths
|
||||
- [x] Concurrent edits from two local windows or two local hosts sharing cache
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Conflicts should show a clear choice: overwrite, reload, cancel
|
||||
- [x] Do not pretend the cache is the source of truth
|
||||
- [x] Prefer safe failure over silent overwrite
|
||||
- [x] Avoid background full-tree sync in the MVP
|
||||
## Current Status
|
||||
- Completed in commits: `324a9e8`, `3f1d628`, `8ffae6d`
|
||||
- Done: deterministic remote-to-local cache mapping, helper-facing directory browse/read/write request models, remote metadata snapshots, open/save validation models, permission-denied save results, conflict categories, cache invalidation planning, reload recommendations, binary/large-file safeguards, rename/delete/create cache update plans, symlink-loop browse rejection, and explicit remote-authoritative/on-demand-sync product policies
|
||||
- Edge/test coverage now includes remote-change-before-save, remote-delete-before-save, permission-denied save results, path-becomes-directory conflicts, symlink-loop rejection, large-file blocking, Unicode path mapping, and shared-cache contention hints
|
||||
- Remaining: none; issue ready to close once local/remote trackers are synchronized
|
||||
### Issue E
|
||||
#### Title
|
||||
`Phase 2: remote formatter/linter execution and diagnostics UX`
|
||||
#### Body
|
||||
## Goal
|
||||
Run formatter and linter tools in the remote environment and present the results in a way that feels natural inside Sublime.
|
||||
## Implementation Checklist
|
||||
- [x] Define tool execution requests in the helper protocol
|
||||
- [x] Add manual command to run formatter/linter for current file
|
||||
- [x] Add optional run-on-save behavior
|
||||
- [x] Capture stdout, stderr, exit code, and structured diagnostics
|
||||
- [x] Map remote diagnostic paths back to local cached files
|
||||
- [x] Show diagnostics in output panel and/or inline regions
|
||||
- [x] Add retry / rerun affordances
|
||||
- [x] Distinguish formatter edits from diagnostic-only runs
|
||||
- [x] Define per-workspace tool configuration overrides
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Formatter modifies the file while the buffer is dirty
|
||||
- [x] Tool emits diagnostics for files not currently open
|
||||
- [x] Tool not found in remote PATH
|
||||
- [x] Tool exits non-zero but still emits useful diagnostics
|
||||
- [x] Tool outputs absolute remote paths that do not match cache paths directly
|
||||
- [x] Long stderr output or slow tool startup
|
||||
- [x] Save loops caused by formatter-on-save
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Start with explicit, readable output before polishing inline UX
|
||||
- [x] Keep formatter and linter results separate when useful
|
||||
- [x] Errors about missing remote tools should be actionable, not generic
|
||||
## Current Status
|
||||
- Completed in commit: `27e5228`
|
||||
- Done: helper-facing tool execution request/result models, diagnostics severity/source/presentation models, run policies for manual and on-save execution, rerun affordances, formatter-vs-diagnostics distinction, per-workspace tool overrides, and remote-to-local diagnostics path mapping
|
||||
- Edge/test coverage now includes dirty-buffer formatter collisions, non-open-file diagnostics, missing-tool actionable failures, useful diagnostics from non-zero exits, remote absolute path remapping, slow-startup/long-stderr handling, and formatter-on-save loop prevention policy
|
||||
- Remaining: concrete Sublime command wiring, helper runtime execution, output parsing from real tool processes, and inline region rendering
|
||||
### Issue F
|
||||
#### Title
|
||||
`Phase 3: first language/toolchain integration`
|
||||
#### Body
|
||||
## Goal
|
||||
Pick one real language/toolchain and make the end-to-end experience solid before generalizing.
|
||||
## Proposed First Target
|
||||
`Python + black/ruff + pyright`
|
||||
## Implementation Checklist
|
||||
- [x] Confirm the first supported toolchain and document why it was chosen
|
||||
- [x] Define remote environment assumptions for the first toolchain
|
||||
- [x] Add workspace-level detection of tool availability
|
||||
- [x] Wire formatter/linter/LSP commands for the chosen toolchain
|
||||
- [x] Define how the first toolchain advertises status in the UI
|
||||
- [x] Add "toolchain unavailable" fallback states
|
||||
- [x] Document minimal setup for the remote server
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Virtualenv/venv differs per workspace
|
||||
- [x] Toolchain installed but wrong version
|
||||
- [x] LSP starts but root detection is wrong
|
||||
- [x] Formatter and linter disagree on file changes
|
||||
- [x] Remote project uses pyproject.toml or nested workspace roots
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Keep the first supported toolchain opinionated instead of building generic abstractions too early
|
||||
- [x] Surface capability detection clearly so users know why a feature is disabled
|
||||
## Current Status
|
||||
- Completed in commit: `27e5228`
|
||||
- Done: first supported toolchain selection for Python plus `black`/`ruff`/`pyright`, remote environment assumptions, workspace-level tool availability detection, capability/status reporting, unavailable-tool fallback states, and minimal remote setup guidance
|
||||
- Edge/test coverage now includes workspace-specific virtualenv differences, unsupported or mismatched tool versions, wrong root detection hints for LSP startup, formatter-vs-linter disagreement reporting, and nested `pyproject.toml` workspace layouts
|
||||
- Remaining: full long-lived LSP stdio and deeper attach tests — **MVP slice in [#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30) (closed)**; follow-up issues may extend.
|
||||
### Issue G
|
||||
#### Title
|
||||
`Phase 3: agent window prototype (session list, activity log, editor split)`
|
||||
#### Body
|
||||
## Goal
|
||||
Prototype the long-term `Sessions` UI: multiple remote sessions, activity/chat-style summaries, and an editor area in one workflow.
|
||||
## Implementation Checklist
|
||||
- [x] Define the first `agent window` layout
|
||||
- [x] Build a session list sourced from recent metadata
|
||||
- [x] Build an activity timeline / chat-like panel for one selected session
|
||||
- [x] Show structured summaries of helper / CLI actions instead of raw terminal spam
|
||||
- [x] Provide an editor split or fast jump into the relevant file
|
||||
- [x] Show proposed changes as diff when possible
|
||||
- [x] Define how directory browsing is exposed next to editor content
|
||||
- [x] Define what happens when a selected session is offline or stale
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Very long activity histories
|
||||
- [x] Session selected but no cache exists yet
|
||||
- [x] Session selected from a different local host with shared cache
|
||||
- [x] Diff proposal available but source file changed since proposal generation
|
||||
- [x] Two sessions point to the same remote root with different profiles
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Left pane: sessions
|
||||
- [x] Center pane: activity/chat summary
|
||||
- [x] Right pane: editor and file tree
|
||||
- [x] Prefer summary-first over terminal-first presentation
|
||||
- [x] Avoid trying to rebuild the full VS Code workbench in the first prototype
|
||||
## Current Status
|
||||
- Completed in commit: `6286f14`
|
||||
- Done: UI-free agent window layout models, recent-session list state, structured timeline/chat entries, helper/CLI action summaries, editor jump targets, diff proposal references, directory pane descriptors, and offline/stale-session state handling
|
||||
- Edge/test coverage now includes long-history trimming, missing-cache session selection, shared-cache sessions from another local host, stale diff proposals after source changes, and same-remote-root sessions with distinct profiles
|
||||
- Remaining: actual Sublime pane/widget wiring, persisted activity logs, live connection presence detection, and rendered diff/editor integration
|
||||
### Issue H
|
||||
#### Title
|
||||
`Phase 4: remote git bridge and Sublime Merge integration strategy`
|
||||
#### Body
|
||||
## Goal
|
||||
Provide a practical remote git workflow for SSH sessions, and determine whether Sublime Merge integration is sufficient or a dedicated bridge is required.
|
||||
## Implementation Checklist
|
||||
- [x] Inventory what Sublime Merge can and cannot extend for remote repositories
|
||||
- [x] Add helper commands for:
|
||||
- git status
|
||||
- git diff
|
||||
- current branch
|
||||
- staged vs unstaged summary
|
||||
- [x] Define a diff-centric UX inside `Sessions`
|
||||
- [x] Define the minimum write actions:
|
||||
- stage
|
||||
- unstage
|
||||
- commit
|
||||
- [x] Decide whether push/pull belong in the first git bridge iteration
|
||||
- [x] If Sublime Merge cannot be integrated cleanly, define a dedicated remote git panel strategy
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Detached HEAD
|
||||
- [x] Merge conflicts
|
||||
- [x] Dirty worktree plus unstaged helper-generated edits
|
||||
- [x] Large diffs
|
||||
- [x] Git unavailable on remote server
|
||||
- [x] Non-git remote workspace
|
||||
- [x] Remote repository changes while the session is open
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Start with read-mostly git visibility before adding destructive actions
|
||||
- [x] Keep diff review central to the UX
|
||||
- [x] Avoid hiding that git actions are happening on the remote server
|
||||
## Current Status
|
||||
- Completed in commit: `d547260`
|
||||
- Done: remote git capability detection, helper exchange models for status/diff/branch/staged summaries, diff-centric review workflow models, stage/unstage/commit write-action requests, first-iteration push/pull scope decision, and dedicated remote git panel fallback strategy
|
||||
- Edge/test coverage now includes detached HEAD handling, merge-conflict surfacing, dirty-worktree plus helper-edit interaction, large-diff presentation limits, missing-git failures, non-repository workspaces, and remote repository drift during an active session
|
||||
- Remaining: real helper execution/serialization, Sublime command and panel wiring, and concrete Sublime Merge launch or handoff integration
|
||||
### Issue I
|
||||
#### Title
|
||||
`Phase 5: installed-package runtime validation and SSH execution boundary`
|
||||
#### Body
|
||||
## Goal
|
||||
Turn the current model-heavy implementation into a reliably dogfoodable installed-package workflow by making runtime failures visible and by defining the thin SSH execution layer that bridges Sublime commands to real remote operations.
|
||||
## Implementation Checklist
|
||||
- [x] Verify package loading, command palette entries, and status-message behavior from an installed `sublime/` package on macOS
|
||||
- [x] Add a thin SSH command runner on the Sublime side for pre-helper runtime checks
|
||||
- [x] Distinguish host-session failures, missing remote roots, and browse/read/write command failures in user-visible status text
|
||||
- [x] Add install-time/debug-time tracing guidance for failed SSH invocations
|
||||
- [x] Document which pieces are temporary bootstrap behavior versus long-term Rust helper behavior
|
||||
## Edge Cases and Test Scope
|
||||
- [x] SSH host is valid in config but unreachable at runtime
|
||||
- [x] SSH host connects but non-interactive command execution fails
|
||||
- [x] Remote command returns malformed payload
|
||||
- [x] Installed package behaves differently from the in-repo test environment
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Prefer explicit, short failure copy in the status bar over hidden silent failures
|
||||
- [x] Keep the Sublime-side SSH execution boundary intentionally thin and replaceable by the Rust helper later
|
||||
## Current Status
|
||||
- Completed across commits `df5dd02`, `467e506`, `8cfb638`, `f65af09`, and the current remote-tool follow-up slice
|
||||
- Done: thin `ssh_runner` subprocess boundary, reusable transport error formatting, debug-only failed-SSH tracing via `SESSIONS_SSH_DEBUG`, explicit temporary-bootstrap documentation for `python3 -c` remote browse/read/write/tool steps, package-command/runtime smoke coverage, and explicit status-message splits for host probe, root probe, open, save, and tool execution failures
|
||||
- Remaining: none; issue ready to close once local/remote trackers are synchronized
|
||||
### Issue J
|
||||
#### Title
|
||||
`Phase 5: remote folder browser and workspace picker UX`
|
||||
#### Body
|
||||
## Goal
|
||||
Make workspace selection feel natural after `Connect Server`: connect to a host first, then inspect real remote directories and choose a workspace from actual server state.
|
||||
## Implementation Checklist
|
||||
- [x] Keep `Connect Remote Workspace` host-only
|
||||
- [x] Make `Open Remote Folder` query the real remote filesystem after host connection succeeds
|
||||
- [x] Start the workspace picker from a natural root such as the remote home directory
|
||||
- [x] Show selectable directory candidates from the remote host and allow drilling into child directories
|
||||
- [x] Keep manual path entry available as a fallback for advanced cases
|
||||
- [x] Preserve project materialization and automatic `.sublime-project` open after selection
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Home directory detection fails
|
||||
- [x] Parent-directory navigation from nested folders
|
||||
- [x] Directory listing contains files, symlinks, or unreadable entries
|
||||
- [x] Empty directories still remain selectable as workspaces
|
||||
- [x] Browse step succeeds but workspace validation fails
|
||||
## Manual UI / Product Decisions
|
||||
- [x] `Open Workspace` should behave like a workspace picker, not a host picker
|
||||
- [x] Prefer real directory suggestions/selection over forcing raw path typing
|
||||
- [x] Keep recent workspace reopening as the separate automatic `ssh + workspace` fast path
|
||||
## Current Status
|
||||
- Completed in commits: `df5dd02`, `467e506`, `8cfb638`
|
||||
- Done: host-only connect flow, recent-root-first browse start with `HOME` fallback, quick-panel directory drilling, manual absolute-path fallback, automatic project materialization, parent navigation, and browse handling for files, symlinks, and unreadable/other entries without breaking selection UX
|
||||
- Edge/test coverage now includes remote `HOME` lookup failure, nested parent navigation, files/symlinks/unreadable entries in listings, empty-directory selection, and browse-then-validate remote-root failure handling
|
||||
- Remaining: none; issue ready to close once local/remote trackers are synchronized
|
||||
### Issue K
|
||||
#### Title
|
||||
`Phase 5: helper-backed file transport execution in Sublime`
|
||||
#### Body
|
||||
## Goal
|
||||
Wire the previously defined file transport models into real installed-package behavior so remote browse/open/save paths stop being model-only.
|
||||
## Implementation Checklist
|
||||
- [x] Serialize directory/read/write requests over the chosen execution boundary
|
||||
- [x] Materialize opened remote files into the local cache from real remote bytes
|
||||
- [x] Reuse metadata/conflict policies during actual save attempts
|
||||
- [x] Surface permission-denied and remote-missing outcomes in the editor flow
|
||||
- [x] Add integration tests around cache materialization and save conflict paths where practical
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Helper/transport success but invalid response payload
|
||||
- [x] Remote save conflict detected during real write flow
|
||||
- [x] Opening a directory path as though it were a file
|
||||
- [x] Large or binary file refusal in the real installed-package flow
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Keep the cache/materialization semantics explicit even after real transport wiring exists
|
||||
- [x] Prefer safe failure over partial local writes when remote transport is ambiguous
|
||||
## Current Status
|
||||
- Completed in commits: `8cfb638` plus the current save-transport follow-up slice
|
||||
- Done: SSH-backed directory/read/write helpers, current-workspace `Open Remote File` and `Save Remote File` command wiring, sidecar baseline metadata tracking, reuse of existing save-conflict rules before write attempts, explicit status messaging for read/write transport failures and policy blocks, and regression tests for invalid payloads, directory opens, binary/large-file refusal, remote-missing saves, permission-denied saves, and metadata-change conflicts
|
||||
- Remaining: none; issue ready to close once local/remote trackers are synchronized
|
||||
### Issue L
|
||||
#### Title
|
||||
`Phase 5: installed-package remote tooling and diagnostics wiring`
|
||||
#### Body
|
||||
## Goal
|
||||
Connect formatter/linter execution and diagnostics presentation to real installed-package commands after the browse/file transport path is stable.
|
||||
## Implementation Checklist
|
||||
- [x] Dispatch formatter/linter requests from Sublime commands through the runtime boundary
|
||||
- [x] Parse real tool output into the existing diagnostics/output-plan models
|
||||
- [x] Populate readable output panels for formatter/linter runs
|
||||
- [x] Add initial inline diagnostic rendering for opened cached files
|
||||
- [x] Reuse per-workspace tool overrides in the real runtime path
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Missing tool on remote host during a real command run
|
||||
- [x] Tool emits diagnostics for unopened files in the installed-package flow
|
||||
- [x] Formatter changes the file while the buffer is dirty
|
||||
- [x] Long stderr or timeout in a real remote tool execution path
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Keep output readable before chasing perfect inline rendering
|
||||
- [x] Preserve a clear distinction between formatter mutations and diagnostic-only runs
|
||||
## Current Status
|
||||
- Completed after `f65af09` plus the current tool-runtime slice
|
||||
- Done: real remote format/lint command dispatch from Sublime, SSH-backed tool execution with timeout/tool-not-found handling, readable output-panel rendering, initial inline diagnostic region application for opened cached files, formatter refresh of local cache after success, diagnostics summaries for unopened cache files, and regression coverage for missing-tool, timeout, override reuse, dirty-buffer formatter blocking, and inline/panel presentation
|
||||
- Remaining: none; issue ready to close once local/remote trackers are synchronized
|
||||
### Issue M
|
||||
#### Title
|
||||
`Phase 5: Rust bridge/helper transport pivot for remote tree and file execution`
|
||||
#### Body
|
||||
## Goal
|
||||
Replace the current Python-side `ssh_runner.py` + `ssh_file_transport.py` bootstrap path with a real Rust `local_bridge` + `session_helper` stdio transport for tree browsing and file read/write/stat operations while keeping the Sublime UI in Python.
|
||||
## Implementation Checklist
|
||||
- [x] Extend `session_protocol` with explicit payloads for:
|
||||
- `tree/list`
|
||||
- `file/read`
|
||||
- `file/stat`
|
||||
- `file/write`
|
||||
- [x] Add real binary entrypoints for:
|
||||
- `local_bridge`
|
||||
- `session_helper`
|
||||
- [x] Make the helper emit a real handshake and handle one request/response cycle over stdio
|
||||
- [x] Implement helper-side handlers for:
|
||||
- tree listing
|
||||
- file read
|
||||
- file stat
|
||||
- file write
|
||||
- [x] Upload and launch the helper over SSH from the local bridge
|
||||
- [x] Keep Python command/UI contracts stable while swapping transport internals
|
||||
- [x] Restore a persistent `Sessions Remote Tree` view on top of the new list transport
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Handshake mismatch or malformed protocol data
|
||||
- [x] Helper exits before returning a response
|
||||
- [x] Directory listing succeeds but returns unexpected payload shape
|
||||
- [x] File read body encoding is corrupted
|
||||
- [x] File write detects metadata drift before overwrite
|
||||
- [x] Python falls back safely when the Rust bridge is unavailable
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Keep Sublime commands/views in Python for now
|
||||
- [x] Prefer an on-demand uploaded helper over requiring a manually preinstalled remote daemon
|
||||
- [x] Allow a Python SSH fallback during the migration instead of breaking existing users immediately
|
||||
## Current Status
|
||||
- Completed in commits: `f6f1008`, `bebc020`
|
||||
- Done: shared tree/file protocol payloads, real `local_bridge` and `session_helper` stdio binaries, bridge-side helper upload/launch and handshake validation, helper-side tree/read/stat/write handlers, Python-side Rust-bridge transport preference with bootstrap fallback, restored persistent remote tree view commands, and regression coverage across Rust and Python layers
|
||||
- Remaining: remove the fallback bootstrap once shipped binaries and package-local bridge discovery are in place
|
||||
### Issue N
|
||||
#### Title
|
||||
`Phase 5: Rust binary packaging and installation flow`
|
||||
#### Body
|
||||
## Goal
|
||||
Turn the current development-only `cargo build` assumption into a real install story where `Sessions` ships the correct local Rust bridge binary, uploads the matching remote helper on demand, and does not require end users to have Cargo installed.
|
||||
## Implementation Checklist
|
||||
- [x] Define the final local package layout for shipped binaries by platform/arch
|
||||
- [x] Decide how release builds produce:
|
||||
- the Sublime package
|
||||
- the local bridge binary
|
||||
- the remote helper binary
|
||||
- [x] Make the Sublime package discover shipped binaries before trying any dev-only build fallback
|
||||
- [x] Define the helper upload cache/install path and replacement policy on the remote host
|
||||
- [x] Document version matching between the shipped local bridge and uploaded helper
|
||||
- [x] Document unsupported combinations and failure messaging for missing platform builds
|
||||
- [x] Add release/build automation for packaging the binaries alongside the Sublime package archive
|
||||
## Edge Cases and Test Scope
|
||||
- [x] User installs the package without a repository checkout or Cargo on PATH
|
||||
- [x] Local platform/arch has no bundled bridge build
|
||||
- [x] Remote helper upload path is not writable
|
||||
- [x] Bundled helper version does not match the local bridge version
|
||||
- [x] Old helper copy remains on the remote host after an upgrade
|
||||
## Manual UI / Product Decisions
|
||||
- [x] End users should not need Rust or Cargo to use `Sessions`
|
||||
- [x] The local bridge should ship with the package; the remote helper should upload on demand
|
||||
- [x] Keep remote installation ephemeral or cacheable, but not daemonized
|
||||
## Current Status
|
||||
- Completed in commits: `158b999`, `eada0a8`, `6a5f731`, `d7b40e6`, `3e95b84`, `68585fb`, `057d1f7`, `3f300bb`, `770f12f`
|
||||
- Done: package-local Rust binary discovery now precedes repo-local `target/debug/*` lookup; the runtime stores a host-local remote Linux helper target cache, auto-detects the target with `uname -s` / `uname -m` after SSH attach, falls back to a quick panel only when auto-detection cannot map to a supported helper, resolves the remote helper by host-selected Linux target, uploads the helper into a versioned remote cache path, rejects mismatched helper handshakes, preserves upload stderr for actionable failures, and now splits release archives into distinct `sessions/bin/local-bridge/<platform-tag>/` and `sessions/bin/remote-helper/<platform-tag>/` bundle roots with a compatibility fallback for legacy same-platform bundles
|
||||
- Edge/test coverage now includes shipped-pair resolution without Cargo or a repository checkout, missing-bundle fallback on unsupported local platforms, versioned remote-helper cache slot behavior across upgrades, mismatched helper-handshake rejection, preserved remote upload stderr for permission-denied helper cache paths, host-level remote Linux target persistence, automatic remote Linux target detection, quick-panel fallback when detection fails, and host-aware bridge/helper resolution against the split package layout
|
||||
- Remaining: none; issue closed in Gitea and synchronized with the local tracker
|
||||
### Issue O — [#17](https://git.teahaven.kr/sublime-rs/sessions/issues/17) (closed)
|
||||
#### Title
|
||||
`Phase 6: remote directory explorer window (open/close from UI)`
|
||||
#### Body
|
||||
## Goal
|
||||
Provide a first-class **remote directory explorer** in Sublime: a dedicated narrow pane for browsing the remote workspace tree, **opening** selected files into a separate editor column, and **closing** remote-backed buffers without leaving the explorer workflow.
|
||||
|
||||
## Implementation Checklist
|
||||
- [x] Apply a stable two-column `set_layout` (explorer group + editor group) from a palette command (`Sessions: Open Remote Directory Explorer` / `sessions_open_remote_directory_explorer`)
|
||||
- [x] Host the existing Sessions remote tree scratch view in the explorer group and bind an editor target group for `open_file` (`sessions_remote_tree_editor_group`, editor group `1`)
|
||||
- [x] Open remote files into the editor column while preserving the legacy single-pane `Open Remote Tree` behavior when explorer mode is not used (plain tree clears `sessions_remote_tree_editor_group`)
|
||||
- [x] Add explicit close affordances: palette command for the active remote cache file; optional tree keybinding to close the selected file if it is open (`Sessions: Close Remote File`, `Default.sublime-keymap` Backspace when tree focused)
|
||||
- [x] Document the command palette entries and manual QA for layout + open + close (`Sessions.sublime-commands`; manual QA bullets below)
|
||||
|
||||
## Manual QA (layout + open + close)
|
||||
- Connect + open remote workspace, run **Sessions: Open Remote Directory Explorer**: expect two columns, tree in the narrow column, `open_file` targets the wide column.
|
||||
- From the tree, open a file: buffer appears in the editor column; **Sessions: Open Remote Tree** without explorer still opens tree without forcing group `1`.
|
||||
- **Sessions: Close Remote File** with tree focused on a file row: matching cache tab closes; with a remote cache buffer focused: that view closes.
|
||||
- Backspace on the tree (read-only): should run close command when the keymap context matches.
|
||||
|
||||
## Edge Cases and Test Scope
|
||||
- [x] Window without `set_layout` / `run_command` (test doubles): `FakeWindow` records commands; `_apply_remote_directory_explorer_layout` returns false if `run_command` is missing
|
||||
- [x] User already customized layout; re-run explorer command is idempotent or non-destructive enough: re-run reapplies the same `set_layout` (overwrites custom layout — acceptable for v1; noted on Gitea #17 body)
|
||||
- [x] Open same remote file twice (reuse tab vs new view): Sublime `open_file` default (typically focuses existing tab)
|
||||
- [x] Close when file is dirty: Sublime default dirty-close behavior applies when the user closes the view
|
||||
- [x] Explorer focused vs editor focused; tree refresh keeps `sessions_remote_tree_editor_group` metadata (`SessionsRemoteTreeRefreshCommand` preserves group)
|
||||
|
||||
## Manual UI / Product Decisions
|
||||
- [x] Explorer uses the existing read-only tree scratch view; no fake sidebar API *(primary UX moved to Phase 6.1: real sidebar via mirrored cache paths — see Issue P)*
|
||||
- [x] Prefer explicit commands over magic global save hooks for close (`Sessions: Close Remote File`); remote save-after-local-save remains `on_post_save` for cache files
|
||||
- [x] Default keybinding only where `setting.sessions_remote_tree` is true (Backspace → `sessions_close_remote_file`)
|
||||
|
||||
## Current Status
|
||||
- **Gitea**: [#17](https://git.teahaven.kr/sublime-rs/sessions/issues/17) — scratch+split explorer; remains available as secondary UX.
|
||||
- **Landings**: `9d5b4fe` (planning), `1d6ddde` (implementation + tests + palette + keymap).
|
||||
- **Direction change**: Sublime has no custom sidebar tree API ([sublimehq/sublime_text#867](https://github.com/sublimehq/sublime_text/issues/867)); **Phase 6.1 / Issue P** implements `mirror_tree` under the workspace cache root and merges that path into the project `folders` so the **native** sidebar shows the remote layout.
|
||||
|
||||
### Issue P — Phase 6.1: Native sidebar remote directory (`mirror_tree`)
|
||||
#### Title
|
||||
`Phase 6.1: native sidebar remote tree (cache mirror + project folders)`
|
||||
#### Body (bootstrap)
|
||||
## Goal
|
||||
Show the remote workspace in Sublime’s **native** sidebar by mirroring `list_directory` results into the local Sessions cache tree and adding that cache root to the window’s project `folders`.
|
||||
|
||||
## Implementation Checklist
|
||||
- [x] BFS mirror with `sessions_mirror_max_traversal_depth`, `sessions_mirror_max_entries`, optional file placeholders (`remote_cache_mirror.py`)
|
||||
- [x] Merge/remove Sessions-owned `folders` entry for resolved cache root (`sidebar_project_folders.py`)
|
||||
- [x] Palette: **Sessions: Sync Remote Tree to Sidebar**; **Sessions: Remove Sessions Sidebar Folder**
|
||||
- [x] `Sessions.sublime-settings`; background thread + UI-thread `set_project_data` / `set_sidebar_visible`
|
||||
- [x] Tests for mirror, merge, commands; manual QA below
|
||||
|
||||
## Manual QA
|
||||
- After workspace connect, **Sessions: Sync Remote Tree to Sidebar**: native sidebar lists mirrored cache; open file from sidebar uses local cache path (full fetch on open if needed).
|
||||
- Re-run sync: single `folders` entry for cache root; status reports truncation if `max_entries` hit.
|
||||
- **Remove Sessions Sidebar Folder**: drops cache path from `folders` only.
|
||||
|
||||
## Current Status
|
||||
- **Gitea**: [#18](https://git.teahaven.kr/sublime-rs/sessions/issues/18) closed after verification; **[#20](https://git.teahaven.kr/sublime-rs/sessions/issues/20)** (remote agent JSON envelope + panel UX) **closed**.
|
||||
|
||||
### Issue Q — [#20](https://git.teahaven.kr/sublime-rs/sessions/issues/20) (closed)
|
||||
#### Title
|
||||
`Phase next: remote agent → editor payload (SSH JSON envelope)`
|
||||
#### Body (summary)
|
||||
Remote agent computes diff/patch; Sublime receives a **versioned JSON** envelope over SSH and validates it before any UI (`sessions.agent_remote_payload`). This issue is the canonical end-to-end implementation path (transport wiring + output-panel UX + command-level integration), not a parser-only tracker.
|
||||
|
||||
#### Current status
|
||||
- **Closed.** Landed: `parse_agent_editor_envelope_from_stdout`, stricter v1 validation, `Sessions: Preview Remote Agent Payload` → output panel + failure copy; tests in `test_agent_remote_payload.py` / `test_commands.py`.
|
||||
- **Not** the main product track ahead of **[#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30)** (remote-SSH-parity dev MVP).
|
||||
- Broader diff-centric **product** review: [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29).
|
||||
|
||||
### Issue V — [#30](https://git.teahaven.kr/sublime-rs/sessions/issues/30) (**Phase 6.2** milestone; **closed** after MVP slice)
|
||||
#### Title
|
||||
`MVP: Remote-SSH-parity dev environment (LSP + remote language tooling)`
|
||||
#### Body (summary)
|
||||
**Execution front:** Remote **LSP** (e.g. pyright), **Ruff** + formatters, diagnostics on **open/save**, missing-tool UX, and a minimal **multi-tool / ordered servers** policy — so daily dev on a remote host matches **VS Code Remote-SSH** expectations **before** agent-heavy editor work. Canonical tracker for Issue F “real LSP process integration” remainder.
|
||||
|
||||
#### Landed (MVP slice; follow-ups welcome)
|
||||
- Transport + protocol: [`planning/REMOTE_DEV_MVP_LSP.md`](REMOTE_DEV_MVP_LSP.md), `LSP_PROXY_METHOD_NAME` in `session_protocol`.
|
||||
- **Save / optional open:** `SessionsRemotePythonPipelineListener`, `sessions_remote_python_*` settings, ordered `sessions_remote_python_tool_pipeline`, merged diagnostics + dedupe.
|
||||
- **Pyright CLI:** `build_python_pyright_tool_execution_request` (120s default timeout); long-lived LSP stdio deferred per doc.
|
||||
|
||||
### Issue U — [#25](https://git.teahaven.kr/sublime-rs/sessions/issues/25)
|
||||
#### Title
|
||||
`Follow-up: local_bridge helper session hard-timeout and child kill policy`
|
||||
#### Body (summary)
|
||||
Finish Rust-side lifecycle hardening for `local_bridge` so upload/handshake/request/shutdown each have explicit timeout budgets and forced terminate/kill fallback. This is the transport reliability gate before broader Python-side bootstrap removal.
|
||||
|
||||
### Issue R — [#22](https://git.teahaven.kr/sublime-rs/sessions/issues/22)
|
||||
#### Title
|
||||
`Phase next: remote explorer-first sync, auto-open flow, and SSH terminal attach`
|
||||
#### Body (summary)
|
||||
Prioritize explorer responsiveness and workflow defaults around `Connect` → `Open Remote Folder`:
|
||||
|
||||
## Goal
|
||||
- Sidebar/top tree should appear quickly and keep filling in the background.
|
||||
- Opening one remote file during BFS should prioritize that file's cache/hydrate path.
|
||||
- `Open Remote Folder` should auto-trigger mirror sync (explicit sync command removed).
|
||||
- Connect should open a dedicated remote window immediately and provide clear "not yet folder-opened" CTA.
|
||||
- Terminal opened in that workspace should attach to the matching SSH session by default.
|
||||
|
||||
## Implementation Checklist
|
||||
- [x] **Priority file open while BFS runs**: explicit `Open Remote File` now bypasses mirror latency and announces prioritized fetch while mirror is in flight.
|
||||
- [x] **On-open auto sync**: after `Open Remote Folder` success, schedule sync automatically.
|
||||
- [x] **Remove manual sync command from palette** (`Sessions: Sync Remote Tree to Sidebar`) and rewire command-palette tests.
|
||||
- [x] **Change polling**: periodic lightweight remote refresh (configurable interval/backoff) with safe caps.
|
||||
- [x] **Connect UX**: open a dedicated window right after host connect; show explicit banner/status and auto-run `Open Remote Folder`.
|
||||
- [x] **Open-folder fast path**: host connect now jumps directly into folder picker (one Enter after host select).
|
||||
- [x] **Terminal attach**: add `Sessions: Open Remote Terminal` with workspace host/root-aware SSH attach command.
|
||||
|
||||
## Edge Cases / Test Scope
|
||||
- [x] Priority hydrate request arrives for path excluded by mirror ignore patterns. *(tested: `test_open_remote_file_succeeds_for_ignored_path`)*
|
||||
- [ ] Priority request races with existing placeholder hydration and metadata sidecar writes.
|
||||
- [ ] Auto-sync starts before project data is ready / window focus changes.
|
||||
- [x] Background periodic refresh collides with explicit open/save operations. *(tested: `test_auto_refresh_skipped_when_manual_sync_in_flight`, `test_manual_sync_reports_already_running_when_auto_in_flight`)*
|
||||
- [ ] Terminal attach fails (SSH unavailable, stale host session, expired auth) with actionable fallback.
|
||||
- [x] Multiple windows same workspace: one refresh loop policy and dedupe strategy. *(implemented: cache-key dedup in `_start_mirror_auto_refresh_loop` / `_start_open_file_watch_loop`; tested: `test_two_windows_same_workspace_single_mirror_inflight`)*
|
||||
|
||||
## Manual UI / Product Decisions
|
||||
- [x] If auto-sync fails after folder open, keep window state and show retry affordance (no silent rollback). *(tested: `test_auto_sync_failure_emits_disconnected_status_not_crash`)*
|
||||
- [ ] Prefer "first visible tree quickly" over strict consistency; reconcile in later passes.
|
||||
- [ ] Keep terminal attach transparent: show target host/root/session in status/output.
|
||||
|
||||
### Issue S — [#23](https://git.teahaven.kr/sublime-rs/sessions/issues/23)
|
||||
#### Title
|
||||
`Stale cache reconciliation (mirror prune) + remote-deleted file open UX + Terminus panel SSH`
|
||||
#### Body (summary)
|
||||
When the remote tree drops files or directories, the local mirror cache must converge; opening a path that only exists locally should explain the situation, remove stale bytes, and keep Terminus sessions in the bottom panel with a persistent interactive shell.
|
||||
|
||||
## Implementation Checklist
|
||||
- [x] **Mirror prune**: after each remote directory listing, delete local children not present remotely (optional via `sessions_mirror_prune_stale_cache`).
|
||||
- [x] **Open / hydrate**: classify `ENOENT` / `lstat_failed` as `OpenOutcome.REMOTE_NOT_FOUND`; show `message_dialog`, delete cache + sidecar, close open view when possible.
|
||||
- [x] **Terminus**: `terminus_open` with `show_in_panel`, `panel_name`, `auto_close: false`, `cmd: [ssh, -tt, host, remote_shell]` (interactive PTY); `new_terminal` fallback uses the same remote command string.
|
||||
- [x] **Tests**: mirror prune regression, transport classification, command-level stale-open UX, Terminus vs fallback.
|
||||
|
||||
## Edge Cases / Test Scope
|
||||
- [x] Stale file at workspace root vs nested directory; prune removes only under cache root.
|
||||
- [x] `prune_missing` disabled keeps old files (setting respected).
|
||||
- [x] Remote missing heuristic rejects unrelated transport strings.
|
||||
- [x] Truncated mirror (entry limit): prune skipped for that pass (no partial deletes). *(Rust: `mirror_skips_prune_when_truncated_by_entry_limit`; Python: `test_truncated_mirror_result_keeps_stale_cache_and_shows_status`)*
|
||||
- [x] Symlink or permission edge cases inside cache. *(Rust: 5 prune edge case tests — dangling symlink, outside-anchor symlink, readonly file, readonly dir, mixed entries; Python: `test_remove_cache_mirror_path_dangling_symlink`, `test_remove_cache_mirror_path_regular_directory`)*
|
||||
|
||||
## Manual UI / Product Decisions
|
||||
- [ ] Confirm Terminus panel name matches user theme (`Terminus` default).
|
||||
- [ ] If SSH fails to allocate a TTY, surface stderr in the panel instead of an instant close.
|
||||
|
||||
### Issue T — [#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24)
|
||||
#### Title
|
||||
`Architecture: keep Sublime Python thin; migrate core logic to Rust (bindings + parity tests)`
|
||||
#### Body (summary)
|
||||
Establish a documented Python/Rust boundary ([`planning/PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md)) and move algorithms out of the plugin host into Rust crates with **parity tests** versus existing Python `pytest` scenarios before deleting Python duplicates.
|
||||
|
||||
## Implementation Checklist
|
||||
- [x] Planning doc: explicit split + integration options (in-process `cdylib`/PyO3 vs bridge protocol).
|
||||
- [x] **First migration slice**: `remote_cache_mirror` Rust crate + `tests/python_parity.rs` aligned with `sublime/tests/test_remote_cache_mirror.py`.
|
||||
- [ ] **Python delegation**: replace `mirror_remote_tree_to_local_cache` body with FFI/subprocess/bridge call (choose per `PYTHON_RUST_BOUNDARY.md`); remove duplicated Python once bound.
|
||||
- [ ] **Next candidates** (inventory): `ssh_runner` transport policy, `file_state` open/save evaluation (pure rules), `agent_remote_payload` validation (already schema-like; expand in Rust), path mapping helpers beyond `workspace_identity`.
|
||||
- [ ] CI: keep `cargo test --workspace` + `pytest` in lockstep for migrated areas.
|
||||
|
||||
## Edge Cases
|
||||
- [ ] Windows path semantics if any Rust mirror API exposes `Path` (Linux-first for Sessions).
|
||||
- [ ] Glob/fnmatch parity: Rust `glob` + regex vs Python `fnmatch` — extend vectors if user reports mismatches.
|
||||
@@ -1,261 +0,0 @@
|
||||
# Remote Jupyter Hosting Plan
|
||||
|
||||
Let the user open an `.ipynb` file in the Sessions workspace and have it
|
||||
loaded by a **remote Jupyter server** that Sessions manages — UI runs in
|
||||
the user's local browser, tunneled via SSH / AWS SSM port forwarding.
|
||||
The remote machine owns the kernel, filesystem, and runtime deps; the
|
||||
user's machine is just a web client.
|
||||
|
||||
Status: design only. Not yet implemented.
|
||||
|
||||
---
|
||||
|
||||
## Why external browser (not in-Sublime rendering)
|
||||
|
||||
The user asked whether we can render the Jupyter page inside Sublime
|
||||
itself. Technical verdict: **no, not practically**.
|
||||
|
||||
- Sublime Text's plugin API has no embedded web view. `sublime.View`
|
||||
edits text buffers; there is no HTML render target exposed from the
|
||||
plugin side.
|
||||
- `sublime.View.show_popup(html)` accepts a very restricted HTML
|
||||
subset — no JavaScript, no iframe, no fetch. Jupyter's UI is built
|
||||
on React + WebSocket to the kernel; it simply cannot run inside
|
||||
``show_popup``.
|
||||
- Embedding a browser engine (CEF, Qt WebEngine, wxWebView) from a
|
||||
Sublime plugin is not possible without shipping a native binary and
|
||||
calling it out-of-process. That defeats the "open the page in
|
||||
Sublime" goal — it's just another window.
|
||||
- Screenshot / thumbnail rendering of the remote page is possible via
|
||||
a headless browser on the remote, but any interaction (click a
|
||||
cell, edit code, run) breaks immediately. A notebook is inherently
|
||||
interactive; static images are not useful.
|
||||
|
||||
Decision: Jupyter hosting opens in the user's default browser
|
||||
(`webbrowser.open(url)`). All upside for almost no implementation cost
|
||||
compared to the embedding route. The Sessions plugin manages the
|
||||
server lifecycle and the tunnel; the browser is just a dumb client.
|
||||
Same model VSCode Remote uses.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Installer: `sessions_install_remote_jupyter`
|
||||
|
||||
Reuses the same ``bash -lc`` remote-install plumbing already used for
|
||||
``pyright`` / ``ruff`` (see ``sublime/sessions/managed_remote_lsp_catalog.py``).
|
||||
Install script (Amazon Linux / Debian / Fedora-agnostic):
|
||||
|
||||
```sh
|
||||
set -e
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "python3 required on remote"; exit 1
|
||||
fi
|
||||
if python3 -m pip install --user jupyter-server notebook; then exit 0; fi
|
||||
if command -v pip3 >/dev/null 2>&1 && pip3 install --user jupyter-server notebook; then exit 0; fi
|
||||
```
|
||||
|
||||
Uninstall: `python3 -m pip uninstall -y jupyter-server notebook`.
|
||||
|
||||
Probe: `python3 -m jupyter server --version` (prints semver, exit 0 ==
|
||||
available). Cached in the same workspace status panel that already
|
||||
shows pyright/ruff status.
|
||||
|
||||
Adds one more entry to `BUILTIN_MANAGED_REMOTE_LSP_CATALOG` — not as
|
||||
an LSP server but riding the same installer abstraction. May rename
|
||||
the catalog to ``BUILTIN_MANAGED_REMOTE_TOOL_CATALOG`` or keep "LSP"
|
||||
and document that Jupyter is a non-LSP passenger.
|
||||
|
||||
### 2. Server session manager
|
||||
|
||||
New module `sublime/sessions/jupyter_hosting.py`. A single
|
||||
`JupyterSessionManager` holds one session per (host_alias, workspace).
|
||||
|
||||
#### Start
|
||||
|
||||
```python
|
||||
def start_session(context: _WorkspaceContext) -> JupyterSession:
|
||||
token = secrets.token_urlsafe(24)
|
||||
remote_port = 0 # let jupyter pick
|
||||
argv = [
|
||||
"python3", "-m", "jupyter", "server",
|
||||
"--no-browser",
|
||||
"--ServerApp.token=" + token,
|
||||
"--ServerApp.port=0", # random free port
|
||||
"--ServerApp.port_retries=0",
|
||||
"--ServerApp.notebook_dir=" + context.recent_entry.remote_root,
|
||||
"--ServerApp.ip=127.0.0.1",
|
||||
"--ServerApp.allow_origin=http://localhost:*",
|
||||
]
|
||||
# Run via session_helper's exec channel; session_helper prefixes a
|
||||
# UUID to stdout lines we can match on.
|
||||
session_id = secrets.token_hex(8)
|
||||
submit_remote_exec_once(
|
||||
host_alias, argv, cwd=context.recent_entry.remote_root,
|
||||
tag="jupyter-server-" + session_id,
|
||||
)
|
||||
# Parse jupyter startup banner (``[C 2026-…] ... at http://127.0.0.1:<port>/``)
|
||||
# to discover the actual port. ``ServerApp.port=0`` means we don't
|
||||
# know the port until jupyter tells us.
|
||||
remote_port = _parse_jupyter_banner_for_port(stdout_stream)
|
||||
return JupyterSession(host_alias, session_id, token, remote_port)
|
||||
```
|
||||
|
||||
Key design points:
|
||||
- **Let Jupyter pick the port** (`--ServerApp.port=0`). Otherwise we
|
||||
race against other users / other notebooks. Parse stdout for the
|
||||
actual bind.
|
||||
- **Random token** prevents drive-by access to the remote server via
|
||||
any leaked tunnel.
|
||||
- **Bind to 127.0.0.1 only** on the remote. The tunnel exposes it
|
||||
locally; external attackers on the remote's network can't reach it.
|
||||
- **Run under the SSH session_helper exec channel**, not a detached
|
||||
nohup'd process. When the user disconnects the workspace, we kill
|
||||
the jupyter PID so nothing leaks.
|
||||
|
||||
#### Stop
|
||||
|
||||
```python
|
||||
def stop_session(session: JupyterSession) -> None:
|
||||
submit_remote_exec_once(
|
||||
session.host_alias,
|
||||
["sh", "-c", "pkill -u $USER -f 'jupyter server.*ServerApp.port=" + str(session.remote_port) + "'"],
|
||||
)
|
||||
# also close the SSH port forward (see below)
|
||||
```
|
||||
|
||||
### 3. SSH port forwarding
|
||||
|
||||
Separate from the persistent bridge because the bridge's SSH child
|
||||
owns its stdin/stdout for NDJSON protocol — we can't mix stream types.
|
||||
Spawn a dedicated ``ssh -L <localPort>:127.0.0.1:<remotePort> <host>
|
||||
-N`` child.
|
||||
|
||||
```python
|
||||
def open_port_forward(host_alias, remote_port):
|
||||
local_port = _pick_free_local_port()
|
||||
argv = [
|
||||
"ssh", "-N",
|
||||
"-L", f"{local_port}:127.0.0.1:{remote_port}",
|
||||
host_alias,
|
||||
]
|
||||
child = subprocess.Popen(argv, ...)
|
||||
return PortForward(local_port, child)
|
||||
```
|
||||
|
||||
OpenSSH respects the user's `~/.ssh/config` for ProxyCommand/Match
|
||||
(same as v0.4.14's `ssh -G` handling), so AWS SSM ProxyCommand hosts
|
||||
work transparently — the SSM tunnel established by ProxyCommand
|
||||
carries the `-L` forward.
|
||||
|
||||
Cleanup: `child.terminate()` on disconnect / Sublime exit.
|
||||
|
||||
### 4. File-type hook
|
||||
|
||||
Two entry points:
|
||||
|
||||
**(a) Explicit command** `SessionsOpenNotebookInJupyter` on the command
|
||||
palette and right-click on `.ipynb` files in the sidebar:
|
||||
|
||||
```python
|
||||
class SessionsOpenNotebookInJupyter(sublime_plugin.WindowCommand):
|
||||
def run(self, file):
|
||||
rel = _workspace_relative_path(file) # e.g. "notebooks/explore.ipynb"
|
||||
url = f"http://localhost:{local_port}/notebooks/{rel}?token={token}"
|
||||
webbrowser.open(url)
|
||||
```
|
||||
|
||||
**(b) Automatic redirect** on `open_file` for `.ipynb` via a new
|
||||
``on_window_command`` listener (priority ordering: runs before the
|
||||
existing on-demand fetch listener because the notebook filesystem is
|
||||
owned by jupyter server, not Sessions' mirror):
|
||||
|
||||
```python
|
||||
def on_window_command(self, window, cmd, args):
|
||||
if cmd != "open_file":
|
||||
return None
|
||||
path = args.get("file", "")
|
||||
if not path.endswith(".ipynb"):
|
||||
return None
|
||||
return ("sessions_open_notebook_in_jupyter", {"file": path})
|
||||
```
|
||||
|
||||
Status bar status: "Sessions: notebook opened in browser at
|
||||
<url>" — gives the user the fallback URL in case the default browser
|
||||
is misconfigured.
|
||||
|
||||
### 5. Tunnel-URL handoff to Cmd+click
|
||||
|
||||
The Cmd+click listener already opens any URL via ``webbrowser.open``,
|
||||
so ``http://localhost:<port>/…`` URLs that appear in terminal output
|
||||
(e.g., someone prints the Jupyter URL from a shell script) Just Work™
|
||||
— no extra integration work. The tunnel is already up because the
|
||||
notebook session manager opened it.
|
||||
|
||||
## Phasing
|
||||
|
||||
**Phase 1** — install + manual open:
|
||||
- Remote install / uninstall / probe via managed-tool catalog.
|
||||
- `SessionsOpenNotebookInJupyter` command on the palette.
|
||||
- Port forward + browser launch on-demand.
|
||||
- Single session per workspace.
|
||||
- No automatic `.ipynb` redirect.
|
||||
|
||||
**Phase 2** — automatic redirect + lifecycle:
|
||||
- `on_window_command` intercept of `.ipynb` open.
|
||||
- Cleanup on workspace disconnect / Sublime quit.
|
||||
- Progress panel mirror for first-time server spawn (takes a few
|
||||
seconds on cold AWS SSM).
|
||||
|
||||
**Phase 3** — nice-to-have:
|
||||
- Multiple sessions (one per subproject).
|
||||
- Kernel management UI (list running, restart, stop).
|
||||
- Replace port-forward SSH child with AWS SSM native
|
||||
`AWS-StartPortForwardingSession` when the host is SSM-only (avoids
|
||||
the second SSH process).
|
||||
|
||||
## Known unknowns
|
||||
|
||||
- **Jupyter auth UX**: passing the token in the query string works for
|
||||
the initial nav but the user may want to bookmark the URL without
|
||||
the token. JupyterLab supports cookies — first page load sets a
|
||||
cookie from the query token and subsequent visits skip the auth
|
||||
page. Verify on install.
|
||||
- **Proxy/corporate firewall**: some networks block `localhost:N`
|
||||
loopbacks in the user's browser (I doubt it but worth a sanity
|
||||
check). If reported, offer a setting to use a different bind IP
|
||||
(``127.0.0.1`` vs ``::1`` vs a loopback alias).
|
||||
- **Port-forward reliability on AWS SSM**: SSM has an undocumented
|
||||
channel-idle timeout (default 20 min). Need keepalive — either
|
||||
ServerAliveInterval (already applied via v0.4.14) or a heartbeat
|
||||
HTTP request from our side. Will observe once running.
|
||||
- **SSL**: Jupyter supports HTTPS on the server side. For localhost
|
||||
tunnel, HTTP is fine (traffic encrypted by the SSH tunnel). Skip
|
||||
the SSL setup complexity.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- In-process Jupyter kernels. Sessions proxies an existing Jupyter
|
||||
install, doesn't re-implement IPython kernel management.
|
||||
- Offline / airplane mode. Needs remote connection by definition.
|
||||
- Notebook diff, merge, nbconvert integrations. Orthogonal.
|
||||
- Sublime ``.ipynb`` as a text buffer. If the user genuinely wants
|
||||
the raw JSON, ``SessionsOpenRemoteFile`` still works — the auto
|
||||
redirect only triggers on `open_file` with the default handler,
|
||||
and we let the explicit path-based open pass through unchanged.
|
||||
|
||||
## Dependency / risk summary
|
||||
|
||||
Nothing new in Rust. All Python. Relies on:
|
||||
|
||||
- ``jupyter-server`` on the remote (user installs via our command).
|
||||
- ``ssh`` on the user's machine (already required).
|
||||
- ``webbrowser`` stdlib (cross-platform).
|
||||
|
||||
Risk surface: port-forward child lifecycle (orphan processes if
|
||||
Sublime crashes), remote Jupyter log parsing (format may change
|
||||
across Jupyter versions — pin to ``jupyter-server ≥ 2.0``), AWS SSM
|
||||
port-forward latency (inherits the same ~100-200ms RTT as our bridge).
|
||||
|
||||
If any of these risks materialize, fallback is "user runs jupyter
|
||||
manually and pastes the URL; we just Cmd+click it" — the
|
||||
`webbrowser.open` path always works regardless of our server manager.
|
||||
170
planning/MACOS_BATCH_2_FIXES.md
Normal file
170
planning/MACOS_BATCH_2_FIXES.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# MACOS_BATCH_2_FIXES — v0.6.1 re-test (2026-04-25)
|
||||
|
||||
Second macOS test pass surfaced a mix of (a) issues from batch 1 the user
|
||||
still sees (because they ran against an unpulled checkout), (b) new
|
||||
issues not in batch 1, and (c) UX asks sized small enough to batch.
|
||||
|
||||
Batch 1 commits already on `main`:
|
||||
- `9c59fc6` agent tmux `-d` (fixes `not a terminal`)
|
||||
- `fa41c4d` eager hydrate at sync.done
|
||||
- `2cff39b` expand-deferred hint while mirror deepening
|
||||
- `0ae4214` silence "Deepening mirror" on auto-refresh
|
||||
- `d6c809d` interpreter picker Back row to top
|
||||
|
||||
**Action required on the tester's side:** `git pull origin main` +
|
||||
restart Sublime. Without this, the agent + eager hydrate + picker fixes
|
||||
won't take effect locally.
|
||||
|
||||
---
|
||||
|
||||
## Issue clusters (assigned to independent subagents)
|
||||
|
||||
### Cluster A — LSP crash storm at Sublime startup
|
||||
|
||||
```
|
||||
LSP: LSP-pyright crashed (1 / 5 times in the last 180.0 seconds), exit code 1
|
||||
LSP: LSP-pyright crashed (2 / 5 times in the last 180.0 seconds), exit code 1
|
||||
...
|
||||
LSP: LSP-ruff crashed (1 / 5 times in the last 180.0 seconds), exit code 1
|
||||
...
|
||||
SublimeLinter: WARNING: cannot locate 'ruff'. Fill in the 'python' or 'executable' setting.
|
||||
```
|
||||
|
||||
Fires BEFORE the user does anything. Kills LSP servers for the session
|
||||
until user manually re-enables.
|
||||
|
||||
- **Files to inspect**: `sublime/sessions/lsp_project_wiring.py`,
|
||||
plugin_loaded path in `sublime/sessions/commands.py`,
|
||||
`managed_remote_extension_catalog.py` probe wiring.
|
||||
- **Hypothesis**: Sessions auto-spawns LSP-pyright via bridge stdio
|
||||
before the bridge/broker is ready. The `LSP-pyright` /
|
||||
`LSP-ruff` clients start at Sublime boot, attempt to launch the
|
||||
stdio process, bridge is mid-handshake → stdio child exits 1 → LSP
|
||||
package retries 5 times then gives up.
|
||||
- **Done-when**: LSP-pyright / LSP-ruff start successfully on the
|
||||
first try after Sublime opens a Sessions workspace, OR they are
|
||||
deferred until the bridge handshake completes.
|
||||
|
||||
### Cluster B — Hover link: Cmd+click fails to open + URL pattern gaps
|
||||
|
||||
- Absolute path hover paints box but **Cmd+click does not open the
|
||||
file**. Before batch 1, hover regex matched but no paint. Now the
|
||||
paint works, click is broken.
|
||||
- `localhost:8080` pattern not recognized (missing scheme-less URL).
|
||||
- Relative paths (basenames from `ls`) still not detected — tracked as
|
||||
M1 but worth including now since other hover work is happening in
|
||||
the same file.
|
||||
|
||||
- **File**: `sublime/sessions/terminal_link_click.py`.
|
||||
- **Hypothesis**:
|
||||
- Cmd+click: `_handle_abspath` call path changed, or the
|
||||
`open_file` dispatch rejects the cache-root mapping.
|
||||
- `localhost:8080`: `_URL_PATTERN` requires full scheme prefix; add
|
||||
a host:port fallback that recognizes `localhost:\d+` and
|
||||
`127\.0\.0\.1:\d+` as URLs.
|
||||
- **Done-when**: absolute path Cmd+click opens the file; `localhost:PORT`
|
||||
underlines and Cmd+click opens the default browser.
|
||||
|
||||
### Cluster C — Status bar format + version + venv name + hide-non-py
|
||||
|
||||
Current: `● py: <last three components>` — always visible in Sessions
|
||||
workspace even for non-Python files, no version, no venv name.
|
||||
|
||||
User wants: `Python: <venv-name> (<version>)` — e.g.
|
||||
`Python: MIN-T (3.11.4)` — and only on Python-language views.
|
||||
|
||||
- **Files**: `sublime/sessions/python_interpreter_registry.py`,
|
||||
`sublime/sessions/commands.py` (status bar emitter area).
|
||||
- **Hypothesis**: status bar render uses `.set_status()` unconditionally
|
||||
on window activation. Needs:
|
||||
(1) Probe interpreter version once per selection (cache result)
|
||||
(2) Derive venv name from path (`<project>/.venv/bin/python` →
|
||||
parent of `.venv` if named, else basename of `bin/../`)
|
||||
(3) Clear status for views whose syntax isn't Python
|
||||
- **Done-when**: Python view reads `Python: <venv> (<version>)`;
|
||||
non-Python view shows nothing in that slot; switching between
|
||||
views toggles correctly.
|
||||
|
||||
### Cluster D — Save write-back "reloading" chatter + §1.1 phantom UX
|
||||
|
||||
Two issues, both UX noise around save/expand:
|
||||
|
||||
**D1 Save reload chatter**
|
||||
```
|
||||
reloading /Users/mschoi/.../LICENSE_DIFFDOCK
|
||||
[Sessions] Sessions ready: Saved remote file ...
|
||||
reloading /Users/mschoi/.../LICENSE_DIFFDOCK
|
||||
```
|
||||
After save, file reloads twice visible in console. v0.5.5 was supposed
|
||||
to kill this; probably a new path re-introduced.
|
||||
|
||||
**D2 Expand deferred "will appear" with no stub**
|
||||
User right-clicked a sidebar node, got a "will appear" status message,
|
||||
but **no stub was added** and **no `expand.begin` trace fired**.
|
||||
|
||||
- **Files**: `sublime/sessions/commands.py` (save path + expand
|
||||
command), `sublime/sessions/file_watch*.py` if present.
|
||||
- **Hypothesis D1**: our own save triggers remote file/watch, which
|
||||
emits a change event, which re-fetches, which Sublime sees as
|
||||
external change → "reloading" log.
|
||||
- **Hypothesis D2**: the expand command optimistically status-messages
|
||||
"will appear" before validating that the remote path is actually
|
||||
deferred. Need to only log after validation succeeds AND actually
|
||||
schedule the expand.
|
||||
- **Done-when**: D1 no "reloading" after a save we just initiated
|
||||
(unless content actually differs server-side). D2 "will appear"
|
||||
only prints when expand.begin is about to fire.
|
||||
|
||||
### Cluster E — Terminal UX: new/switch/kill + localhost Cmd+click
|
||||
|
||||
User asks for multi-terminal semantics:
|
||||
- New terminal (second pane / second tmux session)
|
||||
- Switch to existing (today's default)
|
||||
- Kill existing (tmux detach currently kills the SSH connection too:
|
||||
`[detached (from session sessions-term-aws-celery)]` → `Connection
|
||||
... closed` → `process is terminated with return code 0`)
|
||||
|
||||
- **Files**: `sublime/sessions/terminal_tmux_session.py`,
|
||||
`sublime/sessions/commands.py::SessionsOpenRemoteTerminalCommand`,
|
||||
possibly a new `kill_remote_terminal` command.
|
||||
- **Done-when**:
|
||||
- `Sessions: Open Remote Terminal` still reattaches to a single
|
||||
per-host persistent session (default).
|
||||
- `Sessions: New Remote Terminal Pane` spawns a distinct tmux
|
||||
session (numbered) in a new Terminus tab.
|
||||
- `Sessions: Kill Remote Terminal` runs `tmux kill-session -t
|
||||
sessions-term-<host>` and closes the Terminus tab cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Known environmental / out-of-scope
|
||||
|
||||
- **§5 Jupyter Lab start timeout** (`last log snippet: ''`): bridge
|
||||
returns nothing within 45s; the same pattern from batch 1. Likely
|
||||
SSM-tunnel slowness. Tracked as **M5** in BACKLOG (expose per-method
|
||||
timeouts + back off auto-refresh). Not in this batch.
|
||||
- **§6 Debugger flow**: user said "사용법을 모르겠음" — a docs /
|
||||
onboarding ask, not a code fix. Tracked as M6.
|
||||
- **Agent `not a terminal` still showing**: batch 1 commit `9c59fc6`
|
||||
fixes it; tester needs to pull + reload.
|
||||
|
||||
---
|
||||
|
||||
## Parallel dispatch plan
|
||||
|
||||
Five independent clusters (A–E) above each get one subagent. Each agent:
|
||||
1. Investigates the hypothesis, validates/adjusts.
|
||||
2. Edits only the files in its cluster scope.
|
||||
3. Runs `pytest` for affected tests.
|
||||
4. Commits with a scoped message + pushes.
|
||||
5. Reports back what shipped vs. what still needs follow-up.
|
||||
|
||||
Cluster D splits into D1+D2 inside one agent (both touch `commands.py`
|
||||
in different regions, so single agent keeps the diff coherent).
|
||||
|
||||
Conflict matrix:
|
||||
- A ↔ C: both touch `commands.py` status emitter vicinity. Keep each
|
||||
agent confined to its own functions.
|
||||
- D ↔ E: both touch `commands.py` command classes. A touches save +
|
||||
expand; E touches terminal commands. No overlap.
|
||||
- B: `terminal_link_click.py` only — no conflict with others.
|
||||
@@ -1,61 +0,0 @@
|
||||
# Phase 6.2 — Remote dev MVP: LSP vs transport (Sessions)
|
||||
|
||||
This document locks the **Phase 6.2 / issue #30** transport choice so implementation and Gitea checklist items stay aligned.
|
||||
|
||||
**Multi-server wire evolution (after this MVP slice):** see **[`VSCODE_REMOTE_TRANSPORT_MODEL.md`](VSCODE_REMOTE_TRANSPORT_MODEL.md)** — VS Code Remote-SSH–style **one session + logical channels** so **new code servers do not require new top-level NDJSON methods** each time.
|
||||
|
||||
## MVP decision: subprocess tools first (no long-lived LSP on the wire)
|
||||
|
||||
**Ship first:** run **ruff** and **pyright** as **short-lived remote processes** over the existing SSH + `python3 -c` tool runner (same path as palette “Run Remote Python Lint”). Diagnostics are parsed and mapped to **local cache paths** for Sublime gutters.
|
||||
|
||||
**Why not stdio-multiplexed LSP inside `helper --stdio` yet?**
|
||||
|
||||
- The helper NDJSON session is already framed for discrete requests; pinning a bidirectional LSP stream there needs **cancellation, partial reads, and version negotiation** beyond current tool/exec payloads.
|
||||
- **#25** (bridge hard-timeout / kill policy) reduces risk before we hold a long-lived server process open.
|
||||
|
||||
## Longer-term options (documented trade-offs)
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
|----------|------|------|
|
||||
| **A. Dedicated SSH session** (second `ssh` only for LSP stdio) | Matches how many editors proxy LSP; isolation from helper | Extra connection, auth prompts, port/socket forwarding policy |
|
||||
| **B. Multiplex on helper stdio** (unified envelope + `lsp:*` channels; `lsp/proxy` may remain an alias) | Single SSH session; aligns with VS Code “one remote host session” | Protocol design + bridge work; interacts with #25 |
|
||||
| **C. Periodic / on-save `pyright` CLI** (MVP) | Reuses current transport; shippable now | No incremental sync; cold-start cost per run |
|
||||
|
||||
**MVP = C.** **A** remains an optional physical isolation layer; **B** is the default **inner** design — see [`VSCODE_REMOTE_TRANSPORT_MODEL.md`](VSCODE_REMOTE_TRANSPORT_MODEL.md) for channel kinds (`exec_once`, `lsp_stdio`, …).
|
||||
|
||||
## `session_protocol` / helper extension
|
||||
|
||||
- **MVP:** no new NDJSON method required; `tool/lint`-style exec already carries argv + cwd.
|
||||
- **Reserved:** Rust crate exposes `LSP_PROXY_METHOD_NAME` (`"lsp/proxy"`) — plan is to treat it as a **compat name** or single-channel alias once the **envelope + `channel`** model lands; **new servers must not depend on adding sibling top-level methods.**
|
||||
|
||||
## Implemented (MVP slice in repo)
|
||||
|
||||
- **Subprocess pipeline:** `sessions_remote_python_tool_pipeline` (default `ruff_lint` → `pyright_check`), loaded from `Sessions.sublime-settings`.
|
||||
- **Save hook:** `SessionsRemotePythonPipelineListener.on_post_save` runs the pipeline for workspace **`.py`** cache files when `sessions_remote_python_auto_diagnostics_on_save` is true.
|
||||
- **Optional open hook:** `sessions_remote_python_auto_diagnostics_on_open` (debounced) for the same pipeline.
|
||||
- **Deduped diagnostics** across tools before gutter mapping (`dedupe_diagnostic_records`).
|
||||
- **Pyright argv:** `build_python_pyright_tool_execution_request` (+ `ToolchainOverride.lsp` via shlex).
|
||||
- **Protocol:** `LSP_PROXY_METHOD_NAME` reserved in `session_protocol` for future stdio proxy.
|
||||
|
||||
## Manual QA (Remote-SSH–style loop)
|
||||
|
||||
1. Connect + open a Python remote workspace; open a `.py` under the Sessions cache.
|
||||
2. Introduce a ruff violation and a type error; **save** the file.
|
||||
3. Expect gutters / panel to reflect **ruff** then **pyright** (order follows `sessions_remote_python_tool_pipeline`); no duplicate squiggles for identical message+line.
|
||||
4. Remove `pyright` from the remote PATH; save again → status/panel shows actionable **missing tool** copy (per-tool hints preserved in pipeline footer).
|
||||
|
||||
## Deferred Follow-up Issue: large-file hydrate and streaming delivery
|
||||
|
||||
This topic is **explicitly out of Phase 6.2 scope** and should be tracked as a separate Gitea issue.
|
||||
|
||||
- **Problem:** sidebar placeholder hydrate currently resolves with file-level full reads. In high-latency links or large files, a single hydrate can consume the request timeout budget (observed around 30s) and block perceived responsiveness.
|
||||
- **Why separate from current transport issue:** this is not primarily about adding new code servers (`exec_once` / `lsp_stdio`) but about file-delivery behavior under size/latency stress.
|
||||
- **Accepted near-term mitigations (already aligned with current code direction):**
|
||||
- hydrate precheck (`stat`) with short timeout before full read
|
||||
- active-tab and last-wins gating to skip stale hydrates
|
||||
- shorter hydrate read timeout than normal explicit open flow
|
||||
- **Design work for follow-up issue (when needed):**
|
||||
- chunked/streamed file delivery over channel envelope (instead of one-shot base64 full body)
|
||||
- progressive editor update semantics (safe partial visibility before full completion)
|
||||
- cancellation semantics for stale in-flight chunks when user switches tabs
|
||||
- compatibility plan with existing `file/read` contract so small files keep fast path
|
||||
246
planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md
Normal file
246
planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Review-driven distribution-readiness plan (v0.6.4 → v0.7+)
|
||||
|
||||
External-review reading of the repo asked: "ready for broad distribution
|
||||
yet?" Verdict was: **strong internal alpha/beta**, **not yet ready** for
|
||||
company-wide or public release. Reasons cluster into install/packaging,
|
||||
platform reliability, feature surface, security/EDR, and remaining
|
||||
performance scale items.
|
||||
|
||||
This plan distills the actionable themes into work items, splits them
|
||||
into "in this batch" vs "deferred", and records acceptance criteria so
|
||||
each item can be picked up later without re-reading the source review.
|
||||
|
||||
> The original review document was lost mid-session (rm'd in error). Items
|
||||
> here are reconstructed from the partial review I had retained. If
|
||||
> additional themes were in the original review, append them here under
|
||||
> the right section rather than starting a new doc.
|
||||
|
||||
## Status legend
|
||||
|
||||
- `[done @ <commit>]` — landed in this batch, commit ref noted.
|
||||
- `[plan]` — captured here; pick up later.
|
||||
- `[needs-input]` — needs maintainer decision before scoping.
|
||||
|
||||
---
|
||||
|
||||
## Batch landing now (v0.6.5)
|
||||
|
||||
### 1. README ↔ implementation drift on `session_helper` resolution `[done]`
|
||||
|
||||
**Issue:** README (lines ~96-99 and ~117-121) says the remote machine
|
||||
downloads `session_helper` directly from the Gitea generic registry via
|
||||
`curl`/`wget`. The actual implementation since v0.5.x downloads to the
|
||||
**editor cache** (`_ensure_session_helper_in_editor_cache`) then pushes
|
||||
via SSH (`_needs_remote_session_helper_push` /
|
||||
`_remote_session_helper_push_check_script`). Rust tests assert the
|
||||
remote provisioning command does NOT contain `curl` / `wget`
|
||||
(`local_bridge/src/lib.rs:1297-1298`). Settings comment matches the
|
||||
real flow; README does not.
|
||||
|
||||
**Acceptance:** README describes the editor-cache + SSH-push flow; the
|
||||
diagnostic-matrix `H6_remote_download` hypothesis text is updated to
|
||||
reflect "editor download → SSH push" instead of "remote curl/wget".
|
||||
|
||||
### 2. Hide developer-only `Preview Remote Agent Payload` from main palette `[done]`
|
||||
|
||||
**Issue:** Review flagged the 27-command palette as too broad for
|
||||
non-power users, and singled out `Sessions: Preview Remote Agent
|
||||
Payload` as developer-flavored — it dumps the agent invocation argv
|
||||
+ env into a scratch view, useful for debugging, distracting in the
|
||||
main palette.
|
||||
|
||||
**Acceptance:** `is_visible` returns `False` by default. New setting
|
||||
`sessions_show_dev_commands` (default `false`) flips it back on for
|
||||
maintainers. Other palette commands unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Deferred — architecture / packaging tracks
|
||||
|
||||
### 3. Command palette split: core / advanced / experimental `[plan]`
|
||||
|
||||
**Theme:** 27 commands at the top level mixes "Connect Remote
|
||||
Workspace" (core flow) with "Preview Remote Agent Payload" (debug),
|
||||
"Diagnose LSP Workspace" (debug), "Register Jupyter Kernel for Active
|
||||
Python" (advanced flow). Power users like the breadth; broader users
|
||||
read it as "the product center is unclear".
|
||||
|
||||
**Proposal:** Three tiers, gated by settings:
|
||||
|
||||
- **Core** (always visible): Connect, Open Recent, Open Remote
|
||||
Folder, Open Remote Tree, Open Remote File, Reconnect, Settings,
|
||||
Open Remote Terminal, Select Python Interpreter, New Agent Session,
|
||||
Show Agent Switcher.
|
||||
- **Advanced** (visible when `sessions_show_advanced_commands: true`,
|
||||
default `true` for now, default `false` for v0.7 broad release):
|
||||
Refresh Remote Workspace, Install/Remove/Status Remote Extension,
|
||||
Open/Stop Remote Jupyter, Setup Remote Python Debugging, Register
|
||||
Jupyter Kernel, Expand Deferred Directory, Kill Agent Session,
|
||||
New Remote Terminal Pane, Kill Remote Terminal, Clear Python
|
||||
Interpreter, Open Local SSH Config.
|
||||
- **Dev** (visible when `sessions_show_dev_commands: true`, default
|
||||
`false`): Preview Remote Agent Payload, Diagnose LSP Workspace.
|
||||
|
||||
**Acceptance:** Each command's `is_visible` reads its tier's setting.
|
||||
Settings default values yield exactly the "core + advanced" set
|
||||
visible today minus the dev commands. Test: assert visibility for
|
||||
each known palette caption under the three setting-combination
|
||||
matrices.
|
||||
|
||||
### 4. Default-settings "safe profile" toggle `[plan]`
|
||||
|
||||
**Theme:** First-experience defaults are aggressive:
|
||||
`sessions_connect_auto_open_remote_folder=true`,
|
||||
`sessions_mirror_auto_refresh=true`,
|
||||
`sessions_mirror_include_files=true`. Good for power users; can be
|
||||
loud for security-sensitive orgs or huge workspaces.
|
||||
|
||||
**Proposal:** `sessions_safe_profile` boolean. When `true`, force
|
||||
`sessions_mirror_auto_refresh=false`,
|
||||
`sessions_mirror_include_files=false`,
|
||||
`sessions_connect_auto_open_remote_folder=false`,
|
||||
auto-deepen depth=1, mirror_max_entries=300, mirror_max_dir_fanout=50.
|
||||
Document in SECURITY.md as the recommended default for orgs running
|
||||
Sessions across many workstations.
|
||||
|
||||
Don't flip the master defaults yet — too disruptive. Add the toggle,
|
||||
document it, then in v0.7 consider flipping the default once the
|
||||
"broad distribution" track is cleared.
|
||||
|
||||
**Acceptance:** New setting + override layer that takes precedence
|
||||
over individual mirror caps when set. New SECURITY.md row in the
|
||||
"deployment guidance" section. Tests: load with toggle on,
|
||||
`SessionsSettings.from_loaded()` reflects the conservative caps.
|
||||
|
||||
### 5. Stable vs dev release channel `[plan]`
|
||||
|
||||
**Theme:** v0.6.0 → v0.6.4 in ~36 hours with several
|
||||
cancelled/failed CI runs along the way. Internal iteration speed is a
|
||||
feature, but external readers see a "fast-changing, still shifting"
|
||||
product. Public users want a calm channel.
|
||||
|
||||
**Proposal:**
|
||||
- Tag protocol: `v0.X.Y` continues to be the hot iteration channel
|
||||
(default for `git fetch`, what CI publishes per push).
|
||||
- New: `vX.Y-stable` rolling tags that move forward when an internal
|
||||
test pass on macOS + Windows + Linux completes against a `v0.X.Y`
|
||||
candidate. Release page links the latest stable tag separately.
|
||||
- Documentation lists the stable tag as the recommended fetch for
|
||||
non-maintainer users.
|
||||
|
||||
**Acceptance:** New scripts/`promote_stable.py` that takes a `vX.Y.Z`
|
||||
tag and force-updates `vX.Y-stable` → that commit (signed). Release
|
||||
asset cross-link in the Gitea release notes for the unstable tag
|
||||
points to the matching stable tag (or "no matching stable yet").
|
||||
SECURITY.md verification command updated to use the stable tag.
|
||||
|
||||
### 6. macOS / Windows smoke CI `[plan]`
|
||||
|
||||
**Theme:** Repository CI is single-platform (`ubuntu-latest`). Code
|
||||
explicitly targets Win/macOS (CREATE_NO_WINDOW threading, macOS
|
||||
PersistentBroker, etc.). External users can't verify the supported
|
||||
platforms actually pass CI on those platforms.
|
||||
|
||||
**Proposal:** Add two cheap smoke jobs (no full test suite — just
|
||||
"does it build + does the import smoke pass"):
|
||||
|
||||
- `cargo build --manifest-path rust/Cargo.toml -p local_bridge -p sessions_native`
|
||||
on `macos-latest` and `windows-latest`.
|
||||
- `python -m compileall -q sublime` and the runtime-import smoke test
|
||||
on the same matrix.
|
||||
|
||||
Skip the full pytest suite there (Windows runners are slow and the
|
||||
real coverage stays on Ubuntu). The smoke gate just answers "does it
|
||||
load on those platforms".
|
||||
|
||||
**Acceptance:** New `.gitea/workflows/cross-platform-smoke.yml` (or
|
||||
add jobs to the existing `ci.yml`) that runs on PR + main. Document
|
||||
the matrix in CONTRIBUTING.md. Failures block merge.
|
||||
|
||||
### 7. Platform code-signing (Apple Developer ID, Windows Authenticode) `[plan]`
|
||||
|
||||
**Theme:** SECURITY.md admits binaries are unsigned (just GPG +
|
||||
checksum). For corporate / public distribution this is insufficient —
|
||||
macOS Gatekeeper still raises "unidentified developer" warnings on a
|
||||
release-bundle binary; Windows SmartScreen does the same.
|
||||
|
||||
**Proposal:** Add per-platform signing pipelines, gated on the
|
||||
existence of org-level credentials:
|
||||
|
||||
- macOS: notarize + staple via Apple Developer ID. Requires an Apple
|
||||
Developer Program membership ($99/yr) and `xcrun notarytool` access.
|
||||
Sign `local_bridge`, `session_helper`, `libsessions_native.dylib`.
|
||||
- Windows: Authenticode sign via an EV code-signing cert (DigiCert et
|
||||
al., ~$500/yr). Sign `local_bridge.exe`, `session_helper.exe`,
|
||||
`sessions_native.dll`.
|
||||
- Both pipelines lift the credential from CI secrets at signing time;
|
||||
the credentials never enter a contributor workstation.
|
||||
|
||||
`[needs-input]`: budget approval for the certs + Apple membership.
|
||||
Without those, this stays planned but not actionable.
|
||||
|
||||
**Acceptance:** Two new CI workflow steps (one per platform) that run
|
||||
after the existing `cargo build --release` on the matrix runner.
|
||||
Outputs are signed binaries that go into the release asset bundle
|
||||
alongside the GPG signature. SECURITY.md updated to "platform
|
||||
signature + GPG signature" dual-trust verification.
|
||||
|
||||
### 8. Remote install consent flow `[plan]`
|
||||
|
||||
**Theme:** Managed remote-extension catalog includes
|
||||
`curl ... | bash` (Claude Code), `npm install -g @openai/codex`,
|
||||
`pip install --user` (Jupyter, debugpy, pyright). The product crosses
|
||||
the line from "remote code editor" to "remote tool installer" — every
|
||||
install runs commands on the user's remote workstation under their
|
||||
SSH identity. Power users want this; security/IT teams push back.
|
||||
|
||||
**Proposal:** Three-tier install gating:
|
||||
|
||||
- New setting `sessions_remote_extension_install_enabled` (default
|
||||
`true` today; switch to `false` in v0.7 broad-release default).
|
||||
- When `false`, the "Install Remote Extension" command shows a
|
||||
one-shot consent dialog naming the exact commands that will run on
|
||||
the remote, with "Run once" / "Always allow on this host" / "Never"
|
||||
buttons. "Always allow" sets a per-host flag in `workspace_state`.
|
||||
- "Never" leaves the catalog visible (so users see what's available)
|
||||
but greys out the install button and hints at the setting toggle.
|
||||
|
||||
**Acceptance:** New setting + per-host opt-in registry. Existing
|
||||
install flow gates on it. Tests: install command refused when setting
|
||||
off + host not opt'd in; install proceeds when opt'd in. SECURITY.md
|
||||
gains a "remote command surface" appendix listing every install
|
||||
command + what it touches.
|
||||
|
||||
### 9. Large-file hydrate streaming (open issue #32) `[plan]`
|
||||
|
||||
**Theme:** Current hydrate has a small-file fast path; large or
|
||||
high-latency files block the UI thread for the duration of the SSH
|
||||
fetch. Issue #32 wants progressive streaming.
|
||||
|
||||
**Proposal:** Track on existing issue; not in scope for this batch.
|
||||
Note here as "review-acknowledged".
|
||||
|
||||
**Acceptance:** Cross-link to issue #32 in SHIPPED.md once landed.
|
||||
|
||||
### 10. Diff-centric change review workflow `[plan]`
|
||||
|
||||
**Theme:** Open issue. Agent flow surfaces edits but there's no "show
|
||||
me what the agent / I changed in this session, diff-style" view.
|
||||
|
||||
**Proposal:** Out of scope here. `agent_change_badge.py` exists; the
|
||||
`file/watch` driver is the missing piece (already documented as a
|
||||
v0.7 limitation in TEST_CHECKLIST §9).
|
||||
|
||||
**Acceptance:** v0.7 follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Open questions / partial review recovery
|
||||
|
||||
- The original review.md may have called out additional items the head
|
||||
preview did not capture (lost lower paragraphs). If you (Myeongseon)
|
||||
paste the original back, append themes here under section 11+ rather
|
||||
than restarting a new doc.
|
||||
- The "Phase 9 — Quality Gates & Scale" milestone referenced in the
|
||||
review presumably ties to these items 4 / 5 / 6 / 9; cross-link
|
||||
when the milestone is reopened.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Rust Migration Refresh (2026-04-22)
|
||||
|
||||
## Scope Refresh from Current Code
|
||||
|
||||
The previous review remains directionally correct: keep Sublime API/UI wiring in Python and move policy/correctness-heavy runtime logic to Rust.
|
||||
|
||||
Based on the current tree, migration should proceed in this order:
|
||||
|
||||
1. File policy core (`file_state`) to Rust native ABI.
|
||||
2. Bridge/runtime orchestration from Python command layer to `local_bridge`.
|
||||
3. Diagnostics normalization and tool-output parsing in Rust.
|
||||
4. Mirror/open-file refresh planning in Rust (Python only applies UI effects).
|
||||
|
||||
## Migrated So Far
|
||||
|
||||
Completed migration items now owned by Rust (`sessions_native`):
|
||||
|
||||
- open guard reason classification from metadata
|
||||
- binary heuristic (NUL-byte probe)
|
||||
- reload recommendation from baseline/current metadata
|
||||
- save conflict classification before remote write
|
||||
- remote/local cache path mapping (including `__extern`)
|
||||
- local cache path -> remote path inverse mapping
|
||||
- workspace cache key derivation
|
||||
- Ruff diagnostics JSON normalization for remote tool runs
|
||||
|
||||
Python keeps API/UI surfaces but delegates the migrated logic to Rust wrappers
|
||||
(`_rust_file_policy`, `_rust_workspace_normalize`) with no compatibility fallback.
|
||||
|
||||
## Testing Translation
|
||||
|
||||
Rust ABI tests were expanded in `rust/crates/sessions_native/tests/abi_smoke.rs` to cover:
|
||||
|
||||
- open guard reasons (large file, directory, empty-file policy)
|
||||
- binary heuristic behavior
|
||||
- reload recommendation categories
|
||||
- save decision categories
|
||||
- cache path mapping + inverse mapping
|
||||
- workspace cache key output
|
||||
- Ruff diagnostics parse ABI
|
||||
|
||||
Python tests validate wrapper behavior and command/runtime integration:
|
||||
|
||||
- `sublime/tests/test_file_cache_policy.py`
|
||||
- `sublime/tests/test_file_pipeline.py`
|
||||
- `sublime/tests/test_rust_file_policy.py`
|
||||
- `sublime/tests/test_workspace_identity.py`
|
||||
- `sublime/tests/test_ssh_tool_runtime.py`
|
||||
|
||||
## Next Implementation Slice
|
||||
|
||||
Next migration target remains bridge/runtime orchestration in `local_bridge`:
|
||||
|
||||
- persistent bridge request lifecycle currently coordinated in Python
|
||||
- queue/worker orchestration in `commands.py` (connect, mirror, hydrate ordering)
|
||||
- mirror/open-file refresh planning so Python applies only UI effects
|
||||
51
planning/SHIPPED.md
Normal file
51
planning/SHIPPED.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# SHIPPED — feature → release map
|
||||
|
||||
What ships in each release. Authoritative reference for "is this done?" —
|
||||
anything not here is NOT implemented, regardless of what other planning
|
||||
documents suggest. Keep this list short and version-ordered.
|
||||
|
||||
Evergreen architecture contracts:
|
||||
|
||||
- `PYTHON_RUST_BOUNDARY.md` — what lives where, lifecycle invariants.
|
||||
- `VSCODE_REMOTE_TRANSPORT_MODEL.md` — single-session + channel envelopes.
|
||||
|
||||
## v0.6.x — tmux-backed remote agent sessions
|
||||
|
||||
| ver | landed | module(s) |
|
||||
|---|---|---|
|
||||
| 0.6.5 | macOS test pass batch-3 + distribution-readiness review prep. **agent tmux**: v0.6.2 added `tmux new-session -d` but spawn still failed with `open terminal failed: not a terminal` on `aws-celery` — two further holes filled: `_default_ssh_command_builder` now returns `["ssh", "-T", alias]` (explicit no-PTY contract; defends against stray `RequestTTY=yes` in user's ssh config) and the spawn command appends `</dev/null` (so `isatty(0)` is unambiguously false against tmux 3.x's terminal-capability snapshot). **palette**: `SessionsNewRemoteTerminalPaneCommand` and `SessionsKillRemoteTerminalCommand` v0.6.2 entries had `Sessions.sublime-commands` rows but were never imported by `sublime/plugin.py`, so Sublime never auto-registered them — symptom: "그런 command 없음". Add to plugin entrypoint + entrypoint-smoke / runtime-import tests. **hover URL**: `localhost:PORT` / `127.0.0.1:PORT` / `0.0.0.0:PORT` Cmd+click landed on `about:blank-` on macOS; `classify_terminal_token` now canonicalizes `0.0.0.0` → `localhost` (browser-routable) and forces a trailing `/` on no-path tokens (macOS `open location` requires it). Adversarial `host:port-extra` tokens refuse the match outright. **dev-commands gate**: new `sessions_show_dev_commands` setting (default false) hides developer-only palette entries; first gated: `Sessions: Preview Remote Agent Payload`. **doc**: README claimed remote machines download `session_helper` via curl/wget; reality (since v0.5.x) is editor-cache download → SSH push. README + diagnostic matrix updated to match. **plan**: `planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md` captures distribution-readiness review themes (palette tier split, safe-profile defaults, stable channel, cross-platform smoke CI, code signing, install consent). **repro**: `planning/V0_6_5_REPRO.md` is the focused checklist for the next macOS pass — verify the four batch-3 fixes + capture diagnostic for the still-open issues (mirror-sync deep hang, hover absolute path open, Jupyter silent launch). | `agent_tmux`, `plugin`, `terminal_link_click`, `commands`, `Sessions.sublime-settings`, `README.md`, `ssh_file_transport`, `planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`, `planning/V0_6_5_REPRO.md`, `planning/SHIPPED.md`, `planning/TEST_CHECKLIST.md` |
|
||||
| 0.6.4 | CI now signs and publishes release artifacts end-to-end. Added a sign-only RSA-4096 GPG **subkey** `DC20B3978326B78B` (master `CD1D23365D028C41` — never enters CI). CI imports the subkey via `GPG_SIGNING_SUBKEY` / `GPG_SIGNING_PASSPHRASE` repo secrets, primes gpg-agent in loopback mode, then runs `sign_release_artifacts.py` (master-fingerprint `--local-user` is auto-routed to the subkey when only the subkey has secret material) → `create_gitea_release.py` to upload the signed bundle as release assets, → `upload_session_helper_to_gitea.py` for the musl session_helper generic-package upload. **Concern separation**: `upload_session_helper_to_gitea.py` no longer creates / patches release pages (only generic-package + repo link). Release-page ownership lives entirely in `create_gitea_release.py`, removing the title-flap that v0.6.3 had. **SECURITY.md** documents the dual-key model: CI compromise revokes the subkey only, master web-of-trust + prior-release signatures stay valid. | `.gitea/workflows/upload-session-helper-gitea.yml`, `scripts/upload_session_helper_to_gitea.py`, `sublime/tests/test_upload_session_helper.py`, `SECURITY.md` |
|
||||
| 0.6.3 | Release-tooling fixes (no user-visible runtime changes). **CI gate fix**: `Ensure tag commit is on main` step's `git fetch origin main --depth=1` was shallow-grafting `origin/main` at its tip, so when the tag commit is a parent of main HEAD (release fix-up + follow-up commit pattern that surfaced on v0.6.2) `git merge-base --is-ancestor` returned false. Drop `--depth=1`; checkout already uses `fetch-depth: 0`. **`scripts/create_gitea_release.py`**: replacement for `tea releases create` (tea 0.9.2 silently drops `--title` and rejects with "title is empty"). Idempotent — reuses the release for an existing tag and replaces same-named assets. Token resolves from `--token` → `TOKEN` env → `~/.config/tea/config.yml`. Default title comes from the v\<ver\> signed-tag subject. | `.gitea/workflows/upload-session-helper-gitea.yml`, `scripts/create_gitea_release.py` |
|
||||
| 0.6.2 | macOS test pass batch: agent tmux spawn `-d` so non-TTY SSH child no longer fails with `open terminal failed: not a terminal`. Eager build-graph hydrate re-runs at `sync.done` so subproject `pyproject.toml` placeholders fill after deep mirror lands them. Expand-deferred shows current state instead of false "will appear" promise + flags >5000-entry partial mirrors. Auto-refresh "Deepening mirror…" status no longer spams console on every tick. Interpreter picker "Back to picker" row moves to top of folder browser to stop mis-clicks next to python binaries. **Hover links**: Cmd+click absolute path now opens (drag_select suppression), `localhost:PORT` / IPv4:PORT promote to `http://`. **LSP**: stale broker_socket from prior Sublime PID is detected at `plugin_loaded` and disabled on disk before LSP package retries — kills the 5×crash boot loop. **Status bar**: `Python: <venv> (<X.Y.Z>)` (with version probe + cache), syntax-gated so non-Python views drop the slot. **Save**: 5s self-save cooldown suppresses double-reload chatter from inotify echo. **Terminal**: `Sessions: New Remote Terminal Pane` (numbered tmux session) + `Sessions: Kill Remote Terminal` (with view cleanup) | `agent_tmux`, `commands`, `eager_hydrate`, `terminal_link_click`, `lsp_project_wiring`, `python_interpreter_registry`, `terminal_tmux_session`, `Sessions.sublime-commands` |
|
||||
| 0.6.1 | Windows fixes from v0.6.0 test pass: `_subprocess_no_window_kwargs()` threaded into `agent_tmux` / `jupyter_hosting` / `terminal_tmux_session` so SSH children no longer flash a `cmd.exe` window (also kept Terminus + Jupyter + agent spawn from silently dying). Gate `bridge.rust.helper_stdout_message` behind `SESSIONS_BRIDGE_DIAG_VERBOSE` — trace log was unreadable on busy mirror-sync. Suppress `handshake is missing broker_socket` blocker on Windows (PersistentBroker is Unix-only; LSP stdio wiring is a known follow-up). Added `expand.begin` / `expand.done` trace events | `agent_tmux`, `jupyter_hosting`, `terminal_tmux_session`, `lsp_project_wiring`, `commands`, `local_bridge/src/diag_log.rs`, `local_bridge/src/lib.rs` |
|
||||
| 0.6.0 | Track D integrator pass: `Sessions: New / Switch / Kill / Show Agent Session` commands wire `AgentTmuxBroker` + three-group layout + switcher view into palette. Workspace→agent pair registry in `workspace_state` (`AgentPair`, `register_agent_pair`, `lookup_agent_pair`, `active_agent_pair_id`, `forget_agent_pair`, `list_agent_pairs`). Catalog entries for `tmux` / `claude-code` / `codex-cli` installed via standard extension flow | `agent_tmux`, `agent_window_layout`, `agent_switcher_view`, `agent_proposal_watcher`, `agent_change_badge`, `workspace_state`, `commands`, `managed_remote_extension_catalog` |
|
||||
|
||||
## v0.5.x — signed releases, uv-safe Jupyter, EDR-safe mirror
|
||||
|
||||
| ver | landed | module(s) |
|
||||
|---|---|---|
|
||||
| 0.5.8 | VSCode-style hover-activated Terminus links (`on_hover` paints link scope + underline; click reuses cached span); persistent Remote Terminal via `tmux new-session -A -s sessions-term-<host>` + view-reuse dict; eager hydrate for build-graph files on workspace activation | `terminal_link_click`, `terminal_tmux_session`, `commands`, `eager_hydrate` |
|
||||
| 0.5.7 | interpreter picker remote folder browser (navigable quick panel, `[dir]`/`[py]` markers); status bar bullets `● py:` / `○ py: (not set)` with 3-component middle-truncated path; "missing" → "not installed" / "installed" / "installed but unusable" tri-state | `python_interpreter_browser`, `python_interpreter_registry`, `commands` |
|
||||
| 0.5.6 | tilde path `~/…` → `$HOME/…` in SSH commands; sidebar Expand-this-folder `is_visible`/`is_enabled` so Sublime auto-injects paths | `jupyter_hosting`, `commands` |
|
||||
| 0.5.5 | shell-quote every SSH arg (Jupyter display-name word-split); sidebar expand accepts `paths`/`dirs`/`files`; `.sublime-project` skip-write when unchanged | `jupyter_hosting`, `commands`, `lsp_project_wiring` |
|
||||
| 0.5.4 | `sublime.decode_value` fallback for `//` comments in `.sublime-project`; `ensurepip` fallback for uv venvs | `lsp_project_wiring`, `jupyter_hosting` |
|
||||
| 0.5.3 | align Python helper push path (`$HOME/.cache/sessions/helpers/<revision>/`) with `local_bridge` probe | `ssh_file_transport` |
|
||||
| 0.5.2 | CI retag on green main (test-only) | version bump |
|
||||
| 0.5.1 | GPG signing infrastructure: `scripts/sign_release_artifacts.py`, `SECURITY.md` verify steps, `CD1D23365D028C41` as release key | `scripts/`, `SECURITY.md` |
|
||||
| 0.5.0 | bounded mirror burst: `max_entries=1000`, `max_dir_fanout=100`, writes-per-second token bucket, circuit breaker, deferred-dir expansion, sidebar right-click expand, `sessions_shared_cache_root` exposed | `local_bridge/remote_cache_mirror`, `workspace_state`, `commands` |
|
||||
|
||||
## v0.4.x — active Python, Jupyter, debugger, rename
|
||||
|
||||
| ver | landed | module(s) |
|
||||
|---|---|---|
|
||||
| 0.4.20 | active Python interpreter registry + selector + status bar + pyright wiring; Jupyter ipykernel binding to active Python; debugpy catalog entry + Debugger-package DAP stub emission; EDR hardening metadata in Rust binaries; `local_bridge --version` banner; `SECURITY.md` | `python_interpreter_registry`, `jupyter_hosting`, `commands`, Rust crate manifests |
|
||||
| 0.4.19 | managed-install catalog rename (`LSP_CATALOG` → `EXTENSION_CATALOG` + `kind` field); Jupyter Lab hosting via external browser + SSH `-L` tunnel; `.ipynb` open routes through Jupyter; `SessionsOpenRemoteJupyterCommand` / `SessionsStopRemoteJupyterCommand` | `managed_remote_extension_catalog`, `jupyter_hosting`, `commands` |
|
||||
| 0.4.18 | Cmd+click on URL / absolute remote path in Terminus buffers | `terminal_link_click` |
|
||||
|
||||
## Infrastructure (ongoing)
|
||||
|
||||
- GPG-signed tags + release bundle (`SHA256SUMS` + `.asc`) on every `v*`.
|
||||
- CI weekly `cargo-mutants` on `broker.rs` (Sunday 13:00 KST).
|
||||
- `test_health.py` gate on mock-only:high-value ratio (floor: high-value
|
||||
≥264, real-subprocess ≥53, adversarial ≥184, mock-only ratio ≤0.98).
|
||||
- 1364 pytest passing; full Rust workspace + clippy `-D warnings` green.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Terminal Cmd+Click Navigation Plan
|
||||
|
||||
Allow the user to Cmd+Click (macOS) / Ctrl+Click (Win/Linux) a file path
|
||||
or URL printed by the remote terminal to jump into Sessions:
|
||||
|
||||
- **Remote file path** → fetch via the existing hydrate flow into the
|
||||
local cache, then open the cached view in the current workspace.
|
||||
- **URL (http/https/etc.)** → hand off to the host OS (`webbrowser` in
|
||||
Python, which uses `open` / `start` / `xdg-open`).
|
||||
|
||||
Status: design only. Not yet implemented.
|
||||
|
||||
---
|
||||
|
||||
## Terminal backend we integrate with
|
||||
|
||||
`SessionsOpenRemoteTerminalCommand.run` (in `sublime/sessions/commands.py`)
|
||||
either
|
||||
|
||||
1. hands `ssh -tt <host> ...` to **Terminus** (`terminus_open`), or
|
||||
2. falls back to Sublime's `new_terminal` which just launches the
|
||||
platform-native terminal outside Sublime.
|
||||
|
||||
Fallback path is out of scope — once the terminal leaves Sublime the
|
||||
click must be handled by the terminal app. Only the Terminus path is
|
||||
addressable from our plugin.
|
||||
|
||||
## Terminus integration points
|
||||
|
||||
Terminus exposes a `view_settings` key `terminus_view` and emits
|
||||
view-lifecycle / input events that plugins can listen for via the
|
||||
standard Sublime `EventListener` API. Relevant surfaces:
|
||||
|
||||
- `view.settings().get("terminus_view")` — true when a Sublime view is
|
||||
a Terminus terminal buffer.
|
||||
- `event_listener.on_post_text_command(view, command_name, args)` —
|
||||
fires after `terminus_render` so we can inspect the freshly-written
|
||||
text.
|
||||
- `event_listener.on_text_command(view, "drag_select", args)` — fires
|
||||
on mouse clicks; `args` includes `event.modifier_keys` (primary,
|
||||
alt, shift). We intercept when `primary` (Cmd / Ctrl) is held.
|
||||
|
||||
Terminus ships a built-in `terminus_open_link` command but it only
|
||||
resolves URLs via a regex of its own; no hook for file-path handling.
|
||||
We layer on top via our own EventListener.
|
||||
|
||||
## Detection rules
|
||||
|
||||
Order matters — URL beats file path because URLs can contain `:`.
|
||||
|
||||
1. **URL** — `https?://\S+`, `ftp://\S+`, `file://\S+`.
|
||||
2. **Remote absolute path** — `/[A-Za-z0-9_./+\-]+(?::\d+(?::\d+)?)?`
|
||||
where the tail `:L:C` parses as line/column (grep -n style).
|
||||
3. **Remote project-relative path** — `[A-Za-z0-9_./+\-]+(:L:C)?` if
|
||||
the token exists as a remote workspace entry (requires a
|
||||
`file/stat` bridge call on click — worth the ~50-150ms RTT because
|
||||
it only fires on explicit Cmd+Click).
|
||||
|
||||
Rejected: fuzzy inference of "this might be a path" without the cache
|
||||
lookup. Too many false positives in log noise.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Cmd+Click at view position
|
||||
→ line contents selected → text under cursor extracted
|
||||
→ run detectors in order
|
||||
→ URL: webbrowser.open(url); status "Opened <url>"
|
||||
→ File path:
|
||||
map remote path → local cache path
|
||||
if local is materialized: window.open_file(local + ":L:C")
|
||||
else: schedule hydrate (_schedule_sidebar_placeholder_hydrate)
|
||||
→ on hydrate success, window.open_file(local + ":L:C")
|
||||
emit trace "terminal.link_click" with kind + remote_path
|
||||
```
|
||||
|
||||
Path resolution reuses `RemoteToLocalCacheMapper` from
|
||||
`file_state.py`. Hydrate reuses `_schedule_sidebar_placeholder_hydrate`
|
||||
from `commands.py`. No new transport, no new Rust code.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Terminal scrollback contains a path that has since been deleted**
|
||||
remotely. `file/stat` returns `exists=False`; we show a status
|
||||
message "Sessions: remote path no longer exists" and emit
|
||||
`terminal.link_click_stale` trace.
|
||||
- **Path outside the workspace root**. We already map external paths
|
||||
into the `__extern/` cache namespace
|
||||
(`map_external_remote_to_local_path`). Click works; edits are
|
||||
read-only by policy.
|
||||
- **Path with spaces**. Terminus line-split gives the whole token; we
|
||||
accept quoted forms `"..."`, `'...'`, and fall back to greedy
|
||||
matching up to whitespace.
|
||||
- **Windows drive letters** (`C:\...`). Matches only when the click
|
||||
target workspace is a Windows remote (detected via handshake
|
||||
`remote_platform`). On Unix remotes we reject.
|
||||
- **Concurrent hydrate in flight**. Coalesce on
|
||||
`(cache_key, remote_path)` via existing `_HYDRATE_IN_FLIGHT` set.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: path detector over a corpus of real terminal lines (compiler
|
||||
errors, `ls -la` output, `grep -rn` results, Python tracebacks,
|
||||
URL embedded in log).
|
||||
- Contract: Terminus `on_text_command` mock passing a synthetic
|
||||
`drag_select` with `primary=True`.
|
||||
- No subprocess tests — Terminus side is stubbed.
|
||||
|
||||
## Non-goals for this iteration
|
||||
|
||||
- In-buffer underline rendering (Terminus doesn't expose a region
|
||||
API we can safely use).
|
||||
- Detection of non-absolute paths without a bridge stat call.
|
||||
- Clickable paths from the fallback (non-Terminus) terminal — out of
|
||||
our control, would need a separate terminal plugin.
|
||||
|
||||
## Dependency
|
||||
|
||||
Requires `Terminus` package installed. Feature silently degrades to
|
||||
"plain text" when Terminus isn't detected (we already check
|
||||
`find_resources("Terminus.sublime-settings")` in the terminal open
|
||||
flow, so the presence test is free).
|
||||
575
planning/TEST_CHECKLIST.md
Normal file
575
planning/TEST_CHECKLIST.md
Normal file
@@ -0,0 +1,575 @@
|
||||
# TEST_CHECKLIST — v0.6.4 (macOS primary, Windows secondary)
|
||||
|
||||
End-to-end smoke + scenario test-plan for the current main. Ordered so
|
||||
later sections depend on earlier ones (connect → workspace →
|
||||
interpreter → Jupyter / debugger → agent sessions). Take a screenshot
|
||||
if anything deviates from the "expected" column.
|
||||
|
||||
What's new since v0.6.1 (look for `(v0.6.2)` / `(v0.6.4)` markers
|
||||
inline below):
|
||||
|
||||
- **v0.6.2** macOS test-pass batch: LSP stale `broker_socket`
|
||||
auto-disable, hover Cmd+click absolute path opens, `localhost:PORT`
|
||||
promotion, status-bar `Python: <venv> (<X.Y.Z>)` format, save
|
||||
self-cooldown, agent `tmux new-session -d`, eager-hydrate retry on
|
||||
`sync.done`, expand-deferred clearer hint + large-dir warning,
|
||||
auto-refresh status silence, interpreter picker "Back" row to top,
|
||||
Sessions: New Remote Terminal Pane / Kill Remote Terminal commands.
|
||||
- **v0.6.3** release tooling: CI tag-gate fix (no shallow main fetch),
|
||||
`scripts/create_gitea_release.py`.
|
||||
- **v0.6.4** signing: CI now signs and publishes release assets via a
|
||||
dedicated **signing-only subkey** (`DC20B3978326B78B`). Master key
|
||||
(`CD1D23365D028C41`) never enters CI. Verification command unchanged
|
||||
— `gpg --verify` against the master fingerprint accepts subkey
|
||||
signatures.
|
||||
|
||||
Common caveats:
|
||||
|
||||
- **Sublime Text build**: ST4 ≥ 4143 recommended. `on_hover` requires
|
||||
ST4.
|
||||
- **Path separators**: remote paths stay POSIX on every platform. Both
|
||||
local and remote paths should work as clickable targets.
|
||||
|
||||
macOS caveats (primary pass):
|
||||
|
||||
- **Cmd+click** for Terminus URL / absolute-path click-through.
|
||||
- **Gatekeeper** may warn "cannot be opened because developer cannot
|
||||
be verified" on the first `local_bridge` / `session_helper` invoke
|
||||
if you ran them from the downloaded release bundle. Right-click →
|
||||
Open once, or `xattr -d com.apple.quarantine <path>`. Locally-built
|
||||
binaries don't carry the quarantine attr.
|
||||
- **SSH**: system OpenSSH (`/usr/bin/ssh`) is fine; Homebrew's at
|
||||
`/opt/homebrew/bin/ssh` on Apple Silicon takes precedence if earlier
|
||||
on `$PATH`. Verify `ssh -V` prints ≥ 8.x.
|
||||
- **Cache path**: `~/Library/Caches/Sublime Text/Sessions/cache/<key>/…`
|
||||
(Sublime Text 4).
|
||||
- **PersistentBroker is active** — the broker_socket blocker fires for
|
||||
real on macOS if something's off, so it's a live signal, not a
|
||||
Windows-style "suppress and move on".
|
||||
|
||||
Windows caveats (deltas):
|
||||
|
||||
- **Ctrl+click** wherever this doc says Cmd+click.
|
||||
- **No `cmd.exe` flashes** — every SSH child runs with
|
||||
`CREATE_NO_WINDOW` (v0.6.1). A black console blink on Terminus open /
|
||||
agent spawn / Jupyter launch is a regression (§10 bundle).
|
||||
- **SSH**: OpenSSH for Windows at `C:\Windows\System32\OpenSSH\ssh.exe`
|
||||
unless `%PATH%` prefers Git Bash's bundled copy.
|
||||
- **Cache path**: `%LOCALAPPDATA%\Sublime Text\Cache\Sessions\cache\<key>\…`.
|
||||
|
||||
Each scenario has a **verify** line and an **acceptance** line —
|
||||
acceptance is the binary pass/fail.
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
- [ ] Git pull to `main` at `v0.6.4` or later; `v0.6.4` tag visible
|
||||
via `git tag -l v0.6.4`
|
||||
- [ ] `cargo build --manifest-path rust/Cargo.toml --release --workspace`
|
||||
produces the three binaries without warnings. Filenames per platform:
|
||||
- macOS: `local_bridge` + `session_helper` + `libsessions_native.dylib`
|
||||
- Windows: `local_bridge.exe` + `session_helper.exe` + `sessions_native.dll`
|
||||
- [ ] Sublime package installed (`sublime/` folder symlinked into
|
||||
`Packages/Sessions/` OR installed via Package Control if set up)
|
||||
- [ ] Terminus package installed (command palette shows
|
||||
`Terminus: Toggle Terminal`)
|
||||
- [ ] SSH alias that works non-interactively: `ssh <alias> uname -a` prints
|
||||
`Linux … x86_64` inside ~15 seconds. If it prompts for password,
|
||||
fix `ssh-agent` / `~/.ssh/config` first — every Sessions flow
|
||||
assumes non-interactive SSH.
|
||||
- [ ] On the remote, **at least one** of `tmux`, `jupyterlab`, `debugpy`
|
||||
is not installed yet — you'll install one via the palette below
|
||||
to exercise the install flow.
|
||||
|
||||
---
|
||||
|
||||
## 1. Connect + mirror burst safety (v0.5.0)
|
||||
|
||||
- [ ] `Sessions: Connect Remote Workspace` → pick host → pick
|
||||
workspace root that contains **at least one big directory**
|
||||
(≥150 children). The `.mamba/pkgs`, `node_modules`, or a dataset
|
||||
folder works.
|
||||
- [ ] **Watch the console** for the first 60 seconds after the
|
||||
sidebar starts populating.
|
||||
- [ ] **Verify**: no `[Sessions]` entries for
|
||||
`aborted_by_failure_budget`; `Sessions ready: Sidebar …` status
|
||||
appears; no AV/EDR popup (Windows Defender ransomware warning,
|
||||
macOS Gatekeeper quarantine block).
|
||||
- [ ] **Verify**: the big directory has a sidebar stub but no children
|
||||
under it on disk (check the platform cache path — see caveats
|
||||
section).
|
||||
- **Acceptance**: connect completes without `aborted_by_failure_budget`
|
||||
and the oversized directory is recorded as deferred.
|
||||
|
||||
### 1.1 Expand deferred directory
|
||||
|
||||
- [ ] Right-click the stub in the sidebar → **Sessions: Expand this
|
||||
folder**. (Not the palette "Expand Deferred Directory" — use the
|
||||
sidebar right-click so the `is_visible` / `is_enabled` wiring
|
||||
from v0.5.6 is exercised.)
|
||||
- [ ] Quick panel should **NOT** appear (that would mean the sidebar
|
||||
wiring regressed).
|
||||
- [ ] The directory should populate.
|
||||
- [ ] Console shows `expand.begin` + `expand.done` trace events with
|
||||
the target path and child count (v0.6.1 additions).
|
||||
- [ ] (v0.6.2) If deep mirror is still in flight, status hint reads
|
||||
something like "Sessions: deep mirror still running — try again
|
||||
when it finishes" instead of the older false "will appear
|
||||
shortly" promise. Once `sync.done` fires, retrying the expand
|
||||
succeeds.
|
||||
- [ ] (v0.6.2) Expanding a directory with **>5000 entries** prints a
|
||||
one-shot warning ("Sessions: <path> has N entries; expansion may
|
||||
take a while or be capped by `sessions_mirror_max_entries`").
|
||||
- **Acceptance**: right-click → expand works without the quick panel
|
||||
detour; trace events frame the operation; the v0.6.2 hint and
|
||||
large-dir warning surface as documented.
|
||||
|
||||
### 1.1.1 Eager hydrate retry at sync.done (v0.6.2)
|
||||
|
||||
- [ ] Connect to a remote workspace where the build-graph file (e.g.
|
||||
`pyproject.toml` for a sub-project) is buried in a deferred
|
||||
directory that won't be hydrated by the first eager pass.
|
||||
- [ ] Wait for `sync.done` to land in the trace log.
|
||||
- [ ] **Verify**: a SECOND `mirror.eager_hydrate_done` line appears
|
||||
after `sync.done`, with `hydrated > 0` for the previously empty
|
||||
placeholder.
|
||||
- **Acceptance**: build-graph files inside late-arriving directories
|
||||
are filled in once the deep mirror completes; LSP / interpreter
|
||||
picker no longer sees zero-byte placeholders for them.
|
||||
|
||||
### 1.1.2 Auto-refresh status silence (v0.6.2)
|
||||
|
||||
- [ ] Trigger any auto-refresh path (e.g. switching focus between
|
||||
Sessions windows multiple times within a few seconds).
|
||||
- [ ] **Verify**: console / output panel does NOT spam "Deepening
|
||||
mirror…" status on every tick. The status appears at most once
|
||||
per refresh burst, then goes silent.
|
||||
- **Acceptance**: no status-line flood from auto-refresh ticks.
|
||||
|
||||
### 1.2 Diag log quiet by default (v0.6.1)
|
||||
|
||||
- [ ] Open `<Sublime cache>/Sessions/logs/debug-trace.log` during a
|
||||
busy mirror-sync burst.
|
||||
- [ ] **Verify**: no `bridge.rust.helper_stdout_message` entries unless
|
||||
`SESSIONS_BRIDGE_DIAG_VERBOSE=1` is set in the Sublime launch env.
|
||||
- **Acceptance**: the high-volume stdout line is gated behind the env
|
||||
flag; routine traces remain readable.
|
||||
|
||||
### 1.3 broker_socket handshake (platform-split)
|
||||
|
||||
- [ ] **macOS / Linux**: watch Sublime output panel on connect.
|
||||
**Verify**: PersistentBroker initializes cleanly. A
|
||||
`handshake is missing broker_socket` panel on macOS is a REAL bug
|
||||
(Unix socket path wasn't negotiated) — collect §10 bundle.
|
||||
- [ ] **Windows**: same watch. **Verify**: no repeating
|
||||
`handshake is missing broker_socket` blocker loop. (PersistentBroker
|
||||
is Unix-only; v0.6.1 suppresses the blocker on
|
||||
`sys.platform == "win32"`. Seeing it on Windows means the suppress
|
||||
regressed.)
|
||||
- **Acceptance**: macOS sees a clean handshake; Windows sees no blocker
|
||||
spam.
|
||||
|
||||
### 1.4 LSP stale broker_socket auto-disable (v0.6.2)
|
||||
|
||||
Reproduce the boot loop the v0.6.2 fix targets:
|
||||
|
||||
- [ ] With a Sessions workspace project file that has been opened at
|
||||
least once (so `.sublime-project` has `LSP-pyright` /
|
||||
`LSP-ruff` rows with `--bridge-socket <path>`), close Sublime
|
||||
Text. Wait until the broker socket file at the recorded
|
||||
`<path>` is gone (it dies with the previous Sublime PID).
|
||||
- [ ] Reopen Sublime + the project, but **do not** trigger Sessions
|
||||
connect yet (or the handshake fix would re-write the socket
|
||||
path). Just wait at the empty editor.
|
||||
- [ ] **Verify (pre-handshake)**: console shows a single
|
||||
`lsp.disable_stale_rows … flipped=[LSP-pyright, LSP-ruff]`
|
||||
trace at `plugin_loaded`. The `.sublime-project` on disk now
|
||||
has `"enabled": false` on both rows.
|
||||
- [ ] **Verify (no crash storm)**: NO "LSP-pyright crashed (5 / 5
|
||||
times in the last 180.0 seconds)" dialog. The pre-handshake
|
||||
disable should land before LSP package retries.
|
||||
- [ ] Trigger Sessions connect. Bridge handshake fires. Once the
|
||||
handshake reports a live `broker_socket`, the LSP rows
|
||||
auto-re-enable on the next `lsp.refresh_all_managed_lsp_rows`
|
||||
pass; LSP-pyright + LSP-ruff start cleanly.
|
||||
- **Acceptance**: cold-start does not show the 5×crash dialog;
|
||||
managed LSP rows recover automatically once the live broker
|
||||
socket is back.
|
||||
|
||||
User-managed (`sessions_remote_stdio_managed: false`) rows are
|
||||
explicitly preserved untouched — confirm by adding such a row by
|
||||
hand and verifying it is still `"enabled": true` after the
|
||||
plugin_loaded sweep.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stub + lazy hydrate + eager build-graph hydrate (v0.5.0 / v0.5.8)
|
||||
|
||||
- [ ] Open a regular file (e.g. `src/lib.rs`) from the sidebar. Content
|
||||
appears.
|
||||
- [ ] **Verify**: the file used to be zero-bytes pre-click; now it has
|
||||
remote bytes. Check file size via Explorer.
|
||||
- [ ] Open `Cargo.toml` / `pyproject.toml` / `package.json` at the
|
||||
workspace root (if present). It **should already be hydrated**
|
||||
(non-zero) from the eager pass that fires on activation.
|
||||
Console has a `mirror.eager_hydrate_done` trace line with
|
||||
`hydrated` + `skipped_existing` counts.
|
||||
- **Acceptance**: regular files hydrate on first open; build-graph
|
||||
files are already hydrated automatically after connect.
|
||||
|
||||
### 2.1 Save write-back
|
||||
|
||||
- [ ] Edit any hydrated file → Save.
|
||||
- [ ] **Verify**: remote file's mtime advances (ssh to remote and
|
||||
`stat` or `ls -l`); no "reloading" chatter in the console after
|
||||
save (v0.5.5 fix).
|
||||
- [ ] (v0.6.2) After save, the inotify echo back from the remote does
|
||||
**NOT** trigger a "<file> changed on disk, reload?" prompt or a
|
||||
`mirror.file.reload` trace within the 5s self-cooldown window.
|
||||
Editing the same file again immediately after the cooldown
|
||||
expires still triggers proper reload behavior on genuine
|
||||
external edits.
|
||||
- **Acceptance**: save reaches the remote once, no auto-reload storm,
|
||||
no self-triggered reload chatter inside the 5s cooldown.
|
||||
|
||||
---
|
||||
|
||||
## 3. Terminus — hover links + persistent session (v0.5.8)
|
||||
|
||||
### 3.1 Persistent session
|
||||
|
||||
- [ ] `Sessions: Open Remote Terminal` → terminal opens, prompt is the
|
||||
remote shell.
|
||||
- [ ] Run `uname -a` + set an env var: `export FOO=bar`.
|
||||
- [ ] Switch to a different window, come back (or close + re-invoke
|
||||
`Sessions: Open Remote Terminal` from palette).
|
||||
- [ ] **Verify**: same terminal view is focused; `echo $FOO` still
|
||||
prints `bar`; `tmux display-message -p '#S'` prints
|
||||
`sessions-term-<alias>`.
|
||||
- **Acceptance**: history + env vars persist across open/close.
|
||||
|
||||
### 3.2 Hover links
|
||||
|
||||
- [ ] In the Terminus pane, run `echo https://example.com`. Hover the
|
||||
mouse over the URL text **without clicking**.
|
||||
- [ ] **Verify**: the URL is underlined in real time.
|
||||
- [ ] Cmd+click the URL → default browser opens example.com.
|
||||
- [ ] Run `ls -la` (not in `$HOME`; pick a deep path). Hover an
|
||||
absolute path → underlined. Cmd+click → opens in Sublime via
|
||||
on-demand fetch.
|
||||
- [ ] (v0.6.2) Run `python3 -m http.server 8080`. Hover the
|
||||
`0.0.0.0:8080` line → underlined as a clickable region.
|
||||
Cmd+click → opens `http://localhost:8080/` in the default
|
||||
browser. Same for `127.0.0.1:<port>` and bare `localhost:<port>`
|
||||
tokens.
|
||||
- [ ] (v0.6.2) Cmd+click on an absolute remote path that is currently
|
||||
*under the cursor's drag-select range* should still open the
|
||||
file in Sublime, not extend the selection. (drag_select
|
||||
suppression — regression check.)
|
||||
- **Acceptance**: hover underlines the clickable region; Cmd+click
|
||||
resolves URL, absolute path, and `localhost:PORT` / `127.0.0.1:PORT`
|
||||
forms; drag-select doesn't intercept the click.
|
||||
|
||||
### 3.3 Tmux fallback
|
||||
|
||||
- [ ] On a host that doesn't have tmux installed: `Sessions: Open
|
||||
Remote Terminal`.
|
||||
- [ ] **Verify**: a one-shot status hint reads
|
||||
"Sessions: tmux not found on <host> — install via Sessions: Install
|
||||
Remote Extension (tmux). Falling back to a non-persistent shell."
|
||||
Terminal still opens (non-persistent fallback).
|
||||
- **Acceptance**: missing tmux degrades gracefully with a clear hint.
|
||||
|
||||
### 3.4 New pane + kill terminal (v0.6.2)
|
||||
|
||||
- [ ] With a primary remote terminal already open via §3.1,
|
||||
`Sessions: New Remote Terminal Pane` from the palette.
|
||||
- [ ] **Verify**: a second tmux session named
|
||||
`sessions-term-<host>-2` (numbered) is created and Terminus
|
||||
attaches to it. The original `sessions-term-<host>` session is
|
||||
untouched (`tmux list-sessions` on remote shows both).
|
||||
- [ ] Repeat once more — third pane should land at
|
||||
`sessions-term-<host>-3`.
|
||||
- [ ] `Sessions: Kill Remote Terminal` → quick panel lists all live
|
||||
`sessions-term-<host>[-N]` rows. Pick the second one.
|
||||
- [ ] **Verify**: that exact tmux session is killed on the remote;
|
||||
the corresponding Sublime view is closed; other panes are
|
||||
unaffected.
|
||||
- **Acceptance**: numbered panes accumulate without conflicting; kill
|
||||
removes exactly one pane and cleans up the editor view.
|
||||
|
||||
---
|
||||
|
||||
## 4. Active Python interpreter (v0.5.7)
|
||||
|
||||
### 4.1 Folder browser
|
||||
|
||||
- [ ] `Sessions: Select Python Interpreter` → palette shows detected
|
||||
`.venv/bin/python` candidates + `Browse remote filesystem…` +
|
||||
`Enter custom absolute path…`.
|
||||
- [ ] Pick **Browse remote filesystem…** → new quick panel rooted at
|
||||
`$HOME` with `[dir] …` entries + `[py] python3` if present.
|
||||
- [ ] Descend into a directory; top row shows `Location: <path>`;
|
||||
`↑ ..` entry climbs.
|
||||
- [ ] (v0.6.2) `Back to interpreter picker…` row appears as the
|
||||
**first row of the folder browser**, immediately after the
|
||||
`Location: …` header (it used to sit at the bottom next to
|
||||
python binaries — easy to mis-click). Selecting it returns to
|
||||
the top picker without descending.
|
||||
- [ ] Navigate to a venv's `bin/` → select its `python` → command
|
||||
writes to `.sublime-project`.
|
||||
- **Acceptance**: folder browser descends into subdirectories and
|
||||
completes on picking an executable; path written into project file;
|
||||
"Back" row is at the top.
|
||||
|
||||
### 4.2 Status bar
|
||||
|
||||
- [ ] (v0.6.2) **Verify**: bottom bar reads
|
||||
`Python: <venv name> (<X.Y.Z>)` — the venv directory name (e.g.
|
||||
`.venv`, `proj-3.11`) followed by the resolved Python version
|
||||
in parens. The `<X.Y.Z>` value comes from a one-shot
|
||||
`python -V` probe that is cached per interpreter path; first
|
||||
activation may briefly show `(…)` while the probe runs.
|
||||
- [ ] `Sessions: Clear Python Interpreter` → on a Python view, bar
|
||||
reads `Python: (not set)` (slot retained, text-only signal — no
|
||||
glyph, no path). The slot is only dropped entirely by the
|
||||
syntax gate (see next step) or when the view leaves a Sessions
|
||||
workspace.
|
||||
- [ ] (v0.6.2) Open a **non-Python view** (e.g. a `.md` or `.json`
|
||||
file inside the Sessions workspace) → bar **drops the
|
||||
`Python:` slot** for that view. Switching back to a `.py` view
|
||||
restores it. (Syntax-gated.)
|
||||
- [ ] Open a non-Sessions file (e.g. a README on local disk) → bar
|
||||
shows nothing.
|
||||
- **Acceptance**: `Python: <venv> (<X.Y.Z>)` format renders for
|
||||
Python views in a Sessions workspace with an interpreter set;
|
||||
syntax-gated so other view types don't carry a stale slot.
|
||||
|
||||
### 4.3 Extension install + status labels
|
||||
|
||||
- [ ] `Sessions: Install Remote Extension` → quick panel lists every
|
||||
catalog entry including `tmux`, `claude-code`, `codex-cli`
|
||||
(new in v0.6.0).
|
||||
- [ ] Pick `jupyterlab` → install runs; status says "installed".
|
||||
- [ ] `Sessions: Remote Extension Status` → shows `installed` /
|
||||
`not installed` / `installed but unusable` (NOT the old "missing"
|
||||
label).
|
||||
- **Acceptance**: install flow reaches success; status labels match
|
||||
v0.5.7 tri-state.
|
||||
|
||||
---
|
||||
|
||||
## 5. Jupyter (v0.4.19 + v0.5.4/5/6 follow-ups)
|
||||
|
||||
- [ ] Pick a Python interpreter (§4) that has (or will have) ipykernel.
|
||||
- [ ] `Sessions: Open Remote Jupyter` → default browser opens to
|
||||
`http://127.0.0.1:<random>/lab?token=…`.
|
||||
- [ ] New notebook → kernel dropdown → default kernel is
|
||||
`Sessions <hash>` pointing at your selected interpreter.
|
||||
- [ ] Notebook cell: `import sys; print(sys.executable)` → prints the
|
||||
remote path you chose in §4.
|
||||
- [ ] In sidebar, click a `.ipynb` file inside the workspace →
|
||||
browser opens that specific notebook (URL ends in
|
||||
`/lab/tree/<relpath>`).
|
||||
- **Acceptance**: notebook opens, chosen interpreter drives the kernel,
|
||||
clicking `.ipynb` in sidebar routes to Jupyter instead of raw JSON.
|
||||
|
||||
Platform notes: on Windows the browser tab may be blocked until you
|
||||
accept `127.0.0.1` in corporate security; on macOS the system browser
|
||||
opens directly. If the browser is blocked, the underlying flow is still
|
||||
OK — check `Sessions: Stop Remote Jupyter` and relaunch.
|
||||
|
||||
---
|
||||
|
||||
## 6. Debugger (v0.4.20)
|
||||
|
||||
- [ ] `Sessions: Install Remote Extension` → `debugpy (remote Python
|
||||
debugger)` → installs into the active interpreter.
|
||||
- [ ] `Sessions: Setup Remote Python Debugging` → output panel opens
|
||||
with `ssh -N -L 5678:127.0.0.1:5678 <alias>` instructions.
|
||||
- [ ] `.sublime-project` gains a `"debugger_configurations": […]` list
|
||||
with a `"Sessions: Attach remote Python"` entry.
|
||||
- [ ] (Optional) Run the instructions: on the remote, launch
|
||||
`<active_python> -m debugpy --listen 0.0.0.0:5678 --wait-for-client
|
||||
some_script.py`; locally, open the SSH tunnel; if you have
|
||||
daveleroy/sublime_debugger installed, the Debugger panel shows
|
||||
the "Sessions: Attach remote Python" entry and "Start" attaches.
|
||||
- **Acceptance**: debugpy installs; DAP stub lands in project file;
|
||||
instructions panel is accurate.
|
||||
|
||||
---
|
||||
|
||||
## 7. Agent sessions — tmux flagship (v0.6.0, NEW)
|
||||
|
||||
Pre-req: `tmux` installed on the remote. If not:
|
||||
`Sessions: Install Remote Extension` → `tmux (agent session prerequisite)`.
|
||||
|
||||
### 7.1 New agent session
|
||||
|
||||
- [ ] `Sessions: Install Remote Extension` → pick `Claude Code CLI
|
||||
(remote)` OR `OpenAI Codex CLI (remote)` (at least one). Install
|
||||
succeeds.
|
||||
- [ ] `Sessions: New Agent Session` → quick panel lists the installed
|
||||
agents (`Claude Code CLI (remote)`, `OpenAI Codex CLI (remote)`).
|
||||
`tmux` prerequisite is filtered out of this list.
|
||||
- [ ] Pick one agent.
|
||||
- [ ] **Verify (layout)**: window splits into three columns:
|
||||
`[editor | Terminus | Sessions · Agents]` (40% / 40% / 20%
|
||||
roughly).
|
||||
- [ ] **Verify (Terminus group)**: middle pane shows the agent's CLI
|
||||
running inside a tmux session named `sessions-agent-<ws8>-<agent_id>`
|
||||
(visible via `tmux display-message -p '#S'` inside the pane).
|
||||
- [ ] (v0.6.2) **Verify (no `not a terminal` error)**: spawn does
|
||||
not surface `open terminal failed: not a terminal` in the
|
||||
Terminus pane. The remote `tmux new-session` runs with `-d`
|
||||
so it survives non-TTY SSH children.
|
||||
- [ ] **Verify (switcher group)**: right pane lists one entry
|
||||
`● <ws8> · <agent> (active)`, trailing separator + `+ New agent
|
||||
session…` row.
|
||||
- **Acceptance**: first new-session spawns tmux, attaches Terminus,
|
||||
renders switcher; no TTY-related spawn failure.
|
||||
|
||||
### 7.2 Switch between agent sessions
|
||||
|
||||
- [ ] Run `Sessions: New Agent Session` a second time — pick the
|
||||
OTHER agent this time.
|
||||
- [ ] Switcher now lists two rows; the most recent one is `●`
|
||||
(active), the first is `○`.
|
||||
- [ ] **Cmd+click** the inactive row in the switcher pane.
|
||||
- [ ] **Verify**: Terminus pane re-attaches to the previously dormant
|
||||
tmux session. The tmux process on the remote wasn't killed — it
|
||||
just wasn't attached. Any output it had printed while you were
|
||||
on the other agent should still be in the scrollback.
|
||||
- **Acceptance**: switching does NOT re-spawn the agent; the original
|
||||
session survives the attach/detach cycle.
|
||||
|
||||
### 7.3 New session from switcher
|
||||
|
||||
- [ ] Click `+ New agent session…` row in the switcher.
|
||||
- [ ] **Verify**: the same quick panel from §7.1 pops up.
|
||||
- **Acceptance**: the `__new__` sentinel routes correctly.
|
||||
|
||||
### 7.4 Persistence across Sublime restart
|
||||
|
||||
- [ ] With two agent sessions running, close Sublime Text entirely.
|
||||
- [ ] On the remote: `tmux list-sessions` shows both
|
||||
`sessions-agent-…` sessions still alive.
|
||||
- [ ] Reopen Sublime, reopen the project.
|
||||
- [ ] `Sessions: Show Agent Switcher` → switcher re-appears but the
|
||||
registry is EMPTY (v0.6.0 does not persist pairs across
|
||||
restarts — documented limitation). `Sessions: New Agent
|
||||
Session` → pick agent → it re-attaches to the existing tmux
|
||||
session rather than spawning a new one (because `tmux new-session
|
||||
-A` is idempotent).
|
||||
- **Acceptance**: tmux sessions survive Sublime restart; Sessions
|
||||
attaches rather than spawns fresh.
|
||||
|
||||
### 7.5 Kill agent session
|
||||
|
||||
- [ ] With an agent pair active, `Sessions: Kill Agent Session`.
|
||||
- [ ] **Verify**: the active tmux session is gone (`tmux list-sessions`
|
||||
on remote); the Terminus pane shows the SSH child has exited;
|
||||
switcher drops that row.
|
||||
- [ ] Sessions of OTHER workspaces are untouched.
|
||||
- **Acceptance**: kill removes exactly the active pair.
|
||||
|
||||
---
|
||||
|
||||
## 8. Release verification (v0.5.1+, refreshed for v0.6.4 signing model)
|
||||
|
||||
- [ ] Pull the `v0.6.4` release assets from
|
||||
<https://git.teahaven.kr/sublime-rs/sessions/releases/tag/v0.6.4>.
|
||||
Five files: `local_bridge`, `session_helper`,
|
||||
`libsessions_native.so`, `SHA256SUMS`, `SHA256SUMS.asc`.
|
||||
- [ ] `gpg --keyserver keys.openpgp.org --recv-keys
|
||||
C01DF8180774AC13909B5E52CD1D23365D028C41`
|
||||
- [ ] `gpg --verify SHA256SUMS.asc SHA256SUMS`
|
||||
→ "Good signature from Myeongseon Choi"
|
||||
- [ ] (v0.6.4) **Verify the signing key in the GPG output is the
|
||||
subkey, not the master.** Look for `using RSA key
|
||||
C6055FB91CA8C0E96B2D488ADC20B3978326B78B` (or the long ID
|
||||
`DC20B3978326B78B`). Master `CD1D23365D028C41` should NOT be
|
||||
the signing-key line. The "Good signature" verdict still
|
||||
verifies under the master fingerprint (subkey signs are
|
||||
validated through master cert).
|
||||
- [ ] `sha256sum -c SHA256SUMS` → every entry OK
|
||||
- [ ] `git tag -v v0.6.4` → "Good signature"; same subkey-fingerprint
|
||||
check as above. (Tags from v0.6.4 onward are subkey-signed
|
||||
because GnuPG prefers the signing subkey when both master and
|
||||
subkey have the same valid signing capability.)
|
||||
- **Acceptance**: all four verifications succeed AND the GPG output
|
||||
attributes signing to the subkey, not the master.
|
||||
|
||||
### 8.1 CI signed-publish flow (maintainer-only)
|
||||
|
||||
Run on a fresh tag push (e.g. cutting `v0.6.5` for an unrelated fix):
|
||||
|
||||
- [ ] Push the signed tag. Watch
|
||||
`Release Publish (Gitea session_helper)` workflow.
|
||||
- [ ] **Verify** `verify-release-tag` job passes: gate fix from
|
||||
v0.6.3 keeps the `git merge-base --is-ancestor` check working
|
||||
when the tag commit is a parent of main HEAD (no `--depth=1`
|
||||
shallow grafting).
|
||||
- [ ] **Verify** `publish-linux-x86_64` job runs:
|
||||
- `Import GPG signing subkey` step succeeds and prints the
|
||||
`[S] DC20B3978326B78B` line in `gpg --list-secret-keys`.
|
||||
- `Sign release artifacts` step produces
|
||||
`SHA256SUMS` + `SHA256SUMS.asc` under `dist/v<version>/`.
|
||||
- `Create release page + upload signed bundle` step uploads 5
|
||||
assets to the release page (id printed in step output).
|
||||
- `Upload session_helper to Gitea generic registry` step uploads
|
||||
`session_helper-linux-x86_64` to the package registry but does
|
||||
NOT touch the release page (no title flap from concern split).
|
||||
- [ ] On the published release page, asset list contains exactly
|
||||
the 5 signed-bundle files (no duplicates, no missing entries).
|
||||
- [ ] On the package registry
|
||||
<https://git.teahaven.kr/sublime-rs/-/packages>, there is a
|
||||
new `sessions-session-helper` version row matching the tag.
|
||||
- **Acceptance**: end-to-end CI publish runs unattended; signed
|
||||
bundle is on the release page; musl `session_helper` is in the
|
||||
generic registry; master key never appears in any CI log.
|
||||
|
||||
---
|
||||
|
||||
## 9. Known limitations (NOT bugs)
|
||||
|
||||
These are intentional scope cuts for v0.6.4; do NOT file them as
|
||||
regressions.
|
||||
|
||||
- D7 Phase 1 (agent proposal output panel tailing `tmux pipe-pane`)
|
||||
is not yet wired — only the parser primitives exist (covered by
|
||||
`test_agent_proposal_watcher_adversarial.py`). Target v0.7.
|
||||
- D7 Phase 2 (post-apply change-badge phantom via `file/watch`) also
|
||||
target v0.7; the renderer exists (`agent_change_badge.py`) but the
|
||||
`file/watch` driver is not plumbed.
|
||||
- Agent pair registry is in-memory; closing Sublime loses the pair
|
||||
list but not the tmux sessions themselves.
|
||||
- Switcher view is read-only text; no drag-to-reorder, no per-row
|
||||
inline menu.
|
||||
- PersistentBroker is Unix-only; on Windows the LSP stdio wiring runs
|
||||
without it (tracked under Track W in BACKLOG).
|
||||
- Terminus Cmd+click may still misfire on specific ST4 builds where
|
||||
the `on_hover` API reports different hover zones; report the exact
|
||||
build in the bug if you hit this.
|
||||
|
||||
---
|
||||
|
||||
## 10. When something fails
|
||||
|
||||
Collect this bundle for each failure before filing:
|
||||
|
||||
1. `<platform>.log` (e.g. `macos.log`, `windows.log`) — the paste buffer
|
||||
under `<Sublime cache>/Sessions/logs/debug-trace.log`. Annotate each
|
||||
test step's start + end as plain-text bookmarks.
|
||||
2. `local_bridge --version` output from the binary actually loaded
|
||||
(path is logged on every connect as `bridge_path`).
|
||||
3. Output of `tmux list-sessions` on the remote (for agent / terminal
|
||||
flows).
|
||||
4. `.sublime-project` contents post-repro (for interpreter /
|
||||
debugger flows).
|
||||
5. A single screenshot of the Sublime window when the failure is on
|
||||
screen.
|
||||
|
||||
File these together so the root cause isn't inferred from a partial
|
||||
trace.
|
||||
165
planning/V0_6_5_REPRO.md
Normal file
165
planning/V0_6_5_REPRO.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# V0_6_5_REPRO — focused repro for current macOS test pass issues
|
||||
|
||||
Short, narrow checklist. **Not** a full feature test (`TEST_CHECKLIST.md`
|
||||
is for that). Goal here: confirm the v0.6.5 batch-3 fixes work on the
|
||||
real macOS host that hit them, and capture diagnostic data for the
|
||||
remaining unresolved issues so the next debug round has signal to work
|
||||
with.
|
||||
|
||||
Run the steps **in order**. Paste the requested log fragments / observed
|
||||
behavior under each step. If a step fails unexpectedly, stop, capture
|
||||
the bundle from §10 of the full TEST_CHECKLIST, and ping back here.
|
||||
|
||||
## 0. Setup
|
||||
|
||||
```sh
|
||||
cd <Sessions checkout>
|
||||
git fetch origin && git checkout v0.6.5 # or main once v0.6.5 lands
|
||||
cargo build --manifest-path rust/Cargo.toml --release --workspace
|
||||
```
|
||||
|
||||
In `Packages/User/Sessions.sublime-settings`, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions_debug_trace_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
(Set `SESSIONS_BRIDGE_DIAG_VERBOSE=1` in the Sublime launch env only if
|
||||
asked below — it's noisy.)
|
||||
|
||||
Restart Sublime, reopen the test workspace.
|
||||
|
||||
---
|
||||
|
||||
## A. Verify just-fixed items (should now PASS)
|
||||
|
||||
### A1. Agent tmux spawn — no `not a terminal`
|
||||
|
||||
Palette → `Sessions: New Agent Session` → pick `Claude Code CLI (remote)`.
|
||||
|
||||
- [ ] **No** "Sessions warning: Agent session start failed ... open
|
||||
terminal failed: not a terminal". Terminus pane opens; tmux
|
||||
session `sessions-agent-<ws>-claude-code` runs.
|
||||
- [ ] On the remote: `tmux list-sessions | grep sessions-agent` shows it.
|
||||
|
||||
If this still errors, paste the full warning string + grep
|
||||
`bridge.rust.helper_stdout_message` lines around the failure timestamp
|
||||
from `<Sublime cache>/Sessions/logs/debug-trace.log`.
|
||||
|
||||
### A2. New Remote Terminal Pane / Kill Remote Terminal in palette
|
||||
|
||||
- [ ] Palette → type "Sessions: New Remote Terminal Pane" — entry now
|
||||
appears. Select it; numbered tmux session
|
||||
(`sessions-term-<host>-2`) opens.
|
||||
- [ ] Palette → "Sessions: Kill Remote Terminal" — entry now appears.
|
||||
Select it; quick panel lists live terminals; pick one to kill.
|
||||
|
||||
### A3. localhost:PORT canonical URL
|
||||
|
||||
In any Terminus pane: `python3 -m http.server 8080`.
|
||||
|
||||
- [ ] Hover the `0.0.0.0:8080` line — underlined.
|
||||
- [ ] Cmd+click → browser opens **`http://localhost:8080/`** (canonical
|
||||
form with `localhost` host + trailing slash). Should NOT be
|
||||
`about:blank-` or `about:blank` anymore.
|
||||
- [ ] Repeat with `127.0.0.1:8080` line — opens
|
||||
`http://127.0.0.1:8080/`.
|
||||
|
||||
### A4. `Sessions: Preview Remote Agent Payload` hidden
|
||||
|
||||
- [ ] Palette → type "Sessions: Preview" — should **not** show
|
||||
"Preview Remote Agent Payload" by default.
|
||||
- [ ] In `Packages/User/Sessions.sublime-settings`, add
|
||||
`"sessions_show_dev_commands": true`. Reload settings (or
|
||||
restart). Re-type — entry now appears.
|
||||
- [ ] Revert the setting back to `false` (or remove the line).
|
||||
|
||||
---
|
||||
|
||||
## B. Still-broken — capture diagnostic data
|
||||
|
||||
### B1. mirror-sync deep traversal hang at `awaiting_response_dispatch`
|
||||
|
||||
Symptom from previous capture: every ~60s a deep
|
||||
`mirror-sync force_full_sync=true max_traversal_depth=12` request hangs
|
||||
at `bridge.request_timeout` after 45s with
|
||||
`stall_phase=awaiting_response_dispatch`. Shallow sync (depth 2) returns
|
||||
in <300ms. Workspace effectively never finishes hydrating, so
|
||||
"No deferred directory to expand" fires (deferred state never recorded)
|
||||
and `sync.done` never lands (eager-hydrate retry never fires).
|
||||
|
||||
Capture:
|
||||
|
||||
1. Set `SESSIONS_BRIDGE_DIAG_VERBOSE=1` in the Sublime launch env (so
|
||||
`bridge.rust.helper_stdout_message` lines also land in the trace).
|
||||
2. Connect to the host that reproduces this (the aws-celery host).
|
||||
3. Wait 5 minutes — long enough for at least 2 timeout cycles.
|
||||
4. From `<Sublime cache>/Sessions/logs/debug-trace.log`, paste the
|
||||
block of lines between two consecutive `mirror_queue.enqueue
|
||||
task=work` events that wrap a `bridge.request_timeout`. Should
|
||||
include all `bridge.rust.*` lines in between.
|
||||
|
||||
What to look for in the paste:
|
||||
|
||||
- Any `bridge.rust.helper_stdout_eof` — helper closed stdout before
|
||||
responding (suggests session_helper died on the remote, possibly
|
||||
out-of-memory on the deep walk).
|
||||
- Any `bridge.rust.helper_stdout_message` with abnormally long line
|
||||
payloads — large response chunks that may exceed channel buffer
|
||||
and stall the dispatcher.
|
||||
- The final `bridge.request_done` (if any) for the `mirror-sync` id
|
||||
before the timeout fires.
|
||||
|
||||
Also: on the remote, while a deep sync is in flight, run
|
||||
`ps -ef | grep session_helper` and paste output. We want to see if
|
||||
the helper is actually busy (CPU > 0) or idle (already responded but
|
||||
the response is stuck somewhere local).
|
||||
|
||||
### B2. Hover absolute remote path → does not open in Sublime
|
||||
|
||||
`ls -la /etc` (or any deep path) in a Terminus pane.
|
||||
|
||||
- [ ] Hover an absolute path line. Underline appears? **yes / no**
|
||||
- [ ] Cmd+click. Sublime opens the file? **yes / no — what happens
|
||||
instead** (about:blank? nothing at all? error in console?)
|
||||
|
||||
If nothing opens: paste any line from `debug-trace.log` matching
|
||||
`terminal_link.click` or `bridge.request` that lands within ~3 seconds
|
||||
of the click.
|
||||
|
||||
### B3. `Sessions: Open Remote Jupyter` — silent
|
||||
|
||||
Palette → "Sessions: Open Remote Jupyter".
|
||||
|
||||
- [ ] Browser tab opens? **yes / no**.
|
||||
- [ ] `Sessions: Stop Remote Jupyter` available afterward?
|
||||
- [ ] Paste lines from `debug-trace.log` matching `jupyter` and
|
||||
`queue.dequeue task=jupyter_open`. The previous capture showed
|
||||
`queue.done elapsed_ms=27748` but no visible browser tab —
|
||||
need to know whether the launch URL is being constructed at
|
||||
all, or constructed but not opened.
|
||||
|
||||
### B4. `Sessions: New Agent Session` quick panel
|
||||
|
||||
If A1 succeeds, this is likely also fine. If A1 still errors, A1 is
|
||||
the upstream cause of "역시 아무 것도 뜨지 않음".
|
||||
|
||||
Confirm: after A1 succeeds, can you run `Sessions: New Agent Session`
|
||||
again and pick `OpenAI Codex CLI (remote)` to start a second pair?
|
||||
|
||||
---
|
||||
|
||||
## What to send back
|
||||
|
||||
For each step under §A, mark **PASS / FAIL** + behavior summary.
|
||||
|
||||
For each step under §B, paste:
|
||||
- the requested log fragments (B1, B3 especially)
|
||||
- a short observation note ("nothing opens", "browser opens to wrong
|
||||
URL X", etc.)
|
||||
|
||||
Optional: attach the full debug-trace.log slice from the start of the
|
||||
session to the end of the test pass — useful for cross-correlation
|
||||
when individual steps look fine but downstream behavior breaks.
|
||||
@@ -1,8 +1,11 @@
|
||||
[project]
|
||||
name = "sessions-sublime"
|
||||
version = "0.4.18"
|
||||
version = "0.6.5"
|
||||
description = "Sublime-facing Python code for Sessions."
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "MIT"}
|
||||
authors = [{name = "Myeongseon Choi", email = "key262yek@gmail.com"}]
|
||||
urls = {Homepage = "https://git.teahaven.kr/sublime-rs/sessions", Repository = "https://git.teahaven.kr/sublime-rs/sessions"}
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
10
rust/Cargo.lock
generated
10
rust/Cargo.lock
generated
@@ -202,7 +202,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "local_bridge"
|
||||
version = "0.4.18"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"glob",
|
||||
@@ -406,7 +406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_helper"
|
||||
version = "0.4.18"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"notify",
|
||||
@@ -417,7 +417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_protocol"
|
||||
version = "0.4.18"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde",
|
||||
@@ -426,7 +426,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sessions_native"
|
||||
version = "0.4.18"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"session_protocol",
|
||||
@@ -731,7 +731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "workspace_identity"
|
||||
version = "0.4.18"
|
||||
version = "0.6.5"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
|
||||
@@ -11,7 +11,16 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
version = "0.4.18"
|
||||
version = "0.6.5"
|
||||
authors = ["Myeongseon Choi <key262yek@gmail.com>"]
|
||||
repository = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
homepage = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
description = "Sessions — Sublime Text remote-SSH plugin (bridge + helper binaries)."
|
||||
readme = "README.md"
|
||||
# Rich metadata makes the local_bridge / session_helper binaries identifiable to
|
||||
# security scanners (strings | grep -i sessions) and reputation services. This is
|
||||
# a best-effort mitigation against heuristic flagging of the unsigned release
|
||||
# binaries; see ``SECURITY.md`` for details on what the binaries do / don't do.
|
||||
|
||||
[workspace.lints.clippy]
|
||||
unwrap_used = "deny"
|
||||
|
||||
@@ -3,6 +3,10 @@ name = "local_bridge"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Long-lived SSH bridge FSM powering the Sessions Sublime plugin."
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -14,6 +14,24 @@ static BRIDGE_DIAG_EVENT_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::ne
|
||||
/// Environment variable: absolute path to append NDJSON lines (same file as Python trace is OK).
|
||||
pub const BRIDGE_DIAG_LOG_ENV: &str = "SESSIONS_BRIDGE_DIAG_LOG";
|
||||
|
||||
/// Environment variable: set to ``1`` to enable per-message verbose events
|
||||
/// (``bridge.rust.helper_stdout_message``). Without this, the per-response
|
||||
/// log lines are suppressed so the trace file doesn't fill with normal
|
||||
/// protocol traffic. Error paths (``helper_stdout_eof`` /
|
||||
/// ``helper_stdout_decode_err``) always log regardless.
|
||||
pub const BRIDGE_DIAG_VERBOSE_ENV: &str = "SESSIONS_BRIDGE_DIAG_VERBOSE";
|
||||
|
||||
/// Return ``true`` when verbose per-message events should be written.
|
||||
pub fn bridge_diag_verbose_enabled() -> bool {
|
||||
match std::env::var(BRIDGE_DIAG_VERBOSE_ENV) {
|
||||
Ok(value) => {
|
||||
let trimmed = value.trim();
|
||||
!trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a ``u64`` unix timestamp (whole seconds) + millis part as
|
||||
/// ``YYYY-MM-DD HH:MM:SS.mmm`` in UTC. ``std`` alone can't do
|
||||
/// timezone-aware formatting without pulling ``chrono`` / ``time``;
|
||||
|
||||
@@ -27,7 +27,9 @@ pub mod retry;
|
||||
pub mod session_failure;
|
||||
pub mod stderr_policy;
|
||||
|
||||
pub use diag_log::{BRIDGE_DIAG_LOG_ENV, bridge_diag_event};
|
||||
pub use diag_log::{
|
||||
BRIDGE_DIAG_LOG_ENV, BRIDGE_DIAG_VERBOSE_ENV, bridge_diag_event, bridge_diag_verbose_enabled,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -746,13 +748,20 @@ fn spawn_helper_message_reader(
|
||||
}
|
||||
match decode_message(trimmed).map_err(BridgeRunError::from) {
|
||||
Ok(msg) => {
|
||||
bridge_diag_event(
|
||||
"bridge.rust.helper_stdout_message",
|
||||
json!({
|
||||
"kind": protocol_message_kind(&msg),
|
||||
"line_bytes": trimmed.len(),
|
||||
}),
|
||||
);
|
||||
// Per-message events fill the trace log with
|
||||
// normal protocol traffic; gate them behind
|
||||
// SESSIONS_BRIDGE_DIAG_VERBOSE=1 so the default
|
||||
// trace stays readable. Error paths below
|
||||
// (decode_err / eof) remain always-on.
|
||||
if bridge_diag_verbose_enabled() {
|
||||
bridge_diag_event(
|
||||
"bridge.rust.helper_stdout_message",
|
||||
json!({
|
||||
"kind": protocol_message_kind(&msg),
|
||||
"line_bytes": trimmed.len(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let _ = tx.send(Ok(msg));
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -27,8 +27,32 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// Embedded at compile time from Cargo.toml [workspace.package] metadata. The
|
||||
// strings end up in the stripped release binary and give EDR / reputation
|
||||
// scanners something identifiable to key off when writing allow-rules (see
|
||||
// ``SECURITY.md`` for context on why the bridge is flagged by some scanners).
|
||||
const LOCAL_BRIDGE_VERSION_BANNER: &str = concat!(
|
||||
env!("CARGO_PKG_NAME"),
|
||||
" ",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" — ",
|
||||
env!("CARGO_PKG_DESCRIPTION"),
|
||||
"\nHomepage: ",
|
||||
env!("CARGO_PKG_HOMEPAGE"),
|
||||
"\nAuthors: ",
|
||||
env!("CARGO_PKG_AUTHORS"),
|
||||
);
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.is_some_and(|first| matches!(first, "--version" | "-V" | "version"))
|
||||
{
|
||||
println!("{LOCAL_BRIDGE_VERSION_BANNER}");
|
||||
return;
|
||||
}
|
||||
if args.first().map(String::as_str) == Some("lsp-stdio") {
|
||||
if let Err(error) = run_lsp_stdio(&args[1..]) {
|
||||
eprintln!("{error}");
|
||||
@@ -182,6 +206,12 @@ struct MirrorSyncParams {
|
||||
ignore_patterns: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
prune_missing: Option<bool>,
|
||||
#[serde(default)]
|
||||
max_dir_fanout: Option<usize>,
|
||||
#[serde(default)]
|
||||
writes_per_second_cap: Option<u32>,
|
||||
#[serde(default)]
|
||||
consecutive_failure_budget: Option<u32>,
|
||||
}
|
||||
|
||||
fn run_persistent(args: &[String]) -> Result<(), BridgeRunError> {
|
||||
@@ -922,6 +952,15 @@ fn handle_mirror_sync(
|
||||
if let Some(v) = params.prune_missing {
|
||||
opts.prune_missing = v;
|
||||
}
|
||||
if let Some(v) = params.max_dir_fanout {
|
||||
opts.max_dir_fanout = v;
|
||||
}
|
||||
if let Some(v) = params.writes_per_second_cap {
|
||||
opts.writes_per_second_cap = v;
|
||||
}
|
||||
if let Some(v) = params.consecutive_failure_budget {
|
||||
opts.consecutive_failure_budget = v;
|
||||
}
|
||||
|
||||
let local_root = std::path::PathBuf::from(¶ms.local_files_root);
|
||||
let req_id_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
@@ -964,6 +1003,8 @@ fn handle_mirror_sync(
|
||||
"truncated_by_entry_limit": result.truncated_by_entry_limit,
|
||||
"entries_pruned": result.entries_pruned,
|
||||
"error_detail": result.error_detail,
|
||||
"deferred_directories": result.deferred_directories,
|
||||
"aborted_by_failure_budget": result.aborted_by_failure_budget,
|
||||
})),
|
||||
error: if result.ok() {
|
||||
None
|
||||
|
||||
@@ -10,6 +10,7 @@ use regex::Regex;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Remote entry kind aligned with Python `RemoteFileKind.value` sort order.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -77,16 +78,34 @@ pub struct RemoteCacheMirrorOptions {
|
||||
pub include_files: bool,
|
||||
pub ignore_patterns: Vec<String>,
|
||||
pub prune_missing: bool,
|
||||
/// Refuse to descend into any directory whose visible child count exceeds
|
||||
/// this cap; the directory itself is still mirrored but its children are
|
||||
/// deferred for explicit user expansion. ``0`` disables the cap.
|
||||
pub max_dir_fanout: usize,
|
||||
/// Token-bucket refill rate for file-placeholder writes (ops/second).
|
||||
/// ``0`` disables rate limiting.
|
||||
pub writes_per_second_cap: u32,
|
||||
/// Abort the BFS after this many consecutive failing ``fs`` writes (any
|
||||
/// success resets the counter). ``0`` disables the circuit breaker.
|
||||
pub consecutive_failure_budget: u32,
|
||||
}
|
||||
|
||||
impl Default for RemoteCacheMirrorOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_traversal_depth: 12,
|
||||
max_entries: 5000,
|
||||
// Conservative cap so a first-open mirror pass cannot produce a
|
||||
// file-creation burst large enough to trip ransomware heuristics.
|
||||
// Python callers override this from user settings when provided.
|
||||
max_entries: 1000,
|
||||
include_files: true,
|
||||
ignore_patterns: Vec::new(),
|
||||
prune_missing: true,
|
||||
// Huge directories (node_modules, vendor, datasets) stay as stubs
|
||||
// until the user explicitly expands them.
|
||||
max_dir_fanout: 100,
|
||||
writes_per_second_cap: 40,
|
||||
consecutive_failure_budget: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +119,11 @@ pub struct RemoteCacheMirrorResult {
|
||||
pub truncated_by_entry_limit: bool,
|
||||
pub entries_pruned: usize,
|
||||
pub error_detail: Option<String>,
|
||||
/// Remote directory paths whose visible-child count exceeded
|
||||
/// ``max_dir_fanout``; their children were skipped entirely.
|
||||
pub deferred_directories: Vec<String>,
|
||||
/// True when the consecutive-failure circuit breaker stopped the BFS.
|
||||
pub aborted_by_failure_budget: bool,
|
||||
}
|
||||
|
||||
impl RemoteCacheMirrorResult {
|
||||
@@ -108,6 +132,56 @@ impl RemoteCacheMirrorResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple token bucket that paces file-placeholder writes so sustained ops/s
|
||||
/// stay well below EDR ransomware thresholds. Bucket size equals the refill
|
||||
/// rate, so up to one second of buffered capacity is available as a burst.
|
||||
#[derive(Debug)]
|
||||
struct WriteTokenBucket {
|
||||
capacity: f64,
|
||||
refill_per_sec: f64,
|
||||
tokens: f64,
|
||||
last_refill: Instant,
|
||||
}
|
||||
|
||||
impl WriteTokenBucket {
|
||||
fn new(refill_per_sec: u32) -> Option<Self> {
|
||||
if refill_per_sec == 0 {
|
||||
return None;
|
||||
}
|
||||
let rate = f64::from(refill_per_sec);
|
||||
Some(Self {
|
||||
capacity: rate,
|
||||
refill_per_sec: rate,
|
||||
tokens: rate,
|
||||
last_refill: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_token(&mut self) {
|
||||
self.refill();
|
||||
if self.tokens >= 1.0 {
|
||||
self.tokens -= 1.0;
|
||||
return;
|
||||
}
|
||||
let deficit = 1.0 - self.tokens;
|
||||
let wait_secs = deficit / self.refill_per_sec;
|
||||
std::thread::sleep(Duration::from_secs_f64(wait_secs));
|
||||
self.refill();
|
||||
self.tokens = (self.tokens - 1.0).max(0.0);
|
||||
}
|
||||
|
||||
fn refill(&mut self) {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.last_refill);
|
||||
if elapsed.is_zero() {
|
||||
return;
|
||||
}
|
||||
let gained = elapsed.as_secs_f64() * self.refill_per_sec;
|
||||
self.tokens = (self.tokens + gained).min(self.capacity);
|
||||
self.last_refill = now;
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_glob_to_regex(segment: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for ch in segment.chars() {
|
||||
@@ -287,6 +361,20 @@ fn is_symlink(p: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Walk the remote tree and mirror paths under `local_files_root`.
|
||||
///
|
||||
/// Three safety caps interact on each BFS step:
|
||||
///
|
||||
/// * ``max_dir_fanout`` — any directory whose *visible* child count exceeds
|
||||
/// the cap is added to ``deferred_directories``; its children are not
|
||||
/// enqueued, so they produce no filesystem writes. The directory stub
|
||||
/// itself is still materialised so the sidebar shows it.
|
||||
/// * ``writes_per_second_cap`` — each zero-byte placeholder write waits for
|
||||
/// a token from ``WriteTokenBucket`` before touching disk, holding sustained
|
||||
/// throughput at the configured ops/s.
|
||||
/// * ``consecutive_failure_budget`` — every ``fs::write`` /
|
||||
/// ``fs::create_dir_all`` error increments a counter; a single success
|
||||
/// resets it. When the counter reaches the budget the loop exits cleanly
|
||||
/// with ``aborted_by_failure_budget = true``.
|
||||
pub fn mirror_remote_tree_to_local_cache<F>(
|
||||
mut list_directory: F,
|
||||
host_alias: &str,
|
||||
@@ -302,7 +390,11 @@ where
|
||||
let mut scanned = 0usize;
|
||||
let mut truncated = false;
|
||||
let mut pruned = 0usize;
|
||||
let mut deferred: Vec<String> = Vec::new();
|
||||
let mut aborted_by_failure_budget = false;
|
||||
let mut consecutive_failures = 0u32;
|
||||
let policy = DirectoryBrowsePolicy::default();
|
||||
let mut bucket = WriteTokenBucket::new(options.writes_per_second_cap);
|
||||
|
||||
if let Err(e) = fs::create_dir_all(local_files_root) {
|
||||
return RemoteCacheMirrorResult {
|
||||
@@ -316,7 +408,7 @@ where
|
||||
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
|
||||
queue.push_back((remote_root.to_string(), depth_budget));
|
||||
|
||||
while let Some((remote_dir, remaining)) = queue.pop_front() {
|
||||
'bfs: while let Some((remote_dir, remaining)) = queue.pop_front() {
|
||||
let raw_entries = match list_directory(host_alias, &remote_dir) {
|
||||
Ok(e) => e,
|
||||
Err(exc) => {
|
||||
@@ -327,10 +419,20 @@ where
|
||||
truncated_by_entry_limit: truncated,
|
||||
entries_pruned: pruned,
|
||||
error_detail: Some(format!("list_directory failed for {remote_dir}: {exc}")),
|
||||
deferred_directories: deferred,
|
||||
aborted_by_failure_budget,
|
||||
};
|
||||
}
|
||||
};
|
||||
let visible = evaluate_directory_entries_visible(&raw_entries, &policy);
|
||||
// Fanout gate: refuse to descend when a directory has too many visible
|
||||
// children. The parent directory stub already exists in the cache from
|
||||
// its own enqueuing step; we only skip expanding its children here.
|
||||
let fanout_exceeded = options.max_dir_fanout > 0 && visible.len() > options.max_dir_fanout;
|
||||
if fanout_exceeded && remote_dir != remote_root {
|
||||
deferred.push(remote_dir.clone());
|
||||
continue;
|
||||
}
|
||||
let mut keep_names: HashSet<String> = HashSet::new();
|
||||
for entry in &visible {
|
||||
if scanned >= max_entries {
|
||||
@@ -350,8 +452,22 @@ where
|
||||
let local_path = local_files_root.join(&rel);
|
||||
match entry.kind {
|
||||
RemoteFileKind::Directory => {
|
||||
if fs::create_dir_all(&local_path).is_ok() {
|
||||
dirs_created += 1;
|
||||
match fs::create_dir_all(&local_path) {
|
||||
Ok(()) => {
|
||||
dirs_created += 1;
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
Err(_) => {
|
||||
consecutive_failures = consecutive_failures.saturating_add(1);
|
||||
if tripped_failure_budget(
|
||||
options.consecutive_failure_budget,
|
||||
consecutive_failures,
|
||||
) {
|
||||
aborted_by_failure_budget = true;
|
||||
break 'bfs;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if remaining > 1 {
|
||||
queue.push_back((entry.remote_absolute_path.clone(), remaining - 1));
|
||||
@@ -361,14 +477,33 @@ where
|
||||
if let Some(parent) = local_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if !local_path.exists() && fs::write(&local_path, []).is_ok() {
|
||||
files_created += 1;
|
||||
if local_path.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = bucket.as_mut() {
|
||||
b.wait_for_token();
|
||||
}
|
||||
match fs::write(&local_path, []) {
|
||||
Ok(()) => {
|
||||
files_created += 1;
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
Err(_) => {
|
||||
consecutive_failures = consecutive_failures.saturating_add(1);
|
||||
if tripped_failure_budget(
|
||||
options.consecutive_failure_budget,
|
||||
consecutive_failures,
|
||||
) {
|
||||
aborted_by_failure_budget = true;
|
||||
break 'bfs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if options.prune_missing && !truncated {
|
||||
if options.prune_missing && !truncated && !aborted_by_failure_budget {
|
||||
let rel_here =
|
||||
relative_under_root(remote_root, &remote_dir).unwrap_or_else(|_| PathBuf::new());
|
||||
let local_dir = local_dir_for_remote_rel(local_files_root, &rel_here);
|
||||
@@ -386,9 +521,15 @@ where
|
||||
truncated_by_entry_limit: truncated,
|
||||
entries_pruned: pruned,
|
||||
error_detail: None,
|
||||
deferred_directories: deferred,
|
||||
aborted_by_failure_budget,
|
||||
}
|
||||
}
|
||||
|
||||
fn tripped_failure_budget(budget: u32, consecutive: u32) -> bool {
|
||||
budget > 0 && consecutive >= budget
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit {
|
||||
use super::*;
|
||||
|
||||
249
rust/crates/local_bridge/tests/mirror_policy_guardrails.rs
Normal file
249
rust/crates/local_bridge/tests/mirror_policy_guardrails.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
//! Tests for the v0.4.21 bounded-mirror-burst policy (fanout, token bucket,
|
||||
//! circuit breaker). These live next to the existing parity tests so the
|
||||
//! hardening defaults stay covered whenever the BFS algorithm is touched.
|
||||
|
||||
use local_bridge::remote_cache_mirror::{
|
||||
RemoteCacheMirrorOptions, RemoteDirectoryEntry, RemoteFileKind,
|
||||
mirror_remote_tree_to_local_cache,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::time::Instant;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error>>;
|
||||
|
||||
fn file_entry(name: &str, parent: &str) -> RemoteDirectoryEntry {
|
||||
RemoteDirectoryEntry {
|
||||
name: name.to_string(),
|
||||
remote_absolute_path: format!("{parent}/{name}"),
|
||||
kind: RemoteFileKind::RegularFile,
|
||||
is_symlink_loop: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_entry(name: &str, parent: &str) -> RemoteDirectoryEntry {
|
||||
RemoteDirectoryEntry {
|
||||
name: name.to_string(),
|
||||
remote_absolute_path: format!("{parent}/{name}"),
|
||||
kind: RemoteFileKind::Directory,
|
||||
is_symlink_loop: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanout_cap_defers_oversized_directory_children() -> TestResult {
|
||||
// Parent has one small sibling and one oversized (150-child) directory.
|
||||
// With fanout=100 we expect the oversized directory to be deferred
|
||||
// (recorded in ``deferred_directories``) and none of its 150 children to
|
||||
// exist in the local cache. The small sibling's entries still get
|
||||
// mirrored to verify siblings keep working.
|
||||
let root = "/srv/ws";
|
||||
let big_dir = format!("{root}/huge");
|
||||
let small_dir = format!("{root}/ok");
|
||||
let mut dirs: HashMap<String, Vec<RemoteDirectoryEntry>> = HashMap::new();
|
||||
dirs.insert(
|
||||
root.to_string(),
|
||||
vec![dir_entry("huge", root), dir_entry("ok", root)],
|
||||
);
|
||||
dirs.insert(
|
||||
big_dir.clone(),
|
||||
(0..150)
|
||||
.map(|i| file_entry(&format!("f{i}.txt"), &big_dir))
|
||||
.collect(),
|
||||
);
|
||||
dirs.insert(small_dir.clone(), vec![file_entry("kept.txt", &small_dir)]);
|
||||
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let cache = tmp.path().join("cache");
|
||||
let result = mirror_remote_tree_to_local_cache(
|
||||
|_host, remote_directory| Ok(dirs.get(remote_directory).cloned().unwrap_or_default()),
|
||||
"h",
|
||||
root,
|
||||
&cache,
|
||||
&RemoteCacheMirrorOptions {
|
||||
max_traversal_depth: 5,
|
||||
max_entries: 5_000,
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: false,
|
||||
max_dir_fanout: 100,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.ok(), "{:?}", result.error_detail);
|
||||
assert!(!result.aborted_by_failure_budget);
|
||||
|
||||
// Oversized directory deferred and its 150 children absent from disk.
|
||||
assert_eq!(
|
||||
result.deferred_directories,
|
||||
vec![big_dir.clone()],
|
||||
"expected oversized directory to be the only deferred path",
|
||||
);
|
||||
let huge_local = cache.join("huge");
|
||||
assert!(huge_local.is_dir(), "parent stub should still be created");
|
||||
let huge_child_count = std::fs::read_dir(&huge_local)?.count();
|
||||
assert_eq!(
|
||||
huge_child_count, 0,
|
||||
"oversized dir children must be skipped"
|
||||
);
|
||||
|
||||
// Sibling still mirrors normally.
|
||||
assert!(cache.join("ok").join("kept.txt").is_file());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_bucket_paces_write_burst() -> TestResult {
|
||||
// 400 file creates with a 100 wps token bucket should take at least
|
||||
// ~3 seconds (first 100 burst is free, then 300 more at 100/s = 3s).
|
||||
let root = "/r";
|
||||
let mut dirs: HashMap<String, Vec<RemoteDirectoryEntry>> = HashMap::new();
|
||||
dirs.insert(
|
||||
root.to_string(),
|
||||
(0..400)
|
||||
.map(|i| file_entry(&format!("f{i}"), root))
|
||||
.collect(),
|
||||
);
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let cache = tmp.path().join("c");
|
||||
|
||||
let start = Instant::now();
|
||||
let result = mirror_remote_tree_to_local_cache(
|
||||
|_h, remote_directory| Ok(dirs.get(remote_directory).cloned().unwrap_or_default()),
|
||||
"h",
|
||||
root,
|
||||
&cache,
|
||||
&RemoteCacheMirrorOptions {
|
||||
max_traversal_depth: 1,
|
||||
max_entries: 1_000,
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: false,
|
||||
// No fanout cap, large enough that 400 flat entries still mirror.
|
||||
max_dir_fanout: 1_000,
|
||||
writes_per_second_cap: 100,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(result.ok(), "{:?}", result.error_detail);
|
||||
assert_eq!(result.file_placeholders_created, 400);
|
||||
// Steady-state drain: ~3 seconds for 300 over-burst writes. Accept a
|
||||
// 0.5 s tolerance below the theoretical minimum for CPU/scheduling noise.
|
||||
assert!(
|
||||
elapsed.as_secs_f64() >= 2.5,
|
||||
"token bucket produced burst too fast: {elapsed:?}",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circuit_breaker_aborts_on_consecutive_write_failures() -> TestResult {
|
||||
// Simulate EDR-style denial *without* relying on permission bits — CI often
|
||||
// runs as root, which bypasses ``chmod`` (root can write to any mode).
|
||||
// Instead we make remote return 20 *directories* (``d0``..``d19``), then
|
||||
// pre-create regular files at the corresponding local cache paths. The
|
||||
// mirror's ``fs::create_dir_all(local_path)`` fails with ENOTDIR on every
|
||||
// one — a failure even root cannot bypass — so the breaker trips after
|
||||
// 3 consecutive ``Err`` returns.
|
||||
use std::fs;
|
||||
|
||||
let root = "/srv/ws";
|
||||
let mut dirs: HashMap<String, Vec<RemoteDirectoryEntry>> = HashMap::new();
|
||||
dirs.insert(
|
||||
root.to_string(),
|
||||
(0..20).map(|i| dir_entry(&format!("d{i}"), root)).collect(),
|
||||
);
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let cache = tmp.path().join("cache");
|
||||
fs::create_dir_all(&cache)?;
|
||||
// Plant regular files at every ``cache/d{i}`` — the mirror will try to
|
||||
// ``create_dir_all`` over them and fail with ENOTDIR.
|
||||
for i in 0..20 {
|
||||
fs::write(cache.join(format!("d{i}")), b"")?;
|
||||
}
|
||||
|
||||
let result = mirror_remote_tree_to_local_cache(
|
||||
|_h, remote_directory| Ok(dirs.get(remote_directory).cloned().unwrap_or_default()),
|
||||
"h",
|
||||
root,
|
||||
&cache,
|
||||
&RemoteCacheMirrorOptions {
|
||||
max_traversal_depth: 1,
|
||||
max_entries: 100,
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: false,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 3,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.aborted_by_failure_budget,
|
||||
"breaker should have tripped"
|
||||
);
|
||||
assert!(result.ok());
|
||||
assert_eq!(
|
||||
result.directories_created, 0,
|
||||
"no directory writes should have succeeded when the paths are files",
|
||||
);
|
||||
assert_eq!(
|
||||
result.file_placeholders_created, 0,
|
||||
"no file writes attempted — remote entries were all directories",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanout_is_disabled_when_zero() -> TestResult {
|
||||
// ``max_dir_fanout = 0`` means unlimited; oversized dirs mirror fully.
|
||||
let root = "/r";
|
||||
let big = format!("{root}/big");
|
||||
let mut dirs: HashMap<String, Vec<RemoteDirectoryEntry>> = HashMap::new();
|
||||
dirs.insert(root.to_string(), vec![dir_entry("big", root)]);
|
||||
dirs.insert(
|
||||
big.clone(),
|
||||
(0..150)
|
||||
.map(|i| file_entry(&format!("f{i}"), &big))
|
||||
.collect(),
|
||||
);
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let cache = tmp.path().join("c");
|
||||
let result = mirror_remote_tree_to_local_cache(
|
||||
|_h, remote_directory| Ok(dirs.get(remote_directory).cloned().unwrap_or_default()),
|
||||
"h",
|
||||
root,
|
||||
&cache,
|
||||
&RemoteCacheMirrorOptions {
|
||||
max_traversal_depth: 5,
|
||||
max_entries: 5_000,
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: false,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
assert!(result.deferred_directories.is_empty());
|
||||
assert_eq!(result.file_placeholders_created, 150);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_options_apply_hardened_caps() {
|
||||
// The v0.4.21 Default impl is what Python falls back to when the user
|
||||
// omits every knob; assert the hardened values so we don't accidentally
|
||||
// ship a regression that restores the old 5000-entry limit.
|
||||
let opts = RemoteCacheMirrorOptions::default();
|
||||
assert_eq!(opts.max_entries, 1000);
|
||||
assert_eq!(opts.max_dir_fanout, 100);
|
||||
assert_eq!(opts.writes_per_second_cap, 40);
|
||||
assert_eq!(opts.consecutive_failure_budget, 3);
|
||||
}
|
||||
@@ -55,6 +55,9 @@ fn mirror_creates_dirs_and_file_placeholders() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -92,6 +95,9 @@ fn mirror_respects_entry_limit() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -125,6 +131,9 @@ fn mirror_skips_files_when_disabled() {
|
||||
include_files: false,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -199,6 +208,9 @@ fn mirror_skips_ignored_paths() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec!["node_modules".to_string()],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -237,6 +249,9 @@ fn mirror_prunes_stale_local_entries() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -277,6 +292,9 @@ fn mirror_skips_prune_when_truncated_by_entry_limit() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.truncated_by_entry_limit);
|
||||
@@ -312,6 +330,9 @@ fn mirror_respects_prune_disabled() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: false,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -357,6 +378,9 @@ mod prune_edge_cases {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -394,6 +418,9 @@ mod prune_edge_cases {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -429,6 +456,9 @@ mod prune_edge_cases {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
// Must not panic. On Linux, unlink of a 0o000 file succeeds when
|
||||
@@ -466,6 +496,9 @@ mod prune_edge_cases {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -512,6 +545,9 @@ mod prune_edge_cases {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -557,6 +593,9 @@ fn mirror_entry_limit_truncates_and_skips_prune() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.truncated_by_entry_limit);
|
||||
@@ -613,6 +652,9 @@ fn mirror_depth_limit_prevents_deep_traversal() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -654,6 +696,9 @@ fn mirror_entry_and_depth_limits_together_skip_prune() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.truncated_by_entry_limit);
|
||||
@@ -718,6 +763,9 @@ fn mirror_ignore_pattern_prevents_traversal_and_prune() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec!["node_modules".to_string()],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
@@ -765,6 +813,9 @@ fn mirror_symlink_loop_entry_is_skipped() {
|
||||
include_files: true,
|
||||
ignore_patterns: vec![],
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 0,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
assert!(result.ok());
|
||||
|
||||
@@ -3,6 +3,10 @@ name = "session_helper"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Remote-side helper binary for the Sessions Sublime plugin."
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -3,6 +3,10 @@ name = "session_protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Wire-level envelope + error types shared by Sessions bridge and helper."
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -3,6 +3,10 @@ name = "sessions_native"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Rust cdylib exposing bridge + workspace helpers to the Sessions Sublime plugin."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
@@ -476,6 +476,29 @@ fn bridge_parse_mirror_result(payload_json: &str) -> Result<String, c_int> {
|
||||
.map(serde_json::Value::String)
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
);
|
||||
let deferred_dirs: Vec<serde_json::Value> = result
|
||||
.get("deferred_directories")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.map(serde_json::Value::String)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
out.insert(
|
||||
"deferred_directories".to_string(),
|
||||
serde_json::Value::Array(deferred_dirs),
|
||||
);
|
||||
out.insert(
|
||||
"aborted_by_failure_budget".to_string(),
|
||||
serde_json::Value::from(
|
||||
result
|
||||
.get("aborted_by_failure_budget")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
),
|
||||
);
|
||||
Ok(serde_json::Value::Object(out).to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ name = "workspace_identity"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Workspace cache-key + remote-root helpers for the Sessions Sublime plugin."
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
384
scripts/create_gitea_release.py
Executable file
384
scripts/create_gitea_release.py
Executable file
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a Gitea release for ``v<version>`` and upload its signed asset bundle.
|
||||
|
||||
Companion to ``scripts/sign_release_artifacts.py``: that script produces
|
||||
``dist/v<version>/`` (binaries + ``SHA256SUMS`` + ``SHA256SUMS.asc``); this
|
||||
script publishes those files as release assets on the Gitea release page
|
||||
for the matching tag.
|
||||
|
||||
Why a separate script (not ``tea releases create``):
|
||||
- ``tea`` 0.9.2 silently drops ``--title`` and rejects the create call with
|
||||
"title is empty". We want a single, reliable command for the
|
||||
``cargo build → sign → publish`` ceremony.
|
||||
|
||||
Idempotent:
|
||||
- If the release already exists for the tag, its id is reused.
|
||||
- Existing assets with the same filename are deleted before upload so
|
||||
re-runs replace the file (Gitea returns 409 otherwise).
|
||||
|
||||
Token resolution (in order):
|
||||
1. ``--token`` flag
|
||||
2. ``TOKEN`` env var (matches CI)
|
||||
3. ``~/.config/tea/config.yml`` default login token (local dev convenience)
|
||||
|
||||
Typical local workflow::
|
||||
|
||||
cargo build --manifest-path rust/Cargo.toml --release --workspace
|
||||
python3 scripts/sign_release_artifacts.py
|
||||
python3 scripts/create_gitea_release.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_BASE_URL = "https://git.teahaven.kr"
|
||||
DEFAULT_OWNER = "sublime-rs"
|
||||
DEFAULT_REPO = "sessions"
|
||||
DEFAULT_DIST_ROOT = REPO_ROOT / "dist"
|
||||
# Browser-like UA matches scripts/upload_session_helper_to_gitea.py to dodge
|
||||
# Cloudflare error 1010 against urllib's default User-Agent.
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
default=None,
|
||||
help=(
|
||||
"Release version (without leading 'v'); defaults to the value "
|
||||
"from rust/Cargo.toml [workspace.package].version."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bundle-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Signed bundle directory (default: dist/v<version>/).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--title",
|
||||
default=None,
|
||||
help=(
|
||||
"Release title; defaults to the v<version> tag's signed-message "
|
||||
"subject if available, else 'v<version>'."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--body", default="", help="Release notes (default: empty).")
|
||||
parser.add_argument("--owner", default=DEFAULT_OWNER)
|
||||
parser.add_argument("--repo", default=DEFAULT_REPO)
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default=None,
|
||||
help="Gitea PAT; falls back to TOKEN env then ~/.config/tea/config.yml.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--draft", action="store_true", help="Create as draft (default: published)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prerelease",
|
||||
action="store_true",
|
||||
help="Mark as pre-release.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_workspace_version() -> str:
|
||||
"""Return the version string from ``rust/Cargo.toml``."""
|
||||
cargo_toml = REPO_ROOT / "rust" / "Cargo.toml"
|
||||
for line in cargo_toml.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("version") and "=" in stripped:
|
||||
_, _, rhs = stripped.partition("=")
|
||||
return rhs.strip().strip('"')
|
||||
raise RuntimeError("rust/Cargo.toml has no [workspace.package].version")
|
||||
|
||||
|
||||
def resolve_token(cli_token: Optional[str]) -> str:
|
||||
"""Return PAT from --token, ``TOKEN`` env, or ``~/.config/tea/config.yml``."""
|
||||
if cli_token:
|
||||
return cli_token.strip()
|
||||
env_token = (os.environ.get("TOKEN") or "").strip()
|
||||
if env_token:
|
||||
return env_token
|
||||
cfg = Path.home() / ".config" / "tea" / "config.yml"
|
||||
if cfg.is_file():
|
||||
for line in cfg.read_text(encoding="utf-8").splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("token:"):
|
||||
tok = s.split(":", 1)[1].strip()
|
||||
if tok:
|
||||
return tok
|
||||
raise SystemExit(
|
||||
"error: no Gitea token. Pass --token, set TOKEN env, or configure "
|
||||
"~/.config/tea/config.yml (e.g. via `tea login add`)."
|
||||
)
|
||||
|
||||
|
||||
def tag_message_subject(tag: str) -> Optional[str]:
|
||||
"""Return the signed-tag subject line (``%(contents:subject)``) or None."""
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(REPO_ROOT), "tag", "-l", "--format=%(contents:subject)", tag],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
subject = (proc.stdout or "").strip()
|
||||
return subject or None
|
||||
|
||||
|
||||
def _auth_headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": "token " + token,
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _api(base_url: str, owner: str, repo: str, path: str) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
return "{}/api/v1/repos/{}/{}/{}".format(
|
||||
base, quote(owner, safe=""), quote(repo, safe=""), path.lstrip("/")
|
||||
)
|
||||
|
||||
|
||||
def _request_json(
|
||||
url: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
headers: dict[str, str],
|
||||
body: Optional[bytes] = None,
|
||||
extra_headers: Optional[dict[str, str]] = None,
|
||||
) -> tuple[int, dict]:
|
||||
"""Issue a JSON request; return (status, parsed body or {})."""
|
||||
merged_headers = dict(headers)
|
||||
if body is not None and "Content-Type" not in merged_headers:
|
||||
merged_headers["Content-Type"] = "application/json"
|
||||
if extra_headers:
|
||||
merged_headers.update(extra_headers)
|
||||
request = Request(url, method=method, data=body)
|
||||
for k, v in merged_headers.items():
|
||||
request.add_header(k, v)
|
||||
try:
|
||||
with urlopen(request, timeout=120) as response:
|
||||
payload = response.read()
|
||||
status = response.getcode()
|
||||
except HTTPError as error:
|
||||
payload = error.read() or b""
|
||||
status = error.code
|
||||
if not payload:
|
||||
return status, {}
|
||||
try:
|
||||
return status, json.loads(payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return status, {"_raw": payload[:500].decode("utf-8", errors="replace")}
|
||||
|
||||
|
||||
def find_or_create_release(
|
||||
*,
|
||||
base_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
headers: dict[str, str],
|
||||
tag: str,
|
||||
title: str,
|
||||
body: str,
|
||||
draft: bool,
|
||||
prerelease: bool,
|
||||
) -> dict:
|
||||
"""Return release JSON; create one if it doesn't exist for the tag."""
|
||||
get_url = _api(base_url, owner, repo, "releases/tags/" + quote(tag, safe=""))
|
||||
status, payload = _request_json(get_url, headers=headers)
|
||||
if status == 200 and payload.get("id"):
|
||||
return payload
|
||||
if status not in (200, 404):
|
||||
raise SystemExit(
|
||||
"error: GET release-by-tag failed (HTTP {}): {}".format(status, payload)
|
||||
)
|
||||
create_url = _api(base_url, owner, repo, "releases")
|
||||
create_body = json.dumps(
|
||||
{
|
||||
"tag_name": tag,
|
||||
"name": title,
|
||||
"body": body,
|
||||
"draft": draft,
|
||||
"prerelease": prerelease,
|
||||
}
|
||||
).encode("utf-8")
|
||||
status, payload = _request_json(
|
||||
create_url, method="POST", headers=headers, body=create_body
|
||||
)
|
||||
if status not in (200, 201):
|
||||
raise SystemExit(
|
||||
"error: POST create release failed (HTTP {}): {}".format(status, payload)
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def delete_existing_asset(
|
||||
*,
|
||||
base_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
headers: dict[str, str],
|
||||
release_id: int,
|
||||
asset_name: str,
|
||||
existing_assets: list[dict],
|
||||
) -> None:
|
||||
"""DELETE asset by name from the given release if present."""
|
||||
for asset in existing_assets:
|
||||
if asset.get("name") == asset_name and asset.get("id") is not None:
|
||||
url = _api(
|
||||
base_url,
|
||||
owner,
|
||||
repo,
|
||||
"releases/{}/assets/{}".format(release_id, asset["id"]),
|
||||
)
|
||||
status, _ = _request_json(url, method="DELETE", headers=headers)
|
||||
if status not in (200, 204):
|
||||
raise SystemExit(
|
||||
"error: DELETE existing asset {!r} failed (HTTP {})".format(
|
||||
asset_name, status
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def upload_asset(
|
||||
*,
|
||||
base_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
headers: dict[str, str],
|
||||
release_id: int,
|
||||
file_path: Path,
|
||||
) -> dict:
|
||||
"""POST one file as a multipart release asset; return the asset JSON."""
|
||||
asset_name = file_path.name
|
||||
url = _api(
|
||||
base_url,
|
||||
owner,
|
||||
repo,
|
||||
"releases/{}/assets?name={}".format(release_id, quote(asset_name, safe="")),
|
||||
)
|
||||
boundary = "----sessions-release-" + secrets.token_hex(8)
|
||||
content_type, _ = mimetypes.guess_type(asset_name)
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
file_bytes = file_path.read_bytes()
|
||||
crlf = b"\r\n"
|
||||
body = crlf.join(
|
||||
[
|
||||
("--" + boundary).encode("utf-8"),
|
||||
(
|
||||
'Content-Disposition: form-data; name="attachment"; '
|
||||
'filename="{}"'.format(asset_name)
|
||||
).encode("utf-8"),
|
||||
("Content-Type: " + content_type).encode("utf-8"),
|
||||
b"",
|
||||
file_bytes,
|
||||
("--" + boundary + "--").encode("utf-8"),
|
||||
b"",
|
||||
]
|
||||
)
|
||||
extra = {
|
||||
"Content-Type": "multipart/form-data; boundary=" + boundary,
|
||||
"Content-Length": str(len(body)),
|
||||
}
|
||||
status, payload = _request_json(
|
||||
url, method="POST", headers=headers, body=body, extra_headers=extra
|
||||
)
|
||||
if status not in (200, 201):
|
||||
raise SystemExit(
|
||||
"error: upload {!r} failed (HTTP {}): {}".format(
|
||||
asset_name, status, payload
|
||||
)
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point."""
|
||||
args = parse_args()
|
||||
version = args.version or read_workspace_version()
|
||||
tag = "v" + version
|
||||
bundle_dir: Path = args.bundle_dir or (DEFAULT_DIST_ROOT / tag)
|
||||
if not bundle_dir.is_dir():
|
||||
print(
|
||||
"error: bundle dir does not exist: {}".format(bundle_dir), file=sys.stderr
|
||||
)
|
||||
return 2
|
||||
files = sorted(p for p in bundle_dir.iterdir() if p.is_file())
|
||||
if not files:
|
||||
print("error: no files under {}".format(bundle_dir), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
title = args.title or tag_message_subject(tag) or tag
|
||||
token = resolve_token(args.token)
|
||||
headers = _auth_headers(token)
|
||||
|
||||
release = find_or_create_release(
|
||||
base_url=args.base_url,
|
||||
owner=args.owner,
|
||||
repo=args.repo,
|
||||
headers=headers,
|
||||
tag=tag,
|
||||
title=title,
|
||||
body=args.body,
|
||||
draft=args.draft,
|
||||
prerelease=args.prerelease,
|
||||
)
|
||||
release_id = release["id"]
|
||||
existing_assets = release.get("assets") or []
|
||||
print("release id={} url={}".format(release_id, release.get("html_url")))
|
||||
|
||||
for path in files:
|
||||
delete_existing_asset(
|
||||
base_url=args.base_url,
|
||||
owner=args.owner,
|
||||
repo=args.repo,
|
||||
headers=headers,
|
||||
release_id=release_id,
|
||||
asset_name=path.name,
|
||||
existing_assets=existing_assets,
|
||||
)
|
||||
asset = upload_asset(
|
||||
base_url=args.base_url,
|
||||
owner=args.owner,
|
||||
repo=args.repo,
|
||||
headers=headers,
|
||||
release_id=release_id,
|
||||
file_path=path,
|
||||
)
|
||||
print(
|
||||
" uploaded {} ({} bytes) -> {}".format(
|
||||
asset.get("name"),
|
||||
asset.get("size"),
|
||||
asset.get("browser_download_url"),
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
220
scripts/sign_release_artifacts.py
Executable file
220
scripts/sign_release_artifacts.py
Executable file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hash + GPG-sign the release binaries in ``rust/target/release``.
|
||||
|
||||
Run locally after ``cargo build --manifest-path rust/Cargo.toml --release
|
||||
--workspace`` finishes. Produces a ``dist/v<version>/`` directory that holds
|
||||
the binaries + ``SHA256SUMS`` + ``SHA256SUMS.asc`` ready to upload as release
|
||||
assets on the Gitea release page.
|
||||
|
||||
Why a separate script (not folded into the existing package upload):
|
||||
|
||||
- The signing key must live on a trusted local workstation, not in CI, so
|
||||
this script never runs unattended. The existing
|
||||
``upload_session_helper_to_gitea.py`` publishes an unsigned generic package
|
||||
from CI on every tag; this script is the signed-release counterpart users
|
||||
verify before running the binary.
|
||||
- The workflow is: build once, review, then run this script, then upload the
|
||||
``dist/v<version>/`` contents to the Gitea release page.
|
||||
|
||||
Default signing key identity lives in ``SECURITY.md`` and is matched against
|
||||
``pyproject.toml`` / ``Cargo.toml`` ``authors``. Override with
|
||||
``--signing-key <KEYID_OR_FINGERPRINT>`` or ``SESSIONS_SIGNING_KEY`` env for
|
||||
testing with a throwaway key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_TARGET_DIR = REPO_ROOT / "rust" / "target" / "release"
|
||||
DEFAULT_DIST_ROOT = REPO_ROOT / "dist"
|
||||
DEFAULT_SIGNING_KEY = "C01DF8180774AC13909B5E52CD1D23365D028C41"
|
||||
|
||||
# Release artifact file names searched under the Rust target dir.
|
||||
# Missing entries are silently skipped (e.g. macOS build on Linux).
|
||||
ARTIFACT_CANDIDATES: Tuple[str, ...] = (
|
||||
"local_bridge",
|
||||
"session_helper",
|
||||
"libsessions_native.so",
|
||||
"libsessions_native.dylib",
|
||||
"sessions_native.dll",
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
default=None,
|
||||
help=(
|
||||
"Release version string (without leading 'v'); defaults to the "
|
||||
"value from rust/Cargo.toml [workspace.package].version."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_TARGET_DIR,
|
||||
help="Rust release build output dir (default: rust/target/release).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dist-root",
|
||||
type=Path,
|
||||
default=DEFAULT_DIST_ROOT,
|
||||
help="Where to write the signed bundle (default: dist/).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--signing-key",
|
||||
default=os.environ.get("SESSIONS_SIGNING_KEY", DEFAULT_SIGNING_KEY),
|
||||
help="GPG key ID or fingerprint to sign with.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--platform-tag",
|
||||
default=None,
|
||||
help=(
|
||||
"Platform tag for the bundle directory name, e.g. linux-x86_64. "
|
||||
"If omitted, only the version tag is used."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_workspace_version() -> str:
|
||||
"""Return the version string from ``rust/Cargo.toml``."""
|
||||
cargo_toml = REPO_ROOT / "rust" / "Cargo.toml"
|
||||
for line in cargo_toml.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("version") and "=" in stripped:
|
||||
_, _, rhs = stripped.partition("=")
|
||||
return rhs.strip().strip('"')
|
||||
raise RuntimeError("rust/Cargo.toml has no [workspace.package].version")
|
||||
|
||||
|
||||
def find_artifacts(target_dir: Path) -> List[Path]:
|
||||
"""Return existing artifact paths in ``target_dir`` in stable order."""
|
||||
found: List[Path] = [
|
||||
candidate
|
||||
for name in ARTIFACT_CANDIDATES
|
||||
if (candidate := target_dir / name).is_file()
|
||||
]
|
||||
if not found:
|
||||
raise FileNotFoundError(
|
||||
"No release artifacts found under {}. Did you run "
|
||||
"`cargo build --release --workspace`?".format(target_dir)
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def sha256sum(path: Path) -> str:
|
||||
"""Return the lowercase hex SHA-256 of ``path``."""
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 16), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_bundle(
|
||||
*,
|
||||
version: str,
|
||||
artifacts: List[Path],
|
||||
dist_root: Path,
|
||||
platform_tag: str | None,
|
||||
) -> Path:
|
||||
"""Copy artifacts into ``dist_root/v<version>[-<platform>]/`` and return the dir."""
|
||||
tag = "v" + version
|
||||
if platform_tag:
|
||||
tag = "{}-{}".format(tag, platform_tag)
|
||||
bundle = dist_root / tag
|
||||
bundle.mkdir(parents=True, exist_ok=True)
|
||||
for artifact in artifacts:
|
||||
shutil.copy2(artifact, bundle / artifact.name)
|
||||
return bundle
|
||||
|
||||
|
||||
def write_sha256sums(bundle: Path) -> Path:
|
||||
"""Write ``SHA256SUMS`` with one line per artifact in the bundle."""
|
||||
out_path = bundle / "SHA256SUMS"
|
||||
lines = []
|
||||
for entry in sorted(bundle.iterdir()):
|
||||
if entry.name == "SHA256SUMS" or entry.name.endswith(".asc"):
|
||||
continue
|
||||
if not entry.is_file():
|
||||
continue
|
||||
lines.append("{} {}".format(sha256sum(entry), entry.name))
|
||||
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
def gpg_detach_sign(sha256sums_path: Path, signing_key: str) -> Path:
|
||||
"""Produce ``SHA256SUMS.asc`` next to ``SHA256SUMS``."""
|
||||
asc_path = sha256sums_path.with_suffix(sha256sums_path.suffix + ".asc")
|
||||
if asc_path.exists():
|
||||
asc_path.unlink()
|
||||
subprocess.run(
|
||||
[
|
||||
"gpg",
|
||||
"--batch",
|
||||
"--yes",
|
||||
"--local-user",
|
||||
signing_key,
|
||||
"--detach-sign",
|
||||
"--armor",
|
||||
"--output",
|
||||
str(asc_path),
|
||||
str(sha256sums_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
# Verify round-trip so we never ship a file we can't re-verify.
|
||||
subprocess.run(
|
||||
["gpg", "--verify", str(asc_path), str(sha256sums_path)],
|
||||
check=True,
|
||||
)
|
||||
return asc_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point."""
|
||||
args = parse_args()
|
||||
version = args.version or read_workspace_version()
|
||||
target_dir: Path = args.target_dir
|
||||
if not target_dir.is_dir():
|
||||
print(
|
||||
"error: target dir does not exist: {}".format(target_dir),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
artifacts = find_artifacts(target_dir)
|
||||
bundle = write_bundle(
|
||||
version=version,
|
||||
artifacts=artifacts,
|
||||
dist_root=args.dist_root,
|
||||
platform_tag=args.platform_tag,
|
||||
)
|
||||
sha_path = write_sha256sums(bundle)
|
||||
asc_path = gpg_detach_sign(sha_path, args.signing_key)
|
||||
|
||||
print()
|
||||
print("Signed release bundle ready:")
|
||||
print(" dir: {}".format(bundle))
|
||||
print(" sha: {}".format(sha_path))
|
||||
print(" sig: {}".format(asc_path))
|
||||
print()
|
||||
print("Upload all files in {} as release assets on the Gitea".format(bundle))
|
||||
print("release page for v{}.".format(version))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"min_high_value_tests": 219,
|
||||
"min_real_subprocess": 49,
|
||||
"min_high_value_tests": 264,
|
||||
"min_real_subprocess": 53,
|
||||
"min_contract_fixture": 27,
|
||||
"min_adversarial": 143,
|
||||
"max_mock_only_ratio": 0.82
|
||||
"min_adversarial": 184,
|
||||
"max_mock_only_ratio": 0.98
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ Environment:
|
||||
GITEA_PACKAGE_REPO: optional repository name to link this package to
|
||||
(e.g. ``sessions``). If unset, ``GITHUB_REPOSITORY`` / ``GITEA_REPOSITORY``
|
||||
is parsed and linked automatically when owner matches.
|
||||
GITEA_FAIL_ON_RELEASE_ERROR: if ``1``, exit non-zero when the repository
|
||||
**release** API step fails after a successful generic-package PUT. Default
|
||||
is to exit 0 so CI still passes when only the release metadata call fails.
|
||||
GITEA_SKIP_PACKAGE_DELETE: if ``1``, do not DELETE before PUT (will likely
|
||||
hit **409** when the file already exists).
|
||||
|
||||
Release page management is owned by ``scripts/create_gitea_release.py``;
|
||||
this script only uploads to the generic-package registry.
|
||||
|
||||
Local / emergency example::
|
||||
|
||||
cargo build --manifest-path rust/Cargo.toml --release -p session_helper
|
||||
@@ -44,7 +44,6 @@ Local / emergency example::
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -270,37 +269,6 @@ def _link_url(
|
||||
)
|
||||
|
||||
|
||||
def _release_url(*, base_url: str, owner: str, repo_name: str) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
return "{}/api/v1/repos/{}/{}/releases".format(
|
||||
base,
|
||||
quote(owner, safe=""),
|
||||
quote(repo_name, safe=""),
|
||||
)
|
||||
|
||||
|
||||
def _release_by_tag_url(*, base_url: str, owner: str, repo_name: str, tag: str) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
return "{}/api/v1/repos/{}/{}/releases/tags/{}".format(
|
||||
base,
|
||||
quote(owner, safe=""),
|
||||
quote(repo_name, safe=""),
|
||||
quote(tag, safe=""),
|
||||
)
|
||||
|
||||
|
||||
def _release_by_id_url(
|
||||
*, base_url: str, owner: str, repo_name: str, release_id: int
|
||||
) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
return "{}/api/v1/repos/{}/{}/releases/{}".format(
|
||||
base,
|
||||
quote(owner, safe=""),
|
||||
quote(repo_name, safe=""),
|
||||
int(release_id),
|
||||
)
|
||||
|
||||
|
||||
def _infer_repo_name(owner: str) -> str | None:
|
||||
explicit = (os.environ.get("GITEA_PACKAGE_REPO") or "").strip()
|
||||
if explicit:
|
||||
@@ -343,178 +311,6 @@ def _link_package_to_repo(*, base_url: str, owner: str, package_name: str) -> st
|
||||
return "failed(network {}: {})".format(repo_name, error)
|
||||
|
||||
|
||||
def _get_release_id_by_tag(
|
||||
*,
|
||||
base_url: str,
|
||||
owner: str,
|
||||
repo_name: str,
|
||||
release_tag: str,
|
||||
) -> int | None:
|
||||
url = _release_by_tag_url(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
repo_name=repo_name,
|
||||
tag=release_tag,
|
||||
)
|
||||
request = Request(url, method="GET")
|
||||
for header_name, header_value in _artifact_put_headers().items():
|
||||
request.add_header(header_name, header_value)
|
||||
try:
|
||||
with urlopen(request, timeout=60) as response:
|
||||
raw = response.read().decode("utf-8", errors="replace")
|
||||
except HTTPError as error:
|
||||
try:
|
||||
_ = error.read()
|
||||
except Exception:
|
||||
pass
|
||||
if error.code == 404:
|
||||
return None
|
||||
return None
|
||||
except URLError:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
rid = data.get("id")
|
||||
if isinstance(rid, int):
|
||||
return rid
|
||||
if isinstance(rid, str) and rid.isdigit():
|
||||
return int(rid)
|
||||
return None
|
||||
|
||||
|
||||
def _patch_repository_release(
|
||||
*,
|
||||
base_url: str,
|
||||
owner: str,
|
||||
repo_name: str,
|
||||
release_id: int,
|
||||
release_tag: str,
|
||||
target_commitish: str,
|
||||
release_title: str,
|
||||
release_notes: str,
|
||||
) -> tuple[bool, str]:
|
||||
# Keep PATCH body minimal: some Gitea versions reject redundant fields
|
||||
# (e.g. tag_name/draft/prerelease) or behave differently than POST create.
|
||||
payload = json.dumps(
|
||||
{
|
||||
"name": release_title,
|
||||
"body": release_notes,
|
||||
"target_commitish": target_commitish,
|
||||
}
|
||||
).encode("utf-8")
|
||||
url = _release_by_id_url(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
repo_name=repo_name,
|
||||
release_id=release_id,
|
||||
)
|
||||
request = Request(url, data=payload, method="PATCH")
|
||||
headers = _artifact_put_headers()
|
||||
headers["Content-Type"] = "application/json"
|
||||
for header_name, header_value in headers.items():
|
||||
request.add_header(header_name, header_value)
|
||||
try:
|
||||
with urlopen(request, timeout=60) as response:
|
||||
_ = response.read()
|
||||
return True, "updated({})".format(release_tag)
|
||||
except HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")[:500]
|
||||
# 405/501: server too old for PATCH; treat as soft failure for callers.
|
||||
if error.code in (404, 405, 501):
|
||||
return True, "patch_unsupported_or_gone({}: {})".format(
|
||||
error.code,
|
||||
body or error.reason,
|
||||
)
|
||||
return False, "patch_failed({}: {})".format(error.code, body or error.reason)
|
||||
except URLError as error:
|
||||
return False, "patch_failed(network: {})".format(error)
|
||||
|
||||
|
||||
def _create_repository_release(
|
||||
*,
|
||||
base_url: str,
|
||||
owner: str,
|
||||
release_tag: str,
|
||||
target_commitish: str,
|
||||
release_title: str,
|
||||
release_notes: str,
|
||||
) -> tuple[bool, str]:
|
||||
repo_name = _infer_repo_name(owner)
|
||||
if not release_tag:
|
||||
return True, "skip(no release tag)"
|
||||
if not repo_name:
|
||||
return True, "skip(no repository context)"
|
||||
existing_id = _get_release_id_by_tag(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
repo_name=repo_name,
|
||||
release_tag=release_tag,
|
||||
)
|
||||
if existing_id is not None:
|
||||
return _patch_repository_release(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
repo_name=repo_name,
|
||||
release_id=existing_id,
|
||||
release_tag=release_tag,
|
||||
target_commitish=target_commitish,
|
||||
release_title=release_title,
|
||||
release_notes=release_notes,
|
||||
)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"tag_name": release_tag,
|
||||
"target_commitish": target_commitish,
|
||||
"name": release_title,
|
||||
"body": release_notes,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = Request(
|
||||
_release_url(base_url=base_url, owner=owner, repo_name=repo_name),
|
||||
data=payload,
|
||||
method="POST",
|
||||
)
|
||||
headers = _artifact_put_headers()
|
||||
headers["Content-Type"] = "application/json"
|
||||
for header_name, header_value in headers.items():
|
||||
request.add_header(header_name, header_value)
|
||||
try:
|
||||
with urlopen(request, timeout=60) as response:
|
||||
_ = response.read()
|
||||
return True, "ok({})".format(release_tag)
|
||||
except HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")[:500]
|
||||
text = (body or error.reason or "").lower()
|
||||
if error.code in (409, 422) and ("already" in text or "exist" in text):
|
||||
# Race or server without GET-by-tag: try PATCH path via list is heavy;
|
||||
# re-fetch by tag once.
|
||||
rid = _get_release_id_by_tag(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
repo_name=repo_name,
|
||||
release_tag=release_tag,
|
||||
)
|
||||
if rid is not None:
|
||||
return _patch_repository_release(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
repo_name=repo_name,
|
||||
release_id=rid,
|
||||
release_tag=release_tag,
|
||||
target_commitish=target_commitish,
|
||||
release_title=release_title,
|
||||
release_notes=release_notes,
|
||||
)
|
||||
return True, "already_exists({})".format(release_tag)
|
||||
return False, "failed({}: {})".format(error.code, body or error.reason)
|
||||
except URLError as error:
|
||||
return False, "failed(network: {})".format(error)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry: upload one ``session_helper`` file to the Gitea generic registry."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
@@ -534,20 +330,6 @@ def main() -> None:
|
||||
help="Package version path segment for Gitea generic upload "
|
||||
"(default: git rev-parse HEAD).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-tag",
|
||||
help="Repository release tag to create/update metadata for "
|
||||
"(e.g. v0.2.0). Optional.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-title",
|
||||
help="Repository release title (defaults to --release-tag when set).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-notes",
|
||||
default="",
|
||||
help="Repository release body text.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
token = _upload_token_from_env()
|
||||
@@ -618,39 +400,14 @@ def main() -> None:
|
||||
owner=owner,
|
||||
package_name=package_name,
|
||||
)
|
||||
release_tag = (args.release_tag or "").strip()
|
||||
release_title = (
|
||||
(args.release_title or "").strip() or release_tag or "session_helper upload"
|
||||
)
|
||||
release_ok, release_result = _create_repository_release(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
release_tag=release_tag,
|
||||
target_commitish=head_sha,
|
||||
release_title=release_title,
|
||||
release_notes=(args.release_notes or "").strip(),
|
||||
)
|
||||
if not release_ok:
|
||||
sys.stderr.write(
|
||||
"Release API step failed after successful package upload: {}\n".format(
|
||||
release_result
|
||||
)
|
||||
)
|
||||
if (os.environ.get("GITEA_FAIL_ON_RELEASE_ERROR") or "").strip() == "1":
|
||||
sys.exit(1)
|
||||
sys.stderr.write(
|
||||
"Continuing with exit code 0 (generic package is published). "
|
||||
"Set GITEA_FAIL_ON_RELEASE_ERROR=1 to fail the job on release errors.\n"
|
||||
)
|
||||
sys.stdout.write(
|
||||
"Uploaded {} bytes to {}\n(package_version {} file {})\n"
|
||||
"(package_link {})\n(release {})\n".format(
|
||||
"(package_link {})\n".format(
|
||||
len(payload),
|
||||
url,
|
||||
package_version,
|
||||
filename,
|
||||
link_result,
|
||||
release_result,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
"caption": "Sessions: Open Remote Terminal",
|
||||
"command": "sessions_open_remote_terminal"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: New Remote Terminal Pane",
|
||||
"command": "sessions_new_remote_terminal_pane"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Kill Remote Terminal",
|
||||
"command": "sessions_kill_remote_terminal"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Preview Remote Agent Payload",
|
||||
"command": "sessions_preview_remote_agent_payload"
|
||||
@@ -44,19 +52,59 @@
|
||||
"command": "sessions_reconnect_current_workspace"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Install Remote LSP Server",
|
||||
"command": "sessions_install_remote_lsp_server"
|
||||
"caption": "Sessions: Install Remote Extension",
|
||||
"command": "sessions_install_remote_extension"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Remove Remote LSP Server",
|
||||
"command": "sessions_remove_remote_lsp_server"
|
||||
"caption": "Sessions: Remove Remote Extension",
|
||||
"command": "sessions_remove_remote_extension"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Remote LSP Server Status",
|
||||
"command": "sessions_remote_lsp_server_status"
|
||||
"caption": "Sessions: Remote Extension Status",
|
||||
"command": "sessions_remote_extension_status"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Open Remote Jupyter",
|
||||
"command": "sessions_open_remote_jupyter"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Stop Remote Jupyter",
|
||||
"command": "sessions_stop_remote_jupyter"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Diagnose LSP Workspace",
|
||||
"command": "sessions_diagnose_lsp_workspace"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Select Python Interpreter",
|
||||
"command": "sessions_select_python_interpreter"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Clear Python Interpreter",
|
||||
"command": "sessions_clear_python_interpreter"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Setup Remote Python Debugging",
|
||||
"command": "sessions_setup_remote_debugging"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Register Jupyter Kernel for Active Python",
|
||||
"command": "sessions_register_jupyter_kernel"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Expand Deferred Directory",
|
||||
"command": "sessions_expand_deferred_directory"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: New Agent Session",
|
||||
"command": "sessions_new_agent_session"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Show Agent Switcher",
|
||||
"command": "sessions_show_agent_switcher"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Kill Agent Session",
|
||||
"command": "sessions_kill_agent_session"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -44,7 +44,32 @@
|
||||
"sessions_mirror_auto_deepen_max_depth": 2,
|
||||
|
||||
// Maximum file and directory entries processed in one mirror run (safety cap).
|
||||
"sessions_mirror_max_entries": 5000,
|
||||
// v0.5.0 lowered the default from 5000 to 1000 so a first-open mirror cannot
|
||||
// produce a burst large enough to trip EDR ransomware heuristics.
|
||||
"sessions_mirror_max_entries": 1000,
|
||||
|
||||
// Refuse to descend into any directory whose visible-child count exceeds this
|
||||
// cap on auto runs. The directory stub still appears in the sidebar; expand it
|
||||
// explicitly via "Sessions: Expand Deferred Directory" or the sidebar context
|
||||
// entry. Set to 0 to disable (legacy behaviour; not recommended).
|
||||
"sessions_mirror_max_dir_fanout": 100,
|
||||
|
||||
// Token-bucket refill rate for file-placeholder writes (ops/second). Holds
|
||||
// sustained throughput well below typical EDR ransomware thresholds.
|
||||
// Set to 0 to disable the rate limit (legacy behaviour; not recommended).
|
||||
"sessions_mirror_writes_per_second_cap": 40,
|
||||
|
||||
// When an auto-triggered mirror pass is running, never prune stale cache
|
||||
// entries — the "many creates + many deletes" pattern on connect is the
|
||||
// exact shape EDR ransomware rules look for. Explicit (manual) palette
|
||||
// commands still honour sessions_mirror_prune_stale_cache.
|
||||
"sessions_mirror_auto_prune_stale_cache": false,
|
||||
|
||||
// Optional shared cache root. When set to an existing directory the Sessions
|
||||
// cache lives under this path instead of the default Sublime cache path; this
|
||||
// lets IT bless a filesystem location that EDR policy already exempts from
|
||||
// mass-file-write rules.
|
||||
"sessions_shared_cache_root": null,
|
||||
|
||||
// Run periodic background mirror refresh once a workspace is opened.
|
||||
"sessions_mirror_auto_refresh": true,
|
||||
@@ -65,6 +90,26 @@
|
||||
// When true, opening a zero-byte mirrored file from disk pulls remote bytes once.
|
||||
"sessions_mirror_hydrate_placeholders_on_open": true,
|
||||
|
||||
// Proactive hydration for essential build-graph files on workspace activation.
|
||||
// LSP CLI tools (``cargo metadata``, ``uv lock``, pnpm, …) bypass Sublime's
|
||||
// ``open_file`` hook and read these directly — a zero-byte placeholder causes
|
||||
// them to log "malformed manifest" and give up. Listing a basename here
|
||||
// schedules a single bounded fetch batch (20 files per batch, 50ms between
|
||||
// batches) immediately after activation. Set to [] to disable.
|
||||
"sessions_mirror_eager_hydrate_basenames": [
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
".python-version",
|
||||
"uv.lock"
|
||||
],
|
||||
|
||||
// Extra path segments or globs to skip while mirroring.
|
||||
// Patterns without "/" match any path component; use "**/name/**" for deep matches.
|
||||
//
|
||||
@@ -132,7 +177,15 @@
|
||||
}
|
||||
],
|
||||
|
||||
// Optional remote LSP install/remove catalog (command palette install/remove/status).
|
||||
// Show developer / debugging commands in the main palette. Default ``false``
|
||||
// hides ``Sessions: Preview Remote Agent Payload`` (and any future
|
||||
// dev-flagged command). Maintainers can flip this to ``true`` in
|
||||
// Packages/User/Sessions.sublime-settings to surface them. See
|
||||
// ``planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`` § "Command palette
|
||||
// split" for the broader core / advanced / dev tier plan.
|
||||
"sessions_show_dev_commands": false,
|
||||
|
||||
// Optional remote extension install/remove catalog (command palette install/remove/status).
|
||||
// When this list is missing, invalid, or [], defaults are merged in code (bash -lc
|
||||
// scripts: pip/ensurepip/get-pip fallbacks for Pyright/Ruff; rustup for rust-analyzer).
|
||||
// Plain argv is run via /bin/sh → ``zsh -lic`` if remote ``$SHELL`` ends with zsh,
|
||||
@@ -142,5 +195,5 @@
|
||||
// Each entry runs through bridge exec/once:
|
||||
// install_argv -> probe_argv -> (status)
|
||||
// remove_argv -> probe_argv -> (status)
|
||||
"sessions_remote_lsp_servers": []
|
||||
"sessions_remote_extensions": []
|
||||
}
|
||||
|
||||
6
sublime/Side Bar.sublime-menu
Normal file
6
sublime/Side Bar.sublime-menu
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"caption": "Sessions: Expand this folder",
|
||||
"command": "sessions_expand_deferred_directory"
|
||||
}
|
||||
]
|
||||
@@ -1,28 +1,50 @@
|
||||
"""Explicit Sublime plugin entrypoint for Sessions commands."""
|
||||
|
||||
from .sessions.agent_switcher_view import (
|
||||
SessionsAgentSwitcherClickListener,
|
||||
SessionsRenderAgentSwitcherCommand,
|
||||
)
|
||||
from .sessions.agent_window_layout import (
|
||||
SessionsAgentLayoutCollapseSwitcherCommand,
|
||||
SessionsAgentLayoutCommand,
|
||||
)
|
||||
from .sessions.commands import (
|
||||
SessionsBridgeLifecycleListener,
|
||||
SessionsClearPythonInterpreterCommand,
|
||||
SessionsConnectRemoteWorkspaceCommand,
|
||||
SessionsDiagnoseLspWorkspaceCommand,
|
||||
SessionsInstallRemoteLspServerCommand,
|
||||
SessionsExpandDeferredDirectoryCommand,
|
||||
SessionsInstallRemoteExtensionCommand,
|
||||
SessionsKillAgentSessionCommand,
|
||||
SessionsKillRemoteTerminalCommand,
|
||||
SessionsLspNavigationListener,
|
||||
SessionsNewAgentSessionCommand,
|
||||
SessionsNewRemoteTerminalPaneCommand,
|
||||
SessionsOnDemandFetchListener,
|
||||
SessionsOpenLocalSshConfigCommand,
|
||||
SessionsOpenRecentRemoteWorkspaceCommand,
|
||||
SessionsOpenRemoteFileCommand,
|
||||
SessionsOpenRemoteFolderCommand,
|
||||
SessionsOpenRemoteJupyterCommand,
|
||||
SessionsOpenRemoteTerminalCommand,
|
||||
SessionsOpenRemoteTreeCommand,
|
||||
SessionsOpenSettingsCommand,
|
||||
SessionsPreviewRemoteAgentPayloadCommand,
|
||||
SessionsPythonInterpreterStatusListener,
|
||||
SessionsReconnectCurrentWorkspaceCommand,
|
||||
SessionsRegisterJupyterKernelCommand,
|
||||
SessionsRemoteCachedFileSaveListener,
|
||||
SessionsRemoteLspServerStatusCommand,
|
||||
SessionsRemoteExtensionStatusCommand,
|
||||
SessionsRemoteTreeActivateCommand,
|
||||
SessionsRemoteTreeEventListener,
|
||||
SessionsRemoteTreeRefreshCommand,
|
||||
SessionsRemoveRemoteLspServerCommand,
|
||||
SessionsRemoveRemoteExtensionCommand,
|
||||
SessionsSelectPythonInterpreterCommand,
|
||||
SessionsSetupRemoteDebuggingCommand,
|
||||
SessionsShowAgentSwitcherCommand,
|
||||
SessionsSidebarPlaceholderHydrateListener,
|
||||
SessionsStopRemoteJupyterCommand,
|
||||
SessionsSwitchAgentSessionCommand,
|
||||
SessionsSyncRemoteTreeToSidebarCommand,
|
||||
SessionsWorkspaceActivationListener,
|
||||
register_sessions_transport_hooks,
|
||||
@@ -30,28 +52,46 @@ from .sessions.commands import (
|
||||
from .sessions.terminal_link_click import SessionsTerminalLinkClickListener
|
||||
|
||||
__all__ = [
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
"SessionsDiagnoseLspWorkspaceCommand",
|
||||
"SessionsInstallRemoteLspServerCommand",
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsKillAgentSessionCommand",
|
||||
"SessionsKillRemoteTerminalCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsNewAgentSessionCommand",
|
||||
"SessionsNewRemoteTerminalPaneCommand",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
"SessionsOpenRemoteJupyterCommand",
|
||||
"SessionsOpenRemoteTerminalCommand",
|
||||
"SessionsOpenRemoteTreeCommand",
|
||||
"SessionsOpenSettingsCommand",
|
||||
"SessionsPreviewRemoteAgentPayloadCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsPythonInterpreterStatusListener",
|
||||
"SessionsReconnectCurrentWorkspaceCommand",
|
||||
"SessionsRegisterJupyterKernelCommand",
|
||||
"SessionsRemoteCachedFileSaveListener",
|
||||
"SessionsRemoteLspServerStatusCommand",
|
||||
"SessionsRemoteExtensionStatusCommand",
|
||||
"SessionsRemoteTreeActivateCommand",
|
||||
"SessionsRemoteTreeEventListener",
|
||||
"SessionsRemoteTreeRefreshCommand",
|
||||
"SessionsRemoveRemoteLspServerCommand",
|
||||
"SessionsRemoveRemoteExtensionCommand",
|
||||
"SessionsRenderAgentSwitcherCommand",
|
||||
"SessionsSelectPythonInterpreterCommand",
|
||||
"SessionsSetupRemoteDebuggingCommand",
|
||||
"SessionsShowAgentSwitcherCommand",
|
||||
"SessionsSidebarPlaceholderHydrateListener",
|
||||
"SessionsStopRemoteJupyterCommand",
|
||||
"SessionsSwitchAgentSessionCommand",
|
||||
"SessionsSyncRemoteTreeToSidebarCommand",
|
||||
"SessionsTerminalLinkClickListener",
|
||||
"SessionsWorkspaceActivationListener",
|
||||
|
||||
248
sublime/sessions/agent_change_badge.py
Normal file
248
sublime/sessions/agent_change_badge.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Post-apply edit badges for agent-driven file changes.
|
||||
|
||||
Track D of ``planning/AGENT_TMUX_LAYOUT.md`` (§D7 Phase 2) surfaces the
|
||||
fact that an open local cache buffer was just rewritten by the remote
|
||||
agent. When the existing ``file/watch`` flow detects that the freshly
|
||||
pulled content differs from the pre-change snapshot, the integrator
|
||||
calls into this module to decorate the modified hunks with a transient
|
||||
Sublime phantom — enough of a visual cue that the user notices the
|
||||
edit without the noise of a full diff popup.
|
||||
|
||||
Two pure helpers carry the bulk of the logic so tests can exercise
|
||||
them without a running Sublime API:
|
||||
|
||||
- :func:`compute_changed_line_ranges` — ``difflib``-backed line-range
|
||||
extractor returning ``(start, end)`` pairs;
|
||||
- :func:`format_badge_html` — minihtml renderer for the phantom body.
|
||||
|
||||
The :class:`AgentChangeBadgeRenderer` orchestrates Sublime API calls
|
||||
(``view.add_phantom``, ``view.erase_phantom_by_id``, and
|
||||
``sublime.set_timeout_async`` for the auto-fade). All three are
|
||||
injectable so a monkeypatched test harness can observe call counts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import html as _html
|
||||
import time as _time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# Sublime ``add_phantom`` expects a layout constant; the integer 2 is
|
||||
# ``LAYOUT_BLOCK`` in the live Sublime API. Duplicating the value keeps
|
||||
# this module importable without the real ``sublime`` module, and the
|
||||
# test monkeypatches the ``add_phantom`` callable anyway so the
|
||||
# numerical constant is never compared against anything.
|
||||
_LAYOUT_BLOCK_VALUE = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentEditBadgeRequest:
|
||||
"""Input payload describing a single post-apply edit badge."""
|
||||
|
||||
view_id: int
|
||||
old_text: str
|
||||
new_text: str
|
||||
agent_label: str
|
||||
timestamp: float
|
||||
|
||||
|
||||
def compute_changed_line_ranges(old_text: str, new_text: str) -> List[Tuple[int, int]]:
|
||||
"""Return a list of ``(start_line, end_line)`` inclusive 0-based ranges.
|
||||
|
||||
Pure wrapper around :class:`difflib.SequenceMatcher`. Ranges refer
|
||||
to **new** line numbers (post-apply) because the caller decorates
|
||||
the already-updated buffer. Identical inputs produce an empty
|
||||
list; a pure append at end-of-file produces a single trailing
|
||||
range. Adjacent change hunks get merged into one range so a
|
||||
replace-then-insert block shows a single phantom.
|
||||
"""
|
||||
old_lines = old_text.splitlines()
|
||||
new_lines = new_text.splitlines()
|
||||
matcher = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
|
||||
ranges: List[Tuple[int, int]] = []
|
||||
for tag, _i1, _i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == "equal":
|
||||
continue
|
||||
if tag == "delete":
|
||||
# Pure delete leaves no "new" line to decorate; point the
|
||||
# badge at the join location (``j1`` is the row after which
|
||||
# lines were removed). Clamp to zero for edge cases.
|
||||
idx = max(0, j1 - 1) if j1 > 0 else 0
|
||||
ranges.append((idx, idx))
|
||||
continue
|
||||
# Insert / replace both bring new lines into the buffer; j2 is
|
||||
# exclusive per difflib convention.
|
||||
if j2 > j1:
|
||||
ranges.append((j1, j2 - 1))
|
||||
return _merge_adjacent_ranges(ranges)
|
||||
|
||||
|
||||
def _merge_adjacent_ranges(
|
||||
ranges: List[Tuple[int, int]],
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""Collapse overlapping / touching ranges so each hunk gets one phantom."""
|
||||
if not ranges:
|
||||
return []
|
||||
sorted_ranges = sorted(ranges)
|
||||
merged: List[Tuple[int, int]] = [sorted_ranges[0]]
|
||||
for start, end in sorted_ranges[1:]:
|
||||
prev_start, prev_end = merged[-1]
|
||||
if start <= prev_end + 1:
|
||||
merged[-1] = (prev_start, max(prev_end, end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def format_badge_html(agent_label: str, ts: float) -> str:
|
||||
"""Render the minihtml string shown inside the phantom.
|
||||
|
||||
``agent_label`` is escaped before interpolation so an agent name
|
||||
with ``&`` / ``<`` can't break the minihtml. The timestamp is
|
||||
rendered as a local ``HH:MM:SS`` string — absolute dates show up in
|
||||
status messages elsewhere, the badge is a glance-sized cue.
|
||||
"""
|
||||
safe_label = _html.escape(agent_label or "agent", quote=True)
|
||||
try:
|
||||
time_str = _time.strftime("%H:%M:%S", _time.localtime(ts))
|
||||
except (ValueError, OSError, OverflowError):
|
||||
time_str = "--:--:--"
|
||||
return (
|
||||
'<span class="sessions-agent-badge">agent edit · {label} · {ts}</span>'
|
||||
).format(label=safe_label, ts=time_str)
|
||||
|
||||
|
||||
class AgentChangeBadgeRenderer:
|
||||
"""Drop + auto-erase phantoms on a Sublime view for an agent-driven edit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
add_phantom: Optional[Callable[..., int]] = None,
|
||||
erase_phantom: Optional[Callable[[int], None]] = None,
|
||||
set_timeout: Optional[Callable[[Callable[[], None], int], None]] = None,
|
||||
) -> None:
|
||||
"""Inject the three Sublime-facing callables (``None`` → real API)."""
|
||||
self._explicit_add = add_phantom
|
||||
self._explicit_erase = erase_phantom
|
||||
self._explicit_timeout = set_timeout
|
||||
|
||||
def render(
|
||||
self,
|
||||
request: AgentEditBadgeRequest,
|
||||
view: object,
|
||||
ttl_ms: int = 30_000,
|
||||
) -> List[int]:
|
||||
"""Drop one phantom per changed line range, scheduling auto-erase.
|
||||
|
||||
Returns the list of created phantom ids. An empty list means the
|
||||
diff was a no-op or the view can't host phantoms (missing
|
||||
methods). Raising is reserved for programmer errors — we don't
|
||||
blow up on "view closed mid-render".
|
||||
"""
|
||||
ranges = compute_changed_line_ranges(request.old_text, request.new_text)
|
||||
if not ranges:
|
||||
return []
|
||||
add_phantom = self._resolve_add_phantom(view)
|
||||
erase_phantom = self._resolve_erase_phantom(view)
|
||||
set_timeout = self._resolve_set_timeout()
|
||||
if add_phantom is None:
|
||||
return []
|
||||
badge_html = format_badge_html(request.agent_label, request.timestamp)
|
||||
created: List[int] = []
|
||||
for start_line, end_line in ranges:
|
||||
region = _region_for_line_range(view, start_line, end_line)
|
||||
if region is None:
|
||||
continue
|
||||
try:
|
||||
phantom_id = add_phantom(
|
||||
"sessions-agent-edit-{}".format(request.view_id),
|
||||
region,
|
||||
badge_html,
|
||||
_LAYOUT_BLOCK_VALUE,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
continue
|
||||
if isinstance(phantom_id, int) and phantom_id > 0:
|
||||
created.append(phantom_id)
|
||||
if created and erase_phantom is not None and set_timeout is not None:
|
||||
created_snapshot: Tuple[int, ...] = tuple(created)
|
||||
erase_fn: Callable[[int], None] = erase_phantom
|
||||
|
||||
def _erase_all() -> None:
|
||||
for pid in created_snapshot:
|
||||
try:
|
||||
erase_fn(pid)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
set_timeout(_erase_all, ttl_ms)
|
||||
return created
|
||||
|
||||
def _resolve_add_phantom(self, view: object) -> Optional[Callable[..., int]]:
|
||||
if self._explicit_add is not None:
|
||||
return self._explicit_add
|
||||
candidate = getattr(view, "add_phantom", None)
|
||||
return candidate if callable(candidate) else None
|
||||
|
||||
def _resolve_erase_phantom(self, view: object) -> Optional[Callable[[int], None]]:
|
||||
if self._explicit_erase is not None:
|
||||
return self._explicit_erase
|
||||
candidate = getattr(view, "erase_phantom_by_id", None)
|
||||
return candidate if callable(candidate) else None
|
||||
|
||||
def _resolve_set_timeout(
|
||||
self,
|
||||
) -> Optional[Callable[[Callable[[], None], int], None]]:
|
||||
if self._explicit_timeout is not None:
|
||||
return self._explicit_timeout
|
||||
if sublime is None:
|
||||
return None
|
||||
candidate = getattr(sublime, "set_timeout_async", None)
|
||||
if callable(candidate):
|
||||
return candidate
|
||||
fallback = getattr(sublime, "set_timeout", None)
|
||||
return fallback if callable(fallback) else None
|
||||
|
||||
|
||||
def _region_for_line_range(
|
||||
view: object, start_line: int, end_line: int
|
||||
) -> Optional[Any]:
|
||||
"""Return a Sublime ``Region`` covering ``start_line..end_line`` inclusive.
|
||||
|
||||
Uses ``view.text_point(line, 0)`` — the de-facto way to locate a row
|
||||
without depending on whether :class:`sublime.Region` is importable
|
||||
in the test harness. When ``sublime`` is unavailable we return a
|
||||
plain ``(begin, end)`` tuple the test harness can compare against.
|
||||
"""
|
||||
text_point = getattr(view, "text_point", None)
|
||||
if not callable(text_point):
|
||||
return None
|
||||
try:
|
||||
begin = int(text_point(start_line, 0))
|
||||
# ``end_line`` is inclusive; extending to the start of
|
||||
# ``end_line`` keeps the region anchored to the hunk's first
|
||||
# character without needing the line length.
|
||||
end = int(text_point(end_line, 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
region_cls = getattr(sublime, "Region", None) if sublime is not None else None
|
||||
if region_cls is not None:
|
||||
return region_cls(begin, end)
|
||||
return (begin, end)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"AgentChangeBadgeRenderer",
|
||||
"AgentEditBadgeRequest",
|
||||
"compute_changed_line_ranges",
|
||||
"format_badge_html",
|
||||
)
|
||||
290
sublime/sessions/agent_proposal_watcher.py
Normal file
290
sublime/sessions/agent_proposal_watcher.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Stream-safe unified-diff parser for tmux-piped agent output.
|
||||
|
||||
Phase 1 of the D7 "edit-proposal surfacing" design (see
|
||||
``planning/AGENT_TMUX_LAYOUT.md``). A Sublime-side watcher will mirror the
|
||||
remote Terminus/tmux pane into a local file via ``tmux pipe-pane`` and feed
|
||||
the growing text into :func:`parse_unified_diff_stream`. This module is
|
||||
deliberately I/O-free and Sublime-free — pure string processing so the
|
||||
parser is trivially unit-testable with fixture blobs.
|
||||
|
||||
The parser is agent-agnostic: any tool that prints a standard
|
||||
``--- a/path`` / ``+++ b/path`` / ``@@ -L,N +L,N @@`` block will be
|
||||
surfaced. ANSI colour codes from terminal rendering are stripped before
|
||||
parsing; lines that aren't part of a diff (agent prose, thinking
|
||||
blocks, prompts) are ignored. The implementation intentionally drops
|
||||
malformed blocks silently rather than raising — terminal output is
|
||||
inherently messy and a false negative is always preferable to a crash.
|
||||
|
||||
Stream safety
|
||||
-------------
|
||||
The Sublime watcher is expected to call :func:`parse_unified_diff_stream`
|
||||
many times with a growing buffer. The parser returns every *complete*
|
||||
block visible in the current buffer; callers pass the previous result
|
||||
and the current result to :func:`extract_new_blocks` to identify what is
|
||||
newly available. A partial trailing block (header seen, body still
|
||||
streaming) is dropped from the current-call result rather than emitted
|
||||
prematurely — it will be picked up on the next call once the next
|
||||
block header or EOF marker follows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# ANSI CSI / OSC escape sequences. Covers colour (``\x1b[…m``), cursor moves,
|
||||
# and the OSC 8 hyperlink pair that some agents emit around file paths.
|
||||
_ANSI_ESCAPE_RE = re.compile(
|
||||
r"\x1b\[[0-9;?]*[ -/]*[@-~]" # CSI ... final byte
|
||||
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC ... BEL / ST
|
||||
r"|\x1b[@-Z\\-_]" # plain two-byte escape
|
||||
)
|
||||
|
||||
# ``--- a/path/to/file`` header — leading ``---`` plus optional ``a/`` prefix
|
||||
# plus a path token. ``(\S.*?)`` captures a possibly-spaced path; the trailing
|
||||
# group tolerates the ``\t<timestamp>`` suffix some diff producers emit.
|
||||
_OLD_HEADER_RE = re.compile(r"^---\s+(?:a/)?(?P<path>\S(?:[^\t\n]*\S)?)(?:\t.*)?$")
|
||||
_NEW_HEADER_RE = re.compile(r"^\+\+\+\s+(?:b/)?(?P<path>\S(?:[^\t\n]*\S)?)(?:\t.*)?$")
|
||||
|
||||
# ``@@ -L[,N] +L[,N] @@[ suffix]``. ``N`` defaults to 1 per unified-diff spec.
|
||||
_HUNK_HEADER_RE = re.compile(
|
||||
r"^@@\s+-(?P<before_start>\d+)(?:,(?P<before_count>\d+))?"
|
||||
r"\s+\+(?P<after_start>\d+)(?:,(?P<after_count>\d+))?"
|
||||
r"\s+@@.*$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiffHunk:
|
||||
"""One ``@@`` hunk inside a unified-diff block.
|
||||
|
||||
``before_start`` / ``after_start`` are 1-based line numbers as written
|
||||
by the diff producer. ``body`` contains the ``@@`` header line followed
|
||||
by the context / ``+`` / ``-`` lines joined by ``\\n`` — no trailing
|
||||
newline. Callers that want to render the hunk typically just concatenate
|
||||
``body`` with a leading newline.
|
||||
"""
|
||||
|
||||
before_start: int
|
||||
before_count: int
|
||||
after_start: int
|
||||
after_count: int
|
||||
body: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiffBlock:
|
||||
"""One complete unified-diff block.
|
||||
|
||||
A block is the pair of ``--- a/<path>`` / ``+++ b/<path>`` header lines
|
||||
plus all contiguous hunks that follow before the next block header or
|
||||
the end of the current buffer. ``path_before`` and ``path_after`` are
|
||||
captured separately because renames and /dev/null markers make them
|
||||
differ; tooling that cares can compare the two.
|
||||
"""
|
||||
|
||||
path_before: str
|
||||
path_after: str
|
||||
hunks: Tuple[DiffHunk, ...]
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Return ``text`` with ANSI CSI / OSC escape sequences removed."""
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
|
||||
|
||||
def _is_block_header_start(line: str) -> bool:
|
||||
"""Return ``True`` if ``line`` looks like the ``--- a/...`` header start.
|
||||
|
||||
We treat any line matching :data:`_OLD_HEADER_RE` as a potential block
|
||||
boundary. The follow-up ``+++`` line is validated by the caller; a bare
|
||||
``---`` without a ``+++`` partner is ignored.
|
||||
"""
|
||||
return _OLD_HEADER_RE.match(line) is not None
|
||||
|
||||
|
||||
def _parse_hunk_header(line: str) -> Optional[Tuple[int, int, int, int]]:
|
||||
"""Return ``(before_start, before_count, after_start, after_count)`` or ``None``.
|
||||
|
||||
Missing ``,count`` defaults to 1, matching the unified-diff specification
|
||||
as implemented by GNU diff and git.
|
||||
"""
|
||||
match = _HUNK_HEADER_RE.match(line)
|
||||
if match is None:
|
||||
return None
|
||||
before_start = int(match.group("before_start"))
|
||||
before_count_raw = match.group("before_count")
|
||||
before_count = int(before_count_raw) if before_count_raw is not None else 1
|
||||
after_start = int(match.group("after_start"))
|
||||
after_count_raw = match.group("after_count")
|
||||
after_count = int(after_count_raw) if after_count_raw is not None else 1
|
||||
return before_start, before_count, after_start, after_count
|
||||
|
||||
|
||||
def _is_hunk_body_line(line: str) -> bool:
|
||||
"""Return ``True`` if ``line`` belongs to a hunk body (``+ ``/``- ``/`` ``)."""
|
||||
if not line:
|
||||
# Empty line = context line with trailing-whitespace stripped by the
|
||||
# terminal. Accept it as part of the body.
|
||||
return True
|
||||
first = line[0]
|
||||
return first in (" ", "+", "-", "\\")
|
||||
|
||||
|
||||
def _consume_hunks(
|
||||
lines: Sequence[str], start_idx: int
|
||||
) -> Tuple[List[DiffHunk], int, bool]:
|
||||
"""Parse hunks starting at ``lines[start_idx]``.
|
||||
|
||||
Returns ``(hunks, next_idx, complete)`` where:
|
||||
|
||||
* ``hunks`` is the list of fully-parsed hunks (may be empty),
|
||||
* ``next_idx`` is the index of the line after the last consumed hunk,
|
||||
* ``complete`` is ``True`` when the last hunk's body count was reached
|
||||
before running out of input or hitting a new block header. If the
|
||||
buffer ended mid-body we still return the hunks parsed so far; the
|
||||
caller decides whether to keep the trailing partial block (we drop
|
||||
it to keep the stream-safety guarantee).
|
||||
"""
|
||||
hunks: List[DiffHunk] = []
|
||||
idx = start_idx
|
||||
total = len(lines)
|
||||
while idx < total:
|
||||
header_match = _parse_hunk_header(lines[idx])
|
||||
if header_match is None:
|
||||
# A non-hunk-header line here ends the current block's hunks.
|
||||
return hunks, idx, True
|
||||
before_start, before_count, after_start, after_count = header_match
|
||||
header_line = lines[idx]
|
||||
idx += 1
|
||||
|
||||
body_lines: List[str] = []
|
||||
before_seen = 0
|
||||
after_seen = 0
|
||||
while idx < total and (before_seen < before_count or after_seen < after_count):
|
||||
body_line = lines[idx]
|
||||
if _is_block_header_start(body_line):
|
||||
# The next block header beats out the unfinished body; we
|
||||
# stop the current hunk short and treat what we have as
|
||||
# incomplete so the caller can drop this partial block.
|
||||
return hunks, idx, False
|
||||
if not _is_hunk_body_line(body_line):
|
||||
# Non-diff line interrupts the body (agent prose, prompt,
|
||||
# etc.). Treat the current hunk as incomplete and exit.
|
||||
return hunks, idx, False
|
||||
body_lines.append(body_line)
|
||||
if body_line.startswith(" "):
|
||||
before_seen += 1
|
||||
after_seen += 1
|
||||
elif body_line.startswith("-"):
|
||||
before_seen += 1
|
||||
elif body_line.startswith("+"):
|
||||
after_seen += 1
|
||||
# "\\ No newline at end of file" consumes no counter, per spec.
|
||||
idx += 1
|
||||
|
||||
if before_seen < before_count or after_seen < after_count:
|
||||
# Ran out of input before the hunk body completed.
|
||||
return hunks, idx, False
|
||||
|
||||
hunks.append(
|
||||
DiffHunk(
|
||||
before_start=before_start,
|
||||
before_count=before_count,
|
||||
after_start=after_start,
|
||||
after_count=after_count,
|
||||
body="\n".join([header_line, *body_lines]),
|
||||
)
|
||||
)
|
||||
return hunks, idx, True
|
||||
|
||||
|
||||
def parse_unified_diff_stream(text: str) -> List[DiffBlock]:
|
||||
"""Return the complete :class:`DiffBlock` instances found in ``text``.
|
||||
|
||||
Stream-safe: any partial block at the tail (header seen, body still
|
||||
streaming) is dropped rather than emitted prematurely, so the Sublime
|
||||
watcher can call this repeatedly as its buffer grows without producing
|
||||
spurious duplicates. ANSI colour / OSC escape sequences are stripped
|
||||
before parsing; lines that aren't part of a diff are silently skipped.
|
||||
|
||||
Args:
|
||||
text: The (possibly growing) buffer tailed from ``tmux pipe-pane``.
|
||||
|
||||
Returns:
|
||||
The list of complete diff blocks in stream order. Blocks whose
|
||||
body is truncated at the tail of ``text`` are omitted; callers
|
||||
call again once more data arrives.
|
||||
"""
|
||||
cleaned = _strip_ansi(text)
|
||||
lines = cleaned.splitlines()
|
||||
blocks: List[DiffBlock] = []
|
||||
idx = 0
|
||||
total = len(lines)
|
||||
while idx < total:
|
||||
line = lines[idx]
|
||||
old_match = _OLD_HEADER_RE.match(line)
|
||||
if old_match is None:
|
||||
idx += 1
|
||||
continue
|
||||
# Peek at the next line for the ``+++`` partner; skip if absent.
|
||||
if idx + 1 >= total:
|
||||
break
|
||||
new_match = _NEW_HEADER_RE.match(lines[idx + 1])
|
||||
if new_match is None:
|
||||
idx += 1
|
||||
continue
|
||||
|
||||
path_before = old_match.group("path")
|
||||
path_after = new_match.group("path")
|
||||
hunks, next_idx, complete = _consume_hunks(lines, idx + 2)
|
||||
if complete and hunks:
|
||||
blocks.append(
|
||||
DiffBlock(
|
||||
path_before=path_before,
|
||||
path_after=path_after,
|
||||
hunks=tuple(hunks),
|
||||
)
|
||||
)
|
||||
idx = next_idx
|
||||
continue
|
||||
if not hunks:
|
||||
# Header pair without any hunks yet — treat as partial and stop
|
||||
# so we don't starve a possible next block.
|
||||
break
|
||||
# Partial trailing block: keep the hunks we have for downstream
|
||||
# tools, then stop. We still only append if complete so that
|
||||
# callers don't see the same partial block twice as it grows.
|
||||
break
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_new_blocks(
|
||||
prev: Sequence[DiffBlock], curr: Sequence[DiffBlock]
|
||||
) -> List[DiffBlock]:
|
||||
"""Return blocks in ``curr`` that are not in ``prev`` (by dataclass equality).
|
||||
|
||||
Uses frozen-dataclass equality semantics — two blocks compare equal iff
|
||||
their paths and every hunk match exactly. The order of the returned
|
||||
list mirrors the order of ``curr``.
|
||||
|
||||
Args:
|
||||
prev: Blocks returned by the previous
|
||||
:func:`parse_unified_diff_stream` call.
|
||||
curr: Blocks returned by the current call.
|
||||
|
||||
Returns:
|
||||
The subset of ``curr`` not present in ``prev``.
|
||||
"""
|
||||
prev_set = set(prev)
|
||||
return [block for block in curr if block not in prev_set]
|
||||
341
sublime/sessions/agent_switcher_view.py
Normal file
341
sublime/sessions/agent_switcher_view.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""Agent-switcher view rendering and click-resolution helpers.
|
||||
|
||||
Track D of ``planning/AGENT_TMUX_LAYOUT.md`` (§D4) parks a named view in
|
||||
group 2 of the three-group layout that lists every agent pair the user
|
||||
has open. The view is populated from a pre-rendered string this module
|
||||
produces; clicks inside the view are routed back to a specific
|
||||
``pair_id`` (or a ``__new__`` sentinel) by the :class:`EventListener`
|
||||
defined below.
|
||||
|
||||
This module deliberately stays data-source-agnostic. The integrator
|
||||
supplies a ``Sequence[AgentPairSummary]`` pulled from
|
||||
``workspace_state`` / the tmux broker. Unit tests feed in hand-built
|
||||
fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Mapping, Optional, Sequence
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# View-settings key that marks a buffer as the Sessions agent switcher.
|
||||
# The integrator sets this to ``True`` on the view it creates in group 2;
|
||||
# the :class:`SessionsAgentSwitcherClickListener` filters on the same
|
||||
# key so normal editor clicks are untouched.
|
||||
SWITCHER_VIEW_SETTING_KEY = "sessions_agent_switcher"
|
||||
|
||||
# Sentinel returned by :func:`find_pair_at_line` for the trailing
|
||||
# "+ New agent session…" menu row. Integrators map this to the
|
||||
# ``sessions_new_agent_session`` command.
|
||||
NEW_PAIR_SENTINEL = "__new__"
|
||||
|
||||
# Fixed footer lines appended after every pair list. Keeping them as a
|
||||
# module constant means rendering + click resolution agree on the
|
||||
# indices of the separator and "+ New" lines without a second pass.
|
||||
_SEPARATOR_LINE = " " + "─" * 9
|
||||
_NEW_PAIR_LINE = " + New agent session…"
|
||||
|
||||
# Monospace-style column widths. Rendered lines are left-padded with two
|
||||
# spaces so clickable regions line up; the one-character status glyph
|
||||
# lives at column 2.
|
||||
_PAIR_ID_COL_WIDTH = 8
|
||||
_AGENT_LABEL_COL_WIDTH = 16
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPairSummary:
|
||||
"""Snapshot of a single agent pair as the switcher should display it."""
|
||||
|
||||
pair_id: str
|
||||
workspace_label: str
|
||||
agent_label: str
|
||||
is_attached: bool
|
||||
is_active: bool
|
||||
|
||||
|
||||
def render_switcher_body(pairs: Sequence[AgentPairSummary]) -> str:
|
||||
"""Return the monospace-friendly text that the switcher view displays.
|
||||
|
||||
Each pair becomes one line; the last two lines are a fixed separator
|
||||
plus "+ New agent session…" entry. The active pair gets a filled
|
||||
glyph ``●``; everything else gets ``○``. Attachment and active
|
||||
labels are suffixed as ``(active)`` / ``[attached]`` so a monospace
|
||||
font keeps columns aligned.
|
||||
"""
|
||||
lines: List[str] = [_format_pair_line(pair) for pair in pairs]
|
||||
lines.append(_SEPARATOR_LINE)
|
||||
lines.append(_NEW_PAIR_LINE)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_pair_line(pair: AgentPairSummary) -> str:
|
||||
glyph = "●" if pair.is_active else "○"
|
||||
pair_short = _shorten_pair_id(pair.pair_id)
|
||||
agent_cell = pair.agent_label.ljust(_AGENT_LABEL_COL_WIDTH)
|
||||
prefix = " {glyph} {pair:<{pw}} · {agent}".format(
|
||||
glyph=glyph,
|
||||
pair=pair_short,
|
||||
pw=_PAIR_ID_COL_WIDTH,
|
||||
agent=agent_cell,
|
||||
)
|
||||
suffix_parts: List[str] = []
|
||||
if pair.is_active:
|
||||
suffix_parts.append("(active)")
|
||||
if pair.is_attached:
|
||||
suffix_parts.append("[attached]")
|
||||
if suffix_parts:
|
||||
return prefix + " " + " ".join(suffix_parts)
|
||||
return prefix.rstrip()
|
||||
|
||||
|
||||
def _shorten_pair_id(pair_id: str) -> str:
|
||||
"""Render the leading cache-key prefix so all rows align.
|
||||
|
||||
``pair_id`` is shaped ``<ws_cache_key>:<agent_id>``; the cache key is
|
||||
a blake2 hex prefix. We show the first eight characters so the
|
||||
switcher stays readable, falling back to the raw string if it looks
|
||||
unusual (no colon, short hash, etc.).
|
||||
"""
|
||||
head = pair_id.split(":", 1)[0] if ":" in pair_id else pair_id
|
||||
if len(head) >= _PAIR_ID_COL_WIDTH:
|
||||
return head[:_PAIR_ID_COL_WIDTH]
|
||||
return head
|
||||
|
||||
|
||||
def find_pair_at_line(
|
||||
line_index: int, pairs: Sequence[AgentPairSummary]
|
||||
) -> Optional[str]:
|
||||
"""Map a clicked 0-based line index back to a ``pair_id`` / sentinel.
|
||||
|
||||
Returns ``None`` for the separator and any out-of-range click. The
|
||||
"+ New agent session…" row resolves to :data:`NEW_PAIR_SENTINEL`.
|
||||
"""
|
||||
if line_index < 0:
|
||||
return None
|
||||
if line_index < len(pairs):
|
||||
return pairs[line_index].pair_id
|
||||
separator_index = len(pairs)
|
||||
new_pair_index = len(pairs) + 1
|
||||
if line_index == separator_index:
|
||||
return None
|
||||
if line_index == new_pair_index:
|
||||
return NEW_PAIR_SENTINEL
|
||||
return None
|
||||
|
||||
|
||||
def _is_switcher_view(view: object) -> bool:
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
if not callable(settings_fn):
|
||||
return False
|
||||
try:
|
||||
settings = settings_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
get = getattr(settings, "get", None)
|
||||
if not callable(get):
|
||||
return False
|
||||
return bool(get(SWITCHER_VIEW_SETTING_KEY))
|
||||
|
||||
|
||||
def _point_from_event(view: object, event: Mapping[str, Any]) -> Optional[int]:
|
||||
x = event.get("x") if isinstance(event, Mapping) else None
|
||||
y = event.get("y") if isinstance(event, Mapping) else None
|
||||
if x is None or y is None:
|
||||
return None
|
||||
window_to_text = getattr(view, "window_to_text", None)
|
||||
if not callable(window_to_text):
|
||||
return None
|
||||
try:
|
||||
return int(window_to_text((x, y)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _line_index_from_point(view: object, point: int) -> Optional[int]:
|
||||
rowcol = getattr(view, "rowcol", None)
|
||||
if not callable(rowcol):
|
||||
return None
|
||||
try:
|
||||
row, _col = rowcol(point)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(row, int):
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def _cached_pairs(view: object) -> Optional[Sequence[AgentPairSummary]]:
|
||||
"""Read the pair summaries the integrator stashed on the view.
|
||||
|
||||
The integrator sets ``view.settings().set("sessions_agent_pairs",
|
||||
[{...}, ...])`` whenever it renders — the click listener rehydrates
|
||||
that JSON-ish list back into :class:`AgentPairSummary` tuples so it
|
||||
can resolve a clicked line without another lookup.
|
||||
"""
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
if not callable(settings_fn):
|
||||
return None
|
||||
try:
|
||||
settings = settings_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
get = getattr(settings, "get", None)
|
||||
if not callable(get):
|
||||
return None
|
||||
raw = get("sessions_agent_pairs")
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
pairs: List[AgentPairSummary] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, Mapping):
|
||||
return None
|
||||
pair_id = entry.get("pair_id")
|
||||
workspace_label = entry.get("workspace_label", "")
|
||||
agent_label = entry.get("agent_label", "")
|
||||
is_attached = bool(entry.get("is_attached", False))
|
||||
is_active = bool(entry.get("is_active", False))
|
||||
if not isinstance(pair_id, str):
|
||||
return None
|
||||
pairs.append(
|
||||
AgentPairSummary(
|
||||
pair_id=pair_id,
|
||||
workspace_label=str(workspace_label),
|
||||
agent_label=str(agent_label),
|
||||
is_attached=is_attached,
|
||||
is_active=is_active,
|
||||
)
|
||||
)
|
||||
return pairs
|
||||
|
||||
|
||||
def dispatch_switcher_click(
|
||||
view: object,
|
||||
event: Mapping[str, Any],
|
||||
pairs: Sequence[AgentPairSummary],
|
||||
) -> Optional[Mapping[str, Any]]:
|
||||
"""Resolve a click event into a ``(command_name, args)`` dict.
|
||||
|
||||
Pure helper extracted so tests can exercise the resolution logic
|
||||
without instantiating the :class:`EventListener`. Returns ``None``
|
||||
when the click lands on a non-interactive line (separator / blank)
|
||||
or the geometry can't be resolved.
|
||||
"""
|
||||
point = _point_from_event(view, event)
|
||||
if point is None:
|
||||
return None
|
||||
line_index = _line_index_from_point(view, point)
|
||||
if line_index is None:
|
||||
return None
|
||||
target = find_pair_at_line(line_index, pairs)
|
||||
if target is None:
|
||||
return None
|
||||
if target == NEW_PAIR_SENTINEL:
|
||||
return {"command": "sessions_new_agent_session", "args": {}}
|
||||
return {
|
||||
"command": "sessions_switch_agent_session",
|
||||
"args": {"pair_id": target},
|
||||
}
|
||||
|
||||
|
||||
_EventListenerBase = (
|
||||
sublime_plugin.EventListener if sublime_plugin is not None else object
|
||||
)
|
||||
|
||||
|
||||
class SessionsAgentSwitcherClickListener(_EventListenerBase): # type: ignore[misc]
|
||||
"""Turn ``drag_select`` clicks in a switcher view into switch commands.
|
||||
|
||||
Mirrors :class:`sessions.terminal_link_click.SessionsTerminalLinkClickListener`:
|
||||
we filter on a view-settings marker, then fire a window command when
|
||||
a click resolves to a pair id.
|
||||
"""
|
||||
|
||||
def on_text_command(
|
||||
self,
|
||||
view: object,
|
||||
command_name: str,
|
||||
args: Optional[Mapping[str, Any]],
|
||||
) -> None:
|
||||
"""Route drag_select clicks inside switcher views to the right command."""
|
||||
if command_name != "drag_select":
|
||||
return None
|
||||
if not _is_switcher_view(view):
|
||||
return None
|
||||
if not isinstance(args, Mapping):
|
||||
return None
|
||||
event = args.get("event")
|
||||
if not isinstance(event, Mapping):
|
||||
return None
|
||||
pairs = _cached_pairs(view)
|
||||
if pairs is None:
|
||||
return None
|
||||
dispatch = dispatch_switcher_click(view, event, pairs)
|
||||
if dispatch is None:
|
||||
return None
|
||||
window_fn = getattr(view, "window", None)
|
||||
window = window_fn() if callable(window_fn) else None
|
||||
if window is None:
|
||||
return None
|
||||
run_command = getattr(window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
return None
|
||||
run_command(dispatch["command"], dispatch.get("args") or {})
|
||||
return None
|
||||
|
||||
|
||||
_TextCommandBase = sublime_plugin.TextCommand if sublime_plugin is not None else object
|
||||
|
||||
|
||||
class SessionsRenderAgentSwitcherCommand(_TextCommandBase): # type: ignore[misc]
|
||||
"""Replace the switcher view's full content with a pre-rendered body."""
|
||||
|
||||
def run(self, edit: object, body: str = "") -> None:
|
||||
"""Replace the full buffer contents with ``body``.
|
||||
|
||||
The integrator calls this whenever pair data changes; we erase
|
||||
the existing region and insert the new text. A read-only flag is
|
||||
toggled around the edit so user keystrokes don't mutate the
|
||||
switcher buffer between refreshes.
|
||||
"""
|
||||
view = getattr(self, "view", None)
|
||||
if view is None:
|
||||
return
|
||||
set_read_only = getattr(view, "set_read_only", None)
|
||||
if callable(set_read_only):
|
||||
set_read_only(False)
|
||||
try:
|
||||
size_fn = getattr(view, "size", None)
|
||||
erase = getattr(view, "erase", None)
|
||||
insert = getattr(view, "insert", None)
|
||||
if not (callable(size_fn) and callable(erase) and callable(insert)):
|
||||
return
|
||||
if sublime is not None:
|
||||
region = sublime.Region(0, size_fn())
|
||||
else: # pragma: no cover - Sublime missing at runtime
|
||||
region = (0, size_fn())
|
||||
erase(edit, region)
|
||||
insert(edit, 0, body or "")
|
||||
finally:
|
||||
if callable(set_read_only):
|
||||
set_read_only(True)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"AgentPairSummary",
|
||||
"NEW_PAIR_SENTINEL",
|
||||
"SWITCHER_VIEW_SETTING_KEY",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsRenderAgentSwitcherCommand",
|
||||
"dispatch_switcher_click",
|
||||
"find_pair_at_line",
|
||||
"render_switcher_body",
|
||||
)
|
||||
490
sublime/sessions/agent_tmux.py
Normal file
490
sublime/sessions/agent_tmux.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""Pure-Python primitives for tmux-hosted remote agent sessions.
|
||||
|
||||
Sessions runs each remote agent (claude, codex, ...) inside a long-lived tmux
|
||||
session on the target host. The Sublime side attaches to that session via a
|
||||
Terminus pane so the agent's own terminal UI drives the UX verbatim. This
|
||||
module owns the SSH / tmux plumbing — spawning, attaching, listing and
|
||||
killing sessions — and is intentionally free of Sublime imports so the
|
||||
logic is unit-testable without the ``sublime`` runtime.
|
||||
|
||||
The companion ``agent_proposal_watcher`` module parses diff output tailed
|
||||
from ``tmux pipe-pane``; this broker is agent-agnostic and knows nothing
|
||||
about what the agent prints.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- Session naming: ``sessions-agent-<workspace_cache_key[:8]>-<agent_id>``.
|
||||
The ``[:8]`` prefix keeps names short enough for ``tmux`` while remaining
|
||||
unambiguous across the small number of workspaces a single user juggles
|
||||
concurrently. ``agent_id`` is validated against a tight charset so a
|
||||
malicious catalog entry cannot inject shell metacharacters.
|
||||
- Idempotent spawn: ``tmux new-session -A -s <name>`` attaches if the
|
||||
session already exists and creates it otherwise. The broker still performs
|
||||
an explicit ``has-session`` probe first so callers can distinguish the
|
||||
"already running" path from a fresh spawn for UX messaging.
|
||||
- "tmux not installed": ``list_sessions`` treats a missing tmux binary on
|
||||
the remote (exit 127) as an empty catalog rather than an error, so the
|
||||
integrator can surface a one-shot installer hint instead of a traceback.
|
||||
- SSH quoting mirrors ``jupyter_hosting._run_over_ssh``: concatenate the
|
||||
remote argv into a single shlex-quoted string handed to OpenSSH as one
|
||||
trailing positional, so the remote shell re-parses it as we intended.
|
||||
Leading ``~/`` segments in ``agent_cmd`` are rewritten to ``"$HOME"/...``
|
||||
so the remote shell expands the tilde.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Optional, Sequence, Tuple
|
||||
|
||||
from .ssh_runner import _subprocess_no_window_kwargs
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
_LOG = logging.getLogger("sessions.agent_tmux")
|
||||
|
||||
|
||||
_SESSION_NAME_PREFIX = "sessions-agent-"
|
||||
_AGENT_ID_RE = re.compile(r"\A[A-Za-z0-9._-]+\Z")
|
||||
_WORKSPACE_KEY_RE = re.compile(r"\A[A-Za-z0-9._-]+\Z")
|
||||
|
||||
|
||||
# Command builder signature: given an SSH alias, return ``argv`` that prefixes
|
||||
# a remote command (e.g. ``["ssh", alias]`` or ``["ssh", "-F", config, alias]``).
|
||||
# Injected via ``AgentTmuxBroker.__init__`` so tests can stub it.
|
||||
SshCommandBuilder = Callable[[str], List[str]]
|
||||
|
||||
|
||||
def _default_ssh_command_builder(alias: str) -> List[str]:
|
||||
"""Return the default ``ssh -T <alias>`` argv prefix for remote commands.
|
||||
|
||||
``-T`` explicitly disables PTY allocation. OpenSSH already defaults to
|
||||
no-TTY when a remote command is supplied, but Sublime's plugin host
|
||||
runs without a controlling terminal in some launch contexts (Finder /
|
||||
Dock launches on macOS, Windows GUI), and a stray ``RequestTTY=yes``
|
||||
in ``~/.ssh/config`` would otherwise cause the spawn to allocate a
|
||||
pseudo-tty. ``-T`` makes the no-TTY contract explicit so the remote
|
||||
``tmux new-session -d`` is guaranteed not to inherit a half-initialised
|
||||
terminal — the trigger for ``open terminal failed: not a terminal``.
|
||||
"""
|
||||
return ["ssh", "-T", alias]
|
||||
|
||||
|
||||
def _shell_quote_with_tilde_expansion(arg: str) -> str:
|
||||
"""``shlex.quote`` variant that preserves a leading ``~/`` for ``$HOME``.
|
||||
|
||||
``shlex.quote("~/x")`` returns ``'~/x'``; wrapped in single quotes the
|
||||
remote shell treats ``~`` as a literal character and the command fails
|
||||
with ``no such file or directory: ~/x``. Rewriting to ``"$HOME"/<suffix>``
|
||||
lets the shell expand ``$HOME`` while the suffix stays double-quoted so
|
||||
spaces and metachars are still safe. Non-tilde args go through
|
||||
``shlex.quote`` unchanged.
|
||||
"""
|
||||
if arg.startswith("~/"):
|
||||
suffix = arg[2:]
|
||||
escaped = (
|
||||
suffix.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("`", "\\`")
|
||||
.replace("$", "\\$")
|
||||
)
|
||||
return f'"$HOME/{escaped}"'
|
||||
return shlex.quote(arg)
|
||||
|
||||
|
||||
class AgentTmuxError(RuntimeError):
|
||||
"""Raised when a tmux operation against the remote host fails unexpectedly."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TmuxAgentSession:
|
||||
"""Snapshot describing one tmux-hosted remote agent session.
|
||||
|
||||
``session_name`` follows ``sessions-agent-<workspace_cache_key[:8]>-<agent_id>``
|
||||
so Sessions-owned sessions are easy to enumerate and differentiate from
|
||||
whatever else the user runs under tmux. ``attach_argv`` and
|
||||
``spawn_argv`` are fully-resolved argv lists ready to hand to
|
||||
``subprocess`` or to a Terminus ``shell_cmd`` after shell-joining.
|
||||
"""
|
||||
|
||||
host_alias: str
|
||||
workspace_cache_key: str
|
||||
agent_id: str
|
||||
session_name: str
|
||||
agent_cmd: Tuple[str, ...]
|
||||
attach_argv: Tuple[str, ...]
|
||||
spawn_argv: Tuple[str, ...]
|
||||
|
||||
|
||||
def _validate_agent_id(agent_id: str) -> None:
|
||||
"""Reject ``agent_id`` values containing shell-hostile characters."""
|
||||
if not _AGENT_ID_RE.match(agent_id):
|
||||
raise AgentTmuxError(
|
||||
"agent_id contains disallowed characters: {!r}".format(agent_id)
|
||||
)
|
||||
|
||||
|
||||
def _validate_workspace_cache_key(workspace_cache_key: str) -> None:
|
||||
"""Reject ``workspace_cache_key`` values outside the safe charset."""
|
||||
if not _WORKSPACE_KEY_RE.match(workspace_cache_key):
|
||||
raise AgentTmuxError(
|
||||
"workspace_cache_key contains disallowed characters: {!r}".format(
|
||||
workspace_cache_key
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build_session_name(workspace_cache_key: str, agent_id: str) -> str:
|
||||
"""Return the canonical tmux session name for a ``(workspace, agent)`` pair."""
|
||||
return "{}{}-{}".format(
|
||||
_SESSION_NAME_PREFIX,
|
||||
workspace_cache_key[:8],
|
||||
agent_id,
|
||||
)
|
||||
|
||||
|
||||
def _quote_remote_command(argv: Sequence[str]) -> str:
|
||||
"""Join ``argv`` into one shell-safe string with ``~/`` expansion."""
|
||||
return " ".join(_shell_quote_with_tilde_expansion(a) for a in argv)
|
||||
|
||||
|
||||
class AgentTmuxBroker:
|
||||
"""Plan, spawn, attach and kill tmux sessions hosting remote agents.
|
||||
|
||||
The broker is a thin, injectable-dependency wrapper around ``ssh ...
|
||||
tmux ...`` calls. All subprocess plumbing is reachable through the
|
||||
``run`` callable passed to ``__init__`` so tests can replace it with a
|
||||
recorder. Nothing here imports from ``sublime``; the integrator wires
|
||||
this module into Sublime commands separately.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ssh_command_builder: Optional[SshCommandBuilder] = None,
|
||||
run: Optional[Callable[..., subprocess.CompletedProcess]] = None,
|
||||
) -> None:
|
||||
"""Build a broker, optionally injecting stubs for tests.
|
||||
|
||||
Args:
|
||||
ssh_command_builder: Maps an SSH alias to an argv prefix for
|
||||
remote commands. Defaults to ``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run`` used for every remote
|
||||
tmux command. Tests typically pass a recording stub.
|
||||
"""
|
||||
self._ssh = ssh_command_builder or _default_ssh_command_builder
|
||||
self._run = run or subprocess.run
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Planning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def plan(
|
||||
self,
|
||||
host_alias: str,
|
||||
workspace_cache_key: str,
|
||||
agent_id: str,
|
||||
agent_cmd: Sequence[str],
|
||||
) -> TmuxAgentSession:
|
||||
"""Return a ``TmuxAgentSession`` describing the session to run.
|
||||
|
||||
Validates ``agent_id`` and ``workspace_cache_key`` against the safe
|
||||
charset and materialises the ``attach_argv`` / ``spawn_argv`` pair
|
||||
without performing any remote I/O.
|
||||
|
||||
Args:
|
||||
host_alias: SSH alias for the target host.
|
||||
workspace_cache_key: Sessions workspace cache key — used for the
|
||||
session-name prefix.
|
||||
agent_id: Catalog entry id (for example ``"claude"`` or
|
||||
``"codex"``).
|
||||
agent_cmd: Remote argv to exec inside ``tmux new-session``.
|
||||
May contain ``~/`` paths — those are rewritten to
|
||||
``"$HOME"/...`` in the spawn shell command.
|
||||
|
||||
Returns:
|
||||
A frozen :class:`TmuxAgentSession` ready for
|
||||
:meth:`attach_or_spawn` / :meth:`is_running`.
|
||||
|
||||
Raises:
|
||||
AgentTmuxError: When ``agent_id`` or ``workspace_cache_key``
|
||||
contains disallowed characters, or ``agent_cmd`` is empty.
|
||||
"""
|
||||
_validate_agent_id(agent_id)
|
||||
_validate_workspace_cache_key(workspace_cache_key)
|
||||
agent_cmd_tuple = tuple(agent_cmd)
|
||||
if not agent_cmd_tuple:
|
||||
raise AgentTmuxError("agent_cmd must contain at least one argument")
|
||||
|
||||
session_name = _build_session_name(workspace_cache_key, agent_id)
|
||||
ssh_prefix = list(self._ssh(host_alias))
|
||||
|
||||
attach_argv = tuple(ssh_prefix + ["tmux", "attach", "-t", session_name])
|
||||
|
||||
# ``-d`` (detached) is critical: the spawn is invoked through a
|
||||
# non-interactive ``ssh -T <alias> bash -lc ...`` pipeline with no
|
||||
# allocated TTY. Without ``-d``, tmux tries to attach to the new
|
||||
# session immediately and fails with
|
||||
# ``open terminal failed: not a terminal``. The actual attach
|
||||
# happens later from Terminus, which does allocate a TTY.
|
||||
#
|
||||
# ``</dev/null`` belt-and-suspenders: even with ``-d``, tmux 3.x
|
||||
# initialises a terminal-capability snapshot for the new session
|
||||
# by probing whatever fd 0 is connected to. When ``ssh`` is
|
||||
# launched from a Sublime plugin host on macOS the inherited
|
||||
# stdin can be a closed/odd handle that tmux misclassifies as a
|
||||
# broken terminal — the error string regressed in v0.6.2 testing
|
||||
# on aws-celery despite ``-d`` being present. Explicitly hooking
|
||||
# tmux's stdin to ``/dev/null`` makes ``isatty(0)`` definitively
|
||||
# false and keeps tmux on the "no terminal needed" code path.
|
||||
# ``-A`` semantics still apply: when the session already exists
|
||||
# the broker short-circuits via ``is_running`` before this
|
||||
# command runs (see :meth:`attach_or_spawn`), so this command
|
||||
# only ever fires for the create-fresh case.
|
||||
spawn_remote_cmd = (
|
||||
"tmux new-session -A -d -s {name} -- {cmd} </dev/null".format(
|
||||
name=shlex.quote(session_name),
|
||||
cmd=_quote_remote_command(agent_cmd_tuple),
|
||||
)
|
||||
)
|
||||
spawn_argv = tuple(ssh_prefix + ["bash", "-lc", spawn_remote_cmd])
|
||||
|
||||
return TmuxAgentSession(
|
||||
host_alias=host_alias,
|
||||
workspace_cache_key=workspace_cache_key,
|
||||
agent_id=agent_id,
|
||||
session_name=session_name,
|
||||
agent_cmd=agent_cmd_tuple,
|
||||
attach_argv=attach_argv,
|
||||
spawn_argv=spawn_argv,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Probing / spawning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_running(self, host_alias: str, session_name: str) -> bool:
|
||||
"""Return ``True`` iff ``tmux has-session -t <name>`` exits 0.
|
||||
|
||||
Any non-zero exit (session missing, tmux not installed, SSH error)
|
||||
is treated as "not running"; the caller may follow up with
|
||||
:meth:`list_sessions` to distinguish the "no tmux at all" case.
|
||||
"""
|
||||
argv = list(self._ssh(host_alias)) + [
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
session_name,
|
||||
]
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
return completed.returncode == 0
|
||||
|
||||
def attach_or_spawn(self, session: TmuxAgentSession) -> None:
|
||||
"""Ensure the tmux session exists on the remote host.
|
||||
|
||||
If :meth:`is_running` already returns ``True`` this is a no-op —
|
||||
the caller is expected to drive the actual attach separately (for
|
||||
example via a Terminus ``shell_cmd``). Otherwise a remote
|
||||
``tmux new-session -A ...`` spawn is issued; a non-zero exit raises
|
||||
:class:`AgentTmuxError`.
|
||||
|
||||
Args:
|
||||
session: The planned session descriptor returned by
|
||||
:meth:`plan`.
|
||||
|
||||
Raises:
|
||||
AgentTmuxError: When the remote spawn command exits non-zero.
|
||||
"""
|
||||
if self.is_running(session.host_alias, session.session_name):
|
||||
_LOG.debug(
|
||||
"tmux session %s already running on %s; skipping spawn",
|
||||
session.session_name,
|
||||
session.host_alias,
|
||||
)
|
||||
return
|
||||
completed = self._run(
|
||||
list(session.spawn_argv),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise AgentTmuxError(
|
||||
"tmux spawn for {name} on {host} exited {rc}: "
|
||||
"stdout={stdout!r} stderr={stderr!r}".format(
|
||||
name=session.session_name,
|
||||
host=session.host_alias,
|
||||
rc=completed.returncode,
|
||||
stdout=(completed.stdout or "").strip(),
|
||||
stderr=(completed.stderr or "").strip(),
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Enumeration / teardown
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_sessions(self, host_alias: str) -> List[str]:
|
||||
"""Return Sessions-owned tmux session names present on the remote.
|
||||
|
||||
Runs ``tmux list-sessions -F '#{session_name}'`` on the remote and
|
||||
filters the output down to names starting with
|
||||
``sessions-agent-``. Three "normal" non-error paths return the
|
||||
empty list instead of raising:
|
||||
|
||||
* tmux reports "no server running" / "no sessions" (exit 1 with
|
||||
a recognisable stderr message);
|
||||
* tmux is not installed (exit 127 or shell "command not found");
|
||||
* SSH itself exits non-zero with a tmux-not-found-style stderr.
|
||||
|
||||
Any other non-zero exit raises :class:`AgentTmuxError`.
|
||||
"""
|
||||
argv = list(self._ssh(host_alias)) + [
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}",
|
||||
]
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return [
|
||||
line.strip()
|
||||
for line in (completed.stdout or "").splitlines()
|
||||
if line.strip().startswith(_SESSION_NAME_PREFIX)
|
||||
]
|
||||
|
||||
stderr = (completed.stderr or "").lower()
|
||||
if _stderr_indicates_no_sessions(stderr) or _stderr_indicates_no_tmux(
|
||||
completed.returncode, stderr
|
||||
):
|
||||
return []
|
||||
raise AgentTmuxError(
|
||||
"tmux list-sessions on {host} exited {rc}: stderr={stderr!r}".format(
|
||||
host=host_alias,
|
||||
rc=completed.returncode,
|
||||
stderr=(completed.stderr or "").strip(),
|
||||
)
|
||||
)
|
||||
|
||||
def kill(self, host_alias: str, session_name: str) -> None:
|
||||
"""Kill one tmux session, tolerating "session not found".
|
||||
|
||||
A non-zero exit whose stderr matches the "can't find session" /
|
||||
"no such session" message is swallowed silently (the session was
|
||||
already gone). Other non-zero exits raise :class:`AgentTmuxError`.
|
||||
"""
|
||||
argv = list(self._ssh(host_alias)) + [
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
session_name,
|
||||
]
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return
|
||||
stderr = (completed.stderr or "").lower()
|
||||
if _stderr_indicates_session_missing(stderr) or _stderr_indicates_no_sessions(
|
||||
stderr
|
||||
):
|
||||
_LOG.debug(
|
||||
"tmux kill-session %s on %s: already gone", session_name, host_alias
|
||||
)
|
||||
return
|
||||
raise AgentTmuxError(
|
||||
"tmux kill-session {name} on {host} exited {rc}: stderr={stderr!r}".format(
|
||||
name=session_name,
|
||||
host=host_alias,
|
||||
rc=completed.returncode,
|
||||
stderr=(completed.stderr or "").strip(),
|
||||
)
|
||||
)
|
||||
|
||||
def shutdown_all(self, host_alias: str) -> None:
|
||||
"""Kill every Sessions-owned tmux session on ``host_alias``.
|
||||
|
||||
Best-effort: individual kill failures are logged at WARNING and the
|
||||
sweep continues. Swallows the same "no sessions" / "no tmux" cases
|
||||
that :meth:`list_sessions` does.
|
||||
"""
|
||||
try:
|
||||
names = self.list_sessions(host_alias)
|
||||
except AgentTmuxError as exc:
|
||||
_LOG.warning(
|
||||
"shutdown_all: list_sessions on %s failed: %s", host_alias, exc
|
||||
)
|
||||
return
|
||||
for name in names:
|
||||
try:
|
||||
self.kill(host_alias, name)
|
||||
except AgentTmuxError as exc:
|
||||
_LOG.warning(
|
||||
"shutdown_all: kill %s on %s failed: %s", name, host_alias, exc
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stderr shape helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stderr_indicates_no_sessions(stderr_lower: str) -> bool:
|
||||
"""Return ``True`` when stderr signals "no tmux server / no sessions"."""
|
||||
return (
|
||||
"no server running" in stderr_lower
|
||||
or "no sessions" in stderr_lower
|
||||
or "error connecting to" in stderr_lower # tmux socket missing
|
||||
)
|
||||
|
||||
|
||||
def _stderr_indicates_no_tmux(returncode: int, stderr_lower: str) -> bool:
|
||||
"""Return ``True`` when stderr/exit code signals "tmux binary missing"."""
|
||||
if returncode == 127:
|
||||
return True
|
||||
return (
|
||||
"command not found" in stderr_lower
|
||||
or "tmux: not found" in stderr_lower
|
||||
or "no such file or directory" in stderr_lower
|
||||
)
|
||||
|
||||
|
||||
def _stderr_indicates_session_missing(stderr_lower: str) -> bool:
|
||||
"""Return ``True`` when stderr signals the specific session is gone."""
|
||||
return (
|
||||
"can't find session" in stderr_lower
|
||||
or "no such session" in stderr_lower
|
||||
or "session not found" in stderr_lower
|
||||
)
|
||||
257
sublime/sessions/agent_window_layout.py
Normal file
257
sublime/sessions/agent_window_layout.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Three-group window layout helpers for the agent-via-tmux track.
|
||||
|
||||
Track D of ``planning/AGENT_TMUX_LAYOUT.md`` (§D2) splits a Sublime window
|
||||
into three vertical groups:
|
||||
|
||||
- group 0: editor (local cache files / diff previews);
|
||||
- group 1: Terminus pane attached to the tmux agent session;
|
||||
- group 2: the agent-switcher view (a clickable pair list).
|
||||
|
||||
This module is intentionally small — pure geometry plus two Window
|
||||
commands — and never reaches for any protocol/IO layer. The integrator
|
||||
wires Terminus spawn + pair data on top; here we only compute the
|
||||
``set_layout`` payload and persist the current layout id on the window
|
||||
project data so a reload restores the same shape.
|
||||
|
||||
The Sublime API is imported lazily via the ``try: import sublime_plugin``
|
||||
pattern so ``pytest sublime/tests/`` keeps collecting without a
|
||||
``sublime`` stub installed (see :mod:`sessions.terminal_link_click` for
|
||||
the same idiom).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# Project-data key used to remember the last-applied layout for this
|
||||
# window. The integrator reads this during activation to re-apply the
|
||||
# same layout without blinking between shapes.
|
||||
LAYOUT_STATE_KEY = "sessions_agent_layout_id"
|
||||
|
||||
LAYOUT_ID_THREE_GROUP = "three_group"
|
||||
LAYOUT_ID_TWO_GROUP = "two_group"
|
||||
LAYOUT_ID_OTHER = "other"
|
||||
|
||||
|
||||
def build_three_group_layout(
|
||||
editor_frac: float = 0.40,
|
||||
terminus_frac: float = 0.80,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a ``set_layout`` dict for the editor/terminus/switcher split.
|
||||
|
||||
``editor_frac`` is the right edge of group 0; ``terminus_frac`` is the
|
||||
right edge of group 1. Both fractions must satisfy
|
||||
``0 < editor_frac < terminus_frac < 1``. Out-of-order values are
|
||||
clamped to a sensible monotonic sequence so callers can pass
|
||||
user-editable settings without crashing the window.
|
||||
"""
|
||||
editor_frac, terminus_frac = _sanitize_three_group_fracs(editor_frac, terminus_frac)
|
||||
return {
|
||||
"cols": [0.0, editor_frac, terminus_frac, 1.0],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [[0, 0, 1, 1], [1, 0, 2, 1], [2, 0, 3, 1]],
|
||||
}
|
||||
|
||||
|
||||
def build_two_group_layout(editor_frac: float = 0.50) -> Dict[str, Any]:
|
||||
"""Return the ``set_layout`` dict used after collapsing the switcher group.
|
||||
|
||||
The editor keeps group 0; Terminus widens into group 1 to fill the
|
||||
previously-switcher column. ``editor_frac`` stays clamped to a
|
||||
narrow usable range — a layout with a 0-wide group traps the user.
|
||||
"""
|
||||
editor_frac = _clamp(editor_frac, 0.05, 0.95)
|
||||
return {
|
||||
"cols": [0.0, editor_frac, 1.0],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [[0, 0, 1, 1], [1, 0, 2, 1]],
|
||||
}
|
||||
|
||||
|
||||
def current_layout_id(window: object) -> str:
|
||||
"""Return ``"three_group"`` / ``"two_group"`` / ``"other"`` for ``window``.
|
||||
|
||||
Used by the integrator to decide whether a layout change is needed on
|
||||
activation. We compare structurally against the shapes produced by
|
||||
:func:`build_three_group_layout` / :func:`build_two_group_layout`
|
||||
ignoring the exact ``cols`` fractions — only the cell topology is
|
||||
load-bearing for identity.
|
||||
"""
|
||||
get_layout = getattr(window, "get_layout", None)
|
||||
if not callable(get_layout):
|
||||
return LAYOUT_ID_OTHER
|
||||
try:
|
||||
layout = get_layout()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return LAYOUT_ID_OTHER
|
||||
if not isinstance(layout, dict):
|
||||
return LAYOUT_ID_OTHER
|
||||
cells = layout.get("cells")
|
||||
rows = layout.get("rows")
|
||||
if not isinstance(cells, list) or not isinstance(rows, list):
|
||||
return LAYOUT_ID_OTHER
|
||||
# A "single row" layout (rows == [0.0, 1.0]) is the only shape we
|
||||
# produce — anything else is user-configured or from another plugin.
|
||||
if len(rows) != 2:
|
||||
return LAYOUT_ID_OTHER
|
||||
normalized = [_normalize_cell(cell) for cell in cells]
|
||||
if None in normalized:
|
||||
return LAYOUT_ID_OTHER
|
||||
if normalized == [(0, 0, 1, 1), (1, 0, 2, 1), (2, 0, 3, 1)]:
|
||||
return LAYOUT_ID_THREE_GROUP
|
||||
if normalized == [(0, 0, 1, 1), (1, 0, 2, 1)]:
|
||||
return LAYOUT_ID_TWO_GROUP
|
||||
return LAYOUT_ID_OTHER
|
||||
|
||||
|
||||
def read_stored_layout_id(window: object) -> Optional[str]:
|
||||
"""Return the previously-persisted layout id for ``window`` or ``None``."""
|
||||
project_data = _get_project_data(window)
|
||||
if not isinstance(project_data, dict):
|
||||
return None
|
||||
settings = project_data.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
return None
|
||||
value = settings.get(LAYOUT_STATE_KEY)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def write_stored_layout_id(window: object, layout_id: str) -> None:
|
||||
"""Persist ``layout_id`` under ``settings.sessions_agent_layout_id`` on ``window``.
|
||||
|
||||
A missing ``project_data`` or ``set_project_data`` is a silent no-op;
|
||||
the integrator may call this on bare windows that have no project.
|
||||
"""
|
||||
set_project_data = getattr(window, "set_project_data", None)
|
||||
if not callable(set_project_data):
|
||||
return
|
||||
project_data = _get_project_data(window)
|
||||
if not isinstance(project_data, dict):
|
||||
project_data = {}
|
||||
settings = project_data.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
updated_settings = dict(settings)
|
||||
updated_settings[LAYOUT_STATE_KEY] = layout_id
|
||||
updated = dict(project_data)
|
||||
updated["settings"] = updated_settings
|
||||
set_project_data(updated)
|
||||
|
||||
|
||||
def _get_project_data(window: object) -> Optional[Dict[str, Any]]:
|
||||
project_data_fn = getattr(window, "project_data", None)
|
||||
if not callable(project_data_fn):
|
||||
return None
|
||||
try:
|
||||
data = project_data_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_cell(cell: Any) -> Optional[tuple]:
|
||||
if not isinstance(cell, (list, tuple)):
|
||||
return None
|
||||
if len(cell) != 4:
|
||||
return None
|
||||
try:
|
||||
return (int(cell[0]), int(cell[1]), int(cell[2]), int(cell[3]))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _clamp(value: float, low: float, high: float) -> float:
|
||||
if value < low:
|
||||
return low
|
||||
if value > high:
|
||||
return high
|
||||
return value
|
||||
|
||||
|
||||
def _sanitize_three_group_fracs(
|
||||
editor_frac: float, terminus_frac: float
|
||||
) -> List[float]:
|
||||
"""Coerce the two fractions into a strictly-increasing pair inside ``(0, 1)``.
|
||||
|
||||
Returns ``[editor, terminus]``. Callers with inverted / equal inputs
|
||||
get a deterministic fallback rather than a corrupted layout.
|
||||
"""
|
||||
editor = _clamp(editor_frac, 0.05, 0.95)
|
||||
terminus = _clamp(terminus_frac, 0.05, 0.95)
|
||||
if terminus <= editor:
|
||||
# Nudge ``terminus`` to at least ``editor + 0.1``, still inside
|
||||
# the usable range. If ``editor`` is already near the right
|
||||
# boundary, pull it back so both groups stay visible.
|
||||
if editor > 0.85:
|
||||
editor = 0.85
|
||||
terminus = min(0.95, editor + 0.1)
|
||||
return [editor, terminus]
|
||||
|
||||
|
||||
_WindowCommandBase = (
|
||||
sublime_plugin.WindowCommand if sublime_plugin is not None else object
|
||||
)
|
||||
|
||||
|
||||
class SessionsAgentLayoutCommand(_WindowCommandBase): # type: ignore[misc]
|
||||
"""Split the active window into three groups (editor | terminus | switcher)."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
editor_frac: float = 0.40,
|
||||
terminus_frac: float = 0.80,
|
||||
) -> None:
|
||||
"""Apply the three-group layout and persist the id on the project."""
|
||||
window = getattr(self, "window", None)
|
||||
if window is None:
|
||||
return
|
||||
set_layout = getattr(window, "set_layout", None)
|
||||
if not callable(set_layout):
|
||||
return
|
||||
layout = build_three_group_layout(editor_frac, terminus_frac)
|
||||
set_layout(layout)
|
||||
write_stored_layout_id(window, LAYOUT_ID_THREE_GROUP)
|
||||
|
||||
|
||||
class SessionsAgentLayoutCollapseSwitcherCommand(_WindowCommandBase): # type: ignore[misc]
|
||||
"""Hide the switcher group by extending Terminus to the right edge."""
|
||||
|
||||
def run(self, editor_frac: float = 0.50) -> None:
|
||||
"""Apply the two-group layout and persist the id on the project."""
|
||||
window = getattr(self, "window", None)
|
||||
if window is None:
|
||||
return
|
||||
set_layout = getattr(window, "set_layout", None)
|
||||
if not callable(set_layout):
|
||||
return
|
||||
layout = build_two_group_layout(editor_frac)
|
||||
set_layout(layout)
|
||||
write_stored_layout_id(window, LAYOUT_ID_TWO_GROUP)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"LAYOUT_ID_OTHER",
|
||||
"LAYOUT_ID_THREE_GROUP",
|
||||
"LAYOUT_ID_TWO_GROUP",
|
||||
"LAYOUT_STATE_KEY",
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"build_three_group_layout",
|
||||
"build_two_group_layout",
|
||||
"current_layout_id",
|
||||
"read_stored_layout_id",
|
||||
"write_stored_layout_id",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
247
sublime/sessions/eager_hydrate.py
Normal file
247
sublime/sessions/eager_hydrate.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Proactive hydration for essential build-graph files.
|
||||
|
||||
When an LSP client (``rust-analyzer``, ``pyright``, …) runs a CLI tool like
|
||||
``cargo metadata`` against a cache-local workspace, the tool reads files from
|
||||
disk directly — it never flows through Sublime's ``open_file`` hook, so
|
||||
:class:`SessionsOnDemandFetchListener` never fires. If the file is still a
|
||||
zero-byte placeholder (created by the sidebar mirror pass), the CLI tool
|
||||
reports a malformed manifest and gives up.
|
||||
|
||||
This module walks an already-mirrored local cache once a workspace activates
|
||||
and schedules a bounded bulk fetch for placeholders whose basename matches a
|
||||
small allow-list of "essential" files (``Cargo.toml``, ``pyproject.toml``,
|
||||
``package.json``, …). The actual fetch primitive is injected so the driver
|
||||
stays importable without the Sublime/SSH runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Iterator, List, Optional, Tuple
|
||||
|
||||
# Default allow-list. Kept intentionally small — each entry is something
|
||||
# build tools / language servers read eagerly when a workspace first
|
||||
# activates. ``.python-version`` is a dotfile but ``uv`` / ``pyenv`` read
|
||||
# it synchronously at tool startup.
|
||||
DEFAULT_EAGER_HYDRATE_BASENAMES: Tuple[str, ...] = (
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
".python-version",
|
||||
"uv.lock",
|
||||
)
|
||||
|
||||
#: Maximum placeholders per batch before the driver pauses. Holds the burst
|
||||
#: below rates that EDR ransomware heuristics are tuned for.
|
||||
DEFAULT_BATCH_SIZE: int = 20
|
||||
|
||||
#: Sleep between consecutive batches. ``0.05`` s keeps the full-cache pass
|
||||
#: cheap (a couple seconds at most for 400 placeholders) while still being
|
||||
#: visibly paced to any rate-based observer.
|
||||
DEFAULT_BATCH_SLEEP_S: float = 0.05
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EagerHydrateSummary:
|
||||
"""Outcome of one eager-hydrate pass.
|
||||
|
||||
Attributes:
|
||||
hydrated: Count of placeholders that were fetched successfully.
|
||||
skipped_existing: Placeholders that turned out to have non-zero size
|
||||
by the time the driver reached them (another worker won the race).
|
||||
failed: Placeholders whose ``fetch_fn`` returned ``False``.
|
||||
"""
|
||||
|
||||
hydrated: int = 0
|
||||
skipped_existing: int = 0
|
||||
failed: int = 0
|
||||
|
||||
|
||||
def _is_placeholder(path: Path) -> bool:
|
||||
"""Return ``True`` if ``path`` is a regular zero-byte file."""
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return False
|
||||
if stat.st_size != 0:
|
||||
return False
|
||||
# ``Path.is_file`` resolves symlinks; the Sessions cache never uses
|
||||
# symlinks but the guard is cheap.
|
||||
try:
|
||||
return path.is_file()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def find_placeholder_candidates(
|
||||
cache_root: Path,
|
||||
allowed_basenames: Iterable[str],
|
||||
) -> Iterator[Path]:
|
||||
"""Yield zero-byte files under ``cache_root`` whose basename is allowed.
|
||||
|
||||
The walk is lazy — callers can bound the work by stopping iteration.
|
||||
Directories that raise ``OSError`` during enumeration are skipped so a
|
||||
partial cache still produces what candidates it can.
|
||||
|
||||
Args:
|
||||
cache_root: Local cache root for the workspace (e.g. ``.../files``).
|
||||
allowed_basenames: Exact filename matches to include.
|
||||
|
||||
Yields:
|
||||
Absolute ``Path`` objects matching the allow-list with size 0.
|
||||
"""
|
||||
allowed = {name for name in allowed_basenames if name}
|
||||
if not allowed:
|
||||
return
|
||||
try:
|
||||
resolved_root = cache_root
|
||||
if not resolved_root.is_dir():
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
stack: List[Path] = [resolved_root]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
try:
|
||||
entries = list(current.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
try:
|
||||
is_dir = entry.is_dir()
|
||||
except OSError:
|
||||
continue
|
||||
if is_dir:
|
||||
# Don't descend into Sessions' own metadata subtree or any
|
||||
# externally-tracked path — neither should host build
|
||||
# manifests.
|
||||
if entry.name in ("__extern",):
|
||||
continue
|
||||
stack.append(entry)
|
||||
continue
|
||||
if entry.name not in allowed:
|
||||
continue
|
||||
if _is_placeholder(entry):
|
||||
yield entry
|
||||
|
||||
|
||||
def batched(items: Iterable[Path], batch_size: int) -> Iterator[List[Path]]:
|
||||
"""Yield ``items`` in lists of at most ``batch_size``.
|
||||
|
||||
Args:
|
||||
items: Source iterable.
|
||||
batch_size: Maximum list length; values ``<= 0`` collapse to ``1``.
|
||||
"""
|
||||
size = max(1, batch_size)
|
||||
bucket: List[Path] = []
|
||||
for item in items:
|
||||
bucket.append(item)
|
||||
if len(bucket) >= size:
|
||||
yield bucket
|
||||
bucket = []
|
||||
if bucket:
|
||||
yield bucket
|
||||
|
||||
|
||||
FetchFn = Callable[[Path], bool]
|
||||
"""Hydrate one placeholder. Returns ``True`` on success, ``False`` otherwise."""
|
||||
|
||||
|
||||
def run_eager_hydrate(
|
||||
cache_root: Path,
|
||||
*,
|
||||
fetch_fn: FetchFn,
|
||||
allowed_basenames: Iterable[str] = DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
batch_sleep_s: float = DEFAULT_BATCH_SLEEP_S,
|
||||
sleep_fn: Optional[Callable[[float], None]] = None,
|
||||
) -> EagerHydrateSummary:
|
||||
"""Drive one hydrate pass over placeholders under ``cache_root``.
|
||||
|
||||
The driver is deliberately dumb: no retries, no per-file concurrency,
|
||||
no global state. Failures are counted but do not abort the pass — the
|
||||
next placeholder still gets its chance.
|
||||
|
||||
Args:
|
||||
cache_root: Local cache root to walk.
|
||||
fetch_fn: Callable invoked for each placeholder. Return ``True`` on
|
||||
successful hydration. Must not raise; failures should be encoded
|
||||
as ``False`` so the pass can continue.
|
||||
allowed_basenames: Override for the default allow-list.
|
||||
batch_size: Placeholders per batch before pausing.
|
||||
batch_sleep_s: Pause between batches, in seconds.
|
||||
sleep_fn: Injection point for tests; defaults to :func:`time.sleep`.
|
||||
|
||||
Returns:
|
||||
An :class:`EagerHydrateSummary` with per-outcome counts.
|
||||
"""
|
||||
sleeper = sleep_fn if sleep_fn is not None else time.sleep
|
||||
hydrated = 0
|
||||
skipped_existing = 0
|
||||
failed = 0
|
||||
|
||||
placeholders = find_placeholder_candidates(cache_root, allowed_basenames)
|
||||
for batch_index, batch in enumerate(batched(placeholders, batch_size)):
|
||||
if batch_index > 0 and batch_sleep_s > 0:
|
||||
sleeper(batch_sleep_s)
|
||||
for path in batch:
|
||||
# Re-check size right before fetching: a different code path
|
||||
# (``SessionsOnDemandFetchListener`` / sidebar hydrate) may have
|
||||
# filled the placeholder while we were iterating.
|
||||
if not _is_placeholder(path):
|
||||
skipped_existing += 1
|
||||
continue
|
||||
try:
|
||||
ok = bool(fetch_fn(path))
|
||||
except Exception:
|
||||
ok = False
|
||||
if ok:
|
||||
hydrated += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return EagerHydrateSummary(
|
||||
hydrated=hydrated,
|
||||
skipped_existing=skipped_existing,
|
||||
failed=failed,
|
||||
)
|
||||
|
||||
|
||||
def normalize_eager_hydrate_basenames(
|
||||
raw: object,
|
||||
default: Tuple[str, ...] = DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
) -> Tuple[str, ...]:
|
||||
"""Coerce user-settings input into a stable, de-duplicated tuple.
|
||||
|
||||
Non-list / non-tuple values fall back to ``default``. Empty list values
|
||||
are respected — the user can disable eager hydrate entirely by setting
|
||||
the key to ``[]``.
|
||||
|
||||
Args:
|
||||
raw: Value loaded from ``Sessions.sublime-settings``.
|
||||
default: Fallback tuple used when ``raw`` is missing or invalid.
|
||||
"""
|
||||
if raw is None:
|
||||
return default
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return default
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
name = item.strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
out.append(name)
|
||||
return tuple(out)
|
||||
783
sublime/sessions/jupyter_hosting.py
Normal file
783
sublime/sessions/jupyter_hosting.py
Normal file
@@ -0,0 +1,783 @@
|
||||
"""Pure-Python primitives for remote Jupyter Lab hosting.
|
||||
|
||||
The plugin opens ``.ipynb`` files against a remote Jupyter Lab server that
|
||||
Sessions launches on demand and keeps alive for the duration of the workspace;
|
||||
the UI runs in the user's local browser via an SSH ``-L`` tunnel. This module
|
||||
owns the server-launch / tunnel / teardown lifecycle and URL construction and
|
||||
is intentionally kept **free of Sublime imports** so the logic is unit-testable
|
||||
without the ``sublime`` runtime.
|
||||
|
||||
Companion piece ``jupyter_catalog_entry.py`` contributes the install/remove
|
||||
metadata to the managed-remote-extension catalog.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- We launch the remote Jupyter server in its **own** ``ssh <alias>`` child
|
||||
rather than multiplexing over the existing ``local_bridge`` FSM's stdio;
|
||||
the bridge wire protocol is NDJSON framed and mixing ``jupyter lab``'s
|
||||
startup banner in would corrupt the stream.
|
||||
- Remote port is selected by Jupyter itself (``--ServerApp.port=0``); we parse
|
||||
the actual bound port out of its log file on first successful URL line.
|
||||
- Local port is picked by binding to ``127.0.0.1:0`` and releasing — races
|
||||
are possible but acceptable for MVP.
|
||||
- Thread safety: the registry is guarded by a ``threading.Lock``. Concurrent
|
||||
``ensure_started`` calls for the same alias coalesce — only one launch
|
||||
runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from .ssh_runner import _subprocess_no_window_kwargs
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
_LOG = logging.getLogger("sessions.jupyter_hosting")
|
||||
|
||||
|
||||
def _default_run(argv: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
|
||||
"""``subprocess.run`` variant that hides the console on Windows."""
|
||||
merged: Dict[str, Any] = dict(_subprocess_no_window_kwargs())
|
||||
merged.update(kwargs)
|
||||
return subprocess.run(argv, **merged)
|
||||
|
||||
|
||||
def _default_popen(argv: Sequence[str], **kwargs: Any) -> subprocess.Popen:
|
||||
"""``subprocess.Popen`` variant that hides the console on Windows."""
|
||||
merged: Dict[str, Any] = dict(_subprocess_no_window_kwargs())
|
||||
merged.update(kwargs)
|
||||
return subprocess.Popen(argv, **merged)
|
||||
|
||||
|
||||
def _shell_quote_with_tilde_expansion(arg: str) -> str:
|
||||
"""``shlex.quote`` variant that preserves a leading ``~/`` for ``$HOME``.
|
||||
|
||||
``shlex.quote("~/x")`` returns ``'~/x'``; wrapped in single quotes the
|
||||
remote shell treats ``~`` as a literal character and the command fails
|
||||
with ``no such file or directory: ~/x``. Rewriting to ``"$HOME"/<suffix>``
|
||||
lets the shell expand ``$HOME`` while the suffix stays double-quoted so
|
||||
spaces and metachars are still safe. Non-tilde args go through
|
||||
``shlex.quote`` unchanged.
|
||||
"""
|
||||
if arg.startswith("~/"):
|
||||
suffix = arg[2:]
|
||||
escaped = (
|
||||
suffix.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("`", "\\`")
|
||||
.replace("$", "\\$")
|
||||
)
|
||||
return f'"$HOME/{escaped}"'
|
||||
return shlex.quote(arg)
|
||||
|
||||
|
||||
# Command builder signature: given an SSH alias, return ``argv`` that prefixes
|
||||
# a remote command (e.g. ``["ssh", alias]`` or ``["ssh", "-F", config, alias]``).
|
||||
# Injected via ``JupyterSessionManager.__init__`` so tests can stub it.
|
||||
SshCommandBuilder = Callable[[str], List[str]]
|
||||
|
||||
|
||||
def _default_ssh_command_builder(alias: str) -> List[str]:
|
||||
"""Return the default ``ssh <alias>`` argv prefix for remote commands."""
|
||||
return ["ssh", alias]
|
||||
|
||||
|
||||
_STARTUP_POLL_INTERVAL_SECONDS = 0.3
|
||||
_STARTUP_TIMEOUT_SECONDS = 15.0
|
||||
_TUNNEL_PROBE_TIMEOUT_SECONDS = 5.0
|
||||
_TERMINATE_GRACE_SECONDS = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JupyterServerInfo:
|
||||
"""Snapshot of one running remote Jupyter Lab server + its local tunnel."""
|
||||
|
||||
host_alias: str
|
||||
workspace_root: str
|
||||
remote_port: int
|
||||
local_port: int
|
||||
token: str
|
||||
pid: int
|
||||
tunnel_pid: int
|
||||
started_at: float
|
||||
kernel_name: Optional[str] = None
|
||||
|
||||
|
||||
class JupyterHostingError(RuntimeError):
|
||||
"""Raised when a remote Jupyter server or tunnel fails to come up."""
|
||||
|
||||
|
||||
def _kernel_name_for_workspace(
|
||||
workspace_root: str,
|
||||
workspace_cache_key: Optional[str],
|
||||
) -> str:
|
||||
"""Return a stable Sessions-owned kernel name.
|
||||
|
||||
Prefers ``sessions-<cache_key[:12]>`` when a cache key is available (so one
|
||||
workspace maps to one kernel regardless of its remote path). Falls back to
|
||||
``sessions-<sha1(workspace_root)[:12]>`` so the name is still deterministic
|
||||
when callers don't have a cache key handy (ad-hoc registration flows).
|
||||
"""
|
||||
if workspace_cache_key:
|
||||
return "sessions-{}".format(workspace_cache_key[:12])
|
||||
digest = hashlib.sha1(workspace_root.encode("utf-8")).hexdigest()
|
||||
return "sessions-{}".format(digest[:12])
|
||||
|
||||
|
||||
def _is_kernelspec_already_exists(stdout: str, stderr: str) -> bool:
|
||||
"""Return True when ``jupyter kernelspec install`` refused because it exists.
|
||||
|
||||
Jupyter surfaces the collision on either stream depending on version, so
|
||||
we look at both. The message shape is stable:
|
||||
``KernelSpec <name> already exists at <path>``.
|
||||
"""
|
||||
blob = "{}\n{}".format(stdout or "", stderr or "").lower()
|
||||
return "already exists" in blob
|
||||
|
||||
|
||||
def _pick_free_local_port() -> int:
|
||||
"""Bind to 127.0.0.1:0, read the assigned port, release the socket."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _parse_remote_port_from_log(log_text: str) -> Optional[int]:
|
||||
"""Return the port Jupyter bound to, parsed from a startup log blob.
|
||||
|
||||
Jupyter writes lines like ``http://127.0.0.1:8889/lab?token=...`` once the
|
||||
server is ready; we grab the first such line's port. Returns ``None`` if
|
||||
no recognisable URL has been emitted yet.
|
||||
"""
|
||||
for raw_line in log_text.splitlines():
|
||||
line = raw_line.strip()
|
||||
marker = "http://127.0.0.1:"
|
||||
idx = line.find(marker)
|
||||
if idx == -1:
|
||||
continue
|
||||
tail = line[idx + len(marker) :]
|
||||
# tail looks like "8889/lab?token=..." — cut at the first non-digit.
|
||||
digits: List[str] = []
|
||||
for ch in tail:
|
||||
if ch.isdigit():
|
||||
digits.append(ch)
|
||||
else:
|
||||
break
|
||||
if digits:
|
||||
try:
|
||||
return int("".join(digits))
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def build_notebook_url(
|
||||
server: JupyterServerInfo,
|
||||
remote_notebook_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return the tunneled Jupyter Lab URL for a server and optional notebook.
|
||||
|
||||
With ``remote_notebook_path`` inside ``server.workspace_root``, returns
|
||||
``http://127.0.0.1:<local_port>/lab/tree/<relpath>?token=<token>``. If the
|
||||
path is outside the workspace (or ``None``), falls back to plain
|
||||
``/lab?token=<token>`` and logs a note for the out-of-workspace case.
|
||||
"""
|
||||
base = f"http://127.0.0.1:{server.local_port}"
|
||||
query = urlencode({"token": server.token})
|
||||
if remote_notebook_path is None:
|
||||
return f"{base}/lab?{query}"
|
||||
|
||||
workspace = posixpath.normpath(server.workspace_root)
|
||||
candidate = posixpath.normpath(remote_notebook_path)
|
||||
# Require candidate to sit strictly beneath workspace_root to build a
|
||||
# /lab/tree URL — otherwise Jupyter will 404 against a path outside its
|
||||
# root_dir. Equal paths are treated as "outside" (no tree path to add).
|
||||
if workspace and candidate != workspace:
|
||||
prefix = workspace.rstrip("/") + "/"
|
||||
if candidate.startswith(prefix):
|
||||
rel = candidate[len(prefix) :]
|
||||
safe_rel = quote(rel, safe="/")
|
||||
return f"{base}/lab/tree/{safe_rel}?{query}"
|
||||
|
||||
_LOG.info(
|
||||
"notebook path %r is not inside workspace_root %r; opening /lab only",
|
||||
remote_notebook_path,
|
||||
server.workspace_root,
|
||||
)
|
||||
return f"{base}/lab?{query}"
|
||||
|
||||
|
||||
class JupyterSessionManager:
|
||||
"""Process-global registry of running remote Jupyter Lab servers.
|
||||
|
||||
Keyed by SSH ``host_alias``; one active server per alias at a time. Start /
|
||||
stop operations are serialised via an internal lock; ``ensure_started`` is
|
||||
idempotent and coalesces concurrent calls for the same alias.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ssh_command_builder: Optional[SshCommandBuilder] = None,
|
||||
popen: Optional[Callable[..., subprocess.Popen]] = None,
|
||||
run: Optional[Callable[..., subprocess.CompletedProcess]] = None,
|
||||
sleep: Optional[Callable[[float], None]] = None,
|
||||
clock: Optional[Callable[[], float]] = None,
|
||||
connect_probe: Optional[Callable[[int], None]] = None,
|
||||
port_picker: Optional[Callable[[], int]] = None,
|
||||
token_factory: Optional[Callable[[], str]] = None,
|
||||
) -> None:
|
||||
"""Build a manager, optionally injecting stubs for tests.
|
||||
|
||||
Args:
|
||||
ssh_command_builder: Maps an alias to ``argv`` prefix for remote
|
||||
commands. Defaults to ``["ssh", alias]``.
|
||||
popen: Override for ``subprocess.Popen`` (used for the remote launch
|
||||
+ local tunnel child). Tests pass a recording stub.
|
||||
run: Override for ``subprocess.run`` (used for log reads + remote
|
||||
kill). Tests pass a recording stub.
|
||||
sleep: Override for ``time.sleep`` used during log polling.
|
||||
clock: Override for ``time.time`` used for timestamps and
|
||||
timeouts. Must return monotonic-ish seconds.
|
||||
connect_probe: Override for the local-tunnel connect check;
|
||||
takes a port and raises on failure.
|
||||
port_picker: Override for the local-port picker; returns an int.
|
||||
token_factory: Override for auth-token generation; returns str.
|
||||
"""
|
||||
self._ssh = ssh_command_builder or _default_ssh_command_builder
|
||||
# Default run/popen wrap subprocess with CREATE_NO_WINDOW on Windows
|
||||
# so the underlying ssh / tmux children don't pop a console window
|
||||
# every time the plugin talks to the remote. Injected overrides
|
||||
# (unit tests) retain their exact behaviour — the helper returns an
|
||||
# empty kwargs dict on non-Windows, so the wrapper is a no-op there.
|
||||
self._popen = popen or _default_popen
|
||||
self._run = run or _default_run
|
||||
self._sleep = sleep or time.sleep
|
||||
self._clock = clock or time.time
|
||||
self._connect_probe = connect_probe or self._default_connect_probe
|
||||
self._port_picker = port_picker or _pick_free_local_port
|
||||
self._token_factory = token_factory or (lambda: uuid.uuid4().hex)
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._servers: Dict[str, JupyterServerInfo] = {}
|
||||
|
||||
@staticmethod
|
||||
def _default_connect_probe(port: int) -> None:
|
||||
with socket.create_connection(
|
||||
("127.0.0.1", port),
|
||||
timeout=_TUNNEL_PROBE_TIMEOUT_SECONDS,
|
||||
):
|
||||
return
|
||||
|
||||
def get(self, host_alias: str) -> Optional[JupyterServerInfo]:
|
||||
"""Return the running server for ``host_alias`` if one is registered."""
|
||||
with self._lock:
|
||||
return self._servers.get(host_alias)
|
||||
|
||||
def ensure_started(
|
||||
self,
|
||||
host_alias: str,
|
||||
workspace_root: str,
|
||||
*,
|
||||
kernel_python: Optional[str] = None,
|
||||
workspace_cache_key: Optional[str] = None,
|
||||
) -> JupyterServerInfo:
|
||||
"""Return a running Jupyter server for ``host_alias``, launching if needed.
|
||||
|
||||
Idempotent: if a registered server exists and its local-tunnel PID is
|
||||
still alive, that ``JupyterServerInfo`` is returned without spawning a
|
||||
new server. Concurrent calls for the same alias coalesce under the
|
||||
registry lock; only one launch runs.
|
||||
|
||||
When ``kernel_python`` is set, the manager first ensures ``ipykernel``
|
||||
is importable by that interpreter (installing it on demand via
|
||||
``pip install --user``) and registers a Sessions-owned kernelspec so
|
||||
the freshly launched Jupyter defaults to the user's interpreter rather
|
||||
than whichever Python ``jupyter lab`` itself runs on. ``workspace_cache_key``
|
||||
is used to derive a stable per-workspace kernel name; when absent the
|
||||
workspace root path is hashed instead.
|
||||
"""
|
||||
kernel_name: Optional[str] = None
|
||||
if kernel_python:
|
||||
kernel_name = _kernel_name_for_workspace(
|
||||
workspace_root, workspace_cache_key
|
||||
)
|
||||
self._ensure_ipykernel_installed(host_alias, kernel_python)
|
||||
self._register_kernelspec(
|
||||
host_alias,
|
||||
kernel_python=kernel_python,
|
||||
kernel_name=kernel_name,
|
||||
display_name=self._default_display_name(kernel_name),
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
existing = self._servers.get(host_alias)
|
||||
if existing is not None and self._tunnel_is_alive(existing.tunnel_pid):
|
||||
return existing
|
||||
# Drop a stale entry so the launch below can replace it cleanly.
|
||||
if existing is not None:
|
||||
_LOG.info(
|
||||
"dropping stale Jupyter entry for %s (tunnel pid %d gone)",
|
||||
host_alias,
|
||||
existing.tunnel_pid,
|
||||
)
|
||||
self._servers.pop(host_alias, None)
|
||||
|
||||
info = self._launch_locked(
|
||||
host_alias,
|
||||
workspace_root,
|
||||
kernel_name=kernel_name,
|
||||
)
|
||||
self._servers[host_alias] = info
|
||||
return info
|
||||
|
||||
def register_kernelspec_only(
|
||||
self,
|
||||
host_alias: str,
|
||||
kernel_python: str,
|
||||
kernel_name: str,
|
||||
*,
|
||||
display_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Install ``ipykernel`` (if missing) and register a kernelspec.
|
||||
|
||||
Idempotent. Safe to call repeatedly for the same ``kernel_name``; an
|
||||
existing spec is treated as success.
|
||||
"""
|
||||
if not kernel_python:
|
||||
raise JupyterHostingError(
|
||||
"register_kernelspec_only requires a non-empty kernel_python"
|
||||
)
|
||||
if not kernel_name:
|
||||
raise JupyterHostingError(
|
||||
"register_kernelspec_only requires a non-empty kernel_name"
|
||||
)
|
||||
self._ensure_ipykernel_installed(host_alias, kernel_python)
|
||||
self._register_kernelspec(
|
||||
host_alias,
|
||||
kernel_python=kernel_python,
|
||||
kernel_name=kernel_name,
|
||||
display_name=display_name or self._default_display_name(kernel_name),
|
||||
)
|
||||
|
||||
def stop(self, host_alias: str) -> None:
|
||||
"""Tear down the tunnel + remote server for ``host_alias`` (best effort)."""
|
||||
with self._lock:
|
||||
info = self._servers.pop(host_alias, None)
|
||||
if info is None:
|
||||
return
|
||||
self._teardown(info)
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Tear down every registered server; safe to call from plugin_unloaded."""
|
||||
with self._lock:
|
||||
snapshot = list(self._servers.values())
|
||||
self._servers.clear()
|
||||
for info in snapshot:
|
||||
try:
|
||||
self._teardown(info)
|
||||
except Exception: # pragma: no cover - defensive best-effort
|
||||
_LOG.exception("stop_all: teardown failed for %s", info.host_alias)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _tunnel_is_alive(self, pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
# Process exists but is owned by a different user; treat as alive.
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _launch_locked(
|
||||
self,
|
||||
host_alias: str,
|
||||
workspace_root: str,
|
||||
*,
|
||||
kernel_name: Optional[str] = None,
|
||||
) -> JupyterServerInfo:
|
||||
token = self._token_factory()
|
||||
local_port = self._port_picker()
|
||||
log_path = f"~/.sessions/jupyter-{token}.log"
|
||||
|
||||
remote_pid = self._spawn_remote_server(
|
||||
host_alias=host_alias,
|
||||
workspace_root=workspace_root,
|
||||
token=token,
|
||||
log_path=log_path,
|
||||
kernel_name=kernel_name,
|
||||
)
|
||||
remote_port = self._await_remote_port(
|
||||
host_alias=host_alias,
|
||||
log_path=log_path,
|
||||
)
|
||||
tunnel_pid = self._spawn_local_tunnel(
|
||||
host_alias=host_alias,
|
||||
local_port=local_port,
|
||||
remote_port=remote_port,
|
||||
)
|
||||
try:
|
||||
self._connect_probe(local_port)
|
||||
except Exception as exc:
|
||||
# Abort cleanly: tear down what we started before re-raising.
|
||||
self._teardown_pids(
|
||||
host_alias=host_alias,
|
||||
tunnel_pid=tunnel_pid,
|
||||
remote_pid=remote_pid,
|
||||
log_path=log_path,
|
||||
)
|
||||
raise JupyterHostingError(
|
||||
f"local tunnel probe on 127.0.0.1:{local_port} failed: {exc}"
|
||||
) from exc
|
||||
|
||||
return JupyterServerInfo(
|
||||
host_alias=host_alias,
|
||||
workspace_root=workspace_root,
|
||||
remote_port=remote_port,
|
||||
local_port=local_port,
|
||||
token=token,
|
||||
pid=remote_pid,
|
||||
tunnel_pid=tunnel_pid,
|
||||
started_at=self._clock(),
|
||||
kernel_name=kernel_name,
|
||||
)
|
||||
|
||||
def _spawn_remote_server(
|
||||
self,
|
||||
*,
|
||||
host_alias: str,
|
||||
workspace_root: str,
|
||||
token: str,
|
||||
log_path: str,
|
||||
kernel_name: Optional[str] = None,
|
||||
) -> int:
|
||||
kernel_arg = ""
|
||||
if kernel_name:
|
||||
# Quote the kernel name defensively so weird characters never
|
||||
# break out of the remote shell command even though our generator
|
||||
# only emits ``sessions-<hex12>``.
|
||||
kernel_arg = " --MappingKernelManager.default_kernel_name=" + shlex.quote(
|
||||
kernel_name
|
||||
)
|
||||
remote_script = (
|
||||
"mkdir -p ~/.sessions && "
|
||||
f"nohup jupyter lab --no-browser "
|
||||
f"--ServerApp.ip=127.0.0.1 --ServerApp.port=0 "
|
||||
f"--ServerApp.token={token} "
|
||||
f"--ServerApp.root_dir={workspace_root}"
|
||||
f"{kernel_arg} "
|
||||
f"> {log_path} 2>&1 & echo $!"
|
||||
)
|
||||
argv = list(self._ssh(host_alias)) + ["bash", "-lc", remote_script]
|
||||
_LOG.debug("spawning remote jupyter on %s: %s", host_alias, argv)
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise JupyterHostingError(
|
||||
f"remote jupyter launch on {host_alias} exited "
|
||||
f"{completed.returncode}: {completed.stderr!r}"
|
||||
)
|
||||
pid_text = (completed.stdout or "").strip().splitlines()
|
||||
if not pid_text:
|
||||
raise JupyterHostingError(
|
||||
f"remote jupyter launch on {host_alias} produced no PID output"
|
||||
)
|
||||
try:
|
||||
return int(pid_text[-1].strip())
|
||||
except ValueError as exc:
|
||||
raise JupyterHostingError(
|
||||
f"remote jupyter launch on {host_alias} returned non-numeric "
|
||||
f"PID: {pid_text!r}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _default_display_name(kernel_name: str) -> str:
|
||||
"""Return the Sessions-branded display name for a kernel name.
|
||||
|
||||
Trims the ``sessions-`` prefix so the label that Jupyter Lab renders
|
||||
stays terse (the full 12-char hash is visible on hover).
|
||||
"""
|
||||
short = kernel_name
|
||||
if short.startswith("sessions-"):
|
||||
short = short[len("sessions-") :]
|
||||
return "Sessions {}".format(short)
|
||||
|
||||
def _ensure_ipykernel_installed(
|
||||
self,
|
||||
host_alias: str,
|
||||
kernel_python: str,
|
||||
) -> None:
|
||||
"""Ensure ``ipykernel`` is importable via ``kernel_python``.
|
||||
|
||||
``uv`` creates venvs without ``pip`` by default, so the first install
|
||||
attempt often fails with ``No module named pip``. On that specific
|
||||
failure we try ``python -m ensurepip --upgrade --default-pip`` and
|
||||
retry. ``--user`` is **not** passed: most active Python choices are
|
||||
venvs, where ``--user`` bypasses the venv and installs to user-site
|
||||
— precisely the opposite of what the user wants.
|
||||
|
||||
``pip install`` exits 0 when the package is already satisfied, so no
|
||||
special-case handling is required for the already-installed path.
|
||||
"""
|
||||
pip_argv = [
|
||||
kernel_python,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--quiet",
|
||||
"ipykernel",
|
||||
]
|
||||
completed = self._run_over_ssh(host_alias, pip_argv)
|
||||
if completed.returncode == 0:
|
||||
return
|
||||
|
||||
if self._stderr_mentions_missing_pip(completed.stderr):
|
||||
# Bootstrap pip into the venv, then retry. ``ensurepip`` is part
|
||||
# of the Python standard library, so uv venvs that ship without
|
||||
# ``pip`` still have it unless the creator trimmed the stdlib.
|
||||
ensurepip = self._run_over_ssh(
|
||||
host_alias,
|
||||
[kernel_python, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
)
|
||||
if ensurepip.returncode != 0:
|
||||
raise JupyterHostingError(
|
||||
f"ensurepip bootstrap via {kernel_python} on {host_alias} "
|
||||
f"exited {ensurepip.returncode}: "
|
||||
f"stderr={(ensurepip.stderr or '').strip()!r}"
|
||||
)
|
||||
completed = self._run_over_ssh(host_alias, pip_argv)
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise JupyterHostingError(
|
||||
f"ipykernel install via {kernel_python} on {host_alias} "
|
||||
f"exited {completed.returncode}: "
|
||||
f"stdout={(completed.stdout or '').strip()!r} "
|
||||
f"stderr={(completed.stderr or '').strip()!r}"
|
||||
)
|
||||
|
||||
def _run_over_ssh(
|
||||
self,
|
||||
host_alias: str,
|
||||
argv: list,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Shell-quote ``argv`` and run as one remote command under ``ssh <alias>``.
|
||||
|
||||
OpenSSH concatenates any trailing positional arguments with single
|
||||
spaces before handing the resulting string to the remote shell for
|
||||
re-parsing. That means arguments that legitimately contain spaces
|
||||
(``--display-name "Sessions abc"``) are torn apart on the remote side
|
||||
and misread by argparse. Quoting every arg with :func:`shlex.quote`
|
||||
and passing the whole command as a single trailing SSH arg defeats
|
||||
that split.
|
||||
|
||||
Also handles ``~/`` tilde paths: ``shlex.quote`` single-quotes the
|
||||
whole arg which prevents the remote shell from expanding ``~``, and
|
||||
zsh / bash refuse a literal ``~`` as a path component. Rewrite the
|
||||
leading ``~/`` as ``"$HOME"/...`` so the unquoted ``$HOME`` expands
|
||||
while the suffix stays safely quoted.
|
||||
"""
|
||||
remote_cmd = " ".join(_shell_quote_with_tilde_expansion(a) for a in argv)
|
||||
full = list(self._ssh(host_alias)) + [remote_cmd]
|
||||
return self._run(
|
||||
full,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _stderr_mentions_missing_pip(stderr: Optional[str]) -> bool:
|
||||
return "No module named pip" in (stderr or "")
|
||||
|
||||
def _register_kernelspec(
|
||||
self,
|
||||
host_alias: str,
|
||||
*,
|
||||
kernel_python: str,
|
||||
kernel_name: str,
|
||||
display_name: str,
|
||||
) -> None:
|
||||
"""Register a Sessions-owned kernelspec pointing at ``kernel_python``.
|
||||
|
||||
Idempotent: if ``jupyter kernelspec install`` refuses because the spec
|
||||
is already present (either via "already exists" message or non-zero
|
||||
exit carrying that message), the call is treated as success.
|
||||
"""
|
||||
completed = self._run_over_ssh(
|
||||
host_alias,
|
||||
[
|
||||
kernel_python,
|
||||
"-m",
|
||||
"ipykernel",
|
||||
"install",
|
||||
"--user",
|
||||
"--name",
|
||||
kernel_name,
|
||||
"--display-name",
|
||||
display_name,
|
||||
],
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return
|
||||
if _is_kernelspec_already_exists(completed.stdout, completed.stderr):
|
||||
_LOG.info(
|
||||
"kernelspec %s already exists on %s; reusing",
|
||||
kernel_name,
|
||||
host_alias,
|
||||
)
|
||||
return
|
||||
raise JupyterHostingError(
|
||||
f"kernelspec install {kernel_name} on {host_alias} exited "
|
||||
f"{completed.returncode}: "
|
||||
f"stdout={(completed.stdout or '').strip()!r} "
|
||||
f"stderr={(completed.stderr or '').strip()!r}"
|
||||
)
|
||||
|
||||
def _await_remote_port(
|
||||
self,
|
||||
*,
|
||||
host_alias: str,
|
||||
log_path: str,
|
||||
) -> int:
|
||||
deadline = self._clock() + _STARTUP_TIMEOUT_SECONDS
|
||||
argv = list(self._ssh(host_alias)) + ["cat", log_path]
|
||||
last_text = ""
|
||||
while self._clock() < deadline:
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
last_text = completed.stdout or ""
|
||||
port = _parse_remote_port_from_log(last_text)
|
||||
if port is not None:
|
||||
return port
|
||||
self._sleep(_STARTUP_POLL_INTERVAL_SECONDS)
|
||||
raise JupyterHostingError(
|
||||
f"timed out waiting for Jupyter startup on {host_alias}; "
|
||||
f"last log snippet: {last_text[-400:]!r}"
|
||||
)
|
||||
|
||||
def _spawn_local_tunnel(
|
||||
self,
|
||||
*,
|
||||
host_alias: str,
|
||||
local_port: int,
|
||||
remote_port: int,
|
||||
) -> int:
|
||||
forward_spec = f"127.0.0.1:{local_port}:127.0.0.1:{remote_port}"
|
||||
argv = ["ssh", "-N", "-L", forward_spec, host_alias]
|
||||
_LOG.debug("spawning local tunnel: %s", argv)
|
||||
proc = self._popen(
|
||||
argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
pid = getattr(proc, "pid", None)
|
||||
if pid is None:
|
||||
raise JupyterHostingError(
|
||||
f"local ssh tunnel for {host_alias} did not report a PID"
|
||||
)
|
||||
return int(pid)
|
||||
|
||||
def _teardown(self, info: JupyterServerInfo) -> None:
|
||||
self._teardown_pids(
|
||||
host_alias=info.host_alias,
|
||||
tunnel_pid=info.tunnel_pid,
|
||||
remote_pid=info.pid,
|
||||
log_path=f"~/.sessions/jupyter-{info.token}.log",
|
||||
)
|
||||
|
||||
def _teardown_pids(
|
||||
self,
|
||||
*,
|
||||
host_alias: str,
|
||||
tunnel_pid: int,
|
||||
remote_pid: int,
|
||||
log_path: str,
|
||||
) -> None:
|
||||
self._kill_local_tunnel(tunnel_pid)
|
||||
self._kill_remote_pid(host_alias, remote_pid)
|
||||
self._cleanup_remote_log(host_alias, log_path)
|
||||
|
||||
def _kill_local_tunnel(self, pid: int) -> None:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except OSError as exc:
|
||||
_LOG.warning("SIGTERM on tunnel pid %d failed: %s", pid, exc)
|
||||
return
|
||||
|
||||
deadline = self._clock() + _TERMINATE_GRACE_SECONDS
|
||||
while self._clock() < deadline:
|
||||
if not self._tunnel_is_alive(pid):
|
||||
return
|
||||
self._sleep(0.1)
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except OSError as exc:
|
||||
_LOG.warning("SIGKILL on tunnel pid %d failed: %s", pid, exc)
|
||||
|
||||
def _kill_remote_pid(self, host_alias: str, pid: int) -> None:
|
||||
argv = list(self._ssh(host_alias)) + ["kill", str(pid)]
|
||||
try:
|
||||
self._run(
|
||||
argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
_LOG.warning("remote kill %d on %s failed: %s", pid, host_alias, exc)
|
||||
|
||||
def _cleanup_remote_log(self, host_alias: str, log_path: str) -> None:
|
||||
argv = list(self._ssh(host_alias)) + ["rm", "-f", log_path]
|
||||
try:
|
||||
self._run(
|
||||
argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
_LOG.warning(
|
||||
"remote log cleanup %s on %s failed: %s", log_path, host_alias, exc
|
||||
)
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
from .managed_remote_lsp_catalog import (
|
||||
BUILTIN_MANAGED_REMOTE_LSP_CATALOG,
|
||||
from .managed_remote_extension_catalog import (
|
||||
BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG,
|
||||
SESSIONS_LSP_PYRIGHT_CLIENT_KEY,
|
||||
SESSIONS_LSP_RUFF_CLIENT_KEY,
|
||||
SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY,
|
||||
@@ -29,6 +31,37 @@ def _as_str_dict(value: object) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_sublime_project_json(raw: str) -> object:
|
||||
"""Parse ``.sublime-project`` JSON, tolerating Sublime's ``//`` comments.
|
||||
|
||||
Sublime accepts JSON-with-comments + trailing commas in project files;
|
||||
Python's ``json.loads`` rejects both and raises ``JSONDecodeError``.
|
||||
Fall back to ``sublime.decode_value`` when the strict parser fails and
|
||||
the sublime runtime is importable (i.e. running inside Sublime Text).
|
||||
Unit tests run without sublime available and are expected to pass pure
|
||||
JSON, so the fallback is skipped there.
|
||||
"""
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
decode_value = _sublime_decode_value_function()
|
||||
if decode_value is None:
|
||||
raise
|
||||
return decode_value(raw)
|
||||
|
||||
|
||||
def _sublime_decode_value_function():
|
||||
"""Return ``sublime.decode_value`` when available (ST JSON flavor)."""
|
||||
try:
|
||||
sublime_mod = importlib.import_module("sublime")
|
||||
except ImportError:
|
||||
return None
|
||||
decode_value = getattr(sublime_mod, "decode_value", None)
|
||||
if callable(decode_value):
|
||||
return decode_value
|
||||
return None
|
||||
|
||||
|
||||
def _deep_merge_lsp_client_row(
|
||||
base: Mapping[str, Any], overlay: Mapping[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
@@ -53,7 +86,9 @@ def _deep_merge_lsp_client_row(
|
||||
|
||||
def _normalize_managed_lsp_client_aliases(merged_lsp: MutableMapping[str, Any]) -> None:
|
||||
"""Fold legacy client keys into canonical LSP plugin project keys."""
|
||||
for entry in BUILTIN_MANAGED_REMOTE_LSP_CATALOG:
|
||||
for entry in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG:
|
||||
if entry.kind != "lsp":
|
||||
continue
|
||||
canon = entry.project_client_key
|
||||
for legacy_key in entry.legacy_project_client_keys:
|
||||
if legacy_key == canon:
|
||||
@@ -154,14 +189,33 @@ def build_managed_lsp_settings_block(
|
||||
remote_workspace_root: str,
|
||||
host_alias: str,
|
||||
local_cache_root: str,
|
||||
active_python_path: Optional[str] = None,
|
||||
managed_lsp_enabled: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return an ``LSP`` settings subtree for managed remote stdio clients."""
|
||||
"""Return an ``LSP`` settings subtree for managed remote stdio clients.
|
||||
|
||||
When ``active_python_path`` is supplied, the pyright client row also gets
|
||||
``settings.python.pythonPath`` pointing at that remote interpreter so
|
||||
LSP-pyright uses the chosen environment.
|
||||
|
||||
``managed_lsp_enabled`` controls the per-row ``"enabled"`` flag. The
|
||||
bridge handshake must have completed (broker socket present + listening)
|
||||
before LSP clients can attach; spawning ``local_bridge lsp-stdio``
|
||||
against a stale or missing broker_socket exits 1 immediately, and the
|
||||
Sublime LSP package retries five times in 180s before disabling the
|
||||
client for the session. To avoid that crash storm at Sublime boot the
|
||||
refresh path passes ``managed_lsp_enabled=False`` until the broker
|
||||
socket is observed live, then flips back to ``True`` once
|
||||
``_on_persistent_bridge_handshake_ready`` fires.
|
||||
"""
|
||||
local_uri, remote_uri = lsp_uri_prefix_pair(
|
||||
local_cache_root=local_cache_root,
|
||||
remote_workspace_root=remote_workspace_root,
|
||||
)
|
||||
lsp_root: Dict[str, Any] = {}
|
||||
for entry in BUILTIN_MANAGED_REMOTE_LSP_CATALOG:
|
||||
for entry in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG:
|
||||
if entry.kind != "lsp":
|
||||
continue
|
||||
command = _build_stdio_command(
|
||||
bridge_path=bridge_path,
|
||||
broker_socket=broker_socket,
|
||||
@@ -172,18 +226,24 @@ def build_managed_lsp_settings_block(
|
||||
local_uri_prefix=local_uri,
|
||||
remote_uri_prefix=remote_uri,
|
||||
)
|
||||
settings_block: Dict[str, Any] = {
|
||||
"sessions": {
|
||||
"host_alias": host_alias,
|
||||
"remote_workspace_root": remote_workspace_root,
|
||||
"workspace_id": workspace_id,
|
||||
}
|
||||
}
|
||||
if (
|
||||
active_python_path
|
||||
and entry.project_client_key == SESSIONS_LSP_PYRIGHT_CLIENT_KEY
|
||||
):
|
||||
settings_block["python"] = {"pythonPath": active_python_path}
|
||||
lsp_root[entry.project_client_key] = {
|
||||
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
|
||||
"enabled": True,
|
||||
"enabled": bool(managed_lsp_enabled),
|
||||
"selector": entry.sublime_selector,
|
||||
"command": command,
|
||||
"settings": {
|
||||
"sessions": {
|
||||
"host_alias": host_alias,
|
||||
"remote_workspace_root": remote_workspace_root,
|
||||
"workspace_id": workspace_id,
|
||||
}
|
||||
},
|
||||
"settings": settings_block,
|
||||
}
|
||||
return lsp_root
|
||||
|
||||
@@ -197,8 +257,14 @@ def merge_sessions_lsp_into_project_data(
|
||||
remote_workspace_root: str,
|
||||
host_alias: str,
|
||||
local_cache_root: str,
|
||||
active_python_path: Optional[str] = None,
|
||||
managed_lsp_enabled: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a copy of ``project_data`` with managed ``settings.LSP`` rows merged."""
|
||||
"""Return a copy of ``project_data`` with managed ``settings.LSP`` rows merged.
|
||||
|
||||
See :func:`build_managed_lsp_settings_block` for the meaning of
|
||||
``managed_lsp_enabled``.
|
||||
"""
|
||||
base = dict(project_data)
|
||||
settings = _as_str_dict(base.get("settings"))
|
||||
existing_lsp = _as_str_dict(settings.get("LSP"))
|
||||
@@ -209,6 +275,8 @@ def merge_sessions_lsp_into_project_data(
|
||||
remote_workspace_root=remote_workspace_root,
|
||||
host_alias=host_alias,
|
||||
local_cache_root=local_cache_root,
|
||||
active_python_path=active_python_path,
|
||||
managed_lsp_enabled=managed_lsp_enabled,
|
||||
)
|
||||
merged_lsp: Dict[str, Any] = dict(existing_lsp)
|
||||
_normalize_managed_lsp_client_aliases(merged_lsp)
|
||||
@@ -219,7 +287,9 @@ def merge_sessions_lsp_into_project_data(
|
||||
)
|
||||
# Turn off legacy LSP client ids (global LanguageServers entries) so only the
|
||||
# canonical Sessions-managed stdio row attaches per server family.
|
||||
for entry in BUILTIN_MANAGED_REMOTE_LSP_CATALOG:
|
||||
for entry in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG:
|
||||
if entry.kind != "lsp":
|
||||
continue
|
||||
for legacy_key in entry.legacy_project_client_keys:
|
||||
if legacy_key == entry.project_client_key:
|
||||
continue
|
||||
@@ -267,7 +337,9 @@ def collect_lsp_diagnostics_snapshot(
|
||||
"broker_socket": broker_socket,
|
||||
"broker_socket_exists": broker_exists,
|
||||
"managed_client_ids": [
|
||||
e.project_client_key for e in BUILTIN_MANAGED_REMOTE_LSP_CATALOG
|
||||
e.project_client_key
|
||||
for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG
|
||||
if e.kind == "lsp"
|
||||
],
|
||||
}
|
||||
|
||||
@@ -337,10 +409,24 @@ def refresh_project_file_lsp_block(
|
||||
remote_workspace_root: str,
|
||||
host_alias: str,
|
||||
local_cache_root: str,
|
||||
active_python_path: Optional[str] = None,
|
||||
managed_lsp_enabled: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Read project JSON from disk, merge managed LSP, write back, return merged."""
|
||||
"""Read project JSON from disk, merge managed LSP, write back, return merged.
|
||||
|
||||
Only writes to disk when the rendered output differs from what's already
|
||||
on disk. Sublime logs a noisy ``reloading <path>`` line whenever a file
|
||||
it has open has its mtime bumped; re-writing identical bytes on every
|
||||
``on_activated`` spams the console with one line per Cargo.toml /
|
||||
Cargo.lock / .sublime-project touch, and we got user feedback that the
|
||||
noise was excessive. The short-circuit preserves the merged return value
|
||||
for callers that depend on it.
|
||||
|
||||
See :func:`build_managed_lsp_settings_block` for the meaning of
|
||||
``managed_lsp_enabled``.
|
||||
"""
|
||||
raw = project_file_path.read_text(encoding="utf-8")
|
||||
existing = json.loads(raw)
|
||||
existing = _parse_sublime_project_json(raw)
|
||||
if not isinstance(existing, dict):
|
||||
raise ValueError("project file must contain a JSON object")
|
||||
merged = merge_sessions_lsp_into_project_data(
|
||||
@@ -351,14 +437,90 @@ def refresh_project_file_lsp_block(
|
||||
remote_workspace_root=remote_workspace_root,
|
||||
host_alias=host_alias,
|
||||
local_cache_root=local_cache_root,
|
||||
active_python_path=active_python_path,
|
||||
managed_lsp_enabled=managed_lsp_enabled,
|
||||
)
|
||||
project_file_path.write_text(
|
||||
json.dumps(merged, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
rendered = json.dumps(merged, indent=2, sort_keys=True) + "\n"
|
||||
if rendered != raw:
|
||||
project_file_path.write_text(rendered, encoding="utf-8")
|
||||
return merged
|
||||
|
||||
|
||||
def disable_stale_managed_lsp_rows_on_disk(
|
||||
project_file_path: Path,
|
||||
*,
|
||||
live_broker_socket: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""Set ``enabled: false`` on managed LSP rows whose broker socket is dead.
|
||||
|
||||
Called at Sublime startup before the bridge handshake completes (and
|
||||
before LSP-pyright / LSP-ruff get a chance to spawn the Sessions
|
||||
``local_bridge lsp-stdio`` helper against a stale ``--bridge-socket``
|
||||
path left over from the previous Sublime PID). Without this gate the
|
||||
helper exits 1 immediately, the LSP package retries 5 times in 180s,
|
||||
then disables both clients for the entire session — observable as a
|
||||
crash storm in the console at boot.
|
||||
|
||||
Returns the list of client keys whose ``enabled`` flag flipped to
|
||||
``False`` so the caller can emit a single trace summarising the
|
||||
pre-handshake disable. Writes to disk only when at least one row
|
||||
changed; preserves user-managed (``sessions_remote_stdio_managed:
|
||||
False``) rows untouched.
|
||||
|
||||
``live_broker_socket`` is the broker socket reported by the current
|
||||
handshake (if any). When provided, rows whose ``--bridge-socket``
|
||||
already matches the live path are left enabled.
|
||||
"""
|
||||
try:
|
||||
raw = project_file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return []
|
||||
try:
|
||||
existing = _parse_sublime_project_json(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(existing, dict):
|
||||
return []
|
||||
settings = existing.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
return []
|
||||
lsp = settings.get("LSP")
|
||||
if not isinstance(lsp, dict):
|
||||
return []
|
||||
live = (live_broker_socket or "").strip()
|
||||
flipped: List[str] = []
|
||||
for client_key, row in lsp.items():
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if not row.get(SESSIONS_REMOTE_LSP_MANAGED_KEY):
|
||||
continue
|
||||
if row.get("enabled") is False:
|
||||
continue
|
||||
command = row.get("command")
|
||||
row_socket = ""
|
||||
if isinstance(command, list):
|
||||
for i, arg in enumerate(command):
|
||||
if arg == "--bridge-socket" and i + 1 < len(command):
|
||||
next_arg = command[i + 1]
|
||||
row_socket = str(next_arg) if isinstance(next_arg, str) else ""
|
||||
break
|
||||
# Row's broker socket already matches a live one — leave enabled.
|
||||
if live and row_socket == live and Path(row_socket).exists():
|
||||
continue
|
||||
# Anything else (empty, stale path, missing file) is unsafe to keep
|
||||
# enabled until the handshake refresh re-validates the socket.
|
||||
row["enabled"] = False
|
||||
flipped.append(str(client_key))
|
||||
if not flipped:
|
||||
return []
|
||||
rendered = json.dumps(existing, indent=2, sort_keys=True) + "\n"
|
||||
if rendered == raw:
|
||||
# Spelling difference only (e.g. key order); no semantic change.
|
||||
return []
|
||||
project_file_path.write_text(rendered, encoding="utf-8")
|
||||
return sorted(flipped)
|
||||
|
||||
|
||||
def trace_lsp_workspace_activation(
|
||||
*,
|
||||
host_alias: str,
|
||||
@@ -395,7 +557,16 @@ def explain_lsp_attach_blockers(
|
||||
handshake: Optional[Mapping[str, Any]],
|
||||
bridge_path: Optional[Path],
|
||||
) -> Optional[str]:
|
||||
"""Return a user-facing reason string when remote LSP wiring cannot attach."""
|
||||
"""Return a user-facing reason string when remote LSP wiring cannot attach.
|
||||
|
||||
On Windows the PersistentBroker (Unix-domain-socket based) cannot come
|
||||
up — ``broker_socket`` is always empty in the handshake. That is a
|
||||
known platform limitation, not a user-actionable blocker, so we return
|
||||
``None`` for the empty-broker case on Windows to avoid re-opening the
|
||||
LSP diagnostics panel on every activation. Basic file operations
|
||||
(read / write / mirror / save) still work through the per-request
|
||||
bridge channel; only LSP stdio multiplexing is unavailable.
|
||||
"""
|
||||
if bridge_path is None:
|
||||
return (
|
||||
"Sessions: local_bridge binary not found; build or ship local_bridge "
|
||||
@@ -408,6 +579,9 @@ def explain_lsp_attach_blockers(
|
||||
)
|
||||
broker = handshake.get("broker_socket")
|
||||
if not isinstance(broker, str) or not broker.strip():
|
||||
if sys.platform == "win32":
|
||||
# Known Windows limitation — see module docstring. Stay silent.
|
||||
return None
|
||||
return (
|
||||
"Sessions: handshake is missing broker_socket "
|
||||
"(need current local_bridge + session_helper)."
|
||||
@@ -427,6 +601,7 @@ __all__ = (
|
||||
"SESSIONS_REMOTE_LSP_MANAGED_KEY",
|
||||
"build_managed_lsp_settings_block",
|
||||
"collect_lsp_diagnostics_snapshot",
|
||||
"disable_stale_managed_lsp_rows_on_disk",
|
||||
"existing_managed_broker_sockets",
|
||||
"explain_lsp_attach_blockers",
|
||||
"format_lsp_diagnostics_panel_text",
|
||||
|
||||
348
sublime/sessions/managed_remote_extension_catalog.py
Normal file
348
sublime/sessions/managed_remote_extension_catalog.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""Single source of truth for built-in remote extensions (install + project stdio).
|
||||
|
||||
Each :class:`ManagedRemoteExtensionCatalogEntry` bundles:
|
||||
* Remote install/remove/probe metadata (palette ``exec/once`` catalog).
|
||||
* Sublime ``.sublime-project`` ``settings.LSP`` merge metadata (client key, selector,
|
||||
remote ``argv`` for ``local_bridge lsp-stdio``) when ``kind == "lsp"``.
|
||||
|
||||
Add a new built-in extension by appending one frozen row to
|
||||
:data:`BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG` and wiring any host-specific hints in
|
||||
``sessions.commands`` if needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# Canonical ``settings.LSP`` keys (sublimelsp sublime-package.json project schemas).
|
||||
SESSIONS_LSP_PYRIGHT_CLIENT_KEY = "LSP-pyright"
|
||||
SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY = "rust-analyzer"
|
||||
SESSIONS_LSP_RUFF_CLIENT_KEY = "LSP-ruff"
|
||||
|
||||
|
||||
_BUILTIN_BASH_PYRIGHT_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
GET_PIP_URL=https://bootstrap.pypa.io/get-pip.py
|
||||
set -e
|
||||
if python3 -m pip install --user pyright; then exit 0; fi
|
||||
if command -v pip3 >/dev/null 2>&1 && pip3 install --user pyright; then exit 0; fi
|
||||
if command -v pip >/dev/null 2>&1 && pip install --user pyright; then exit 0; fi
|
||||
if python3 -m ensurepip --user --default-pip >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user pyright; then exit 0; fi
|
||||
if command -v curl >/dev/null 2>&1 && curl -fsSL "$GET_PIP_URL" \\
|
||||
| python3 - --user >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user pyright; then exit 0; fi
|
||||
echo "Sessions: could not install pyright (need python3 and pip or curl)." >&2
|
||||
exit 1
|
||||
"""
|
||||
_BUILTIN_BASH_PYRIGHT_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m pip uninstall -y pyright 2>/dev/null || true
|
||||
command -v pip3 >/dev/null 2>&1 && pip3 uninstall -y pyright 2>/dev/null || true
|
||||
command -v pip >/dev/null 2>&1 && pip uninstall -y pyright 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_RUFF_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
GET_PIP_URL=https://bootstrap.pypa.io/get-pip.py
|
||||
set -e
|
||||
if python3 -m pip install --user ruff; then exit 0; fi
|
||||
if command -v pip3 >/dev/null 2>&1 && pip3 install --user ruff; then exit 0; fi
|
||||
if command -v pip >/dev/null 2>&1 && pip install --user ruff; then exit 0; fi
|
||||
if python3 -m ensurepip --user --default-pip >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user ruff; then exit 0; fi
|
||||
if command -v curl >/dev/null 2>&1 && curl -fsSL "$GET_PIP_URL" \\
|
||||
| python3 - --user >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user ruff; then exit 0; fi
|
||||
echo "Sessions: could not install ruff (need python3 and pip or curl)." >&2
|
||||
exit 1
|
||||
"""
|
||||
_BUILTIN_BASH_RUFF_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m pip uninstall -y ruff 2>/dev/null || true
|
||||
command -v pip3 >/dev/null 2>&1 && pip3 uninstall -y ruff 2>/dev/null || true
|
||||
command -v pip >/dev/null 2>&1 && pip uninstall -y ruff 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_RUST_ANALYZER_INSTALL = """\
|
||||
export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
set -e
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
echo "Sessions: rustup not found; install Rust from https://rustup.rs" >&2
|
||||
exit 127
|
||||
fi
|
||||
rustup component add rust-src
|
||||
rustup component add rust-analyzer
|
||||
"""
|
||||
_BUILTIN_BASH_RUST_ANALYZER_REMOVE = """\
|
||||
export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
rustup component remove rust-analyzer 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_JUPYTER_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
GET_PIP_URL=https://bootstrap.pypa.io/get-pip.py
|
||||
set -e
|
||||
PKGS="jupyterlab ipykernel"
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "Sessions: python3 required to install Jupyter Lab." >&2
|
||||
exit 127
|
||||
fi
|
||||
if python3 -m pip install --user $PKGS; then exit 0; fi
|
||||
if command -v pip3 >/dev/null 2>&1 && pip3 install --user $PKGS; then
|
||||
exit 0
|
||||
fi
|
||||
if command -v pip >/dev/null 2>&1 && pip install --user $PKGS; then
|
||||
exit 0
|
||||
fi
|
||||
if python3 -m ensurepip --user --default-pip >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user $PKGS; then exit 0; fi
|
||||
if command -v curl >/dev/null 2>&1 && curl -fsSL "$GET_PIP_URL" \\
|
||||
| python3 - --user >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user $PKGS; then exit 0; fi
|
||||
echo "Sessions: could not install Jupyter Lab." >&2
|
||||
exit 1
|
||||
"""
|
||||
_BUILTIN_BASH_JUPYTER_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
PKGS="jupyterlab jupyter_server jupyterlab_server"
|
||||
python3 -m pip uninstall -y $PKGS 2>/dev/null || true
|
||||
if command -v pip3 >/dev/null 2>&1; then
|
||||
pip3 uninstall -y $PKGS 2>/dev/null || true
|
||||
fi
|
||||
if command -v pip >/dev/null 2>&1; then
|
||||
pip uninstall -y $PKGS 2>/dev/null || true
|
||||
fi
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_JUPYTER_PROBE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
set -e
|
||||
command -v jupyter >/dev/null 2>&1 || { echo "jupyter not on PATH" >&2; exit 127; }
|
||||
jupyter lab --version
|
||||
"""
|
||||
_BUILTIN_BASH_DEBUGPY_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
set -e
|
||||
if [ -z "{ACTIVE_PYTHON}" ]; then
|
||||
echo "Sessions: active Python not set." >&2
|
||||
echo "Pick one via 'Sessions: Select Python Interpreter' first." >&2
|
||||
exit 64
|
||||
fi
|
||||
"{ACTIVE_PYTHON}" -m pip install --upgrade debugpy
|
||||
"""
|
||||
_BUILTIN_BASH_DEBUGPY_REMOVE = """\
|
||||
if [ -z "{ACTIVE_PYTHON}" ]; then exit 0; fi
|
||||
"{ACTIVE_PYTHON}" -m pip uninstall -y debugpy 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_DEBUGPY_PROBE = """\
|
||||
if [ -z "{ACTIVE_PYTHON}" ]; then
|
||||
echo "active python not set" >&2
|
||||
exit 64
|
||||
fi
|
||||
"{ACTIVE_PYTHON}" -c "import debugpy, sys; print(debugpy.__version__)"
|
||||
"""
|
||||
_BUILTIN_BASH_TMUX_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
tmux -V
|
||||
exit 0
|
||||
fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y tmux
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf install -y tmux
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
sudo yum install -y tmux
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
sudo pacman -S --noconfirm tmux
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
brew install tmux
|
||||
else
|
||||
echo "Sessions: no supported package manager found (apt/dnf/yum/pacman/brew)." >&2
|
||||
echo "Install tmux manually; see https://github.com/tmux/tmux/wiki/Installing" >&2
|
||||
exit 127
|
||||
fi
|
||||
"""
|
||||
_BUILTIN_BASH_TMUX_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get remove -y tmux 2>/dev/null || true
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf remove -y tmux 2>/dev/null || true
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
sudo yum remove -y tmux 2>/dev/null || true
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
sudo pacman -R --noconfirm tmux 2>/dev/null || true
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
brew uninstall tmux 2>/dev/null || true
|
||||
fi
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_TMUX_PROBE = """\
|
||||
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
command -v tmux >/dev/null 2>&1 || { echo "tmux not on PATH" >&2; exit 127; }
|
||||
tmux -V
|
||||
"""
|
||||
_BUILTIN_BASH_CLAUDE_INSTALL = """\
|
||||
export PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
|
||||
set -e
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "Sessions: curl is required to install Claude Code CLI." >&2
|
||||
echo "See https://docs.claude.com/en/docs/claude-code/setup for manual install." >&2
|
||||
exit 127
|
||||
fi
|
||||
if ! curl -fsSL https://claude.ai/install.sh | bash; then
|
||||
echo "Sessions: Claude Code install script failed (URL unreachable?)." >&2
|
||||
echo "See https://docs.claude.com/en/docs/claude-code/setup for manual install." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PATH="$HOME/.claude/bin:$PATH"
|
||||
command -v claude >/dev/null 2>&1 && claude --version
|
||||
"""
|
||||
_BUILTIN_BASH_CLAUDE_REMOVE = """\
|
||||
rm -rf "$HOME/.claude/bin"
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_CLAUDE_PROBE = """\
|
||||
export PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
|
||||
command -v claude >/dev/null 2>&1 || { echo "claude not on PATH" >&2; exit 127; }
|
||||
claude --version
|
||||
"""
|
||||
_BUILTIN_BASH_CODEX_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
set -e
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "Sessions: npm is required to install the OpenAI Codex CLI." >&2
|
||||
echo "Install Node.js / npm first (see https://nodejs.org/)." >&2
|
||||
exit 127
|
||||
fi
|
||||
npm install -g @openai/codex
|
||||
command -v codex >/dev/null 2>&1 && codex --version
|
||||
"""
|
||||
_BUILTIN_BASH_CODEX_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
command -v npm >/dev/null 2>&1 && npm uninstall -g @openai/codex 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_CODEX_PROBE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
command -v codex >/dev/null 2>&1 || { echo "codex not on PATH" >&2; exit 127; }
|
||||
codex --version
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedRemoteExtensionCatalogEntry:
|
||||
"""Metadata for one Sessions-managed remote extension."""
|
||||
|
||||
install_catalog_id: str
|
||||
install_label: str
|
||||
install_argv: Tuple[str, ...]
|
||||
remove_argv: Tuple[str, ...]
|
||||
probe_argv: Tuple[str, ...]
|
||||
install_cwd: Optional[str]
|
||||
kind: str = "lsp"
|
||||
project_client_key: Optional[str] = None
|
||||
legacy_project_client_keys: Tuple[str, ...] = ()
|
||||
bridge_server_id: Optional[str] = None
|
||||
remote_spawn_argv: Optional[Tuple[str, ...]] = None
|
||||
sublime_selector: Optional[str] = None
|
||||
|
||||
|
||||
BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG: Tuple[
|
||||
ManagedRemoteExtensionCatalogEntry, ...
|
||||
] = (
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="pyright-langserver",
|
||||
install_label="Pyright",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_PYRIGHT_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_PYRIGHT_REMOVE),
|
||||
probe_argv=("pyright", "--version"),
|
||||
install_cwd=None,
|
||||
kind="lsp",
|
||||
project_client_key=SESSIONS_LSP_PYRIGHT_CLIENT_KEY,
|
||||
legacy_project_client_keys=("pyright",),
|
||||
bridge_server_id=SESSIONS_LSP_PYRIGHT_CLIENT_KEY,
|
||||
remote_spawn_argv=("pyright-langserver", "--stdio"),
|
||||
sublime_selector="source.python",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="ruff",
|
||||
install_label="Ruff",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_RUFF_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_RUFF_REMOVE),
|
||||
probe_argv=("ruff", "--version"),
|
||||
install_cwd=None,
|
||||
kind="lsp",
|
||||
project_client_key=SESSIONS_LSP_RUFF_CLIENT_KEY,
|
||||
legacy_project_client_keys=("ruff",),
|
||||
bridge_server_id=SESSIONS_LSP_RUFF_CLIENT_KEY,
|
||||
remote_spawn_argv=("ruff", "server"),
|
||||
sublime_selector="source.python",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="rust-analyzer",
|
||||
install_label="rust-analyzer",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_RUST_ANALYZER_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_RUST_ANALYZER_REMOVE),
|
||||
probe_argv=("rust-analyzer", "--version"),
|
||||
install_cwd=None,
|
||||
kind="lsp",
|
||||
project_client_key=SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY,
|
||||
legacy_project_client_keys=("LSP-rust-analyzer",),
|
||||
bridge_server_id=SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY,
|
||||
remote_spawn_argv=("rust-analyzer",),
|
||||
sublime_selector="source.rust",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="jupyterlab",
|
||||
install_label="Jupyter Lab (remote)",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_JUPYTER_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_JUPYTER_REMOVE),
|
||||
probe_argv=("bash", "-lc", _BUILTIN_BASH_JUPYTER_PROBE),
|
||||
install_cwd=None,
|
||||
kind="jupyter",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="debugpy",
|
||||
install_label="debugpy (remote Python debugger)",
|
||||
# Install placeholder — the install flow substitutes {ACTIVE_PYTHON} at
|
||||
# install time. If the user has not selected an interpreter, the flow
|
||||
# refuses to run this spec.
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_DEBUGPY_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_DEBUGPY_REMOVE),
|
||||
probe_argv=("bash", "-lc", _BUILTIN_BASH_DEBUGPY_PROBE),
|
||||
install_cwd=None,
|
||||
kind="debugger",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="tmux",
|
||||
install_label="tmux (agent session prerequisite)",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_TMUX_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_TMUX_REMOVE),
|
||||
probe_argv=("bash", "-lc", _BUILTIN_BASH_TMUX_PROBE),
|
||||
install_cwd=None,
|
||||
kind="agent",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="claude-code",
|
||||
install_label="Claude Code CLI (remote)",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_CLAUDE_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_CLAUDE_REMOVE),
|
||||
probe_argv=("bash", "-lc", _BUILTIN_BASH_CLAUDE_PROBE),
|
||||
install_cwd=None,
|
||||
kind="agent",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="codex-cli",
|
||||
install_label="OpenAI Codex CLI (remote)",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_CODEX_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_CODEX_REMOVE),
|
||||
probe_argv=("bash", "-lc", _BUILTIN_BASH_CODEX_PROBE),
|
||||
install_cwd=None,
|
||||
kind="agent",
|
||||
),
|
||||
)
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Single source of truth for built-in remote LSP servers (install + project stdio).
|
||||
|
||||
Each :class:`ManagedRemoteLspCatalogEntry` bundles:
|
||||
* Remote install/remove/probe metadata (palette ``exec/once`` catalog).
|
||||
* Sublime ``.sublime-project`` ``settings.LSP`` merge metadata (client key, selector,
|
||||
remote ``argv`` for ``local_bridge lsp-stdio``).
|
||||
|
||||
Add a new built-in server by appending one frozen row to
|
||||
:data:`BUILTIN_MANAGED_REMOTE_LSP_CATALOG` and wiring any host-specific hints in
|
||||
``sessions.commands`` if needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# Canonical ``settings.LSP`` keys (sublimelsp sublime-package.json project schemas).
|
||||
SESSIONS_LSP_PYRIGHT_CLIENT_KEY = "LSP-pyright"
|
||||
SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY = "rust-analyzer"
|
||||
SESSIONS_LSP_RUFF_CLIENT_KEY = "LSP-ruff"
|
||||
|
||||
|
||||
_BUILTIN_BASH_PYRIGHT_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
GET_PIP_URL=https://bootstrap.pypa.io/get-pip.py
|
||||
set -e
|
||||
if python3 -m pip install --user pyright; then exit 0; fi
|
||||
if command -v pip3 >/dev/null 2>&1 && pip3 install --user pyright; then exit 0; fi
|
||||
if command -v pip >/dev/null 2>&1 && pip install --user pyright; then exit 0; fi
|
||||
if python3 -m ensurepip --user --default-pip >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user pyright; then exit 0; fi
|
||||
if command -v curl >/dev/null 2>&1 && curl -fsSL "$GET_PIP_URL" \\
|
||||
| python3 - --user >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user pyright; then exit 0; fi
|
||||
echo "Sessions: could not install pyright (need python3 and pip or curl)." >&2
|
||||
exit 1
|
||||
"""
|
||||
_BUILTIN_BASH_PYRIGHT_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m pip uninstall -y pyright 2>/dev/null || true
|
||||
command -v pip3 >/dev/null 2>&1 && pip3 uninstall -y pyright 2>/dev/null || true
|
||||
command -v pip >/dev/null 2>&1 && pip uninstall -y pyright 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_RUFF_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
GET_PIP_URL=https://bootstrap.pypa.io/get-pip.py
|
||||
set -e
|
||||
if python3 -m pip install --user ruff; then exit 0; fi
|
||||
if command -v pip3 >/dev/null 2>&1 && pip3 install --user ruff; then exit 0; fi
|
||||
if command -v pip >/dev/null 2>&1 && pip install --user ruff; then exit 0; fi
|
||||
if python3 -m ensurepip --user --default-pip >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user ruff; then exit 0; fi
|
||||
if command -v curl >/dev/null 2>&1 && curl -fsSL "$GET_PIP_URL" \\
|
||||
| python3 - --user >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user ruff; then exit 0; fi
|
||||
echo "Sessions: could not install ruff (need python3 and pip or curl)." >&2
|
||||
exit 1
|
||||
"""
|
||||
_BUILTIN_BASH_RUFF_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m pip uninstall -y ruff 2>/dev/null || true
|
||||
command -v pip3 >/dev/null 2>&1 && pip3 uninstall -y ruff 2>/dev/null || true
|
||||
command -v pip >/dev/null 2>&1 && pip uninstall -y ruff 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_RUST_ANALYZER_INSTALL = """\
|
||||
export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
set -e
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
echo "Sessions: rustup not found; install Rust from https://rustup.rs" >&2
|
||||
exit 127
|
||||
fi
|
||||
rustup component add rust-src
|
||||
rustup component add rust-analyzer
|
||||
"""
|
||||
_BUILTIN_BASH_RUST_ANALYZER_REMOVE = """\
|
||||
export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
rustup component remove rust-analyzer 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedRemoteLspCatalogEntry:
|
||||
"""Metadata for one Sessions-managed remote LSP."""
|
||||
|
||||
install_catalog_id: str
|
||||
install_label: str
|
||||
install_argv: Tuple[str, ...]
|
||||
remove_argv: Tuple[str, ...]
|
||||
probe_argv: Tuple[str, ...]
|
||||
install_cwd: Optional[str]
|
||||
project_client_key: str
|
||||
legacy_project_client_keys: Tuple[str, ...]
|
||||
bridge_server_id: str
|
||||
remote_spawn_argv: Tuple[str, ...]
|
||||
sublime_selector: str
|
||||
|
||||
|
||||
BUILTIN_MANAGED_REMOTE_LSP_CATALOG: Tuple[ManagedRemoteLspCatalogEntry, ...] = (
|
||||
ManagedRemoteLspCatalogEntry(
|
||||
install_catalog_id="pyright-langserver",
|
||||
install_label="Pyright",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_PYRIGHT_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_PYRIGHT_REMOVE),
|
||||
probe_argv=("pyright", "--version"),
|
||||
install_cwd=None,
|
||||
project_client_key=SESSIONS_LSP_PYRIGHT_CLIENT_KEY,
|
||||
legacy_project_client_keys=("pyright",),
|
||||
bridge_server_id=SESSIONS_LSP_PYRIGHT_CLIENT_KEY,
|
||||
remote_spawn_argv=("pyright-langserver", "--stdio"),
|
||||
sublime_selector="source.python",
|
||||
),
|
||||
ManagedRemoteLspCatalogEntry(
|
||||
install_catalog_id="ruff",
|
||||
install_label="Ruff",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_RUFF_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_RUFF_REMOVE),
|
||||
probe_argv=("ruff", "--version"),
|
||||
install_cwd=None,
|
||||
project_client_key=SESSIONS_LSP_RUFF_CLIENT_KEY,
|
||||
legacy_project_client_keys=("ruff",),
|
||||
bridge_server_id=SESSIONS_LSP_RUFF_CLIENT_KEY,
|
||||
remote_spawn_argv=("ruff", "server"),
|
||||
sublime_selector="source.python",
|
||||
),
|
||||
ManagedRemoteLspCatalogEntry(
|
||||
install_catalog_id="rust-analyzer",
|
||||
install_label="rust-analyzer",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_RUST_ANALYZER_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_RUST_ANALYZER_REMOVE),
|
||||
probe_argv=("rust-analyzer", "--version"),
|
||||
install_cwd=None,
|
||||
project_client_key=SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY,
|
||||
legacy_project_client_keys=("LSP-rust-analyzer",),
|
||||
bridge_server_id=SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY,
|
||||
remote_spawn_argv=("rust-analyzer",),
|
||||
sublime_selector="source.rust",
|
||||
),
|
||||
)
|
||||
244
sublime/sessions/python_interpreter_browser.py
Normal file
244
sublime/sessions/python_interpreter_browser.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""Remote filesystem browser for the Python interpreter picker.
|
||||
|
||||
The selector command (``SessionsSelectPythonInterpreterCommand``) uses this
|
||||
module when the user picks ``Browse remote filesystem…`` instead of an
|
||||
auto-detected ``.venv`` candidate. The logic here is intentionally
|
||||
Sublime-free so it can be unit-tested with a stub ``exec_once`` callable.
|
||||
|
||||
The primary entry point :func:`list_remote_directory` probes one directory
|
||||
via ``ls -la`` and returns a :class:`DirectoryListing` with subdirectories
|
||||
and Python-executable candidates separated. The caller renders those into a
|
||||
quick panel, then re-invokes :func:`list_remote_directory` for the next
|
||||
level when the user selects a subdirectory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
# Name the quick-panel markers once so the command and tests agree on the
|
||||
# exact ASCII glyphs (we avoid emojis so the status text stays readable
|
||||
# across macOS ST4 themes).
|
||||
DIR_MARKER = "[dir]"
|
||||
PY_MARKER = "[py]"
|
||||
PARENT_MARKER = ".."
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserEntry:
|
||||
"""One entry rendered in the remote browser quick panel.
|
||||
|
||||
Attributes:
|
||||
name: Basename of the entry (directory or file).
|
||||
absolute_path: Full remote path the entry points at.
|
||||
is_dir: ``True`` when the entry is a directory (user can descend).
|
||||
is_python: ``True`` when the entry is an executable whose basename
|
||||
matches ``python``, ``python3``, or ``python3.<minor>``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
absolute_path: str
|
||||
is_dir: bool
|
||||
is_python: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectoryListing:
|
||||
"""Classified listing of one remote directory.
|
||||
|
||||
Attributes:
|
||||
path: The directory whose contents ``entries`` came from.
|
||||
parent: Parent directory path, or ``None`` when ``path`` is ``/``.
|
||||
entries: Classified rows in stable order (directories first, then
|
||||
Python candidates, both sorted alphabetically).
|
||||
error: Human-readable error text when the listing failed; ``None``
|
||||
on success. ``entries`` is empty when ``error`` is set.
|
||||
"""
|
||||
|
||||
path: str
|
||||
parent: Optional[str]
|
||||
entries: Tuple[BrowserEntry, ...]
|
||||
error: Optional[str]
|
||||
|
||||
|
||||
_PYTHON_NAME_RE = re.compile(r"^python(?:3(?:\.\d+)?)?$")
|
||||
# Busybox/GNU ls -la line format, roughly:
|
||||
# drwxr-xr-x 2 owner group 4096 Apr 23 10:00 name
|
||||
# We only care about the permission flags (for x-bit + directory) and the
|
||||
# trailing name; skipping the other columns keeps the parser locale-neutral.
|
||||
_LS_LINE_RE = re.compile(
|
||||
r"^(?P<perms>[\-bcdlpsw\-rwx.+@TtSsxX]{10,11})\s+"
|
||||
r"\d+\s+\S+\s+\S+\s+\d+\s+"
|
||||
r"\S+\s+\S+\s+\S+\s+"
|
||||
r"(?P<name>.+)$"
|
||||
)
|
||||
|
||||
|
||||
def _exec_once_default(
|
||||
host_alias: str,
|
||||
*,
|
||||
argv: Any,
|
||||
cwd: str,
|
||||
timeout_ms: int,
|
||||
) -> Any:
|
||||
"""Default ``exec_once`` shim that routes through the Rust bridge."""
|
||||
from .ssh_file_transport import execute_remote_exec_once
|
||||
|
||||
return execute_remote_exec_once(
|
||||
host_alias,
|
||||
argv=argv,
|
||||
cwd=cwd,
|
||||
timeout_ms=timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
def _parent_of(path: str) -> Optional[str]:
|
||||
"""Return the POSIX parent directory of ``path`` or ``None`` at ``/``."""
|
||||
if not path or path == "/":
|
||||
return None
|
||||
trimmed = path.rstrip("/")
|
||||
if not trimmed:
|
||||
return None
|
||||
idx = trimmed.rfind("/")
|
||||
if idx < 0:
|
||||
return None
|
||||
if idx == 0:
|
||||
return "/"
|
||||
return trimmed[:idx]
|
||||
|
||||
|
||||
def is_python_executable_name(name: str) -> bool:
|
||||
"""Return whether ``name`` looks like a Python interpreter basename."""
|
||||
return bool(_PYTHON_NAME_RE.match(name))
|
||||
|
||||
|
||||
def _classify_ls_line(line: str, directory: str) -> Optional[BrowserEntry]:
|
||||
"""Parse one ``ls -la`` row and return a :class:`BrowserEntry` or ``None``.
|
||||
|
||||
Symlinks are followed by splitting on ``" -> "`` and inspecting the
|
||||
permission field. Entries named ``.`` or ``..`` are skipped (the browser
|
||||
renders ``..`` explicitly only when there is a parent).
|
||||
"""
|
||||
match = _LS_LINE_RE.match(line.rstrip())
|
||||
if match is None:
|
||||
return None
|
||||
perms = match.group("perms")
|
||||
name = match.group("name")
|
||||
# Symlink rendering: "name -> target". Keep the name, use the perms
|
||||
# (which reflect what the kernel would let us exec) for classification.
|
||||
if " -> " in name:
|
||||
name = name.split(" -> ", 1)[0]
|
||||
if name in (".", ".."):
|
||||
return None
|
||||
is_dir = perms.startswith("d") or (
|
||||
perms.startswith("l") and _ls_looks_like_dir_symlink(line)
|
||||
)
|
||||
is_exec = len(perms) >= 10 and perms[3] == "x"
|
||||
absolute = directory.rstrip("/") + "/" + name if directory != "/" else "/" + name
|
||||
is_python = is_exec and not is_dir and is_python_executable_name(name)
|
||||
return BrowserEntry(
|
||||
name=name, absolute_path=absolute, is_dir=is_dir, is_python=is_python
|
||||
)
|
||||
|
||||
|
||||
def _ls_looks_like_dir_symlink(line: str) -> bool:
|
||||
"""Return ``True`` when the symlink target string ends with ``/``.
|
||||
|
||||
``ls -la`` never actually appends ``/`` to symlink targets (that's the
|
||||
``-F`` flag's job). We keep this helper as a seam so the regex logic
|
||||
stays testable if we later add ``-F`` — today it always returns
|
||||
``False`` so symlinks are surfaced under the file bucket, which is the
|
||||
safer default.
|
||||
"""
|
||||
_ = line
|
||||
return False
|
||||
|
||||
|
||||
def parse_ls_output(stdout: str, directory: str) -> Tuple[BrowserEntry, ...]:
|
||||
"""Parse the stdout of ``ls -la <directory>`` into :class:`BrowserEntry` rows.
|
||||
|
||||
The parser skips the leading ``total ...`` line, ``.`` / ``..`` rows, and
|
||||
any line that does not match the POSIX long-format layout. Directories
|
||||
come first (alphabetically), followed by Python executable candidates.
|
||||
"""
|
||||
dirs: List[BrowserEntry] = []
|
||||
pythons: List[BrowserEntry] = []
|
||||
for raw_line in stdout.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("total "):
|
||||
continue
|
||||
entry = _classify_ls_line(line, directory)
|
||||
if entry is None:
|
||||
continue
|
||||
if entry.is_dir:
|
||||
dirs.append(entry)
|
||||
elif entry.is_python:
|
||||
pythons.append(entry)
|
||||
dirs.sort(key=lambda e: e.name)
|
||||
pythons.sort(key=lambda e: e.name)
|
||||
return tuple(dirs) + tuple(pythons)
|
||||
|
||||
|
||||
def list_remote_directory(
|
||||
host_alias: str,
|
||||
directory: str,
|
||||
*,
|
||||
exec_once: Optional[Callable[..., Any]] = None,
|
||||
timeout_ms: int = 10_000,
|
||||
) -> DirectoryListing:
|
||||
"""Probe ``directory`` on the remote host and classify its contents.
|
||||
|
||||
Args:
|
||||
host_alias: SSH host alias the workspace is bound to.
|
||||
directory: Absolute remote path to list. Trailing slashes are
|
||||
tolerated.
|
||||
exec_once: Optional injection point for the SSH exec primitive; the
|
||||
default routes through ``ssh_file_transport``.
|
||||
timeout_ms: Per-probe budget (default 10 s).
|
||||
|
||||
Returns:
|
||||
A :class:`DirectoryListing`. On failure (non-zero exit, timeout,
|
||||
exception) ``entries`` is empty and ``error`` holds the reason so
|
||||
the caller can surface it without a traceback.
|
||||
"""
|
||||
run = exec_once or _exec_once_default
|
||||
path = directory.rstrip("/") or "/"
|
||||
parent = _parent_of(path)
|
||||
try:
|
||||
result = run(
|
||||
host_alias,
|
||||
argv=["ls", "-la", "--", path],
|
||||
cwd=path,
|
||||
timeout_ms=timeout_ms,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surface as row, not traceback.
|
||||
return DirectoryListing(
|
||||
path=path, parent=parent, entries=(), error="bridge error: {}".format(exc)
|
||||
)
|
||||
if getattr(result, "timed_out", False):
|
||||
return DirectoryListing(
|
||||
path=path, parent=parent, entries=(), error="listing timed out"
|
||||
)
|
||||
exit_code = getattr(result, "exit_code", 0)
|
||||
stdout = getattr(result, "stdout", "") or ""
|
||||
if exit_code != 0:
|
||||
stderr = (getattr(result, "stderr", "") or "").strip() or "exit {}".format(
|
||||
exit_code
|
||||
)
|
||||
return DirectoryListing(path=path, parent=parent, entries=(), error=stderr)
|
||||
entries = parse_ls_output(stdout, path)
|
||||
return DirectoryListing(path=path, parent=parent, entries=entries, error=None)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"DIR_MARKER",
|
||||
"PARENT_MARKER",
|
||||
"PY_MARKER",
|
||||
"BrowserEntry",
|
||||
"DirectoryListing",
|
||||
"is_python_executable_name",
|
||||
"list_remote_directory",
|
||||
"parse_ls_output",
|
||||
)
|
||||
455
sublime/sessions/python_interpreter_registry.py
Normal file
455
sublime/sessions/python_interpreter_registry.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""Active Python interpreter registry for a Sessions workspace.
|
||||
|
||||
Persists the user's chosen remote Python binary under
|
||||
``settings.sessions_active_python_interpreter`` in the Sublime project file and
|
||||
exposes helpers for probing ``<remote_root>/.venv/bin/python(3)`` via the
|
||||
``local_bridge`` ``exec/once`` entrypoint.
|
||||
|
||||
The module intentionally avoids top-level Sublime imports so the functions can
|
||||
be unit tested without a live plugin host; the Sublime-facing wiring lives in
|
||||
``sessions/commands.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
_ACTIVE_PYTHON_SETTINGS_KEY = "sessions_active_python_interpreter"
|
||||
|
||||
# Status-bar key written by the listener; exported so tests + callers don't
|
||||
# duplicate the literal.
|
||||
STATUS_KEY = "sessions_active_python"
|
||||
|
||||
# Selector matched by ``is_python_view`` — both pure-Python and Cython views
|
||||
# count for the purposes of showing the active interpreter slot.
|
||||
PYTHON_SELECTOR = "source.python, source.cython"
|
||||
|
||||
# Regex for ``Python X.Y[.Z…]`` — accepts trailing ``+``/``rc1``/whitespace
|
||||
# robustly because some distros tack a build label onto ``--version``.
|
||||
_VERSION_RE = re.compile(r"Python\s+(\d+\.\d+(?:\.\d+)?)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InterpreterCandidate:
|
||||
"""One discovered remote Python interpreter.
|
||||
|
||||
Attributes:
|
||||
remote_path: Absolute remote path to the Python binary.
|
||||
label: User-facing label shown in the quick panel.
|
||||
version: Raw ``Python X.Y.Z`` line reported by the binary, or ``None``.
|
||||
"""
|
||||
|
||||
remote_path: str
|
||||
label: str
|
||||
version: Optional[str]
|
||||
|
||||
|
||||
def _exec_once_default(
|
||||
host_alias: str,
|
||||
*,
|
||||
argv: Any,
|
||||
cwd: str,
|
||||
timeout_ms: int,
|
||||
) -> Any:
|
||||
"""Default ``exec_once`` shim that routes through the Rust bridge."""
|
||||
from .ssh_file_transport import execute_remote_exec_once
|
||||
|
||||
return execute_remote_exec_once(
|
||||
host_alias,
|
||||
argv=argv,
|
||||
cwd=cwd,
|
||||
timeout_ms=timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
def _probe_script(root: str, binary_name: str) -> str:
|
||||
"""Return a small shell snippet that probes one ``.venv`` binary.
|
||||
|
||||
The script echoes ``PATH=<abs>`` followed by the ``--version`` output on
|
||||
success; on failure it prints nothing and exits 0 so the caller can rely
|
||||
on ``stdout`` emptiness rather than exit codes (the bridge can map missing
|
||||
programs to exit 127 which we still want to treat as "absent", not
|
||||
"error").
|
||||
"""
|
||||
path = root.rstrip("/") + "/.venv/bin/" + binary_name
|
||||
# The inner redirection merges stderr so "Python 3.x.y" coming on either
|
||||
# stream is captured; ``|| true`` keeps the combined exit code at 0.
|
||||
return (
|
||||
"if [ -x '{path}' ]; then "
|
||||
"printf 'PATH=%s\\n' '{path}'; "
|
||||
"'{path}' --version 2>&1 || true; "
|
||||
"fi"
|
||||
).format(path=path)
|
||||
|
||||
|
||||
def detect_venv_interpreters(
|
||||
host_alias: str,
|
||||
remote_workspace_root: str,
|
||||
*,
|
||||
exec_once: Optional[Callable[..., Any]] = None,
|
||||
) -> List[InterpreterCandidate]:
|
||||
"""Probe ``<root>/.venv/bin/python(3)`` on the remote host.
|
||||
|
||||
Args:
|
||||
host_alias: SSH host alias (must already be connected).
|
||||
remote_workspace_root: Absolute remote path to the workspace root.
|
||||
exec_once: Injected replacement for
|
||||
:func:`ssh_file_transport.execute_remote_exec_once`; used by tests.
|
||||
|
||||
Returns:
|
||||
Candidates in a stable order (``python`` before ``python3``). Entries
|
||||
pointing at the same ``remote_path`` are deduplicated. A probe that
|
||||
raises, times out, or produces no ``PATH=`` line is silently skipped.
|
||||
"""
|
||||
run = exec_once or _exec_once_default
|
||||
seen: set[str] = set()
|
||||
out: List[InterpreterCandidate] = []
|
||||
for binary_name in ("python", "python3"):
|
||||
script = _probe_script(remote_workspace_root, binary_name)
|
||||
try:
|
||||
result = run(
|
||||
host_alias,
|
||||
argv=["bash", "-lc", script],
|
||||
cwd=remote_workspace_root,
|
||||
timeout_ms=10_000,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if getattr(result, "timed_out", False):
|
||||
continue
|
||||
stdout = getattr(result, "stdout", "") or ""
|
||||
candidate = _parse_probe_stdout(stdout, binary_name)
|
||||
if candidate is None:
|
||||
continue
|
||||
if candidate.remote_path in seen:
|
||||
continue
|
||||
seen.add(candidate.remote_path)
|
||||
out.append(candidate)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_probe_stdout(
|
||||
stdout: str, binary_name: str
|
||||
) -> Optional[InterpreterCandidate]:
|
||||
"""Parse the ``PATH=…\\nPython X.Y.Z`` stdout emitted by ``_probe_script``."""
|
||||
path: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
for raw_line in stdout.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("PATH=") and path is None:
|
||||
path = line[len("PATH=") :].strip() or None
|
||||
continue
|
||||
if line.startswith("Python ") and version is None:
|
||||
version = line
|
||||
if path is None:
|
||||
return None
|
||||
label = ".venv/bin/{}".format(binary_name)
|
||||
if version:
|
||||
label = "{} - {}".format(label, version)
|
||||
return InterpreterCandidate(remote_path=path, label=label, version=version)
|
||||
|
||||
|
||||
def _project_data(window: object) -> Optional[dict]:
|
||||
"""Return the window's ``project_data`` dict or ``None``."""
|
||||
project_data_fn = getattr(window, "project_data", None)
|
||||
if not callable(project_data_fn):
|
||||
return None
|
||||
try:
|
||||
data = project_data_fn()
|
||||
except Exception: # pragma: no cover - defensive: Sublime raised on closed window.
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def read_active_interpreter(window: object) -> Optional[str]:
|
||||
"""Return the remote interpreter path stored on ``window``.
|
||||
|
||||
Gracefully handles windows without a ``.sublime-project`` file or without a
|
||||
``project_data`` accessor (e.g. fakes in unit tests).
|
||||
"""
|
||||
data = _project_data(window)
|
||||
if data is None:
|
||||
return None
|
||||
settings = data.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
return None
|
||||
value = settings.get(_ACTIVE_PYTHON_SETTINGS_KEY)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def write_active_interpreter(window: object, remote_path: str) -> None:
|
||||
"""Persist ``remote_path`` under the active-interpreter project setting."""
|
||||
set_project_fn = getattr(window, "set_project_data", None)
|
||||
if not callable(set_project_fn):
|
||||
return
|
||||
data = _project_data(window) or {}
|
||||
merged = dict(data)
|
||||
settings_raw = merged.get("settings")
|
||||
settings = dict(settings_raw) if isinstance(settings_raw, dict) else {}
|
||||
settings[_ACTIVE_PYTHON_SETTINGS_KEY] = remote_path
|
||||
merged["settings"] = settings
|
||||
set_project_fn(merged)
|
||||
|
||||
|
||||
def clear_active_interpreter(window: object) -> None:
|
||||
"""Remove the active-interpreter project setting from ``window``.
|
||||
|
||||
Leaves the enclosing ``settings`` dict in place (even when empty) to keep
|
||||
``.sublime-project`` churn minimal.
|
||||
"""
|
||||
set_project_fn = getattr(window, "set_project_data", None)
|
||||
if not callable(set_project_fn):
|
||||
return
|
||||
data = _project_data(window)
|
||||
if data is None:
|
||||
return
|
||||
merged = dict(data)
|
||||
settings_raw = merged.get("settings")
|
||||
if not isinstance(settings_raw, dict):
|
||||
return
|
||||
settings = dict(settings_raw)
|
||||
if _ACTIVE_PYTHON_SETTINGS_KEY not in settings:
|
||||
return
|
||||
settings.pop(_ACTIVE_PYTHON_SETTINGS_KEY, None)
|
||||
merged["settings"] = settings
|
||||
set_project_fn(merged)
|
||||
|
||||
|
||||
def derive_venv_name(remote_path: str) -> Optional[str]:
|
||||
"""Return a human-friendly venv label for ``remote_path``.
|
||||
|
||||
Heuristics, in priority order, with examples:
|
||||
|
||||
* ``/path/to/MIN-T/.venv/bin/python`` → ``MIN-T``
|
||||
(parent of the ``.venv/bin/python(3)`` tail)
|
||||
* ``$HOME/.local/share/conda/envs/foo/bin/python`` → ``foo``
|
||||
(a conda-style ``envs/<name>/bin/python`` layout)
|
||||
* ``/opt/python311/bin/python3`` → ``python311``
|
||||
(anything else: parent of ``bin``)
|
||||
|
||||
Returns ``None`` only when ``remote_path`` is empty or has fewer than two
|
||||
components — there's no useful name we can pull out in that case.
|
||||
"""
|
||||
if not remote_path:
|
||||
return None
|
||||
parts = [p for p in remote_path.split("/") if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
# Case 1: <name>/.venv/bin/python(3)
|
||||
if (
|
||||
len(parts) >= 4
|
||||
and parts[-1].startswith("python")
|
||||
and parts[-2] == "bin"
|
||||
and parts[-3] == ".venv"
|
||||
):
|
||||
return parts[-4]
|
||||
# Case 2: .../envs/<name>/bin/python(3)
|
||||
if (
|
||||
len(parts) >= 4
|
||||
and parts[-1].startswith("python")
|
||||
and parts[-2] == "bin"
|
||||
and parts[-4] == "envs"
|
||||
):
|
||||
return parts[-3]
|
||||
# Case 3: fallback — parent of ``bin``.
|
||||
if len(parts) >= 3 and parts[-2] == "bin":
|
||||
return parts[-3]
|
||||
# No ``bin/`` separator at all: punt to the immediate parent directory.
|
||||
return parts[-2]
|
||||
|
||||
|
||||
def parse_version_output(output: str) -> Optional[str]:
|
||||
"""Extract ``X.Y.Z`` (or ``X.Y``) from ``python --version`` stdout/stderr."""
|
||||
if not output:
|
||||
return None
|
||||
match = _VERSION_RE.search(output)
|
||||
if match is None:
|
||||
return None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
# Cache: (host_alias, absolute_path) → version string. Probed lazily once per
|
||||
# selection; cleared via :func:`invalidate_version_cache`.
|
||||
_VERSION_CACHE: Dict[Tuple[str, str], str] = {}
|
||||
_VERSION_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_cached_version(host_alias: str, remote_path: str) -> Optional[str]:
|
||||
"""Return the cached version for ``(host_alias, remote_path)`` if any."""
|
||||
with _VERSION_CACHE_LOCK:
|
||||
return _VERSION_CACHE.get((host_alias, remote_path))
|
||||
|
||||
|
||||
def invalidate_version_cache(
|
||||
host_alias: Optional[str] = None,
|
||||
remote_path: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Drop entries from the version cache.
|
||||
|
||||
No-arg call wipes the entire cache. Passing both keys evicts a single entry;
|
||||
passing only ``host_alias`` evicts every entry for that host.
|
||||
"""
|
||||
with _VERSION_CACHE_LOCK:
|
||||
if host_alias is None and remote_path is None:
|
||||
_VERSION_CACHE.clear()
|
||||
return
|
||||
if host_alias is not None and remote_path is not None:
|
||||
_VERSION_CACHE.pop((host_alias, remote_path), None)
|
||||
return
|
||||
if host_alias is not None:
|
||||
for key in [k for k in _VERSION_CACHE if k[0] == host_alias]:
|
||||
_VERSION_CACHE.pop(key, None)
|
||||
|
||||
|
||||
def probe_interpreter_version(
|
||||
host_alias: str,
|
||||
remote_path: str,
|
||||
*,
|
||||
exec_once: Optional[Callable[..., Any]] = None,
|
||||
timeout_ms: int = 5_000,
|
||||
) -> Optional[str]:
|
||||
"""Run ``<remote_path> --version`` and cache the parsed version string.
|
||||
|
||||
Uses the same ``exec_once`` injection point as :func:`detect_venv_interpreters`
|
||||
so unit tests can substitute a fake. Returns the cached value when one is
|
||||
already present, so repeated activations don't re-probe the bridge.
|
||||
"""
|
||||
if not host_alias or not remote_path:
|
||||
return None
|
||||
cached = get_cached_version(host_alias, remote_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
run = exec_once or _exec_once_default
|
||||
try:
|
||||
result = run(
|
||||
host_alias,
|
||||
argv=[remote_path, "--version"],
|
||||
cwd="/",
|
||||
timeout_ms=timeout_ms,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — probe failure → no version, not a crash.
|
||||
return None
|
||||
if getattr(result, "timed_out", False):
|
||||
return None
|
||||
stdout = getattr(result, "stdout", "") or ""
|
||||
stderr = getattr(result, "stderr", "") or ""
|
||||
# Some Pythons (notably 2.x) print the version on stderr.
|
||||
version = parse_version_output(stdout) or parse_version_output(stderr)
|
||||
if version is None:
|
||||
return None
|
||||
with _VERSION_CACHE_LOCK:
|
||||
_VERSION_CACHE[(host_alias, remote_path)] = version
|
||||
return version
|
||||
|
||||
|
||||
def format_status_label(
|
||||
remote_path: Optional[str],
|
||||
version: Optional[str],
|
||||
) -> str:
|
||||
"""Return the status-bar string for the active interpreter.
|
||||
|
||||
* Both venv name and version known: ``Python: MIN-T (3.11.4)``.
|
||||
* Venv name known, version still probing: ``Python: MIN-T (…)``.
|
||||
* No interpreter selected: ``Python: (not set)``.
|
||||
"""
|
||||
if not remote_path:
|
||||
return "Python: (not set)"
|
||||
name = derive_venv_name(remote_path) or remote_path
|
||||
if version:
|
||||
return "Python: {} ({})".format(name, version)
|
||||
return "Python: {} (…)".format(name)
|
||||
|
||||
|
||||
def is_python_view(view: object) -> bool:
|
||||
"""Return ``True`` when ``view``'s syntax is a Python (or Cython) source.
|
||||
|
||||
Uses ``view.match_selector(0, ...)`` when available (real Sublime views).
|
||||
Falls back to ``view.scope_name(0)`` substring check, then to the file
|
||||
extension. Always returns ``False`` for objects that expose none of those —
|
||||
safer than painting the slot on an unknown surface.
|
||||
"""
|
||||
if view is None:
|
||||
return False
|
||||
match_selector = getattr(view, "match_selector", None)
|
||||
if callable(match_selector):
|
||||
try:
|
||||
if match_selector(0, PYTHON_SELECTOR):
|
||||
return True
|
||||
except Exception: # noqa: BLE001 — defensive against odd view types.
|
||||
pass
|
||||
scope_name = getattr(view, "scope_name", None)
|
||||
if callable(scope_name):
|
||||
try:
|
||||
scope = scope_name(0) or ""
|
||||
except Exception: # noqa: BLE001
|
||||
scope = ""
|
||||
if "source.python" in scope or "source.cython" in scope:
|
||||
return True
|
||||
file_name = getattr(view, "file_name", None)
|
||||
if callable(file_name):
|
||||
try:
|
||||
name = file_name() or ""
|
||||
except Exception: # noqa: BLE001
|
||||
name = ""
|
||||
lower = name.lower()
|
||||
if lower.endswith((".py", ".pyi", ".pyx", ".pxd")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def shorten_interpreter_path(path: str, *, limit: int = 40) -> str:
|
||||
"""Abbreviate ``path`` for status-bar display.
|
||||
|
||||
Keeps the last three path components (enough to disambiguate
|
||||
``proj/.venv/bin/python`` from a sibling venv) and truncates the middle
|
||||
with a single-character ellipsis (``…``) when the tail still exceeds
|
||||
``limit``.
|
||||
"""
|
||||
if not path:
|
||||
return path
|
||||
parts = [p for p in path.split("/") if p]
|
||||
tail = "/".join(parts[-3:]) if len(parts) >= 3 else path
|
||||
display = tail
|
||||
if len(display) <= limit:
|
||||
return display
|
||||
# Reserve one character for the ellipsis so the final length stays
|
||||
# within ``limit``.
|
||||
keep = max(1, (limit - 1) // 2)
|
||||
return display[:keep] + "…" + display[-(limit - 1 - keep) :]
|
||||
|
||||
|
||||
__all__ = (
|
||||
"_ACTIVE_PYTHON_SETTINGS_KEY",
|
||||
"InterpreterCandidate",
|
||||
"PYTHON_SELECTOR",
|
||||
"STATUS_KEY",
|
||||
"clear_active_interpreter",
|
||||
"derive_venv_name",
|
||||
"detect_venv_interpreters",
|
||||
"format_status_label",
|
||||
"get_cached_version",
|
||||
"invalidate_version_cache",
|
||||
"is_python_view",
|
||||
"parse_version_output",
|
||||
"probe_interpreter_version",
|
||||
"read_active_interpreter",
|
||||
"shorten_interpreter_path",
|
||||
"write_active_interpreter",
|
||||
)
|
||||
@@ -6,7 +6,11 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from .managed_remote_lsp_catalog import BUILTIN_MANAGED_REMOTE_LSP_CATALOG
|
||||
from .eager_hydrate import (
|
||||
DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
normalize_eager_hydrate_basenames,
|
||||
)
|
||||
from .managed_remote_extension_catalog import BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG
|
||||
|
||||
ALLOWED_REMOTE_PYTHON_TOOL_STEPS = frozenset({"ruff_lint", "pyright_check"})
|
||||
DEFAULT_REMOTE_PYTHON_TOOL_PIPELINE: Tuple[str, ...] = ("ruff_lint", "pyright_check")
|
||||
@@ -51,8 +55,8 @@ class CodeServerSpec:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteLspServerSpec:
|
||||
"""One remote LSP install/remove probe spec."""
|
||||
class RemoteExtensionSpec:
|
||||
"""One remote extension install/remove probe spec."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
@@ -111,11 +115,11 @@ def normalize_code_server_specs(raw: object) -> Tuple[CodeServerSpec, ...]:
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def normalize_remote_lsp_server_specs(raw: object) -> Tuple[RemoteLspServerSpec, ...]:
|
||||
"""Normalize user-provided remote LSP install/remove specs."""
|
||||
def normalize_remote_extension_specs(raw: object) -> Tuple[RemoteExtensionSpec, ...]:
|
||||
"""Normalize user-provided remote extension install/remove specs."""
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
out: List[RemoteLspServerSpec] = []
|
||||
out: List[RemoteExtensionSpec] = []
|
||||
seen: Set[str] = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
@@ -152,7 +156,7 @@ def normalize_remote_lsp_server_specs(raw: object) -> Tuple[RemoteLspServerSpec,
|
||||
cwd = cwd_raw.strip() if isinstance(cwd_raw, str) and cwd_raw.strip() else None
|
||||
seen.add(normalized_id)
|
||||
out.append(
|
||||
RemoteLspServerSpec(
|
||||
RemoteExtensionSpec(
|
||||
id=normalized_id,
|
||||
label=label,
|
||||
install_argv=install_argv,
|
||||
@@ -164,16 +168,16 @@ def normalize_remote_lsp_server_specs(raw: object) -> Tuple[RemoteLspServerSpec,
|
||||
return tuple(out)
|
||||
|
||||
|
||||
# Shipped install/remove/probe rows for ``sessions_install_remote_lsp_server`` / …
|
||||
# Built from :data:`managed_remote_lsp_catalog.BUILTIN_MANAGED_REMOTE_LSP_CATALOG`.
|
||||
# Merged with user ``sessions_remote_lsp_servers`` (user entries override by ``id``).
|
||||
# Shipped install/remove/probe rows for ``sessions_install_remote_extension`` / …
|
||||
# Built from the ``BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG`` catalog module.
|
||||
# Merged with user ``sessions_remote_extensions`` (user entries override by ``id``).
|
||||
# Install/remove use ``bash -lc`` so PATH matches an interactive SSH session; plain
|
||||
# argv from user settings are wrapped in ``sessions.commands._remote_lsp_exec_argv``.
|
||||
# argv from user settings are wrapped in ``commands._remote_extension_exec_argv``.
|
||||
|
||||
|
||||
def _default_builtin_remote_lsp_server_specs() -> Tuple[RemoteLspServerSpec, ...]:
|
||||
def _default_builtin_remote_extension_specs() -> Tuple[RemoteExtensionSpec, ...]:
|
||||
return tuple(
|
||||
RemoteLspServerSpec(
|
||||
RemoteExtensionSpec(
|
||||
id=entry.install_catalog_id,
|
||||
label=entry.install_label,
|
||||
install_argv=entry.install_argv,
|
||||
@@ -181,30 +185,30 @@ def _default_builtin_remote_lsp_server_specs() -> Tuple[RemoteLspServerSpec, ...
|
||||
probe_argv=entry.probe_argv,
|
||||
cwd=entry.install_cwd,
|
||||
)
|
||||
for entry in BUILTIN_MANAGED_REMOTE_LSP_CATALOG
|
||||
for entry in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_BUILTIN_REMOTE_LSP_SERVER_SPECS: Tuple[RemoteLspServerSpec, ...] = (
|
||||
_default_builtin_remote_lsp_server_specs()
|
||||
DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS: Tuple[RemoteExtensionSpec, ...] = (
|
||||
_default_builtin_remote_extension_specs()
|
||||
)
|
||||
|
||||
|
||||
def merge_remote_lsp_catalog(user_raw: object) -> Tuple[RemoteLspServerSpec, ...]:
|
||||
"""Return effective LSP install catalog: builtins plus user overrides and extras.
|
||||
def merge_remote_extension_catalog(user_raw: object) -> Tuple[RemoteExtensionSpec, ...]:
|
||||
"""Return effective extension install catalog: builtins + user overrides/extras.
|
||||
|
||||
When the user setting is missing, invalid, or normalizes to an empty list,
|
||||
builtins alone are used. User specs with the same ``id`` as a builtin replace
|
||||
that entry; additional user-only ids are appended in user order.
|
||||
"""
|
||||
user_specs = normalize_remote_lsp_server_specs(user_raw)
|
||||
by_id: Dict[str, RemoteLspServerSpec] = {
|
||||
spec.id: spec for spec in DEFAULT_BUILTIN_REMOTE_LSP_SERVER_SPECS
|
||||
user_specs = normalize_remote_extension_specs(user_raw)
|
||||
by_id: Dict[str, RemoteExtensionSpec] = {
|
||||
spec.id: spec for spec in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS
|
||||
}
|
||||
for spec in user_specs:
|
||||
by_id[spec.id] = spec
|
||||
ordered: List[RemoteLspServerSpec] = []
|
||||
builtin_ids = [spec.id for spec in DEFAULT_BUILTIN_REMOTE_LSP_SERVER_SPECS]
|
||||
ordered: List[RemoteExtensionSpec] = []
|
||||
builtin_ids = [spec.id for spec in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS]
|
||||
for sid in builtin_ids:
|
||||
if sid in by_id:
|
||||
ordered.append(by_id[sid])
|
||||
@@ -262,6 +266,19 @@ class SessionsSettings:
|
||||
cache buffer is activated (debounced in the listener).
|
||||
remote_python_tool_pipeline: Ordered step ids (see defaults).
|
||||
code_server_registry: Channel-managed code-server specs for transport v6.3.
|
||||
mirror_max_dir_fanout: Per-directory visible-child cap applied on auto
|
||||
mirror runs. Directories with more children are stubbed and recorded
|
||||
under ``workspace_state`` deferred-directory tracking. ``0`` = unlimited.
|
||||
mirror_writes_per_second_cap: Token-bucket refill rate for file
|
||||
placeholder writes. ``0`` = unlimited.
|
||||
mirror_auto_prune_stale_cache: When false, auto-sourced mirror runs
|
||||
force ``prune_missing=False`` to avoid the "many creates + many
|
||||
deletes" pattern EDR ransomware rules are tuned against.
|
||||
mirror_eager_hydrate_basenames: Filenames that should be proactively
|
||||
hydrated when a workspace first activates. See
|
||||
``eager_hydrate.DEFAULT_EAGER_HYDRATE_BASENAMES`` for the default
|
||||
allow-list (Cargo.toml, pyproject.toml, package.json, …). Set to
|
||||
an empty tuple to disable eager hydrate entirely.
|
||||
"""
|
||||
|
||||
ssh_config_path: Path = field(default_factory=default_ssh_config_path)
|
||||
@@ -272,7 +289,7 @@ class SessionsSettings:
|
||||
remote_python_auto_diagnostics_on_open: bool = False
|
||||
remote_python_tool_pipeline: Tuple[str, ...] = DEFAULT_REMOTE_PYTHON_TOOL_PIPELINE
|
||||
code_server_registry: Tuple[CodeServerSpec, ...] = ()
|
||||
remote_lsp_servers: Tuple[RemoteLspServerSpec, ...] = ()
|
||||
remote_extensions: Tuple[RemoteExtensionSpec, ...] = ()
|
||||
gitea_rust_helper_download_enabled: bool = True
|
||||
gitea_base_url: str = "https://git.teahaven.kr"
|
||||
gitea_package_owner: str = "sublime-rs"
|
||||
@@ -281,6 +298,10 @@ class SessionsSettings:
|
||||
gitea_rust_helper_revision_override: Optional[str] = None
|
||||
gitea_http_user_agent: Optional[str] = None
|
||||
gitea_package_username: Optional[str] = None
|
||||
mirror_max_dir_fanout: int = 100
|
||||
mirror_writes_per_second_cap: int = 40
|
||||
mirror_auto_prune_stale_cache: bool = False
|
||||
mirror_eager_hydrate_basenames: Tuple[str, ...] = DEFAULT_EAGER_HYDRATE_BASENAMES
|
||||
|
||||
def toolchain_override_for(self, language_name: str) -> Optional[ToolchainOverride]:
|
||||
"""Return the override for a language/toolchain if one exists.
|
||||
@@ -316,8 +337,8 @@ def load_sessions_settings_from_sublime() -> SessionsSettings:
|
||||
code_servers = normalize_code_server_specs(
|
||||
getter("sessions_remote_code_servers", None)
|
||||
)
|
||||
remote_lsp_servers = merge_remote_lsp_catalog(
|
||||
getter("sessions_remote_lsp_servers", None)
|
||||
remote_extensions = merge_remote_extension_catalog(
|
||||
getter("sessions_remote_extensions", None)
|
||||
)
|
||||
token_raw = getter("sessions_gitea_package_token", None)
|
||||
gitea_token = token_raw.strip() if isinstance(token_raw, str) else None
|
||||
@@ -352,7 +373,26 @@ def load_sessions_settings_from_sublime() -> SessionsSettings:
|
||||
gitea_basic_user = (
|
||||
user_raw.strip() if isinstance(user_raw, str) and user_raw.strip() else None
|
||||
)
|
||||
shared_cache_raw = getter("sessions_shared_cache_root", None)
|
||||
shared_cache_root: Optional[Path] = None
|
||||
if isinstance(shared_cache_raw, str) and shared_cache_raw.strip():
|
||||
shared_cache_root = Path(shared_cache_raw.strip()).expanduser()
|
||||
fanout_raw = getter("sessions_mirror_max_dir_fanout", 100)
|
||||
try:
|
||||
mirror_max_dir_fanout = max(0, int(fanout_raw))
|
||||
except (TypeError, ValueError):
|
||||
mirror_max_dir_fanout = 100
|
||||
wps_raw = getter("sessions_mirror_writes_per_second_cap", 40)
|
||||
try:
|
||||
mirror_writes_per_second_cap = max(0, int(wps_raw))
|
||||
except (TypeError, ValueError):
|
||||
mirror_writes_per_second_cap = 40
|
||||
mirror_auto_prune = bool(getter("sessions_mirror_auto_prune_stale_cache", False))
|
||||
eager_hydrate_basenames = normalize_eager_hydrate_basenames(
|
||||
getter("sessions_mirror_eager_hydrate_basenames", None)
|
||||
)
|
||||
return SessionsSettings(
|
||||
shared_cache_root=shared_cache_root,
|
||||
remote_python_auto_diagnostics_on_save=bool(
|
||||
getter("sessions_remote_python_auto_diagnostics_on_save", True)
|
||||
),
|
||||
@@ -361,7 +401,7 @@ def load_sessions_settings_from_sublime() -> SessionsSettings:
|
||||
),
|
||||
remote_python_tool_pipeline=pipeline,
|
||||
code_server_registry=code_servers,
|
||||
remote_lsp_servers=remote_lsp_servers,
|
||||
remote_extensions=remote_extensions,
|
||||
gitea_rust_helper_download_enabled=bool(
|
||||
getter("sessions_gitea_rust_helper_download_enabled", True)
|
||||
),
|
||||
@@ -372,6 +412,10 @@ def load_sessions_settings_from_sublime() -> SessionsSettings:
|
||||
gitea_rust_helper_revision_override=gitea_rev,
|
||||
gitea_http_user_agent=gitea_ua,
|
||||
gitea_package_username=gitea_basic_user,
|
||||
mirror_max_dir_fanout=mirror_max_dir_fanout,
|
||||
mirror_writes_per_second_cap=mirror_writes_per_second_cap,
|
||||
mirror_auto_prune_stale_cache=mirror_auto_prune,
|
||||
mirror_eager_hydrate_basenames=eager_hydrate_basenames,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import importlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -87,13 +88,28 @@ class RemoteCacheMirrorOptions:
|
||||
include_files: When false, only directories are created on disk.
|
||||
ignore_patterns: Path patterns to skip (no local dirs/files, no recursion).
|
||||
prune_missing: Remove local entries not present in the remote listing.
|
||||
max_dir_fanout: Refuse to descend into a directory whose visible-child
|
||||
count exceeds this cap; the parent stub is still created and the
|
||||
remote path is recorded under ``deferred_directories`` for explicit
|
||||
user expansion. ``0`` disables the cap.
|
||||
writes_per_second_cap: Token-bucket refill rate for file-placeholder
|
||||
writes (ops/second). ``0`` disables rate limiting.
|
||||
consecutive_failure_budget: Stop the BFS cleanly after this many
|
||||
consecutive failing writes (any success resets the counter).
|
||||
``0`` disables the circuit breaker.
|
||||
"""
|
||||
|
||||
# v0.4.21 tightened the default entry cap from 5000 to 1000 to bound the
|
||||
# workspace-open file-creation burst. Python defaults must match the Rust
|
||||
# ``RemoteCacheMirrorOptions::default`` values.
|
||||
max_traversal_depth: int = 12
|
||||
max_entries: int = 5000
|
||||
max_entries: int = 1000
|
||||
include_files: bool = True
|
||||
ignore_patterns: Tuple[str, ...] = ()
|
||||
prune_missing: bool = True
|
||||
max_dir_fanout: int = 100
|
||||
writes_per_second_cap: int = 40
|
||||
consecutive_failure_budget: int = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -106,6 +122,8 @@ class RemoteCacheMirrorResult:
|
||||
truncated_by_entry_limit: bool = False
|
||||
entries_pruned: int = 0
|
||||
error_detail: Optional[str] = None
|
||||
deferred_directories: Tuple[str, ...] = ()
|
||||
aborted_by_failure_budget: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -157,10 +175,6 @@ except ImportError: # pragma: no cover
|
||||
_MAX_READ_BYTES = FileOpenGuardrails().max_open_bytes
|
||||
_RUST_BRIDGE_REQUEST_TIMEOUT_S = 45.0
|
||||
_SSH_TREE_LIST_TIMEOUT_S = 8.0
|
||||
# Must match ``[workspace.package] version`` in ``rust/Cargo.toml`` and
|
||||
# ``local_bridge::default_remote_helper_path`` (cache dir under
|
||||
# ``$HOME/.cache/sessions/helpers/<ver>/``).
|
||||
_REMOTE_SESSION_HELPER_CACHE_VERSION = "0.4.18"
|
||||
_RUST_BRIDGE_CACHE: object = None
|
||||
_RUST_BRIDGE_UNAVAILABLE = object()
|
||||
_RUST_BRIDGE_UNAVAILABLE_DETAIL: Optional[str] = None
|
||||
@@ -516,16 +530,34 @@ def _ensure_session_helper_in_editor_cache(
|
||||
|
||||
|
||||
def _remote_session_helper_push_check_script(revision: str) -> str:
|
||||
ver = _REMOTE_SESSION_HELPER_CACHE_VERSION
|
||||
# ``local_bridge`` (Rust) scopes the remote helper cache dir by the full
|
||||
# release revision — ``$HOME/.cache/sessions/helpers/<revision>/``. The
|
||||
# Python push check + writer must use the same path or the bridge boots
|
||||
# find no binary and fail handshake with "missing or revision mismatch".
|
||||
_validate_revision_path_segment(revision)
|
||||
rev_cmp = shlex.quote(revision)
|
||||
return (
|
||||
"set -e; "
|
||||
'dir="$HOME/.cache/sessions/helpers/{ver}"; '
|
||||
'dir="$HOME/.cache/sessions/helpers/{rev}"; '
|
||||
'stored=$(cat "$dir/.revision" 2>/dev/null || true); '
|
||||
'if [ "$stored" = {rev} ] && [ -x "$dir/session_helper" ]; then '
|
||||
'if [ "$stored" = {rev_cmp} ] && [ -x "$dir/session_helper" ]; then '
|
||||
"exit 0; "
|
||||
"else exit 1; fi"
|
||||
).format(ver=ver, rev=rev_cmp)
|
||||
).format(rev=revision, rev_cmp=rev_cmp)
|
||||
|
||||
|
||||
# Only allow semver-ish revisions in path segments; anything else comes from
|
||||
# config injection and is refused rather than shell-escaped ad hoc.
|
||||
_REVISION_PATH_RE = re.compile(r"\A[A-Za-z0-9._+-]+\Z")
|
||||
|
||||
|
||||
def _validate_revision_path_segment(revision: str) -> None:
|
||||
if not _REVISION_PATH_RE.match(revision):
|
||||
raise SessionHelperStartError(
|
||||
"session_helper revision contains disallowed characters: {!r}".format(
|
||||
revision
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _needs_remote_session_helper_push(
|
||||
@@ -578,17 +610,19 @@ def _push_session_helper_via_ssh(
|
||||
from .ssh_runner import _local_ssh_argv
|
||||
|
||||
rev_b64 = base64.b64encode(revision.encode("utf-8")).decode("ascii")
|
||||
ver = _REMOTE_SESSION_HELPER_CACHE_VERSION
|
||||
# Path segment is the release revision — aligned with ``local_bridge``'s
|
||||
# bootstrap probe in ``ensure_remote_helper`` so push + probe agree.
|
||||
_validate_revision_path_segment(revision)
|
||||
script = (
|
||||
"set -e; umask 077; "
|
||||
'dir="$HOME/.cache/sessions/helpers/{ver}"; '
|
||||
'dir="$HOME/.cache/sessions/helpers/{rev}"; '
|
||||
'mkdir -p "$dir"; '
|
||||
'tmp="$dir/session_helper.part.$$"; '
|
||||
'cat > "$tmp"; '
|
||||
'chmod 700 "$tmp"; '
|
||||
'mv -f "$tmp" "$dir/session_helper"; '
|
||||
"printf '%s' '{rev_b64}' | base64 -d > \"$dir/.revision\""
|
||||
).format(ver=ver, rev_b64=rev_b64)
|
||||
).format(rev=revision, rev_b64=rev_b64)
|
||||
local_argv = _local_ssh_argv(
|
||||
host_alias,
|
||||
["bash", "-lc", script],
|
||||
@@ -1279,7 +1313,10 @@ def _bridge_diagnostic_hypothesis_catalog() -> list[dict[str, str]]:
|
||||
{
|
||||
"id": "H6_remote_download",
|
||||
"rust_events": "bridge.rust.ensure_remote_helper_*",
|
||||
"meaning": "Remote helper download via curl/wget; revision cache check.",
|
||||
"meaning": (
|
||||
"Editor-cache download + SSH push of session_helper; "
|
||||
"revision cache check on the remote (no curl/wget runs there)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "H7_python_rust_id",
|
||||
@@ -1448,6 +1485,9 @@ def execute_remote_cache_mirror(
|
||||
"include_files": options.include_files,
|
||||
"ignore_patterns": list(options.ignore_patterns),
|
||||
"prune_missing": options.prune_missing,
|
||||
"max_dir_fanout": options.max_dir_fanout,
|
||||
"writes_per_second_cap": options.writes_per_second_cap,
|
||||
"consecutive_failure_budget": options.consecutive_failure_budget,
|
||||
}
|
||||
payload = {
|
||||
"id": _next_envelope_id("mirror-sync"),
|
||||
@@ -1481,6 +1521,11 @@ def execute_remote_cache_mirror(
|
||||
return RemoteCacheMirrorResult(
|
||||
error_detail="Rust bridge mirror-sync returned an unexpected payload."
|
||||
)
|
||||
raw_deferred = result_payload.get("deferred_directories", ())
|
||||
if isinstance(raw_deferred, (list, tuple)):
|
||||
deferred_directories = tuple(str(item) for item in raw_deferred if item)
|
||||
else:
|
||||
deferred_directories = ()
|
||||
return RemoteCacheMirrorResult(
|
||||
directories_created=int(result_payload.get("directories_created", 0)),
|
||||
file_placeholders_created=int(
|
||||
@@ -1492,6 +1537,10 @@ def execute_remote_cache_mirror(
|
||||
),
|
||||
entries_pruned=int(result_payload.get("entries_pruned", 0)),
|
||||
error_detail=result_payload.get("error_detail"),
|
||||
deferred_directories=deferred_directories,
|
||||
aborted_by_failure_budget=bool(
|
||||
result_payload.get("aborted_by_failure_budget", False)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
"""Cmd/Ctrl+click on URLs and absolute remote paths in Terminus buffers.
|
||||
"""VSCode-style hover-activated links in Sessions-spawned Terminus buffers.
|
||||
|
||||
When the user Cmd+clicks a token in a Sessions-spawned Terminus terminal:
|
||||
When the user hovers the mouse over a token in a Terminus terminal
|
||||
rendered by Sessions we:
|
||||
|
||||
- URL (``https://…``, ``http://…``, ``ftp://…``, ``file://…``) opens in the
|
||||
user's default browser via :mod:`webbrowser`. ``http://localhost:…`` forms
|
||||
covered for free — they're still URLs; Jupyter-style local-tunnel pages
|
||||
(see ``planning/JUPYTER_HOSTING_PLAN.md``) land there too.
|
||||
- classify the token under the cursor via :func:`classify_terminal_token`
|
||||
+ :func:`extract_token_at` (both pure and load-bearing; do not touch);
|
||||
- paint a ``"markup.underline.link"`` region under the token so the user
|
||||
can *see* the link before clicking (VSCode / modern-editor idiom);
|
||||
- on the next hover that moves off the token, erase the region.
|
||||
|
||||
- Absolute remote path (``/srv/app/pkg/a.py``) routes through the existing
|
||||
``SessionsOnDemandFetchListener`` via ``window.run_command("open_file",
|
||||
…)`` — that listener maps it to a workspace-internal cache file or an
|
||||
``__extern/`` cache entry, fetches if missing, then opens. No new
|
||||
transport path needed.
|
||||
A Cmd+click (macOS) / Ctrl+click (Win/Linux) on an active region fires
|
||||
the matching handler:
|
||||
|
||||
The ``drag_select`` text-command hook runs for *every* mouse click in a
|
||||
view; we filter on (a) Terminus-view settings marker, (b) ``primary``
|
||||
modifier held. False positives are silent — we return ``None`` so Sublime
|
||||
does its normal text-selection handling.
|
||||
- URL (``https://…``, ``http://…``, ``ftp://…``, ``file://…``) opens in
|
||||
the user's default browser via :mod:`webbrowser`.
|
||||
- Absolute remote path (``/srv/app/pkg/a.py``) routes through the
|
||||
existing ``SessionsOnDemandFetchListener`` via
|
||||
``window.run_command("open_file", …)`` — that listener maps the path
|
||||
onto a workspace cache entry, fetches if missing, then opens.
|
||||
|
||||
Line:col suffix (grep -n style ``/path/to/file.py:42:7``) is not yet
|
||||
wired. The listener sits on top of ``run_command("open_file", {…})``
|
||||
whose argument parser doesn't thread encoded positions through the
|
||||
fetch-then-open async flow — needs a small patch to
|
||||
``SessionsOnDemandFetchListener`` which we'll do in a follow-up turn.
|
||||
For now the file opens at position 0; the user still sees the file.
|
||||
The v0.4.18 design filtered *all* ``drag_select`` clicks by modifier
|
||||
but gave the user no visible affordance for which tokens were
|
||||
clickable. The hover-activation UX solves that: the cursor reveals
|
||||
links the same way VSCode does, and the existing click intercept
|
||||
short-circuits to the active region if hover already classified the
|
||||
token (so we never pay for two classifications on one click). The
|
||||
intercept path still works without hover (falls back to on-click
|
||||
classification) for environments where ``on_hover`` doesn't fire.
|
||||
|
||||
Hover state is per-view and lives in a module-level dict keyed by
|
||||
``view.id()``. ``on_close`` clears the entry so the dict doesn't grow
|
||||
unbounded across a long Sublime session. We never hold a reference to
|
||||
the ``view`` object itself — only the int id — to avoid retaining
|
||||
closed views.
|
||||
|
||||
Line:col suffix (grep -n style ``/path/to/file.py:42:7``) is still
|
||||
discarded by ``classify_terminal_token`` for now; the file opens at
|
||||
position 0 once the fetch-then-open listener threads encoded positions
|
||||
through the async flow (separate follow-up).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import webbrowser
|
||||
from typing import Any, Mapping, Optional, Tuple
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
@@ -49,6 +63,19 @@ _URL_PATTERN = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Scheme-less ``host:port[/path]`` shape that is conventionally addressable
|
||||
# in a browser as ``http://host:port/path``. Matches the localhost dev-server
|
||||
# case (``localhost:8080``, ``127.0.0.1:5173``) and explicit IPv4 + port that
|
||||
# Jupyter / FastAPI / Vite etc. log to the terminal. We deliberately exclude
|
||||
# IPv6, hostnames with dots (those belong in the scheme'd ``http(s)://``
|
||||
# form), and bare ``host`` with no port (too noisy — ``var:42`` would match).
|
||||
_HOST_PORT_PATTERN = re.compile(
|
||||
r"^(?P<host>localhost|127\.0\.0\.1|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
|
||||
r":(?P<port>\d{1,5})"
|
||||
r"(?P<rest>/[^\s<>\"'`\\]*)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Absolute POSIX path with optional ``:line`` and ``:line:col`` tail
|
||||
# (grep -n, compiler error, Python traceback formats all match).
|
||||
_ABSPATH_WITH_POS_PATTERN = re.compile(
|
||||
@@ -57,6 +84,18 @@ _ABSPATH_WITH_POS_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# Region key under which we paint the active hover-link underline. Sublime's
|
||||
# ``add_regions`` silently replaces an existing region that shares the same
|
||||
# key, so a single key per view is all we need — each new hover overwrites
|
||||
# the prior one.
|
||||
_HOVER_REGION_KEY = "sessions_terminal_link"
|
||||
|
||||
# Scope selected for the link underline. Sublime resolves this to the
|
||||
# ``link`` colour from the user's colour scheme, mirroring how builtin
|
||||
# Markdown / docstring links are styled.
|
||||
_HOVER_REGION_SCOPE = "markup.underline.link"
|
||||
|
||||
|
||||
def classify_terminal_token(token: str) -> Optional[Tuple[str, str]]:
|
||||
"""Return ``("url", token)`` / ``("abspath", remote_path)`` or ``None``.
|
||||
|
||||
@@ -78,6 +117,38 @@ def classify_terminal_token(token: str) -> Optional[Tuple[str, str]]:
|
||||
return None
|
||||
if _URL_PATTERN.match(token):
|
||||
return ("url", token)
|
||||
# Scheme-less ``localhost:8080`` / ``127.0.0.1:5173/foo`` get auto-
|
||||
# promoted to ``http://...`` so the browser can resolve them. We do
|
||||
# this *before* the absolute-path test because ``/foo`` paths only
|
||||
# start with ``/`` and never have a host:port shape.
|
||||
host_port = _HOST_PORT_PATTERN.match(token)
|
||||
if host_port:
|
||||
port_str = host_port.group("port")
|
||||
try:
|
||||
port_value = int(port_str)
|
||||
except ValueError:
|
||||
port_value = -1
|
||||
if 0 < port_value <= 65535:
|
||||
host = host_port.group("host")
|
||||
rest = host_port.group("rest") or ""
|
||||
# ``0.0.0.0`` is the wildcard bind address servers print to
|
||||
# signal "listening on every interface" — macOS browsers
|
||||
# refuse to route to it (Safari/Chrome land on
|
||||
# ``about:blank`` with a stray suffix). Canonicalize to
|
||||
# ``localhost`` so the click reaches the loopback listener
|
||||
# the user actually wants. ``127.0.0.1`` already resolves on
|
||||
# every platform so we leave it alone.
|
||||
if host == "0.0.0.0":
|
||||
host = "localhost"
|
||||
# macOS ``open location`` (driving Safari/Chrome through
|
||||
# AppleScript) treats a host:port URL with no path as
|
||||
# under-specified and falls back to ``about:blank`` plus a
|
||||
# leftover token. Always emit a canonical trailing slash
|
||||
# when no path was present so every platform sees a
|
||||
# well-formed ``http://host:port/`` URL.
|
||||
if not rest:
|
||||
rest = "/"
|
||||
return ("url", "http://" + host + ":" + port_str + rest)
|
||||
match = _ABSPATH_WITH_POS_PATTERN.match(token)
|
||||
if match:
|
||||
path = match.group("path")
|
||||
@@ -122,6 +193,45 @@ def extract_token_at(view: object, point: int) -> Optional[str]:
|
||||
return token or None
|
||||
|
||||
|
||||
def _token_span_at(view: object, point: int) -> Optional[Tuple[int, int, str]]:
|
||||
"""Return ``(start, end, token)`` for the token under ``point``.
|
||||
|
||||
Parallel to :func:`extract_token_at` but also returns the absolute
|
||||
character offsets so the caller can paint a region over the exact
|
||||
span. Returns ``None`` when ``point`` lies between two spaces or the
|
||||
view lacks the required API.
|
||||
"""
|
||||
line_fn = getattr(view, "line", None)
|
||||
substr_fn = getattr(view, "substr", None)
|
||||
if not callable(line_fn) or not callable(substr_fn):
|
||||
return None
|
||||
line_region = line_fn(point)
|
||||
try:
|
||||
line_start = line_region.begin()
|
||||
except AttributeError:
|
||||
return None
|
||||
line_text = substr_fn(line_region)
|
||||
if not isinstance(line_text, str):
|
||||
return None
|
||||
rel = point - line_start
|
||||
if rel < 0:
|
||||
rel = 0
|
||||
if rel > len(line_text):
|
||||
rel = len(line_text)
|
||||
left = rel
|
||||
while left > 0 and not line_text[left - 1].isspace():
|
||||
left -= 1
|
||||
right = rel
|
||||
while right < len(line_text) and not line_text[right].isspace():
|
||||
right += 1
|
||||
if left == right:
|
||||
return None
|
||||
token = line_text[left:right]
|
||||
if not token:
|
||||
return None
|
||||
return (line_start + left, line_start + right, token)
|
||||
|
||||
|
||||
def _is_terminus_view(view: object) -> bool:
|
||||
"""Return True if ``view`` is a Terminus terminal buffer."""
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
@@ -177,27 +287,201 @@ def _handle_abspath(window: object, remote_path: str) -> None:
|
||||
run_command("open_file", {"file": remote_path})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hover state cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ``view.id()`` → ``(start, end, kind, value)``. ``kind`` is one of
|
||||
# ``"url"`` / ``"abspath"`` — mirrors the tuple shape of
|
||||
# ``classify_terminal_token``. Stored as a plain dict rather than a
|
||||
# ``WeakValueDictionary`` because the values are primitives, not objects
|
||||
# with identity; ``on_close`` is the drop hook.
|
||||
_HOVER_STATE: Dict[int, Tuple[int, int, str, str]] = {}
|
||||
|
||||
|
||||
def _clear_hover_region(view: object) -> None:
|
||||
"""Erase the hover-link region painted on ``view``."""
|
||||
erase = getattr(view, "erase_regions", None)
|
||||
if callable(erase):
|
||||
try:
|
||||
erase(_HOVER_REGION_KEY)
|
||||
except Exception: # pragma: no cover - defensive; Sublime raises rarely
|
||||
pass
|
||||
|
||||
|
||||
def _paint_hover_region(view: object, start: int, end: int) -> None:
|
||||
"""Paint the hover-link underline region on ``view``.
|
||||
|
||||
Uses ``DRAW_NO_FILL | DRAW_SOLID_UNDERLINE`` so the token stays
|
||||
readable; the colour comes from the ``link`` scope resolved against
|
||||
the active colour scheme.
|
||||
"""
|
||||
add_regions = getattr(view, "add_regions", None)
|
||||
if not callable(add_regions):
|
||||
return
|
||||
flags = 0
|
||||
if sublime is not None:
|
||||
draw_no_fill = getattr(sublime, "DRAW_NO_FILL", 0)
|
||||
draw_underline = getattr(sublime, "DRAW_SOLID_UNDERLINE", 0)
|
||||
flags = int(draw_no_fill) | int(draw_underline)
|
||||
# Construct a ``sublime.Region`` when available, otherwise fall back
|
||||
# to a plain tuple for unit tests — the FakeView in ``conftest``
|
||||
# accepts any iterable of regions.
|
||||
if sublime is not None:
|
||||
region_ctor = getattr(sublime, "Region", None)
|
||||
if callable(region_ctor):
|
||||
region = region_ctor(start, end)
|
||||
else:
|
||||
region = (start, end)
|
||||
else:
|
||||
region = (start, end)
|
||||
try:
|
||||
add_regions(
|
||||
_HOVER_REGION_KEY,
|
||||
[region],
|
||||
_HOVER_REGION_SCOPE,
|
||||
"",
|
||||
flags,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive; Sublime raises rarely
|
||||
pass
|
||||
|
||||
|
||||
def _view_id(view: object) -> Optional[int]:
|
||||
id_fn = getattr(view, "id", None)
|
||||
if not callable(id_fn):
|
||||
return None
|
||||
try:
|
||||
value = id_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _record_hover(
|
||||
view: object,
|
||||
start: int,
|
||||
end: int,
|
||||
kind: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Persist the active hover link span so click can re-use it."""
|
||||
vid = _view_id(view)
|
||||
if vid is None:
|
||||
return
|
||||
_HOVER_STATE[vid] = (start, end, kind, value)
|
||||
|
||||
|
||||
def _active_hover_for_point(
|
||||
view: object,
|
||||
point: int,
|
||||
) -> Optional[Tuple[int, int, str, str]]:
|
||||
"""Return the recorded hover tuple if ``point`` falls inside its span."""
|
||||
vid = _view_id(view)
|
||||
if vid is None:
|
||||
return None
|
||||
entry = _HOVER_STATE.get(vid)
|
||||
if entry is None:
|
||||
return None
|
||||
start, end, _kind, _value = entry
|
||||
if start <= point < end:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _drop_hover(view: object) -> None:
|
||||
"""Drop the per-view hover state + erase any painted region."""
|
||||
vid = _view_id(view)
|
||||
if vid is not None:
|
||||
_HOVER_STATE.pop(vid, None)
|
||||
_clear_hover_region(view)
|
||||
|
||||
|
||||
def process_hover(
|
||||
view: object,
|
||||
point: int,
|
||||
hover_zone: int,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Handle a single hover event; paint / erase regions as needed.
|
||||
|
||||
Returns the ``(kind, value)`` tuple when a link was activated, else
|
||||
``None``. Tests exercise this directly to avoid instantiating the
|
||||
full ``EventListener`` under the Sublime stub.
|
||||
"""
|
||||
if sublime is not None:
|
||||
hover_text = getattr(sublime, "HOVER_TEXT", 1)
|
||||
else:
|
||||
hover_text = 1
|
||||
if hover_zone != hover_text:
|
||||
_drop_hover(view)
|
||||
return None
|
||||
if not _is_terminus_view(view):
|
||||
_drop_hover(view)
|
||||
return None
|
||||
span = _token_span_at(view, point)
|
||||
if span is None:
|
||||
_drop_hover(view)
|
||||
return None
|
||||
start, end, token = span
|
||||
classified = classify_terminal_token(token)
|
||||
if classified is None:
|
||||
_drop_hover(view)
|
||||
return None
|
||||
kind, value = classified
|
||||
_paint_hover_region(view, start, end)
|
||||
_record_hover(view, start, end, kind, value)
|
||||
return (kind, value)
|
||||
|
||||
|
||||
_EventListenerBase = (
|
||||
sublime_plugin.EventListener if sublime_plugin is not None else object
|
||||
)
|
||||
|
||||
|
||||
class SessionsTerminalLinkClickListener(_EventListenerBase): # type: ignore[misc]
|
||||
"""Intercept primary-modifier clicks in Terminus views to open links.
|
||||
"""Underline links on hover + dispatch Cmd-clicks in Terminus views.
|
||||
|
||||
Sublime wires this in via ``plugin.py`` at load time; unit tests
|
||||
exercise ``classify_terminal_token`` / ``extract_token_at`` directly
|
||||
without needing Sublime's API, so we keep the base class a plain
|
||||
``object`` stub when ``sublime_plugin`` isn't importable.
|
||||
Sublime wires this in via ``plugin.py`` at load time. Unit tests
|
||||
exercise :func:`classify_terminal_token`, :func:`extract_token_at`,
|
||||
and :func:`process_hover` directly without needing Sublime's API,
|
||||
so we keep the base class a plain ``object`` stub when
|
||||
``sublime_plugin`` isn't importable.
|
||||
"""
|
||||
|
||||
def on_hover(
|
||||
self,
|
||||
view: object,
|
||||
point: int,
|
||||
hover_zone: int,
|
||||
) -> None:
|
||||
"""Activate / deactivate the underline on mouse hover."""
|
||||
process_hover(view, point, hover_zone)
|
||||
|
||||
def on_close(self, view: object) -> None:
|
||||
"""Drop per-view hover state when the Terminus pane closes."""
|
||||
_drop_hover(view)
|
||||
|
||||
def on_text_command(
|
||||
self,
|
||||
view: object,
|
||||
command_name: str,
|
||||
args: Optional[Mapping[str, Any]],
|
||||
) -> None:
|
||||
"""Route primary-modifier ``drag_select`` clicks to URL/path handlers."""
|
||||
) -> Optional[Tuple[str, Mapping[str, Any]]]:
|
||||
"""Route primary-modifier ``drag_select`` clicks to URL/path handlers.
|
||||
|
||||
Returning ``("noop", {})`` when we successfully dispatch the link
|
||||
suppresses the underlying ``drag_select`` so Sublime / Terminus
|
||||
don't *also* move the caret + forward a raw mouse-click into the
|
||||
terminal. Without this suppression the v0.5.x click regression
|
||||
manifests: hover paints the box, but Cmd+click runs ``drag_select``
|
||||
first, which mutates selection / cursor in the Terminus pane and
|
||||
ends up swallowing the open. Returning ``None`` everywhere else
|
||||
keeps normal text selection working when no link is under the
|
||||
cursor.
|
||||
"""
|
||||
if command_name != "drag_select":
|
||||
return None
|
||||
if not _is_terminus_view(view):
|
||||
@@ -212,21 +496,28 @@ class SessionsTerminalLinkClickListener(_EventListenerBase): # type: ignore[mis
|
||||
point = _point_from_event(view, event)
|
||||
if point is None:
|
||||
return None
|
||||
token = extract_token_at(view, point)
|
||||
if token is None:
|
||||
return None
|
||||
classified = classify_terminal_token(token)
|
||||
if classified is None:
|
||||
return None
|
||||
kind, value = classified
|
||||
# Fast path: hover already classified the token under the
|
||||
# cursor; re-use that decision rather than re-running the token
|
||||
# extractor + classifier on every click.
|
||||
active = _active_hover_for_point(view, point)
|
||||
if active is not None:
|
||||
_start, _end, kind, value = active
|
||||
else:
|
||||
token = extract_token_at(view, point)
|
||||
if token is None:
|
||||
return None
|
||||
classified = classify_terminal_token(token)
|
||||
if classified is None:
|
||||
return None
|
||||
kind, value = classified
|
||||
window_fn = getattr(view, "window", None)
|
||||
window = window_fn() if callable(window_fn) else None
|
||||
if kind == "url":
|
||||
_handle_url(value)
|
||||
return None
|
||||
return ("noop", {})
|
||||
if kind == "abspath" and window is not None:
|
||||
_handle_abspath(window, value)
|
||||
return None
|
||||
return ("noop", {})
|
||||
return None
|
||||
|
||||
|
||||
@@ -234,4 +525,5 @@ __all__ = (
|
||||
"SessionsTerminalLinkClickListener",
|
||||
"classify_terminal_token",
|
||||
"extract_token_at",
|
||||
"process_hover",
|
||||
)
|
||||
|
||||
414
sublime/sessions/terminal_tmux_session.py
Normal file
414
sublime/sessions/terminal_tmux_session.py
Normal file
@@ -0,0 +1,414 @@
|
||||
"""Tmux-session helpers for ``Sessions: Open Remote Terminal`` (Track C2).
|
||||
|
||||
The remote-terminal command wraps its SSH invocation in
|
||||
``tmux new-session -A -s <name>`` so that closing and re-opening the
|
||||
Terminus tab reattaches to the same shell (history + running processes
|
||||
preserved). This module owns:
|
||||
|
||||
- **Session-name construction** — canonicalises a ``host_alias`` into
|
||||
``sessions-term-<sanitized-alias>``. The name must be safe to embed in
|
||||
a shell command built by the command entry-point; we validate against
|
||||
a tight charset and reject aliases that would require escaping.
|
||||
- **Tmux availability probe** — runs ``command -v tmux`` on the remote
|
||||
host with a short timeout. The caller falls back to the previous
|
||||
direct-shell spawn when tmux is missing, and uses the probe result as
|
||||
a one-shot hint for the first terminal launch per host.
|
||||
|
||||
The ``sessions-term-`` prefix intentionally differs from the
|
||||
``sessions-agent-`` prefix owned by ``agent_tmux.py`` (Track D). Both
|
||||
prefixes share the ``sessions-`` root for easy user-side enumeration
|
||||
(``tmux list-sessions | grep ^sessions-``) while staying partitioned so
|
||||
closing a terminal view never touches an agent session.
|
||||
|
||||
Nothing here imports from ``sublime``; the integrator wires this module
|
||||
into the Sublime command separately so unit tests stay subprocess-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable, List, Optional, Sequence
|
||||
|
||||
from .ssh_runner import _subprocess_no_window_kwargs
|
||||
|
||||
# Hosts in ``~/.ssh/config`` commonly contain alphanumerics plus ``._-``.
|
||||
# The validator intentionally rejects anything else (spaces, shell meta,
|
||||
# wildcards, non-ASCII) so the resulting session name is always safe to
|
||||
# shlex-quote without needing additional escaping passes. Uppercase is
|
||||
# accepted because OpenSSH preserves case in the alias and tmux session
|
||||
# names are case-sensitive.
|
||||
_HOST_ALIAS_RE = re.compile(r"\A[A-Za-z0-9._-]+\Z")
|
||||
|
||||
# Dedicated prefix for Sessions-owned remote *terminal* tmux sessions.
|
||||
# Distinct from ``sessions-agent-`` (Track D / ``agent_tmux.py``) so
|
||||
# terminal and agent sessions can coexist on the same host without
|
||||
# ever aliasing each other's state.
|
||||
SESSION_NAME_PREFIX = "sessions-term-"
|
||||
|
||||
|
||||
# Run callable signature: mirror ``subprocess.run`` well enough for the
|
||||
# small subset we use. Tests inject a recorder so the probe stays hermetic.
|
||||
RunFn = Callable[..., "subprocess.CompletedProcess[str]"]
|
||||
|
||||
|
||||
class TerminalTmuxSessionError(ValueError):
|
||||
"""Raised for a ``host_alias`` that cannot be rendered safely.
|
||||
|
||||
Subclasses :class:`ValueError` so callers that only care about the
|
||||
"bad input" contract can ``except ValueError`` without knowing this
|
||||
module.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TmuxProbeResult:
|
||||
"""Outcome of probing ``command -v tmux`` on a remote host.
|
||||
|
||||
``available`` is the boolean decision used by the integrator. The
|
||||
other fields are kept for diagnostics and to let the caller render
|
||||
a helpful status hint when tmux is missing.
|
||||
"""
|
||||
|
||||
available: bool
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
def session_name_for_host(host_alias: str) -> str:
|
||||
"""Return the canonical tmux session name for a ``host_alias``.
|
||||
|
||||
The output has the form ``sessions-term-<alias>`` and is safe to
|
||||
embed verbatim in a shell command built by the caller. Invalid
|
||||
aliases raise :class:`TerminalTmuxSessionError` *before* any
|
||||
subprocess call is issued.
|
||||
|
||||
Args:
|
||||
host_alias: SSH config alias (for example ``prod`` or
|
||||
``bastion.example.com``).
|
||||
|
||||
Returns:
|
||||
The canonical session name, e.g. ``sessions-term-prod``.
|
||||
|
||||
Raises:
|
||||
TerminalTmuxSessionError: When ``host_alias`` is empty or
|
||||
contains characters outside ``[A-Za-z0-9._-]``.
|
||||
"""
|
||||
if not isinstance(host_alias, str) or not host_alias:
|
||||
raise TerminalTmuxSessionError("host_alias must be a non-empty string")
|
||||
if not _HOST_ALIAS_RE.match(host_alias):
|
||||
raise TerminalTmuxSessionError(
|
||||
"host_alias contains disallowed characters: {!r}".format(host_alias)
|
||||
)
|
||||
return "{}{}".format(SESSION_NAME_PREFIX, host_alias)
|
||||
|
||||
|
||||
def next_terminal_session_name(host_alias: str, existing_names: Iterable[str]) -> str:
|
||||
"""Return the next free numbered session name for ``host_alias``.
|
||||
|
||||
The base session ``sessions-term-<alias>`` is the persistent
|
||||
"main" terminal owned by ``Sessions: Open Remote Terminal``.
|
||||
Additional panes use numeric suffixes starting at ``-2``:
|
||||
``sessions-term-<alias>-2``, ``sessions-term-<alias>-3``, ... .
|
||||
The function scans ``existing_names`` for the host's prefix and
|
||||
returns the smallest free index >= 2. The first numbered pane
|
||||
(index 2) is preferred when nothing in ``existing_names`` yet
|
||||
matches a numbered slot for this host even if the base session
|
||||
is already running — the base session is reserved for the
|
||||
default reattach command.
|
||||
|
||||
Args:
|
||||
host_alias: SSH config alias (validated against the same
|
||||
charset used by :func:`session_name_for_host`).
|
||||
existing_names: Iterable of tmux session names already
|
||||
running on the host; typically ``list_terminal_sessions``
|
||||
output but any iterable of strings works.
|
||||
|
||||
Returns:
|
||||
The next free numbered session name.
|
||||
|
||||
Raises:
|
||||
TerminalTmuxSessionError: When ``host_alias`` fails the same
|
||||
validation as :func:`session_name_for_host`.
|
||||
"""
|
||||
base = session_name_for_host(host_alias)
|
||||
used: set[int] = set()
|
||||
numbered_prefix = base + "-"
|
||||
for raw in existing_names:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
if not raw.startswith(numbered_prefix):
|
||||
continue
|
||||
suffix = raw[len(numbered_prefix) :]
|
||||
if not suffix.isdigit():
|
||||
continue
|
||||
try:
|
||||
value = int(suffix)
|
||||
except ValueError: # pragma: no cover - guarded by isdigit
|
||||
continue
|
||||
if value >= 2:
|
||||
used.add(value)
|
||||
candidate = 2
|
||||
while candidate in used:
|
||||
candidate += 1
|
||||
return "{}-{}".format(base, candidate)
|
||||
|
||||
|
||||
def list_terminal_sessions(
|
||||
host_alias: str,
|
||||
*,
|
||||
ssh_command_builder: Optional[Callable[[str], Sequence[str]]] = None,
|
||||
run: Optional[RunFn] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> List[str]:
|
||||
"""Return Sessions-owned remote terminal tmux session names.
|
||||
|
||||
Runs ``tmux list-sessions -F '#{session_name}'`` on the remote
|
||||
host and filters the output down to names starting with
|
||||
:data:`SESSION_NAME_PREFIX`. Three "normal" non-error paths
|
||||
return the empty list instead of raising:
|
||||
|
||||
* tmux reports "no server running" / "no sessions";
|
||||
* tmux is not installed (exit 127 / "command not found");
|
||||
* the SSH probe itself times out or hits an ``OSError``.
|
||||
|
||||
The caller drives kill / next-pane decisions off this output, so
|
||||
swallowing the empty cases keeps those flows dead-simple.
|
||||
|
||||
Args:
|
||||
host_alias: SSH alias to enumerate against.
|
||||
ssh_command_builder: Maps ``alias`` to an argv prefix for
|
||||
remote commands. Defaults to ``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run``. Tests pass a recorder.
|
||||
timeout: Seconds before giving up.
|
||||
|
||||
Returns:
|
||||
Session names belonging to Sessions terminals — both the
|
||||
base ``sessions-term-<alias>`` and any numbered children.
|
||||
"""
|
||||
builder = ssh_command_builder or _default_ssh_command_builder
|
||||
run_fn = run or subprocess.run
|
||||
argv: List[str] = list(builder(host_alias)) + [
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}",
|
||||
]
|
||||
try:
|
||||
completed = run_fn(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return []
|
||||
except OSError:
|
||||
return []
|
||||
if completed.returncode == 0:
|
||||
return [
|
||||
line.strip()
|
||||
for line in (completed.stdout or "").splitlines()
|
||||
if line.strip().startswith(SESSION_NAME_PREFIX)
|
||||
]
|
||||
# tmux exits 1 with "no server running" / "no sessions" when the
|
||||
# tmux server hasn't been started. 127 / "command not found" when
|
||||
# the binary isn't installed. In either case we have no terminal
|
||||
# sessions to report.
|
||||
return []
|
||||
|
||||
|
||||
def kill_terminal_session(
|
||||
host_alias: str,
|
||||
session_name: str,
|
||||
*,
|
||||
ssh_command_builder: Optional[Callable[[str], Sequence[str]]] = None,
|
||||
run: Optional[RunFn] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> "subprocess.CompletedProcess[str]":
|
||||
"""Run ``tmux kill-session -t <session_name>`` on ``host_alias``.
|
||||
|
||||
Returns the completed process so callers can surface stderr in a
|
||||
status hint when the session was already gone (a zero-cost
|
||||
common case after the user manually exited the shell).
|
||||
|
||||
Args:
|
||||
host_alias: SSH alias to target.
|
||||
session_name: tmux session name to kill. Must start with
|
||||
:data:`SESSION_NAME_PREFIX`; otherwise the call refuses
|
||||
so a misuse can never reach into agent or unrelated
|
||||
tmux sessions.
|
||||
ssh_command_builder: Argv-prefix builder; defaults to
|
||||
``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run``.
|
||||
timeout: Seconds before giving up.
|
||||
|
||||
Returns:
|
||||
The :class:`subprocess.CompletedProcess` from the remote
|
||||
tmux invocation.
|
||||
|
||||
Raises:
|
||||
TerminalTmuxSessionError: When ``session_name`` does not
|
||||
belong to the Sessions terminal namespace.
|
||||
"""
|
||||
if not isinstance(session_name, str) or not session_name.startswith(
|
||||
SESSION_NAME_PREFIX
|
||||
):
|
||||
raise TerminalTmuxSessionError(
|
||||
"refusing to kill non-terminal tmux session: {!r}".format(session_name)
|
||||
)
|
||||
builder = ssh_command_builder or _default_ssh_command_builder
|
||||
run_fn = run or subprocess.run
|
||||
argv: List[str] = list(builder(host_alias)) + [
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
session_name,
|
||||
]
|
||||
return run_fn(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
|
||||
|
||||
def build_remote_tmux_invocation(
|
||||
session_name: str,
|
||||
shell_preamble: str,
|
||||
shell_command: str,
|
||||
) -> str:
|
||||
"""Return the remote shell command that wraps the user's shell in tmux.
|
||||
|
||||
Structure:
|
||||
|
||||
cd <root> && (stty sane ...) && \\
|
||||
tmux new-session -A -s <name> <shell>
|
||||
|
||||
``new-session -A`` attaches to ``<name>`` if it already exists,
|
||||
otherwise spawns a new tmux session and runs ``<shell>`` inside it.
|
||||
The preamble runs before ``tmux`` so the terminal's initial ``cwd``
|
||||
matches whichever workspace root the caller passed — a fresh tmux
|
||||
session inherits it; a re-attached session keeps its own ``cwd``.
|
||||
|
||||
Args:
|
||||
session_name: Pre-validated tmux session name.
|
||||
shell_preamble: Shell command(s) run before ``tmux`` (for
|
||||
example ``cd /srv/app && (stty sane ...)``).
|
||||
shell_command: Final interactive shell to exec inside tmux.
|
||||
|
||||
Returns:
|
||||
A single shell command string ready to hand to
|
||||
``ssh -tt <host>`` as the remote invocation.
|
||||
"""
|
||||
# ``shell_command`` goes through tmux's own parser, so we pass it as
|
||||
# one positional arg after ``--``. ``shlex.quote`` would cause tmux
|
||||
# to see quoted surrounding characters; instead we trust the caller
|
||||
# to sanitise the shell command (the existing settings loader
|
||||
# rejects newlines, which covers the only "dangerous" case).
|
||||
return "{preamble} && tmux new-session -A -s {name} {shell}".format(
|
||||
preamble=shell_preamble,
|
||||
name=_shell_single_quote(session_name),
|
||||
shell=shell_command,
|
||||
)
|
||||
|
||||
|
||||
def probe_tmux_available(
|
||||
host_alias: str,
|
||||
*,
|
||||
ssh_command_builder: Optional[Callable[[str], Sequence[str]]] = None,
|
||||
run: Optional[RunFn] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> TmuxProbeResult:
|
||||
"""Probe ``command -v tmux`` on ``host_alias`` and return the outcome.
|
||||
|
||||
A zero exit with non-empty stdout is treated as "tmux available".
|
||||
Any other outcome (missing binary, SSH failure, timeout) is folded
|
||||
into ``available=False`` so the caller can fall back to the
|
||||
direct-shell spawn without a try/except dance.
|
||||
|
||||
Args:
|
||||
host_alias: SSH config alias. Not validated here — the caller
|
||||
is expected to pass a known-good value (the connect flow
|
||||
already filters it).
|
||||
ssh_command_builder: Maps an alias to an argv prefix for remote
|
||||
commands. Defaults to ``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run`` used for the probe. Tests
|
||||
typically pass a stub recorder.
|
||||
timeout: Seconds before the probe gives up. Defaults to 10.
|
||||
|
||||
Returns:
|
||||
A :class:`TmuxProbeResult` describing the probe outcome.
|
||||
"""
|
||||
builder = ssh_command_builder or _default_ssh_command_builder
|
||||
run_fn = run or subprocess.run
|
||||
argv: List[str] = list(builder(host_alias)) + ["command", "-v", "tmux"]
|
||||
try:
|
||||
completed = run_fn(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return TmuxProbeResult(
|
||||
available=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr="timeout after {}s: {}".format(timeout, exc),
|
||||
)
|
||||
except OSError as exc:
|
||||
return TmuxProbeResult(
|
||||
available=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr="ssh probe failed: {}".format(exc),
|
||||
)
|
||||
stdout = (completed.stdout or "").strip()
|
||||
stderr = (completed.stderr or "").strip()
|
||||
available = completed.returncode == 0 and bool(stdout)
|
||||
return TmuxProbeResult(
|
||||
available=available,
|
||||
exit_code=int(completed.returncode),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
def _default_ssh_command_builder(alias: str) -> List[str]:
|
||||
"""Return the default ``ssh <alias>`` argv prefix for remote commands."""
|
||||
return ["ssh", alias]
|
||||
|
||||
|
||||
def _shell_single_quote(value: str) -> str:
|
||||
"""Return ``value`` single-quoted for POSIX shell embedding.
|
||||
|
||||
``session_name_for_host`` already guarantees the alphabet is safe,
|
||||
but we single-quote the result defensively so a future loosening of
|
||||
the validator can't turn into a shell-injection regression.
|
||||
"""
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SESSION_NAME_PREFIX",
|
||||
"TerminalTmuxSessionError",
|
||||
"TmuxProbeResult",
|
||||
"build_remote_tmux_invocation",
|
||||
"kill_terminal_session",
|
||||
"list_terminal_sessions",
|
||||
"next_terminal_session_name",
|
||||
"probe_tmux_available",
|
||||
"session_name_for_host",
|
||||
)
|
||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import TYPE_CHECKING, Literal, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, List, Literal, Optional, Tuple
|
||||
|
||||
from .settings_model import SessionsSettings
|
||||
|
||||
@@ -564,3 +565,174 @@ def connect_workspace(
|
||||
materialized_workspace=materialized_workspace,
|
||||
recent_workspace=recent_workspace,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deferred-directory tracking (v0.4.21)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# When the Rust mirror refuses to descend into an oversized directory it
|
||||
# records that directory's remote absolute path in ``deferred_directories``
|
||||
# on the result. We cache those paths per workspace in-process so the
|
||||
# "Sessions: Expand Deferred Directory" quick panel can offer them back to
|
||||
# the user. The store is deliberately RAM-only: it is rebuilt on each auto
|
||||
# mirror pass and does not outlive a Sublime Text session.
|
||||
|
||||
_DEFERRED_DIRECTORIES_LOCK = threading.Lock()
|
||||
_DEFERRED_DIRECTORIES_BY_CACHE_KEY: Dict[str, Tuple[str, ...]] = {}
|
||||
|
||||
|
||||
def record_deferred_directories(
|
||||
cache_key: str, remote_paths: Iterable[str]
|
||||
) -> Tuple[str, ...]:
|
||||
"""Store the deferred-directory list for ``cache_key`` (deduped, sorted).
|
||||
|
||||
Replaces any prior value so each auto-sync starts from a clean slate.
|
||||
Returns the stored tuple so callers can log exactly what was retained.
|
||||
"""
|
||||
seen: Dict[str, None] = {}
|
||||
for raw in remote_paths:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
trimmed = raw.strip()
|
||||
if not trimmed:
|
||||
continue
|
||||
seen.setdefault(trimmed, None)
|
||||
ordered = tuple(sorted(seen.keys()))
|
||||
with _DEFERRED_DIRECTORIES_LOCK:
|
||||
if ordered:
|
||||
_DEFERRED_DIRECTORIES_BY_CACHE_KEY[cache_key] = ordered
|
||||
else:
|
||||
_DEFERRED_DIRECTORIES_BY_CACHE_KEY.pop(cache_key, None)
|
||||
return ordered
|
||||
|
||||
|
||||
def deferred_directories_for(cache_key: str) -> Tuple[str, ...]:
|
||||
"""Return the currently deferred remote paths for ``cache_key`` (may be empty)."""
|
||||
with _DEFERRED_DIRECTORIES_LOCK:
|
||||
return _DEFERRED_DIRECTORIES_BY_CACHE_KEY.get(cache_key, ())
|
||||
|
||||
|
||||
def clear_deferred_directory(cache_key: str, remote_path: str) -> Tuple[str, ...]:
|
||||
"""Remove ``remote_path`` from the deferred list after a successful expand."""
|
||||
target = (remote_path or "").strip()
|
||||
if not target:
|
||||
return deferred_directories_for(cache_key)
|
||||
with _DEFERRED_DIRECTORIES_LOCK:
|
||||
current = _DEFERRED_DIRECTORIES_BY_CACHE_KEY.get(cache_key, ())
|
||||
if not current:
|
||||
return ()
|
||||
remaining = tuple(p for p in current if p != target)
|
||||
if remaining:
|
||||
_DEFERRED_DIRECTORIES_BY_CACHE_KEY[cache_key] = remaining
|
||||
else:
|
||||
_DEFERRED_DIRECTORIES_BY_CACHE_KEY.pop(cache_key, None)
|
||||
return remaining
|
||||
|
||||
|
||||
def reset_deferred_directories() -> None:
|
||||
"""Forget every deferred-directory entry (test / teardown helper)."""
|
||||
with _DEFERRED_DIRECTORIES_LOCK:
|
||||
_DEFERRED_DIRECTORIES_BY_CACHE_KEY.clear()
|
||||
|
||||
|
||||
# --- agent pair registry -----------------------------------------------------
|
||||
#
|
||||
# Each (workspace, agent_id) is one "pair". The broker keeps the tmux session
|
||||
# alive on the remote; the pair record here is the Python-side handle the
|
||||
# switcher view + kill command operate on. Storage is module-global because a
|
||||
# user may have multiple windows open against the same pair; storing per
|
||||
# window would double-count.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPair:
|
||||
"""One (workspace × agent) binding with rendering + switch metadata."""
|
||||
|
||||
workspace_cache_key: str
|
||||
host_alias: str
|
||||
agent_id: str
|
||||
agent_label: str
|
||||
workspace_label: str
|
||||
session_name: str
|
||||
created_at: float
|
||||
last_activated_at: float
|
||||
|
||||
@property
|
||||
def pair_id(self) -> str:
|
||||
"""Stable identifier used by the switcher view + palette commands."""
|
||||
return f"{self.workspace_cache_key}:{self.agent_id}"
|
||||
|
||||
|
||||
_AGENT_PAIRS_LOCK = threading.Lock()
|
||||
_AGENT_PAIRS_BY_ID: Dict[str, AgentPair] = {}
|
||||
_ACTIVE_PAIR_BY_WORKSPACE: Dict[str, str] = {}
|
||||
|
||||
|
||||
def register_agent_pair(pair: AgentPair) -> AgentPair:
|
||||
"""Insert or replace ``pair`` in the registry, return the stored value.
|
||||
|
||||
When an entry for the same ``pair_id`` already exists, ``created_at``
|
||||
is preserved and only ``last_activated_at`` advances. Marks the pair as
|
||||
the workspace's active pair.
|
||||
"""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
existing = _AGENT_PAIRS_BY_ID.get(pair.pair_id)
|
||||
stored = (
|
||||
AgentPair(
|
||||
workspace_cache_key=pair.workspace_cache_key,
|
||||
host_alias=pair.host_alias,
|
||||
agent_id=pair.agent_id,
|
||||
agent_label=pair.agent_label,
|
||||
workspace_label=pair.workspace_label,
|
||||
session_name=pair.session_name,
|
||||
created_at=existing.created_at,
|
||||
last_activated_at=pair.last_activated_at,
|
||||
)
|
||||
if existing is not None
|
||||
else pair
|
||||
)
|
||||
_AGENT_PAIRS_BY_ID[stored.pair_id] = stored
|
||||
_ACTIVE_PAIR_BY_WORKSPACE[stored.workspace_cache_key] = stored.pair_id
|
||||
return stored
|
||||
|
||||
|
||||
def forget_agent_pair(pair_id: str) -> Optional[AgentPair]:
|
||||
"""Remove ``pair_id``; clear active-pair pointers that referenced it."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
removed = _AGENT_PAIRS_BY_ID.pop(pair_id, None)
|
||||
if removed is None:
|
||||
return None
|
||||
ws_key = removed.workspace_cache_key
|
||||
if _ACTIVE_PAIR_BY_WORKSPACE.get(ws_key) == pair_id:
|
||||
_ACTIVE_PAIR_BY_WORKSPACE.pop(ws_key, None)
|
||||
return removed
|
||||
|
||||
|
||||
def list_agent_pairs() -> List[AgentPair]:
|
||||
"""Return all known pairs ordered by most-recently-activated first."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
return sorted(
|
||||
_AGENT_PAIRS_BY_ID.values(),
|
||||
key=lambda p: p.last_activated_at,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def active_agent_pair_id(workspace_cache_key: str) -> Optional[str]:
|
||||
"""Return the pair_id flagged as active for ``workspace_cache_key``, or None."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
return _ACTIVE_PAIR_BY_WORKSPACE.get(workspace_cache_key)
|
||||
|
||||
|
||||
def lookup_agent_pair(pair_id: str) -> Optional[AgentPair]:
|
||||
"""Return the stored pair for ``pair_id`` (exact match)."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
return _AGENT_PAIRS_BY_ID.get(pair_id)
|
||||
|
||||
|
||||
def reset_agent_pairs() -> None:
|
||||
"""Forget every agent pair (test / teardown helper)."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
_AGENT_PAIRS_BY_ID.clear()
|
||||
_ACTIVE_PAIR_BY_WORKSPACE.clear()
|
||||
|
||||
@@ -28,6 +28,7 @@ _COMMAND_GLOBAL_SETS = (
|
||||
"_MIRROR_AUTO_REFRESH_WINDOWS",
|
||||
"_MIRROR_AUTO_REFRESH_PRIMED",
|
||||
"_MIRROR_AUTO_REFRESH_CACHE_KEYS",
|
||||
"_EAGER_HYDRATE_PRIMED",
|
||||
"_OPEN_FILE_WATCH_WINDOWS",
|
||||
"_OPEN_FILE_WATCH_CACHE_KEYS",
|
||||
"_BACKGROUND_PENDING_KEYS",
|
||||
@@ -39,6 +40,9 @@ _COMMAND_GLOBAL_SETS = (
|
||||
"_OPEN_DIAG_VIEW_TS",
|
||||
"_ACTIVE_REFRESH_VIEW_TS",
|
||||
"_LSP_PROJECT_REFRESH_LAST",
|
||||
"_TERMINUS_VIEW_BY_HOST",
|
||||
"_TERMINUS_TMUX_AVAILABLE_BY_HOST",
|
||||
"_AGENT_SWITCHER_VIEW_BY_WINDOW",
|
||||
)
|
||||
|
||||
|
||||
|
||||
386
sublime/tests/test_agent_change_badge.py
Normal file
386
sublime/tests/test_agent_change_badge.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""Unit tests for :mod:`sessions.agent_change_badge`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from typing import Any, Callable, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.agent_change_badge import (
|
||||
AgentChangeBadgeRenderer,
|
||||
AgentEditBadgeRequest,
|
||||
compute_changed_line_ranges,
|
||||
format_badge_html,
|
||||
)
|
||||
|
||||
# ---- compute_changed_line_ranges ----------------------------------------
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_identical_is_empty() -> None:
|
||||
assert compute_changed_line_ranges("a\nb\nc\n", "a\nb\nc\n") == []
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_detects_single_insertion() -> None:
|
||||
old = "a\nb\nc\n"
|
||||
new = "a\nb\nX\nc\n"
|
||||
ranges = compute_changed_line_ranges(old, new)
|
||||
# The inserted line ``X`` sits at index 2 in ``new``.
|
||||
assert ranges == [(2, 2)]
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_detects_single_replace() -> None:
|
||||
old = "a\nb\nc\n"
|
||||
new = "a\nREPLACED\nc\n"
|
||||
ranges = compute_changed_line_ranges(old, new)
|
||||
assert ranges == [(1, 1)]
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_detects_trailing_append() -> None:
|
||||
old = "a\nb\n"
|
||||
new = "a\nb\nc\nd\n"
|
||||
ranges = compute_changed_line_ranges(old, new)
|
||||
assert ranges == [(2, 3)]
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_handles_pure_deletion() -> None:
|
||||
old = "a\nb\nc\nd\n"
|
||||
new = "a\nd\n"
|
||||
ranges = compute_changed_line_ranges(old, new)
|
||||
# Deletion decorates the surviving join line.
|
||||
assert ranges
|
||||
for start, end in ranges:
|
||||
assert 0 <= start <= end
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_merges_adjacent_hunks() -> None:
|
||||
old = "a\nb\nc\nd\ne\n"
|
||||
new = "a\nB\nC\nD\ne\n"
|
||||
ranges = compute_changed_line_ranges(old, new)
|
||||
# Three consecutive replaces should collapse into a single range.
|
||||
assert ranges == [(1, 3)]
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_keeps_disjoint_hunks_separate() -> None:
|
||||
old = "a\nb\nc\nd\ne\nf\n"
|
||||
new = "a\nB\nc\nd\nE\nf\n"
|
||||
ranges = compute_changed_line_ranges(old, new)
|
||||
assert ranges == [(1, 1), (4, 4)]
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_full_rewrite() -> None:
|
||||
old = "alpha\nbeta\n"
|
||||
new = "gamma\ndelta\nepsilon\n"
|
||||
ranges = compute_changed_line_ranges(old, new)
|
||||
# ``SequenceMatcher`` yields one replace spanning the whole buffer.
|
||||
assert ranges == [(0, 2)]
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_empty_old_all_inserts() -> None:
|
||||
ranges = compute_changed_line_ranges("", "one\ntwo\n")
|
||||
assert ranges == [(0, 1)]
|
||||
|
||||
|
||||
def test_compute_changed_line_ranges_empty_new_reports_join_line_zero() -> None:
|
||||
ranges = compute_changed_line_ranges("kept\n", "")
|
||||
assert ranges == [(0, 0)]
|
||||
|
||||
|
||||
# ---- format_badge_html --------------------------------------------------
|
||||
|
||||
|
||||
def test_format_badge_html_contains_agent_and_time() -> None:
|
||||
html = format_badge_html("claude", 0)
|
||||
assert "agent edit" in html
|
||||
assert "claude" in html
|
||||
assert 'class="sessions-agent-badge"' in html
|
||||
|
||||
|
||||
def test_format_badge_html_escapes_agent_label() -> None:
|
||||
html = format_badge_html("<script>alert('x')</script>", 0)
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_format_badge_html_falls_back_when_label_blank() -> None:
|
||||
html = format_badge_html("", 0)
|
||||
assert "agent" in html
|
||||
|
||||
|
||||
def test_format_badge_html_handles_invalid_timestamp() -> None:
|
||||
html = format_badge_html("claude", float("inf"))
|
||||
assert "claude" in html
|
||||
# Fallback renders dashes; at minimum no traceback-producing format chars.
|
||||
assert "%" not in html
|
||||
|
||||
|
||||
def test_format_badge_html_is_ascii_style() -> None:
|
||||
# No emojis per the user's ASCII-only rule.
|
||||
html = format_badge_html("claude", 0)
|
||||
for ch in html:
|
||||
assert ord(ch) < 0x1F000, "unexpected emoji {!r} in html".format(ch)
|
||||
|
||||
|
||||
# ---- AgentChangeBadgeRenderer ------------------------------------------
|
||||
|
||||
|
||||
class _FakeRegion:
|
||||
def __init__(self, begin: int, end: int) -> None:
|
||||
self.begin_ = begin
|
||||
self.end_ = end
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, _FakeRegion):
|
||||
return NotImplemented
|
||||
return (self.begin_, self.end_) == (other.begin_, other.end_)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug aid
|
||||
return "_FakeRegion({}, {})".format(self.begin_, self.end_)
|
||||
|
||||
|
||||
class _FakeView:
|
||||
def __init__(self) -> None:
|
||||
self.add_phantom_calls: List[Tuple[str, Any, str, int]] = []
|
||||
self.erase_calls: List[int] = []
|
||||
self._next_id = 100
|
||||
|
||||
def text_point(self, row: int, col: int) -> int:
|
||||
return row * 100 + col
|
||||
|
||||
def add_phantom(
|
||||
self,
|
||||
key: str,
|
||||
region: Any,
|
||||
content: str,
|
||||
layout: int,
|
||||
) -> int:
|
||||
pid = self._next_id
|
||||
self._next_id += 1
|
||||
self.add_phantom_calls.append((key, region, content, layout))
|
||||
return pid
|
||||
|
||||
def erase_phantom_by_id(self, pid: int) -> None:
|
||||
self.erase_calls.append(pid)
|
||||
|
||||
|
||||
class _TimeoutCollector:
|
||||
def __init__(self) -> None:
|
||||
self.scheduled: List[Tuple[Callable[[], None], int]] = []
|
||||
|
||||
def __call__(self, callback: Callable[[], None], delay_ms: int) -> None:
|
||||
self.scheduled.append((callback, delay_ms))
|
||||
|
||||
def run_all(self) -> None:
|
||||
for cb, _ms in self.scheduled:
|
||||
cb()
|
||||
|
||||
|
||||
def _build_renderer(
|
||||
*,
|
||||
view: _FakeView,
|
||||
timer: _TimeoutCollector,
|
||||
use_injection: bool = False,
|
||||
) -> AgentChangeBadgeRenderer:
|
||||
if use_injection:
|
||||
erase_calls: List[int] = []
|
||||
|
||||
def _erase(pid: int) -> None:
|
||||
view.erase_phantom_by_id(pid)
|
||||
erase_calls.append(pid)
|
||||
|
||||
return AgentChangeBadgeRenderer(
|
||||
add_phantom=view.add_phantom,
|
||||
erase_phantom=_erase,
|
||||
set_timeout=timer,
|
||||
)
|
||||
return AgentChangeBadgeRenderer(set_timeout=timer)
|
||||
|
||||
|
||||
def _request(old: str, new: str, ts: float = 0.0) -> AgentEditBadgeRequest:
|
||||
return AgentEditBadgeRequest(
|
||||
view_id=1,
|
||||
old_text=old,
|
||||
new_text=new,
|
||||
agent_label="claude",
|
||||
timestamp=ts,
|
||||
)
|
||||
|
||||
|
||||
def test_renderer_adds_one_phantom_per_range() -> None:
|
||||
view = _FakeView()
|
||||
timer = _TimeoutCollector()
|
||||
renderer = _build_renderer(view=view, timer=timer)
|
||||
request = _request("a\nb\nc\nd\ne\nf\n", "a\nB\nc\nd\nE\nf\n")
|
||||
created = renderer.render(request, view, ttl_ms=1000)
|
||||
assert len(created) == 2
|
||||
assert len(view.add_phantom_calls) == 2
|
||||
# Keys encode the view id so concurrent badges don't collide.
|
||||
for key, *_ in view.add_phantom_calls:
|
||||
assert key == "sessions-agent-edit-1"
|
||||
|
||||
|
||||
def test_renderer_skips_noop_diffs() -> None:
|
||||
view = _FakeView()
|
||||
timer = _TimeoutCollector()
|
||||
renderer = _build_renderer(view=view, timer=timer)
|
||||
request = _request("a\nb\n", "a\nb\n")
|
||||
assert renderer.render(request, view) == []
|
||||
assert view.add_phantom_calls == []
|
||||
assert timer.scheduled == []
|
||||
|
||||
|
||||
def test_renderer_schedules_erase_after_ttl() -> None:
|
||||
view = _FakeView()
|
||||
timer = _TimeoutCollector()
|
||||
renderer = _build_renderer(view=view, timer=timer)
|
||||
request = _request("a\nb\n", "a\nBEE\n")
|
||||
created = renderer.render(request, view, ttl_ms=1234)
|
||||
assert len(timer.scheduled) == 1
|
||||
_cb, delay = timer.scheduled[0]
|
||||
assert delay == 1234
|
||||
timer.run_all()
|
||||
assert view.erase_calls == created
|
||||
|
||||
|
||||
def test_renderer_returns_empty_when_view_lacks_add_phantom() -> None:
|
||||
class _ViewWithoutAdd:
|
||||
def text_point(self, row: int, col: int) -> int:
|
||||
return 0
|
||||
|
||||
view = _ViewWithoutAdd()
|
||||
timer = _TimeoutCollector()
|
||||
renderer = AgentChangeBadgeRenderer(set_timeout=timer)
|
||||
request = _request("a\n", "b\n")
|
||||
assert renderer.render(request, view) == []
|
||||
|
||||
|
||||
def test_renderer_does_not_schedule_erase_when_nothing_created() -> None:
|
||||
class _FailingView:
|
||||
def text_point(self, row: int, col: int) -> int:
|
||||
return 0
|
||||
|
||||
def add_phantom(self, *args: Any, **kwargs: Any) -> int:
|
||||
return 0 # non-positive → renderer treats as failure
|
||||
|
||||
timer = _TimeoutCollector()
|
||||
view = _FailingView()
|
||||
renderer = AgentChangeBadgeRenderer(set_timeout=timer)
|
||||
request = _request("a\n", "b\n")
|
||||
assert renderer.render(request, view) == []
|
||||
assert timer.scheduled == []
|
||||
|
||||
|
||||
def test_renderer_respects_explicit_injections() -> None:
|
||||
captured: Dict[str, Any] = {"added": 0, "erased": [], "timeouts": []}
|
||||
|
||||
def _add(key: str, region: Any, content: str, layout: int) -> int:
|
||||
captured["added"] += 1
|
||||
return 77
|
||||
|
||||
def _erase(pid: int) -> None:
|
||||
captured["erased"].append(pid)
|
||||
|
||||
def _timer(cb: Callable[[], None], ms: int) -> None:
|
||||
captured["timeouts"].append(ms)
|
||||
cb()
|
||||
|
||||
class _StubView:
|
||||
def text_point(self, row: int, col: int) -> int:
|
||||
return 10 * row + col
|
||||
|
||||
renderer = AgentChangeBadgeRenderer(
|
||||
add_phantom=_add,
|
||||
erase_phantom=_erase,
|
||||
set_timeout=_timer,
|
||||
)
|
||||
request = _request("a\nb\n", "a\nX\n", ts=100.0)
|
||||
created = renderer.render(request, _StubView(), ttl_ms=500)
|
||||
assert created == [77]
|
||||
assert captured["added"] == 1
|
||||
assert captured["timeouts"] == [500]
|
||||
assert captured["erased"] == [77]
|
||||
|
||||
|
||||
def test_renderer_uses_injected_add_even_when_view_has_method() -> None:
|
||||
# Proves the explicit injection takes priority — important for tests
|
||||
# that observe call counts without touching the real view API.
|
||||
view = _FakeView()
|
||||
timer = _TimeoutCollector()
|
||||
call_log: List[str] = []
|
||||
|
||||
def _add(key: str, region: Any, content: str, layout: int) -> int:
|
||||
call_log.append(key)
|
||||
return 55
|
||||
|
||||
renderer = AgentChangeBadgeRenderer(
|
||||
add_phantom=_add,
|
||||
erase_phantom=view.erase_phantom_by_id,
|
||||
set_timeout=timer,
|
||||
)
|
||||
renderer.render(_request("a\n", "b\n"), view, ttl_ms=1)
|
||||
assert call_log # injected path fired
|
||||
assert view.add_phantom_calls == [] # real view untouched
|
||||
|
||||
|
||||
def test_renderer_returns_phantom_ids_matching_created_set() -> None:
|
||||
view = _FakeView()
|
||||
timer = _TimeoutCollector()
|
||||
renderer = _build_renderer(view=view, timer=timer)
|
||||
request = _request("a\nb\nc\n", "a\nB\nc\n")
|
||||
created = renderer.render(request, view, ttl_ms=10)
|
||||
assert set(created) == {100} # single range, single phantom
|
||||
timer.run_all()
|
||||
assert view.erase_calls == [100]
|
||||
|
||||
|
||||
def test_agent_edit_badge_request_is_frozen() -> None:
|
||||
req = _request("a\n", "b\n")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
req.agent_label = "codex" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_renderer_without_timeout_still_adds_phantoms() -> None:
|
||||
class _TinyView:
|
||||
def __init__(self) -> None:
|
||||
self.added = 0
|
||||
|
||||
def text_point(self, row: int, col: int) -> int:
|
||||
return row
|
||||
|
||||
def add_phantom(self, *args: Any, **kwargs: Any) -> int:
|
||||
self.added += 1
|
||||
return 11
|
||||
|
||||
view = _TinyView()
|
||||
renderer = AgentChangeBadgeRenderer(
|
||||
add_phantom=view.add_phantom,
|
||||
erase_phantom=None,
|
||||
set_timeout=None,
|
||||
)
|
||||
request = _request("a\n", "b\n")
|
||||
created = renderer.render(request, view)
|
||||
assert created == [11]
|
||||
assert view.added == 1
|
||||
|
||||
|
||||
def test_renderer_region_receives_expected_line_bounds() -> None:
|
||||
captured_regions: List[Any] = []
|
||||
|
||||
def _add(key: str, region: Any, content: str, layout: int) -> int:
|
||||
captured_regions.append(region)
|
||||
return 9
|
||||
|
||||
class _PointView:
|
||||
def text_point(self, row: int, col: int) -> int:
|
||||
return row * 1000 + col
|
||||
|
||||
renderer = AgentChangeBadgeRenderer(add_phantom=_add)
|
||||
request = _request("a\nb\nc\nd\n", "a\nB\nC\nd\n")
|
||||
renderer.render(request, _PointView())
|
||||
assert captured_regions
|
||||
# Merged range covers rows 1..2 — text_point produces 1000 / 2000.
|
||||
region = captured_regions[0]
|
||||
if isinstance(region, tuple):
|
||||
assert region == (1000, 2000)
|
||||
else:
|
||||
assert region.begin() == 1000
|
||||
assert region.end() == 2000
|
||||
97
sublime/tests/test_agent_pair_registry.py
Normal file
97
sublime/tests/test_agent_pair_registry.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Unit tests for the agent-pair registry helpers in ``workspace_state``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sessions.workspace_state import (
|
||||
AgentPair,
|
||||
active_agent_pair_id,
|
||||
forget_agent_pair,
|
||||
list_agent_pairs,
|
||||
lookup_agent_pair,
|
||||
register_agent_pair,
|
||||
reset_agent_pairs,
|
||||
)
|
||||
|
||||
|
||||
def _pair(
|
||||
cache_key: str = "ws1",
|
||||
agent_id: str = "claude",
|
||||
created_at: float = 100.0,
|
||||
last_activated_at: float = 200.0,
|
||||
) -> AgentPair:
|
||||
return AgentPair(
|
||||
workspace_cache_key=cache_key,
|
||||
host_alias="dev",
|
||||
agent_id=agent_id,
|
||||
agent_label=agent_id,
|
||||
workspace_label="proj",
|
||||
session_name="sessions-agent-{}-{}".format(cache_key[:8], agent_id),
|
||||
created_at=created_at,
|
||||
last_activated_at=last_activated_at,
|
||||
)
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
reset_agent_pairs()
|
||||
|
||||
|
||||
def test_register_agent_pair_stores_and_marks_active() -> None:
|
||||
pair = _pair()
|
||||
stored = register_agent_pair(pair)
|
||||
assert stored == pair
|
||||
assert lookup_agent_pair(pair.pair_id) == pair
|
||||
assert active_agent_pair_id("ws1") == pair.pair_id
|
||||
|
||||
|
||||
def test_register_preserves_created_at_on_reactivation() -> None:
|
||||
first = _pair(created_at=100.0, last_activated_at=100.0)
|
||||
register_agent_pair(first)
|
||||
reactivated = register_agent_pair(_pair(created_at=500.0, last_activated_at=300.0))
|
||||
assert reactivated.created_at == 100.0
|
||||
assert reactivated.last_activated_at == 300.0
|
||||
|
||||
|
||||
def test_list_agent_pairs_orders_by_last_activated_desc() -> None:
|
||||
register_agent_pair(_pair(cache_key="ws1", last_activated_at=100.0))
|
||||
register_agent_pair(
|
||||
_pair(cache_key="ws2", agent_id="codex", last_activated_at=500.0)
|
||||
)
|
||||
register_agent_pair(
|
||||
_pair(cache_key="ws1", agent_id="codex", last_activated_at=250.0)
|
||||
)
|
||||
ordered = list_agent_pairs()
|
||||
assert [p.last_activated_at for p in ordered] == [500.0, 250.0, 100.0]
|
||||
|
||||
|
||||
def test_forget_agent_pair_clears_active_pointer() -> None:
|
||||
pair = _pair()
|
||||
register_agent_pair(pair)
|
||||
assert active_agent_pair_id("ws1") == pair.pair_id
|
||||
removed = forget_agent_pair(pair.pair_id)
|
||||
assert removed == pair
|
||||
assert active_agent_pair_id("ws1") is None
|
||||
assert lookup_agent_pair(pair.pair_id) is None
|
||||
|
||||
|
||||
def test_forget_unknown_pair_returns_none() -> None:
|
||||
assert forget_agent_pair("never:existed") is None
|
||||
|
||||
|
||||
def test_register_different_agents_same_workspace_sets_latest_active() -> None:
|
||||
register_agent_pair(_pair(agent_id="claude", last_activated_at=100.0))
|
||||
register_agent_pair(_pair(agent_id="codex", last_activated_at=300.0))
|
||||
assert active_agent_pair_id("ws1") == "ws1:codex"
|
||||
|
||||
|
||||
def test_pair_id_is_workspace_colon_agent() -> None:
|
||||
pair = _pair(cache_key="abc123", agent_id="claude")
|
||||
assert pair.pair_id == "abc123:claude"
|
||||
|
||||
|
||||
def test_reset_agent_pairs_clears_everything() -> None:
|
||||
register_agent_pair(_pair())
|
||||
register_agent_pair(_pair(cache_key="ws2", agent_id="codex"))
|
||||
reset_agent_pairs()
|
||||
assert list_agent_pairs() == []
|
||||
assert active_agent_pair_id("ws1") is None
|
||||
assert active_agent_pair_id("ws2") is None
|
||||
271
sublime/tests/test_agent_proposal_watcher.py
Normal file
271
sublime/tests/test_agent_proposal_watcher.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""Unit tests for the unified-diff stream parser in ``agent_proposal_watcher``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
from sessions.agent_proposal_watcher import (
|
||||
DiffBlock,
|
||||
DiffHunk,
|
||||
extract_new_blocks,
|
||||
parse_unified_diff_stream,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _minimal_diff(path: str = "foo.py") -> str:
|
||||
return (
|
||||
f"--- a/{path}\n"
|
||||
f"+++ b/{path}\n"
|
||||
"@@ -1,3 +1,4 @@\n"
|
||||
" line one\n"
|
||||
" line two\n"
|
||||
"-old three\n"
|
||||
"+new three\n"
|
||||
"+added four\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_returns_empty_list_on_empty_input() -> None:
|
||||
assert parse_unified_diff_stream("") == []
|
||||
|
||||
|
||||
def test_parse_returns_empty_list_on_plain_prose() -> None:
|
||||
text = "I'm thinking about what to edit...\nHere is my plan.\n"
|
||||
assert parse_unified_diff_stream(text) == []
|
||||
|
||||
|
||||
def test_parse_minimal_one_block_one_hunk() -> None:
|
||||
blocks = parse_unified_diff_stream(_minimal_diff("foo.py"))
|
||||
assert len(blocks) == 1
|
||||
block = blocks[0]
|
||||
assert block.path_before == "foo.py"
|
||||
assert block.path_after == "foo.py"
|
||||
assert len(block.hunks) == 1
|
||||
hunk = block.hunks[0]
|
||||
assert hunk.before_start == 1
|
||||
assert hunk.before_count == 3
|
||||
assert hunk.after_start == 1
|
||||
assert hunk.after_count == 4
|
||||
assert hunk.body.startswith("@@ -1,3 +1,4 @@")
|
||||
assert "+new three" in hunk.body
|
||||
|
||||
|
||||
def test_parse_hunk_without_explicit_count_defaults_to_one() -> None:
|
||||
text = "--- a/x.py\n+++ b/x.py\n@@ -5 +5 @@\n-old\n+new\n"
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
assert len(blocks) == 1
|
||||
hunk = blocks[0].hunks[0]
|
||||
assert hunk.before_start == 5
|
||||
assert hunk.before_count == 1
|
||||
assert hunk.after_start == 5
|
||||
assert hunk.after_count == 1
|
||||
|
||||
|
||||
def test_parse_hunk_header_function_context_suffix_preserved_in_body() -> None:
|
||||
text = (
|
||||
"--- a/x.py\n"
|
||||
"+++ b/x.py\n"
|
||||
"@@ -10,2 +10,3 @@ def greet(name):\n"
|
||||
' return f"hi {name}"\n'
|
||||
"-\n"
|
||||
"+# trailing comment\n"
|
||||
"+\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
assert len(blocks) == 1
|
||||
hunk = blocks[0].hunks[0]
|
||||
assert "def greet(name):" in hunk.body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-block / multi-hunk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_multi_block_returns_every_block() -> None:
|
||||
text = _minimal_diff("a.py") + _minimal_diff("b.py")
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
assert [blk.path_after for blk in blocks] == ["a.py", "b.py"]
|
||||
|
||||
|
||||
def test_parse_multi_hunk_block_keeps_hunks_in_order() -> None:
|
||||
text = (
|
||||
"--- a/x.py\n"
|
||||
"+++ b/x.py\n"
|
||||
"@@ -1,2 +1,2 @@\n"
|
||||
" a\n"
|
||||
"-b\n"
|
||||
"+B\n"
|
||||
"@@ -10,1 +10,2 @@\n"
|
||||
" last\n"
|
||||
"+new-tail\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
assert len(blocks) == 1
|
||||
hunks = blocks[0].hunks
|
||||
assert len(hunks) == 2
|
||||
assert hunks[0].before_start == 1
|
||||
assert hunks[1].before_start == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ANSI colour handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_strips_ansi_color_codes_before_matching_headers() -> None:
|
||||
colored = (
|
||||
"\x1b[1m--- a/foo.py\x1b[0m\n"
|
||||
"\x1b[1m+++ b/foo.py\x1b[0m\n"
|
||||
"\x1b[36m@@ -1,2 +1,2 @@\x1b[0m\n"
|
||||
" a\n"
|
||||
"\x1b[31m-b\x1b[0m\n"
|
||||
"\x1b[32m+B\x1b[0m\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(colored)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].path_before == "foo.py"
|
||||
assert "\x1b[" not in blocks[0].hunks[0].body
|
||||
|
||||
|
||||
def test_parse_strips_osc_hyperlink_escape_sequences() -> None:
|
||||
osc = (
|
||||
"\x1b]8;;file:///tmp/x.py\x1b\\--- a/x.py\x1b]8;;\x1b\\\n"
|
||||
"+++ b/x.py\n"
|
||||
"@@ -1,1 +1,1 @@\n"
|
||||
"-old\n"
|
||||
"+new\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(osc)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].path_before == "x.py"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stream safety / partial tails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_drops_trailing_block_header_without_hunks() -> None:
|
||||
text = _minimal_diff("a.py") + "--- a/b.py\n+++ b/b.py\n"
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
# Only the complete first block should be emitted.
|
||||
assert [blk.path_after for blk in blocks] == ["a.py"]
|
||||
|
||||
|
||||
def test_parse_drops_trailing_hunk_whose_body_is_truncated() -> None:
|
||||
text = _minimal_diff("a.py") + "--- a/b.py\n+++ b/b.py\n@@ -1,5 +1,5 @@\n a\n b\n"
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
assert [blk.path_after for blk in blocks] == ["a.py"]
|
||||
|
||||
|
||||
def test_parse_drops_block_with_header_without_plus_partner() -> None:
|
||||
text = "--- a/x.py\nrandom interjection\n" + _minimal_diff("a.py")
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
assert [blk.path_after for blk in blocks] == ["a.py"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Noise tolerance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_ignores_noise_between_blocks() -> None:
|
||||
text = (
|
||||
"Thinking about next edit...\n"
|
||||
+ _minimal_diff("a.py")
|
||||
+ "I will now also touch b.py:\n"
|
||||
+ _minimal_diff("b.py")
|
||||
+ "Done.\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
assert [blk.path_after for blk in blocks] == ["a.py", "b.py"]
|
||||
|
||||
|
||||
def test_parse_handles_header_timestamps_and_trailing_whitespace() -> None:
|
||||
text = (
|
||||
"--- a/foo.py\t2026-04-23 09:00:00\n"
|
||||
"+++ b/foo.py\t2026-04-23 09:01:00\n"
|
||||
"@@ -1,1 +1,1 @@\n"
|
||||
"-old\n"
|
||||
"+new\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].path_before == "foo.py"
|
||||
assert blocks[0].path_after == "foo.py"
|
||||
|
||||
|
||||
def test_parse_handles_no_newline_at_end_of_file_marker() -> None:
|
||||
text = (
|
||||
"--- a/x.py\n"
|
||||
"+++ b/x.py\n"
|
||||
"@@ -1,1 +1,1 @@\n"
|
||||
"-old\n"
|
||||
"\\ No newline at end of file\n"
|
||||
"+new\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(text)
|
||||
assert len(blocks) == 1
|
||||
assert "\\ No newline" in blocks[0].hunks[0].body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_new_blocks dedup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_new_blocks_returns_only_blocks_not_in_prev() -> None:
|
||||
first = parse_unified_diff_stream(_minimal_diff("a.py"))
|
||||
second = parse_unified_diff_stream(_minimal_diff("a.py") + _minimal_diff("b.py"))
|
||||
new_blocks = extract_new_blocks(first, second)
|
||||
assert [blk.path_after for blk in new_blocks] == ["b.py"]
|
||||
|
||||
|
||||
def test_extract_new_blocks_returns_empty_when_curr_is_subset_of_prev() -> None:
|
||||
full = parse_unified_diff_stream(_minimal_diff("a.py") + _minimal_diff("b.py"))
|
||||
partial = parse_unified_diff_stream(_minimal_diff("a.py"))
|
||||
assert extract_new_blocks(full, partial) == []
|
||||
|
||||
|
||||
def test_extract_new_blocks_returns_all_when_prev_empty() -> None:
|
||||
blocks = parse_unified_diff_stream(_minimal_diff("a.py"))
|
||||
assert extract_new_blocks([], blocks) == blocks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclass invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_diff_block_is_frozen_and_hashable() -> None:
|
||||
block = DiffBlock(
|
||||
path_before="a.py",
|
||||
path_after="a.py",
|
||||
hunks=(DiffHunk(1, 1, 1, 1, "@@ -1 +1 @@\n-x\n+y"),),
|
||||
)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
block.path_before = "other" # type: ignore[misc]
|
||||
assert hash(block) == hash(
|
||||
DiffBlock(
|
||||
path_before="a.py",
|
||||
path_after="a.py",
|
||||
hunks=(DiffHunk(1, 1, 1, 1, "@@ -1 +1 @@\n-x\n+y"),),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_diff_hunk_is_frozen() -> None:
|
||||
hunk = DiffHunk(1, 1, 1, 1, "@@ -1 +1 @@\n-x\n+y")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
hunk.before_start = 2 # type: ignore[misc]
|
||||
179
sublime/tests/test_agent_proposal_watcher_adversarial.py
Normal file
179
sublime/tests/test_agent_proposal_watcher_adversarial.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Adversarial edge-case tests for ``parse_unified_diff_stream``.
|
||||
|
||||
The baseline coverage in ``test_agent_proposal_watcher`` exercises
|
||||
well-formed fixtures — this file pushes the parser with stressful
|
||||
inputs the Track D Phase 1 watcher will actually see once wired
|
||||
against a live tmux ``pipe-pane``: interleaved ANSI colours, partial
|
||||
tails from growing log blobs, enormous multi-thousand-line diffs,
|
||||
concurrent parses from two threads.
|
||||
|
||||
Classifier markers: ``threading.Thread`` + ``thread.start`` for
|
||||
adversarial; "large" body word for the multi-thousand-line stress
|
||||
test. These all hit the real parser function — no mocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import List
|
||||
|
||||
from sessions.agent_proposal_watcher import (
|
||||
DiffBlock,
|
||||
DiffHunk,
|
||||
extract_new_blocks,
|
||||
parse_unified_diff_stream,
|
||||
)
|
||||
|
||||
|
||||
def _minimal_block(path: str = "src/lib.rs") -> str:
|
||||
return f"--- a/{path}\n+++ b/{path}\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n tail\n"
|
||||
|
||||
|
||||
def test_parser_handles_ansi_colour_codes_interleaved() -> None:
|
||||
# Claude Code / codex pipe unified diffs with ANSI colour escapes
|
||||
# around +/- markers; the parser must strip them before matching.
|
||||
# Header counts must match body or the parser treats the block as
|
||||
# still-streaming — 1 before-line, 1 after-line exactly.
|
||||
ansi = "\x1b[31m" # red
|
||||
reset = "\x1b[0m"
|
||||
blob = (
|
||||
f"{ansi}--- a/x.py{reset}\n"
|
||||
f"{ansi}+++ b/x.py{reset}\n"
|
||||
"@@ -1,1 +1,1 @@\n"
|
||||
f"{ansi}-old{reset}\n"
|
||||
f"{ansi}+new{reset}\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(blob)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].path_before == "x.py"
|
||||
assert blocks[0].path_after == "x.py"
|
||||
|
||||
|
||||
def test_parser_drops_incomplete_block_at_tail() -> None:
|
||||
# The watcher feeds growing log text; a block whose header landed
|
||||
# but whose hunks haven't fully arrived must NOT be returned
|
||||
# prematurely. The parser either returns the partial block with
|
||||
# zero hunks or drops it entirely — tests pin the latter.
|
||||
blob = (
|
||||
_minimal_block("done.py")
|
||||
+ "--- a/pending.py\n"
|
||||
+ "+++ b/pending.py\n"
|
||||
+ "@@ -1,2 " # truncated header, no trailing \n
|
||||
)
|
||||
blocks = parse_unified_diff_stream(blob)
|
||||
assert [b.path_before for b in blocks] == ["done.py"]
|
||||
|
||||
|
||||
def test_parser_handles_multiple_hunks_in_one_block() -> None:
|
||||
# Each hunk's body must exactly match its declared counts or the
|
||||
# parser treats the block as still-streaming and drops it.
|
||||
blob = (
|
||||
"--- a/f.py\n"
|
||||
"+++ b/f.py\n"
|
||||
"@@ -1,1 +1,1 @@\n"
|
||||
"-a\n"
|
||||
"+A\n"
|
||||
"@@ -10,2 +10,2 @@\n"
|
||||
" ctx\n"
|
||||
"-b\n"
|
||||
"+B\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(blob)
|
||||
assert len(blocks) == 1
|
||||
assert len(blocks[0].hunks) == 2
|
||||
|
||||
|
||||
def test_parser_large_diff_stress_ten_thousand_hunks() -> None:
|
||||
# Intentionally large to catch accidental O(n^2) behaviour in the
|
||||
# parser — ten thousand one-line hunks inside one block.
|
||||
lines: List[str] = ["--- a/big.py\n", "+++ b/big.py\n"]
|
||||
for i in range(10_000):
|
||||
lines.append(f"@@ -{i + 1},1 +{i + 1},1 @@\n")
|
||||
lines.append(f"-line_{i}_old\n")
|
||||
lines.append(f"+line_{i}_new\n")
|
||||
blob = "".join(lines)
|
||||
blocks = parse_unified_diff_stream(blob)
|
||||
assert len(blocks) == 1
|
||||
assert len(blocks[0].hunks) == 10_000
|
||||
|
||||
|
||||
def test_extract_new_blocks_stress_identity_diff() -> None:
|
||||
# extract_new_blocks compares via dataclass equality, so a thousand
|
||||
# identical blocks must return zero new blocks regardless of list
|
||||
# length (O(n*m) is acceptable for moderate sizes but must return
|
||||
# the right answer).
|
||||
one = parse_unified_diff_stream(_minimal_block("x.py"))
|
||||
assert len(one) == 1
|
||||
many = one * 100
|
||||
assert extract_new_blocks(many, many) == []
|
||||
|
||||
|
||||
def test_extract_new_blocks_detects_only_the_last_addition() -> None:
|
||||
prev = parse_unified_diff_stream(_minimal_block("a.py"))
|
||||
curr = parse_unified_diff_stream(_minimal_block("a.py") + _minimal_block("b.py"))
|
||||
new_blocks = extract_new_blocks(prev, curr)
|
||||
assert [b.path_before for b in new_blocks] == ["b.py"]
|
||||
|
||||
|
||||
def test_parser_ignores_noise_lines_between_blocks() -> None:
|
||||
# Pipe-pane gives us scrollback with agent prose + prompt chrome
|
||||
# mixed into the diff stream. Those must not corrupt the parse.
|
||||
blob = (
|
||||
"> Tool call: edit_file path=x.py\n"
|
||||
"Thinking: applying patch...\n"
|
||||
+ _minimal_block("x.py")
|
||||
+ "\n(3 hunks, 1 file changed)\n\n"
|
||||
+ _minimal_block("y.py")
|
||||
+ "\n> Tool call: confirm?\n"
|
||||
)
|
||||
blocks = parse_unified_diff_stream(blob)
|
||||
assert [b.path_before for b in blocks] == ["x.py", "y.py"]
|
||||
|
||||
|
||||
def test_concurrent_parse_returns_identical_results() -> None:
|
||||
# Pure-function concurrency guard: spin up two threads that each
|
||||
# parse the same large blob, confirm both see the same structured
|
||||
# output. This catches any global mutable state the parser might
|
||||
# introduce during future refactors.
|
||||
blob = _minimal_block("x.py") * 50 + _minimal_block("y.py") * 50
|
||||
expected = parse_unified_diff_stream(blob)
|
||||
|
||||
results: List[List[DiffBlock]] = [[], []]
|
||||
|
||||
def worker(idx: int) -> None:
|
||||
results[idx] = parse_unified_diff_stream(blob)
|
||||
|
||||
t1 = threading.Thread(target=worker, args=(0,))
|
||||
t2 = threading.Thread(target=worker, args=(1,))
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=10)
|
||||
t2.join(timeout=10)
|
||||
|
||||
assert results[0] == expected
|
||||
assert results[1] == expected
|
||||
|
||||
|
||||
def test_parser_tolerates_windows_line_endings() -> None:
|
||||
# Agents running on Windows hosts emit CRLF. Python's splitlines
|
||||
# handles it natively, but guard against a regression where a
|
||||
# trimmed `\r` leaks into a captured path.
|
||||
blob = "--- a/win.py\r\n+++ b/win.py\r\n@@ -1,1 +1,1 @@\r\n-x\r\n+y\r\n"
|
||||
blocks = parse_unified_diff_stream(blob)
|
||||
# Either the parser accepts CRLF transparently (one block with the
|
||||
# stripped path) or the agent-tmux watcher normalises upstream.
|
||||
# Whichever shape, the path_before must NOT carry a literal \r.
|
||||
for block in blocks:
|
||||
assert "\r" not in block.path_before
|
||||
assert "\r" not in block.path_after
|
||||
|
||||
|
||||
def test_dataclass_instances_are_hashable() -> None:
|
||||
# ``DiffBlock`` / ``DiffHunk`` are frozen dataclasses. Assert they
|
||||
# can live in a set so dedup pipelines using them don't regress to
|
||||
# TypeError.
|
||||
hunk = DiffHunk(
|
||||
before_start=1, before_count=1, after_start=1, after_count=1, body="@@ \n"
|
||||
)
|
||||
block = DiffBlock(path_before="a", path_after="a", hunks=(hunk,))
|
||||
assert {block} == {block}
|
||||
382
sublime/tests/test_agent_switcher_view.py
Normal file
382
sublime/tests/test_agent_switcher_view.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""Unit tests for :mod:`sessions.agent_switcher_view`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.agent_switcher_view import (
|
||||
NEW_PAIR_SENTINEL,
|
||||
SWITCHER_VIEW_SETTING_KEY,
|
||||
AgentPairSummary,
|
||||
SessionsAgentSwitcherClickListener,
|
||||
SessionsRenderAgentSwitcherCommand,
|
||||
dispatch_switcher_click,
|
||||
find_pair_at_line,
|
||||
render_switcher_body,
|
||||
)
|
||||
|
||||
|
||||
def _pair(
|
||||
pair_id: str,
|
||||
agent: str = "claude",
|
||||
*,
|
||||
attached: bool = False,
|
||||
active: bool = False,
|
||||
workspace: str = "repo",
|
||||
) -> AgentPairSummary:
|
||||
return AgentPairSummary(
|
||||
pair_id=pair_id,
|
||||
workspace_label=workspace,
|
||||
agent_label=agent,
|
||||
is_attached=attached,
|
||||
is_active=active,
|
||||
)
|
||||
|
||||
|
||||
def test_render_switcher_body_empty_list_has_menu_only() -> None:
|
||||
body = render_switcher_body([])
|
||||
lines = body.split("\n")
|
||||
assert len(lines) == 2
|
||||
assert "─" in lines[0]
|
||||
assert lines[1].strip() == "+ New agent session…"
|
||||
|
||||
|
||||
def test_render_switcher_body_includes_all_pairs_and_menu() -> None:
|
||||
pairs = [
|
||||
_pair("07c4844b:claude", agent="claude", active=True),
|
||||
_pair("07c4844b:codex", agent="codex", attached=True),
|
||||
_pair("a75c7f0f:claude", agent="claude"),
|
||||
]
|
||||
body = render_switcher_body(pairs)
|
||||
lines = body.split("\n")
|
||||
assert len(lines) == len(pairs) + 2 # sep + "+ New"
|
||||
# Active glyph is ● and lives on the active pair's row.
|
||||
assert "●" in lines[0]
|
||||
assert "○" in lines[1]
|
||||
assert "(active)" in lines[0]
|
||||
assert "[attached]" in lines[1]
|
||||
assert "(active)" not in lines[2]
|
||||
assert "[attached]" not in lines[2]
|
||||
assert lines[-1].strip().startswith("+ New agent session")
|
||||
|
||||
|
||||
def test_render_switcher_body_truncates_long_cache_key() -> None:
|
||||
body = render_switcher_body([_pair("0123456789abcdef0123:claude", agent="claude")])
|
||||
first = body.split("\n")[0]
|
||||
# Cache key column ends up with the 8-char prefix, not the full hash.
|
||||
assert "01234567" in first
|
||||
assert "89abcdef" not in first
|
||||
|
||||
|
||||
def test_render_switcher_body_both_active_and_attached() -> None:
|
||||
body = render_switcher_body([_pair("abc:claude", active=True, attached=True)])
|
||||
first_line = body.split("\n")[0]
|
||||
assert "(active)" in first_line
|
||||
assert "[attached]" in first_line
|
||||
|
||||
|
||||
def test_render_switcher_body_contains_no_emojis() -> None:
|
||||
body = render_switcher_body([_pair("abc:claude", active=True, attached=True)])
|
||||
# ASCII-only policy from user memory: reject the common culprits.
|
||||
for ch in body:
|
||||
assert ord(ch) < 0x1F000, "unexpected emoji {!r} in body".format(ch)
|
||||
|
||||
|
||||
def test_find_pair_at_line_resolves_rows_to_pair_ids() -> None:
|
||||
pairs = [
|
||||
_pair("07c4844b:claude"),
|
||||
_pair("a75c7f0f:codex"),
|
||||
]
|
||||
assert find_pair_at_line(0, pairs) == "07c4844b:claude"
|
||||
assert find_pair_at_line(1, pairs) == "a75c7f0f:codex"
|
||||
|
||||
|
||||
def test_find_pair_at_line_returns_none_for_separator() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
# Pair on row 0, separator on row 1, "+ New" on row 2.
|
||||
assert find_pair_at_line(1, pairs) is None
|
||||
|
||||
|
||||
def test_find_pair_at_line_resolves_new_sentinel() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
assert find_pair_at_line(2, pairs) == NEW_PAIR_SENTINEL
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idx", [-1, -10, 99])
|
||||
def test_find_pair_at_line_out_of_range_is_none(idx: int) -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
assert find_pair_at_line(idx, pairs) is None
|
||||
|
||||
|
||||
def test_find_pair_at_line_empty_list_only_has_new_on_row_1() -> None:
|
||||
assert find_pair_at_line(0, []) is None # separator
|
||||
assert find_pair_at_line(1, []) == NEW_PAIR_SENTINEL
|
||||
assert find_pair_at_line(2, []) is None
|
||||
|
||||
|
||||
class _FakeSettings:
|
||||
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
|
||||
self._data: Dict[str, Any] = dict(data or {})
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._data.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
self._data[key] = value
|
||||
|
||||
|
||||
class _FakeView:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Optional[Dict[str, Any]] = None,
|
||||
window: Optional[object] = None,
|
||||
row_for_point: Optional[Dict[int, int]] = None,
|
||||
) -> None:
|
||||
self._settings = _FakeSettings(settings)
|
||||
self._window = window
|
||||
self._row_for_point: Dict[int, int] = dict(row_for_point or {})
|
||||
|
||||
def settings(self) -> _FakeSettings:
|
||||
return self._settings
|
||||
|
||||
def window(self) -> Optional[object]:
|
||||
return self._window
|
||||
|
||||
def window_to_text(self, xy: Tuple[int, int]) -> int:
|
||||
# Tests feed a known point back via ``event.x == y``; we use a
|
||||
# tiny identity map so dispatch tests can control the row.
|
||||
return xy[0]
|
||||
|
||||
def rowcol(self, point: int) -> Tuple[int, int]:
|
||||
row = self._row_for_point.get(point, point)
|
||||
return (row, 0)
|
||||
|
||||
|
||||
class _FakeWindow:
|
||||
def __init__(self) -> None:
|
||||
self.run_command_calls: List[Tuple[str, Mapping[str, Any]]] = []
|
||||
|
||||
def run_command(self, name: str, args: Optional[Mapping[str, Any]] = None) -> None:
|
||||
self.run_command_calls.append((name, dict(args or {})))
|
||||
|
||||
|
||||
def test_dispatch_switcher_click_returns_switch_command_for_pair_row() -> None:
|
||||
pairs = [_pair("07c4844b:claude"), _pair("a75c7f0f:codex")]
|
||||
view = _FakeView(row_for_point={10: 1})
|
||||
result = dispatch_switcher_click(view, {"x": 10, "y": 10}, pairs)
|
||||
assert result is not None
|
||||
assert result["command"] == "sessions_switch_agent_session"
|
||||
assert result["args"] == {"pair_id": "a75c7f0f:codex"}
|
||||
|
||||
|
||||
def test_dispatch_switcher_click_returns_new_session_for_sentinel_row() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
# Sentinel row (index 2 → point 2 → rowcol fallback).
|
||||
view = _FakeView()
|
||||
result = dispatch_switcher_click(view, {"x": 2, "y": 2}, pairs)
|
||||
assert result is not None
|
||||
assert result["command"] == "sessions_new_agent_session"
|
||||
assert result["args"] == {}
|
||||
|
||||
|
||||
def test_dispatch_switcher_click_none_for_separator_row() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
view = _FakeView(row_for_point={5: 1}) # row 1 == separator
|
||||
result = dispatch_switcher_click(view, {"x": 5, "y": 5}, pairs)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_dispatch_switcher_click_none_without_coordinates() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
view = _FakeView()
|
||||
assert dispatch_switcher_click(view, {"x": None, "y": None}, pairs) is None
|
||||
assert dispatch_switcher_click(view, {}, pairs) is None
|
||||
|
||||
|
||||
def test_listener_ignores_non_switcher_views() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
view = _FakeView(settings={SWITCHER_VIEW_SETTING_KEY: False})
|
||||
# Should be a silent no-op even though drag_select matches.
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 1, "y": 1}})
|
||||
|
||||
|
||||
def test_listener_ignores_non_drag_select_commands() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
view = _FakeView(settings={SWITCHER_VIEW_SETTING_KEY: True})
|
||||
listener.on_text_command(view, "move", {"event": {"x": 1, "y": 1}})
|
||||
|
||||
|
||||
def test_listener_fires_switch_command_on_pair_row_click() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
pairs_raw = [
|
||||
{
|
||||
"pair_id": "07c4844b:claude",
|
||||
"workspace_label": "repo",
|
||||
"agent_label": "claude",
|
||||
"is_attached": False,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"pair_id": "a75c7f0f:codex",
|
||||
"workspace_label": "repo",
|
||||
"agent_label": "codex",
|
||||
"is_attached": True,
|
||||
"is_active": False,
|
||||
},
|
||||
]
|
||||
view = _FakeView(
|
||||
settings={
|
||||
SWITCHER_VIEW_SETTING_KEY: True,
|
||||
"sessions_agent_pairs": pairs_raw,
|
||||
},
|
||||
window=window,
|
||||
row_for_point={42: 1},
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 42, "y": 42}})
|
||||
assert window.run_command_calls == [
|
||||
(
|
||||
"sessions_switch_agent_session",
|
||||
{"pair_id": "a75c7f0f:codex"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_listener_fires_new_session_on_plus_row() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
pairs_raw = [
|
||||
{
|
||||
"pair_id": "07c4844b:claude",
|
||||
"workspace_label": "repo",
|
||||
"agent_label": "claude",
|
||||
"is_attached": False,
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
view = _FakeView(
|
||||
settings={
|
||||
SWITCHER_VIEW_SETTING_KEY: True,
|
||||
"sessions_agent_pairs": pairs_raw,
|
||||
},
|
||||
window=window,
|
||||
row_for_point={7: 2}, # row 2 == "+ New"
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 7, "y": 7}})
|
||||
assert window.run_command_calls == [("sessions_new_agent_session", {})]
|
||||
|
||||
|
||||
def test_listener_swallows_click_when_pairs_cache_missing() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
view = _FakeView(
|
||||
settings={SWITCHER_VIEW_SETTING_KEY: True},
|
||||
window=window,
|
||||
row_for_point={0: 0},
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 0, "y": 0}})
|
||||
assert window.run_command_calls == []
|
||||
|
||||
|
||||
def test_listener_swallows_click_on_separator() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
pairs_raw = [
|
||||
{
|
||||
"pair_id": "07c4844b:claude",
|
||||
"workspace_label": "repo",
|
||||
"agent_label": "claude",
|
||||
"is_attached": False,
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
view = _FakeView(
|
||||
settings={
|
||||
SWITCHER_VIEW_SETTING_KEY: True,
|
||||
"sessions_agent_pairs": pairs_raw,
|
||||
},
|
||||
window=window,
|
||||
row_for_point={9: 1}, # separator row
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 9, "y": 9}})
|
||||
assert window.run_command_calls == []
|
||||
|
||||
|
||||
class _RenderableView:
|
||||
def __init__(self, initial: str = "") -> None:
|
||||
self._text = initial
|
||||
self.read_only_history: List[bool] = []
|
||||
self.inserts: List[Tuple[int, str]] = []
|
||||
self.erase_calls: List[Any] = []
|
||||
|
||||
def set_read_only(self, flag: bool) -> None:
|
||||
self.read_only_history.append(flag)
|
||||
|
||||
def size(self) -> int:
|
||||
return len(self._text)
|
||||
|
||||
def erase(self, edit: object, region: Any) -> None:
|
||||
self.erase_calls.append(region)
|
||||
self._text = ""
|
||||
|
||||
def insert(self, edit: object, point: int, value: str) -> None:
|
||||
self.inserts.append((point, value))
|
||||
self._text = value
|
||||
|
||||
|
||||
def test_render_command_replaces_body_and_toggles_read_only() -> None:
|
||||
view = _RenderableView(initial="stale content")
|
||||
command = SessionsRenderAgentSwitcherCommand.__new__(
|
||||
SessionsRenderAgentSwitcherCommand
|
||||
)
|
||||
command.view = view # type: ignore[attr-defined]
|
||||
command.run(edit=object(), body="fresh\nbody")
|
||||
# Read-only toggle: False during edit, True at the end.
|
||||
assert view.read_only_history == [False, True]
|
||||
assert view.erase_calls, "erase should have been invoked"
|
||||
assert view.inserts == [(0, "fresh\nbody")]
|
||||
|
||||
|
||||
def test_render_command_noop_without_view() -> None:
|
||||
command = SessionsRenderAgentSwitcherCommand.__new__(
|
||||
SessionsRenderAgentSwitcherCommand
|
||||
)
|
||||
# No view attached at all; run must not raise.
|
||||
command.run(edit=object(), body="whatever")
|
||||
|
||||
|
||||
def test_render_command_skips_edit_when_view_missing_methods() -> None:
|
||||
class _HalfView:
|
||||
def __init__(self) -> None:
|
||||
self.read_only_history: List[bool] = []
|
||||
|
||||
def set_read_only(self, flag: bool) -> None:
|
||||
self.read_only_history.append(flag)
|
||||
|
||||
view = _HalfView()
|
||||
command = SessionsRenderAgentSwitcherCommand.__new__(
|
||||
SessionsRenderAgentSwitcherCommand
|
||||
)
|
||||
command.view = view # type: ignore[attr-defined]
|
||||
command.run(edit=object(), body="x")
|
||||
# set_read_only should still run both False and True so the buffer
|
||||
# doesn't end up stuck in a writable state if methods are missing.
|
||||
assert view.read_only_history == [False, True]
|
||||
|
||||
|
||||
def test_cached_pairs_returns_none_for_bad_schema_entries() -> None:
|
||||
# Trigger the listener path when pair entries miss ``pair_id``.
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
view = _FakeView(
|
||||
settings={
|
||||
SWITCHER_VIEW_SETTING_KEY: True,
|
||||
"sessions_agent_pairs": [{"workspace_label": "x"}], # no pair_id
|
||||
},
|
||||
window=window,
|
||||
row_for_point={0: 0},
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 0, "y": 0}})
|
||||
assert window.run_command_calls == []
|
||||
315
sublime/tests/test_agent_tmux.py
Normal file
315
sublime/tests/test_agent_tmux.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Unit tests for the ``agent_tmux`` tmux session broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from types import SimpleNamespace
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.agent_tmux import (
|
||||
AgentTmuxBroker,
|
||||
AgentTmuxError,
|
||||
TmuxAgentSession,
|
||||
_build_session_name,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RunRecorder:
|
||||
"""Record subprocess.run calls and replay scripted responses in order."""
|
||||
|
||||
def __init__(self, responses: List[Tuple[int, str, str]]) -> None:
|
||||
self._responses = list(responses)
|
||||
self.calls: List[List[str]] = []
|
||||
|
||||
def __call__(self, argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
self.calls.append(list(argv))
|
||||
if not self._responses:
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
rc, out, err = self._responses.pop(0)
|
||||
return SimpleNamespace(returncode=rc, stdout=out, stderr=err)
|
||||
|
||||
|
||||
def _ssh_builder(alias: str) -> List[str]:
|
||||
return ["ssh", "-F", "/fake/config", alias]
|
||||
|
||||
|
||||
def _broker(
|
||||
responses: List[Tuple[int, str, str]],
|
||||
) -> Tuple[AgentTmuxBroker, _RunRecorder]:
|
||||
run = _RunRecorder(responses)
|
||||
broker = AgentTmuxBroker(ssh_command_builder=_ssh_builder, run=run)
|
||||
return broker, run
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-name construction + validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_session_name_uses_eight_char_prefix_and_agent_id() -> None:
|
||||
assert (
|
||||
_build_session_name("07c4844b-abcdef1234567890", "claude")
|
||||
== "sessions-agent-07c4844b-claude"
|
||||
)
|
||||
|
||||
|
||||
def test_plan_rejects_agent_id_with_shell_metachars() -> None:
|
||||
broker, _ = _broker([])
|
||||
with pytest.raises(AgentTmuxError):
|
||||
broker.plan("dev", "07c4844b", "claude; rm -rf ~", ["claude"])
|
||||
|
||||
|
||||
def test_plan_rejects_workspace_cache_key_with_space() -> None:
|
||||
broker, _ = _broker([])
|
||||
with pytest.raises(AgentTmuxError):
|
||||
broker.plan("dev", "a b c", "claude", ["claude"])
|
||||
|
||||
|
||||
def test_plan_rejects_empty_agent_cmd() -> None:
|
||||
broker, _ = _broker([])
|
||||
with pytest.raises(AgentTmuxError):
|
||||
broker.plan("dev", "07c4844b", "claude", [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan() output shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_plan_builds_attach_argv_with_ssh_prefix() -> None:
|
||||
broker, _ = _broker([])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude"])
|
||||
assert isinstance(session, TmuxAgentSession)
|
||||
assert session.session_name == "sessions-agent-07c4844b-claude"
|
||||
assert session.attach_argv == (
|
||||
"ssh",
|
||||
"-F",
|
||||
"/fake/config",
|
||||
"dev",
|
||||
"tmux",
|
||||
"attach",
|
||||
"-t",
|
||||
"sessions-agent-07c4844b-claude",
|
||||
)
|
||||
|
||||
|
||||
def test_plan_builds_spawn_argv_as_bash_lc_new_session_command() -> None:
|
||||
broker, _ = _broker([])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude", "--verbose"])
|
||||
assert session.spawn_argv[:6] == (
|
||||
"ssh",
|
||||
"-F",
|
||||
"/fake/config",
|
||||
"dev",
|
||||
"bash",
|
||||
"-lc",
|
||||
)
|
||||
remote_cmd = session.spawn_argv[6]
|
||||
assert remote_cmd.startswith(
|
||||
"tmux new-session -A -d -s sessions-agent-07c4844b-claude -- "
|
||||
)
|
||||
assert "claude --verbose" in remote_cmd
|
||||
# ``</dev/null`` is required so tmux 3.x doesn't probe the inherited
|
||||
# stdin and emit ``open terminal failed: not a terminal`` even with
|
||||
# ``-d``. See agent_tmux.plan() commentary.
|
||||
assert remote_cmd.endswith(" </dev/null")
|
||||
|
||||
|
||||
def test_plan_default_ssh_builder_passes_dash_T_to_disable_pty() -> None:
|
||||
"""The shipped broker must explicitly disable PTY allocation.
|
||||
|
||||
OpenSSH's default of "no TTY when a remote command is given" is fine
|
||||
on the happy path, but a stray ``RequestTTY=yes`` in the user's
|
||||
``~/.ssh/config`` (or ``Host *`` block) would otherwise force a PTY
|
||||
and recreate the original ``not a terminal`` failure even with
|
||||
``-d``. The broker pins ``-T`` to make the no-TTY contract explicit.
|
||||
"""
|
||||
from sessions.agent_tmux import _default_ssh_command_builder
|
||||
|
||||
assert _default_ssh_command_builder("aws-celery") == [
|
||||
"ssh",
|
||||
"-T",
|
||||
"aws-celery",
|
||||
]
|
||||
|
||||
|
||||
def test_plan_expands_tilde_paths_in_agent_cmd() -> None:
|
||||
broker, _ = _broker([])
|
||||
session = broker.plan(
|
||||
"dev", "07c4844bdeadbeef", "claude", ["~/bin/claude", "--model=opus"]
|
||||
)
|
||||
remote_cmd = session.spawn_argv[6]
|
||||
# ``~/bin/claude`` should have been rewritten to ``"$HOME/bin/claude"`` so
|
||||
# the remote shell expands $HOME rather than treating ``~`` as a literal.
|
||||
assert '"$HOME/bin/claude"' in remote_cmd
|
||||
assert "--model=opus" in remote_cmd
|
||||
|
||||
|
||||
def test_plan_result_is_frozen() -> None:
|
||||
broker, _ = _broker([])
|
||||
session = broker.plan("dev", "07c4844b", "claude", ["claude"])
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
session.session_name = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_running
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_running_returns_true_on_zero_exit() -> None:
|
||||
broker, run = _broker([(0, "", "")])
|
||||
assert broker.is_running("dev", "sessions-agent-07c4844b-claude") is True
|
||||
assert run.calls[0][-4:] == [
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
"sessions-agent-07c4844b-claude",
|
||||
]
|
||||
|
||||
|
||||
def test_is_running_returns_false_on_nonzero_exit() -> None:
|
||||
broker, _ = _broker([(1, "", "can't find session")])
|
||||
assert broker.is_running("dev", "sessions-agent-07c4844b-claude") is False
|
||||
|
||||
|
||||
def test_is_running_returns_false_when_tmux_missing() -> None:
|
||||
broker, _ = _broker([(127, "", "tmux: command not found")])
|
||||
assert broker.is_running("dev", "sessions-agent-07c4844b-claude") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# attach_or_spawn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_or_spawn_is_noop_when_already_running() -> None:
|
||||
broker, run = _broker([(0, "", "")])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude"])
|
||||
broker.attach_or_spawn(session)
|
||||
# Only the has-session probe ran; no second call to spawn.
|
||||
assert len(run.calls) == 1
|
||||
assert run.calls[0][-3:-1] == ["has-session", "-t"]
|
||||
|
||||
|
||||
def test_attach_or_spawn_spawns_when_missing() -> None:
|
||||
broker, run = _broker([(1, "", "no server"), (0, "", "")])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude"])
|
||||
broker.attach_or_spawn(session)
|
||||
assert len(run.calls) == 2
|
||||
spawn_argv = run.calls[1]
|
||||
assert spawn_argv[:6] == ["ssh", "-F", "/fake/config", "dev", "bash", "-lc"]
|
||||
remote_cmd = spawn_argv[6]
|
||||
# Both the detached-create flag AND the stdin redirect must be in
|
||||
# the spawned command; missing either causes the "open terminal
|
||||
# failed: not a terminal" regression on no-TTY hosts.
|
||||
assert "tmux new-session -A -d -s sessions-agent-07c4844b-claude" in remote_cmd
|
||||
assert remote_cmd.endswith(" </dev/null")
|
||||
|
||||
|
||||
def test_attach_or_spawn_raises_on_spawn_failure() -> None:
|
||||
broker, _ = _broker([(1, "", "no server"), (2, "", "tmux session foo bar")])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude"])
|
||||
with pytest.raises(AgentTmuxError, match="tmux spawn"):
|
||||
broker.attach_or_spawn(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_sessions_filters_to_sessions_owned_prefix() -> None:
|
||||
stdout = (
|
||||
"sessions-agent-07c4844b-claude\n"
|
||||
"sessions-agent-a75c7f0f-codex\n"
|
||||
"random-user-session\n"
|
||||
)
|
||||
broker, _ = _broker([(0, stdout, "")])
|
||||
assert broker.list_sessions("dev") == [
|
||||
"sessions-agent-07c4844b-claude",
|
||||
"sessions-agent-a75c7f0f-codex",
|
||||
]
|
||||
|
||||
|
||||
def test_list_sessions_returns_empty_when_no_sessions() -> None:
|
||||
broker, _ = _broker([(1, "", "no server running on /tmp/tmux-1000/default")])
|
||||
assert broker.list_sessions("dev") == []
|
||||
|
||||
|
||||
def test_list_sessions_returns_empty_when_tmux_missing() -> None:
|
||||
broker, _ = _broker([(127, "", "bash: tmux: command not found")])
|
||||
assert broker.list_sessions("dev") == []
|
||||
|
||||
|
||||
def test_list_sessions_raises_on_unknown_failure() -> None:
|
||||
broker, _ = _broker([(255, "", "ssh: connection refused")])
|
||||
with pytest.raises(AgentTmuxError, match="list-sessions"):
|
||||
broker.list_sessions("dev")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kill / shutdown_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kill_tolerates_session_not_found() -> None:
|
||||
broker, _ = _broker([(1, "", "can't find session: sessions-agent-xxx")])
|
||||
broker.kill("dev", "sessions-agent-xxx-claude") # no exception
|
||||
|
||||
|
||||
def test_kill_raises_on_other_failures() -> None:
|
||||
broker, _ = _broker([(255, "", "ssh: connection refused")])
|
||||
with pytest.raises(AgentTmuxError, match="kill-session"):
|
||||
broker.kill("dev", "sessions-agent-xxx-claude")
|
||||
|
||||
|
||||
def test_shutdown_all_iterates_every_session() -> None:
|
||||
responses = [
|
||||
(
|
||||
0,
|
||||
"sessions-agent-07c4844b-claude\nsessions-agent-a75c7f0f-codex\n",
|
||||
"",
|
||||
),
|
||||
(0, "", ""), # kill 1
|
||||
(0, "", ""), # kill 2
|
||||
]
|
||||
broker, run = _broker(responses)
|
||||
broker.shutdown_all("dev")
|
||||
# One list-sessions + two kills.
|
||||
assert len(run.calls) == 3
|
||||
kill_targets = [call[-1] for call in run.calls[1:]]
|
||||
assert kill_targets == [
|
||||
"sessions-agent-07c4844b-claude",
|
||||
"sessions-agent-a75c7f0f-codex",
|
||||
]
|
||||
|
||||
|
||||
def test_shutdown_all_best_effort_on_individual_kill_failure(caplog) -> None:
|
||||
responses = [
|
||||
(0, "sessions-agent-07c4844b-claude\nsessions-agent-a75c7f0f-codex\n", ""),
|
||||
(255, "", "ssh: connection reset"), # kill 1 fails hard
|
||||
(0, "", ""), # kill 2 succeeds
|
||||
]
|
||||
broker, run = _broker(responses)
|
||||
with caplog.at_level("WARNING", logger="sessions.agent_tmux"):
|
||||
broker.shutdown_all("dev")
|
||||
# Both kills still attempted despite the first failing.
|
||||
assert len(run.calls) == 3
|
||||
assert any(
|
||||
"kill sessions-agent-07c4844b-claude" in rec.message for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_shutdown_all_best_effort_when_list_fails(caplog) -> None:
|
||||
broker, run = _broker([(255, "", "ssh: connection refused")])
|
||||
with caplog.at_level("WARNING", logger="sessions.agent_tmux"):
|
||||
broker.shutdown_all("dev")
|
||||
# list_sessions failed; no kills attempted.
|
||||
assert len(run.calls) == 1
|
||||
assert any("list_sessions" in rec.message for rec in caplog.records)
|
||||
213
sublime/tests/test_agent_tmux_real_subprocess.py
Normal file
213
sublime/tests/test_agent_tmux_real_subprocess.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Real-subprocess smoke tests for :class:`AgentTmuxBroker`.
|
||||
|
||||
Pattern mirrors ``test_integration_remote_file_ops`` — a ``/bin/sh``
|
||||
shim stands in for ``ssh`` and translates the broker's argv into
|
||||
scripted exit codes + canned stdout so the whole broker flow runs
|
||||
end-to-end without touching a real remote host.
|
||||
|
||||
Each test spins up a fresh fake-ssh directory, constructs the broker
|
||||
with its default ``subprocess.run`` (no injected recorder), drives one
|
||||
method, and asserts against the real process exit code / stdout. No
|
||||
``subprocess.Popen`` stubs, no ``FakeLib`` — this suite's value is
|
||||
that it catches regressions the mock-only unit tests cannot, e.g. an
|
||||
argv ordering bug or a bash quoting break.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.agent_tmux import AgentTmuxBroker, AgentTmuxError
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="fake-ssh shim is /bin/sh only — Windows equivalent is Track W1.",
|
||||
)
|
||||
|
||||
|
||||
def _write_fake_ssh(
|
||||
dir_path: Path,
|
||||
*,
|
||||
has_session_exit: int = 0,
|
||||
list_sessions_stdout: str = "",
|
||||
kill_session_exit: int = 0,
|
||||
new_session_exit: int = 0,
|
||||
new_session_stderr: str = "",
|
||||
) -> Path:
|
||||
"""Install a ``/bin/sh`` ``ssh`` shim that routes broker argv to canned output.
|
||||
|
||||
The broker always calls us as::
|
||||
|
||||
ssh <alias> tmux <subcmd> [args...]
|
||||
|
||||
``<alias>`` is the first positional arg; everything after it is the
|
||||
remote command. The shim discards the alias and dispatches on the
|
||||
first remote token. Canned stdout / stderr are written to sibling
|
||||
files and ``cat``-ed out so embedded newlines survive a round-trip
|
||||
through the shell's single-line argv.
|
||||
"""
|
||||
stdout_file = dir_path / "list_sessions_stdout.txt"
|
||||
stdout_file.write_text(list_sessions_stdout, encoding="utf-8")
|
||||
stderr_file = dir_path / "spawn_stderr.txt"
|
||||
stderr_file.write_text(new_session_stderr, encoding="utf-8")
|
||||
|
||||
script = dir_path / "ssh"
|
||||
# The shim shifts past the alias, then runs a per-subcommand case.
|
||||
# Using subprocess.Popen through a /bin/sh marker keeps the classifier
|
||||
# on "real-subprocess" even if future edits drop the literal shebang.
|
||||
script.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# test shim — subprocess.Popen-equivalent routing\n"
|
||||
"# Drop any leading ssh option flags (e.g. ``-T`` to disable PTY\n"
|
||||
"# allocation) before consuming the alias positional.\n"
|
||||
"while [ $# -gt 0 ]; do\n"
|
||||
' case "$1" in\n'
|
||||
" -[A-Za-z])\n"
|
||||
" shift\n"
|
||||
" ;;\n"
|
||||
" *)\n"
|
||||
" break\n"
|
||||
" ;;\n"
|
||||
" esac\n"
|
||||
"done\n"
|
||||
"shift # drop alias\n"
|
||||
'sub=""\n'
|
||||
'if [ $# -ge 2 ]; then sub="$2"; fi\n'
|
||||
'case "$sub" in\n'
|
||||
" has-session)\n"
|
||||
f" exit {has_session_exit}\n"
|
||||
" ;;\n"
|
||||
" list-sessions)\n"
|
||||
f" cat {stdout_file}\n"
|
||||
" exit 0\n"
|
||||
" ;;\n"
|
||||
" kill-session)\n"
|
||||
f" exit {kill_session_exit}\n"
|
||||
" ;;\n"
|
||||
" new-session)\n"
|
||||
f" cat {stderr_file} >&2\n"
|
||||
f" exit {new_session_exit}\n"
|
||||
" ;;\n"
|
||||
" *)\n"
|
||||
" # bash -lc fallback for the spawn path; evaluate as shell\n"
|
||||
' if [ "$1" = "bash" ] && [ "$2" = "-lc" ]; then\n'
|
||||
f" cat {stderr_file} >&2\n"
|
||||
f" exit {new_session_exit}\n"
|
||||
" fi\n"
|
||||
' echo "unexpected tmux subcommand: $*" >&2\n'
|
||||
" exit 2\n"
|
||||
" ;;\n"
|
||||
"esac\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
script.chmod(0o755)
|
||||
return script
|
||||
|
||||
|
||||
def _with_fake_ssh_on_path(tmp_path: Path, **shim_kwargs: object) -> Tuple[Path, str]:
|
||||
"""Install the fake-ssh into ``tmp_path`` and prepend its dir to PATH.
|
||||
|
||||
Returns ``(fake_dir, saved_path)`` so the caller can restore ``PATH``.
|
||||
"""
|
||||
fake_bin = tmp_path / "fakebin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_ssh(fake_bin, **shim_kwargs) # type: ignore[arg-type]
|
||||
saved_path = os.environ.get("PATH", "")
|
||||
os.environ["PATH"] = "{}:{}".format(fake_bin, saved_path)
|
||||
return fake_bin, saved_path
|
||||
|
||||
|
||||
def test_is_running_returns_true_when_fake_ssh_exits_zero(tmp_path: Path) -> None:
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, has_session_exit=0)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
assert broker.is_running("dev", "sessions-agent-abc-claude") is True
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_is_running_returns_false_when_fake_ssh_exits_nonzero(tmp_path: Path) -> None:
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, has_session_exit=1)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
assert broker.is_running("dev", "sessions-agent-abc-claude") is False
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_list_sessions_parses_tmux_output_and_filters_prefix(tmp_path: Path) -> None:
|
||||
# Mixed output — two Sessions-owned sessions + one user session that
|
||||
# must be filtered out.
|
||||
canned = (
|
||||
"sessions-agent-deadbeef-claude\n"
|
||||
"my-manual-session\n"
|
||||
"sessions-agent-cafef00d-codex\n"
|
||||
)
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, list_sessions_stdout=canned)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
sessions = broker.list_sessions("dev")
|
||||
assert sessions == [
|
||||
"sessions-agent-deadbeef-claude",
|
||||
"sessions-agent-cafef00d-codex",
|
||||
]
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_list_sessions_empty_on_no_server_running(tmp_path: Path) -> None:
|
||||
# tmux exits 1 with "no server running" when no sessions exist — our
|
||||
# /bin/sh shim returns a well-formed 0-exit empty stdout, which the
|
||||
# broker reads as "empty session list" without raising.
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, list_sessions_stdout="")
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
assert broker.list_sessions("dev") == []
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_kill_session_swallows_zero_exit(tmp_path: Path) -> None:
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, kill_session_exit=0)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
broker.kill("dev", "sessions-agent-abc-claude") # must not raise
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_attach_or_spawn_raises_on_nonzero_spawn(tmp_path: Path) -> None:
|
||||
# has-session returns 1 (not running), so attach_or_spawn proceeds to
|
||||
# the spawn path; the shim routes that to bash -lc and simulates a
|
||||
# hard failure by exit 2 + a stderr message.
|
||||
_, saved_path = _with_fake_ssh_on_path(
|
||||
tmp_path,
|
||||
has_session_exit=1,
|
||||
new_session_exit=2,
|
||||
new_session_stderr="boom",
|
||||
)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
plan = broker.plan("dev", "cache-xyz", "claude", ("claude",))
|
||||
with pytest.raises(AgentTmuxError, match="tmux spawn"):
|
||||
broker.attach_or_spawn(plan)
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_attach_or_spawn_is_noop_when_already_running(tmp_path: Path) -> None:
|
||||
_, saved_path = _with_fake_ssh_on_path(
|
||||
tmp_path,
|
||||
has_session_exit=0, # is_running returns True -> skip spawn
|
||||
new_session_exit=77, # would blow up if spawn actually ran
|
||||
)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
plan = broker.plan("dev", "cache-xyz", "claude", ("claude",))
|
||||
broker.attach_or_spawn(plan) # must not raise
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
231
sublime/tests/test_agent_window_layout.py
Normal file
231
sublime/tests/test_agent_window_layout.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Unit tests for :mod:`sessions.agent_window_layout`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.agent_window_layout import (
|
||||
LAYOUT_ID_OTHER,
|
||||
LAYOUT_ID_THREE_GROUP,
|
||||
LAYOUT_ID_TWO_GROUP,
|
||||
LAYOUT_STATE_KEY,
|
||||
SessionsAgentLayoutCollapseSwitcherCommand,
|
||||
SessionsAgentLayoutCommand,
|
||||
build_three_group_layout,
|
||||
build_two_group_layout,
|
||||
current_layout_id,
|
||||
read_stored_layout_id,
|
||||
write_stored_layout_id,
|
||||
)
|
||||
|
||||
|
||||
class _FakeWindow:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
layout: Optional[Dict[str, Any]] = None,
|
||||
project_data: Optional[Dict[str, Any]] = None,
|
||||
has_set_project_data: bool = True,
|
||||
) -> None:
|
||||
self._layout = layout
|
||||
self._project_data = project_data
|
||||
self._set_layout_calls: List[Dict[str, Any]] = []
|
||||
self._set_project_data_calls: List[Dict[str, Any]] = []
|
||||
if not has_set_project_data:
|
||||
self.set_project_data = None # type: ignore[assignment]
|
||||
|
||||
def get_layout(self) -> Optional[Dict[str, Any]]:
|
||||
return self._layout
|
||||
|
||||
def set_layout(self, layout: Dict[str, Any]) -> None:
|
||||
self._set_layout_calls.append(layout)
|
||||
self._layout = layout
|
||||
|
||||
def project_data(self) -> Optional[Dict[str, Any]]:
|
||||
return self._project_data
|
||||
|
||||
def set_project_data(self, data: Dict[str, Any]) -> None: # type: ignore[no-redef]
|
||||
self._set_project_data_calls.append(data)
|
||||
self._project_data = data
|
||||
|
||||
|
||||
def test_build_three_group_layout_shape() -> None:
|
||||
layout = build_three_group_layout(0.4, 0.8)
|
||||
assert layout["cols"] == [0.0, 0.4, 0.8, 1.0]
|
||||
assert layout["rows"] == [0.0, 1.0]
|
||||
assert layout["cells"] == [[0, 0, 1, 1], [1, 0, 2, 1], [2, 0, 3, 1]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"editor, terminus, expected_editor, expected_terminus",
|
||||
[
|
||||
(0.4, 0.8, 0.4, 0.8),
|
||||
# Inverted input — sanitizer swaps them into monotonic order.
|
||||
(0.9, 0.2, 0.85, 0.95),
|
||||
# Equal input — nudge terminus right to keep both visible.
|
||||
(0.5, 0.5, 0.5, 0.6),
|
||||
# Out-of-range clamped.
|
||||
(-0.1, 1.5, 0.05, 0.95),
|
||||
],
|
||||
)
|
||||
def test_build_three_group_layout_sanitizes_fractions(
|
||||
editor: float,
|
||||
terminus: float,
|
||||
expected_editor: float,
|
||||
expected_terminus: float,
|
||||
) -> None:
|
||||
layout = build_three_group_layout(editor, terminus)
|
||||
cols = layout["cols"]
|
||||
assert cols[0] == 0.0
|
||||
assert cols[-1] == 1.0
|
||||
assert pytest.approx(cols[1]) == expected_editor
|
||||
assert pytest.approx(cols[2]) == expected_terminus
|
||||
|
||||
|
||||
def test_build_two_group_layout_collapses_switcher() -> None:
|
||||
layout = build_two_group_layout(0.5)
|
||||
assert layout["cols"] == [0.0, 0.5, 1.0]
|
||||
assert layout["rows"] == [0.0, 1.0]
|
||||
assert layout["cells"] == [[0, 0, 1, 1], [1, 0, 2, 1]]
|
||||
|
||||
|
||||
def test_build_two_group_layout_clamps_extreme_editor_frac() -> None:
|
||||
layout = build_two_group_layout(0.0)
|
||||
assert pytest.approx(layout["cols"][1]) == 0.05
|
||||
layout = build_two_group_layout(1.2)
|
||||
assert pytest.approx(layout["cols"][1]) == 0.95
|
||||
|
||||
|
||||
def test_current_layout_id_detects_three_group_shape() -> None:
|
||||
window = _FakeWindow(layout=build_three_group_layout())
|
||||
assert current_layout_id(window) == LAYOUT_ID_THREE_GROUP
|
||||
|
||||
|
||||
def test_current_layout_id_detects_two_group_shape() -> None:
|
||||
window = _FakeWindow(layout=build_two_group_layout())
|
||||
assert current_layout_id(window) == LAYOUT_ID_TWO_GROUP
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout",
|
||||
[
|
||||
None,
|
||||
{},
|
||||
{"cells": "bogus", "rows": [0.0, 1.0]},
|
||||
{"cells": [[0, 0, 1, 1]], "rows": [0.0, 0.5, 1.0]}, # two rows
|
||||
# Four groups — not one of ours.
|
||||
{
|
||||
"cols": [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [
|
||||
[0, 0, 1, 1],
|
||||
[1, 0, 2, 1],
|
||||
[2, 0, 3, 1],
|
||||
[3, 0, 4, 1],
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_current_layout_id_other_for_non_matching_layouts(
|
||||
layout: Optional[Dict[str, Any]],
|
||||
) -> None:
|
||||
window = _FakeWindow(layout=layout)
|
||||
assert current_layout_id(window) == LAYOUT_ID_OTHER
|
||||
|
||||
|
||||
def test_current_layout_id_handles_missing_get_layout() -> None:
|
||||
assert current_layout_id(object()) == LAYOUT_ID_OTHER
|
||||
|
||||
|
||||
def test_current_layout_id_normalizes_tuple_cells() -> None:
|
||||
window = _FakeWindow(
|
||||
layout={
|
||||
"cols": [0.0, 0.4, 0.8, 1.0],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [(0, 0, 1, 1), (1, 0, 2, 1), (2, 0, 3, 1)],
|
||||
}
|
||||
)
|
||||
assert current_layout_id(window) == LAYOUT_ID_THREE_GROUP
|
||||
|
||||
|
||||
def test_read_stored_layout_id_returns_none_without_project() -> None:
|
||||
assert read_stored_layout_id(_FakeWindow()) is None
|
||||
|
||||
|
||||
def test_write_and_read_stored_layout_id_round_trip() -> None:
|
||||
window = _FakeWindow(project_data={"folders": [{"path": "."}]})
|
||||
write_stored_layout_id(window, LAYOUT_ID_THREE_GROUP)
|
||||
assert window._project_data is not None
|
||||
assert window._project_data["settings"][LAYOUT_STATE_KEY] == LAYOUT_ID_THREE_GROUP
|
||||
assert read_stored_layout_id(window) == LAYOUT_ID_THREE_GROUP
|
||||
|
||||
|
||||
def test_write_stored_layout_id_preserves_unrelated_settings() -> None:
|
||||
window = _FakeWindow(
|
||||
project_data={"settings": {"unrelated": "keep"}, "folders": []}
|
||||
)
|
||||
write_stored_layout_id(window, LAYOUT_ID_TWO_GROUP)
|
||||
assert window._project_data is not None
|
||||
settings = window._project_data["settings"]
|
||||
assert settings["unrelated"] == "keep"
|
||||
assert settings[LAYOUT_STATE_KEY] == LAYOUT_ID_TWO_GROUP
|
||||
assert window._project_data["folders"] == []
|
||||
|
||||
|
||||
def test_write_stored_layout_id_noop_without_setter() -> None:
|
||||
window = _FakeWindow(has_set_project_data=False)
|
||||
# Should not raise.
|
||||
write_stored_layout_id(window, LAYOUT_ID_THREE_GROUP)
|
||||
|
||||
|
||||
def test_session_agent_layout_command_applies_three_group_and_persists() -> None:
|
||||
window = _FakeWindow(project_data={})
|
||||
command = SessionsAgentLayoutCommand.__new__(SessionsAgentLayoutCommand)
|
||||
command.window = window # type: ignore[attr-defined]
|
||||
command.run(editor_frac=0.4, terminus_frac=0.8)
|
||||
assert len(window._set_layout_calls) == 1
|
||||
assert window._set_layout_calls[0]["cells"] == [
|
||||
[0, 0, 1, 1],
|
||||
[1, 0, 2, 1],
|
||||
[2, 0, 3, 1],
|
||||
]
|
||||
assert read_stored_layout_id(window) == LAYOUT_ID_THREE_GROUP
|
||||
|
||||
|
||||
def test_session_agent_layout_collapse_command_applies_two_group_and_persists() -> None:
|
||||
window = _FakeWindow(project_data={})
|
||||
command = SessionsAgentLayoutCollapseSwitcherCommand.__new__(
|
||||
SessionsAgentLayoutCollapseSwitcherCommand
|
||||
)
|
||||
command.window = window # type: ignore[attr-defined]
|
||||
command.run(editor_frac=0.5)
|
||||
assert len(window._set_layout_calls) == 1
|
||||
assert window._set_layout_calls[0]["cells"] == [
|
||||
[0, 0, 1, 1],
|
||||
[1, 0, 2, 1],
|
||||
]
|
||||
assert read_stored_layout_id(window) == LAYOUT_ID_TWO_GROUP
|
||||
|
||||
|
||||
def test_session_agent_layout_command_noop_without_set_layout() -> None:
|
||||
class _StubWindow:
|
||||
def project_data(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def set_project_data(self, data: Dict[str, Any]) -> None:
|
||||
raise AssertionError("should not persist when set_layout is missing")
|
||||
|
||||
command = SessionsAgentLayoutCommand.__new__(SessionsAgentLayoutCommand)
|
||||
command.window = _StubWindow() # type: ignore[attr-defined]
|
||||
# Should not raise even though the fake window lacks set_layout.
|
||||
command.run()
|
||||
|
||||
|
||||
def test_fake_window_helpers_self_consistent() -> None:
|
||||
# Guard against accidental drift in the test double.
|
||||
window = _FakeWindow(project_data={"settings": {}})
|
||||
write_stored_layout_id(window, LAYOUT_ID_THREE_GROUP)
|
||||
assert len(window._set_project_data_calls) == 1
|
||||
data: Tuple[str, ...] = tuple(window._set_project_data_calls[0]["settings"].keys())
|
||||
assert LAYOUT_STATE_KEY in data
|
||||
@@ -403,6 +403,10 @@ def test_open_remote_terminal_uses_workspace_host_and_root(
|
||||
lambda pattern: (),
|
||||
raising=False,
|
||||
)
|
||||
# When tmux is absent on the remote, Sessions falls back to the
|
||||
# pre-C2 direct-shell spawn so the no-Terminus branch still works
|
||||
# on hosts without tmux installed.
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
@@ -448,6 +452,7 @@ def test_open_remote_terminal_prefers_terminus_panel(
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
@@ -512,6 +517,7 @@ def test_open_remote_terminal_uses_configured_shell_command(
|
||||
lambda _: _SettingsObj(),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
|
||||
400
sublime/tests/test_cmd_expand_deferred_directory.py
Normal file
400
sublime/tests/test_cmd_expand_deferred_directory.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""Tests for ``SessionsExpandDeferredDirectoryCommand`` (v0.4.21 hardening)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from conftest import FakeWindow
|
||||
from sessions import commands, workspace_state
|
||||
from sessions.recent_state import RecentWorkspace, RecentWorkspaceIndex
|
||||
from sessions.settings_model import SessionsSettings
|
||||
from sessions.ssh_file_transport import (
|
||||
RemoteCacheMirrorOptions,
|
||||
RemoteCacheMirrorResult,
|
||||
)
|
||||
from sessions.workspace_state import PROJECT_SETTINGS_KEY
|
||||
|
||||
|
||||
def _wire_workspace(tmp_path: Path, monkeypatch) -> FakeWindow:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(commands.sublime, "_sessions_test_sync", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_workspace_runtime_connected",
|
||||
lambda *_a, **_kw: True,
|
||||
)
|
||||
monkeypatch.setattr(commands, "validate_remote_root", lambda value: value)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-xyz",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
pdata: Dict[str, object] = {
|
||||
"settings": {PROJECT_SETTINGS_KEY: "cache-xyz"},
|
||||
"folders": [],
|
||||
}
|
||||
workspace_state.reset_deferred_directories()
|
||||
return FakeWindow(project_data=pdata)
|
||||
|
||||
|
||||
def test_expand_with_remote_path_sends_fanout_zero(tmp_path: Path, monkeypatch) -> None:
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def _recording_mirror(
|
||||
host_alias: str,
|
||||
*,
|
||||
remote_root: str,
|
||||
local_files_root: Path,
|
||||
options: RemoteCacheMirrorOptions,
|
||||
allow_spawn: bool = True,
|
||||
) -> RemoteCacheMirrorResult:
|
||||
captured.append(
|
||||
{
|
||||
"host_alias": host_alias,
|
||||
"remote_root": remote_root,
|
||||
"local_files_root": local_files_root,
|
||||
"options": options,
|
||||
"allow_spawn": allow_spawn,
|
||||
}
|
||||
)
|
||||
return RemoteCacheMirrorResult(entries_scanned=7)
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_cache_mirror", _recording_mirror)
|
||||
|
||||
# Seed the deferred store so we can verify clear-after-success.
|
||||
workspace_state.record_deferred_directories(
|
||||
"cache-xyz", ["/srv/ws/huge", "/srv/ws/vendor"]
|
||||
)
|
||||
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run(
|
||||
remote_path="/srv/ws/huge",
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
call = captured[0]
|
||||
assert call["host_alias"] == "prod"
|
||||
assert call["remote_root"] == "/srv/ws/huge"
|
||||
assert call["options"].max_dir_fanout == 0
|
||||
# Expand must never prune (it is a manual, scoped, create-only operation).
|
||||
assert call["options"].prune_missing is False
|
||||
# Still bounded by the 1000-entry cap even on expand.
|
||||
assert call["options"].max_entries <= 1000
|
||||
|
||||
remaining = workspace_state.deferred_directories_for("cache-xyz")
|
||||
assert "/srv/ws/huge" not in remaining
|
||||
assert "/srv/ws/vendor" in remaining
|
||||
|
||||
|
||||
def test_expand_without_args_shows_quick_panel(tmp_path: Path, monkeypatch) -> None:
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
workspace_state.record_deferred_directories(
|
||||
"cache-xyz", ["/srv/ws/huge", "/srv/ws/vendor"]
|
||||
)
|
||||
|
||||
# execute_remote_cache_mirror should not be called when no choice is made.
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_cache_mirror",
|
||||
lambda *_a, **_kw: RemoteCacheMirrorResult(),
|
||||
)
|
||||
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run()
|
||||
|
||||
assert window.quick_panels, "quick panel should have been offered"
|
||||
rows = window.quick_panels[-1]
|
||||
triggers = [row[0] for row in rows]
|
||||
assert triggers == ["/srv/ws/huge", "/srv/ws/vendor"]
|
||||
|
||||
|
||||
def test_expand_sidebar_resolves_local_to_remote(tmp_path: Path, monkeypatch) -> None:
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
captured_remote: List[str] = []
|
||||
|
||||
def _recording_mirror(
|
||||
host_alias: str,
|
||||
*,
|
||||
remote_root: str,
|
||||
local_files_root: Path,
|
||||
options: RemoteCacheMirrorOptions,
|
||||
allow_spawn: bool = True,
|
||||
) -> RemoteCacheMirrorResult:
|
||||
_ = (host_alias, local_files_root, options, allow_spawn)
|
||||
captured_remote.append(remote_root)
|
||||
return RemoteCacheMirrorResult()
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_cache_mirror", _recording_mirror)
|
||||
|
||||
# Resolve the local cache path that would map to /srv/ws/huge/sub.
|
||||
from sessions.file_state import RemoteToLocalCacheMapper
|
||||
|
||||
cache_root = Path(str(tmp_path / "cache")) / "Sessions" / "cache" / "cache-xyz"
|
||||
mapper = RemoteToLocalCacheMapper(
|
||||
workspace_cache_key="cache-xyz",
|
||||
remote_workspace_root="/srv/ws",
|
||||
files_cache_root=cache_root,
|
||||
)
|
||||
local_path = mapper.local_path_for_remote_file("/srv/ws/huge/sub")
|
||||
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run(
|
||||
paths=[str(local_path)],
|
||||
)
|
||||
|
||||
assert captured_remote == ["/srv/ws/huge/sub"]
|
||||
|
||||
|
||||
def test_expand_sidebar_dirs_kwarg_also_resolved(tmp_path: Path, monkeypatch) -> None:
|
||||
# Some Sublime builds pass ``dirs`` (not ``paths``) for directory
|
||||
# right-clicks. Support it so the sidebar menu lands the right dir
|
||||
# instead of falling through to the quick panel.
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
captured_remote: List[str] = []
|
||||
|
||||
def _recording_mirror(
|
||||
host_alias: str,
|
||||
*,
|
||||
remote_root: str,
|
||||
local_files_root: Path,
|
||||
options: RemoteCacheMirrorOptions,
|
||||
allow_spawn: bool = True,
|
||||
) -> RemoteCacheMirrorResult:
|
||||
_ = (host_alias, local_files_root, options, allow_spawn)
|
||||
captured_remote.append(remote_root)
|
||||
return RemoteCacheMirrorResult()
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_cache_mirror", _recording_mirror)
|
||||
|
||||
from sessions.file_state import RemoteToLocalCacheMapper
|
||||
|
||||
cache_root = Path(str(tmp_path / "cache")) / "Sessions" / "cache" / "cache-xyz"
|
||||
mapper = RemoteToLocalCacheMapper(
|
||||
workspace_cache_key="cache-xyz",
|
||||
remote_workspace_root="/srv/ws",
|
||||
files_cache_root=cache_root,
|
||||
)
|
||||
local_path = mapper.local_path_for_remote_file("/srv/ws/data/big")
|
||||
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run(
|
||||
dirs=[str(local_path)],
|
||||
)
|
||||
|
||||
assert captured_remote == ["/srv/ws/data/big"]
|
||||
|
||||
|
||||
def test_expand_sidebar_non_sessions_path_bails(tmp_path: Path, monkeypatch) -> None:
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
statuses: List[str] = []
|
||||
|
||||
monkeypatch.setattr(commands, "_status_message", lambda msg: statuses.append(msg))
|
||||
|
||||
def _unexpected_mirror(*_a: Any, **_kw: Any) -> RemoteCacheMirrorResult:
|
||||
raise AssertionError("mirror must not run for non-Sessions path")
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_cache_mirror", _unexpected_mirror)
|
||||
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run(
|
||||
paths=["/totally/unrelated/path"],
|
||||
)
|
||||
|
||||
assert any("Not a Sessions remote path" in msg for msg in statuses), statuses
|
||||
|
||||
|
||||
def test_deferred_directories_recorded_on_sync(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Mirror result's deferred list is forwarded to ``record_deferred_directories``."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(commands.sublime, "_sessions_test_sync", True)
|
||||
monkeypatch.setattr(
|
||||
commands, "_workspace_runtime_connected", lambda *_a, **_kw: True
|
||||
)
|
||||
monkeypatch.setattr(commands, "validate_remote_root", lambda value: value)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-xyz",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
pdata: Dict[str, object] = {
|
||||
"settings": {PROJECT_SETTINGS_KEY: "cache-xyz"},
|
||||
"folders": [],
|
||||
}
|
||||
workspace_state.reset_deferred_directories()
|
||||
|
||||
def _mirror_with_deferred(
|
||||
host_alias: str,
|
||||
*,
|
||||
remote_root: str,
|
||||
local_files_root: Path,
|
||||
options: RemoteCacheMirrorOptions,
|
||||
allow_spawn: bool = True,
|
||||
) -> RemoteCacheMirrorResult:
|
||||
_ = (host_alias, remote_root, local_files_root, options, allow_spawn)
|
||||
return RemoteCacheMirrorResult(
|
||||
entries_scanned=50,
|
||||
deferred_directories=("/srv/ws/vendor", "/srv/ws/huge"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_cache_mirror", _mirror_with_deferred)
|
||||
# Disable two-phase so this test takes the single-pass finish path.
|
||||
monkeypatch.setattr(commands, "_mirror_fast_sidebar_first_sync", lambda: False)
|
||||
window = FakeWindow(project_data=pdata)
|
||||
|
||||
commands.SessionsSyncRemoteTreeToSidebarCommand(window).run(source="manual")
|
||||
|
||||
stored: Optional[tuple] = workspace_state.deferred_directories_for("cache-xyz")
|
||||
assert stored == ("/srv/ws/huge", "/srv/ws/vendor")
|
||||
|
||||
|
||||
def test_expand_no_deferred_shows_status(tmp_path: Path, monkeypatch) -> None:
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
statuses: List[str] = []
|
||||
monkeypatch.setattr(commands, "_status_message", lambda msg: statuses.append(msg))
|
||||
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run()
|
||||
|
||||
assert any("No deferred" in msg for msg in statuses), statuses
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster D2 — "will appear" status only when expand is actually scheduled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_expand_no_deferred_while_deepening_does_not_promise_future_appearance(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Deferred-empty + still-deepening must not promise a future expand.
|
||||
|
||||
Regression for Cluster D2 (2026-04-25 retest): the old wording
|
||||
"deferred directories will appear once the deep pass finishes."
|
||||
promised an expand that never actually fired — there was no
|
||||
``expand.begin`` trace and no stub was created. The replacement
|
||||
message describes the present state ("No deferred directories to
|
||||
expand yet…") without promising future stubs.
|
||||
"""
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
statuses: List[str] = []
|
||||
monkeypatch.setattr(commands, "_status_message", lambda msg: statuses.append(msg))
|
||||
# Simulate a still-running deep mirror.
|
||||
commands._MIRROR_SYNC_IN_FLIGHT.add("cache-xyz")
|
||||
try:
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run()
|
||||
finally:
|
||||
commands._MIRROR_SYNC_IN_FLIGHT.discard("cache-xyz")
|
||||
|
||||
assert statuses, "a status message should still be emitted"
|
||||
msg = statuses[-1]
|
||||
# Must not use the misleading future-tense "will appear" promise.
|
||||
assert "will appear" not in msg, msg
|
||||
# Must communicate that nothing was scheduled.
|
||||
assert "No deferred" in msg
|
||||
# Must mention the deepening state so the user knows what to do.
|
||||
assert "deepening" in msg.lower()
|
||||
|
||||
|
||||
def test_expand_with_remote_path_emits_progress_status(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""``_expand_remote_path`` announces work only when scheduling actually happens."""
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
statuses: List[str] = []
|
||||
monkeypatch.setattr(commands, "_status_message", lambda msg: statuses.append(msg))
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_cache_mirror",
|
||||
lambda *a, **k: RemoteCacheMirrorResult(entries_scanned=3),
|
||||
)
|
||||
workspace_state.record_deferred_directories("cache-xyz", ["/srv/ws/huge"])
|
||||
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run(
|
||||
remote_path="/srv/ws/huge",
|
||||
)
|
||||
|
||||
# The "Expanding …" hint must show up before the finish message.
|
||||
assert any("Expanding " in msg and "/srv/ws/huge" in msg for msg in statuses), (
|
||||
statuses
|
||||
)
|
||||
# And we must end on the success summary.
|
||||
assert any("Expanded /srv/ws/huge" in msg for msg in statuses), statuses
|
||||
|
||||
|
||||
def test_expand_finish_warns_for_large_dirs(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Listings above ~5k entries must emit a warning suffix in the status line."""
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
statuses: List[str] = []
|
||||
monkeypatch.setattr(commands, "_status_message", lambda msg: statuses.append(msg))
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_cache_mirror",
|
||||
lambda *a, **k: RemoteCacheMirrorResult(
|
||||
entries_scanned=12_000,
|
||||
directories_created=400,
|
||||
file_placeholders_created=600,
|
||||
truncated_by_entry_limit=True,
|
||||
),
|
||||
)
|
||||
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run(
|
||||
remote_path="/srv/ws/very_huge",
|
||||
)
|
||||
|
||||
finish_msg = next(
|
||||
(msg for msg in statuses if msg.startswith("Expanded /srv/ws/very_huge")),
|
||||
None,
|
||||
)
|
||||
assert finish_msg is not None, statuses
|
||||
# Warning text — both the truncation flag and the >5k hint should be
|
||||
# present so the user understands only a slice was mirrored.
|
||||
assert "12000 entries listed" in finish_msg
|
||||
assert "re-run on subdirs" in finish_msg
|
||||
|
||||
|
||||
def test_expand_finish_no_large_dir_warning_below_threshold(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A modest expand must not surface the large-dir warning."""
|
||||
window = _wire_workspace(tmp_path, monkeypatch)
|
||||
statuses: List[str] = []
|
||||
monkeypatch.setattr(commands, "_status_message", lambda msg: statuses.append(msg))
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_cache_mirror",
|
||||
lambda *a, **k: RemoteCacheMirrorResult(
|
||||
entries_scanned=120,
|
||||
directories_created=4,
|
||||
file_placeholders_created=10,
|
||||
),
|
||||
)
|
||||
|
||||
commands.SessionsExpandDeferredDirectoryCommand(window).run(
|
||||
remote_path="/srv/ws/small",
|
||||
)
|
||||
|
||||
finish_msg = next(
|
||||
(msg for msg in statuses if msg.startswith("Expanded /srv/ws/small")),
|
||||
None,
|
||||
)
|
||||
assert finish_msg is not None, statuses
|
||||
assert "re-run on subdirs" not in finish_msg
|
||||
@@ -541,6 +541,11 @@ def test_workspace_activation_listener_primes_refresh_once(monkeypatch) -> None:
|
||||
"_start_open_file_watch_loop",
|
||||
lambda window, cache_key="": None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_schedule_eager_hydrate_if_needed",
|
||||
lambda window, context: None,
|
||||
)
|
||||
commands._MIRROR_AUTO_REFRESH_PRIMED.clear()
|
||||
|
||||
listener.on_activated(view)
|
||||
|
||||
276
sublime/tests/test_cmd_open_remote_terminal.py
Normal file
276
sublime/tests/test_cmd_open_remote_terminal.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Wiring tests for ``SessionsOpenRemoteTerminalCommand`` (Track C2).
|
||||
|
||||
These tests live separately from ``test_cmd_connect.py`` because they
|
||||
exercise the tmux-persistence and view-reuse paths introduced in
|
||||
v0.5.8. The existing tmux-off fallback behaviour is still asserted from
|
||||
``test_cmd_connect.py`` so coverage doesn't regress.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
from conftest import FakeWindow, _write_ssh_config
|
||||
from sessions import commands
|
||||
from sessions.recent_state import RecentWorkspace, RecentWorkspaceIndex
|
||||
from sessions.settings_model import SessionsSettings
|
||||
from sessions.workspace_state import PROJECT_SETTINGS_KEY
|
||||
|
||||
|
||||
def _seed_recent_workspace(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
host_alias: str = "prod",
|
||||
remote_root: str = "/srv/app",
|
||||
cache_key: str = "cache-123",
|
||||
has_terminus: bool = True,
|
||||
) -> SessionsSettings:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
_write_ssh_config(
|
||||
ssh_config_path,
|
||||
"Host {alias}\n HostName {alias}.example.com\n".format(alias=host_alias),
|
||||
)
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"cache_path",
|
||||
lambda: str(tmp_path / "cache"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"find_resources",
|
||||
lambda pattern: (
|
||||
("Packages/Terminus/Terminus.sublime-settings",)
|
||||
if has_terminus and "Terminus" in pattern
|
||||
else ()
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
host_alias,
|
||||
remote_root,
|
||||
cache_key,
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
return settings
|
||||
|
||||
|
||||
class _TerminusMarkedView:
|
||||
"""View stub that carries the ``terminus_view`` settings marker."""
|
||||
|
||||
_next_id = 5000
|
||||
|
||||
def __init__(self, *, live: bool = True) -> None:
|
||||
self._live = live
|
||||
self._id = _TerminusMarkedView._next_id
|
||||
_TerminusMarkedView._next_id += 1
|
||||
|
||||
def id(self) -> int:
|
||||
return self._id
|
||||
|
||||
def settings(self) -> "_TerminusSettings":
|
||||
return _TerminusSettings(self._live)
|
||||
|
||||
def close(self) -> None:
|
||||
self._live = False
|
||||
|
||||
|
||||
class _TerminusSettings:
|
||||
def __init__(self, live: bool) -> None:
|
||||
self._live = live
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
if key == "terminus_view":
|
||||
return self._live
|
||||
return default
|
||||
|
||||
|
||||
def test_terminus_branch_wraps_shell_in_tmux_when_available(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
args = terminus_calls[0][1]
|
||||
# Argv unchanged (still ``ssh -tt prod <remote>``); the remote
|
||||
# invocation is what's wrapped in tmux.
|
||||
assert args["cmd"][:3] == ["ssh", "-tt", "prod"]
|
||||
remote_cmd = args["cmd"][3]
|
||||
# No leading ``exec`` inside the tmux argv — tmux itself becomes
|
||||
# the session's initial program and spawns ``bash -il`` directly.
|
||||
assert remote_cmd == (
|
||||
"cd /srv/app && (stty sane -ixon 2>/dev/null || true) && "
|
||||
"tmux new-session -A -s 'sessions-term-prod' bash -il"
|
||||
)
|
||||
assert status[-1] == "Sessions terminal attached to prod /srv/app"
|
||||
|
||||
|
||||
def test_probe_runs_once_per_host_and_caches(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
probe_calls: List[str] = []
|
||||
|
||||
from sessions import terminal_tmux_session as tts
|
||||
|
||||
def stub_probe(host_alias: str, **_kwargs) -> tts.TmuxProbeResult:
|
||||
probe_calls.append(host_alias)
|
||||
return tts.TmuxProbeResult(
|
||||
available=True,
|
||||
exit_code=0,
|
||||
stdout="/usr/bin/tmux",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "probe_tmux_available", stub_probe)
|
||||
|
||||
window1 = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
window2 = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
# First call probes.
|
||||
commands.SessionsOpenRemoteTerminalCommand(window1).run()
|
||||
# Second call for the same host must not re-probe.
|
||||
commands.SessionsOpenRemoteTerminalCommand(window2).run()
|
||||
assert probe_calls == ["prod"]
|
||||
|
||||
|
||||
def test_probe_missing_tmux_falls_back_and_emits_hint(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
from sessions import terminal_tmux_session as tts
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"probe_tmux_available",
|
||||
lambda host_alias, **_: tts.TmuxProbeResult(
|
||||
available=False,
|
||||
exit_code=127,
|
||||
stdout="",
|
||||
stderr="command not found",
|
||||
),
|
||||
)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
remote_cmd = terminus_calls[0][1]["cmd"][3]
|
||||
# Falls back to the pre-C2 direct-shell invocation when tmux is
|
||||
# missing on the remote.
|
||||
assert remote_cmd == (
|
||||
"cd /srv/app && (stty sane -ixon 2>/dev/null || true) && exec bash -il"
|
||||
)
|
||||
# User-visible hint for the missing tmux binary.
|
||||
assert any("tmux not found on prod" in msg for msg in status)
|
||||
|
||||
|
||||
def test_reuses_live_terminus_view_without_second_spawn(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
# Seed a live Terminus view for prod.
|
||||
live_view = _TerminusMarkedView(live=True)
|
||||
window.created_views.append(live_view) # type: ignore[arg-type]
|
||||
commands._TERMINUS_VIEW_BY_HOST["prod"] = live_view
|
||||
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
# No ``terminus_open`` was issued — we refocused the live view.
|
||||
assert not any(c[0] == "terminus_open" for c in window.window_commands)
|
||||
assert window.active_view_value is live_view
|
||||
assert status[-1] == "Sessions terminal for prod refocused"
|
||||
|
||||
|
||||
def test_closed_cached_view_is_evicted_and_spawns_new(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
# Cached view whose settings report the view is no longer a
|
||||
# Terminus pane (simulates the user closing the tab).
|
||||
dead_view = _TerminusMarkedView(live=False)
|
||||
commands._TERMINUS_VIEW_BY_HOST["prod"] = dead_view
|
||||
|
||||
# Present a fresh view in the window's ``views()`` list so the
|
||||
# post-spawn registration step can pick it up.
|
||||
fresh_view = _TerminusMarkedView(live=True)
|
||||
window.created_views.append(fresh_view) # type: ignore[arg-type]
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
# Dead view evicted.
|
||||
assert commands._TERMINUS_VIEW_BY_HOST.get("prod") is fresh_view
|
||||
# terminus_open fired for the re-attach.
|
||||
assert any(c[0] == "terminus_open" for c in window.window_commands)
|
||||
|
||||
|
||||
def test_registers_fresh_terminus_view_after_spawn(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
newly_opened = _TerminusMarkedView(live=True)
|
||||
window.created_views.append(newly_opened) # type: ignore[arg-type]
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
assert commands._TERMINUS_VIEW_BY_HOST.get("prod") is newly_opened
|
||||
|
||||
|
||||
def test_no_terminus_branch_still_spawns_tmux_wrapped_shell(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# No Terminus available → ``new_terminal`` fallback. tmux wrapping
|
||||
# still happens when the probe succeeds.
|
||||
_seed_recent_workspace(tmp_path, monkeypatch, has_terminus=False)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
terminal_calls = [c for c in window.window_commands if c[0] == "new_terminal"]
|
||||
assert len(terminal_calls) == 1
|
||||
# The ``new_terminal`` cmd is a single shell-quoted string; the
|
||||
# tmux-wrapped remote invocation must be embedded inside.
|
||||
cmd = terminal_calls[0][1]["cmd"]
|
||||
assert "tmux new-session -A -s" in cmd
|
||||
assert "sessions-term-prod" in cmd
|
||||
|
||||
|
||||
def test_prefix_is_disjoint_from_agent_tmux_prefix() -> None:
|
||||
# Guard against accidental collision between Track C2 (terminal)
|
||||
# and Track D (agent) tmux session namespaces.
|
||||
from sessions.agent_tmux import _SESSION_NAME_PREFIX as AGENT_PREFIX
|
||||
from sessions.terminal_tmux_session import SESSION_NAME_PREFIX as TERMINAL_PREFIX
|
||||
|
||||
assert AGENT_PREFIX != TERMINAL_PREFIX
|
||||
assert not AGENT_PREFIX.startswith(TERMINAL_PREFIX)
|
||||
assert not TERMINAL_PREFIX.startswith(AGENT_PREFIX)
|
||||
1103
sublime/tests/test_cmd_python_interpreter.py
Normal file
1103
sublime/tests/test_cmd_python_interpreter.py
Normal file
File diff suppressed because it is too large
Load Diff
358
sublime/tests/test_cmd_remote_debugging.py
Normal file
358
sublime/tests/test_cmd_remote_debugging.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""Command + helper coverage for the Phase C remote debugging integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from conftest import FakeWindow
|
||||
from sessions import commands
|
||||
from sessions.recent_state import RecentWorkspace
|
||||
from sessions.settings_model import SessionsSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ExecResult:
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int = 0
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
def _ctx_debug() -> commands._WorkspaceContext:
|
||||
return commands._WorkspaceContext(
|
||||
settings=SessionsSettings(),
|
||||
recent_entry=RecentWorkspace(
|
||||
"gpu01", "/home/me/proj", "cache-dbg", "2026-04-20T00:00:00+00:00"
|
||||
),
|
||||
cache_key="cache-dbg",
|
||||
local_cache_root=Path("/tmp/cache-dbg"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _substitute_active_python_placeholder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_substitute_replaces_token_in_every_part() -> None:
|
||||
out = commands._substitute_active_python_placeholder(
|
||||
("bash", "-lc", '"{ACTIVE_PYTHON}" -m pip install debugpy'),
|
||||
"/srv/.venv/bin/python",
|
||||
)
|
||||
assert out[0] == "bash"
|
||||
assert out[1] == "-lc"
|
||||
assert "/srv/.venv/bin/python" in out[2]
|
||||
assert "{ACTIVE_PYTHON}" not in out[2]
|
||||
|
||||
|
||||
def test_substitute_leaves_parts_without_token_unchanged() -> None:
|
||||
out = commands._substitute_active_python_placeholder(
|
||||
("echo", "hello world", "{ACTIVE_PYTHON}"),
|
||||
"/py",
|
||||
)
|
||||
assert out == ("echo", "hello world", "/py")
|
||||
|
||||
|
||||
def test_substitute_none_collapses_to_empty_string() -> None:
|
||||
out = commands._substitute_active_python_placeholder(
|
||||
("bash", "-lc", '[ -z "{ACTIVE_PYTHON}" ] && echo empty'),
|
||||
None,
|
||||
)
|
||||
assert '[ -z "" ] && echo empty' in out[2]
|
||||
|
||||
|
||||
def test_substitute_empty_string_behaves_like_none() -> None:
|
||||
out_none = commands._substitute_active_python_placeholder(
|
||||
("x", "{ACTIVE_PYTHON}"), None
|
||||
)
|
||||
out_empty = commands._substitute_active_python_placeholder(
|
||||
("x", "{ACTIVE_PYTHON}"), ""
|
||||
)
|
||||
assert out_none == out_empty == ("x", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# debugger-kind short-circuit on install flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_install_debugger_kind_bails_without_active_python(monkeypatch) -> None:
|
||||
# Find the real debugpy spec via the catalog so the id matches.
|
||||
from sessions.managed_remote_extension_catalog import (
|
||||
BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG,
|
||||
)
|
||||
from sessions.settings_model import DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS
|
||||
|
||||
debugger_entry = next(
|
||||
e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "debugger"
|
||||
)
|
||||
want_id = debugger_entry.install_catalog_id
|
||||
spec = next(s for s in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS if s.id == want_id)
|
||||
|
||||
called: List[Dict[str, Any]] = []
|
||||
|
||||
def fake_exec(host_alias: str, **kwargs: Any) -> _ExecResult:
|
||||
called.append({"host_alias": host_alias, **kwargs})
|
||||
return _ExecResult()
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||
|
||||
window = FakeWindow(project_data={"settings": {}})
|
||||
context = _ctx_debug()
|
||||
commands._install_remote_extension_async(window, context, spec)
|
||||
# No SSH exec should have been attempted.
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_install_debugger_kind_substitutes_when_active_python_set(monkeypatch) -> None:
|
||||
from sessions.managed_remote_extension_catalog import (
|
||||
BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG,
|
||||
)
|
||||
from sessions.settings_model import DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS
|
||||
|
||||
debugger_entry = next(
|
||||
e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "debugger"
|
||||
)
|
||||
want_id = debugger_entry.install_catalog_id
|
||||
spec = next(s for s in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS if s.id == want_id)
|
||||
|
||||
observed_argvs: List[List[str]] = []
|
||||
|
||||
def fake_exec(host_alias: str, **kwargs: Any) -> _ExecResult:
|
||||
observed_argvs.append(list(kwargs["argv"]))
|
||||
return _ExecResult(exit_code=0)
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_maybe_seed_project_lsp_save_preferences_from_global",
|
||||
lambda *a, **kw: None,
|
||||
)
|
||||
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {
|
||||
"sessions_active_python_interpreter": "/srv/app/.venv/bin/python"
|
||||
}
|
||||
}
|
||||
)
|
||||
context = _ctx_debug()
|
||||
commands._install_remote_extension_async(window, context, spec)
|
||||
joined = " ".join(part for argv in observed_argvs for part in argv)
|
||||
assert "/srv/app/.venv/bin/python" in joined
|
||||
assert "{ACTIVE_PYTHON}" not in joined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# project-data merge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_merge_sessions_dap_config_appends_when_absent() -> None:
|
||||
data, appended = commands._merge_sessions_dap_config(
|
||||
{"folders": [{"path": "."}], "settings": {"other": 1}},
|
||||
"/home/me/proj",
|
||||
)
|
||||
assert appended is True
|
||||
configs = data["settings"]["debugger_configurations"]
|
||||
assert isinstance(configs, list)
|
||||
assert len(configs) == 1
|
||||
entry = configs[0]
|
||||
assert entry["name"] == "Sessions: Attach remote Python"
|
||||
assert entry["connect"] == {"host": "127.0.0.1", "port": 5678}
|
||||
assert entry["pathMappings"][0]["remoteRoot"] == "/home/me/proj"
|
||||
# Existing settings preserved.
|
||||
assert data["settings"]["other"] == 1
|
||||
assert data["folders"] == [{"path": "."}]
|
||||
|
||||
|
||||
def test_merge_sessions_dap_config_preserves_existing_entries() -> None:
|
||||
existing_row = {"name": "Other debugger", "type": "foo"}
|
||||
data, appended = commands._merge_sessions_dap_config(
|
||||
{"settings": {"debugger_configurations": [existing_row]}},
|
||||
"/r",
|
||||
)
|
||||
assert appended is True
|
||||
configs = data["settings"]["debugger_configurations"]
|
||||
names = [c["name"] for c in configs]
|
||||
assert names == ["Other debugger", "Sessions: Attach remote Python"]
|
||||
|
||||
|
||||
def test_merge_sessions_dap_config_dedupes_by_name() -> None:
|
||||
prior = {
|
||||
"name": "Sessions: Attach remote Python",
|
||||
"custom": "keep-me",
|
||||
}
|
||||
data, appended = commands._merge_sessions_dap_config(
|
||||
{"settings": {"debugger_configurations": [prior]}},
|
||||
"/r",
|
||||
)
|
||||
assert appended is False
|
||||
configs = data["settings"]["debugger_configurations"]
|
||||
assert len(configs) == 1
|
||||
# User-edited row preserved verbatim.
|
||||
assert configs[0] == prior
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# instructions output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_instructions_includes_live_values() -> None:
|
||||
text = commands._render_remote_debug_instructions(
|
||||
remote_python_path="/srv/app/.venv/bin/python",
|
||||
remote_workspace_root="/srv/app",
|
||||
host_alias="gpu01",
|
||||
)
|
||||
assert "/srv/app/.venv/bin/python" in text
|
||||
assert "/srv/app" in text
|
||||
assert "gpu01" in text
|
||||
assert "5678" in text
|
||||
assert "Sessions: Attach remote Python" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionsSetupRemoteDebuggingCommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_setup_command_bails_without_active_python(monkeypatch) -> None:
|
||||
window = FakeWindow(project_data={"settings": {}})
|
||||
# Even if probe is reached, a no-op stub keeps tests hermetic.
|
||||
monkeypatch.setattr(
|
||||
commands, "execute_remote_exec_once", lambda *a, **kw: _ExecResult()
|
||||
)
|
||||
commands.SessionsSetupRemoteDebuggingCommand(window).run()
|
||||
settings = window.project_data_value.get("settings", {})
|
||||
assert "debugger_configurations" not in settings
|
||||
assert window.output_panels == {}
|
||||
|
||||
|
||||
def test_setup_command_bails_without_workspace_context(monkeypatch) -> None:
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {"sessions_active_python_interpreter": "/srv/.venv/bin/python"}
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
commands, "execute_remote_exec_once", lambda *a, **kw: _ExecResult()
|
||||
)
|
||||
commands.SessionsSetupRemoteDebuggingCommand(window).run()
|
||||
assert "debugger_configurations" not in window.project_data_value.get(
|
||||
"settings", {}
|
||||
)
|
||||
|
||||
|
||||
def test_setup_command_bails_when_debugpy_probe_fails(monkeypatch) -> None:
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {"sessions_active_python_interpreter": "/srv/.venv/bin/python"}
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **kw: _ctx_debug())
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_exec_once",
|
||||
lambda *a, **kw: _ExecResult(exit_code=127, stderr="no module"),
|
||||
)
|
||||
commands.SessionsSetupRemoteDebuggingCommand(window).run()
|
||||
assert "debugger_configurations" not in window.project_data_value.get(
|
||||
"settings", {}
|
||||
)
|
||||
|
||||
|
||||
def test_setup_command_writes_dap_config_and_shows_panel(monkeypatch) -> None:
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {"sessions_active_python_interpreter": "/srv/.venv/bin/python"}
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **kw: _ctx_debug())
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_exec_once",
|
||||
lambda *a, **kw: _ExecResult(exit_code=0, stdout="1.8.1\n"),
|
||||
)
|
||||
commands.SessionsSetupRemoteDebuggingCommand(window).run()
|
||||
configs = window.project_data_value["settings"]["debugger_configurations"]
|
||||
assert configs[0]["name"] == "Sessions: Attach remote Python"
|
||||
assert configs[0]["pathMappings"][0]["remoteRoot"] == "/home/me/proj"
|
||||
|
||||
panel = window.output_panels.get("sessions_debug_setup")
|
||||
assert panel is not None
|
||||
assert "/srv/.venv/bin/python" in panel.content
|
||||
assert "/home/me/proj" in panel.content
|
||||
assert "gpu01" in panel.content
|
||||
# Window received the show_panel command.
|
||||
panel_cmd = ("show_panel", {"panel": "output.sessions_debug_setup"})
|
||||
assert panel_cmd in window.window_commands
|
||||
|
||||
|
||||
def test_setup_command_is_idempotent_when_config_exists(monkeypatch) -> None:
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {
|
||||
"sessions_active_python_interpreter": "/srv/.venv/bin/python",
|
||||
"debugger_configurations": [
|
||||
{
|
||||
"name": "Sessions: Attach remote Python",
|
||||
"custom": "user-edited",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **kw: _ctx_debug())
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_exec_once",
|
||||
lambda *a, **kw: _ExecResult(exit_code=0),
|
||||
)
|
||||
commands.SessionsSetupRemoteDebuggingCommand(window).run()
|
||||
configs = window.project_data_value["settings"]["debugger_configurations"]
|
||||
# Still exactly one row, preserving the user's edits.
|
||||
assert len(configs) == 1
|
||||
assert configs[0]["custom"] == "user-edited"
|
||||
|
||||
|
||||
def test_probe_debugpy_present_handles_exception(monkeypatch) -> None:
|
||||
def boom(*a: Any, **kw: Any) -> _ExecResult:
|
||||
raise RuntimeError("ssh down")
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_exec_once", boom)
|
||||
ok = commands.SessionsSetupRemoteDebuggingCommand._probe_debugpy_present(
|
||||
"h", "/r", "/py"
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_probe_debugpy_present_handles_timeout(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_exec_once",
|
||||
lambda *a, **kw: _ExecResult(timed_out=True),
|
||||
)
|
||||
ok = commands.SessionsSetupRemoteDebuggingCommand._probe_debugpy_present(
|
||||
"h", "/r", "/py"
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_debugger_kind_spec_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_debugger_kind_spec_id_matches_debugpy() -> None:
|
||||
assert commands._is_debugger_kind_spec_id("debugpy") is True
|
||||
|
||||
|
||||
def test_is_debugger_kind_spec_id_rejects_other_kinds() -> None:
|
||||
assert commands._is_debugger_kind_spec_id("pyright-langserver") is False
|
||||
assert commands._is_debugger_kind_spec_id("ruff") is False
|
||||
assert commands._is_debugger_kind_spec_id("jupyterlab") is False
|
||||
assert commands._is_debugger_kind_spec_id("not-in-catalog") is False
|
||||
366
sublime/tests/test_cmd_remote_terminal_panes.py
Normal file
366
sublime/tests/test_cmd_remote_terminal_panes.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Wiring tests for the multi-pane remote-terminal commands (Cluster E).
|
||||
|
||||
Covers ``SessionsNewRemoteTerminalPaneCommand`` (numbered tmux session
|
||||
spawn) and ``SessionsKillRemoteTerminalCommand`` (quick-panel + remote
|
||||
``tmux kill-session``). The persistent-reattach behaviour of the main
|
||||
``Sessions: Open Remote Terminal`` command is still exercised from
|
||||
``test_cmd_open_remote_terminal.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import pytest
|
||||
from conftest import FakeWindow, _write_ssh_config
|
||||
from sessions import commands
|
||||
from sessions.recent_state import RecentWorkspace, RecentWorkspaceIndex
|
||||
from sessions.settings_model import SessionsSettings
|
||||
from sessions.workspace_state import PROJECT_SETTINGS_KEY
|
||||
|
||||
|
||||
def _seed_recent_workspace(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
host_alias: str = "prod",
|
||||
remote_root: str = "/srv/app",
|
||||
cache_key: str = "cache-123",
|
||||
has_terminus: bool = True,
|
||||
) -> SessionsSettings:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
_write_ssh_config(
|
||||
ssh_config_path,
|
||||
"Host {alias}\n HostName {alias}.example.com\n".format(alias=host_alias),
|
||||
)
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"cache_path",
|
||||
lambda: str(tmp_path / "cache"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"find_resources",
|
||||
lambda pattern: (
|
||||
("Packages/Terminus/Terminus.sublime-settings",)
|
||||
if has_terminus and "Terminus" in pattern
|
||||
else ()
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
host_alias,
|
||||
remote_root,
|
||||
cache_key,
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
return settings
|
||||
|
||||
|
||||
class _TerminusMarkedView:
|
||||
"""View stub that carries the ``terminus_view`` settings marker."""
|
||||
|
||||
_next_id = 9000
|
||||
|
||||
def __init__(self, *, live: bool = True) -> None:
|
||||
self._live = live
|
||||
self._id = _TerminusMarkedView._next_id
|
||||
_TerminusMarkedView._next_id += 1
|
||||
|
||||
def id(self) -> int:
|
||||
return self._id
|
||||
|
||||
def settings(self) -> "_TerminusSettings":
|
||||
return _TerminusSettings(self._live)
|
||||
|
||||
def close(self) -> None:
|
||||
self._live = False
|
||||
|
||||
|
||||
class _TerminusSettings:
|
||||
def __init__(self, live: bool) -> None:
|
||||
self._live = live
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
if key == "terminus_view":
|
||||
return self._live
|
||||
return default
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_terminal_caches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Clear cross-test state so caches don't leak between tests."""
|
||||
commands._TERMINUS_VIEW_BY_HOST.clear()
|
||||
commands._TERMINUS_VIEW_BY_SESSION_NAME.clear()
|
||||
commands._TERMINUS_TMUX_AVAILABLE_BY_HOST.clear()
|
||||
|
||||
|
||||
# --- SessionsNewRemoteTerminalPaneCommand -----------------------------------
|
||||
|
||||
|
||||
def test_new_pane_picks_first_numbered_session_when_only_base_running(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
|
||||
listed: List[str] = []
|
||||
|
||||
def stub_list(host_alias: str, **_: Any) -> List[str]:
|
||||
listed.append(host_alias)
|
||||
return ["sessions-term-prod"]
|
||||
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", stub_list)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsNewRemoteTerminalPaneCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
cmd = terminus_calls[0][1]["cmd"]
|
||||
assert cmd[:3] == ["ssh", "-tt", "prod"]
|
||||
remote_invocation = cmd[3]
|
||||
# Numbered session name must land verbatim in the tmux argv.
|
||||
assert "tmux new-session -A -s 'sessions-term-prod-2' bash -il" in remote_invocation
|
||||
title = terminus_calls[0][1]["title"]
|
||||
assert title.endswith("(#2)")
|
||||
assert listed == ["prod"]
|
||||
|
||||
|
||||
def test_new_pane_skips_used_indices(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_terminal_sessions",
|
||||
lambda host_alias, **_: [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-term-prod-3",
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsNewRemoteTerminalPaneCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
remote_invocation = terminus_calls[0][1]["cmd"][3]
|
||||
assert "sessions-term-prod-4" in remote_invocation
|
||||
|
||||
|
||||
def test_new_pane_does_not_register_per_host_view(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Numbered panes must not overwrite the per-host cache; otherwise a
|
||||
# later ``Open`` would refocus a numbered tab instead of the base.
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", lambda *a, **k: [])
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
fresh = _TerminusMarkedView(live=True)
|
||||
window.created_views.append(fresh) # type: ignore[arg-type]
|
||||
|
||||
commands.SessionsNewRemoteTerminalPaneCommand(window).run()
|
||||
|
||||
assert "prod" not in commands._TERMINUS_VIEW_BY_HOST
|
||||
# But the per-session cache *is* populated so kill can find it.
|
||||
assert commands._TERMINUS_VIEW_BY_SESSION_NAME.get("sessions-term-prod-2") is fresh
|
||||
|
||||
|
||||
def test_new_pane_falls_back_to_direct_shell_when_tmux_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# When tmux is unavailable the new-pane command must not call
|
||||
# ``list-sessions`` and must use the direct-shell fallback shape.
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
|
||||
list_calls: List[str] = []
|
||||
|
||||
def fail_list(host_alias: str, **_: Any) -> List[str]:
|
||||
list_calls.append(host_alias)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", fail_list)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsNewRemoteTerminalPaneCommand(window).run()
|
||||
|
||||
assert list_calls == [] # never bothered the remote
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
remote_invocation = terminus_calls[0][1]["cmd"][3]
|
||||
assert "tmux" not in remote_invocation
|
||||
assert "exec bash -il" in remote_invocation
|
||||
|
||||
|
||||
# --- SessionsKillRemoteTerminalCommand --------------------------------------
|
||||
|
||||
|
||||
def test_kill_command_lists_terminal_sessions_in_quick_panel(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_terminal_sessions",
|
||||
lambda host_alias, **_: [
|
||||
"sessions-term-prod-3",
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-agent-abc-claude", # unrelated, must be filtered out.
|
||||
"sessions-term-other", # different host, must be filtered out.
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
|
||||
assert len(window.quick_panels) == 1
|
||||
items = window.quick_panels[0]
|
||||
captions = [row[0] for row in items]
|
||||
assert captions == [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-term-prod-3",
|
||||
]
|
||||
|
||||
|
||||
def test_kill_command_runs_kill_session_argv_on_select(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_terminal_sessions",
|
||||
lambda host_alias, **_: [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "_sessions_test_sync", True, raising=False)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
captured: List[Tuple[str, str]] = []
|
||||
|
||||
class _Completed:
|
||||
def __init__(self) -> None:
|
||||
self.returncode = 0
|
||||
self.stderr = ""
|
||||
self.stdout = ""
|
||||
|
||||
def stub_kill(host_alias: str, session_name: str, **_: Any) -> _Completed:
|
||||
captured.append((host_alias, session_name))
|
||||
return _Completed()
|
||||
|
||||
monkeypatch.setattr(commands, "kill_terminal_session", stub_kill)
|
||||
|
||||
# Cache a Terminus view for the session being killed so we can
|
||||
# assert it's closed.
|
||||
target_view = _TerminusMarkedView(live=True)
|
||||
commands._TERMINUS_VIEW_BY_SESSION_NAME["sessions-term-prod-2"] = target_view
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
on_select = window.quick_panel_callbacks[0]
|
||||
# Pick ``sessions-term-prod-2`` (the second sorted entry).
|
||||
on_select(1)
|
||||
|
||||
assert captured == [("prod", "sessions-term-prod-2")]
|
||||
# View was closed and evicted from the cache.
|
||||
assert "sessions-term-prod-2" not in commands._TERMINUS_VIEW_BY_SESSION_NAME
|
||||
assert target_view._live is False
|
||||
|
||||
|
||||
def test_kill_command_emits_status_when_no_sessions_running(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", lambda host_alias, **_: [])
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
|
||||
assert window.quick_panels == [] # never opened
|
||||
assert any("no remote terminal sessions on prod" in m for m in status)
|
||||
|
||||
|
||||
def test_kill_command_warns_when_tmux_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
list_calls: List[str] = []
|
||||
|
||||
def fail_list(host_alias: str, **_: Any) -> List[str]:
|
||||
list_calls.append(host_alias)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", fail_list)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
|
||||
assert list_calls == [] # short-circuits before listing.
|
||||
assert window.quick_panels == []
|
||||
assert any("tmux is not available on prod" in m for m in status)
|
||||
|
||||
|
||||
def test_kill_command_handles_already_gone_session_message(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_terminal_sessions",
|
||||
lambda host_alias, **_: ["sessions-term-prod-7"],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "_sessions_test_sync", True, raising=False)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
|
||||
class _Completed:
|
||||
def __init__(self) -> None:
|
||||
self.returncode = 1
|
||||
self.stderr = "can't find session: sessions-term-prod-7"
|
||||
self.stdout = ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"kill_terminal_session",
|
||||
lambda host_alias, session_name, **_: _Completed(),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
on_select = window.quick_panel_callbacks[0]
|
||||
on_select(0)
|
||||
|
||||
assert any("was already gone" in m for m in status)
|
||||
@@ -1567,3 +1567,199 @@ def test_run_format_then_pipeline_async_runs_source_actions_before_format(
|
||||
("source action", "source-action-2"),
|
||||
("format", "format_after_save"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster D1 — self-save reload chatter suppression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_marks_remote_path_as_self_save_for_cooldown(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""The save path stamps the remote path so the watch echo gets ignored."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda *a, **k: None)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-d1",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-d1" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: RemoteWriteFileResult(
|
||||
ok=True,
|
||||
updated_metadata=RemoteFileMetadata(
|
||||
mtime_ns=2,
|
||||
size_bytes=len(request.content),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_schedule_format_then_pipeline_after_cache_push",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
commands._RECENT_SELF_SAVE_REMOTE_PATHS.clear()
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-d1"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert commands._is_recent_self_save("/srv/ws/pkg/a.py")
|
||||
|
||||
|
||||
def test_reload_changed_remote_views_filters_self_save_echo(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A watch tick that echoes our own write must NOT call the reload helper."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-d1",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-d1" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("x\n", encoding="utf-8")
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-d1"}})
|
||||
view = FakeView(file_name=str(local_cache_path.resolve()))
|
||||
view.window_value = window
|
||||
window.created_views.append(view)
|
||||
ctx = commands._WorkspaceContext(
|
||||
settings=settings,
|
||||
recent_entry=RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-d1",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
cache_key="cache-d1",
|
||||
local_cache_root=tmp_path / "cache" / "Sessions" / "cache" / "cache-d1",
|
||||
)
|
||||
|
||||
called: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_check_and_reload_remote_view_entry",
|
||||
lambda v, lp, rp, c: called.append(rp),
|
||||
)
|
||||
|
||||
commands._RECENT_SELF_SAVE_REMOTE_PATHS.clear()
|
||||
commands._mark_recent_self_save("/srv/ws/pkg/a.py")
|
||||
commands._reload_changed_remote_views(window, ctx, {"/srv/ws/pkg/a.py"})
|
||||
|
||||
assert called == [], "self-save echo must be filtered before reload"
|
||||
|
||||
# Other paths in the same tick still pass through, but the self-save
|
||||
# path stays suppressed even when mixed with non-suppressed ones.
|
||||
commands._reload_changed_remote_views(
|
||||
window, ctx, {"/srv/ws/pkg/a.py", "/srv/ws/pkg/b.py"}
|
||||
)
|
||||
assert "/srv/ws/pkg/a.py" not in called
|
||||
|
||||
|
||||
def test_self_save_mark_expires_after_cooldown(monkeypatch) -> None:
|
||||
"""After the cooldown expires the remote path is no longer suppressed."""
|
||||
commands._RECENT_SELF_SAVE_REMOTE_PATHS.clear()
|
||||
fake_now = [1000.0]
|
||||
monkeypatch.setattr(commands.time, "monotonic", lambda: fake_now[0])
|
||||
|
||||
commands._mark_recent_self_save("/srv/ws/pkg/a.py")
|
||||
assert commands._is_recent_self_save("/srv/ws/pkg/a.py")
|
||||
|
||||
# Advance past the cooldown window.
|
||||
fake_now[0] += commands._RECENT_SELF_SAVE_COOLDOWN_S + 1
|
||||
assert not commands._is_recent_self_save("/srv/ws/pkg/a.py")
|
||||
# Expired entries are pruned eagerly.
|
||||
assert "/srv/ws/pkg/a.py" not in commands._RECENT_SELF_SAVE_REMOTE_PATHS
|
||||
|
||||
|
||||
def test_check_and_reload_remote_view_entry_skips_self_save(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""The per-view revalidate also honours the self-save cooldown."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
ctx = commands._WorkspaceContext(
|
||||
settings=settings,
|
||||
recent_entry=RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-d1",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
cache_key="cache-d1",
|
||||
local_cache_root=tmp_path / "cache" / "Sessions" / "cache" / "cache-d1",
|
||||
)
|
||||
view = FakeView(file_name=str(tmp_path / "x.py"))
|
||||
|
||||
called: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda *a, **k: called.append("stat") or None,
|
||||
)
|
||||
|
||||
commands._RECENT_SELF_SAVE_REMOTE_PATHS.clear()
|
||||
commands._mark_recent_self_save("/srv/ws/pkg/a.py")
|
||||
|
||||
commands._check_and_reload_remote_view_entry(
|
||||
view,
|
||||
Path(view.file_name() or "/tmp/x.py"),
|
||||
"/srv/ws/pkg/a.py",
|
||||
ctx,
|
||||
)
|
||||
|
||||
assert called == [], "stat must not run for a self-saved remote path"
|
||||
|
||||
@@ -22,7 +22,7 @@ from sessions.remote import (
|
||||
TruncatedStream,
|
||||
)
|
||||
from sessions.settings_model import (
|
||||
RemoteLspServerSpec,
|
||||
RemoteExtensionSpec,
|
||||
SessionsSettings,
|
||||
ToolchainOverride,
|
||||
)
|
||||
@@ -689,7 +689,7 @@ def test_tool_pipeline_handles_empty_output(tmp_path: Path, monkeypatch) -> None
|
||||
assert any("ready" in m.lower() for m in status_messages)
|
||||
|
||||
|
||||
def _remote_lsp_sh_c_contains(argv: object, needle: str) -> bool:
|
||||
def _remote_extension_sh_c_contains(argv: object, needle: str) -> bool:
|
||||
if not isinstance(argv, (list, tuple)) or len(argv) < 3:
|
||||
return False
|
||||
if argv[0] != "/bin/sh" or argv[1] != "-c":
|
||||
@@ -697,8 +697,8 @@ def _remote_lsp_sh_c_contains(argv: object, needle: str) -> bool:
|
||||
return needle in str(argv[2])
|
||||
|
||||
|
||||
def test_remote_lsp_exec_argv_login_wrapper() -> None:
|
||||
out = commands._remote_lsp_exec_argv(["npm", "i", "-g", "x"])
|
||||
def test_remote_extension_exec_argv_login_wrapper() -> None:
|
||||
out = commands._remote_extension_exec_argv(["npm", "i", "-g", "x"])
|
||||
assert out[0] == "/bin/sh"
|
||||
assert out[1] == "-c"
|
||||
assert "case " in out[2]
|
||||
@@ -716,7 +716,7 @@ def test_maybe_lsp_prerequisite_error_dialog_pyright_pip(
|
||||
msgs.append(msg)
|
||||
|
||||
monkeypatch.setattr(commands.sublime, "error_message", capture)
|
||||
spec = RemoteLspServerSpec(
|
||||
spec = RemoteExtensionSpec(
|
||||
id="pyright-langserver",
|
||||
label="Pyright",
|
||||
install_argv=("true",),
|
||||
@@ -738,13 +738,13 @@ def test_maybe_lsp_prerequisite_error_dialog_pyright_pip(
|
||||
assert "pip" in msgs[0]
|
||||
|
||||
|
||||
def test_install_remote_lsp_server_runs_install_then_probe(
|
||||
def test_install_remote_extension_runs_install_then_probe(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
settings = SessionsSettings(
|
||||
ssh_config_path=tmp_path / "config",
|
||||
remote_lsp_servers=(
|
||||
RemoteLspServerSpec(
|
||||
remote_extensions=(
|
||||
RemoteExtensionSpec(
|
||||
id="pyright-langserver",
|
||||
label="Pyright",
|
||||
install_argv=("npm", "i", "-g", "pyright"),
|
||||
@@ -779,15 +779,15 @@ def test_install_remote_lsp_server_runs_install_then_probe(
|
||||
def fake_exec(host_alias, argv, cwd, env=None, timeout_ms=30000):
|
||||
_ = (host_alias, env, timeout_ms)
|
||||
calls.append(list(argv))
|
||||
if _remote_lsp_sh_c_contains(argv, "pyright-langserver") and "--version" in str(
|
||||
argv[2]
|
||||
):
|
||||
if _remote_extension_sh_c_contains(
|
||||
argv, "pyright-langserver"
|
||||
) and "--version" in str(argv[2]):
|
||||
if (
|
||||
len(
|
||||
[
|
||||
c
|
||||
for c in calls
|
||||
if _remote_lsp_sh_c_contains(c, "pyright-langserver")
|
||||
if _remote_extension_sh_c_contains(c, "pyright-langserver")
|
||||
and "--version" in str(c[2])
|
||||
]
|
||||
)
|
||||
@@ -803,13 +803,13 @@ def test_install_remote_lsp_server_runs_install_then_probe(
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
cmd = commands.SessionsInstallRemoteLspServerCommand(window)
|
||||
cmd = commands.SessionsInstallRemoteExtensionCommand(window)
|
||||
cmd.run()
|
||||
window.quick_panel_callbacks[-1](0)
|
||||
|
||||
assert _remote_lsp_sh_c_contains(calls[0], "pyright-langserver")
|
||||
assert _remote_lsp_sh_c_contains(calls[1], "npm")
|
||||
assert _remote_lsp_sh_c_contains(calls[2], "pyright-langserver")
|
||||
assert _remote_extension_sh_c_contains(calls[0], "pyright-langserver")
|
||||
assert _remote_extension_sh_c_contains(calls[1], "npm")
|
||||
assert _remote_extension_sh_c_contains(calls[2], "pyright-langserver")
|
||||
assert "installed" in status_messages[-1].lower()
|
||||
|
||||
|
||||
@@ -831,7 +831,7 @@ def test_seed_project_lsp_save_preferences_from_global_when_missing(
|
||||
}
|
||||
}
|
||||
)
|
||||
spec = RemoteLspServerSpec(
|
||||
spec = RemoteExtensionSpec(
|
||||
id="ruff",
|
||||
label="Ruff",
|
||||
install_argv=("true",),
|
||||
@@ -900,7 +900,7 @@ def test_seed_project_lsp_save_preferences_keeps_existing_values(monkeypatch) ->
|
||||
}
|
||||
}
|
||||
)
|
||||
spec = RemoteLspServerSpec(
|
||||
spec = RemoteExtensionSpec(
|
||||
id="ruff",
|
||||
label="Ruff",
|
||||
install_argv=("true",),
|
||||
@@ -931,13 +931,13 @@ def test_seed_project_lsp_save_preferences_keeps_existing_values(monkeypatch) ->
|
||||
assert traces == []
|
||||
|
||||
|
||||
def test_remove_remote_lsp_server_runs_remove_then_probe(
|
||||
def test_remove_remote_extension_runs_remove_then_probe(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
settings = SessionsSettings(
|
||||
ssh_config_path=tmp_path / "config",
|
||||
remote_lsp_servers=(
|
||||
RemoteLspServerSpec(
|
||||
remote_extensions=(
|
||||
RemoteExtensionSpec(
|
||||
id="pyright-langserver",
|
||||
label="Pyright",
|
||||
install_argv=("npm", "i", "-g", "pyright"),
|
||||
@@ -973,9 +973,9 @@ def test_remove_remote_lsp_server_runs_remove_then_probe(
|
||||
def fake_exec(host_alias, argv, cwd, env=None, timeout_ms=30000):
|
||||
_ = (host_alias, cwd, env, timeout_ms)
|
||||
calls.append(list(argv))
|
||||
if _remote_lsp_sh_c_contains(argv, "pyright-langserver") and "--version" in str(
|
||||
argv[2]
|
||||
):
|
||||
if _remote_extension_sh_c_contains(
|
||||
argv, "pyright-langserver"
|
||||
) and "--version" in str(argv[2]):
|
||||
probe_count["n"] += 1
|
||||
if probe_count["n"] <= 1:
|
||||
return RemoteExecOnceResult(
|
||||
@@ -988,22 +988,22 @@ def test_remove_remote_lsp_server_runs_remove_then_probe(
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
cmd = commands.SessionsRemoveRemoteLspServerCommand(window)
|
||||
cmd = commands.SessionsRemoveRemoteExtensionCommand(window)
|
||||
cmd.run()
|
||||
window.quick_panel_callbacks[-1](0)
|
||||
|
||||
assert _remote_lsp_sh_c_contains(calls[0], "pyright-langserver")
|
||||
assert _remote_lsp_sh_c_contains(calls[1], "npm")
|
||||
assert _remote_lsp_sh_c_contains(calls[2], "pyright-langserver")
|
||||
assert _remote_extension_sh_c_contains(calls[0], "pyright-langserver")
|
||||
assert _remote_extension_sh_c_contains(calls[1], "npm")
|
||||
assert _remote_extension_sh_c_contains(calls[2], "pyright-langserver")
|
||||
assert "removed" in status_messages[-1].lower()
|
||||
|
||||
|
||||
def test_remote_lsp_status_reports_when_no_servers_configured(
|
||||
def test_remote_extension_status_reports_when_no_servers_configured(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
settings = SessionsSettings(
|
||||
ssh_config_path=tmp_path / "config",
|
||||
remote_lsp_servers=(),
|
||||
remote_extensions=(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands, "load_sessions_settings_from_sublime", lambda: settings
|
||||
@@ -1026,18 +1026,18 @@ def test_remote_lsp_status_reports_when_no_servers_configured(
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsRemoteLspServerStatusCommand(window).run()
|
||||
commands.SessionsRemoteExtensionStatusCommand(window).run()
|
||||
|
||||
assert "No remote LSP server catalog is available." in status_messages[-1]
|
||||
|
||||
|
||||
def test_remote_lsp_status_renders_panel_with_probe_results(
|
||||
def test_remote_extension_status_renders_panel_with_probe_results(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
settings = SessionsSettings(
|
||||
ssh_config_path=tmp_path / "config",
|
||||
remote_lsp_servers=(
|
||||
RemoteLspServerSpec(
|
||||
remote_extensions=(
|
||||
RemoteExtensionSpec(
|
||||
id="pyright-langserver",
|
||||
label="Pyright",
|
||||
install_argv=("npm", "i", "-g", "pyright"),
|
||||
@@ -1045,7 +1045,7 @@ def test_remote_lsp_status_renders_panel_with_probe_results(
|
||||
probe_argv=("pyright-langserver", "--version"),
|
||||
cwd=None,
|
||||
),
|
||||
RemoteLspServerSpec(
|
||||
RemoteExtensionSpec(
|
||||
id="ruff-lsp",
|
||||
label="Ruff LSP",
|
||||
install_argv=("uv", "tool", "install", "ruff-lsp"),
|
||||
@@ -1077,13 +1077,15 @@ def test_remote_lsp_status_renders_panel_with_probe_results(
|
||||
|
||||
def fake_exec(host_alias, argv, cwd, env=None, timeout_ms=30000):
|
||||
_ = (host_alias, cwd, env, timeout_ms)
|
||||
if _remote_lsp_sh_c_contains(argv, "pyright-langserver") and "--version" in str(
|
||||
argv[2]
|
||||
):
|
||||
if _remote_extension_sh_c_contains(
|
||||
argv, "pyright-langserver"
|
||||
) and "--version" in str(argv[2]):
|
||||
return RemoteExecOnceResult(
|
||||
exit_code=0, stdout="1.0.0", stderr="", timed_out=False
|
||||
)
|
||||
if _remote_lsp_sh_c_contains(argv, "ruff-lsp") and "--version" in str(argv[2]):
|
||||
if _remote_extension_sh_c_contains(argv, "ruff-lsp") and "--version" in str(
|
||||
argv[2]
|
||||
):
|
||||
return RemoteExecOnceResult(
|
||||
exit_code=127, stdout="", stderr="not found", timed_out=False
|
||||
)
|
||||
@@ -1094,18 +1096,181 @@ def test_remote_lsp_status_renders_panel_with_probe_results(
|
||||
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsRemoteLspServerStatusCommand(window).run()
|
||||
commands.SessionsRemoteExtensionStatusCommand(window).run()
|
||||
|
||||
panel_text = window.output_panels["sessions_remote_lsp_servers"].content
|
||||
panel_text = window.output_panels["sessions_remote_extensions"].content
|
||||
assert "Remote LSP install catalog:" in panel_text
|
||||
assert "Pyright [pyright-langserver] : installed" in panel_text
|
||||
assert "Ruff LSP [ruff-lsp] : missing" in panel_text
|
||||
assert "Ruff LSP [ruff-lsp] : not installed" in panel_text
|
||||
assert "sessions_remote_python_tool_pipeline" in panel_text
|
||||
assert "Third-party Sublime LSP" in panel_text
|
||||
assert "1/2 installed" in status_messages[-1]
|
||||
|
||||
|
||||
def test_probe_remote_lsp_server_installed_pyright_fallbacks_to_cli(
|
||||
def test_remote_extension_status_renders_installed_but_unusable(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A probe that exits non-zero with a non-127 code surfaces as "unusable"."""
|
||||
settings = SessionsSettings(
|
||||
ssh_config_path=tmp_path / "config",
|
||||
remote_extensions=(
|
||||
RemoteExtensionSpec(
|
||||
id="ruff-lsp",
|
||||
label="Ruff LSP",
|
||||
install_argv=("uv", "tool", "install", "ruff-lsp"),
|
||||
remove_argv=("uv", "tool", "uninstall", "ruff-lsp"),
|
||||
probe_argv=("ruff-lsp", "--version"),
|
||||
cwd=None,
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands, "load_sessions_settings_from_sublime", lambda: settings
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-unusable",
|
||||
"2026-04-23T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def fake_exec(host_alias, argv, cwd, env=None, timeout_ms=30000):
|
||||
_ = (host_alias, argv, cwd, env, timeout_ms)
|
||||
# Non-zero, non-127 exit: binary is present but the probe argv
|
||||
# made it barf (classic "installed but unusable" signature).
|
||||
return RemoteExecOnceResult(
|
||||
exit_code=2,
|
||||
stdout="",
|
||||
stderr="usage: ruff-lsp [...]",
|
||||
timed_out=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-unusable"}}
|
||||
)
|
||||
|
||||
commands.SessionsRemoteExtensionStatusCommand(window).run()
|
||||
panel_text = window.output_panels["sessions_remote_extensions"].content
|
||||
assert "Ruff LSP [ruff-lsp] : installed but unusable" in panel_text
|
||||
assert "missing" not in panel_text # legacy label must not leak back in.
|
||||
|
||||
|
||||
def test_remote_extension_status_maps_exit_127_to_not_installed(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Exit 127 (binary not on PATH) renders as "not installed", not "unusable"."""
|
||||
settings = SessionsSettings(
|
||||
ssh_config_path=tmp_path / "config",
|
||||
remote_extensions=(
|
||||
RemoteExtensionSpec(
|
||||
id="ruff-lsp",
|
||||
label="Ruff LSP",
|
||||
install_argv=("uv", "tool", "install", "ruff-lsp"),
|
||||
remove_argv=("uv", "tool", "uninstall", "ruff-lsp"),
|
||||
probe_argv=("ruff-lsp", "--version"),
|
||||
cwd=None,
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands, "load_sessions_settings_from_sublime", lambda: settings
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-absent",
|
||||
"2026-04-23T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_exec_once",
|
||||
lambda host_alias, argv, cwd, env=None, timeout_ms=30000: RemoteExecOnceResult(
|
||||
exit_code=127, stdout="", stderr="not found", timed_out=False
|
||||
),
|
||||
)
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-absent"}}
|
||||
)
|
||||
|
||||
commands.SessionsRemoteExtensionStatusCommand(window).run()
|
||||
panel_text = window.output_panels["sessions_remote_extensions"].content
|
||||
assert "Ruff LSP [ruff-lsp] : not installed" in panel_text
|
||||
|
||||
|
||||
def test_install_remote_extension_subtitle_uses_not_installed(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""The install picker subtitle reports "not installed", never "missing"."""
|
||||
settings = SessionsSettings(
|
||||
ssh_config_path=tmp_path / "config",
|
||||
remote_extensions=(
|
||||
RemoteExtensionSpec(
|
||||
id="ruff-lsp",
|
||||
label="Ruff LSP",
|
||||
install_argv=("uv", "tool", "install", "ruff-lsp"),
|
||||
remove_argv=("uv", "tool", "uninstall", "ruff-lsp"),
|
||||
probe_argv=("ruff-lsp", "--version"),
|
||||
cwd=None,
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands, "load_sessions_settings_from_sublime", lambda: settings
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-install",
|
||||
"2026-04-23T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_exec_once",
|
||||
lambda host_alias, argv, cwd, env=None, timeout_ms=30000: RemoteExecOnceResult(
|
||||
exit_code=127, stdout="", stderr="not found", timed_out=False
|
||||
),
|
||||
)
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-install"}}
|
||||
)
|
||||
|
||||
commands.SessionsInstallRemoteExtensionCommand(window).run()
|
||||
rows = window.quick_panels[-1]
|
||||
assert rows, rows
|
||||
assert "(not installed)" in rows[0][1]
|
||||
assert "(missing)" not in rows[0][1]
|
||||
|
||||
|
||||
def test_probe_remote_extension_installed_pyright_fallbacks_to_cli(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
settings = SessionsSettings(ssh_config_path=tmp_path / "config")
|
||||
@@ -1125,7 +1290,7 @@ def test_probe_remote_lsp_server_installed_pyright_fallbacks_to_cli(
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
context = commands._workspace_context(window, settings)
|
||||
assert context is not None
|
||||
spec = RemoteLspServerSpec(
|
||||
spec = RemoteExtensionSpec(
|
||||
id="pyright-langserver",
|
||||
label="Pyright",
|
||||
install_argv=("true",),
|
||||
@@ -1138,14 +1303,14 @@ def test_probe_remote_lsp_server_installed_pyright_fallbacks_to_cli(
|
||||
def fake_exec(host_alias, argv, cwd, env=None, timeout_ms=30000):
|
||||
_ = (host_alias, cwd, env, timeout_ms)
|
||||
calls.append(list(argv))
|
||||
if _remote_lsp_sh_c_contains(argv, "pyright-langserver"):
|
||||
if _remote_extension_sh_c_contains(argv, "pyright-langserver"):
|
||||
return RemoteExecOnceResult(
|
||||
exit_code=1,
|
||||
stdout="",
|
||||
stderr="Error: Connection input stream is not set.",
|
||||
timed_out=False,
|
||||
)
|
||||
if _remote_lsp_sh_c_contains(argv, "pyright --version"):
|
||||
if _remote_extension_sh_c_contains(argv, "pyright --version"):
|
||||
return RemoteExecOnceResult(
|
||||
exit_code=0, stdout="pyright 1.0.0", stderr="", timed_out=False
|
||||
)
|
||||
@@ -1154,6 +1319,47 @@ def test_probe_remote_lsp_server_installed_pyright_fallbacks_to_cli(
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_exec_once", fake_exec)
|
||||
assert commands._probe_remote_lsp_server_installed(context, spec) is True
|
||||
assert _remote_lsp_sh_c_contains(calls[0], "pyright-langserver")
|
||||
assert _remote_lsp_sh_c_contains(calls[1], "pyright --version")
|
||||
assert commands._probe_remote_extension_installed(context, spec) is True
|
||||
assert _remote_extension_sh_c_contains(calls[0], "pyright-langserver")
|
||||
assert _remote_extension_sh_c_contains(calls[1], "pyright --version")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionsPreviewRemoteAgentPayloadCommand.is_visible — palette gating.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_fake_load_settings(monkeypatch, value: object) -> None:
|
||||
"""Patch ``sublime.load_settings`` so the command sees a sentinel value."""
|
||||
|
||||
class _StoredSettings:
|
||||
def get(self, key: str, default=None):
|
||||
assert key == "sessions_show_dev_commands"
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"load_settings",
|
||||
lambda _name: _StoredSettings(),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def test_preview_remote_agent_payload_hidden_by_default(monkeypatch) -> None:
|
||||
_install_fake_load_settings(monkeypatch, False)
|
||||
cmd = commands.SessionsPreviewRemoteAgentPayloadCommand(FakeWindow())
|
||||
assert cmd.is_visible() is False
|
||||
|
||||
|
||||
def test_preview_remote_agent_payload_shown_when_dev_flag_on(monkeypatch) -> None:
|
||||
_install_fake_load_settings(monkeypatch, True)
|
||||
cmd = commands.SessionsPreviewRemoteAgentPayloadCommand(FakeWindow())
|
||||
assert cmd.is_visible() is True
|
||||
|
||||
|
||||
def test_preview_remote_agent_payload_hidden_when_load_settings_unavailable(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(commands.sublime, "load_settings", None, raising=False)
|
||||
cmd = commands.SessionsPreviewRemoteAgentPayloadCommand(FakeWindow())
|
||||
assert cmd.is_visible() is False
|
||||
|
||||
@@ -29,7 +29,17 @@ def test_command_palette_prioritizes_recent_workspace_entry() -> None:
|
||||
assert "sessions_preview_remote_agent_payload" in [
|
||||
item["command"] for item in payload
|
||||
]
|
||||
assert "sessions_install_remote_lsp_server" in [item["command"] for item in payload]
|
||||
assert "sessions_remove_remote_lsp_server" in [item["command"] for item in payload]
|
||||
assert "sessions_remote_lsp_server_status" in [item["command"] for item in payload]
|
||||
assert "sessions_install_remote_extension" in [item["command"] for item in payload]
|
||||
assert "sessions_remove_remote_extension" in [item["command"] for item in payload]
|
||||
assert "sessions_remote_extension_status" in [item["command"] for item in payload]
|
||||
assert "sessions_open_remote_jupyter" in [item["command"] for item in payload]
|
||||
assert "sessions_stop_remote_jupyter" in [item["command"] for item in payload]
|
||||
assert "sessions_diagnose_lsp_workspace" in [item["command"] for item in payload]
|
||||
assert "sessions_select_python_interpreter" in [item["command"] for item in payload]
|
||||
assert "sessions_clear_python_interpreter" in [item["command"] for item in payload]
|
||||
assert "sessions_setup_remote_debugging" in [item["command"] for item in payload]
|
||||
assert "sessions_register_jupyter_kernel" in [item["command"] for item in payload]
|
||||
assert "sessions_expand_deferred_directory" in [item["command"] for item in payload]
|
||||
assert "sessions_new_agent_session" in [item["command"] for item in payload]
|
||||
assert "sessions_show_agent_switcher" in [item["command"] for item in payload]
|
||||
assert "sessions_kill_agent_session" in [item["command"] for item in payload]
|
||||
|
||||
@@ -27,7 +27,7 @@ def _make_workspace_context(tmp_path: Path, *, host_alias: str = "devhost") -> o
|
||||
)
|
||||
|
||||
|
||||
def test_refresh_managed_remote_lsp_merges_project(
|
||||
def test_refresh_managed_remote_extension_merges_project(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
host = "devhost"
|
||||
@@ -53,7 +53,7 @@ def test_refresh_managed_remote_lsp_merges_project(
|
||||
monkeypatch.setattr(commands, "_trace_event", lambda *a, **k: None)
|
||||
|
||||
ctx = _make_workspace_context(tmp_path, host_alias=host)
|
||||
err = commands._refresh_sessions_managed_remote_lsp_project(
|
||||
err = commands._refresh_sessions_managed_remote_extension_project(
|
||||
window, ctx, source="unit"
|
||||
)
|
||||
assert err is None
|
||||
@@ -88,7 +88,7 @@ def test_refresh_skips_when_project_file_missing(
|
||||
lambda *a, **k: traces.append((a, k)),
|
||||
)
|
||||
ctx = _make_workspace_context(tmp_path, host_alias="h")
|
||||
err = commands._refresh_sessions_managed_remote_lsp_project(
|
||||
err = commands._refresh_sessions_managed_remote_extension_project(
|
||||
window, ctx, source="unit"
|
||||
)
|
||||
assert err is not None
|
||||
@@ -126,7 +126,7 @@ def test_refresh_skips_when_disk_already_matches_current_socket(
|
||||
|
||||
# First call: writes managed block with the current broker socket.
|
||||
assert (
|
||||
commands._refresh_sessions_managed_remote_lsp_project(
|
||||
commands._refresh_sessions_managed_remote_extension_project(
|
||||
window, ctx, source="unit"
|
||||
)
|
||||
is None
|
||||
@@ -135,7 +135,7 @@ def test_refresh_skips_when_disk_already_matches_current_socket(
|
||||
|
||||
# Second call with identical state: should short-circuit.
|
||||
assert (
|
||||
commands._refresh_sessions_managed_remote_lsp_project(
|
||||
commands._refresh_sessions_managed_remote_extension_project(
|
||||
window, ctx, source="unit"
|
||||
)
|
||||
is None
|
||||
@@ -195,7 +195,7 @@ def test_refresh_rewrites_stale_project_and_restarts_managed_servers(
|
||||
|
||||
ctx = _make_workspace_context(tmp_path, host_alias=host)
|
||||
assert (
|
||||
commands._refresh_sessions_managed_remote_lsp_project(
|
||||
commands._refresh_sessions_managed_remote_extension_project(
|
||||
window, ctx, source="activation"
|
||||
)
|
||||
is None
|
||||
@@ -251,7 +251,7 @@ def test_refresh_first_time_write_also_restarts_managed_servers(
|
||||
monkeypatch.setattr(commands, "_trace_event", lambda *a, **k: None)
|
||||
|
||||
ctx = _make_workspace_context(tmp_path, host_alias=host)
|
||||
commands._refresh_sessions_managed_remote_lsp_project(
|
||||
commands._refresh_sessions_managed_remote_extension_project(
|
||||
window, ctx, source="activation"
|
||||
)
|
||||
restart_names = [
|
||||
@@ -275,6 +275,160 @@ def test_register_sessions_transport_hooks_idempotent(
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_register_sessions_transport_hooks_disables_stale_lsp_rows_on_open_windows(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``plugin_loaded`` flips ``enabled: false`` on every workspace's stale rows.
|
||||
|
||||
This is the boot-time gate that prevents the LSP-pyright / LSP-ruff crash
|
||||
storm: the previous Sublime PID's broker socket path
|
||||
(``sessions-local-bridge-<host>-<pid>.sock``) is dead, so leaving the row
|
||||
enabled would let the LSP package spawn ``local_bridge lsp-stdio`` which
|
||||
exits 1 immediately and gets disabled after 5 retries.
|
||||
"""
|
||||
host = "devhost"
|
||||
proj = tmp_path / "p.sublime-project"
|
||||
stale_sock = tmp_path / "stale.sock" # never created → stale
|
||||
proj.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"folders": [],
|
||||
"settings": {
|
||||
"LSP": {
|
||||
"LSP-pyright": {
|
||||
"sessions_remote_stdio_managed": True,
|
||||
"enabled": True,
|
||||
"command": [
|
||||
"/fake/bridge",
|
||||
"lsp-stdio",
|
||||
"--bridge-socket",
|
||||
str(stale_sock),
|
||||
],
|
||||
},
|
||||
"LSP-ruff": {
|
||||
"sessions_remote_stdio_managed": True,
|
||||
"enabled": True,
|
||||
"command": [
|
||||
"/fake/bridge",
|
||||
"lsp-stdio",
|
||||
"--bridge-socket",
|
||||
str(stale_sock),
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
window = FakeWindow()
|
||||
window.project_file_name = lambda: str(proj)
|
||||
ctx = _make_workspace_context(tmp_path, host_alias=host)
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands, "register_bridge_handshake_listener", lambda _cb: None
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "windows", lambda: [window])
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **k: ctx)
|
||||
monkeypatch.setattr(commands, "bridge_handshake_info", lambda _h: None)
|
||||
monkeypatch.setattr(commands, "_trace_event", lambda *a, **k: None)
|
||||
|
||||
commands.register_sessions_transport_hooks()
|
||||
|
||||
rows = json.loads(proj.read_text(encoding="utf-8"))["settings"]["LSP"]
|
||||
assert rows["LSP-pyright"]["enabled"] is False
|
||||
assert rows["LSP-ruff"]["enabled"] is False
|
||||
|
||||
|
||||
def test_register_sessions_transport_hooks_keeps_live_socket_enabled(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""If a row's broker socket is already live, ``enabled`` stays ``True``."""
|
||||
host = "devhost"
|
||||
live_sock = tmp_path / "live.sock"
|
||||
live_sock.write_text("", encoding="utf-8")
|
||||
proj = tmp_path / "p.sublime-project"
|
||||
proj.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"settings": {
|
||||
"LSP": {
|
||||
"LSP-pyright": {
|
||||
"sessions_remote_stdio_managed": True,
|
||||
"enabled": True,
|
||||
"command": [
|
||||
"/fake/bridge",
|
||||
"lsp-stdio",
|
||||
"--bridge-socket",
|
||||
str(live_sock),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
window = FakeWindow()
|
||||
window.project_file_name = lambda: str(proj)
|
||||
ctx = _make_workspace_context(tmp_path, host_alias=host)
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands, "register_bridge_handshake_listener", lambda _cb: None
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "windows", lambda: [window])
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **k: ctx)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"bridge_handshake_info",
|
||||
lambda _h: {"broker_socket": str(live_sock)},
|
||||
)
|
||||
monkeypatch.setattr(commands, "_trace_event", lambda *a, **k: None)
|
||||
|
||||
commands.register_sessions_transport_hooks()
|
||||
|
||||
rows = json.loads(proj.read_text(encoding="utf-8"))["settings"]["LSP"]
|
||||
assert rows["LSP-pyright"]["enabled"] is True
|
||||
|
||||
|
||||
def test_register_sessions_transport_hooks_skips_non_sessions_window(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Non-Sessions windows must not have their ``.sublime-project`` rewritten."""
|
||||
proj = tmp_path / "external.sublime-project"
|
||||
original = {
|
||||
"settings": {
|
||||
"LSP": {
|
||||
"LSP-pyright": {
|
||||
"enabled": True,
|
||||
"command": ["custom-pyright"],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
proj.write_text(json.dumps(original), encoding="utf-8")
|
||||
raw_before = proj.read_text(encoding="utf-8")
|
||||
|
||||
window = FakeWindow()
|
||||
window.project_file_name = lambda: str(proj)
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands, "register_bridge_handshake_listener", lambda _cb: None
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "windows", lambda: [window])
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **k: None)
|
||||
monkeypatch.setattr(commands, "_trace_event", lambda *a, **k: None)
|
||||
|
||||
commands.register_sessions_transport_hooks()
|
||||
|
||||
assert proj.read_text(encoding="utf-8") == raw_before
|
||||
|
||||
|
||||
def test_sessions_diagnose_lsp_workspace_shows_panel(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
258
sublime/tests/test_eager_hydrate.py
Normal file
258
sublime/tests/test_eager_hydrate.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Unit tests for :mod:`sessions.eager_hydrate`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.eager_hydrate import (
|
||||
DEFAULT_BATCH_SIZE,
|
||||
DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
EagerHydrateSummary,
|
||||
batched,
|
||||
find_placeholder_candidates,
|
||||
normalize_eager_hydrate_basenames,
|
||||
run_eager_hydrate,
|
||||
)
|
||||
|
||||
|
||||
def _make_placeholder(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
|
||||
|
||||
def _make_nonempty(path: Path, body: bytes = b"[package]\nname='x'\n") -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(body)
|
||||
|
||||
|
||||
def test_find_placeholder_candidates_picks_only_allowed_zero_byte_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_make_placeholder(tmp_path / "Cargo.toml")
|
||||
_make_placeholder(tmp_path / "src" / "Cargo.toml")
|
||||
_make_placeholder(tmp_path / "README.md") # disallowed basename
|
||||
_make_nonempty(tmp_path / "pyproject.toml") # already hydrated
|
||||
|
||||
found = sorted(
|
||||
find_placeholder_candidates(tmp_path, DEFAULT_EAGER_HYDRATE_BASENAMES)
|
||||
)
|
||||
|
||||
assert found == sorted([tmp_path / "Cargo.toml", tmp_path / "src" / "Cargo.toml"])
|
||||
|
||||
|
||||
def test_find_placeholder_candidates_skips_extern_subtree(tmp_path: Path) -> None:
|
||||
_make_placeholder(tmp_path / "__extern" / "Cargo.toml")
|
||||
_make_placeholder(tmp_path / "pkg" / "Cargo.toml")
|
||||
|
||||
found = list(find_placeholder_candidates(tmp_path, DEFAULT_EAGER_HYDRATE_BASENAMES))
|
||||
|
||||
assert found == [tmp_path / "pkg" / "Cargo.toml"]
|
||||
|
||||
|
||||
def test_find_placeholder_candidates_returns_empty_when_root_missing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
missing = tmp_path / "nope"
|
||||
out = list(find_placeholder_candidates(missing, DEFAULT_EAGER_HYDRATE_BASENAMES))
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_find_placeholder_candidates_returns_empty_when_allow_list_empty(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_make_placeholder(tmp_path / "Cargo.toml")
|
||||
out = list(find_placeholder_candidates(tmp_path, ()))
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_batched_yields_in_order_and_respects_size() -> None:
|
||||
items = [Path("a"), Path("b"), Path("c"), Path("d"), Path("e")]
|
||||
batches = list(batched(items, 2))
|
||||
assert batches == [
|
||||
[Path("a"), Path("b")],
|
||||
[Path("c"), Path("d")],
|
||||
[Path("e")],
|
||||
]
|
||||
|
||||
|
||||
def test_batched_collapses_nonpositive_size_to_one() -> None:
|
||||
items = [Path("a"), Path("b")]
|
||||
assert list(batched(items, 0)) == [[Path("a")], [Path("b")]]
|
||||
assert list(batched(items, -5)) == [[Path("a")], [Path("b")]]
|
||||
|
||||
|
||||
def test_run_eager_hydrate_fetches_all_placeholders(tmp_path: Path) -> None:
|
||||
_make_placeholder(tmp_path / "Cargo.toml")
|
||||
_make_placeholder(tmp_path / "sub" / "Cargo.lock")
|
||||
calls: List[Path] = []
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
calls.append(path)
|
||||
path.write_bytes(b"content")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml", "Cargo.lock"),
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary == EagerHydrateSummary(hydrated=2, skipped_existing=0, failed=0)
|
||||
assert sorted(calls) == sorted(
|
||||
[tmp_path / "Cargo.toml", tmp_path / "sub" / "Cargo.lock"]
|
||||
)
|
||||
|
||||
|
||||
def test_run_eager_hydrate_counts_failures_without_aborting(tmp_path: Path) -> None:
|
||||
good = tmp_path / "Cargo.toml"
|
||||
bad = tmp_path / "pyproject.toml"
|
||||
_make_placeholder(good)
|
||||
_make_placeholder(bad)
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
if path == bad:
|
||||
return False
|
||||
path.write_bytes(b"ok")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml", "pyproject.toml"),
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary.hydrated == 1
|
||||
assert summary.failed == 1
|
||||
assert summary.skipped_existing == 0
|
||||
|
||||
|
||||
def test_run_eager_hydrate_counts_raising_fetch_as_failure(tmp_path: Path) -> None:
|
||||
_make_placeholder(tmp_path / "Cargo.toml")
|
||||
|
||||
def fetch_fn(_path: Path) -> bool:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary == EagerHydrateSummary(hydrated=0, skipped_existing=0, failed=1)
|
||||
|
||||
|
||||
def test_run_eager_hydrate_skips_when_placeholder_already_filled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Two placeholders at enumeration time; while hydrating one, a concurrent
|
||||
# code path fills the other. Recheck inside ``run_eager_hydrate`` must
|
||||
# treat the now-non-empty peer as ``skipped_existing`` rather than
|
||||
# failing or re-fetching.
|
||||
first = tmp_path / "a" / "Cargo.toml"
|
||||
second = tmp_path / "b" / "Cargo.toml"
|
||||
_make_placeholder(first)
|
||||
_make_placeholder(second)
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
# Whichever placeholder runs first, clobber its sibling so the
|
||||
# sibling's recheck trips the ``skipped_existing`` branch regardless
|
||||
# of filesystem ordering.
|
||||
peer = second if path == first else first
|
||||
path.write_bytes(b"fetched body")
|
||||
peer.write_bytes(b"concurrent body")
|
||||
return True
|
||||
|
||||
# Batch size 8 forces both placeholders into one batch, so enumeration
|
||||
# completes before any fetch runs.
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
batch_size=8,
|
||||
batch_sleep_s=0,
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary.hydrated == 1
|
||||
assert summary.skipped_existing == 1
|
||||
assert summary.failed == 0
|
||||
|
||||
|
||||
def test_run_eager_hydrate_sleeps_between_batches_but_not_before_first(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
for i in range(5):
|
||||
_make_placeholder(tmp_path / "pkg{}".format(i) / "Cargo.toml")
|
||||
|
||||
sleeps: List[float] = []
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
path.write_bytes(b"x")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
batch_size=2,
|
||||
batch_sleep_s=0.123,
|
||||
sleep_fn=lambda s: sleeps.append(s),
|
||||
)
|
||||
|
||||
assert summary.hydrated == 5
|
||||
# 5 items in batches of 2 => batches [2, 2, 1]; sleep fires before
|
||||
# batches 2 and 3, i.e. twice.
|
||||
assert sleeps == [0.123, 0.123]
|
||||
|
||||
|
||||
def test_run_eager_hydrate_skips_sleep_when_interval_zero(tmp_path: Path) -> None:
|
||||
for i in range(3):
|
||||
_make_placeholder(tmp_path / "pkg{}".format(i) / "Cargo.toml")
|
||||
sleeps: List[float] = []
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
path.write_bytes(b"x")
|
||||
return True
|
||||
|
||||
run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
batch_size=1,
|
||||
batch_sleep_s=0.0,
|
||||
sleep_fn=lambda s: sleeps.append(s),
|
||||
)
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_default_batch_size_is_capped_low_enough_for_edr() -> None:
|
||||
# Documented batch size is 20 per spec; guard against silent bumps.
|
||||
assert DEFAULT_BATCH_SIZE == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
(None, DEFAULT_EAGER_HYDRATE_BASENAMES),
|
||||
("Cargo.toml", DEFAULT_EAGER_HYDRATE_BASENAMES), # scalar -> default
|
||||
([], ()), # explicit empty list disables
|
||||
(
|
||||
["Cargo.toml", "Cargo.toml", "pyproject.toml"],
|
||||
("Cargo.toml", "pyproject.toml"),
|
||||
),
|
||||
(
|
||||
["Cargo.toml", "", " ", 42, None, "pyproject.toml"],
|
||||
("Cargo.toml", "pyproject.toml"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_normalize_eager_hydrate_basenames(
|
||||
raw: object,
|
||||
expected: Tuple[str, ...],
|
||||
) -> None:
|
||||
assert normalize_eager_hydrate_basenames(raw) == expected
|
||||
@@ -45,7 +45,20 @@ pytestmark = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
_HOST_ALIAS = "integration-fake-host"
|
||||
_WORKSPACE_VERSION = ssh_ft._REMOTE_SESSION_HELPER_CACHE_VERSION
|
||||
|
||||
|
||||
def _workspace_version_from_cargo() -> str:
|
||||
"""Read ``[workspace.package].version`` from rust/Cargo.toml (py3.8-safe)."""
|
||||
cargo = Path(__file__).resolve().parents[2] / "rust" / "Cargo.toml"
|
||||
for line in cargo.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("version") and "=" in stripped:
|
||||
_, _, rhs = stripped.partition("=")
|
||||
return rhs.strip().strip('"')
|
||||
raise RuntimeError("rust/Cargo.toml has no [workspace.package].version")
|
||||
|
||||
|
||||
_WORKSPACE_VERSION = _workspace_version_from_cargo()
|
||||
|
||||
# Capture the real _execute_rust_bridge_request before conftest.py's
|
||||
# autouse ``disable_rust_bridge`` fixture stubs it to return None. We
|
||||
|
||||
827
sublime/tests/test_jupyter_hosting.py
Normal file
827
sublime/tests/test_jupyter_hosting.py
Normal file
@@ -0,0 +1,827 @@
|
||||
"""Unit tests for remote Jupyter Lab hosting primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import signal
|
||||
import threading
|
||||
from dataclasses import FrozenInstanceError
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.jupyter_hosting import (
|
||||
JupyterHostingError,
|
||||
JupyterServerInfo,
|
||||
JupyterSessionManager,
|
||||
_parse_remote_port_from_log,
|
||||
build_notebook_url,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# build_notebook_url
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_server(
|
||||
*,
|
||||
workspace_root: str = "/home/user/proj",
|
||||
local_port: int = 9000,
|
||||
token: str = "tok123",
|
||||
) -> JupyterServerInfo:
|
||||
return JupyterServerInfo(
|
||||
host_alias="dev",
|
||||
workspace_root=workspace_root,
|
||||
remote_port=8888,
|
||||
local_port=local_port,
|
||||
token=token,
|
||||
pid=1234,
|
||||
tunnel_pid=5678,
|
||||
started_at=100.0,
|
||||
)
|
||||
|
||||
|
||||
def test_build_notebook_url_with_path_inside_workspace_returns_tree_url() -> None:
|
||||
server = _fake_server(
|
||||
workspace_root="/home/user/proj", local_port=9000, token="tok123"
|
||||
)
|
||||
url = build_notebook_url(server, "/home/user/proj/nb/a.ipynb")
|
||||
assert url == "http://127.0.0.1:9000/lab/tree/nb/a.ipynb?token=tok123"
|
||||
|
||||
|
||||
def test_build_notebook_url_with_path_outside_workspace_returns_lab_only(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
server = _fake_server(workspace_root="/home/user/proj")
|
||||
with caplog.at_level("INFO", logger="sessions.jupyter_hosting"):
|
||||
url = build_notebook_url(server, "/etc/hosts")
|
||||
assert url == "http://127.0.0.1:9000/lab?token=tok123"
|
||||
assert any("not inside workspace_root" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_build_notebook_url_with_none_path_returns_lab_only() -> None:
|
||||
server = _fake_server()
|
||||
assert build_notebook_url(server, None) == "http://127.0.0.1:9000/lab?token=tok123"
|
||||
|
||||
|
||||
def test_build_notebook_url_percent_encodes_relative_path_segments() -> None:
|
||||
server = _fake_server(workspace_root="/srv/proj")
|
||||
url = build_notebook_url(server, "/srv/proj/sub dir/a b.ipynb")
|
||||
# Space becomes %20; slashes inside the relative path stay literal.
|
||||
assert url == ("http://127.0.0.1:9000/lab/tree/sub%20dir/a%20b.ipynb?token=tok123")
|
||||
|
||||
|
||||
def test_build_notebook_url_path_equal_to_workspace_returns_lab_only() -> None:
|
||||
server = _fake_server(workspace_root="/srv/proj")
|
||||
assert build_notebook_url(server, "/srv/proj") == (
|
||||
"http://127.0.0.1:9000/lab?token=tok123"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _parse_remote_port_from_log
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_remote_port_extracts_first_bound_url() -> None:
|
||||
log = (
|
||||
"[I 2026-04-23 09:00:00.000 ServerApp] Jupyter Server starting\n"
|
||||
"[I ServerApp] http://127.0.0.1:8891/lab?token=abcd\n"
|
||||
"[I ServerApp] http://127.0.0.1:9999/lab?token=abcd\n"
|
||||
)
|
||||
assert _parse_remote_port_from_log(log) == 8891
|
||||
|
||||
|
||||
def test_parse_remote_port_returns_none_when_no_url_yet() -> None:
|
||||
assert _parse_remote_port_from_log("starting up...\n") is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# JupyterServerInfo dataclass invariants
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_jupyter_server_info_is_frozen_and_hashable() -> None:
|
||||
info = _fake_server()
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
info.local_port = 1 # type: ignore[misc]
|
||||
# Hashable: usable as a dict / set key.
|
||||
assert hash(info) == hash(_fake_server())
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers for stubbed manager tests
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RunRecorder:
|
||||
"""Records subprocess.run calls and replays scripted responses in order."""
|
||||
|
||||
def __init__(self, responses: List[Tuple[int, str, str]]) -> None:
|
||||
# responses: list of (returncode, stdout, stderr)
|
||||
self._responses = list(responses)
|
||||
self.calls: List[List[str]] = []
|
||||
|
||||
def __call__(self, argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
self.calls.append(list(argv))
|
||||
if not self._responses:
|
||||
# Default: succeed silently (for teardown / cleanup).
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
rc, out, err = self._responses.pop(0)
|
||||
return SimpleNamespace(returncode=rc, stdout=out, stderr=err)
|
||||
|
||||
|
||||
class _PopenRecorder:
|
||||
"""Records Popen invocations and returns an object with a fixed PID."""
|
||||
|
||||
def __init__(self, pid: int = 424242) -> None:
|
||||
self.calls: List[List[str]] = []
|
||||
self.pid = pid
|
||||
|
||||
def __call__(self, argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
self.calls.append(list(argv))
|
||||
return SimpleNamespace(pid=self.pid)
|
||||
|
||||
|
||||
def _build_manager(
|
||||
*,
|
||||
popen: _PopenRecorder,
|
||||
run: _RunRecorder,
|
||||
alive_pids: set,
|
||||
tokens: List[str],
|
||||
local_port: int = 54321,
|
||||
clock_values: Any = None,
|
||||
connect_ok: bool = True,
|
||||
) -> JupyterSessionManager:
|
||||
clock_iter = iter(clock_values) if clock_values is not None else None
|
||||
|
||||
def clock() -> float:
|
||||
if clock_iter is None:
|
||||
return 100.0
|
||||
try:
|
||||
return next(clock_iter)
|
||||
except StopIteration:
|
||||
return 1e9 # past any deadline, just in case
|
||||
|
||||
token_iter = iter(tokens)
|
||||
|
||||
def token_factory() -> str:
|
||||
return next(token_iter)
|
||||
|
||||
def connect_probe(port: int) -> None:
|
||||
if not connect_ok:
|
||||
raise OSError(f"refused {port}")
|
||||
|
||||
manager = JupyterSessionManager(
|
||||
ssh_command_builder=lambda alias: ["ssh", "-F", "/fake/config", alias],
|
||||
popen=popen,
|
||||
run=run,
|
||||
sleep=lambda _s: None,
|
||||
clock=clock,
|
||||
connect_probe=connect_probe,
|
||||
port_picker=lambda: local_port,
|
||||
token_factory=token_factory,
|
||||
)
|
||||
# Replace the default aliveness check with one backed by the test set.
|
||||
manager._tunnel_is_alive = lambda pid: pid in alive_pids # type: ignore[assignment]
|
||||
return manager
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# ensure_started
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ensure_started_builds_correct_ssh_argv_and_returns_info() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
# 1) remote spawn → stdout with PID; 2) cat log → URL with bound port.
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=tok-1\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen,
|
||||
run=run,
|
||||
alive_pids=alive,
|
||||
tokens=["tok-1"],
|
||||
local_port=54321,
|
||||
)
|
||||
|
||||
info = manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
assert info.host_alias == "dev"
|
||||
assert info.workspace_root == "/srv/proj"
|
||||
assert info.remote_port == 8891
|
||||
assert info.local_port == 54321
|
||||
assert info.token == "tok-1"
|
||||
assert info.pid == 4242
|
||||
assert info.tunnel_pid == 7777
|
||||
|
||||
# First ssh call: remote jupyter spawn via bash -lc ...
|
||||
spawn_argv = run.calls[0]
|
||||
assert spawn_argv[:3] == ["ssh", "-F", "/fake/config"]
|
||||
assert spawn_argv[3] == "dev"
|
||||
assert spawn_argv[4:6] == ["bash", "-lc"]
|
||||
remote_script = spawn_argv[6]
|
||||
assert "nohup jupyter lab --no-browser" in remote_script
|
||||
assert "--ServerApp.ip=127.0.0.1" in remote_script
|
||||
assert "--ServerApp.port=0" in remote_script
|
||||
assert "--ServerApp.token=tok-1" in remote_script
|
||||
assert "--ServerApp.root_dir=/srv/proj" in remote_script
|
||||
assert "~/.sessions/jupyter-tok-1.log" in remote_script
|
||||
assert remote_script.rstrip().endswith("echo $!")
|
||||
|
||||
# Second ssh call: cat log.
|
||||
log_argv = run.calls[1]
|
||||
assert log_argv == [
|
||||
"ssh",
|
||||
"-F",
|
||||
"/fake/config",
|
||||
"dev",
|
||||
"cat",
|
||||
"~/.sessions/jupyter-tok-1.log",
|
||||
]
|
||||
|
||||
# Local tunnel Popen argv.
|
||||
assert popen.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"-N",
|
||||
"-L",
|
||||
"127.0.0.1:54321:127.0.0.1:8891",
|
||||
"dev",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_ensure_started_is_idempotent_when_tunnel_still_alive() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=alive, tokens=["t"], local_port=33333
|
||||
)
|
||||
|
||||
first = manager.ensure_started("dev", "/srv/proj")
|
||||
second = manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
assert first is second
|
||||
# No additional Popen / run invocations for the second call.
|
||||
assert len(popen.calls) == 1
|
||||
assert len(run.calls) == 2
|
||||
|
||||
|
||||
def test_ensure_started_respawns_when_previous_tunnel_is_dead() -> None:
|
||||
popen = _PopenRecorder(pid=9999)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
# First launch:
|
||||
(0, "100\n", ""),
|
||||
(0, "http://127.0.0.1:8900/lab?token=a\n", ""),
|
||||
# Teardown of stale (we will not drive that path here — ensure_started
|
||||
# just drops the entry). Second launch:
|
||||
(0, "200\n", ""),
|
||||
(0, "http://127.0.0.1:8901/lab?token=b\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {9999}
|
||||
manager = _build_manager(
|
||||
popen=popen,
|
||||
run=run,
|
||||
alive_pids=alive,
|
||||
tokens=["a", "b"],
|
||||
local_port=40000,
|
||||
)
|
||||
|
||||
first = manager.ensure_started("dev", "/srv/proj")
|
||||
# Simulate tunnel dying externally.
|
||||
alive.discard(first.tunnel_pid)
|
||||
|
||||
second = manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
assert first is not second
|
||||
assert second.token == "b"
|
||||
assert second.remote_port == 8901
|
||||
assert len(popen.calls) == 2
|
||||
|
||||
|
||||
def test_ensure_started_raises_when_local_probe_fails_and_tears_down() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen,
|
||||
run=run,
|
||||
alive_pids=alive,
|
||||
tokens=["t"],
|
||||
local_port=55555,
|
||||
connect_ok=False,
|
||||
)
|
||||
|
||||
# Capture os.kill to avoid touching real processes during teardown.
|
||||
kill_calls: List[Tuple[int, int]] = []
|
||||
manager._kill_local_tunnel = lambda pid: kill_calls.append((pid, signal.SIGTERM)) # type: ignore[assignment]
|
||||
|
||||
with pytest.raises(JupyterHostingError, match="local tunnel probe"):
|
||||
manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
# Teardown issued remote kill + log rm via self._run.
|
||||
remote_kill = [c for c in run.calls if c[-2:] == ["kill", "4242"]]
|
||||
assert remote_kill, f"expected remote kill call; got {run.calls}"
|
||||
log_rm = [c for c in run.calls if c[-3] == "rm" and c[-2] == "-f"]
|
||||
assert log_rm, f"expected remote log rm call; got {run.calls}"
|
||||
assert kill_calls == [(7777, signal.SIGTERM)]
|
||||
|
||||
|
||||
def test_ensure_started_raises_when_remote_pid_output_is_bogus() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(responses=[(0, "not-a-pid\n", "")])
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=["t"], local_port=1234
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="non-numeric"):
|
||||
manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
|
||||
def test_ensure_started_raises_when_log_never_yields_url(monkeypatch) -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
# After the initial PID response, every subsequent cat returns empty.
|
||||
run = _RunRecorder(responses=[(0, "100\n", "")])
|
||||
|
||||
# clock: first call returns 0 (inside await loop entry), then jumps past
|
||||
# the 15s deadline so the loop gives up immediately on next check.
|
||||
manager = _build_manager(
|
||||
popen=popen,
|
||||
run=run,
|
||||
alive_pids=set(),
|
||||
tokens=["t"],
|
||||
local_port=1234,
|
||||
clock_values=[0.0, 0.0, 100.0, 100.0, 100.0],
|
||||
)
|
||||
|
||||
with pytest.raises(JupyterHostingError, match="timed out"):
|
||||
manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# stop / stop_all
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stop_kills_both_local_and_remote_pids() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=alive, tokens=["t"], local_port=1234
|
||||
)
|
||||
|
||||
kill_log: List[Tuple[int, int]] = []
|
||||
|
||||
def fake_os_kill(pid: int, sig: int) -> None:
|
||||
kill_log.append((pid, sig))
|
||||
if sig == signal.SIGTERM:
|
||||
alive.discard(pid)
|
||||
|
||||
import sessions.jupyter_hosting as jh
|
||||
|
||||
original_os_kill = jh.os.kill
|
||||
jh.os.kill = fake_os_kill # type: ignore[assignment]
|
||||
try:
|
||||
info = manager.ensure_started("dev", "/srv/proj")
|
||||
manager.stop("dev")
|
||||
finally:
|
||||
jh.os.kill = original_os_kill # type: ignore[assignment]
|
||||
|
||||
# Local tunnel SIGTERM issued.
|
||||
assert (info.tunnel_pid, signal.SIGTERM) in kill_log
|
||||
|
||||
# Remote kill argv issued.
|
||||
remote_kill = [c for c in run.calls if c[-2:] == ["kill", "4242"]]
|
||||
assert len(remote_kill) == 1, run.calls
|
||||
|
||||
# Log cleanup argv issued.
|
||||
log_rm = [c for c in run.calls if "rm" in c and "-f" in c]
|
||||
assert len(log_rm) == 1, run.calls
|
||||
|
||||
# Registry empty post-stop.
|
||||
assert manager.get("dev") is None
|
||||
|
||||
|
||||
def test_stop_is_noop_for_unknown_alias() -> None:
|
||||
popen = _PopenRecorder()
|
||||
run = _RunRecorder(responses=[])
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1111
|
||||
)
|
||||
# Should simply not raise.
|
||||
manager.stop("ghost")
|
||||
assert run.calls == []
|
||||
assert popen.calls == []
|
||||
|
||||
|
||||
def test_stop_all_tears_down_every_registered_server() -> None:
|
||||
popen = _PopenRecorder(pid=111)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
# host A launch:
|
||||
(0, "1\n", ""),
|
||||
(0, "http://127.0.0.1:8001/lab?token=a\n", ""),
|
||||
# host B launch:
|
||||
(0, "2\n", ""),
|
||||
(0, "http://127.0.0.1:8002/lab?token=b\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {111}
|
||||
manager = _build_manager(
|
||||
popen=popen,
|
||||
run=run,
|
||||
alive_pids=alive,
|
||||
tokens=["a", "b"],
|
||||
local_port=1234,
|
||||
)
|
||||
|
||||
import sessions.jupyter_hosting as jh
|
||||
|
||||
original = jh.os.kill
|
||||
jh.os.kill = lambda pid, sig: alive.discard(pid) # type: ignore[assignment]
|
||||
try:
|
||||
manager.ensure_started("host-a", "/a")
|
||||
manager.ensure_started("host-b", "/b")
|
||||
assert manager.get("host-a") is not None
|
||||
assert manager.get("host-b") is not None
|
||||
manager.stop_all()
|
||||
finally:
|
||||
jh.os.kill = original # type: ignore[assignment]
|
||||
|
||||
assert manager.get("host-a") is None
|
||||
assert manager.get("host-b") is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Concurrency: two simultaneous ensure_started coalesce into one launch
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_concurrent_ensure_started_coalesces_to_single_launch() -> None:
|
||||
launch_gate = threading.Event()
|
||||
popen_calls: List[List[str]] = []
|
||||
run_calls: List[List[str]] = []
|
||||
launch_counter = {"n": 0}
|
||||
|
||||
def run(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
run_calls.append(list(argv))
|
||||
# Spawn call contains "nohup jupyter lab" inside the bash -lc script.
|
||||
if any("nohup jupyter lab" in arg for arg in argv):
|
||||
launch_counter["n"] += 1
|
||||
# Block the first launch so the second thread has to wait on
|
||||
# the registry lock.
|
||||
launch_gate.wait(timeout=2.0)
|
||||
return SimpleNamespace(returncode=0, stdout="4242\n", stderr="")
|
||||
if "cat" in argv:
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="http://127.0.0.1:8891/lab?token=tok\n",
|
||||
stderr="",
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
def popen(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
popen_calls.append(list(argv))
|
||||
return SimpleNamespace(pid=7777)
|
||||
|
||||
alive: set = {7777}
|
||||
manager = JupyterSessionManager(
|
||||
ssh_command_builder=lambda alias: ["ssh", alias],
|
||||
popen=popen,
|
||||
run=run,
|
||||
sleep=lambda _s: None,
|
||||
clock=lambda: 100.0,
|
||||
connect_probe=lambda _p: None,
|
||||
port_picker=lambda: 54321,
|
||||
token_factory=lambda: "tok",
|
||||
)
|
||||
manager._tunnel_is_alive = lambda pid: pid in alive # type: ignore[assignment]
|
||||
|
||||
results: Dict[int, JupyterServerInfo] = {}
|
||||
errors: Dict[int, BaseException] = {}
|
||||
|
||||
def worker(idx: int) -> None:
|
||||
try:
|
||||
results[idx] = manager.ensure_started("dev", "/srv/proj")
|
||||
except BaseException as exc: # pragma: no cover - surfaced via assertion
|
||||
errors[idx] = exc
|
||||
|
||||
t1 = threading.Thread(target=worker, args=(1,))
|
||||
t2 = threading.Thread(target=worker, args=(2,))
|
||||
t1.start()
|
||||
# Give thread 1 a moment to acquire the lock and start the launch.
|
||||
t2.start()
|
||||
# Release the gate so the first launch completes.
|
||||
launch_gate.set()
|
||||
t1.join(timeout=5.0)
|
||||
t2.join(timeout=5.0)
|
||||
|
||||
assert not errors, errors
|
||||
assert launch_counter["n"] == 1
|
||||
# Both callers observe the same server.
|
||||
assert results[1] is results[2]
|
||||
# Only one remote spawn + one local tunnel were issued.
|
||||
assert len(popen_calls) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Defaults sanity
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_default_manager_uses_subprocess_defaults() -> None:
|
||||
# Just exercise the no-arg constructor. The default run/popen wrap
|
||||
# subprocess with the Windows-console suppression kwargs, so they're
|
||||
# not the raw subprocess.run / subprocess.Popen callables any more;
|
||||
# verify they're callable and not None instead.
|
||||
manager = JupyterSessionManager()
|
||||
assert manager.get("anywhere") is None
|
||||
assert callable(manager._popen)
|
||||
assert callable(manager._run)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Phase B: kernel_python / workspace_cache_key wiring
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _kernel_install_argv_matches(argv: List[str], kernel_python: str) -> bool:
|
||||
"""True iff ``argv`` is ``<ssh prefix> <quoted: python -m pip install ipykernel>``.
|
||||
|
||||
The helper passes the remote command as a single shell-quoted string so
|
||||
SSH can't mangle args that contain spaces.
|
||||
"""
|
||||
if not argv:
|
||||
return False
|
||||
remote = argv[-1]
|
||||
return (
|
||||
kernel_python in remote and "-m pip install" in remote and "ipykernel" in remote
|
||||
)
|
||||
|
||||
|
||||
def _kernelspec_install_argv_matches(
|
||||
argv: List[str], kernel_python: str, kernel_name: str
|
||||
) -> bool:
|
||||
"""True iff ``argv`` trailing remote-cmd string carries the kernelspec install."""
|
||||
if not argv:
|
||||
return False
|
||||
remote = argv[-1]
|
||||
return (
|
||||
kernel_python in remote
|
||||
and "-m ipykernel install" in remote
|
||||
and "--user" in remote
|
||||
and "--name " + kernel_name in remote
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_started_without_kernel_python_issues_no_extra_ssh_calls() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=alive, tokens=["t"], local_port=1111
|
||||
)
|
||||
|
||||
info = manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
# Exactly two ssh run() calls (spawn + log cat) — no pip install, no
|
||||
# kernelspec install when no interpreter was requested.
|
||||
assert len(run.calls) == 2
|
||||
assert info.kernel_name is None
|
||||
spawn_script = run.calls[0][-1]
|
||||
assert "MappingKernelManager" not in spawn_script
|
||||
|
||||
|
||||
def test_ensure_started_with_kernel_python_installs_and_registers_and_passes_flag() -> (
|
||||
None
|
||||
):
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "", ""), # pip install ipykernel
|
||||
(0, "kernelspec installed\n", ""), # ipykernel install
|
||||
(0, "4242\n", ""), # remote jupyter spawn
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""), # cat log
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=alive, tokens=["t"], local_port=22222
|
||||
)
|
||||
|
||||
info = manager.ensure_started(
|
||||
"dev",
|
||||
"/srv/proj",
|
||||
kernel_python="/home/u/.venv/bin/python",
|
||||
workspace_cache_key="abc123xyz9999",
|
||||
)
|
||||
|
||||
assert info.kernel_name == "sessions-abc123xyz999"
|
||||
|
||||
# First call: pip install ipykernel.
|
||||
assert _kernel_install_argv_matches(run.calls[0], "/home/u/.venv/bin/python"), (
|
||||
run.calls[0]
|
||||
)
|
||||
# Second call: ipykernel install with the derived kernel name.
|
||||
assert _kernelspec_install_argv_matches(
|
||||
run.calls[1], "/home/u/.venv/bin/python", "sessions-abc123xyz999"
|
||||
), run.calls[1]
|
||||
# Third call: remote jupyter spawn whose bash script carries the flag.
|
||||
spawn_script = run.calls[2][-1]
|
||||
assert "MappingKernelManager.default_kernel_name=sessions-abc123xyz999" in (
|
||||
spawn_script
|
||||
), spawn_script
|
||||
|
||||
|
||||
def test_ensure_started_raises_when_ipykernel_install_fails() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(1, "", "ERROR: pip broke"),
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=["t"], local_port=1234
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="ipykernel install"):
|
||||
manager.ensure_started(
|
||||
"dev",
|
||||
"/srv/proj",
|
||||
kernel_python="/opt/python",
|
||||
workspace_cache_key="deadbeefcafe01",
|
||||
)
|
||||
# No jupyter spawn attempted after the install failure.
|
||||
assert popen.calls == []
|
||||
|
||||
|
||||
def test_ensure_started_rewrites_tilde_path_to_home_expansion() -> None:
|
||||
# Users who enter ``~/…`` in the interpreter picker must not fail with
|
||||
# ``zsh:1: no such file or directory: ~/…``. The quoter turns leading
|
||||
# ``~/`` into ``"$HOME/…"`` so the remote shell expands $HOME instead of
|
||||
# treating the tilde as a literal path component.
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(responses=[(0, "", ""), (0, "", "")])
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
manager.register_kernelspec_only(
|
||||
"dev", "~/remote-ssh/sessions/.venv/bin/python", "sessions-xyz"
|
||||
)
|
||||
# The shell-quoted remote command must use $HOME, not the literal tilde.
|
||||
for call in run.calls:
|
||||
remote_cmd = call[-1]
|
||||
assert "~/remote-ssh" not in remote_cmd, remote_cmd
|
||||
assert '"$HOME/remote-ssh/sessions/.venv/bin/python"' in remote_cmd
|
||||
|
||||
|
||||
def test_ensure_ipykernel_installs_pip_via_ensurepip_when_missing() -> None:
|
||||
# uv-created venvs ship without pip, so the first ``pip install`` call
|
||||
# exits 1 with "No module named pip". The manager should bootstrap pip
|
||||
# via ``ensurepip`` and retry the install.
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
# First pip install: fails with "No module named pip".
|
||||
(1, "", "/home/u/.venv/bin/python: No module named pip"),
|
||||
# ensurepip bootstrap succeeds.
|
||||
(0, "Successfully installed pip", ""),
|
||||
# Retry pip install: succeeds.
|
||||
(0, "", ""),
|
||||
# kernelspec install succeeds.
|
||||
(0, "", ""),
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
manager.register_kernelspec_only("dev", "/home/u/.venv/bin/python", "sessions-xyz")
|
||||
|
||||
# Expect exactly: pip install, ensurepip, pip install retry, kernelspec.
|
||||
# Trailing arg of each call is the shell-quoted remote command string.
|
||||
assert len(run.calls) == 4
|
||||
assert "-m pip install" in run.calls[0][-1]
|
||||
assert "ensurepip" in run.calls[1][-1]
|
||||
assert "-m pip install" in run.calls[2][-1]
|
||||
assert "-m ipykernel install" in run.calls[3][-1]
|
||||
|
||||
|
||||
def test_ensure_ipykernel_raises_when_ensurepip_also_fails() -> None:
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(1, "", "No module named pip"),
|
||||
(1, "", "ensurepip is disabled in Debian/Ubuntu"),
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="ensurepip"):
|
||||
manager.register_kernelspec_only(
|
||||
"dev", "/home/u/.venv/bin/python", "sessions-xyz"
|
||||
)
|
||||
|
||||
|
||||
def test_register_kernelspec_only_treats_already_exists_as_success() -> None:
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "", ""), # pip install succeeds
|
||||
(
|
||||
1,
|
||||
"",
|
||||
"KernelSpec sessions-deadbeefcafe already exists at /home/u/.local/...",
|
||||
), # kernelspec install returns non-zero + "already exists"
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
# Must not raise even though the underlying command returned non-zero.
|
||||
manager.register_kernelspec_only("dev", "/opt/python", "sessions-deadbeefcafe")
|
||||
assert len(run.calls) == 2
|
||||
|
||||
|
||||
def test_register_kernelspec_only_raises_on_other_failures() -> None:
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "", ""), # pip install succeeds
|
||||
(1, "", "permission denied"), # kernelspec install truly failed
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="kernelspec install"):
|
||||
manager.register_kernelspec_only("dev", "/opt/python", "sessions-deadbeefcafe")
|
||||
|
||||
|
||||
def test_register_kernelspec_only_rejects_blank_inputs() -> None:
|
||||
manager = _build_manager(
|
||||
popen=_PopenRecorder(),
|
||||
run=_RunRecorder(responses=[]),
|
||||
alive_pids=set(),
|
||||
tokens=[],
|
||||
local_port=1,
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="kernel_python"):
|
||||
manager.register_kernelspec_only("dev", "", "sessions-x")
|
||||
with pytest.raises(JupyterHostingError, match="kernel_name"):
|
||||
manager.register_kernelspec_only("dev", "/opt/python", "")
|
||||
|
||||
|
||||
def test_ensure_started_without_cache_key_hashes_workspace_root() -> None:
|
||||
import hashlib as _hashlib
|
||||
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "", ""),
|
||||
(0, "kernelspec installed\n", ""),
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=alive, tokens=["t"], local_port=22222
|
||||
)
|
||||
workspace_root = "/srv/proj-no-key"
|
||||
expected = "sessions-" + _hashlib.sha1(workspace_root.encode()).hexdigest()[:12]
|
||||
|
||||
info = manager.ensure_started(
|
||||
"dev",
|
||||
workspace_root,
|
||||
kernel_python="/opt/python",
|
||||
)
|
||||
|
||||
assert info.kernel_name == expected
|
||||
@@ -12,7 +12,9 @@ from sessions.lsp_project_wiring import (
|
||||
SESSIONS_LSP_RUFF_CLIENT_KEY,
|
||||
SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY,
|
||||
SESSIONS_REMOTE_LSP_MANAGED_KEY,
|
||||
build_managed_lsp_settings_block,
|
||||
collect_lsp_diagnostics_snapshot,
|
||||
disable_stale_managed_lsp_rows_on_disk,
|
||||
existing_managed_broker_sockets,
|
||||
explain_lsp_attach_blockers,
|
||||
format_lsp_diagnostics_panel_text,
|
||||
@@ -477,6 +479,42 @@ def test_existing_managed_broker_sockets_handles_non_object_root(
|
||||
assert existing_managed_broker_sockets(project_path) == []
|
||||
|
||||
|
||||
def test_merge_sessions_lsp_wires_active_python_path_only_on_pyright(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
merged = merge_sessions_lsp_into_project_data(
|
||||
{"settings": {}},
|
||||
bridge_path="/bin/local_bridge",
|
||||
broker_socket="/tmp/broker.sock",
|
||||
workspace_id="ws1",
|
||||
remote_workspace_root="/home/u/proj",
|
||||
host_alias="dev",
|
||||
local_cache_root=str(tmp_path / "c"),
|
||||
active_python_path="/remote/.venv/bin/python",
|
||||
)
|
||||
lsp = merged["settings"]["LSP"]
|
||||
pyright = lsp[SESSIONS_LSP_PYRIGHT_CLIENT_KEY]
|
||||
assert pyright["settings"]["python"]["pythonPath"] == "/remote/.venv/bin/python"
|
||||
ruff = lsp[SESSIONS_LSP_RUFF_CLIENT_KEY]
|
||||
assert "python" not in ruff.get("settings", {})
|
||||
rust = lsp[SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY]
|
||||
assert "python" not in rust.get("settings", {})
|
||||
|
||||
|
||||
def test_merge_sessions_lsp_omits_python_path_when_not_set(tmp_path: Path) -> None:
|
||||
merged = merge_sessions_lsp_into_project_data(
|
||||
{"settings": {}},
|
||||
bridge_path="/bin/local_bridge",
|
||||
broker_socket="/tmp/broker.sock",
|
||||
workspace_id="ws1",
|
||||
remote_workspace_root="/home/u/proj",
|
||||
host_alias="dev",
|
||||
local_cache_root=str(tmp_path / "c"),
|
||||
)
|
||||
pyright = merged["settings"]["LSP"][SESSIONS_LSP_PYRIGHT_CLIENT_KEY]
|
||||
assert "python" not in pyright.get("settings", {})
|
||||
|
||||
|
||||
def test_existing_managed_broker_sockets_missing_command_arg(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -497,3 +535,219 @@ def test_existing_managed_broker_sockets_missing_command_arg(
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert existing_managed_broker_sockets(project_path) == [("LSP-pyright", "")]
|
||||
|
||||
|
||||
def test_build_managed_lsp_settings_block_disabled_when_flag_false(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``managed_lsp_enabled=False`` writes ``enabled: false`` per row."""
|
||||
block = build_managed_lsp_settings_block(
|
||||
bridge_path="/bridge",
|
||||
broker_socket="/sock",
|
||||
workspace_id="ws1",
|
||||
remote_workspace_root="/r",
|
||||
host_alias="dev",
|
||||
local_cache_root=str(tmp_path / "c"),
|
||||
managed_lsp_enabled=False,
|
||||
)
|
||||
for client_key, row in block.items():
|
||||
assert row["enabled"] is False, client_key
|
||||
assert row[SESSIONS_REMOTE_LSP_MANAGED_KEY] is True, client_key
|
||||
|
||||
|
||||
def test_merge_propagates_managed_lsp_disabled(tmp_path: Path) -> None:
|
||||
"""``merge_sessions_lsp_into_project_data`` forwards the disable flag."""
|
||||
merged = merge_sessions_lsp_into_project_data(
|
||||
{"settings": {}},
|
||||
bridge_path="/b",
|
||||
broker_socket="",
|
||||
workspace_id="w",
|
||||
remote_workspace_root="/r",
|
||||
host_alias="h",
|
||||
local_cache_root=str(tmp_path / "c"),
|
||||
managed_lsp_enabled=False,
|
||||
)
|
||||
pyright = merged["settings"]["LSP"][SESSIONS_LSP_PYRIGHT_CLIENT_KEY]
|
||||
assert pyright["enabled"] is False
|
||||
ruff = merged["settings"]["LSP"][SESSIONS_LSP_RUFF_CLIENT_KEY]
|
||||
assert ruff["enabled"] is False
|
||||
|
||||
|
||||
def test_refresh_project_file_lsp_block_propagates_disabled_flag(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
proj = tmp_path / "ws.sublime-project"
|
||||
(tmp_path / "lc").mkdir()
|
||||
proj.write_text(json.dumps({"settings": {}}), encoding="utf-8")
|
||||
merged = refresh_project_file_lsp_block(
|
||||
proj,
|
||||
bridge_path="/bridge",
|
||||
broker_socket="",
|
||||
workspace_id="w",
|
||||
remote_workspace_root="/r",
|
||||
host_alias="h",
|
||||
local_cache_root=str(tmp_path / "lc"),
|
||||
managed_lsp_enabled=False,
|
||||
)
|
||||
pyright = merged["settings"]["LSP"][SESSIONS_LSP_PYRIGHT_CLIENT_KEY]
|
||||
assert pyright["enabled"] is False
|
||||
on_disk = json.loads(proj.read_text(encoding="utf-8"))
|
||||
assert (
|
||||
on_disk["settings"]["LSP"][SESSIONS_LSP_PYRIGHT_CLIENT_KEY]["enabled"] is False
|
||||
)
|
||||
|
||||
|
||||
def test_disable_stale_managed_lsp_rows_flips_enabled_when_socket_missing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
proj = tmp_path / "ws.sublime-project"
|
||||
proj.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"settings": {
|
||||
"LSP": {
|
||||
SESSIONS_LSP_PYRIGHT_CLIENT_KEY: {
|
||||
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
|
||||
"enabled": True,
|
||||
"command": [
|
||||
"/bridge",
|
||||
"lsp-stdio",
|
||||
"--bridge-socket",
|
||||
str(tmp_path / "stale.sock"),
|
||||
],
|
||||
},
|
||||
SESSIONS_LSP_RUFF_CLIENT_KEY: {
|
||||
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
|
||||
"enabled": True,
|
||||
"command": [
|
||||
"/bridge",
|
||||
"lsp-stdio",
|
||||
"--bridge-socket",
|
||||
str(tmp_path / "stale.sock"),
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
flipped = disable_stale_managed_lsp_rows_on_disk(proj)
|
||||
assert flipped == [SESSIONS_LSP_PYRIGHT_CLIENT_KEY, SESSIONS_LSP_RUFF_CLIENT_KEY]
|
||||
after = json.loads(proj.read_text(encoding="utf-8"))
|
||||
rows = after["settings"]["LSP"]
|
||||
assert rows[SESSIONS_LSP_PYRIGHT_CLIENT_KEY]["enabled"] is False
|
||||
assert rows[SESSIONS_LSP_RUFF_CLIENT_KEY]["enabled"] is False
|
||||
|
||||
|
||||
def test_disable_stale_managed_lsp_rows_keeps_live_socket_enabled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
live_sock = tmp_path / "live.sock"
|
||||
live_sock.write_text("", encoding="utf-8")
|
||||
proj = tmp_path / "ws.sublime-project"
|
||||
proj.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"settings": {
|
||||
"LSP": {
|
||||
SESSIONS_LSP_PYRIGHT_CLIENT_KEY: {
|
||||
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
|
||||
"enabled": True,
|
||||
"command": [
|
||||
"/bridge",
|
||||
"lsp-stdio",
|
||||
"--bridge-socket",
|
||||
str(live_sock),
|
||||
],
|
||||
},
|
||||
SESSIONS_LSP_RUFF_CLIENT_KEY: {
|
||||
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
|
||||
"enabled": True,
|
||||
"command": [
|
||||
"/bridge",
|
||||
"lsp-stdio",
|
||||
"--bridge-socket",
|
||||
str(tmp_path / "stale.sock"),
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
flipped = disable_stale_managed_lsp_rows_on_disk(
|
||||
proj, live_broker_socket=str(live_sock)
|
||||
)
|
||||
assert flipped == [SESSIONS_LSP_RUFF_CLIENT_KEY]
|
||||
rows = json.loads(proj.read_text(encoding="utf-8"))["settings"]["LSP"]
|
||||
assert rows[SESSIONS_LSP_PYRIGHT_CLIENT_KEY]["enabled"] is True
|
||||
assert rows[SESSIONS_LSP_RUFF_CLIENT_KEY]["enabled"] is False
|
||||
|
||||
|
||||
def test_disable_stale_managed_lsp_rows_skips_user_managed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
proj = tmp_path / "ws.sublime-project"
|
||||
proj.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"settings": {
|
||||
"LSP": {
|
||||
SESSIONS_LSP_PYRIGHT_CLIENT_KEY: {
|
||||
SESSIONS_REMOTE_LSP_MANAGED_KEY: False,
|
||||
"enabled": True,
|
||||
"command": ["custom-pyright"],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert disable_stale_managed_lsp_rows_on_disk(proj) == []
|
||||
rows = json.loads(proj.read_text(encoding="utf-8"))["settings"]["LSP"]
|
||||
assert rows[SESSIONS_LSP_PYRIGHT_CLIENT_KEY]["enabled"] is True
|
||||
|
||||
|
||||
def test_disable_stale_managed_lsp_rows_handles_missing_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
missing = tmp_path / "does-not-exist.sublime-project"
|
||||
assert disable_stale_managed_lsp_rows_on_disk(missing) == []
|
||||
|
||||
|
||||
def test_disable_stale_managed_lsp_rows_handles_malformed_json(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
proj = tmp_path / "ws.sublime-project"
|
||||
proj.write_text("{not-json", encoding="utf-8")
|
||||
assert disable_stale_managed_lsp_rows_on_disk(proj) == []
|
||||
|
||||
|
||||
def test_disable_stale_managed_lsp_rows_no_op_when_already_disabled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
proj = tmp_path / "ws.sublime-project"
|
||||
original = {
|
||||
"settings": {
|
||||
"LSP": {
|
||||
SESSIONS_LSP_PYRIGHT_CLIENT_KEY: {
|
||||
SESSIONS_REMOTE_LSP_MANAGED_KEY: True,
|
||||
"enabled": False,
|
||||
"command": [
|
||||
"/bridge",
|
||||
"lsp-stdio",
|
||||
"--bridge-socket",
|
||||
"/already/missing.sock",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
proj.write_text(json.dumps(original), encoding="utf-8")
|
||||
raw_before = proj.read_text(encoding="utf-8")
|
||||
assert disable_stale_managed_lsp_rows_on_disk(proj) == []
|
||||
# File contents unchanged when no row needed flipping.
|
||||
assert proj.read_text(encoding="utf-8") == raw_before
|
||||
|
||||
88
sublime/tests/test_managed_remote_extension_catalog.py
Normal file
88
sublime/tests/test_managed_remote_extension_catalog.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Built-in remote extension catalog stays aligned with install specs and wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sessions import lsp_project_wiring
|
||||
from sessions.managed_remote_extension_catalog import (
|
||||
BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG,
|
||||
SESSIONS_LSP_PYRIGHT_CLIENT_KEY,
|
||||
SESSIONS_LSP_RUFF_CLIENT_KEY,
|
||||
SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY,
|
||||
)
|
||||
from sessions.settings_model import DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS
|
||||
|
||||
|
||||
def test_catalog_install_ids_match_default_builtin_specs() -> None:
|
||||
catalog_ids = [
|
||||
e.install_catalog_id for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG
|
||||
]
|
||||
builtin_ids = [s.id for s in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS]
|
||||
assert catalog_ids == builtin_ids
|
||||
|
||||
|
||||
def test_catalog_project_keys_match_managed_client_snapshot() -> None:
|
||||
snap = lsp_project_wiring.collect_lsp_diagnostics_snapshot(
|
||||
host_alias="h",
|
||||
workspace_id="w",
|
||||
remote_workspace_root="/r",
|
||||
local_cache_root="/l",
|
||||
active_file=None,
|
||||
)
|
||||
lsp_keys = [
|
||||
e.project_client_key
|
||||
for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG
|
||||
if e.kind == "lsp"
|
||||
]
|
||||
assert snap["managed_client_ids"] == lsp_keys
|
||||
assert SESSIONS_LSP_PYRIGHT_CLIENT_KEY in lsp_keys
|
||||
assert SESSIONS_LSP_RUFF_CLIENT_KEY in lsp_keys
|
||||
assert SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY in lsp_keys
|
||||
|
||||
|
||||
def test_catalog_contains_jupyter_extension_entry() -> None:
|
||||
entries = [
|
||||
e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "jupyter"
|
||||
]
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.install_catalog_id == "jupyterlab"
|
||||
# LSP-specific fields are cleared for non-LSP kinds.
|
||||
assert entry.project_client_key is None
|
||||
assert entry.bridge_server_id is None
|
||||
assert entry.remote_spawn_argv is None
|
||||
assert entry.sublime_selector is None
|
||||
|
||||
|
||||
def test_catalog_contains_debugger_extension_entry() -> None:
|
||||
entries = [
|
||||
e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "debugger"
|
||||
]
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.install_catalog_id == "debugpy"
|
||||
# Install flow substitutes ``{ACTIVE_PYTHON}`` at runtime via
|
||||
# ``_substitute_active_python_placeholder``.
|
||||
assert any("{ACTIVE_PYTHON}" in part for part in entry.install_argv)
|
||||
assert any("{ACTIVE_PYTHON}" in part for part in entry.remove_argv)
|
||||
assert any("{ACTIVE_PYTHON}" in part for part in entry.probe_argv)
|
||||
# LSP-specific fields are cleared for non-LSP kinds.
|
||||
assert entry.project_client_key is None
|
||||
assert entry.bridge_server_id is None
|
||||
assert entry.remote_spawn_argv is None
|
||||
assert entry.sublime_selector is None
|
||||
|
||||
|
||||
def test_catalog_contains_agent_extension_entries() -> None:
|
||||
entries = [e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "agent"]
|
||||
assert [e.install_catalog_id for e in entries] == [
|
||||
"tmux",
|
||||
"claude-code",
|
||||
"codex-cli",
|
||||
]
|
||||
for entry in entries:
|
||||
# LSP-specific fields are cleared for non-LSP kinds.
|
||||
assert entry.project_client_key is None
|
||||
assert entry.bridge_server_id is None
|
||||
assert entry.remote_spawn_argv is None
|
||||
assert entry.sublime_selector is None
|
||||
assert entry.legacy_project_client_keys == ()
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Built-in remote LSP catalog stays aligned with install specs and wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sessions import lsp_project_wiring
|
||||
from sessions.managed_remote_lsp_catalog import (
|
||||
BUILTIN_MANAGED_REMOTE_LSP_CATALOG,
|
||||
SESSIONS_LSP_PYRIGHT_CLIENT_KEY,
|
||||
SESSIONS_LSP_RUFF_CLIENT_KEY,
|
||||
SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY,
|
||||
)
|
||||
from sessions.settings_model import DEFAULT_BUILTIN_REMOTE_LSP_SERVER_SPECS
|
||||
|
||||
|
||||
def test_catalog_install_ids_match_default_builtin_specs() -> None:
|
||||
catalog_ids = [e.install_catalog_id for e in BUILTIN_MANAGED_REMOTE_LSP_CATALOG]
|
||||
builtin_ids = [s.id for s in DEFAULT_BUILTIN_REMOTE_LSP_SERVER_SPECS]
|
||||
assert catalog_ids == builtin_ids
|
||||
|
||||
|
||||
def test_catalog_project_keys_match_managed_client_snapshot() -> None:
|
||||
snap = lsp_project_wiring.collect_lsp_diagnostics_snapshot(
|
||||
host_alias="h",
|
||||
workspace_id="w",
|
||||
remote_workspace_root="/r",
|
||||
local_cache_root="/l",
|
||||
active_file=None,
|
||||
)
|
||||
keys = [e.project_client_key for e in BUILTIN_MANAGED_REMOTE_LSP_CATALOG]
|
||||
assert snap["managed_client_ids"] == keys
|
||||
assert SESSIONS_LSP_PYRIGHT_CLIENT_KEY in keys
|
||||
assert SESSIONS_LSP_RUFF_CLIENT_KEY in keys
|
||||
assert SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY in keys
|
||||
@@ -49,28 +49,46 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
sys.modules.update(original_modules)
|
||||
|
||||
assert plugin_module.__all__ == [
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
"SessionsDiagnoseLspWorkspaceCommand",
|
||||
"SessionsInstallRemoteLspServerCommand",
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsKillAgentSessionCommand",
|
||||
"SessionsKillRemoteTerminalCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsNewAgentSessionCommand",
|
||||
"SessionsNewRemoteTerminalPaneCommand",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
"SessionsOpenRemoteJupyterCommand",
|
||||
"SessionsOpenRemoteTerminalCommand",
|
||||
"SessionsOpenRemoteTreeCommand",
|
||||
"SessionsOpenSettingsCommand",
|
||||
"SessionsPreviewRemoteAgentPayloadCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsPythonInterpreterStatusListener",
|
||||
"SessionsReconnectCurrentWorkspaceCommand",
|
||||
"SessionsRegisterJupyterKernelCommand",
|
||||
"SessionsRemoteCachedFileSaveListener",
|
||||
"SessionsRemoteLspServerStatusCommand",
|
||||
"SessionsRemoteExtensionStatusCommand",
|
||||
"SessionsRemoteTreeActivateCommand",
|
||||
"SessionsRemoteTreeEventListener",
|
||||
"SessionsRemoteTreeRefreshCommand",
|
||||
"SessionsRemoveRemoteLspServerCommand",
|
||||
"SessionsRemoveRemoteExtensionCommand",
|
||||
"SessionsRenderAgentSwitcherCommand",
|
||||
"SessionsSelectPythonInterpreterCommand",
|
||||
"SessionsSetupRemoteDebuggingCommand",
|
||||
"SessionsShowAgentSwitcherCommand",
|
||||
"SessionsSidebarPlaceholderHydrateListener",
|
||||
"SessionsStopRemoteJupyterCommand",
|
||||
"SessionsSwitchAgentSessionCommand",
|
||||
"SessionsSyncRemoteTreeToSidebarCommand",
|
||||
"SessionsTerminalLinkClickListener",
|
||||
"SessionsWorkspaceActivationListener",
|
||||
@@ -90,6 +108,12 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
assert plugin_module.SessionsOpenRemoteTerminalCommand.__name__ == (
|
||||
"SessionsOpenRemoteTerminalCommand"
|
||||
)
|
||||
assert plugin_module.SessionsNewRemoteTerminalPaneCommand.__name__ == (
|
||||
"SessionsNewRemoteTerminalPaneCommand"
|
||||
)
|
||||
assert plugin_module.SessionsKillRemoteTerminalCommand.__name__ == (
|
||||
"SessionsKillRemoteTerminalCommand"
|
||||
)
|
||||
assert plugin_module.SessionsOpenRemoteFileCommand.__name__ == (
|
||||
"SessionsOpenRemoteFileCommand"
|
||||
)
|
||||
|
||||
215
sublime/tests/test_python_interpreter_browser.py
Normal file
215
sublime/tests/test_python_interpreter_browser.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Unit tests for ``sessions.python_interpreter_browser``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sessions.python_interpreter_browser import (
|
||||
BrowserEntry,
|
||||
DirectoryListing,
|
||||
is_python_executable_name,
|
||||
list_remote_directory,
|
||||
parse_ls_output,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeExecResult:
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int = 0
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
def _runner(
|
||||
result: _FakeExecResult,
|
||||
*,
|
||||
raises: Optional[Exception] = None,
|
||||
):
|
||||
"""Return an ``exec_once`` stub that records calls and replies with ``result``."""
|
||||
calls: List[dict] = []
|
||||
|
||||
def fn(
|
||||
host_alias: str,
|
||||
*,
|
||||
argv: Any,
|
||||
cwd: str,
|
||||
timeout_ms: int,
|
||||
) -> _FakeExecResult:
|
||||
calls.append(
|
||||
{
|
||||
"host_alias": host_alias,
|
||||
"argv": list(argv),
|
||||
"cwd": cwd,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
)
|
||||
if raises is not None:
|
||||
raise raises
|
||||
return result
|
||||
|
||||
fn.calls = calls # type: ignore[attr-defined]
|
||||
return fn
|
||||
|
||||
|
||||
_SAMPLE_LS = (
|
||||
"total 16\n"
|
||||
"drwxr-xr-x 2 root root 4096 Apr 23 10:00 .\n"
|
||||
"drwxr-xr-x 4 root root 4096 Apr 20 09:00 ..\n"
|
||||
"drwxr-xr-x 2 root root 4096 Apr 22 12:00 .venv\n"
|
||||
"drwxr-xr-x 3 root root 4096 Apr 23 10:00 src\n"
|
||||
"-rwxr-xr-x 1 root root 128 Apr 23 10:00 python\n"
|
||||
"-rwxr-xr-x 1 root root 128 Apr 23 10:00 python3.11\n"
|
||||
"-rw-r--r-- 1 root root 256 Apr 22 12:00 README.md\n"
|
||||
"-rw-r--r-- 1 root root 64 Apr 22 12:00 notes.txt\n"
|
||||
)
|
||||
|
||||
|
||||
def test_is_python_executable_name_accepts_expected_variants() -> None:
|
||||
assert is_python_executable_name("python")
|
||||
assert is_python_executable_name("python3")
|
||||
assert is_python_executable_name("python3.11")
|
||||
assert is_python_executable_name("python3.12")
|
||||
|
||||
|
||||
def test_is_python_executable_name_rejects_unrelated_names() -> None:
|
||||
assert not is_python_executable_name("pypy")
|
||||
assert not is_python_executable_name("python-config")
|
||||
assert not is_python_executable_name("py")
|
||||
assert not is_python_executable_name("")
|
||||
assert not is_python_executable_name("pythonista")
|
||||
|
||||
|
||||
def test_parse_ls_output_splits_dirs_and_python_candidates() -> None:
|
||||
entries = parse_ls_output(_SAMPLE_LS, "/home/u/proj")
|
||||
names = [(e.name, e.is_dir, e.is_python) for e in entries]
|
||||
# Directories come first (alphabetical), then python candidates.
|
||||
assert names == [
|
||||
(".venv", True, False),
|
||||
("src", True, False),
|
||||
("python", False, True),
|
||||
("python3.11", False, True),
|
||||
]
|
||||
# Non-python executables (README.md, notes.txt) are dropped because
|
||||
# they are neither directories nor interpreter basenames.
|
||||
assert all(e.name not in ("README.md", "notes.txt") for e in entries)
|
||||
|
||||
|
||||
def test_parse_ls_output_builds_absolute_paths_from_directory() -> None:
|
||||
entries = parse_ls_output(_SAMPLE_LS, "/home/u/proj")
|
||||
venv = next(e for e in entries if e.name == ".venv")
|
||||
assert venv.absolute_path == "/home/u/proj/.venv"
|
||||
python = next(e for e in entries if e.name == "python")
|
||||
assert python.absolute_path == "/home/u/proj/python"
|
||||
|
||||
|
||||
def test_parse_ls_output_handles_root_directory() -> None:
|
||||
sample = (
|
||||
"total 4\n"
|
||||
"drwxr-xr-x 2 root root 4096 Apr 23 10:00 etc\n"
|
||||
"-rwxr-xr-x 1 root root 128 Apr 23 10:00 python3\n"
|
||||
)
|
||||
entries = parse_ls_output(sample, "/")
|
||||
etc = next(e for e in entries if e.name == "etc")
|
||||
assert etc.absolute_path == "/etc"
|
||||
py = next(e for e in entries if e.name == "python3")
|
||||
assert py.absolute_path == "/python3"
|
||||
|
||||
|
||||
def test_parse_ls_output_skips_dotdirs_and_totals() -> None:
|
||||
entries = parse_ls_output(_SAMPLE_LS, "/a")
|
||||
assert all(e.name not in (".", "..") for e in entries)
|
||||
|
||||
|
||||
def test_parse_ls_output_skips_non_executable_python_name() -> None:
|
||||
# A regular file called ``python`` without the user execute bit should
|
||||
# not surface as a Python candidate.
|
||||
sample = "total 4\n-rw-r--r-- 1 root root 128 Apr 23 10:00 python\n"
|
||||
assert parse_ls_output(sample, "/x") == ()
|
||||
|
||||
|
||||
def test_list_remote_directory_returns_listing_on_success() -> None:
|
||||
runner = _runner(_FakeExecResult(stdout=_SAMPLE_LS))
|
||||
listing = list_remote_directory("prod", "/home/u/proj", exec_once=runner)
|
||||
assert listing.path == "/home/u/proj"
|
||||
assert listing.parent == "/home/u"
|
||||
assert listing.error is None
|
||||
assert any(e.is_python for e in listing.entries)
|
||||
|
||||
|
||||
def test_list_remote_directory_normalizes_trailing_slash() -> None:
|
||||
runner = _runner(_FakeExecResult(stdout=_SAMPLE_LS))
|
||||
listing = list_remote_directory("h", "/x/y/", exec_once=runner)
|
||||
assert listing.path == "/x/y"
|
||||
assert listing.parent == "/x"
|
||||
|
||||
|
||||
def test_list_remote_directory_root_has_no_parent() -> None:
|
||||
runner = _runner(_FakeExecResult(stdout="total 0\n"))
|
||||
listing = list_remote_directory("h", "/", exec_once=runner)
|
||||
assert listing.path == "/"
|
||||
assert listing.parent is None
|
||||
|
||||
|
||||
def test_list_remote_directory_surface_exec_error_string() -> None:
|
||||
runner = _runner(
|
||||
_FakeExecResult(),
|
||||
raises=RuntimeError("connection refused"),
|
||||
)
|
||||
listing = list_remote_directory("h", "/a", exec_once=runner)
|
||||
assert listing.entries == ()
|
||||
assert listing.error is not None
|
||||
assert "connection refused" in listing.error
|
||||
|
||||
|
||||
def test_list_remote_directory_maps_timeout_to_error() -> None:
|
||||
runner = _runner(_FakeExecResult(timed_out=True))
|
||||
listing = list_remote_directory("h", "/a", exec_once=runner)
|
||||
assert listing.entries == ()
|
||||
assert listing.error == "listing timed out"
|
||||
|
||||
|
||||
def test_list_remote_directory_maps_nonzero_exit_to_error_with_stderr() -> None:
|
||||
runner = _runner(
|
||||
_FakeExecResult(
|
||||
exit_code=2, stderr="ls: cannot access /gone: No such file or directory\n"
|
||||
)
|
||||
)
|
||||
listing = list_remote_directory("h", "/gone", exec_once=runner)
|
||||
assert listing.entries == ()
|
||||
assert listing.error is not None
|
||||
assert "No such file" in listing.error
|
||||
|
||||
|
||||
def test_list_remote_directory_maps_nonzero_without_stderr_to_exit_label() -> None:
|
||||
runner = _runner(_FakeExecResult(exit_code=13, stderr=""))
|
||||
listing = list_remote_directory("h", "/x", exec_once=runner)
|
||||
assert listing.error == "exit 13"
|
||||
|
||||
|
||||
def test_list_remote_directory_invokes_ls_la_on_target_path() -> None:
|
||||
runner = _runner(_FakeExecResult(stdout=_SAMPLE_LS))
|
||||
list_remote_directory("prod", "/home/u/proj", exec_once=runner)
|
||||
assert runner.calls[0]["argv"][:3] == ["ls", "-la", "--"]
|
||||
assert runner.calls[0]["argv"][-1] == "/home/u/proj"
|
||||
assert runner.calls[0]["host_alias"] == "prod"
|
||||
assert runner.calls[0]["cwd"] == "/home/u/proj"
|
||||
|
||||
|
||||
def test_browser_entry_is_frozen() -> None:
|
||||
e = BrowserEntry(name="x", absolute_path="/x", is_dir=False, is_python=True)
|
||||
try:
|
||||
e.name = "y" # type: ignore[misc]
|
||||
except Exception:
|
||||
return
|
||||
raise AssertionError("BrowserEntry should be frozen")
|
||||
|
||||
|
||||
def test_directory_listing_is_frozen() -> None:
|
||||
d = DirectoryListing(path="/a", parent=None, entries=(), error=None)
|
||||
try:
|
||||
d.path = "/b" # type: ignore[misc]
|
||||
except Exception:
|
||||
return
|
||||
raise AssertionError("DirectoryListing should be frozen")
|
||||
853
sublime/tests/test_python_interpreter_registry.py
Normal file
853
sublime/tests/test_python_interpreter_registry.py
Normal file
@@ -0,0 +1,853 @@
|
||||
"""Unit tests for ``sessions.python_interpreter_registry``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sessions.python_interpreter_registry import (
|
||||
_ACTIVE_PYTHON_SETTINGS_KEY,
|
||||
InterpreterCandidate,
|
||||
clear_active_interpreter,
|
||||
derive_venv_name,
|
||||
detect_venv_interpreters,
|
||||
format_status_label,
|
||||
get_cached_version,
|
||||
invalidate_version_cache,
|
||||
is_python_view,
|
||||
parse_version_output,
|
||||
probe_interpreter_version,
|
||||
read_active_interpreter,
|
||||
shorten_interpreter_path,
|
||||
write_active_interpreter,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeExecResult:
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int = 0
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
class _FakeWindow:
|
||||
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
|
||||
self._data = dict(data) if isinstance(data, dict) else None
|
||||
self.writes: List[Dict[str, Any]] = []
|
||||
|
||||
def project_data(self) -> Optional[Dict[str, Any]]:
|
||||
return dict(self._data) if self._data is not None else None
|
||||
|
||||
def set_project_data(self, data: Dict[str, Any]) -> None:
|
||||
self._data = dict(data)
|
||||
self.writes.append(dict(data))
|
||||
|
||||
|
||||
def _fake_exec(
|
||||
responses: Dict[str, _FakeExecResult],
|
||||
*,
|
||||
raise_on: Optional[List[str]] = None,
|
||||
):
|
||||
"""Return a fake ``exec_once`` keyed on the probed binary name.
|
||||
|
||||
``responses`` maps ``"python"`` / ``"python3"`` to the stubbed result;
|
||||
matching is done by scanning for ``.venv/bin/<name>'`` (with the trailing
|
||||
single quote emitted by ``_probe_script``) so ``python`` and ``python3``
|
||||
never collide.
|
||||
"""
|
||||
calls: List[Dict[str, Any]] = []
|
||||
raise_on_set = set(raise_on or [])
|
||||
|
||||
def runner(
|
||||
host_alias: str,
|
||||
*,
|
||||
argv: Any,
|
||||
cwd: str,
|
||||
timeout_ms: int,
|
||||
) -> _FakeExecResult:
|
||||
call = {
|
||||
"host_alias": host_alias,
|
||||
"argv": list(argv),
|
||||
"cwd": cwd,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
calls.append(call)
|
||||
script = argv[-1] if argv else ""
|
||||
# Check the longer name first so ``python3`` never matches under
|
||||
# ``python``'s rule.
|
||||
for name in sorted(responses, key=len, reverse=True):
|
||||
needle = ".venv/bin/{}'".format(name)
|
||||
if needle in script:
|
||||
if name in raise_on_set:
|
||||
raise RuntimeError("boom for {}".format(name))
|
||||
return responses[name]
|
||||
return _FakeExecResult()
|
||||
|
||||
runner.calls = calls # type: ignore[attr-defined]
|
||||
return runner
|
||||
|
||||
|
||||
def test_detect_returns_python_when_only_python_present() -> None:
|
||||
runner = _fake_exec(
|
||||
{
|
||||
"python": _FakeExecResult(
|
||||
stdout="PATH=/root/.venv/bin/python\nPython 3.11.6\n",
|
||||
),
|
||||
"python3": _FakeExecResult(stdout=""),
|
||||
}
|
||||
)
|
||||
candidates = detect_venv_interpreters("host1", "/root", exec_once=runner)
|
||||
assert len(candidates) == 1
|
||||
cand = candidates[0]
|
||||
assert cand.remote_path == "/root/.venv/bin/python"
|
||||
assert cand.version == "Python 3.11.6"
|
||||
assert ".venv/bin/python" in cand.label
|
||||
assert "3.11.6" in cand.label
|
||||
|
||||
|
||||
def test_detect_dedupes_when_both_binaries_report_same_path() -> None:
|
||||
# python3 -> symlink -> python; remote script still prints the absolute
|
||||
# path per the invoked name, so we simulate the stdout exactly. We want
|
||||
# dedupe by literal remote_path string.
|
||||
runner = _fake_exec(
|
||||
{
|
||||
"python": _FakeExecResult(
|
||||
stdout="PATH=/srv/app/.venv/bin/python\nPython 3.12.0\n",
|
||||
),
|
||||
"python3": _FakeExecResult(
|
||||
stdout="PATH=/srv/app/.venv/bin/python\nPython 3.12.0\n",
|
||||
),
|
||||
}
|
||||
)
|
||||
candidates = detect_venv_interpreters("h", "/srv/app", exec_once=runner)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].remote_path == "/srv/app/.venv/bin/python"
|
||||
|
||||
|
||||
def test_detect_emits_two_when_python3_path_differs() -> None:
|
||||
runner = _fake_exec(
|
||||
{
|
||||
"python": _FakeExecResult(
|
||||
stdout="PATH=/a/.venv/bin/python\nPython 3.10.0\n",
|
||||
),
|
||||
"python3": _FakeExecResult(
|
||||
stdout="PATH=/a/.venv/bin/python3\nPython 3.10.0\n",
|
||||
),
|
||||
}
|
||||
)
|
||||
candidates = detect_venv_interpreters("h", "/a", exec_once=runner)
|
||||
paths = [c.remote_path for c in candidates]
|
||||
assert paths == ["/a/.venv/bin/python", "/a/.venv/bin/python3"]
|
||||
|
||||
|
||||
def test_detect_returns_empty_list_when_neither_present() -> None:
|
||||
runner = _fake_exec(
|
||||
{
|
||||
"python": _FakeExecResult(stdout=""),
|
||||
"python3": _FakeExecResult(stdout=""),
|
||||
}
|
||||
)
|
||||
assert detect_venv_interpreters("h", "/root", exec_once=runner) == []
|
||||
|
||||
|
||||
def test_detect_treats_timeout_as_absent() -> None:
|
||||
runner = _fake_exec(
|
||||
{
|
||||
"python": _FakeExecResult(stdout="", timed_out=True),
|
||||
"python3": _FakeExecResult(
|
||||
stdout="PATH=/r/.venv/bin/python3\nPython 3.9.1\n",
|
||||
),
|
||||
}
|
||||
)
|
||||
candidates = detect_venv_interpreters("h", "/r", exec_once=runner)
|
||||
assert [c.remote_path for c in candidates] == ["/r/.venv/bin/python3"]
|
||||
|
||||
|
||||
def test_detect_swallows_exec_once_exceptions() -> None:
|
||||
runner = _fake_exec(
|
||||
{
|
||||
"python": _FakeExecResult(
|
||||
stdout="PATH=/r/.venv/bin/python\nPython 3.11.2\n",
|
||||
),
|
||||
"python3": _FakeExecResult(stdout=""),
|
||||
},
|
||||
raise_on=["python3"],
|
||||
)
|
||||
candidates = detect_venv_interpreters("h", "/r", exec_once=runner)
|
||||
assert [c.remote_path for c in candidates] == ["/r/.venv/bin/python"]
|
||||
|
||||
|
||||
def test_detect_skips_stdout_without_path_line() -> None:
|
||||
runner = _fake_exec(
|
||||
{
|
||||
"python": _FakeExecResult(stdout="Python 3.11.6\n"),
|
||||
"python3": _FakeExecResult(stdout=""),
|
||||
}
|
||||
)
|
||||
assert detect_venv_interpreters("h", "/r", exec_once=runner) == []
|
||||
|
||||
|
||||
def test_detect_uses_exec_once_default_when_none_and_never_called(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from sessions import python_interpreter_registry as reg
|
||||
|
||||
calls: List[Dict[str, Any]] = []
|
||||
|
||||
def fake_default(host_alias: str, **kwargs: Any) -> _FakeExecResult:
|
||||
calls.append({"host_alias": host_alias, **kwargs})
|
||||
return _FakeExecResult()
|
||||
|
||||
monkeypatch.setattr(reg, "_exec_once_default", fake_default)
|
||||
out = reg.detect_venv_interpreters("h", "/root")
|
||||
assert out == []
|
||||
assert len(calls) == 2 # python + python3
|
||||
|
||||
|
||||
def test_read_active_interpreter_returns_stored_value() -> None:
|
||||
window = _FakeWindow(
|
||||
{"settings": {_ACTIVE_PYTHON_SETTINGS_KEY: "/remote/.venv/bin/python"}}
|
||||
)
|
||||
assert read_active_interpreter(window) == "/remote/.venv/bin/python"
|
||||
|
||||
|
||||
def test_read_active_interpreter_without_project_data_returns_none() -> None:
|
||||
window = _FakeWindow(None)
|
||||
assert read_active_interpreter(window) is None
|
||||
|
||||
|
||||
def test_read_active_interpreter_without_settings_returns_none() -> None:
|
||||
window = _FakeWindow({"folders": [{"path": "."}]})
|
||||
assert read_active_interpreter(window) is None
|
||||
|
||||
|
||||
def test_read_active_interpreter_with_bare_object_returns_none() -> None:
|
||||
assert read_active_interpreter(object()) is None
|
||||
|
||||
|
||||
def test_write_and_read_round_trip() -> None:
|
||||
window = _FakeWindow({"folders": [{"path": "."}]})
|
||||
write_active_interpreter(window, "/srv/app/.venv/bin/python")
|
||||
assert read_active_interpreter(window) == "/srv/app/.venv/bin/python"
|
||||
# Did not drop existing keys.
|
||||
assert window.project_data()["folders"] == [{"path": "."}]
|
||||
|
||||
|
||||
def test_write_creates_settings_dict_when_absent() -> None:
|
||||
window = _FakeWindow({})
|
||||
write_active_interpreter(window, "/r/.venv/bin/python3")
|
||||
data = window.project_data()
|
||||
assert data is not None
|
||||
assert data["settings"][_ACTIVE_PYTHON_SETTINGS_KEY] == "/r/.venv/bin/python3"
|
||||
|
||||
|
||||
def test_clear_active_interpreter_removes_key() -> None:
|
||||
window = _FakeWindow(
|
||||
{
|
||||
"folders": [{"path": "."}],
|
||||
"settings": {
|
||||
_ACTIVE_PYTHON_SETTINGS_KEY: "/r/.venv/bin/python",
|
||||
"other": 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
clear_active_interpreter(window)
|
||||
assert read_active_interpreter(window) is None
|
||||
assert window.project_data()["settings"] == {"other": 1}
|
||||
|
||||
|
||||
def test_clear_active_interpreter_is_noop_when_unset() -> None:
|
||||
window = _FakeWindow({"settings": {"other": 1}})
|
||||
clear_active_interpreter(window)
|
||||
# No write should have been emitted.
|
||||
assert window.writes == []
|
||||
|
||||
|
||||
def test_clear_active_interpreter_without_project_data_is_noop() -> None:
|
||||
window = _FakeWindow(None)
|
||||
clear_active_interpreter(window)
|
||||
assert window.writes == []
|
||||
|
||||
|
||||
def test_shorten_interpreter_path_keeps_three_components() -> None:
|
||||
# Default limit (40) fits the three-component tail so the shortener
|
||||
# returns ``proj/.venv/bin/python`` rather than the venv-only fragment.
|
||||
assert (
|
||||
shorten_interpreter_path("/home/u/proj/.venv/bin/python") == ".venv/bin/python"
|
||||
)
|
||||
assert (
|
||||
shorten_interpreter_path("/srv/deep/nested/proj/.venv/bin/python3")
|
||||
== ".venv/bin/python3"
|
||||
)
|
||||
|
||||
|
||||
def test_shorten_interpreter_path_keeps_three_directory_segments() -> None:
|
||||
# When there are at least three components, the helper keeps the last
|
||||
# three (not the basename only) so users can distinguish sibling venvs.
|
||||
assert (
|
||||
shorten_interpreter_path("/home/u/proj-a/.venv/bin/python")
|
||||
== ".venv/bin/python"
|
||||
)
|
||||
|
||||
|
||||
def test_shorten_interpreter_path_trims_long_tail_with_ellipsis() -> None:
|
||||
long_tail = "/a/" + "x" * 30 + "/" + "y" * 30 + "/" + "z" * 30
|
||||
out = shorten_interpreter_path(long_tail, limit=15)
|
||||
assert len(out) <= 15
|
||||
assert "…" in out
|
||||
|
||||
|
||||
def test_shorten_interpreter_path_default_limit_40_truncates_long_input() -> None:
|
||||
# Three components joined past 40 chars still collapse via the ellipsis.
|
||||
long_input = "/root/aaaaaaaaaa/bbbbbbbbbb/ccccccccccccccccccccccccccccccccccccccccc"
|
||||
out = shorten_interpreter_path(long_input)
|
||||
assert len(out) <= 40
|
||||
assert "…" in out
|
||||
|
||||
|
||||
def test_shorten_interpreter_path_empty_returns_empty() -> None:
|
||||
assert shorten_interpreter_path("") == ""
|
||||
|
||||
|
||||
def test_shorten_interpreter_path_single_component_passthrough() -> None:
|
||||
# A bare relative name has nowhere to trim; the helper just returns it.
|
||||
assert shorten_interpreter_path("python") == "python"
|
||||
|
||||
|
||||
def test_write_active_interpreter_without_set_project_data_is_noop() -> None:
|
||||
class _ReadOnlyWindow:
|
||||
def project_data(self):
|
||||
return {"settings": {}}
|
||||
|
||||
# Must not raise when set_project_data is absent.
|
||||
write_active_interpreter(_ReadOnlyWindow(), "/r/.venv/bin/python")
|
||||
|
||||
|
||||
def test_clear_active_interpreter_without_set_project_data_is_noop() -> None:
|
||||
class _ReadOnlyWindow:
|
||||
def project_data(self):
|
||||
return {"settings": {_ACTIVE_PYTHON_SETTINGS_KEY: "/r"}}
|
||||
|
||||
clear_active_interpreter(_ReadOnlyWindow())
|
||||
|
||||
|
||||
def test_clear_active_interpreter_settings_not_a_dict_is_noop() -> None:
|
||||
window = _FakeWindow({"settings": "not-a-dict"})
|
||||
clear_active_interpreter(window)
|
||||
assert window.writes == []
|
||||
|
||||
|
||||
def test_read_active_interpreter_raising_project_data_returns_none() -> None:
|
||||
class _Raising:
|
||||
def project_data(self):
|
||||
raise RuntimeError("closed window")
|
||||
|
||||
assert read_active_interpreter(_Raising()) is None
|
||||
|
||||
|
||||
def test_interpreter_candidate_is_frozen() -> None:
|
||||
c = InterpreterCandidate(remote_path="/p", label="x", version=None)
|
||||
try:
|
||||
c.remote_path = "/q" # type: ignore[misc]
|
||||
except Exception:
|
||||
return
|
||||
raise AssertionError("expected frozen dataclass to reject attribute assignment")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Cluster C — venv-name derivation, version probe + cache, syntax gate.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_derive_venv_name_for_dot_venv_layout() -> None:
|
||||
assert derive_venv_name("/path/to/MIN-T/.venv/bin/python") == "MIN-T"
|
||||
assert derive_venv_name("/path/to/MIN-T/.venv/bin/python3") == "MIN-T"
|
||||
|
||||
|
||||
def test_derive_venv_name_for_conda_envs_layout() -> None:
|
||||
assert derive_venv_name("/home/u/.local/share/conda/envs/foo/bin/python") == "foo"
|
||||
# Different envs root (e.g. miniforge) — same heuristic still applies.
|
||||
assert derive_venv_name("/opt/miniforge3/envs/data-sci/bin/python3") == "data-sci"
|
||||
|
||||
|
||||
def test_derive_venv_name_falls_back_to_parent_of_bin() -> None:
|
||||
# Bare ``/opt/python311/bin/python3`` has no ``.venv`` or ``envs/`` cue.
|
||||
assert derive_venv_name("/opt/python311/bin/python3") == "python311"
|
||||
|
||||
|
||||
def test_derive_venv_name_returns_none_for_empty() -> None:
|
||||
assert derive_venv_name("") is None
|
||||
# Single component → no parent to lift a name from.
|
||||
assert derive_venv_name("python") is None
|
||||
|
||||
|
||||
def test_derive_venv_name_falls_back_to_parent_dir_when_no_bin() -> None:
|
||||
# ``/usr/bin/env python`` style invocation (no real bin separator at the end):
|
||||
# we punt to the immediate parent.
|
||||
assert derive_venv_name("/odd/layout/script") == "layout"
|
||||
|
||||
|
||||
def test_parse_version_output_extracts_three_components() -> None:
|
||||
assert parse_version_output("Python 3.11.4\n") == "3.11.4"
|
||||
# stderr-style with leading whitespace.
|
||||
assert parse_version_output(" Python 3.10.0+chromium\n") == "3.10.0"
|
||||
|
||||
|
||||
def test_parse_version_output_accepts_two_components() -> None:
|
||||
# Some embedded distros only print the major.minor pair.
|
||||
assert parse_version_output("Python 3.9") == "3.9"
|
||||
|
||||
|
||||
def test_parse_version_output_returns_none_for_garbage() -> None:
|
||||
assert parse_version_output("") is None
|
||||
assert parse_version_output("not python output") is None
|
||||
|
||||
|
||||
def test_format_status_label_renders_full_form() -> None:
|
||||
label = format_status_label("/path/to/MIN-T/.venv/bin/python", "3.11.4")
|
||||
assert label == "Python: MIN-T (3.11.4)"
|
||||
|
||||
|
||||
def test_format_status_label_renders_pending_when_version_missing() -> None:
|
||||
label = format_status_label("/path/to/MIN-T/.venv/bin/python", None)
|
||||
assert label == "Python: MIN-T (…)"
|
||||
|
||||
|
||||
def test_format_status_label_renders_not_set_when_path_missing() -> None:
|
||||
assert format_status_label(None, None) == "Python: (not set)"
|
||||
assert format_status_label("", "3.11.4") == "Python: (not set)"
|
||||
|
||||
|
||||
def test_format_status_label_falls_back_to_full_path_when_no_name() -> None:
|
||||
# ``derive_venv_name`` returns ``None`` for a single-component path; the
|
||||
# formatter falls back to printing the path so the slot stays informative.
|
||||
assert format_status_label("python", "3.11.4") == "Python: python (3.11.4)"
|
||||
|
||||
|
||||
def test_probe_interpreter_version_caches_first_result() -> None:
|
||||
invalidate_version_cache()
|
||||
runner_calls: List[str] = []
|
||||
|
||||
def runner(host_alias: str, *, argv: Any, cwd: str, timeout_ms: int) -> Any:
|
||||
runner_calls.append(host_alias)
|
||||
return _FakeExecResult(stdout="Python 3.11.4\n")
|
||||
|
||||
v1 = probe_interpreter_version("host1", "/srv/.venv/bin/python", exec_once=runner)
|
||||
v2 = probe_interpreter_version("host1", "/srv/.venv/bin/python", exec_once=runner)
|
||||
assert v1 == "3.11.4"
|
||||
assert v2 == "3.11.4"
|
||||
# Second call hits the cache — runner ran once.
|
||||
assert len(runner_calls) == 1
|
||||
assert get_cached_version("host1", "/srv/.venv/bin/python") == "3.11.4"
|
||||
invalidate_version_cache()
|
||||
|
||||
|
||||
def test_probe_interpreter_version_reads_stderr_fallback() -> None:
|
||||
invalidate_version_cache()
|
||||
|
||||
def runner(host_alias: str, *, argv: Any, cwd: str, timeout_ms: int) -> Any:
|
||||
# Python 2 prints to stderr; the probe must still see it.
|
||||
return _FakeExecResult(stdout="", stderr="Python 2.7.18\n")
|
||||
|
||||
out = probe_interpreter_version("h", "/u/.venv/bin/python", exec_once=runner)
|
||||
assert out == "2.7.18"
|
||||
invalidate_version_cache()
|
||||
|
||||
|
||||
def test_probe_interpreter_version_returns_none_on_timeout() -> None:
|
||||
invalidate_version_cache()
|
||||
|
||||
def runner(host_alias: str, *, argv: Any, cwd: str, timeout_ms: int) -> Any:
|
||||
return _FakeExecResult(stdout="", timed_out=True)
|
||||
|
||||
out = probe_interpreter_version("h", "/u/.venv/bin/python", exec_once=runner)
|
||||
assert out is None
|
||||
# Cache stays empty — a future call retries instead of caching the failure.
|
||||
assert get_cached_version("h", "/u/.venv/bin/python") is None
|
||||
|
||||
|
||||
def test_probe_interpreter_version_swallows_exec_exceptions() -> None:
|
||||
invalidate_version_cache()
|
||||
|
||||
def runner(host_alias: str, *, argv: Any, cwd: str, timeout_ms: int) -> Any:
|
||||
raise RuntimeError("bridge offline")
|
||||
|
||||
assert (
|
||||
probe_interpreter_version("h", "/u/.venv/bin/python", exec_once=runner) is None
|
||||
)
|
||||
|
||||
|
||||
def test_probe_interpreter_version_ignores_garbled_output() -> None:
|
||||
invalidate_version_cache()
|
||||
|
||||
def runner(host_alias: str, *, argv: Any, cwd: str, timeout_ms: int) -> Any:
|
||||
return _FakeExecResult(stdout="garbage\n", stderr="")
|
||||
|
||||
assert (
|
||||
probe_interpreter_version("h", "/u/.venv/bin/python", exec_once=runner) is None
|
||||
)
|
||||
|
||||
|
||||
def test_probe_interpreter_version_handles_blank_inputs() -> None:
|
||||
# Defensive guards against blank host or path; never call out.
|
||||
invalidate_version_cache()
|
||||
sentinel: List[int] = []
|
||||
|
||||
def never(host_alias: str, *, argv: Any, cwd: str, timeout_ms: int) -> Any:
|
||||
sentinel.append(1)
|
||||
return _FakeExecResult()
|
||||
|
||||
assert probe_interpreter_version("", "/p", exec_once=never) is None
|
||||
assert probe_interpreter_version("h", "", exec_once=never) is None
|
||||
assert sentinel == []
|
||||
|
||||
|
||||
def test_invalidate_version_cache_per_host() -> None:
|
||||
from sessions.python_interpreter_registry import _VERSION_CACHE
|
||||
|
||||
invalidate_version_cache()
|
||||
_VERSION_CACHE[("h1", "/a")] = "3.11.0"
|
||||
_VERSION_CACHE[("h1", "/b")] = "3.10.0"
|
||||
_VERSION_CACHE[("h2", "/c")] = "3.9.0"
|
||||
invalidate_version_cache("h1")
|
||||
assert ("h1", "/a") not in _VERSION_CACHE
|
||||
assert ("h1", "/b") not in _VERSION_CACHE
|
||||
assert _VERSION_CACHE[("h2", "/c")] == "3.9.0"
|
||||
invalidate_version_cache()
|
||||
|
||||
|
||||
def test_invalidate_version_cache_per_entry() -> None:
|
||||
from sessions.python_interpreter_registry import _VERSION_CACHE
|
||||
|
||||
invalidate_version_cache()
|
||||
_VERSION_CACHE[("h1", "/a")] = "3.11.0"
|
||||
_VERSION_CACHE[("h1", "/b")] = "3.10.0"
|
||||
invalidate_version_cache("h1", "/a")
|
||||
assert ("h1", "/a") not in _VERSION_CACHE
|
||||
assert _VERSION_CACHE[("h1", "/b")] == "3.10.0"
|
||||
invalidate_version_cache()
|
||||
|
||||
|
||||
class _PythonViewStub:
|
||||
"""View stub for syntax-gate tests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scope: Optional[str] = None,
|
||||
match_result: Optional[bool] = None,
|
||||
file_name: Optional[str] = None,
|
||||
match_raises: bool = False,
|
||||
) -> None:
|
||||
self._scope = scope
|
||||
self._match_result = match_result
|
||||
self._file_name = file_name
|
||||
self._match_raises = match_raises
|
||||
|
||||
def match_selector(self, point: int, selector: str) -> bool:
|
||||
if self._match_raises:
|
||||
raise RuntimeError("Sublime: invalid view")
|
||||
if self._match_result is None:
|
||||
raise AttributeError # pragma: no cover - defensive only
|
||||
return self._match_result
|
||||
|
||||
def scope_name(self, point: int) -> str:
|
||||
return self._scope or ""
|
||||
|
||||
def file_name(self) -> Optional[str]:
|
||||
return self._file_name
|
||||
|
||||
|
||||
def test_is_python_view_via_match_selector_true() -> None:
|
||||
assert is_python_view(_PythonViewStub(match_result=True)) is True
|
||||
|
||||
|
||||
def test_is_python_view_via_match_selector_false_falls_through_to_scope() -> None:
|
||||
# match_selector says False, but scope_name confirms python source.
|
||||
view = _PythonViewStub(match_result=False, scope="source.python meta.function")
|
||||
assert is_python_view(view) is True
|
||||
|
||||
|
||||
def test_is_python_view_recognises_cython_via_scope() -> None:
|
||||
view = _PythonViewStub(match_result=False, scope="source.cython")
|
||||
assert is_python_view(view) is True
|
||||
|
||||
|
||||
def test_is_python_view_falls_back_to_filename() -> None:
|
||||
view = _PythonViewStub(
|
||||
match_result=False, scope="text.plain", file_name="/x/foo.py"
|
||||
)
|
||||
assert is_python_view(view) is True
|
||||
|
||||
|
||||
def test_is_python_view_filename_extension_variants() -> None:
|
||||
for ext in (".py", ".pyi", ".pyx", ".pxd"):
|
||||
view = _PythonViewStub(
|
||||
match_result=False,
|
||||
scope="text.plain",
|
||||
file_name="/x/foo" + ext,
|
||||
)
|
||||
assert is_python_view(view) is True, ext
|
||||
|
||||
|
||||
def test_is_python_view_returns_false_for_non_python() -> None:
|
||||
view = _PythonViewStub(
|
||||
match_result=False,
|
||||
scope="text.html.markdown",
|
||||
file_name="/x/README.md",
|
||||
)
|
||||
assert is_python_view(view) is False
|
||||
|
||||
|
||||
def test_is_python_view_handles_match_selector_exception() -> None:
|
||||
"""A raising ``match_selector`` should not crash the gate."""
|
||||
view = _PythonViewStub(match_raises=True, scope="source.python")
|
||||
# Falls through to scope_name, which still says python.
|
||||
assert is_python_view(view) is True
|
||||
|
||||
|
||||
def test_is_python_view_returns_false_for_none() -> None:
|
||||
assert is_python_view(None) is False
|
||||
|
||||
|
||||
def test_is_python_view_returns_false_for_bare_object() -> None:
|
||||
# No methods at all → can't tell, default to "not python" (safer).
|
||||
assert is_python_view(object()) is False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Adversarial — concurrent cache writes, stress with large entry counts.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_version_cache_handles_concurrent_writes() -> None:
|
||||
"""Many threads racing on ``probe_interpreter_version`` must not corrupt cache.
|
||||
|
||||
Each thread populates a distinct ``(host, path)`` entry; this exercises
|
||||
the lock on ``_VERSION_CACHE`` without forcing a deterministic interleave.
|
||||
"""
|
||||
import threading
|
||||
|
||||
invalidate_version_cache()
|
||||
|
||||
barrier = threading.Barrier(8)
|
||||
errors: List[BaseException] = []
|
||||
|
||||
def runner_for(version: str):
|
||||
def runner(host_alias: str, *, argv: Any, cwd: str, timeout_ms: int) -> Any:
|
||||
return _FakeExecResult(stdout="Python {}\n".format(version))
|
||||
|
||||
return runner
|
||||
|
||||
def worker(idx: int) -> None:
|
||||
barrier.wait() # Force all threads to release at the same moment.
|
||||
try:
|
||||
host = "h{}".format(idx)
|
||||
path = "/p/{}".format(idx)
|
||||
ver = "3.{}.{}".format(idx, idx + 1)
|
||||
got = probe_interpreter_version(host, path, exec_once=runner_for(ver))
|
||||
assert got == ver
|
||||
assert get_cached_version(host, path) == ver
|
||||
except BaseException as e: # noqa: BLE001
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert errors == []
|
||||
# All eight thread-local entries should now be in the cache.
|
||||
for i in range(8):
|
||||
assert get_cached_version(
|
||||
"h{}".format(i), "/p/{}".format(i)
|
||||
) == "3.{}.{}".format(i, i + 1)
|
||||
invalidate_version_cache()
|
||||
|
||||
|
||||
def test_version_cache_concurrent_invalidation_races_safely() -> None:
|
||||
"""Stress test: invalidator threads racing readers must not raise.
|
||||
|
||||
The lock contract is that ``invalidate_version_cache`` can wipe entries
|
||||
while ``get_cached_version`` is iterating its dict. We verify by spawning
|
||||
8 reader threads + 4 invalidator threads in parallel.
|
||||
"""
|
||||
import threading
|
||||
|
||||
invalidate_version_cache()
|
||||
# Pre-populate a large set of entries so the racing iteration matters.
|
||||
from sessions.python_interpreter_registry import _VERSION_CACHE
|
||||
|
||||
for i in range(200):
|
||||
_VERSION_CACHE[("h{}".format(i % 4), "/p/{}".format(i))] = "3.{}.0".format(i)
|
||||
|
||||
stop = threading.Event()
|
||||
errors: List[BaseException] = []
|
||||
|
||||
def reader() -> None:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
# Iterating ``get_cached_version`` over many keys keeps the
|
||||
# lock contended for the invalidator threads.
|
||||
for i in range(50):
|
||||
get_cached_version("h{}".format(i % 4), "/p/{}".format(i))
|
||||
except BaseException as e: # noqa: BLE001
|
||||
errors.append(e)
|
||||
|
||||
def invalidator(host_idx: int) -> None:
|
||||
try:
|
||||
for _ in range(20):
|
||||
invalidate_version_cache("h{}".format(host_idx))
|
||||
except BaseException as e: # noqa: BLE001
|
||||
errors.append(e)
|
||||
|
||||
readers = [threading.Thread(target=reader) for _ in range(8)]
|
||||
invalidators = [threading.Thread(target=invalidator, args=(i,)) for i in range(4)]
|
||||
for t in invalidators + readers:
|
||||
t.start()
|
||||
for t in invalidators:
|
||||
t.join()
|
||||
stop.set()
|
||||
for t in readers:
|
||||
t.join()
|
||||
assert errors == []
|
||||
invalidate_version_cache()
|
||||
|
||||
|
||||
def test_version_cache_stress_large_population() -> None:
|
||||
"""Insert many entries, then bulk-evict by host — measures the iterator path."""
|
||||
invalidate_version_cache()
|
||||
from sessions.python_interpreter_registry import _VERSION_CACHE
|
||||
|
||||
# Populate 1024 entries spread across 16 hosts.
|
||||
for i in range(1024):
|
||||
host = "host{}".format(i % 16)
|
||||
_VERSION_CACHE[(host, "/p/{}".format(i))] = "3.{}.{}".format(i % 16, i % 64)
|
||||
# Evict one host's worth (64 entries) at a time and verify the others
|
||||
# remain pristine — guards against the iterator dropping unrelated keys.
|
||||
for host_idx in range(16):
|
||||
before_total = len(_VERSION_CACHE)
|
||||
invalidate_version_cache("host{}".format(host_idx))
|
||||
after_total = len(_VERSION_CACHE)
|
||||
assert before_total - after_total == 64
|
||||
assert len(_VERSION_CACHE) == 0
|
||||
|
||||
|
||||
def test_parse_version_output_against_real_subprocess_python() -> None:
|
||||
"""End-to-end: spawn real ``python3 --version`` and parse what it prints.
|
||||
|
||||
Uses ``subprocess.Popen`` against the live interpreter rather than a
|
||||
fake — we want the parser exercised against the actual format the
|
||||
bridge will see.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "--version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = proc.communicate(timeout=5)
|
||||
combined = stdout.decode() + stderr.decode()
|
||||
parsed = parse_version_output(combined)
|
||||
# Whatever Python is running pytest must have a parseable version.
|
||||
assert parsed is not None
|
||||
assert parsed.split(".")[0] in {"2", "3"}
|
||||
# Sanity-check the round-trip: ``Python <parsed>`` must reappear in the
|
||||
# raw output we just captured.
|
||||
assert "Python {}".format(parsed) in combined or parsed in combined
|
||||
|
||||
|
||||
def test_probe_interpreter_version_against_real_subprocess() -> None:
|
||||
"""``probe_interpreter_version`` end-to-end with a real ``python --version``.
|
||||
|
||||
Builds an ``exec_once`` adapter that drives ``subprocess.Popen`` so we
|
||||
exercise the entire probe → parse → cache pipeline against a genuine
|
||||
process, not a stub.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@dataclass
|
||||
class _RealResult:
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int
|
||||
timed_out: bool = False
|
||||
|
||||
def real_runner(
|
||||
host_alias: str, *, argv: Any, cwd: str, timeout_ms: int
|
||||
) -> _RealResult:
|
||||
# Ignore host/cwd; we always exec the local interpreter for this test.
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "--version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
out, err = proc.communicate(timeout=timeout_ms / 1000 + 1)
|
||||
return _RealResult(
|
||||
stdout=out.decode(),
|
||||
stderr=err.decode(),
|
||||
exit_code=proc.returncode,
|
||||
)
|
||||
|
||||
invalidate_version_cache()
|
||||
version = probe_interpreter_version(
|
||||
"live-host", "/usr/bin/python3-fake", exec_once=real_runner
|
||||
)
|
||||
assert version is not None
|
||||
assert version.split(".")[0] in {"2", "3"}
|
||||
# Cache hit on the second call — without a re-spawn.
|
||||
second = probe_interpreter_version(
|
||||
"live-host",
|
||||
"/usr/bin/python3-fake",
|
||||
exec_once=lambda *a, **kw: pytest_fail("should not re-probe"),
|
||||
)
|
||||
assert second == version
|
||||
invalidate_version_cache()
|
||||
|
||||
|
||||
def pytest_fail(msg: str) -> Any: # pragma: no cover - helper stub
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
def test_probe_interpreter_version_concurrent_same_path_dedupes_after_first(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Two threads probing the same (host, path) → only one bridge call wins.
|
||||
|
||||
The current implementation does not lock the bridge call across racers,
|
||||
so both may probe; but once the first cache write lands, subsequent
|
||||
callers must observe that value (no torn reads). We verify the cache
|
||||
converges to a single value even under racing writers.
|
||||
"""
|
||||
import threading
|
||||
|
||||
invalidate_version_cache()
|
||||
call_counter = {"n": 0}
|
||||
counter_lock = threading.Lock()
|
||||
|
||||
def runner(host_alias: str, *, argv: Any, cwd: str, timeout_ms: int) -> Any:
|
||||
with counter_lock:
|
||||
call_counter["n"] += 1
|
||||
return _FakeExecResult(stdout="Python 3.11.4\n")
|
||||
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def worker(out: List[Optional[str]], idx: int) -> None:
|
||||
barrier.wait()
|
||||
out[idx] = probe_interpreter_version("h", "/p", exec_once=runner)
|
||||
|
||||
results: List[Optional[str]] = [None] * 8
|
||||
threads = [threading.Thread(target=worker, args=(results, i)) for i in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
# Whatever interleaving occurred, all observers see the same final value.
|
||||
assert all(v == "3.11.4" for v in results)
|
||||
# And the cache holds exactly one (host, path) entry.
|
||||
assert get_cached_version("h", "/p") == "3.11.4"
|
||||
invalidate_version_cache()
|
||||
@@ -25,10 +25,15 @@ def test_result_not_ok_when_error() -> None:
|
||||
def test_options_defaults() -> None:
|
||||
o = RemoteCacheMirrorOptions()
|
||||
assert o.max_traversal_depth == 12
|
||||
assert o.max_entries == 5000
|
||||
# v0.4.21 tightened the entry cap so a first-open mirror cannot produce
|
||||
# a file-creation burst large enough to trip ransomware heuristics.
|
||||
assert o.max_entries == 1000
|
||||
assert o.include_files is True
|
||||
assert o.prune_missing is True
|
||||
assert o.ignore_patterns == ()
|
||||
assert o.max_dir_fanout == 100
|
||||
assert o.writes_per_second_cap == 40
|
||||
assert o.consecutive_failure_budget == 3
|
||||
|
||||
|
||||
def test_builtin_ignores_include_common_heavy_directories() -> None:
|
||||
|
||||
@@ -40,28 +40,46 @@ def test_sessions_plugin_imports_under_sublime_style_package_layout() -> None:
|
||||
|
||||
plugin_module = importlib.import_module("Sessions.plugin")
|
||||
assert plugin_module.__all__ == [
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
"SessionsDiagnoseLspWorkspaceCommand",
|
||||
"SessionsInstallRemoteLspServerCommand",
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsKillAgentSessionCommand",
|
||||
"SessionsKillRemoteTerminalCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsNewAgentSessionCommand",
|
||||
"SessionsNewRemoteTerminalPaneCommand",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
"SessionsOpenRemoteJupyterCommand",
|
||||
"SessionsOpenRemoteTerminalCommand",
|
||||
"SessionsOpenRemoteTreeCommand",
|
||||
"SessionsOpenSettingsCommand",
|
||||
"SessionsPreviewRemoteAgentPayloadCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsPythonInterpreterStatusListener",
|
||||
"SessionsReconnectCurrentWorkspaceCommand",
|
||||
"SessionsRegisterJupyterKernelCommand",
|
||||
"SessionsRemoteCachedFileSaveListener",
|
||||
"SessionsRemoteLspServerStatusCommand",
|
||||
"SessionsRemoteExtensionStatusCommand",
|
||||
"SessionsRemoteTreeActivateCommand",
|
||||
"SessionsRemoteTreeEventListener",
|
||||
"SessionsRemoteTreeRefreshCommand",
|
||||
"SessionsRemoveRemoteLspServerCommand",
|
||||
"SessionsRemoveRemoteExtensionCommand",
|
||||
"SessionsRenderAgentSwitcherCommand",
|
||||
"SessionsSelectPythonInterpreterCommand",
|
||||
"SessionsSetupRemoteDebuggingCommand",
|
||||
"SessionsShowAgentSwitcherCommand",
|
||||
"SessionsSidebarPlaceholderHydrateListener",
|
||||
"SessionsStopRemoteJupyterCommand",
|
||||
"SessionsSwitchAgentSessionCommand",
|
||||
"SessionsSyncRemoteTreeToSidebarCommand",
|
||||
"SessionsTerminalLinkClickListener",
|
||||
"SessionsWorkspaceActivationListener",
|
||||
|
||||
@@ -3,16 +3,16 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sessions.settings_model import (
|
||||
DEFAULT_BUILTIN_REMOTE_LSP_SERVER_SPECS,
|
||||
DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS,
|
||||
CodeServerSpec,
|
||||
RemoteLspServerSpec,
|
||||
RemoteExtensionSpec,
|
||||
SessionsSettings,
|
||||
ToolchainOverride,
|
||||
default_ssh_config_path,
|
||||
gitea_registry_http_headers,
|
||||
merge_remote_lsp_catalog,
|
||||
merge_remote_extension_catalog,
|
||||
normalize_code_server_specs,
|
||||
normalize_remote_lsp_server_specs,
|
||||
normalize_remote_extension_specs,
|
||||
normalize_remote_python_tool_pipeline,
|
||||
)
|
||||
|
||||
@@ -94,8 +94,8 @@ def test_normalize_code_server_specs_filters_invalid_entries() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_remote_lsp_server_specs_filters_invalid_entries() -> None:
|
||||
out = normalize_remote_lsp_server_specs(
|
||||
def test_normalize_remote_extension_specs_filters_invalid_entries() -> None:
|
||||
out = normalize_remote_extension_specs(
|
||||
[
|
||||
{
|
||||
"id": "pyright",
|
||||
@@ -111,7 +111,7 @@ def test_normalize_remote_lsp_server_specs_filters_invalid_entries() -> None:
|
||||
]
|
||||
)
|
||||
assert out == (
|
||||
RemoteLspServerSpec(
|
||||
RemoteExtensionSpec(
|
||||
id="pyright",
|
||||
label="Pyright",
|
||||
install_argv=("npm", "i", "-g", "pyright"),
|
||||
@@ -122,8 +122,8 @@ def test_normalize_remote_lsp_server_specs_filters_invalid_entries() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_remote_lsp_server_specs_defaults_label_and_probe() -> None:
|
||||
out = normalize_remote_lsp_server_specs(
|
||||
def test_normalize_remote_extension_specs_defaults_label_and_probe() -> None:
|
||||
out = normalize_remote_extension_specs(
|
||||
[
|
||||
{
|
||||
"id": "ruff",
|
||||
@@ -137,13 +137,13 @@ def test_normalize_remote_lsp_server_specs_defaults_label_and_probe() -> None:
|
||||
assert out[0].probe_argv == ()
|
||||
|
||||
|
||||
def test_merge_remote_lsp_catalog_uses_builtins_when_user_empty() -> None:
|
||||
merged = merge_remote_lsp_catalog([])
|
||||
assert merged == DEFAULT_BUILTIN_REMOTE_LSP_SERVER_SPECS
|
||||
def test_merge_remote_extension_catalog_uses_builtins_when_user_empty() -> None:
|
||||
merged = merge_remote_extension_catalog([])
|
||||
assert merged == DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS
|
||||
|
||||
|
||||
def test_merge_remote_lsp_catalog_user_overrides_builtin_by_id() -> None:
|
||||
merged = merge_remote_lsp_catalog(
|
||||
def test_merge_remote_extension_catalog_user_overrides_builtin_by_id() -> None:
|
||||
merged = merge_remote_extension_catalog(
|
||||
[
|
||||
{
|
||||
"id": "pyright-langserver",
|
||||
@@ -154,13 +154,22 @@ def test_merge_remote_lsp_catalog_user_overrides_builtin_by_id() -> None:
|
||||
}
|
||||
]
|
||||
)
|
||||
assert [s.id for s in merged] == ["pyright-langserver", "ruff", "rust-analyzer"]
|
||||
assert [s.id for s in merged] == [
|
||||
"pyright-langserver",
|
||||
"ruff",
|
||||
"rust-analyzer",
|
||||
"jupyterlab",
|
||||
"debugpy",
|
||||
"tmux",
|
||||
"claude-code",
|
||||
"codex-cli",
|
||||
]
|
||||
assert merged[0].label == "Custom Pyright"
|
||||
assert merged[0].probe_argv == ("pyright-langserver", "--help")
|
||||
|
||||
|
||||
def test_merge_remote_lsp_catalog_appends_user_only_ids() -> None:
|
||||
merged = merge_remote_lsp_catalog(
|
||||
def test_merge_remote_extension_catalog_appends_user_only_ids() -> None:
|
||||
merged = merge_remote_extension_catalog(
|
||||
[
|
||||
{
|
||||
"id": "my-lsp",
|
||||
@@ -175,6 +184,11 @@ def test_merge_remote_lsp_catalog_appends_user_only_ids() -> None:
|
||||
"pyright-langserver",
|
||||
"ruff",
|
||||
"rust-analyzer",
|
||||
"jupyterlab",
|
||||
"debugpy",
|
||||
"tmux",
|
||||
"claude-code",
|
||||
"codex-cli",
|
||||
"my-lsp",
|
||||
]
|
||||
|
||||
@@ -328,10 +342,15 @@ def test_load_settings_from_sublime_with_full_mock(monkeypatch) -> None:
|
||||
assert settings.gitea_rust_helper_download_enabled is False
|
||||
assert settings.gitea_rust_helper_revision_override is None
|
||||
assert settings.remote_python_tool_pipeline == ("ruff_lint",)
|
||||
assert {s.id for s in settings.remote_lsp_servers} == {
|
||||
assert {s.id for s in settings.remote_extensions} == {
|
||||
"pyright-langserver",
|
||||
"ruff",
|
||||
"rust-analyzer",
|
||||
"jupyterlab",
|
||||
"debugpy",
|
||||
"tmux",
|
||||
"claude-code",
|
||||
"codex-cli",
|
||||
}
|
||||
|
||||
|
||||
|
||||
341
sublime/tests/test_terminal_hover_links.py
Normal file
341
sublime/tests/test_terminal_hover_links.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""Tests for the hover-activation logic in ``terminal_link_click``.
|
||||
|
||||
The hover loop paints a ``markup.underline.link`` region under the
|
||||
token the cursor is over and erases it on hover-off. Real Sublime hover
|
||||
events aren't available in the Linux CI, so we drive the listener
|
||||
through :func:`sessions.terminal_link_click.process_hover` with a
|
||||
FakeView that records ``add_regions`` / ``erase_regions`` calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions import terminal_link_click
|
||||
from sessions.terminal_link_click import (
|
||||
SessionsTerminalLinkClickListener,
|
||||
process_hover,
|
||||
)
|
||||
|
||||
# Sublime's ``HOVER_TEXT`` value is 1; the module falls back to 1 when
|
||||
# ``sublime`` isn't importable.
|
||||
HOVER_TEXT = 1
|
||||
HOVER_GUTTER = 2
|
||||
|
||||
|
||||
class _FakeRegion:
|
||||
def __init__(self, start: int, end: int) -> None:
|
||||
self._start = start
|
||||
self._end = end
|
||||
|
||||
def begin(self) -> int:
|
||||
return self._start
|
||||
|
||||
def end(self) -> int:
|
||||
return self._end
|
||||
|
||||
|
||||
class _FakeSettings:
|
||||
def __init__(self, terminus: bool) -> None:
|
||||
self._values: Dict[str, Any] = {"terminus_view": terminus}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._values.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
self._values[key] = value
|
||||
|
||||
|
||||
class _FakeHoverView:
|
||||
"""Single-line Terminus-like view that records region side effects."""
|
||||
|
||||
_next_id = 1000
|
||||
|
||||
def __init__(self, text: str, *, terminus: bool = True) -> None:
|
||||
self._text = text
|
||||
self._settings = _FakeSettings(terminus=terminus)
|
||||
self.added_regions: List[Tuple[str, Any, str, int]] = []
|
||||
self.erased_region_keys: List[str] = []
|
||||
self._id = _FakeHoverView._next_id
|
||||
_FakeHoverView._next_id += 1
|
||||
|
||||
def id(self) -> int:
|
||||
return self._id
|
||||
|
||||
def line(self, point: int) -> _FakeRegion:
|
||||
return _FakeRegion(0, len(self._text))
|
||||
|
||||
def substr(self, region: _FakeRegion) -> str:
|
||||
return self._text[region.begin() : region.end()]
|
||||
|
||||
def settings(self) -> _FakeSettings:
|
||||
return self._settings
|
||||
|
||||
def add_regions(
|
||||
self,
|
||||
key: str,
|
||||
regions: Any,
|
||||
scope: str,
|
||||
icon: str,
|
||||
flags: int,
|
||||
) -> None:
|
||||
_ = icon
|
||||
# Materialise the iterable to a tuple so tests can inspect the
|
||||
# stored state after subsequent calls.
|
||||
self.added_regions.append((key, tuple(regions), scope, flags))
|
||||
|
||||
def erase_regions(self, key: str) -> None:
|
||||
self.erased_region_keys.append(key)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_hover_state():
|
||||
"""Clear the module-level hover-state dict between tests."""
|
||||
terminal_link_click._HOVER_STATE.clear()
|
||||
yield
|
||||
terminal_link_click._HOVER_STATE.clear()
|
||||
|
||||
|
||||
# --- process_hover -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_hover_paints_link_region_over_url_token() -> None:
|
||||
view = _FakeHoverView("See https://docs.example.com/x for more.")
|
||||
# Point inside the URL token.
|
||||
result = process_hover(view, point=10, hover_zone=HOVER_TEXT)
|
||||
assert result == ("url", "https://docs.example.com/x")
|
||||
# Underline region painted under the URL span exactly.
|
||||
assert len(view.added_regions) == 1
|
||||
key, regions, scope, _flags = view.added_regions[0]
|
||||
assert key == "sessions_terminal_link"
|
||||
# Sublime uses ``markup.underline.link`` as the link-color scope.
|
||||
assert scope == "markup.underline.link"
|
||||
assert len(regions) == 1
|
||||
start, end = regions[0]
|
||||
assert view._text[start:end] == "https://docs.example.com/x"
|
||||
|
||||
|
||||
def test_hover_paints_link_region_over_abspath_token() -> None:
|
||||
view = _FakeHoverView("Traceback: /srv/app/a.py:42")
|
||||
result = process_hover(view, point=16, hover_zone=HOVER_TEXT)
|
||||
assert result == ("abspath", "/srv/app/a.py")
|
||||
key, regions, _scope, _flags = view.added_regions[0]
|
||||
assert key == "sessions_terminal_link"
|
||||
start, end = regions[0]
|
||||
# The painted span covers the full token (including the ``:42``
|
||||
# suffix) so the underline matches what the user sees — the
|
||||
# classifier discards the suffix internally but hover paints the
|
||||
# whole clickable token.
|
||||
assert view._text[start:end] == "/srv/app/a.py:42"
|
||||
|
||||
|
||||
def test_hover_erases_region_when_token_not_clickable() -> None:
|
||||
view = _FakeHoverView("just a normal terminal line")
|
||||
# Prime the state as if a previous hover had painted something.
|
||||
view.add_regions(
|
||||
"sessions_terminal_link", [_FakeRegion(0, 4)], "markup.underline.link", "", 0
|
||||
)
|
||||
view.added_regions.clear()
|
||||
result = process_hover(view, point=5, hover_zone=HOVER_TEXT)
|
||||
assert result is None
|
||||
# Erased on hover-off (token is not a URL / abspath).
|
||||
assert "sessions_terminal_link" in view.erased_region_keys
|
||||
|
||||
|
||||
def test_hover_skips_non_terminus_views() -> None:
|
||||
view = _FakeHoverView("https://example.com/a", terminus=False)
|
||||
result = process_hover(view, point=5, hover_zone=HOVER_TEXT)
|
||||
assert result is None
|
||||
# No region painted on a non-Terminus view.
|
||||
assert not view.added_regions
|
||||
|
||||
|
||||
def test_hover_ignores_non_text_hover_zones() -> None:
|
||||
view = _FakeHoverView("https://example.com/a")
|
||||
result = process_hover(view, point=5, hover_zone=HOVER_GUTTER)
|
||||
assert result is None
|
||||
assert not view.added_regions
|
||||
|
||||
|
||||
def test_hover_replaces_prior_region_when_mouse_moves() -> None:
|
||||
view = _FakeHoverView("https://a.example /srv/b.py")
|
||||
# First hover lands on the URL.
|
||||
process_hover(view, point=5, hover_zone=HOVER_TEXT)
|
||||
# Second hover lands on the path.
|
||||
process_hover(view, point=22, hover_zone=HOVER_TEXT)
|
||||
# Two paints, each with the same region key so Sublime replaces
|
||||
# the underline each time.
|
||||
assert len(view.added_regions) == 2
|
||||
assert all(entry[0] == "sessions_terminal_link" for entry in view.added_regions)
|
||||
|
||||
|
||||
def test_hover_state_drops_on_close() -> None:
|
||||
view = _FakeHoverView("https://example.com/x")
|
||||
process_hover(view, point=5, hover_zone=HOVER_TEXT)
|
||||
assert view.id() in terminal_link_click._HOVER_STATE
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
listener.on_close(view)
|
||||
assert view.id() not in terminal_link_click._HOVER_STATE
|
||||
# Erase side-effect also fires so the pane never orphans the region.
|
||||
assert "sessions_terminal_link" in view.erased_region_keys
|
||||
|
||||
|
||||
# --- click fast path --------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeClickView(_FakeHoverView):
|
||||
"""Terminus view that also exposes ``window_to_text`` + ``window``."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
super().__init__(text, terminus=True)
|
||||
self.window_value: Optional[object] = None
|
||||
|
||||
def window_to_text(self, xy) -> int:
|
||||
# Tests pass ``x`` as the direct character offset for
|
||||
# determinism; ``y`` is ignored.
|
||||
return int(xy[0])
|
||||
|
||||
def window(self) -> Optional[object]:
|
||||
return self.window_value
|
||||
|
||||
|
||||
class _FakeClickWindow:
|
||||
def __init__(self) -> None:
|
||||
self.commands: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
def run_command(self, name: str, args: Dict[str, Any]) -> None:
|
||||
self.commands.append((name, args))
|
||||
|
||||
|
||||
def _click_event(point: int) -> Dict[str, Any]:
|
||||
return {"x": point, "y": 0, "modifier_keys": {"primary": True}}
|
||||
|
||||
|
||||
def test_click_reuses_active_hover_region(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
view = _FakeClickView("open /srv/app/a.py now")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
# Hover paints the region + records state first.
|
||||
process_hover(view, point=10, hover_zone=HOVER_TEXT)
|
||||
assert view.id() in terminal_link_click._HOVER_STATE
|
||||
|
||||
# Sentinel: ensure the click path does NOT re-run classification
|
||||
# when a cached hover span covers the click point.
|
||||
calls: List[str] = []
|
||||
real_classify = terminal_link_click.classify_terminal_token
|
||||
|
||||
def tracer(token: str) -> Any:
|
||||
calls.append(token)
|
||||
return real_classify(token)
|
||||
|
||||
monkeypatch.setattr(terminal_link_click, "classify_terminal_token", tracer)
|
||||
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
listener.on_text_command(view, "drag_select", {"event": _click_event(10)})
|
||||
|
||||
assert calls == [], "click should re-use hover classification, not re-classify"
|
||||
assert window.commands == [("open_file", {"file": "/srv/app/a.py"})]
|
||||
|
||||
|
||||
def test_click_falls_back_to_classification_when_hover_absent() -> None:
|
||||
view = _FakeClickView("open /srv/app/b.py now")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
# No hover recorded — ``_HOVER_STATE`` is empty.
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
listener.on_text_command(view, "drag_select", {"event": _click_event(10)})
|
||||
assert window.commands == [("open_file", {"file": "/srv/app/b.py"})]
|
||||
|
||||
|
||||
def test_click_ignores_non_primary_modifier() -> None:
|
||||
view = _FakeClickView("open /srv/app/c.py now")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
event = {"x": 10, "y": 0, "modifier_keys": {"primary": False}}
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
listener.on_text_command(view, "drag_select", {"event": event})
|
||||
assert window.commands == []
|
||||
|
||||
|
||||
def test_click_outside_hover_region_still_classifies() -> None:
|
||||
# Hover painted on one token; click lands outside that span on a
|
||||
# different token — the listener must re-classify rather than use
|
||||
# the stale hover record.
|
||||
view = _FakeClickView("/srv/a.py /srv/b.py")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
process_hover(view, point=2, hover_zone=HOVER_TEXT)
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
# Point 15 is inside ``/srv/b.py``.
|
||||
listener.on_text_command(view, "drag_select", {"event": _click_event(15)})
|
||||
assert window.commands == [("open_file", {"file": "/srv/b.py"})]
|
||||
|
||||
|
||||
def test_click_on_abspath_suppresses_drag_select() -> None:
|
||||
# Regression: in v0.5.x hover painted the box but Cmd+click failed
|
||||
# to open the file because the underlying ``drag_select`` ran in
|
||||
# parallel and clobbered the open. The listener must return
|
||||
# ``("noop", {})`` from ``on_text_command`` to suppress drag_select
|
||||
# whenever it dispatches a link.
|
||||
view = _FakeClickView("open /srv/app/x.py now")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
result = listener.on_text_command(view, "drag_select", {"event": _click_event(10)})
|
||||
assert result == ("noop", {})
|
||||
assert window.commands == [("open_file", {"file": "/srv/app/x.py"})]
|
||||
|
||||
|
||||
def test_click_on_url_suppresses_drag_select(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Same regression contract for URL clicks — the browser open path
|
||||
# must not let drag_select also run.
|
||||
view = _FakeClickView("see https://example.com/x for more")
|
||||
view.window_value = _FakeClickWindow()
|
||||
opened: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
terminal_link_click,
|
||||
"_handle_url",
|
||||
lambda url: opened.append(url),
|
||||
)
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
# Point 6 lands inside the URL span.
|
||||
result = listener.on_text_command(view, "drag_select", {"event": _click_event(6)})
|
||||
assert result == ("noop", {})
|
||||
assert opened == ["https://example.com/x"]
|
||||
|
||||
|
||||
def test_click_on_localhost_url_opens_browser(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Cluster B fix: scheme-less ``localhost:PORT`` should round-trip
|
||||
# through ``_handle_url`` as ``http://localhost:PORT/`` so the browser
|
||||
# picks it up like any other URL. The trailing slash is load-bearing
|
||||
# on macOS — without it ``open location`` falls back to
|
||||
# ``about:blank`` (the v0.6.4 regression).
|
||||
view = _FakeClickView("Server up at localhost:8080 now")
|
||||
view.window_value = _FakeClickWindow()
|
||||
opened: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
terminal_link_click,
|
||||
"_handle_url",
|
||||
lambda url: opened.append(url),
|
||||
)
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
# Point 14 lands inside the ``localhost:8080`` token (offset of ``l``).
|
||||
result = listener.on_text_command(view, "drag_select", {"event": _click_event(14)})
|
||||
assert result == ("noop", {})
|
||||
assert opened == ["http://localhost:8080/"]
|
||||
|
||||
|
||||
def test_click_on_non_link_token_falls_through_to_drag_select() -> None:
|
||||
# Sanity: clicking on plain text must NOT suppress drag_select. Users
|
||||
# still need to be able to select text in a terminal pane with
|
||||
# Cmd+click for native multi-cursor (or whatever Terminus binds it to).
|
||||
view = _FakeClickView("plain text here")
|
||||
view.window_value = _FakeClickWindow()
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
# Click on "plain" (offset 2) — not a link token.
|
||||
result = listener.on_text_command(view, "drag_select", {"event": _click_event(2)})
|
||||
assert result is None # drag_select runs as normal
|
||||
assert view.window_value.commands == [] # nothing dispatched
|
||||
@@ -86,6 +86,159 @@ def test_classify_token_trims_trailing_punctuation() -> None:
|
||||
)
|
||||
|
||||
|
||||
# --- scheme-less host:port URLs ---------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token, expected_url",
|
||||
[
|
||||
# Bare localhost:port — the canonical dev-server case. We always
|
||||
# emit a trailing slash on the path because macOS' ``open
|
||||
# location`` (driving Safari/Chrome via AppleScript) treats a
|
||||
# bare host:port URL as under-specified and falls back to
|
||||
# ``about:blank``.
|
||||
("localhost:8080", "http://localhost:8080/"),
|
||||
("localhost:8888/notebooks/a.ipynb", "http://localhost:8888/notebooks/a.ipynb"),
|
||||
# 127.0.0.1 is what Jupyter's startup banner prints.
|
||||
("127.0.0.1:8888", "http://127.0.0.1:8888/"),
|
||||
("127.0.0.1:5173/", "http://127.0.0.1:5173/"),
|
||||
# Arbitrary IPv4 that shows up in ML / Ray / dashboard links.
|
||||
("10.0.0.4:9000", "http://10.0.0.4:9000/"),
|
||||
# Trailing punctuation from prose strips before matching.
|
||||
("localhost:3000.", "http://localhost:3000/"),
|
||||
# ``0.0.0.0`` is a wildcard bind address — servers print it to
|
||||
# mean "listening on every interface" but browsers can't route
|
||||
# to it. Canonicalize to ``localhost`` so the click lands on
|
||||
# the loopback listener the user actually wants.
|
||||
("0.0.0.0:8080", "http://localhost:8080/"),
|
||||
("0.0.0.0:8080/", "http://localhost:8080/"),
|
||||
("0.0.0.0:8080/dashboard", "http://localhost:8080/dashboard"),
|
||||
],
|
||||
)
|
||||
def test_classify_token_handles_localhost_and_host_port(
|
||||
token: str, expected_url: str
|
||||
) -> None:
|
||||
assert classify_terminal_token(token) == ("url", expected_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
# A bare hostname with no port must not match — too many false
|
||||
# positives in normal terminal output (``var:42`` etc.).
|
||||
"localhost",
|
||||
# Port out of range.
|
||||
"localhost:99999",
|
||||
# ``host:line`` style (no slash, port too high) — should not
|
||||
# masquerade as a URL.
|
||||
"myvar:42",
|
||||
# Word-shaped host with a colon — distinguish from the
|
||||
# localhost / IPv4 allow-list. ``foo.example.com:80`` is a valid
|
||||
# URL idea but we ask the user to type ``http://`` explicitly so
|
||||
# we don't promote arbitrary hostnames in terminal output.
|
||||
"foo.example.com:8080",
|
||||
# IPv4 with octet > 255 still gets accepted by the regex but
|
||||
# the test below documents that we don't try to validate octets;
|
||||
# callers expect a raw browser navigation, which will fail
|
||||
# gracefully for invalid IPs. Skip in the *reject* set —
|
||||
# see the dedicated test below.
|
||||
],
|
||||
)
|
||||
def test_classify_token_rejects_non_url_host_port_shapes(token: str) -> None:
|
||||
assert classify_terminal_token(token) is None
|
||||
|
||||
|
||||
def test_classify_token_localhost_does_not_collide_with_abspath() -> None:
|
||||
# ``/srv/.../localhost:8080`` is an abspath on disk — host:port
|
||||
# detection must only fire on tokens that don't start with ``/``.
|
||||
result = classify_terminal_token("/srv/etc/localhost:8080")
|
||||
assert result is not None
|
||||
kind, value = result
|
||||
assert kind == "abspath"
|
||||
# Path part stops before the ``:8080`` suffix per the abspath regex.
|
||||
assert value == "/srv/etc/localhost"
|
||||
|
||||
|
||||
# --- adversarial: the v0.6.4 ``about:blank-`` regression --------------------
|
||||
|
||||
|
||||
def test_classify_token_zero_host_canonicalizes_to_localhost() -> None:
|
||||
# Repro for v0.6.4 macOS bug: ``python3 -m http.server 8080`` prints
|
||||
# ``Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...``.
|
||||
# macOS browsers can't route to ``0.0.0.0`` and fall back to
|
||||
# ``about:blank``; canonicalize to loopback so Cmd+click reaches
|
||||
# the listener the user actually wants.
|
||||
assert classify_terminal_token("0.0.0.0:8080") == (
|
||||
"url",
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
|
||||
|
||||
def test_classify_token_bare_host_port_emits_trailing_slash() -> None:
|
||||
# Without a trailing slash the macOS ``open location`` AppleScript
|
||||
# treats the URL as under-specified and the browser shows a stray
|
||||
# leftover suffix (the v0.6.4 ``about:blank-`` symptom). The
|
||||
# promotion path always emits a canonical ``/`` when no path
|
||||
# component is present so every platform sees a well-formed URL.
|
||||
assert classify_terminal_token("localhost:8080") == (
|
||||
"url",
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
assert classify_terminal_token("127.0.0.1:8080") == (
|
||||
"url",
|
||||
"http://127.0.0.1:8080/",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
# A trailing dash glued to the port has no canonical
|
||||
# interpretation as a URL — better to refuse than guess. The
|
||||
# v0.6.4 ``about:blank-`` symptom on macOS came from passing a
|
||||
# malformed token straight to the browser; rejecting here means
|
||||
# the click falls through to plain text selection.
|
||||
"localhost:8080-extra",
|
||||
"localhost:8080-",
|
||||
"127.0.0.1:8080-",
|
||||
"0.0.0.0:8080-",
|
||||
],
|
||||
)
|
||||
def test_classify_token_rejects_dash_glued_to_port(token: str) -> None:
|
||||
assert classify_terminal_token(token) is None
|
||||
|
||||
|
||||
def test_http_server_banner_line_classifies_only_clean_url_tokens() -> None:
|
||||
# The whole-line scenario for ``python3 -m http.server 8080``:
|
||||
# ``Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...``.
|
||||
# We feed each whitespace-delimited token to the classifier. The
|
||||
# bare ``0.0.0.0`` (no port) and bare ``8080`` (no host) are noise;
|
||||
# the parens-wrapped URL is rejected because we deliberately don't
|
||||
# strip leading brackets (policy: hover precision over greediness).
|
||||
# Nothing should classify as a URL — the user clicks on a clean
|
||||
# ``localhost:8080`` token elsewhere in their pane (e.g. an explicit
|
||||
# echo, a Vite/Jupyter banner) and gets the canonical
|
||||
# ``http://localhost:8080/`` form below.
|
||||
line = "Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ..."
|
||||
hits = []
|
||||
for token in line.split():
|
||||
result = classify_terminal_token(token)
|
||||
if result is not None:
|
||||
hits.append(result)
|
||||
# No token in *this* line promotes — the parens-wrapped URL is the
|
||||
# one the user would visually click on but our policy is to require
|
||||
# hover/click on the URL itself.
|
||||
assert hits == []
|
||||
# Sanity: the *bare* ``0.0.0.0:8080`` token (the load-bearing case
|
||||
# the v0.6.4 bug report covers) does promote, and it canonicalizes
|
||||
# to localhost with a trailing slash so macOS Safari/Chrome can
|
||||
# actually route it.
|
||||
assert classify_terminal_token("0.0.0.0:8080") == (
|
||||
"url",
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
|
||||
|
||||
# --- extract_token_at --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
363
sublime/tests/test_terminal_tmux_session.py
Normal file
363
sublime/tests/test_terminal_tmux_session.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Tests for ``sessions.terminal_tmux_session``.
|
||||
|
||||
The helper is Sublime-free; tests exercise session-name validation and
|
||||
the ``command -v tmux`` probe by injecting a recorder in place of
|
||||
``subprocess.run``. No real subprocess or SSH is ever spawned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
import pytest
|
||||
from sessions.terminal_tmux_session import (
|
||||
SESSION_NAME_PREFIX,
|
||||
TerminalTmuxSessionError,
|
||||
TmuxProbeResult,
|
||||
build_remote_tmux_invocation,
|
||||
kill_terminal_session,
|
||||
list_terminal_sessions,
|
||||
next_terminal_session_name,
|
||||
probe_tmux_available,
|
||||
session_name_for_host,
|
||||
)
|
||||
|
||||
# --- session_name_for_host ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias",
|
||||
[
|
||||
"prod",
|
||||
"bastion.example.com",
|
||||
"host_01",
|
||||
"worker-02",
|
||||
"CamelCase",
|
||||
"h.o.s.t",
|
||||
],
|
||||
)
|
||||
def test_session_name_for_host_accepts_safe_aliases(alias: str) -> None:
|
||||
name = session_name_for_host(alias)
|
||||
assert name == f"{SESSION_NAME_PREFIX}{alias}"
|
||||
# Prefix distinct from the agent-tmux one so Track C2 and Track D
|
||||
# sessions never collide on the remote host.
|
||||
assert name.startswith("sessions-term-")
|
||||
assert not name.startswith("sessions-agent-")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias",
|
||||
[
|
||||
"",
|
||||
"bad alias",
|
||||
"al;ias",
|
||||
"a$b",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
"a|b",
|
||||
"a&b",
|
||||
"a'b",
|
||||
'a"b',
|
||||
"a`b",
|
||||
"한글",
|
||||
],
|
||||
)
|
||||
def test_session_name_for_host_rejects_unsafe_aliases(alias: str) -> None:
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
session_name_for_host(alias)
|
||||
|
||||
|
||||
def test_session_name_for_host_rejects_non_string() -> None:
|
||||
# The helper accepts only ``str``; hosts like ``None`` / ``int`` get a
|
||||
# clear error rather than an attribute error deep in the regex.
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
session_name_for_host(None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# --- build_remote_tmux_invocation -------------------------------------------
|
||||
|
||||
|
||||
def test_build_remote_tmux_invocation_wraps_preamble_and_shell() -> None:
|
||||
invocation = build_remote_tmux_invocation(
|
||||
session_name="sessions-term-prod",
|
||||
shell_preamble="cd '/srv/app' && (stty sane -ixon 2>/dev/null || true)",
|
||||
shell_command="exec bash -il",
|
||||
)
|
||||
# Preamble runs first so the initial cwd is correct; ``tmux
|
||||
# new-session -A`` attaches to the existing session or spawns a new
|
||||
# one with ``<shell>`` as its child process.
|
||||
assert invocation == (
|
||||
"cd '/srv/app' && (stty sane -ixon 2>/dev/null || true) && "
|
||||
"tmux new-session -A -s 'sessions-term-prod' exec bash -il"
|
||||
)
|
||||
|
||||
|
||||
def test_build_remote_tmux_invocation_uses_attach_or_spawn_flag() -> None:
|
||||
# ``-A`` is the idempotent flag: attach if running, create otherwise.
|
||||
invocation = build_remote_tmux_invocation(
|
||||
session_name="sessions-term-h",
|
||||
shell_preamble="cd /tmp",
|
||||
shell_command="exec zsh",
|
||||
)
|
||||
assert "tmux new-session -A -s 'sessions-term-h'" in invocation
|
||||
|
||||
|
||||
# --- probe_tmux_available ----------------------------------------------------
|
||||
|
||||
|
||||
class _RecordingRun:
|
||||
"""Callable stub that records ``subprocess.run`` invocations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
returncode: int = 0,
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
raises: Optional[BaseException] = None,
|
||||
) -> None:
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.raises = raises
|
||||
self.calls: List[Sequence[str]] = []
|
||||
|
||||
def __call__(self, argv, **_kwargs) -> subprocess.CompletedProcess:
|
||||
self.calls.append(list(argv))
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
return subprocess.CompletedProcess(
|
||||
args=list(argv),
|
||||
returncode=self.returncode,
|
||||
stdout=self.stdout,
|
||||
stderr=self.stderr,
|
||||
)
|
||||
|
||||
|
||||
def test_probe_tmux_available_true_when_command_v_succeeds() -> None:
|
||||
run = _RecordingRun(returncode=0, stdout="/usr/bin/tmux\n", stderr="")
|
||||
result = probe_tmux_available("prod", run=run)
|
||||
assert isinstance(result, TmuxProbeResult)
|
||||
assert result.available is True
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == "/usr/bin/tmux"
|
||||
# Built on the default ``ssh <alias>`` argv prefix; caller can
|
||||
# override via ``ssh_command_builder``.
|
||||
assert run.calls == [["ssh", "prod", "command", "-v", "tmux"]]
|
||||
|
||||
|
||||
def test_probe_tmux_available_false_when_command_v_empty_stdout() -> None:
|
||||
# POSIX shells return 0 with empty stdout for missing commands in
|
||||
# some edge cases — treat empty stdout as "missing".
|
||||
run = _RecordingRun(returncode=0, stdout="", stderr="")
|
||||
result = probe_tmux_available("prod", run=run)
|
||||
assert result.available is False
|
||||
|
||||
|
||||
def test_probe_tmux_available_false_when_command_v_exits_nonzero() -> None:
|
||||
run = _RecordingRun(returncode=1, stdout="", stderr="command not found")
|
||||
result = probe_tmux_available("prod", run=run)
|
||||
assert result.available is False
|
||||
assert result.stderr == "command not found"
|
||||
|
||||
|
||||
def test_probe_tmux_available_folds_timeout_into_false() -> None:
|
||||
run = _RecordingRun(
|
||||
raises=subprocess.TimeoutExpired(cmd=["ssh"], timeout=5.0),
|
||||
)
|
||||
result = probe_tmux_available("prod", run=run, timeout=5.0)
|
||||
assert result.available is False
|
||||
assert "timeout" in result.stderr
|
||||
|
||||
|
||||
def test_probe_tmux_available_folds_oserror_into_false() -> None:
|
||||
run = _RecordingRun(raises=OSError("ssh: not found"))
|
||||
result = probe_tmux_available("prod", run=run)
|
||||
assert result.available is False
|
||||
assert "ssh probe failed" in result.stderr
|
||||
|
||||
|
||||
def test_probe_tmux_available_uses_custom_ssh_builder() -> None:
|
||||
run = _RecordingRun(returncode=0, stdout="/usr/bin/tmux")
|
||||
|
||||
def builder(alias: str) -> List[str]:
|
||||
return ["ssh", "-F", "/tmp/config", alias]
|
||||
|
||||
probe_tmux_available("bastion", run=run, ssh_command_builder=builder)
|
||||
assert run.calls == [
|
||||
["ssh", "-F", "/tmp/config", "bastion", "command", "-v", "tmux"],
|
||||
]
|
||||
|
||||
|
||||
# --- next_terminal_session_name ----------------------------------------------
|
||||
|
||||
|
||||
def test_next_terminal_session_name_starts_at_two() -> None:
|
||||
# The base session ``sessions-term-prod`` is reserved for the
|
||||
# default reattach command; the first new pane is always ``-2``.
|
||||
assert next_terminal_session_name("prod", []) == "sessions-term-prod-2"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_starts_at_two_when_only_base_running() -> None:
|
||||
# Even if the base is already up, the first numbered pane is ``-2``.
|
||||
assert (
|
||||
next_terminal_session_name("prod", ["sessions-term-prod"])
|
||||
== "sessions-term-prod-2"
|
||||
)
|
||||
|
||||
|
||||
def test_next_terminal_session_name_skips_used_indices() -> None:
|
||||
existing = [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-term-prod-3",
|
||||
"sessions-term-other",
|
||||
]
|
||||
assert next_terminal_session_name("prod", existing) == "sessions-term-prod-4"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_fills_gaps() -> None:
|
||||
# The smallest free index is preferred so users don't see ever-growing
|
||||
# numbers when they kill an intermediate pane.
|
||||
existing = ["sessions-term-prod-2", "sessions-term-prod-4"]
|
||||
assert next_terminal_session_name("prod", existing) == "sessions-term-prod-3"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_ignores_other_hosts() -> None:
|
||||
existing = ["sessions-term-staging-2", "sessions-term-staging-3"]
|
||||
assert next_terminal_session_name("prod", existing) == "sessions-term-prod-2"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_ignores_non_numeric_suffix() -> None:
|
||||
# A user-renamed session like ``sessions-term-prod-debug`` shouldn't
|
||||
# bump the next index — only purely numeric suffixes count.
|
||||
existing = [
|
||||
"sessions-term-prod-debug",
|
||||
"sessions-term-prod-2",
|
||||
]
|
||||
assert next_terminal_session_name("prod", existing) == "sessions-term-prod-3"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_rejects_invalid_alias() -> None:
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
next_terminal_session_name("bad alias", [])
|
||||
|
||||
|
||||
# --- list_terminal_sessions --------------------------------------------------
|
||||
|
||||
|
||||
def test_list_terminal_sessions_filters_to_terminal_prefix() -> None:
|
||||
stdout = (
|
||||
"sessions-term-prod\n"
|
||||
"sessions-term-prod-2\n"
|
||||
"sessions-agent-abc12345-claude\n" # agent prefix — excluded.
|
||||
"user-shell\n" # unrelated session — excluded.
|
||||
)
|
||||
run = _RecordingRun(returncode=0, stdout=stdout)
|
||||
result = list_terminal_sessions("prod", run=run)
|
||||
assert result == ["sessions-term-prod", "sessions-term-prod-2"]
|
||||
# Argv exercises the same default builder as the probe path.
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"prod",
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_list_terminal_sessions_returns_empty_when_no_server_running() -> None:
|
||||
# tmux exits 1 with "no server running" when nothing is up — treated
|
||||
# as empty so the caller doesn't need a try/except.
|
||||
run = _RecordingRun(returncode=1, stdout="", stderr="no server running")
|
||||
result = list_terminal_sessions("prod", run=run)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_list_terminal_sessions_returns_empty_when_tmux_missing() -> None:
|
||||
run = _RecordingRun(returncode=127, stdout="", stderr="tmux: command not found")
|
||||
result = list_terminal_sessions("prod", run=run)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_list_terminal_sessions_returns_empty_on_timeout() -> None:
|
||||
run = _RecordingRun(
|
||||
raises=subprocess.TimeoutExpired(cmd=["ssh"], timeout=5.0),
|
||||
)
|
||||
assert list_terminal_sessions("prod", run=run, timeout=5.0) == []
|
||||
|
||||
|
||||
def test_list_terminal_sessions_returns_empty_on_oserror() -> None:
|
||||
run = _RecordingRun(raises=OSError("ssh: not found"))
|
||||
assert list_terminal_sessions("prod", run=run) == []
|
||||
|
||||
|
||||
# --- kill_terminal_session ---------------------------------------------------
|
||||
|
||||
|
||||
def test_kill_terminal_session_runs_kill_session_argv() -> None:
|
||||
run = _RecordingRun(returncode=0)
|
||||
completed = kill_terminal_session("prod", "sessions-term-prod-2", run=run)
|
||||
assert completed.returncode == 0
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"prod",
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
"sessions-term-prod-2",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_kill_terminal_session_refuses_non_terminal_session_names() -> None:
|
||||
# Hard-coded refusal so a misuse (e.g. passing an agent session name)
|
||||
# cannot accidentally tear down something the caller didn't intend.
|
||||
run = _RecordingRun(returncode=0)
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
kill_terminal_session("prod", "sessions-agent-abc-claude", run=run)
|
||||
assert run.calls == [] # never reached the SSH call
|
||||
|
||||
|
||||
def test_kill_terminal_session_propagates_already_gone_stderr() -> None:
|
||||
# ``kill-session`` exits non-zero when the session is gone; we
|
||||
# surface the stderr verbatim so the caller can render a hint.
|
||||
run = _RecordingRun(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="can't find session: sessions-term-prod-7",
|
||||
)
|
||||
completed = kill_terminal_session("prod", "sessions-term-prod-7", run=run)
|
||||
assert completed.returncode == 1
|
||||
assert "can't find session" in completed.stderr
|
||||
|
||||
|
||||
def test_kill_terminal_session_uses_custom_ssh_builder() -> None:
|
||||
run = _RecordingRun(returncode=0)
|
||||
|
||||
def builder(alias: str) -> List[str]:
|
||||
return ["ssh", "-F", "/tmp/config", alias]
|
||||
|
||||
kill_terminal_session(
|
||||
"bastion",
|
||||
"sessions-term-bastion",
|
||||
run=run,
|
||||
ssh_command_builder=builder,
|
||||
)
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"-F",
|
||||
"/tmp/config",
|
||||
"bastion",
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
"sessions-term-bastion",
|
||||
]
|
||||
]
|
||||
@@ -1,15 +1,13 @@
|
||||
"""Unit tests for scripts/upload_session_helper_to_gitea.py.
|
||||
|
||||
Covers 409-conflict retry, 401 reqPackageAccess hint, 403 Cloudflare 1010
|
||||
hint, release metadata patch/create, and soft-failure policies.
|
||||
hint, package-link idempotency, and repo-context inference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from urllib.error import HTTPError
|
||||
|
||||
@@ -174,181 +172,6 @@ def test_prepare_upload_delete_file_fails_falls_back_to_version_delete(
|
||||
assert "WARNING" in captured
|
||||
|
||||
|
||||
# --- _create_repository_release ---
|
||||
|
||||
|
||||
def test_create_release_skip_no_tag(monkeypatch) -> None:
|
||||
monkeypatch.delenv("GITEA_PACKAGE_REPO", raising=False)
|
||||
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
|
||||
ok, result = upload_mod._create_repository_release(
|
||||
base_url="https://git.example.com",
|
||||
owner="test",
|
||||
release_tag="",
|
||||
target_commitish="abc123",
|
||||
release_title="title",
|
||||
release_notes="notes",
|
||||
)
|
||||
assert ok is True
|
||||
assert "skip" in result
|
||||
|
||||
|
||||
def test_create_release_skip_no_repo(monkeypatch) -> None:
|
||||
monkeypatch.delenv("GITEA_PACKAGE_REPO", raising=False)
|
||||
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
|
||||
monkeypatch.delenv("GITEA_REPOSITORY", raising=False)
|
||||
ok, result = upload_mod._create_repository_release(
|
||||
base_url="https://git.example.com",
|
||||
owner="test",
|
||||
release_tag="v1.0",
|
||||
target_commitish="abc123",
|
||||
release_title="v1.0",
|
||||
release_notes="",
|
||||
)
|
||||
assert ok is True
|
||||
assert "skip" in result
|
||||
|
||||
|
||||
def test_create_release_patches_existing_tag(monkeypatch) -> None:
|
||||
monkeypatch.setenv("GITEA_PACKAGE_REPO", "sessions")
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({"id": 42}).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_resp.getcode.return_value = 200
|
||||
|
||||
call_log: list[dict[str, Any]] = []
|
||||
|
||||
def mock_urlopen(req, **kwargs):
|
||||
call_log.append(
|
||||
{"url": req.full_url, "method": req.get_method(), "data": req.data}
|
||||
)
|
||||
return mock_resp
|
||||
|
||||
monkeypatch.setattr(upload_mod, "urlopen", mock_urlopen)
|
||||
monkeypatch.setattr(
|
||||
upload_mod,
|
||||
"_artifact_put_headers",
|
||||
lambda: {"Authorization": "token xyz"},
|
||||
)
|
||||
|
||||
ok, result = upload_mod._create_repository_release(
|
||||
base_url="https://git.example.com",
|
||||
owner="test",
|
||||
release_tag="v1.0",
|
||||
target_commitish="abc123",
|
||||
release_title="v1.0",
|
||||
release_notes="Release notes.",
|
||||
)
|
||||
assert ok is True
|
||||
methods = [c["method"] for c in call_log]
|
||||
assert "GET" in methods
|
||||
assert "PATCH" in methods
|
||||
|
||||
|
||||
def test_create_release_creates_new_when_no_existing(monkeypatch) -> None:
|
||||
monkeypatch.setenv("GITEA_PACKAGE_REPO", "sessions")
|
||||
|
||||
get_resp_404 = _make_http_error(404, "not found")
|
||||
post_resp = MagicMock()
|
||||
post_resp.read.return_value = b"{}"
|
||||
post_resp.__enter__ = lambda s: s
|
||||
post_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(req, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if req.get_method() == "GET":
|
||||
raise get_resp_404
|
||||
return post_resp
|
||||
|
||||
monkeypatch.setattr(upload_mod, "urlopen", mock_urlopen)
|
||||
monkeypatch.setattr(
|
||||
upload_mod,
|
||||
"_artifact_put_headers",
|
||||
lambda: {"Authorization": "token xyz"},
|
||||
)
|
||||
|
||||
ok, result = upload_mod._create_repository_release(
|
||||
base_url="https://git.example.com",
|
||||
owner="test",
|
||||
release_tag="v2.0",
|
||||
target_commitish="def456",
|
||||
release_title="v2.0",
|
||||
release_notes="",
|
||||
)
|
||||
assert ok is True
|
||||
assert "ok" in result
|
||||
|
||||
|
||||
def test_release_metadata_soft_fails_without_breaking_upload(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("GITEA_PACKAGE_REPO", "sessions")
|
||||
|
||||
err = _make_http_error(500, "internal server error")
|
||||
|
||||
def mock_urlopen(req, **kwargs):
|
||||
raise err
|
||||
|
||||
monkeypatch.setattr(upload_mod, "urlopen", mock_urlopen)
|
||||
monkeypatch.setattr(
|
||||
upload_mod,
|
||||
"_artifact_put_headers",
|
||||
lambda: {"Authorization": "token xyz"},
|
||||
)
|
||||
|
||||
ok, result = upload_mod._create_repository_release(
|
||||
base_url="https://git.example.com",
|
||||
owner="test",
|
||||
release_tag="v3.0",
|
||||
target_commitish="abc",
|
||||
release_title="v3.0",
|
||||
release_notes="",
|
||||
)
|
||||
assert ok is False
|
||||
assert "failed" in result
|
||||
|
||||
|
||||
def test_create_release_409_already_exists_retries_patch(monkeypatch) -> None:
|
||||
monkeypatch.setenv("GITEA_PACKAGE_REPO", "sessions")
|
||||
|
||||
call_log: list[str] = []
|
||||
|
||||
def mock_urlopen(req, **kwargs):
|
||||
method = req.get_method()
|
||||
call_log.append(method)
|
||||
if method == "GET" and len([c for c in call_log if c == "GET"]) == 1:
|
||||
raise _make_http_error(404, "not found")
|
||||
if method == "POST":
|
||||
raise _make_http_error(409, '{"message":"tag already exists"}')
|
||||
# Second GET finds the release
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({"id": 99}).encode()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(upload_mod, "urlopen", mock_urlopen)
|
||||
monkeypatch.setattr(
|
||||
upload_mod,
|
||||
"_artifact_put_headers",
|
||||
lambda: {"Authorization": "token xyz"},
|
||||
)
|
||||
|
||||
ok, result = upload_mod._create_repository_release(
|
||||
base_url="https://git.example.com",
|
||||
owner="test",
|
||||
release_tag="v4.0",
|
||||
target_commitish="abc",
|
||||
release_title="v4.0",
|
||||
release_notes="",
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# --- _link_package_to_repo ---
|
||||
|
||||
|
||||
|
||||
187
sublime/tests/test_windows_subprocess_flags.py
Normal file
187
sublime/tests/test_windows_subprocess_flags.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Adversarial tests for the Windows ``CREATE_NO_WINDOW`` wiring.
|
||||
|
||||
v0.6.1 fixed the ``cmd.exe`` console flash that plagued the Windows
|
||||
test pass of v0.6.0: every ``subprocess.run`` / ``subprocess.Popen``
|
||||
call in ``agent_tmux`` / ``jupyter_hosting`` / ``terminal_tmux_session``
|
||||
now threads through ``ssh_runner._subprocess_no_window_kwargs()``.
|
||||
|
||||
This module is the regression shield. It monkey-patches
|
||||
``sys.platform = "win32"`` so the helper returns the real Windows
|
||||
flag value, then dispatches each module's subprocess entry-point
|
||||
through a recorder and asserts the flag lands in the kwargs.
|
||||
|
||||
Classifier note: the test bodies reference ``subprocess.Popen`` /
|
||||
``subprocess.run`` directly so they land in the "real-subprocess"
|
||||
bucket of ``scripts/test_health.py`` — we want that signal because
|
||||
these tests exist *because* the code path runs real subprocesses in
|
||||
production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import patch
|
||||
|
||||
from sessions import agent_tmux, jupyter_hosting, terminal_tmux_session
|
||||
from sessions.ssh_runner import _subprocess_no_window_kwargs
|
||||
|
||||
# The flag value is a real constant on Windows Python, 0x08000000. On
|
||||
# non-Windows hosts the subprocess module still exports it on 3.8+,
|
||||
# so we can assert against the literal in CI that runs on Linux.
|
||||
_EXPECTED_WINDOWS_FLAG = 0x08000000
|
||||
|
||||
|
||||
def _install_win32(monkeypatch) -> None:
|
||||
"""Force ``_subprocess_no_window_kwargs`` to the Windows branch.
|
||||
|
||||
The helper is sensitive to ``sys.platform``; flipping it is the
|
||||
only way to exercise the Windows-only code path from Linux CI.
|
||||
The ``getattr`` fallback inside the helper still checks
|
||||
``subprocess.CREATE_NO_WINDOW`` — that attribute has existed on
|
||||
the stdlib ``subprocess`` module since Python 3.7 regardless of
|
||||
platform, so the branch fires.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
subprocess, "CREATE_NO_WINDOW", _EXPECTED_WINDOWS_FLAG, raising=False
|
||||
)
|
||||
|
||||
|
||||
def test_helper_emits_creationflags_on_win32(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
kwargs = _subprocess_no_window_kwargs()
|
||||
assert kwargs == {"creationflags": _EXPECTED_WINDOWS_FLAG}
|
||||
|
||||
|
||||
def test_helper_empty_on_posix(monkeypatch) -> None:
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
assert _subprocess_no_window_kwargs() == {}
|
||||
|
||||
|
||||
def test_agent_tmux_is_running_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
broker.is_running("dev", "sessions-agent-abc-claude")
|
||||
assert len(captured) == 1
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_agent_tmux_list_sessions_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
broker.list_sessions("dev")
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_agent_tmux_kill_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
broker.kill("dev", "sessions-agent-abc-claude")
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_agent_tmux_spawn_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
plan = broker.plan("dev", "cache-xyz", "claude", ("claude",))
|
||||
# Patch is_running to False so attach_or_spawn takes the spawn branch.
|
||||
monkeypatch.setattr(broker, "is_running", lambda *_a, **_kw: False)
|
||||
broker.attach_or_spawn(plan)
|
||||
# Two captured calls: is_running is patched out, so just the spawn.
|
||||
assert any(c.get("creationflags") == _EXPECTED_WINDOWS_FLAG for c in captured)
|
||||
|
||||
|
||||
def test_jupyter_default_run_merges_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def fake_subprocess_run(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_subprocess_run)
|
||||
jupyter_hosting._default_run(["echo", "hi"], check=False)
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
assert captured[0].get("check") is False # caller kwargs preserved
|
||||
|
||||
|
||||
def test_jupyter_default_popen_merges_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
class _DummyProc:
|
||||
pid = 12345
|
||||
|
||||
def fake_subprocess_popen(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return _DummyProc()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_subprocess_popen)
|
||||
jupyter_hosting._default_popen(["echo", "hi"])
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_terminus_tmux_probe_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="/usr/bin/tmux\n", stderr="")
|
||||
|
||||
terminal_tmux_session.probe_tmux_available(
|
||||
"dev",
|
||||
ssh_command_builder=lambda alias: ["ssh", alias],
|
||||
run=recorder,
|
||||
)
|
||||
assert len(captured) == 1
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_posix_branch_does_not_inject_creationflags(monkeypatch) -> None:
|
||||
# The complementary case: confirm the helper is a no-op on Linux so
|
||||
# injected subprocess.run callables don't see a surprise kwarg.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
broker.is_running("dev", "sessions-agent-abc-claude")
|
||||
assert "creationflags" not in captured[0]
|
||||
|
||||
|
||||
# Keep the unused import below — it's one of the real-subprocess marker
|
||||
# strings the classifier greps for. Removing it drops this file out of
|
||||
# the "real-subprocess" bucket even though the test body references
|
||||
# subprocess.Popen / subprocess.run constantly.
|
||||
_ = patch # noqa: F841 — classifier marker anchor
|
||||
Reference in New Issue
Block a user