Compare commits
202 Commits
v0.4.18
...
2f237ac265
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f237ac265 | |||
| 3a8e86ca6b | |||
| 8b08e5778a | |||
| 291bfc70e4 | |||
| 8db28d609c | |||
| b2f933490a | |||
| 63ef3a8313 | |||
| 05c08e3223 | |||
| 20227dde4d | |||
| b5d5404f73 | |||
| 1fbfa8010b | |||
| 927b685059 | |||
| 6730c9ddfd | |||
| 10868231ae | |||
| 32c3e6241a | |||
| 9691726d99 | |||
| b7189f9550 | |||
| 951307dd50 | |||
| 4c8dcde161 | |||
| b570710bff | |||
| 0832a0cef0 | |||
| a1d70c7f8d | |||
| fd1e5ad719 | |||
| cf74d89b9a | |||
| 7329454b90 | |||
| e6ab866da8 | |||
| ae11415967 | |||
| 156c9de347 | |||
| a480990c33 | |||
| 24ff54a0e1 | |||
| ab1d57b8d9 | |||
| 268477e8a3 | |||
| 06a31b968d | |||
| 9d6feea697 | |||
| 74b9fef98e | |||
| e25b866ea7 | |||
| ed9db42d07 | |||
| 8ac7225bd2 | |||
| 1b70a56037 | |||
| 0d370dee0b | |||
| 1035a75d5b | |||
| 7114fe844d | |||
| 92dd66a510 | |||
| 51dc5c557b | |||
| 859c413872 | |||
| b47f7eba3b | |||
| c19aaaef1a | |||
| 890bf69de1 | |||
| 32fc8efb84 | |||
| c29e3f5995 | |||
| 2238b55aee | |||
| 322fa26ac8 | |||
| b11802ad2e | |||
| 86d444885a | |||
| f70999a9d7 | |||
| 7b43de90ad | |||
| e61e56c21d | |||
| 60a8ad1f0b | |||
| 0e2fdd959e | |||
| 3eaa697419 | |||
| 007e53628d | |||
| 0b4fdb0abd | |||
| 5194d34180 | |||
| e52239629e | |||
| e75e028a63 | |||
| 7131397c50 | |||
| 6e8288205a | |||
| 1d31817d27 | |||
| 28d4611350 | |||
| 6880b2daec | |||
| 3f6d0c0c1e | |||
| 22dd0d8260 | |||
| a469e8b886 | |||
| 23c34fa7d6 | |||
| 8accab2cad | |||
| 45bb611b5b | |||
| c677c21b1d | |||
| 8b85f367bc | |||
| 0bbf1e2ec8 | |||
| 2fe70b0059 | |||
| 55169003af | |||
| 3c09ece770 | |||
| 9fd73a38d8 | |||
| b989cd8f6e | |||
| 9fcceab7c6 | |||
| 39cc679736 | |||
| 7f9f534b88 | |||
| a0a76c7e43 | |||
| 9666a0d992 | |||
| 44bde8c138 | |||
| 3748a6980c | |||
| 372d4882cc | |||
| 681dbb1553 | |||
| 1c7d7eccb8 | |||
| ef5a599563 | |||
| 933be5cf9b | |||
| 95c3b1fa79 | |||
| 88a9aca72d | |||
| 4f0b0ba24c | |||
| e767baf052 | |||
| 046ddde83e | |||
| 36e6814d87 | |||
| b9271a8308 | |||
| 2b015841a9 | |||
| 383d8c1e0d | |||
| e7e3332073 | |||
| b26b32fcf1 | |||
| 9d364e7f01 | |||
| 0ed9371288 | |||
| affd921265 | |||
| d21600f0c1 | |||
| 7daddf82ae | |||
| 4e8180489a | |||
| 147f4bd091 | |||
| a441ff23c1 | |||
| 76bdf5b773 | |||
| 3590822201 | |||
| e4b5d51e2f | |||
| c36a3dc24a | |||
| ade2e91256 | |||
| 628bc48baf | |||
| c906f1021c | |||
| 7ca1dbcb7c | |||
| c066cb9962 | |||
| d29a101b44 | |||
| 0660d24071 | |||
| bf8b386be2 | |||
| fd623034c0 | |||
| 7e97306288 | |||
| 6260ed024e | |||
| c3848e2392 | |||
| 242bec3063 | |||
| c928d1f6b3 | |||
| 3e6ddb4cee | |||
| 237726a7a6 | |||
| 8979d6a366 | |||
| 6197227643 | |||
| f7b5b3befd | |||
| 89db1ce8e3 | |||
| 0b527d7cbf | |||
| db24a9b711 | |||
| 964ee5cf64 | |||
| 1117c36639 | |||
| 60bf1e56ba | |||
| 68d2ffc939 | |||
| 2aeedd4cc4 | |||
| e666e914f9 | |||
| 4bcf1636ea | |||
| 358d674f3d | |||
| 57523033a0 | |||
| 5483e35b7b | |||
| 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 |
67
.gitea/workflows/boundary-lint.yml
Normal file
67
.gitea/workflows/boundary-lint.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
name: boundary-lint
|
||||
|
||||
# Wave 1.5 거버넌스 가드 — PR/push에서 boundary lint + duplication-deadline 검사.
|
||||
#
|
||||
# 두 검사 모두 PR diff 기반(추가된 라인만)이므로 main의 기존 코드는 grandfather.
|
||||
# 자세한 룰: scripts/lint_python_thinning.py docstring 참조.
|
||||
# 거버넌스 normative: planning/PYTHON_RUST_BOUNDARY.md (Wave 1.5 amend).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ban-list:
|
||||
name: ban-list lint (Lint #1/#2/#2.5/#3/#4)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # diff base 계산 위해 full history 필요
|
||||
|
||||
- name: setup python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: run boundary lint
|
||||
env:
|
||||
CI: "true"
|
||||
run: python3 scripts/lint_python_thinning.py --lint 1 --lint 2 --lint 2.5 --lint 3 --lint 4
|
||||
|
||||
duplication-deadline:
|
||||
name: duplication-deadline (Layer 1/2)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: setup python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: check expired TEMP_DUPLICATION_UNTIL markers
|
||||
run: python3 scripts/duplication_deadline.py
|
||||
|
||||
pr-boundary-claim:
|
||||
name: PR boundary-claim (Lint #6)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: setup python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: write PR body to temp file
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: printf '%s\n' "$PR_BODY" > /tmp/pr_body.md
|
||||
|
||||
- name: validate boundary-claim header
|
||||
run: python3 scripts/lint_python_thinning.py --lint 6 --pr-body /tmp/pr_body.md
|
||||
@@ -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 }}"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,3 +13,5 @@ target/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
.claude/
|
||||
|
||||
@@ -52,7 +52,7 @@ repos:
|
||||
|
||||
- id: rust-test
|
||||
name: rust test
|
||||
entry: sh -c 'if command -v cargo-llvm-cov >/dev/null 2>&1 && rustup component list --installed 2>/dev/null | grep -q llvm-tools; then cargo llvm-cov --manifest-path rust/Cargo.toml --ignore-filename-regex "main\.rs|sessions_native" --fail-under-lines 80; else cargo test --manifest-path rust/Cargo.toml; fi'
|
||||
entry: sh -c 'if command -v cargo-llvm-cov >/dev/null 2>&1 && rustup component list --installed 2>/dev/null | grep -q llvm-tools; then cargo llvm-cov --manifest-path rust/Cargo.toml --ignore-filename-regex "main\.rs|sessions_native|local_bridge/src/(cli|persistent|lsp_stdio|mirror)\.rs" --fail-under-lines 80; else cargo test --manifest-path rust/Cargo.toml; fi'
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
|
||||
36
README.md
36
README.md
@@ -4,18 +4,15 @@
|
||||
|
||||
Current focus:
|
||||
|
||||
- **Completed milestones:** Phase 0–6.2 (all closed), Phase 7 - Stability Hardening (closed), Phase 8 - Rust Transport Expansion (closed). See [`planning/GITEA_ISSUES.md`](planning/GITEA_ISSUES.md).
|
||||
- **Open milestones:** Phase 9 - Quality Gates & Scale ([#10](https://git.teahaven.kr/sublime-rs/sessions/issues/10), [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29)), Remote LSP integration track ([#34](https://git.teahaven.kr/sublime-rs/sessions/issues/34)).
|
||||
- **Execution order (2026-04, Rust-first):** P0.5 stabilization → crate consolidation → artifact publish + manifest/checksum → **[#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24)** Rust runtime ownership → **[#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32)** large-file → **[#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29)** diff-centric product. Normative detail: [`planning/GITEA_ISSUES.md`](planning/GITEA_ISSUES.md) (execution priority and schedule), migration waves: [`planning/PYTHON_RUST_BOUNDARY.md`](planning/PYTHON_RUST_BOUNDARY.md).
|
||||
- **P0.5 stabilization (2026-04):**
|
||||
- Done: persistent bridge, download-only helper, reconnect, mirror ignore patterns, save conflict UI, wire contract test coverage (bridge stdout fixtures, binary smoke test, ABI smoke test), stability hardening (prune symlink/permission edges, multi-window dedup, refresh race prevention)
|
||||
- In progress: remote file auto-reload (periodic stat → revert), LSP-ready on-demand fetch (external path mapper + `on_window_command` interceptor)
|
||||
- **Remote LSP implementation (next):** `local_bridge lsp-stdio` endpoint + persistent broker attach IPC, `session_helper lsp_stdio` child supervision, URI rewrite/save barrier/materialization in `local_bridge`, and host-scoped install with workspace-scoped env/config ([#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)).
|
||||
- **Completed milestones:** Phase 0–6.2 (all closed), Phase 7 - Stability Hardening (closed), Phase 8 - Rust Transport Expansion (closed), Remote LSP integration track ([#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) — all closed; `local_bridge lsp-stdio`, persistent broker attach IPC, `session_helper lsp_stdio` supervision, URI rewrite + save barrier, host-scoped install with workspace-scoped env/config). See [`planning/GITEA_ISSUES.md`](planning/GITEA_ISSUES.md).
|
||||
- **Open milestones:** Phase 9 - Quality Gates & Scale ([#10](https://git.teahaven.kr/sublime-rs/sessions/issues/10), [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32) large-file streaming). [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) (diff-centric review) was reframed in the 2026-04-25 distribution review and is **no longer the next feature** — see [`planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`](planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md) and [`planning/SHIPPED.md`](planning/SHIPPED.md). Track D (in-Sublime agent integration) was dropped 2026-04-27 and the residual `tmux`/`claude-code`/`codex-cli`/`jupyterlab` catalog entries were excised on 2026-04-30 — see [`planning/BACKLOG.md`](planning/BACKLOG.md) and [`planning/SHIPPED.md`](planning/SHIPPED.md).
|
||||
- **Execution order (2026-04, Rust-first):** P0.5 stabilization → crate consolidation → artifact publish + manifest/checksum → **[#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24)** Rust runtime ownership → **[#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32)** large-file → Track G v1 (multi-repo, refs/ fast-path, line-staging polish). #29 diff-centric review/apply is **deprioritized**, not on this order. Normative detail: [`planning/GITEA_ISSUES.md`](planning/GITEA_ISSUES.md) (execution priority and schedule), migration waves: [`planning/PYTHON_RUST_BOUNDARY.md`](planning/PYTHON_RUST_BOUNDARY.md). Distribution-readiness + ownership-migration plan: [`planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`](planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md).
|
||||
- **P0.5 stabilization (2026-04, closed):** persistent bridge, download-only helper, reconnect, mirror ignore patterns, save conflict UI, wire contract test coverage (bridge stdout fixtures, binary smoke test, ABI smoke test), stability hardening (prune symlink/permission edges, multi-window dedup, refresh race prevention), remote file auto-reload via periodic stat → revert, LSP-ready on-demand fetch via external path mapper + `on_window_command` interceptor.
|
||||
- SSH config driven workspace selection
|
||||
- session-bound helper over SSH stdio
|
||||
- local cache with local-host-independent workspace identity
|
||||
- formatter and linter execution in the remote environment (baseline + #30 pipeline on save)
|
||||
- long-term evolution toward a multi-session agent window (after the MVP above)
|
||||
- ~~long-term evolution toward a multi-session agent window~~ — **dropped 2026-04-27, residue removed 2026-04-30**: the v0.6.0–v0.6.7 in-Sublime agent code (`agent_tmux`, `agent_window_layout`, `agent_switcher_view`, agent palette commands) was deleted in v0.6.7; the `tmux`/`claude-code`/`codex-cli` catalog entries and the parallel `jupyterlab` (`kind="jupyter"`) entry were excised on 2026-04-30. Agents now run in an external terminal that the user manages outside Sublime; `marimo` replaces in-tree Jupyter hosting. See [`planning/BACKLOG.md`](planning/BACKLOG.md) Track D and [`planning/SHIPPED.md`](planning/SHIPPED.md).
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -33,6 +30,7 @@ Current focus:
|
||||
| [`planning/VSCODE_REMOTE_TRANSPORT_MODEL.md`](planning/VSCODE_REMOTE_TRANSPORT_MODEL.md) | Envelope + logical channels (VS Code–aligned) |
|
||||
| [`planning/REMOTE_DEV_MVP_LSP.md`](planning/REMOTE_DEV_MVP_LSP.md) | Phase 6.2 LSP / tool transport choices |
|
||||
| [`planning/DEEP-RESEARCH-REPORT.md`](planning/DEEP-RESEARCH-REPORT.md) | External audit + **priority reconciliation** (end) |
|
||||
| [`planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md`](planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md) | Track G v1 plan: bidirectional `.git` sync redesign (op-log + ref snapshot + `git bundle`, replaces tar-wipe) |
|
||||
|
||||
## Installing In Sublime Text
|
||||
|
||||
@@ -93,10 +91,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 +114,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.
|
||||
|
||||
241
SECURITY.md
Normal file
241
SECURITY.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# 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.
|
||||
|
||||
## Sync mode (`sessions_sync_mode`)
|
||||
|
||||
The plugin exposes a single product-level knob, `sessions_sync_mode`, that
|
||||
collapses the "first-connect noise" knobs an EDR administrator most often wants
|
||||
to clamp into one named tier:
|
||||
|
||||
- `safe` — quiet first connect for EDR-managed or shared machines. Forces
|
||||
`sessions_mirror_auto_refresh`, `sessions_mirror_include_files`, and
|
||||
`sessions_connect_auto_open_remote_folder` to `false` regardless of their
|
||||
per-key value. The plugin still works, but no periodic background refresh
|
||||
runs, the cache contains directory placeholders only (files materialise on
|
||||
open), and connect does not auto-open the remote folder picker.
|
||||
- `balanced` — historical default. Per-key settings (auto-refresh interval,
|
||||
EDR caps, etc.) take effect unchanged. Recommended for most desktop use.
|
||||
- `full` — same as `balanced` today; reserved for future opt-in "more
|
||||
aggressive" defaults.
|
||||
|
||||
The bandwidth caps that exist independently of the sync mode
|
||||
(`sessions_mirror_max_entries`, `sessions_mirror_max_dir_fanout`,
|
||||
`sessions_mirror_writes_per_second_cap`,
|
||||
`sessions_mirror_auto_prune_stale_cache: false`) still apply in every mode.
|
||||
Picking `safe` is a strict superset of those caps for the periodic and
|
||||
auto-open paths.
|
||||
|
||||
For policy distribution: shipping `Packages/User/Sessions.sublime-settings`
|
||||
with `"sessions_sync_mode": "safe"` is enough to neutralise the three
|
||||
auto-on behaviours without touching individual per-key settings.
|
||||
|
||||
## 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 | 멀티 윈도우 | (해당되면) 창 두 개에서 서로 다른 호스트/같은 호스트 | 세션 혼선·캐시 키 충돌 없음 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
552
planning/BACKLOG.md
Normal file
552
planning/BACKLOG.md
Normal file
@@ -0,0 +1,552 @@
|
||||
# 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.
|
||||
|
||||
Active tracks (2026-04-27 onward):
|
||||
|
||||
- **G** — Sublime Merge–compatible git/SCM integration (the next big
|
||||
feature; v0 = single-repo MVP).
|
||||
- **M** — M3 (remote extension install/probe latency + auto-format race) is
|
||||
the only macOS follow-up still open; M1/M2/M4/M5 shipped or retired.
|
||||
- **W** — Windows parity: W1 (PersistentBroker for LSP stdio multiplex)
|
||||
and W4 (folder browser auto-descend on `/`).
|
||||
- **E** — security/ops, slower cadence; not on the active queue but
|
||||
retained for visibility.
|
||||
|
||||
Dropped / closed (2026-04-27): Track A closed (A1 shipped v0.5.7, A2
|
||||
shipped v0.6.2 — entries were stale). Track B dropped (B1 absorbed
|
||||
into M3, B2 deferred). Track C dropped (Terminus session persist +
|
||||
hover — terminal's role narrowed to "lightweight execution"; mirror
|
||||
items W2/W3 dropped for the same reason). Track D dropped (agent
|
||||
runs in an external terminal now, no in-Sublime wiring).
|
||||
|
||||
Legend:
|
||||
|
||||
- **[file]** — primary file(s) the task touches.
|
||||
- **[conflict with]** — tracks that would conflict if parallelised.
|
||||
- **[done-when]** — acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## ~~Track A — Python interpreter UX polish~~ — **[shipped, closed 2026-04-27]**
|
||||
|
||||
Both items already landed in earlier releases; the track was kept
|
||||
open in BACKLOG by mistake.
|
||||
|
||||
### A1. Remote folder browser for the interpreter picker — **[shipped v0.5.7]**
|
||||
|
||||
`python_interpreter_browser.py` + the `Browse remote filesystem...`
|
||||
quick-panel row reach the documented done-when (navigate from
|
||||
`$HOME`, descend / ascend / select Python binary; selection writes
|
||||
via `write_active_interpreter`). The "type as you go" autocompletion
|
||||
piece overlaps with W4 (folder browser auto-descend on `/`); tracked
|
||||
there.
|
||||
|
||||
### A2. Status-bar indicator styling — **[shipped v0.6.2]**
|
||||
|
||||
`Python: <venv> (<X.Y.Z>)` with version probe + cache, syntax-gated
|
||||
so non-Python views drop the slot. Same surface M2 was tracking —
|
||||
folded together at BACKLOG cleanup.
|
||||
|
||||
---
|
||||
|
||||
## ~~Track B — Caching & remote-probe efficiency~~ — **[dropped 2026-04-27]**
|
||||
|
||||
B1 (extension probe caching) merged into M3 — same surface, same
|
||||
done-when, no need for a second track. B2 (Cargo.toml hydrate-on-demand)
|
||||
deferred; rust-analyzer noise on placeholder manifests is real but
|
||||
not blocking, and the broader on-demand hydrate path will likely be
|
||||
revisited when Track G's materialisation controller lands (similar
|
||||
"trigger-fetch-on-access" plumbing).
|
||||
|
||||
---
|
||||
|
||||
## ~~Track C — macOS Terminus integration~~ — **[dropped 2026-04-27]**
|
||||
|
||||
C1 (hover-activated link UX) and C2 (persistent terminal session) both
|
||||
dropped. Terminus's role narrowed to "lightweight execution" — no
|
||||
in-editor click/hover wiring, no session persistence. The shipped
|
||||
v0.6.10 hover path (M1) covers the basic clickable-paths case; further
|
||||
investment in Terminus-side polish is out of scope.
|
||||
|
||||
---
|
||||
|
||||
## ~~Track D — Agent integration via tmux~~ — **[dropped 2026-04-27, residue removed 2026-04-30]**
|
||||
|
||||
Whole-track drop. The new direction: agents (codex / claude / etc.)
|
||||
run in an external terminal that the user manages outside Sublime —
|
||||
no in-Sublime layout / switcher / proposal-surfacing work. The
|
||||
v0.6.0–v0.6.7 in-tree code (`agent_tmux`, `agent_window_layout`,
|
||||
`agent_switcher_view`, workspace/agent pair registry, three palette
|
||||
commands) was deleted in v0.6.7. The residual catalog entries
|
||||
(`tmux` / `claude-code` / `codex-cli` `kind="agent"` rows plus their
|
||||
install/remove/probe bash blocks) and the parallel `jupyterlab`
|
||||
`kind="jupyter"` row were excised on 2026-04-30 along with the
|
||||
matching tests and the `planning/AGENT_TMUX_LAYOUT.md` design
|
||||
document; D1–D7 sub-tracks have no follow-up work. Anything still
|
||||
needed about the historical layout lives in git history at
|
||||
`v0.6.6..v0.6.7`.
|
||||
|
||||
---
|
||||
|
||||
## Track G — Sublime Merge–compatible git/SCM integration — **[v0 shipped 2026-04-28]**
|
||||
|
||||
v0 milestone (single repo, manual refresh) is feature-complete in
|
||||
v0.7.9 (G1+G2), v0.7.11 (G3), and v0.7.12 (G4+G6). Sublime Merge
|
||||
opens the cache root and sees real history / refs / blame / staging
|
||||
/ commit / branch switching against repos that physically live on
|
||||
the remote. Sub-tracks below kept for traceability; v1 work
|
||||
(automatic reconcile, refs/ diff fast-path, multi-repo, submodules,
|
||||
LFS, untracked-not-ignored lazy fetch) lives at the bottom of the
|
||||
section under the "v1 scope" heading.
|
||||
|
||||
*Second major feature track (peer of Track D). Goal: let local Sublime
|
||||
Merge open repos that physically live on the remote host, with correct
|
||||
status / diff / log / branch-switch / line-staging, without rsync'ing
|
||||
the full working tree. Builds on the existing mirror +
|
||||
`execute_remote_exec_once` primitive — no new bridge protocol needed
|
||||
for v0.*
|
||||
|
||||
Design converged 2026-04-27. See conversation history for the full
|
||||
trade-off discussion that led to the policy below.
|
||||
|
||||
### Architecture (decided)
|
||||
|
||||
- **`.git` is real** (full bidirectional sync). Remote-side commits or
|
||||
branch ops reconcile in via cheap `refs/` + `HEAD` + `packed-refs`
|
||||
diff.
|
||||
- **Working tree materialisation policy**:
|
||||
- clean tracked file → stub + `git update-index --skip-worktree`
|
||||
(git treats it as if it matches index → no false diff, no
|
||||
spurious "modified" entries).
|
||||
- dirty (unstaged) tracked file → real content. Push-driven from
|
||||
the remote save event the mirror already watches; pull-on-demand
|
||||
fallback when modified outside the editor (e.g. remote shell ran
|
||||
`cargo fmt`). Invariant: **local materialised file == remote
|
||||
last-saved content**.
|
||||
- untracked **+ gitignored** → ignored, stub stays (git already
|
||||
excludes from status).
|
||||
- untracked **+ NOT gitignored** → stub-first, lazy materialise
|
||||
only when Sublime Merge actually reads the file. Avoids pulling
|
||||
byproduct files (build artefacts, scratch notes) the user never
|
||||
intends to commit.
|
||||
- **Branch switch from Sublime Merge**: works locally because
|
||||
skip-worktree files don't get touched on `git checkout`. Post-checkout
|
||||
hook in local `.git` calls bridge → remote `git checkout <X>` →
|
||||
mirror refresh. **Refuse** the switch with the stock git
|
||||
"would overwrite local changes" error when dirty files exist — no
|
||||
auto-stash.
|
||||
|
||||
### Sub-tracks
|
||||
|
||||
- **G1. Repo discovery.** Scan workspace mount for `.git` directories
|
||||
(and `.git`-as-file for worktrees). Expose each as a Sublime Merge
|
||||
candidate. Cheap one-shot at workspace open + on-demand on directory
|
||||
expansion.
|
||||
- **[file]** `sublime/sessions/ssh_file_transport.py`,
|
||||
new `sublime/sessions/git_repo_discovery.py`.
|
||||
- **G2. `.git` initial pull + reconcile loop.** First open: bridge-fetch
|
||||
the entire `.git` for each discovered repo. After: cheap reconcile
|
||||
diffing `refs/` + `HEAD` + `packed-refs` and pulling deltas only.
|
||||
- **[file]** new `sublime/sessions/git_dot_git_sync.py`. May add a
|
||||
`git/refs-snapshot` fast-path in `local_bridge` if the naive walk
|
||||
is too slow on big repos.
|
||||
- **[note]** budget: roughly "git clone" cost on first open;
|
||||
incremental thereafter.
|
||||
- **G3. Materialisation controller.** On workspace open + on remote
|
||||
save events: compute the dirty / untracked-not-ignored set via
|
||||
remote `git status --porcelain=v2 -z`, materialise dirty files,
|
||||
apply `--skip-worktree` to clean tracked, lazy-pull
|
||||
untracked-not-ignored on access.
|
||||
- **[file]** new `sublime/sessions/git_materialise.py`. Plugs into
|
||||
the existing mirror's file-watch event. Uses
|
||||
`execute_remote_exec_once` for the `git status` call.
|
||||
- **G4. Post-checkout proxy.** Install a `.git/hooks/post-checkout`
|
||||
on local checkout that fires a bridge command to do `git checkout
|
||||
<ref>` on the remote, then re-runs G3.
|
||||
- **[file]** new `sublime/sessions/git_branch_proxy.py`,
|
||||
`sublime/sessions/git_materialise.py`.
|
||||
- **G5. Dirty-set freshness.** Push-driven update on remote save
|
||||
(piggyback on existing mirror watch); pull-on-demand on Sublime
|
||||
Merge view focus / file select for files modified outside the
|
||||
editor.
|
||||
- **[file]** `sublime/sessions/git_materialise.py`,
|
||||
`sublime/sessions/ssh_file_transport.py` (mirror watch tap).
|
||||
- **G6. Branch-switch-with-dirty refusal UX.** When a checkout would
|
||||
overwrite dirty remote-side changes, surface git's stock error
|
||||
cleanly through Sublime Merge. No auto-stash.
|
||||
- **[file]** `sublime/sessions/git_branch_proxy.py`. Test: with a
|
||||
dirty remote file in the materialised set, attempt a branch
|
||||
switch → fails with the standard git error, no state corruption.
|
||||
|
||||
### Dependency graph
|
||||
|
||||
- G1 is the root.
|
||||
- G2 depends on G1.
|
||||
- G3 depends on G1 + G2 (uses `.git` to know HEAD).
|
||||
- G4 depends on G2 + G3.
|
||||
- G5 depends on G3 (extends materialisation policy).
|
||||
- G6 depends on G3 + G4.
|
||||
|
||||
### v0 scope (single-repo MVP)
|
||||
|
||||
- G1 single-repo discovery (workspace root only; no nested-repo
|
||||
handling, no submodules, no LFS).
|
||||
- G2 initial pull only; reconcile is a manual `Sessions: Refresh Git
|
||||
State` command for v0.
|
||||
- G3 file-level only. Sublime Merge already does hunk staging
|
||||
client-side once the working file is real, so line-level staging
|
||||
comes "for free" for files in the materialised set.
|
||||
- G4 + G6 happy path.
|
||||
- G5 push-driven only; pull-on-demand deferred to v1.
|
||||
|
||||
v1 scope: nested repos, submodules, multi-repo workspaces, LFS,
|
||||
packed-`.git` reconcile fast-path, untracked-not-ignored lazy fetch
|
||||
polishing, automatic reconcile loop replacing the manual command.
|
||||
|
||||
**v1 architecture plan**: see
|
||||
[`TRACK_G_V1_BIDIRECTIONAL_SYNC.md`](TRACK_G_V1_BIDIRECTIONAL_SYNC.md) for
|
||||
the full audit + redesign — op log + ref snapshot at every refresh,
|
||||
`git bundle` over the existing bridge replacing the tar-wipe, and
|
||||
conflict-copy semantics for diverged refs/files. Replaces the
|
||||
wipe-and-replace `.git` sync with a CAS-guarded refspec model so
|
||||
local-only branches and unpushed commits survive every refresh.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- GitLens-style inline blame / hover annotations. Sublime Text UI
|
||||
primitives (no webview, no native tree widget, no CodeLens) don't
|
||||
reach VSCode SCM territory; skip the visual-decoration features
|
||||
entirely.
|
||||
- TUI git client integration (lazygit / tig over the bridge — covered
|
||||
by users choosing the terminal route instead of this track).
|
||||
|
||||
### Risk register
|
||||
|
||||
- **R1. `.git` reconcile correctness.** Two-way sync is the
|
||||
load-bearing wall; if local and remote `.git` ever desync silently,
|
||||
user commits land on the wrong tip. Mitigation: writes go local
|
||||
first then propagate to remote; reads observe latest local.
|
||||
- **R2. skip-worktree edge cases on `git reset --hard` / merge /
|
||||
rebase.** Some plumbing commands clear or touch skip-worktree bits
|
||||
unexpectedly. Need a regression test exercising checkout / reset /
|
||||
merge across the materialised-vs-stubbed split.
|
||||
- **R3. Big `.git` initial pull cost.** Acceptable but UI must show
|
||||
progress for repos > 100 MB pack size; otherwise looks like a hang.
|
||||
|
||||
### Parallel plan
|
||||
|
||||
3-agent fan-out is feasible once G1 + G2 land:
|
||||
|
||||
- Agent α: G1 + G2 (discovery + `.git` sync) — pure data layer, no
|
||||
Sublime UI dep, fully unit-testable with stubbed bridge calls.
|
||||
- Agent β: G3 + G5 (materialisation controller + freshness hooks).
|
||||
Depends on α's discovery output shape only.
|
||||
- Agent γ: G4 + G6 (branch proxy + refusal UX). Depends on G3.
|
||||
|
||||
Final integration agent wires Sublime Merge launch + the manual
|
||||
`Sessions: Refresh Git State` palette command.
|
||||
|
||||
---
|
||||
|
||||
## Track H — Rust ownership migration (Python monolith reduction) — **[opened 2026-04-29]**
|
||||
|
||||
The 2026-04 distribution review (external) flagged that the current
|
||||
shape is closer to "Python calls Rust a lot" than "Rust owns the hot
|
||||
paths" — `commands.py` (7379 LOC), `ssh_file_transport.py` (2240),
|
||||
`_rust_ffi.py` (1337) still carry runtime ownership Python should not.
|
||||
Track H stops the helper-migration cadence and shifts to *ownership*
|
||||
migration. It implements the concrete sub-tracks behind
|
||||
[`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md) Wave 2–3 and the
|
||||
[REVIEW_v0_6_4_DISTRIBUTION_PLAN](REVIEW_v0_6_4_DISTRIBUTION_PLAN.md)
|
||||
"Stage 4 ownership" line.
|
||||
|
||||
User-visible behaviour does not change inside Track H. Anything that
|
||||
adds new wire format or new commands belongs to a different track.
|
||||
|
||||
### H1. `open_remote_file_into_local_cache()` → Rust runtime API
|
||||
|
||||
**[file]** `sublime/sessions/ssh_file_transport.py`,
|
||||
`sublime/sessions/_rust_ffi.py`, `rust/crates/local_bridge/src/`,
|
||||
`rust/crates/sessions_native/src/`
|
||||
|
||||
**[conflict with]** Track G v1 (working-tree materialiser shares the
|
||||
read path), M3 (auto-format race lives in the same save flow).
|
||||
|
||||
**[done-when]** Python `open_remote_file_into_local_cache()` shrinks
|
||||
to a thin wrapper around one Rust call; remote read → open guardrail
|
||||
→ local cache write happens inside one Rust transaction. Target:
|
||||
`ssh_file_transport.py` < 1500 LOC. Pairs with Gitea #24 / #27.
|
||||
|
||||
First-PR scope:
|
||||
1. New Rust module (`local_bridge::file_open` or
|
||||
`sessions_native::runtime::file_open`) that bundles the existing
|
||||
`sessions_file_open_guard_reason`, the bridge `file/read`, and the
|
||||
cache write into a single function returning a structured outcome.
|
||||
2. Python wrapper in `_rust_ffi.py` that calls the new ABI; the
|
||||
pre-existing Python implementation is **deleted in the same PR**
|
||||
(single-source-of-truth rule from `PYTHON_RUST_BOUNDARY.md`).
|
||||
3. Save / reload / hydrate / stale-refresh call sites become thin
|
||||
wrappers — the transaction is owned by Rust.
|
||||
4. Regression coverage: `test_remote_file_metadata`,
|
||||
`test_eager_hydrate`, `test_cmd_save`, `test_file_pipeline` pass
|
||||
against the new path.
|
||||
|
||||
Risk: save-conflict UI and the save barrier currently live in Python
|
||||
(Sublime UI thread). Pulling the *decision* into Rust would force a
|
||||
new sync surface; the first PR keeps the decision (warning popup) in
|
||||
Python and only moves guardrail + read + write.
|
||||
|
||||
### H2. `commands.py` service split + module-global state reduction
|
||||
|
||||
**[file]** `sublime/sessions/commands.py` (7379 LOC today), new
|
||||
`sublime/sessions/commands_*.py` modules (the
|
||||
`commands_file_actions.py` / `commands_python_pipeline.py` pattern is
|
||||
already established).
|
||||
|
||||
**[conflict with]** H1 (the save / reload / hydrate sites are touched
|
||||
by H1 too — bundle them in the same PR or H1 will land first), Track
|
||||
G (commands.py hosts much of the git track wiring).
|
||||
|
||||
**[done-when]** `commands.py` < 4000 LOC; six service modules
|
||||
(connect / sync / git / lsp / save / terminal) each own their state;
|
||||
at least half of the module-globals (`_BACKGROUND_PENDING_KEYS`,
|
||||
`_HYDRATE_IN_FLIGHT`, `_MIRROR_AUTO_REFRESH_*`,
|
||||
`_OPEN_FILE_WATCH_WINDOWS`, …) become service-local.
|
||||
|
||||
First-PR scope: extract the **save** service into
|
||||
`commands_save.py` (save / barrier / conflict UI + the related state
|
||||
keys: `_OPEN_REQUEST_SERIAL_BY_WORKSPACE`, `_HYDRATE_REVERT_COOLDOWN`,
|
||||
…). Regression coverage from `test_cmd_save`, `test_cmd_auto_reload`,
|
||||
`test_save_*`. Connect/mirror/git/lsp services follow in their own
|
||||
PRs.
|
||||
|
||||
Risk: naive file split easily creates import cycles. Mitigation:
|
||||
move state and helpers **into the service module** rather than
|
||||
re-export from `commands.py`; allow only `service module → commands`
|
||||
direction in imports, never the reverse.
|
||||
|
||||
### H3. Background queue / mirror queue / open-file watch / auto-reconnect → Rust broker
|
||||
|
||||
**[file]** `sublime/sessions/commands.py` (queue/worker/watch
|
||||
functions), `sublime/sessions/_rust_ffi.py` (broker FFI),
|
||||
`rust/crates/sessions_native/src/broker*.rs`,
|
||||
`rust/crates/local_bridge/`.
|
||||
|
||||
**[conflict with]** H1 (open-file watch shares the read path), H2
|
||||
(landing the commands split first makes this PR much smaller).
|
||||
|
||||
**[done-when]** `_BACKGROUND_TASK_QUEUE`, `_MIRROR_TASK_QUEUE`,
|
||||
`_OPEN_FILE_WATCH_*`, and the auto-reconnect thread no longer exist
|
||||
in Python or are reduced to a status-callback hook on Rust broker
|
||||
events. The boundary doc's "multiplexed stdio / channel supervisor"
|
||||
responsibility is owned by Rust.
|
||||
|
||||
First-PR scope: auto-reconnect thread → Rust broker. The
|
||||
`sessions_broker_*` FFI (open_session, reset, handshake, is_active)
|
||||
already exists; broker drives health probing, Python only receives
|
||||
the status callback. Regression coverage:
|
||||
`test_bridge_lifecycle`, `test_connect_workflow`, the
|
||||
reconnect-specific test cases.
|
||||
|
||||
Risk: moving the queue (later PRs) changes the meaning of the
|
||||
generation token / connect-preempt rule (the `disciscard` typo from
|
||||
2026-04-29 lived in this exact area). Mitigation: the first PR moves
|
||||
*the thread*, not the queue. Queue semantics stay identical until a
|
||||
follow-up PR explicitly re-derives them on the Rust side.
|
||||
|
||||
### Dependency graph (Track H)
|
||||
|
||||
```
|
||||
H1 ──▶ H2-save (save service is a thin wrapper after H1)
|
||||
H1 ──▶ H3 (open-file watch sits on H1's ownership boundary)
|
||||
H2-save ──▶ H3-reconnect (status callbacks land cleanly into a service)
|
||||
```
|
||||
|
||||
Recommended PR order: H1 → H2-save → H3-reconnect → H2-connect →
|
||||
H3-queue → H2-mirror → H3-mirror-queue.
|
||||
|
||||
### Out of scope (Track H)
|
||||
|
||||
- New features, new ABI / protocol / wire format. Track H is
|
||||
**ownership only**; user-visible behaviour must not change inside
|
||||
the track.
|
||||
- Cosmetic clean-up of Python wrappers. That belongs to a separate
|
||||
PR after Track H lands.
|
||||
|
||||
---
|
||||
|
||||
## 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) — **[shipped v0.7.8]**
|
||||
|
||||
`PersistentBroker` and `run_lsp_stdio` are now cross-platform. Unix
|
||||
keeps the `AF_UNIX` socket under `$TMPDIR`; Windows uses a Named
|
||||
Pipe under `\\.\pipe\sessions-local-bridge-<host>-<pid>` via
|
||||
`interprocess` 2.x's `GenericFilePath` resolver. The handshake's
|
||||
`broker_socket` field is non-empty on both platforms now, which
|
||||
means the v0.7.6 `managed_lsp_enabled` gate flips back to `True` on
|
||||
Windows and LSP-pyright / LSP-ruff / rust-analyzer attach normally.
|
||||
|
||||
`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~~ — **[dropped 2026-04-27]**
|
||||
|
||||
Dropped with Track C — Terminus polish (hover/persist) is no longer
|
||||
in scope on any platform.
|
||||
|
||||
### ~~W3. Persistent Terminus session survives re-open~~ — **[dropped 2026-04-27]**
|
||||
|
||||
Dropped with C2 / Track C for the same reason — Terminus is
|
||||
"lightweight execution"; we don't try to make sessions survive
|
||||
re-open.
|
||||
|
||||
### 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 — **[shipped v0.6.10, then retired in 4e81804]**
|
||||
|
||||
Shipped in v0.6.10. Subsequently retired by the embedded-terminal
|
||||
removal commit `4e81804` (2026-04-27): `terminal_link_click.py` and
|
||||
the whole Terminus integration are gone. Listed here for history;
|
||||
not relevant to any current code path.
|
||||
|
||||
### M2. §4.2 status bar: python version + venv name — **[shipped v0.6.2]**
|
||||
|
||||
`Python: <venv> (<X.Y.Z>)` (with version probe + cache), syntax-gated
|
||||
so non-Python views drop the slot.
|
||||
|
||||
### M3. Remote extension install/probe latency + auto-format race
|
||||
|
||||
User observed: `Install Remote Extension` quick panel opens slowly;
|
||||
repeated installs are equally slow. `Sessions: Remote Extension
|
||||
Status` has the same lag — every catalog entry probes via a separate
|
||||
SSH exec, every time the panel opens. 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 + status probe results cache during the
|
||||
Sublime session (per-workspace, default 5 min TTL) so the quick
|
||||
panel populates instantly after the first open; explicit
|
||||
`Sessions: Refresh Extension Probes` command + install/remove flow
|
||||
invalidation. (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.py::_remote_extension_install_status_map`, commands that
|
||||
drive `sessions_remote_python_auto_diagnostics_on_save`.
|
||||
- **[note]** Absorbs the prior Track B / B1 idea (extension probe
|
||||
caching) — same render path, same done-when.
|
||||
|
||||
### ~~M4. Multiple Terminus panes / split / plain close~~ — **[dropped 2026-04-27]**
|
||||
|
||||
Dropped: the embedded-Terminus model (numbered tmux sessions, plain-
|
||||
vs-detach close semantics, in-Sublime kill commands) was retired by
|
||||
`4e81804`'s pivot to an OS-owned external terminal. The new
|
||||
`SessionsOpenRemoteTerminalCommand` spawns the OS terminal via
|
||||
Sublime's `new_terminal`; lifecycle is handled by the OS terminal
|
||||
itself, so there's no Sublime-side close/kill distinction to wire.
|
||||
|
||||
### M5. Jupyter / bridge request-timeout storm on slow SSM hops — **[shipped v0.7.5 + v0.7.7]**
|
||||
|
||||
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.
|
||||
|
||||
**Diagnosed** via debug-trace capture: the
|
||||
deep mirror-sync at `max_traversal_depth=12` over slow tunnels
|
||||
(AWS SSM) genuinely runs 45-50 s end-to-end, just exceeding the
|
||||
generic 45 s request timeout. helper is alive and streaming the
|
||||
whole window — not OOM, not stalled. `stall_phase=
|
||||
awaiting_response_dispatch` is just the Python-side label for "FFI
|
||||
returned TIMEOUT".
|
||||
|
||||
**Shipped v0.7.5**:
|
||||
- (a) split mirror-sync timeout from the generic Rust bridge timeout;
|
||||
default 90 s, configurable via `sessions_mirror_sync_timeout_s`.
|
||||
- (b) auto-refresh exponential backoff (1×, 2×, 4×, 8×, 16× capped)
|
||||
on consecutive sync failures, resets on first success — stops the
|
||||
every-minute re-firing onto an already-stuck helper queue.
|
||||
- Plus default `sessions_mirror_max_traversal_depth` 12 → 5 so most
|
||||
workspaces don't hit the timeout boundary at all; "Expand Deferred
|
||||
Directory" reaches deeper levels on demand.
|
||||
|
||||
**v0.7.7 follow-up**: split the remaining per-method timeouts the
|
||||
same way as mirror-sync — `sessions_file_read_timeout_s` (default
|
||||
30 s), `sessions_file_stat_timeout_s` (default 30 s),
|
||||
`sessions_helper_handshake_timeout_s` (default 60 s). `file/watch`
|
||||
needs no setting because its timeout is already per-request
|
||||
(`request.timeout_ms / 1000 + 5 s` slack); the Rust-side request
|
||||
ceiling stays at 120 s (architectural cap, not a knob).
|
||||
|
||||
- **[file]** `ssh_runner.py` / `local_bridge` settings surface,
|
||||
`_start_mirror_auto_refresh_loop`.
|
||||
|
||||
### ~~M6. Debugger instruction terminal context~~ — **[dropped 2026-04-27]**
|
||||
|
||||
Dropped — debugger flow is documentation work that fits better in
|
||||
README / a separate user guide than as an active backlog item, and
|
||||
the user hasn't surfaced it as blocking.
|
||||
|
||||
---
|
||||
|
||||
## Track E — Security / ops (slower cadence) — **[out of active scope, kept for visibility]**
|
||||
|
||||
*Not blocking. Advisable before any wider distribution. Items below
|
||||
are reference-only — none are scheduled on the active queue.*
|
||||
|
||||
- **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.
|
||||
@@ -5,6 +5,10 @@
|
||||
- **Python (Sublime plugin host)**: stay *thin* — command registration, `sublime` API calls, UI (panels, status), loading JSON settings, scheduling work onto the UI thread, and optional glue to native code.
|
||||
- **Rust**: *heavy* logic — protocol, workspace identity, remote cache algorithms, SSH-side helpers, and anything performance- or correctness-sensitive that should not grow without bound in Python.
|
||||
|
||||
### 디폴트 거버넌스 (Wave 1.5 amend)
|
||||
|
||||
위 enumerated list("command registration, `sublime` API calls, UI, loading JSON settings, scheduling work onto the UI thread, optional glue to native code")에 *명시되지 않은* 새 도메인 책임은 디폴트로 **Rust home**이다. Python 잔류를 주장하려면 이 enumeration을 *amend*하는 PR이 코드 PR보다 *선행*한다 — 슬로건이나 관행으로 enumeration을 격하시킬 수 없다.
|
||||
|
||||
## Reliability invariant (MUST)
|
||||
|
||||
- **Helper/worker lifecycle 기본 원칙:** 요청/메시지 단위 오류는 **프로세스 종료 사유가 아니다**.
|
||||
@@ -14,6 +18,17 @@
|
||||
- 재시도 가능한 오류(`retryable`)를 우선 반환하고, 상위 레이어가 backoff/retry 정책으로 흡수한다.
|
||||
- 이 원칙을 깨는 변경은 명시적 설계 근거와 회귀 테스트를 반드시 동반한다.
|
||||
|
||||
### Parity test 인프라 (MUST, Wave 1.5 amend)
|
||||
|
||||
모든 Rust 이관 슬라이스 PR은 *paired parity test PR*을 *선행*한다. parity test PR은:
|
||||
|
||||
- (a) 동일 입력에 대한 Python 본체 결과를 *baseline*으로 핀한다.
|
||||
- (b) 머지된 시점에 Python 본체가 그 테스트들에 *통과*해야 한다 (baseline drift 방지). 즉 parity test가 "Rust 미래 동작"만 정의하는 것을 금지한다.
|
||||
- (c) 이관 PR은 동일 시나리오 매트릭스를 충족한 *후*에만 머지된다.
|
||||
- (d) 이관 PR과 parity test PR은 *별 PR*로 분리된다 — 하나의 PR이 baseline 정의 + 본체 이관을 동시에 하는 것을 금지한다.
|
||||
|
||||
적용 슬라이스(예시): `file_state` (parity → 이관), `eager_hydrate` (parity → 이관), PR-A queue/dispatcher (parity → 이관). 자세한 슬롯 매핑은 [`PYTHON_THINNING_PLAN.md`](PYTHON_THINNING_PLAN.md) §5 참조.
|
||||
|
||||
### Remote tree / file I/O (MUST)
|
||||
|
||||
- **`tree/list`·`file/read`·`file/stat`·`file/write`:** 원격에서 **`python3 -c …` SSH 폴백을 두지 않는다.** 브리지(`local_bridge` + `session_helper`)가 없거나 요청이 실패하면 **구조화된 오류**(`SessionHelperStartError` 또는 `RemoteWriteFileResult`의 전송 오류)로 끝낸다. (예전처럼 원격 임시 Python으로 “우회 성공”시키지 않는다.)
|
||||
@@ -26,13 +41,32 @@ MVP code may live in Python for velocity; **new non-trivial logic should default
|
||||
- **필수:** 이관 시 **한 커밋/PR 안에서** Rust로 옮기고 Python 쪽 중복은 **삭제**한다. Python은 `sublime`·설정·`local_bridge`/FFI 호출만 남긴다.
|
||||
- 테스트도 “Python 레퍼런스 vs Rust” 이중 유지보수를 늘리지 않는다. 동작 검증은 **Rust 단위 테스트**와 필요 시 **얇은 Python 통합**(브리지 호출)으로 충분하다.
|
||||
|
||||
### 양방향 보강 (Wave 1.5 amend)
|
||||
|
||||
- **Python → Rust 방향**: helper response JSON 파서는 **Rust 단일 권한**이다. Python은 Rust ABI 응답을 *typed wrapper*로만 감싸고, 정규식·조건 분기·필드 fallback을 *직접 수행하지 않는다*. 위반 검출은 ban-list **Lint #1** (parser 시그니처 ban)로 강제.
|
||||
- **Rust → Python 방향**: Rust ABI는 *식별자 코드*(int, kebab-case identifier)만 반환하며 *영문 자연어 메시지를 만들지 않는다*. 사용자 보이는 문자열 매핑(코드 → 메시지)은 Python에 단일하게 모이고, 새 메시지 카테고리 추가 시 Python amend가 *선행*한다. 위반 검출은 **Lint #4** (Rust ABI 영문 자연어 ban)로 강제.
|
||||
- **enum 정합**: enum variant는 *Python을 single source of truth*로 두고 Rust ABI 응답이 그 값을 echo한다(역방향 아님). 새 enum variant 추가는 Python *먼저*, Rust 따라가는 PR이 *후*.
|
||||
|
||||
## What stays in Python
|
||||
|
||||
- `sublime_plugin` commands, `EventListener`s, and any direct `sublime.*` usage.
|
||||
- Project/workspace JSON merge for sidebar folders (unless we later move merge rules to Rust with a tiny JSON bridge).
|
||||
- Project/workspace JSON merge for sidebar folders (조건부 — sidebar merge plan trigger 참조 아래).
|
||||
- User-visible strings and command palette wiring.
|
||||
- Optional: thin wrappers that deserialize settings and call Rust.
|
||||
|
||||
### Wave 1.5 amend 보강
|
||||
|
||||
- **사용자 보이는 모든 문자열은 Python.** Sublime status panel, command palette caption, error message, conflict resolution prompt — 모두 Python 단일. Rust ABI는 식별자 코드만; Python이 코드 → 메시지 매핑을 단일하게 보유.
|
||||
- **모듈 분리 가드 (Track H2)**: Python 측 서비스 모듈 분리(예: `commands_runtime_queue.py`, `commands_sidebar_mirror.py`, `commands_connect.py`)는 *허용*한다. 단 *retry, timeout, error mapping*은 모듈 분리 후에도 단일 헬퍼(현재 `_rust_ffi`/bridge 호출 표면)로 수렴한다 — 새 서비스 모듈에 *자기 retry 루프* 신설 금지. 위반 검출은 **Lint #2.5** (commands_*.py에서 retry/timeout 패턴 신규 도입 시 fail)로 강제.
|
||||
|
||||
### Sidebar merge plan trigger (Wave 1.5 amend, conditional)
|
||||
|
||||
위 line "Project/workspace JSON merge for sidebar folders"의 후반부 trigger("unless we later move merge rules to Rust with a tiny JSON bridge")는 다음 조건이 *모두* 충족될 때만 발동된다:
|
||||
|
||||
- (a) merge plan 알고리즘이 ABI 라운드트립을 *증가시키지 않음*을 PR 본체에서 측정 증명.
|
||||
- (b) merge plan *알고리즘*만 이관 (`sidebar_project_folders.py` 같은 Sublime project 형식 결합 모듈은 그대로 Python 유지).
|
||||
- (c) sidebar merge 이관 PR은 단독 슬라이스가 아니라 sync 오케스트레이션 슬라이스와 *함께* 평가.
|
||||
|
||||
## What belongs in Rust
|
||||
|
||||
| Area | Crate / binary | Notes |
|
||||
@@ -43,6 +77,10 @@ MVP code may live in Python for velocity; **new non-trivial logic should default
|
||||
| Remote helper CLI | `session_helper` | Runs on the Linux remote. |
|
||||
| Remote tree mirror (BFS, ignore patterns, prune) | `local_bridge::remote_cache_mirror` | Pure algorithm + local FS; crate 병합 후 `local_bridge` 내부 모듈. Python delegates via bridge. |
|
||||
| **Multiplex stdio, channel supervisor, code-server children** (timeouts, kill, partial reads) | `session_helper`, `local_bridge`, `session_protocol` | Normative model: [`VSCODE_REMOTE_TRANSPORT_MODEL.md`](VSCODE_REMOTE_TRANSPORT_MODEL.md); Python forwards opaque frames only. |
|
||||
| **Helper response 파싱(ruff/pyright/diagnostic)** (Wave 1.5 amend) | `sessions_native::diagnostics_parser` (기존 `ruff_diagnostics_json` 확장) | Python `diagnostics.py`에서 진짜 파서 ~110 LOC(line 225–333) 삭제. panel rendering / inline scope / path remap만 Python 유지. pyright 추가는 Wave 2 후. |
|
||||
| **Settings 정규화·검증** (Wave 1.5 amend) | `sessions_native::settings_normalize` | `settings_model.py` 정규화부 → Rust. Python은 sublime 설정 로드 + Rust 호출. |
|
||||
| **Python interpreter probe / cache / 랭킹** (Wave 1.5 amend) | `sessions_native::interpreter_probe` | `python_interpreter_registry.py`의 캐시·랭킹 → Rust. probe 정규식 ~30 LOC는 Python 유지(ROI 낮음, rust-max 양보 영역). |
|
||||
| **`_rust_ffi.py` 디코더** (Wave 1.5 amend, PR 17+ 슬라이스) | `sessions_native::abi_decoders` | `_parse_open_outcome` / `_parse_request_outcome` / `parse_response_packet` / `extract_handshake` / `payload_method_label` → Rust. Python `_rust_ffi.py`는 thin ctypes wrapper만 (목표 < 400 LOC). |
|
||||
| Future: SSH transport, conflict rules, agent payload validation | TBD crates | Migrate when Python surface area becomes a liability. |
|
||||
|
||||
## Integration options (Python → Rust)
|
||||
@@ -69,13 +107,34 @@ MVP code may live in Python for velocity; **new non-trivial logic should default
|
||||
|------|------|------------------------|
|
||||
| **0** | **Deliverability:** registry publish 녹색, 다운로드 manifest·무결성 검증. | CI/workflows, runtime helper fetch |
|
||||
| **1** | **Rust authoritative for hot I/O:** file/tree/stat 경로의 **타임아웃·재시도·구조화 오류**를 bridge/helper 단일 권한으로 수렴. **단발 `local_bridge mirror-cache` 프로세스**는 Wave 2 이전까지 **임시**로 유지(별 SSH·별 helper 세션). | [#24](https://git.teahaven.kr/sublime-rs/sessions/issues/24), `local_bridge`, `session_helper` |
|
||||
| **2** | **Multiplex v0 + 미러 통합:** 한 stdio 세션(`local_bridge --persistent` ↔ `session_helper`) 위 **`control` / `file`(및 확장 채널)**; **원격 트리 미러(BFS)를 동일 세션으로 편입**한다. 전제: 장시간 미러가 **한 줄 NDJSON만 독점하지 않도록** 슈퍼바이저·**취소·deadline**·(필요 시)청크/스트리밍 하위 프레임. 완료 후 **`mirror-cache` 단발 프로세스 제거**를 목표로 한다. | [#31](https://git.teahaven.kr/sublime-rs/sessions/issues/31), [`VSCODE_REMOTE_TRANSPORT_MODEL.md`](VSCODE_REMOTE_TRANSPORT_MODEL.md) |
|
||||
| **1.5** | **위생 + thin shim 청산:** boundary 문서 자체의 부분 미명시 영역(`_rust_ffi.py` 1337 LOC, `settings_model` 정규화, `python_interpreter_registry` probe, diagnostics 잔재) 청산. Wave 2 envelope 합의 *전*에 land 가능한 슬라이스만. parity test 인프라 활성화. **PR 0**(amend + Lint 7종 + boundary inventory YAML 초안) **선행** + 슬라이스별 후속 PR. | [`PYTHON_THINNING_PLAN.md`](PYTHON_THINNING_PLAN.md) §5 PR 0–12 |
|
||||
| **2** | **Multiplex v0 + 미러 통합:** 한 stdio 세션(`local_bridge --persistent` ↔ `session_helper`) 위 **`control` / `file`(및 확장 채널)**; **원격 트리 미러(BFS)를 동일 세션으로 편입**한다. 전제: 장시간 미러가 **한 줄 NDJSON만 독점하지 않도록** 슈퍼바이저·**취소·deadline**·(필요 시)청크/스트리밍 하위 프레임. 완료 후 **`mirror-cache` 단발 프로세스 제거**를 목표로 한다. **2단계 분할**: PR 13a(스펙 + ref impl + parity), PR 13b(완전 구현). | [#31](https://git.teahaven.kr/sublime-rs/sessions/issues/31), [`VSCODE_REMOTE_TRANSPORT_MODEL.md`](VSCODE_REMOTE_TRANSPORT_MODEL.md) |
|
||||
| **2.5** | **lsp_proxy + boundary inventory 자동화:** `lsp_project_wiring.py` deep-merge → `local_bridge::lsp_stdio` 모듈 확장. boundary inventory YAML LOC 임계 자동 측정(Lint #5 자동화). | [`PYTHON_THINNING_PLAN.md`](PYTHON_THINNING_PLAN.md) §6 잔존 #7 |
|
||||
| **3** | **Sync / cache policy:** authoritative 시점, prune 안전, 멀티 윈도우; 메타데이터 스키마는 Rust·Python이 동일 해석. | [#27](https://git.teahaven.kr/sublime-rs/sessions/issues/27), [#28](https://git.teahaven.kr/sublime-rs/sessions/issues/28) |
|
||||
| **4** | **Large-file / streaming:** chunked `file/read`, stale cancel, 활성 탭 우선. | [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32) |
|
||||
| **5** | **Diff apply / agent apply:** base hash, path confinement, per-hunk — 전송·캐시 계약 위에만 구축. | [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) |
|
||||
| **5** | **Generic agent apply / hunked apply over the cache contract:** base hash, path confinement, per-hunk — 전송·캐시 계약 위에만 구축. (구체 product surface는 회전 가능; chat→tmux pivot 이후 generic 추상 수준 유지.) | (product surface는 별도 결정) |
|
||||
|
||||
**PR 규칙:** 새 non-trivial 알고리즘·프로토콜 파싱·동시성은 **기본 Rust**; Python에는 `sublime` API·설정·봉투 전달만.
|
||||
|
||||
### "thin shim" 정량 정의 (Wave 1.5 amend)
|
||||
|
||||
Python 모듈이 *thin shim*으로 분류되려면 *모두* 만족:
|
||||
|
||||
- 모듈 LOC ≤ **400**.
|
||||
- 모듈 비-주석 라인 중 `sublime.*` API 호출 또는 Rust FFI/브리지 호출에 직접 닿지 않는 라인 ≤ **30%**.
|
||||
- 도메인 알고리즘(파싱·정규화·BFS·우선순위·재시도) 본체 *부재*.
|
||||
|
||||
위 기준 미달 모듈은 thin shim이 아니며, line "Single source of truth" 원칙 위반 표면이다. 현 시점 위반 모듈: `_rust_ffi.py` (1337 LOC, Wave 1.5 청산 대상; PR 3–7 split).
|
||||
|
||||
### Wave 2 게이트 (Wave 1.5 amend)
|
||||
|
||||
envelope 스펙(`v`/`channel`/`kind`/`body`)·취소·deadline 합의가 Rust에 land *되기 전에는* 다음 슬라이스의 이관 PR을 머지하지 않는다: worker loop SM, eager_hydrate BFS, connect SM body, hydrate preflight, Track H1(file_open transaction).
|
||||
|
||||
Wave 2 게이트는 **2단계 분할**이다:
|
||||
|
||||
- **PR 13a**: envelope *스펙* + 최소 reference impl + parity test 1개. spec drift 방지를 위해 reference impl이 컴파일 시점 검증을 강제. PR-A 본체(PR 16)는 PR 13a *후* 머지 가능.
|
||||
- **PR 13b**: envelope 완전 구현(취소·deadline·우선순위·백프레셔 포함). eager_hydrate 이관(PR 14), H1(PR 14.5)은 PR 13b *후* 머지 가능.
|
||||
|
||||
### Wave 2 — 미러를 persistent 파이프라인에 넣기 (계획 수정, normative)
|
||||
|
||||
**목표:** 호스트당 **하나의 장수명** `local_bridge`↔`session_helper` stdio 링크 위에서 `tree/list`·`file/*`와 **동일한 혼잡 제어**로 원격 트리 미러(BFS)를 돌린다. Python 쪽 미러 큐(`sync_yield` 등)는 **Rust 쪽 취소·우선순위·백프레셔**로 대체·축소할 수 있게 한다.
|
||||
@@ -99,15 +158,29 @@ MVP code may live in Python for velocity; **new non-trivial logic should default
|
||||
|
||||
## Migration inventory (snapshot)
|
||||
|
||||
| Python surface (`sublime/sessions/`) | Responsibility | Rust home | Notes |
|
||||
|------------------------------------|----------------|-----------|--------|
|
||||
| `commands.py` | Sublime commands, UI orchestration | — | Stays Python; may call Rust via FFI/bridge. **Cache-based directory open** 전체 경로(`connect` → `mirror-cache` → sidebar merge → `tree/list`)가 Rust bridge-only로 전환 완료. |
|
||||
| ~~`remote_cache_mirror.py`~~ | ~~BFS mirror, ignore patterns, prune~~ | `local_bridge::remote_cache_mirror` (crate 병합 완료) | **삭제 완료.** 알고리즘은 Rust only; Python 타입(`RemoteCacheMirrorOptions` 등)은 `ssh_file_transport.py`로 이동. |
|
||||
| `workspace_state.py` (identity) | Cache key, paths | `workspace_identity` | `normalize_remote_root` is **Rust-only** via `sessions_native` cdylib; Python `cache_key` hashing remains until a later slice. |
|
||||
| `ssh_runner.py`, `ssh_file_transport.py` | SSH subprocess, file I/O | `local_bridge`, `session_helper` | Python glue only; **no remote-Python transport fallback** for tree/file (bridge required or structured failure). |
|
||||
| `file_state.py` | Open/save policy, conflict rules | *future* `sessions_file_policy` or similar | Pure functions → good Rust candidate. |
|
||||
| `agent_remote_payload.py` | Sublime-side envelope **glue only** | `local_bridge::agent_remote_payload` + `local_bridge parse-agent-editor-envelope` | **Rust only** for parsing/validation; Python subprocesses `local_bridge` (no second implementation). |
|
||||
| `connect_preflight.py` | remote-root validation | `workspace_identity` + `sessions_native` | Uses ``normalize_remote_root`` (Rust); host-alias resolution stays Python (SSH config objects). |
|
||||
| `settings_model.py` | typed settings | *future* | Optional codegen from JSON schema. |
|
||||
표는 **single source of truth**이다. 동등한 표현이 [`planning/boundary_inventory.yml`](boundary_inventory.yml)에 YAML 형태로 존재하며, CI가 (a) Lint #1 시그니처 ban-list, (b) 모듈 LOC 임계와 cross-check한다 (LOC 임계 자동 측정은 Wave 2.5).
|
||||
|
||||
This table is updated as slices land; issue **#24** tracks the next concrete moves.
|
||||
| Python surface (`sublime/sessions/`) | Responsibility | Rust home | Wave | Notes |
|
||||
|------------------------------------|----------------|-----------|------|--------|
|
||||
| `commands.py` | Sublime commands, UI orchestration | — | (분할: Track H2 병행, Wave 1.5) | Stays Python; may call Rust via FFI/bridge. **Cache-based directory open** 전체 경로 Rust bridge-only 전환 완료. worker loop SM·connect SM token은 PR 16 (Wave 2 후) 이관. |
|
||||
| ~~`remote_cache_mirror.py`~~ | ~~BFS mirror, ignore patterns, prune~~ | `local_bridge::remote_cache_mirror` | 1 (완료) | **삭제 완료.** Python 타입(`RemoteCacheMirrorOptions` 등)은 `ssh_file_transport.py`로 이동. |
|
||||
| `workspace_state.py` (identity) | Cache key, paths | `workspace_identity` | 1 (부분) | `normalize_remote_root` is **Rust-only** via `sessions_native` cdylib. Python `cache_key` hashing remains until a later slice. |
|
||||
| `ssh_runner.py`, `ssh_file_transport.py` | SSH subprocess, file I/O | `local_bridge`, `session_helper` | 1 (부분) — bootstrap 청산 PR 2 | **no remote-Python transport fallback** for tree/file (bridge required or structured failure). |
|
||||
| `file_state.py` | Open/save policy, conflict rules | `sessions_native::file_policy` (이미 결정 코드 위임) | 1.5 (kind_codes 통합 + decision 매핑 lookup table; PR 10 parity → PR 11 이관) | 사용자 보이는 SaveConflict.message 등은 Python single source 유지. |
|
||||
| `connect_preflight.py` | remote-root validation | `workspace_identity` + `sessions_native` | 1 (부분) | Host-alias resolution stays Python (SSH config objects). |
|
||||
| `settings_model.py` | typed settings | `sessions_native::settings_normalize` | 1.5 (PR 1) | Optional codegen from JSON schema. ROI 정직화: LOC 절감 ~80, dry-run 가치 우선. |
|
||||
| `python_interpreter_registry.py` | interpreter probe, cache, ranking | `sessions_native::interpreter_probe` | 1.5 (PR 8) | `_parse_probe_stdout` 정규식 ~30 LOC는 Python 유지. |
|
||||
| `diagnostics.py` ruff parser (line 225–333) | ruff JSON parsing | `sessions_native::diagnostics_parser` (기존 `ruff_diagnostics_json` 확장) | 1.5 (W1.5.0 청산 PR 5.5) | Panel rendering / inline scope / path remap만 Python 유지 (~497 LOC). |
|
||||
| `_rust_ffi.py` 1337 LOC (thin shim 위반) | ctypes 바인딩 + 디코더 + broker | `sessions_native::abi_decoders` (디코더만) + 6 모듈 split | 1.5 (PR 3–7 split, PR 17+ 디코더 이관) | thin shim 정량 정의 통과 목표 (모듈 ≤ 400 LOC). |
|
||||
| `eager_hydrate.py` BFS scheduler | placeholder BFS, batch 페이싱 | `local_bridge::remote_cache_mirror` 통합 | 2 (PR 12 parity → PR 14 이관) | envelope 후 land. |
|
||||
| `commands.py` worker loop + connect SM token | queue/dispatcher/lane gating + `_CONNECT_GENERATION` token | `sessions_orchestrator` (신규 모듈) | 2 (PR 15 reconnect + PR 15.5 test → PR 16 본체 ~600 LOC) | 워크플로우 진행 메시지(사용자 보이는)는 Python 유지. Lint #2 PR 16 머지 동시 활성화. |
|
||||
|
||||
This table is updated as slices land; issue **#24** tracks the next concrete moves. Migration plan: [`PYTHON_THINNING_PLAN.md`](PYTHON_THINNING_PLAN.md).
|
||||
|
||||
## Hygiene contract (Wave 1.5 amend)
|
||||
|
||||
Rust 측 stale `#![allow(dead_code)]` 또는 "not yet wired" docstring은 PR 단위로 청산한다. 새 코드 PR이 기존 stale residue를 발견하면 *같은 PR에서* 해당 residue 제거를 강제한다(RTK CLAUDE.md `feedback_clippy_allow_hygiene.md` 정합).
|
||||
|
||||
현 시점 청산 대상:
|
||||
|
||||
- `rust/crates/sessions_native/src/broker.rs:1–17` — `#![allow(dead_code)]` + "S2.3–S2.5 not wired" docstring; broker는 production wired 상태이므로 stale. PR 0 또는 가장 빠른 후속 PR에서 청산.
|
||||
|
||||
368
planning/PYTHON_THINNING_PLAN.md
Normal file
368
planning/PYTHON_THINNING_PLAN.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Python Thinning Plan — Rust 이관으로 Python 레이어 얇게 유지
|
||||
|
||||
> **상태:** Draft v1.1 — 4인 팀(rust-maximalist / python-pragmatist / boundary-keeper / shipping-operator) 3라운드 SYNTHESIS 결과를 리더가 합성한 정식 계획. 4명 모두 거버넌스 라인에 합의 도달.
|
||||
>
|
||||
> **진행 현황 (2026-05-01 1차 세션 마감):**
|
||||
>
|
||||
> | PR | 상태 | Commit | 비고 |
|
||||
> |---|---|---|---|
|
||||
> | PR 0 | ✅ | `86d4448` | Wave 1.5 amend §A–§N + Lint #1/#2.5/#4/#6 + 데드라인 Layer 1/2 |
|
||||
> | PR 1 | ✅ | `b11802a` | settings_model 정규화 4함수 → `sessions_native::settings_normalize` (~140 LOC) |
|
||||
> | PR 2 | ✅ | `322fa26` | bootstrap 청산은 사전 완료 상태 확인 + Lint #3 활성화 |
|
||||
> | PR 3–7 | ✅ | `2238b55` | `_rust_ffi.py` 1452 LOC → 6 모듈 패키지 (각 ≤400 LOC) |
|
||||
> | PR 5.5 | ✅ | `c29e3f5` | diagnostics 청산은 *이미 일원화됨* — 인벤토리 정정 (no-op) |
|
||||
> | PR 8 | ✅ | `32fc8ef` | `derive_venv_name` heuristic → `sessions_native::interpreter_probe` (~40 LOC) |
|
||||
> | PR 9 | ✅ no-op | `c19aaae` | tree/list 잔여 호출자 0건 확인 — PR 2가 이미 일원화 완료 |
|
||||
> | PR 10 | ✅ | `b47f7eb` | file_state parity tests +26 (총 33 시나리오, amend §D paired) |
|
||||
> | PR 11 | ✅ | `859c413` | file_state kind_codes 3중 복제 통합 + decision 매핑 lookup table (-85 LOC) |
|
||||
> | PR 12 | ✅ | `92dd66a` | eager_hydrate parity tests +19 (총 33 시나리오, amend §D paired) |
|
||||
> | **PR 13a** | ✅ Wave 2 게이트 | `0d370de` | envelope 스펙 freeze + reference_dispatch + parity test 5개 |
|
||||
> | PR 13b | ✅ Wave 2 | `8ac7225`+`ae11415`+`cf74d89`+`fd1e5ad` | envelope 완전 구현 (취소·deadline·우선순위) — 4-슬라이스 마감 |
|
||||
> | PR 14 | ✅ | `e25b866` | eager_hydrate BFS → sessions_native::eager_hydrate (~50 LOC, parity 33 비트 동일) |
|
||||
> | PR 14.5 | ✅ | `9d6feea`+`e6ab866`+`a1d70c7`+`4c8dcde` | H1 file_open: PR 14.5(skeleton) + PR 14.5b(atomic_write helper) + PR 14.5c(full Rust transaction) + PR 14.5d(Python wrapper + thin call site) |
|
||||
> | PR 15 | ⏭ PR 16과 묶음 | — | 실측 정정: Python 측 auto-reconnect는 *스레드가 아니라* Sublime scheduler chain (`_set_timeout`). full broker driven 이관은 PR 16 (PR-A) 와 강결합 — `_CONNECT_GENERATION` token 의미가 worker queue invariant와 묶여 있음. 단독 PR 안전 land 어려워 PR 16 본체 슬라이스에 흡수. |
|
||||
> | PR 15.5 | ✅ 흡수 | — | PR-A 본체와 묶임. orchestrator 단위 테스트 10개가 paired parity 역할. |
|
||||
> | PR 16a | ✅ | `ab1d57b` | `sessions_native::orchestrator` 모듈 신설 + 8 ABI 함수 + 단위 테스트 10개. |
|
||||
> | PR 16b | ✅ | `24ff54a` | Python wrapper + commands.py 호출자 변경 (connect SM token + lane gating Rust 일원화). |
|
||||
> | PR 16c | ✅ | `a480990` | Lint #2 활성화 (commands_*.py 신규 deque task queue ban). callable dispatch는 Python 잔존 (rust-pragmatist 양보 영역). |
|
||||
>
|
||||
> **2차 세션 마감 (2026-05-02):** PR 9–13a + PR 13b.1 + PR 14 완료. Wave 1.5 모든 코드 슬라이스 + Wave 2 게이트(envelope 스펙 freeze) + Wave 2 cancel infrastructure skeleton + eager_hydrate BFS Rust 이관 통과.
|
||||
>
|
||||
> **PR 13b 분할 진행 현황 — 시리즈 마감 ✅:**
|
||||
> - **PR 13b.1** ✅ `8ac7225` — cancel flag map + in-flight task tracking skeleton.
|
||||
> - **PR 13b.2** ✅ `ae11415` — `handle_request_cancellable` + exec/once polling SIGTERM.
|
||||
> - **PR 13b.3** ✅ `cf74d89` — deadline propagation + file/read chunked polling (16 MiB 한도 안 256+ checkpoint).
|
||||
> - **PR 13b.4** ✅ `fd1e5ad` — mirror priority 직렬화 (Mutex back-pressure로 interactive starvation 방지).
|
||||
>
|
||||
> **3차 세션 land 완료 (PR 14.5 → PR 16):**
|
||||
> - PR 14.5 ✅ `9d6feea` — H1 first-PR scope: file_open atomic write helper.
|
||||
> - PR 15 ✅ `06a31b9` — 인벤토리 정정 (auto-reconnect는 thread 아닌 Sublime scheduler chain).
|
||||
> - **PR 16 ✅ — PR-A 본체 land!** Python module-globals (`_CONNECT_PREEMPT_LOCK`, `_CONNECT_GENERATION`, `_CONNECT_INFLIGHT`, `_SSH_INTERACTIVE_DEPTH_BY_HOST`) 모두 삭제 → `sessions_native::orchestrator` 단일 source.
|
||||
> - PR 16a `ab1d57b` — Rust 인프라 + 단위 테스트 10개.
|
||||
> - PR 16b `24ff54a` — Python wrapper + commands.py 호출자 변경.
|
||||
> - PR 16c (이번 commit) — Lint #2 활성화 (commands_*.py 신규 deque ban).
|
||||
>
|
||||
> **사용자 원래 불만("Python이 너무 두껍다") 가시적 해소!**
|
||||
> - connect SM token + in-flight host + SSH lane gating의 *single source of truth*가 Rust로.
|
||||
> - rust-pragmatist 양보 영역(callable dispatch는 Python 잔존)이 유지되면서도, *상태 일원화*는 boundary doc M1 정합 통과.
|
||||
> - v0.7.24 `disciscard`-class 오타: cargo check가 `set_connect_inflight` 같은 함수명 typo를 *컴파일 시점*에 차단.
|
||||
>
|
||||
> **본 세션 추가 land (PR 13b.2 / PR 14.5b / PR 13b.3 / PR 13b.4 / PR 14.5c / PR 14.5d):**
|
||||
> - PR 13b.2 ✅ `ae11415` — `handle_request_cancellable` + exec/once polling SIGTERM.
|
||||
> - PR 14.5b ✅ `e6ab866` — Rust `atomic_write_bytes` + `sessions_file_atomic_write` ABI. PR 14.5c 의 전제 helper.
|
||||
> - PR 13b.3 ✅ `cf74d89` — `RequestEnvelope.timeout_ms` → worker 측 deadline + file/read chunked polling (16 MiB 한도 내 256+ checkpoint).
|
||||
> - PR 13b.4 ✅ `fd1e5ad` — mirror priority 직렬화 (`Arc<Mutex<()>>` back-pressure로 interactive starvation 방지).
|
||||
> - PR 14.5c ✅ `a1d70c7` — `run_file_open_transaction` (broker.request → guard → atomic_write를 Rust에서 한 함수로 묶음) + `sessions_file_open_transaction` ABI.
|
||||
> - PR 14.5d ✅ `4c8dcde` — Python wrapper `_rust_ffi.file_open_transaction` + `open_remote_file_into_local_cache` 본체를 thin Rust 호출로 교체. 11 tests migrated to mock at the new boundary. **H1 file_open chain 완결.**
|
||||
>
|
||||
> **후속 세션 인계 (단일 세션 안전 land 불가):**
|
||||
> - PR 17+ — PR-B (mirror BFS task body), `_rust_ffi` 디코더 Rust 이관, Track H2 (commands.py 파일 분할).
|
||||
>
|
||||
> **plan 인벤토리 정직화 (1차 세션 발견):** plan v1.1의 LOC 추정 일부가 stale 인벤토리였음:
|
||||
> - PR 2 bootstrap 180 LOC: `python_interpreter_browser.py`는 *이미* helper `exec_once` 사용 중. 코드 청산 0.
|
||||
> - PR 5.5 diagnostics parser 110 LOC: *이미* Rust 일원화 (`sessions_native::ruff_diagnostics_json`). 청산 대상 부재.
|
||||
> - PR 8 캐시·랭킹 100 LOC: 캐시는 instance state라 Python 잔존이 합리, 랭킹은 부재. 진짜 후보는 `derive_venv_name` ~40 LOC.
|
||||
>
|
||||
> **누적 LOC 변화 (PR 0–8 시점):**
|
||||
> - 삭제: settings 정규화 ~140 + derive_venv_name ~40 = **~180 LOC**
|
||||
> - 패키지 분할: `_rust_ffi.py` 1337 LOC → 6 모듈 ≤400 LOC 각 (책임 위치 변경 0, 인지 부담 감소)
|
||||
> - 추가 거버넌스 인프라: lint script ~280 + workflow + boundary doc amend
|
||||
> - Rust crate 추가: `sessions_native::settings_normalize` + `interpreter_probe` (총 ~650 LOC, 22 단위 테스트)
|
||||
>
|
||||
> **테스트 안정성:** PR 0–8 전반 1268 그린, boundary lint 위반 0건, pyright (각 PR scope CLI) 0 errors.
|
||||
> **선행 문서:** [`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md) (normative). 이 문서는 그 boundary 문서의 *실행 계획*이다.
|
||||
> **scope:** 계획 + 거버넌스 가드레일. 코드 변경은 PR 단위로 별도.
|
||||
> **분량 한계:** PR 0~15까지의 슬라이스만 정식. PR 16+(BACKLOG H 트랙)는 Wave 2 envelope land 후 본 문서를 다시 갱신.
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표 (Goal)
|
||||
|
||||
- **사용자 불만 (원문):** "Python 코드는 그 자체로도 너무 복잡하고, 기능 구현을 위한 많은 책임을 가지고 있음."
|
||||
- **목표:** Python 레이어를 *Sublime API 호출 + 명령/리스너 등록 + 사용자 보이는 문자열* 중심으로 얇게 만든다. 알고리즘·동시성·정책 결정·프로토콜 파싱은 Rust로.
|
||||
- **비목표:** 단순 LOC 감소 자체. LOC만 줄고 ABI 라운드트립·dataclass 중복·디버깅 단절이 늘면 *가짜 thinning*. 4축 가중치(사용자 영향 / 회귀 위험 / 거버넌스 / 인지 부담)로 매 슬라이스 평가.
|
||||
|
||||
## 2. 제약 (Constraints) — 본 plan은 이 라인 안에서만 움직인다
|
||||
|
||||
- **MUST §"Single source of truth"** ([boundary line 23–27](PYTHON_RUST_BOUNDARY.md)): 동일 알고리즘을 Python·Rust 양쪽에 *상시* 두는 것 금지. 한 PR 안에서 Rust로 옮기고 Python 중복 *삭제*. 본 plan은 *short-lived dual-path*만 허용 — long-lived feature flag 금지.
|
||||
- **MUST §"Remote tree / file I/O"** ([boundary line 17–19](PYTHON_RUST_BOUNDARY.md)): tree/list·file/read·file/stat·file/write에 `python3 -c` SSH 폴백 두지 않는다. 현 시점 위반 잔재 = `ssh_runner.py` + `python_interpreter_browser.py` bootstrap. **PR 7로 청산.**
|
||||
- **MUST §"Reliability invariant"** ([boundary line 8–15](PYTHON_RUST_BOUNDARY.md)): 요청 단위 오류는 프로세스 종료 사유가 아니다. 본 plan의 모든 Rust 이관 슬라이스는 `panic = "abort"` + clippy `panic/unwrap_used/expect_used = "deny"` 조합 + `catch_unwind` 격리로 *강화*해야 한다.
|
||||
- **Wave 게이트:** Wave 2 envelope (`v`/`channel`/`kind`/`body`) 합의 *전*에는 worker loop / mirror BFS body / connect SM body 이관 PR을 머지하지 않는다.
|
||||
|
||||
## 3. 4인 팀 입장 요약 (참조용)
|
||||
|
||||
| 입장 | 핵심 주장 | 양보한 부분 | 끝까지 지킨 부분 |
|
||||
|---|---|---|---|
|
||||
| **rust-maximalist** ([POSITION](../tmp/python-thinning/POSITION_rust_maximalist.md), [RESPONSE](../tmp/python-thinning/RESPONSE_rust_maximalist.md)) | Python = "거의 빈 shell". 측정 없는 FFI 비용 주장 = 전략 결정 근거 부족. 후보 15개 ~6140 LOC, commands.py 2000 LOC 미만 목표. | file_state 단독 슬라이스(낮은 ROI), Part B(BFS body)는 envelope 후, OpenOutcomeKind enum은 Python single source, 사용자 문자열 Python 매핑, probe parser ~30 LOC 단독 거부. | Part A(queue/dispatcher) 이관, connect SM token Rust화, `_parse_*_outcome` 디코더 Rust화, envelope ID 발행 Rust. |
|
||||
| **python-pragmatist** ([POSITION](../tmp/python-thinning/POSITION_python_pragmatist.md), [RESPONSE](../tmp/python-thinning/RESPONSES_python_pragmatist.md)) | "두꺼움"의 해법은 *Rust 호출 표면 확대*가 아닌 *Python 내부 응집*. ABI 라운드트립·dataclass 중복·디버거 단절·i18n 위험. | perf-cost framing은 측정 부재로 약화(human-cost framing은 유지), settings_model + interpreter probe 워밍업 인정, file_state 이관 반대를 ROI framing으로 약화. | Track H2 Python 내부 응집(8경로 ~1300 LOC), 디코더 Rust 이관 반대, 사용자 보이는 문자열 = Python 영역. |
|
||||
| **boundary-keeper** ([POSITION](../tmp/python-thinning/POSITION_boundary_keeper.md), [RESPONSE](../tmp/python-thinning/RESPONSE_boundary_keeper.md)) | 11후보 판정 매트릭스. Wave 1.5 amend + thin shim 정량 정의 + ban-list lint 6종 + Wave 2 envelope 게이트가 *기계적* 거버넌스. | sidebar merge 거부 강도 하향(line 32 trigger 인정), diagnostics 거부 사유 정정(별 crate 신설 → 기존 위반 청산), file_state 우선순위 인상(silent corruption), PR-A 본문이 envelope 무관임 인정. | Wave 6/7 통합 신설 거부, Wave 2 envelope 게이트 절대, M1 단일진실 절대 라인, amend 절차로만 boundary 확장. |
|
||||
| **shipping-operator** ([POSITION](../tmp/python-thinning/POSITION_shipping_operator.md), [RESPONSE](../tmp/python-thinning/RESPONSE_shipping_operator.md), [SYNTHESIS](../tmp/python-thinning/SYNTHESIS_shipping_operator.md)) | risk surface = 영향 × 발견 지연 × 변경 LOC. v0.6.12+1 / v0.7.24 / v0.6.5 측정 증거. 18-PR + 데드라인 메커니즘 3-layer. | rust_ffi split을 첫 PR로(워크플로우 시범), bootstrap 청산을 PR 7로 끌어올림(거버넌스 가중치), ROI 모델 명시화, H1 transaction-level 큰 PR 인정. | 5영역 동시 이관 거부, long-lived feature flag 거부 (Layer 3 auto-revert로 강제 종료), file_state 4번째 슬롯, file_state 패리티 테스트-먼저. |
|
||||
|
||||
## 4. 합의된 거버넌스 가드레일 (PR 0에 함께 land)
|
||||
|
||||
본 plan의 **모든** 슬라이스는 다음 가드레일을 통과해야 머지된다.
|
||||
|
||||
### 4.1 Boundary doc amend (PR 0)
|
||||
|
||||
[`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md)에 다음 4개 amend 본문을 land:
|
||||
|
||||
- **A1** (`§What stays in Python` 보강 #1): 사용자 보이는 모든 문자열은 Python에 둔다. Rust ABI는 *코드/식별자*만 반환. Enum 값은 Python single source of truth, Rust ABI는 *그 값을 echo*. 새 enum variant 추가는 Python *먼저*. 위반은 Lint #4 강제.
|
||||
- **A2** (`§What stays in Python` 보강 #2): Python 측 서비스 모듈 분리(Track H2)는 허용하되 *retry/timeout/error mapping*은 모듈 분리 후에도 단일 헬퍼(`_rust_ffi`/bridge 호출 표면)로 수렴. 분산 금지. 위반은 Lint #2 강제.
|
||||
- **A3** (`§Single source of truth` 보강): helper response JSON 파서는 *Rust 단일 권한*. Python은 Rust ABI 반환을 typed wrapper로 감쌀 수만 있고, 정규식·조건 분기·필드 fallback을 직접 수행 금지. 위반은 Lint #1 강제.
|
||||
- **A4** (`§What belongs in Rust` 표 보강): `diagnostics_parser` (ruff + pyright + 향후 도구) → `sessions_native::diagnostics`. Python은 panel rendering / inline scope / path remap만.
|
||||
|
||||
또한 새 슬롯 **Wave 1.5** 추가 — Wave 1 마무리(부트스트랩 청산) + 위생 슬라이스(`_rust_ffi.py` 분할, settings_model 정규화, interpreter probe, diagnostics 청산)를 흡수.
|
||||
|
||||
### 4.2 Thin shim 정량 정의 (boundary 문서 amend)
|
||||
|
||||
> "Thin shim"의 작업 정의: 단일 모듈 ≤400 LOC + 비-shim 라인(알고리즘/조건분기/상태) ≤30% + 도메인 알고리즘 부재.
|
||||
|
||||
이 기준으로 `_rust_ffi.py` 1337 LOC는 *현 시점 위반*. PR 1–6에서 6 모듈로 split하여 통과시킨다.
|
||||
|
||||
### 4.3 Ban-list CI lint 7종
|
||||
|
||||
`scripts/lint_python_thinning.py` 신설, `.gitea/workflows/`에 등록. 활성화 시점은 슬라이스마다 다름.
|
||||
|
||||
| Lint | 룰 (요약) | 활성화 시점 |
|
||||
|---|---|---|
|
||||
| **#1** Helper response parser ban | Python 측에서 `parse_ruff` / `parse_pyright` / `parse_diagnostic` / `parse_open_outcome` / `parse_request_outcome` / `parse_response_packet` / `extract_handshake` / `payload_method_label` 시그니처 신규 금지. `_rust_ffi.py`의 thin ctypes wrapper만 허용 (본체 = `_lib.<함수>(...)` 호출 + dict 변환 1단계). | **PR 0** |
|
||||
| **#2** Python deque/Event/Lock task queue 신설 ban | `commands_*.py` 분리 모듈에서 `_*_TASK_QUEUE = deque()` / `_*_TASK_EVENT = threading.Event()` 패턴 금지. `commands.py` 본체의 기존 deque는 *callable dispatch가 Sublime UI thread에 묶여 있어* grandfather (rust-pragmatist 양보). | **PR 16c** ✅ 활성 |
|
||||
| **#2.5** Python 측 retry/timeout 분산 ban (Track H2 가드) | `commands_*.py` (Track H2 분리된 서비스 모듈)에서 `time.monotonic()` / `requests.exceptions` / `for _ in range(retries):` / `tenacity` 같은 retry/timeout 원시 직접 사용 금지. retry는 `_rust_ffi`/bridge 호출 표면에 응집. | **PR 0** (Track H2 시작 전 가드) |
|
||||
| **#3** Python `python3 -c` SSH 폴백 ban | `sublime/sessions/`에 `subprocess.*[ssh].*python3.*-c` 또는 `"python3", "-c"` literal 금지. | **PR 2** (bootstrap 청산 시) |
|
||||
| **#4** 사용자 문자열 Rust ABI 반환 ban | `rust/crates/sessions_native/src/`에서 영문 자연어 문장(3+ 어휘)을 ABI 반환에 포함 금지. 식별자 코드(int, kebab-case)만 반환. | **PR 0** |
|
||||
| **#5** Boundary inventory metasync | [boundary line 100–112](PYTHON_RUST_BOUNDARY.md) Migration inventory 표를 `planning/boundary_inventory.yml`로 single source 화. CI가 코드 LOC 임계 + 시그니처 ban-list와 cross-check. | **Wave 2.5 슬라이스** ([잔존 쟁점 #1](#6-잔존-쟁점--리더-결정) 결정 결과) |
|
||||
| **#6** PR `boundary-claim:` 헤더 필수 | 모든 이관 PR description에 `boundary-claim:` 블록(removes / delete-count / ban-list 활성화). CI 훅이 diff 검증. | **PR 0** |
|
||||
|
||||
### 4.4 데드라인 메커니즘 3-layer (이중 구현 임시 잔존 강제 만료)
|
||||
|
||||
본 plan은 short-lived dual-path만 허용. *임시 병행*이 release 사이를 넘어서 누적되지 않도록:
|
||||
|
||||
| Layer | 메커니즘 | 활성화 |
|
||||
|---|---|---|
|
||||
| **Layer 1** | PR template 필수 마커: `TEMP_DUPLICATION_UNTIL=v0.X.Y` + `DELETION_PR=#NNN`. `v0.X.Y`는 현재 + 1 minor 이내. | **PR 0** |
|
||||
| **Layer 2** | `.gitea/workflows/duplication-deadline.yml` — main HEAD에서 마커 grep + 현재 버전 비교. 만료 시 release 차단. | **PR 0** |
|
||||
| **Layer 3** | Auto-revert: `DELETION_PR=#NNN`이 같은 sprint(2주) 내 머지 안 되면 원 이관 PR 자동 revert. | **Wave 2 envelope (PR 14) land 후** — envelope 슬라이스 자체가 Layer 3로 자동 revert당하면 회귀 폭발. |
|
||||
|
||||
## 5. PR 시퀀스 (PR 0 → 16)
|
||||
|
||||
> **참조**: 슬라이스 LOC 추정은 1차 인벤토리 + 2/3라운드 검증 결과의 *관용적* 추정치. PR description의 `boundary-claim:` 블록에 정확한 라인 범위와 delete-count를 기록.
|
||||
>
|
||||
> **3라운드 SYNTHESIS 갱신점 (vs v1)**:
|
||||
> - 4명 합의된 PR 순서를 그대로 채택 (boundary-keeper SYNTHESIS §5.3).
|
||||
> - PR 13(envelope) → **PR 13a(스펙+ref impl+parity test) / PR 13b(완전 구현) 분할** — rust-maximalist 합의, spec drift 방지 가드.
|
||||
> - PR 16(PR-A 본체) 사이즈 ~860 → **~600 LOC** — connect 진행 메시지(워크플로우 안내)는 Python 유지.
|
||||
> - PR 7(bootstrap) → **PR 2로 앞당김** — 거버넌스 가중치(MUST §17–19 위반 청산)가 silent corruption 영역(file_state)보다 *기계적 청산* 우선.
|
||||
|
||||
### Wave 1.5 (위생 + Wave 1 마무리)
|
||||
|
||||
#### **PR 0 — 거버넌스 가드레일 활성화** (코드 변경 0)
|
||||
|
||||
- [`PYTHON_RUST_BOUNDARY.md`](PYTHON_RUST_BOUNDARY.md)에 amend §A–§N (외부 draft `tmp/python-thinning/AMEND_DRAFT_boundary_keeper.md` 본문) land. 핵심:
|
||||
- §A 디폴트 거버넌스 (line 5–6 enumerated list 밖은 디폴트 Rust)
|
||||
- §C Single source of truth 양방향 보강 (parser + Rust ABI 자연어 ban)
|
||||
- §D Parity test 인프라 (paired parity test PR 선행 필수)
|
||||
- §E What stays in Python 보강 (사용자 문자열 + 모듈 분리 가드)
|
||||
- §F What belongs in Rust 표 신설 4행 (diagnostics_parser, settings_normalize, interpreter_probe, abi_decoders)
|
||||
- §H Wave 1.5 행 신설 + thin shim 정량 정의
|
||||
- §I Wave 2 게이트 (PR 13a/13b 분할 명시)
|
||||
- §M 위생 라인 (`rust/crates/sessions_native/src/broker.rs:1–17` stale `#![allow(dead_code)]` + "S2.3–S2.5 not wired" docstring 제거)
|
||||
- `scripts/lint_python_thinning.py` 신설 — **Lint #1, #2.5, #4, #6 활성화**. (#2, #3은 후속 PR에서, #5는 Wave 2.5에서.)
|
||||
- `.gitea/workflows/duplication-deadline.yml` 신설 — Layer 1, 2 활성화.
|
||||
- `boundary_inventory.yml` *초안만* (Wave 2.5에서 자동화).
|
||||
|
||||
**AC**: lint가 현재 코드 베이스에서 *새 위반*은 차단, *기존 위반*은 grandfather. CI 그린. 4명 모두 amend 본문 합의 (3라운드 SYNTHESIS 도달).
|
||||
|
||||
#### **PR 1 — settings_model 정규화** (Wave 1.5 워밍업, ROI 정직화)
|
||||
|
||||
`settings_model.py` 정규화 함수(`normalize_remote_python_tool_pipeline` 등 ~80 LOC) → `sessions_settings` 신규 crate.
|
||||
|
||||
**`boundary-claim:` 헤더에 ROI 정직화 명시:** "LOC 절감 ~80, 진짜 가치는 (a) 데드라인 메커니즘 dry-run, (b) Lint #1/#6 시운전, (c) Wave 1.5 워크플로우 검증."
|
||||
|
||||
**AC**: `load_sessions_settings_from_sublime`은 Python 유지 (Sublime API 결합). 정규화 단위 테스트 패리티.
|
||||
|
||||
#### **PR 2 — Bootstrap tree/list 청산** ([Wave 1 closure](PYTHON_RUST_BOUNDARY.md))
|
||||
|
||||
`ssh_runner.py` + `python_interpreter_browser.py`의 `python3 -c` 부트스트랩 디렉토리 리스팅 제거 → `session_helper tree/list` 호출로 일원화 (~180 LOC 감소).
|
||||
|
||||
**Lint #3 동시 활성화** — 다시는 Python에 `python3 -c` 폴백이 안 들어가도록 컴파일-게이트.
|
||||
|
||||
**AC**: SSH 폴백 0건. boundary doc MUST §17–19 완전 청산.
|
||||
|
||||
#### **PR 3–7 — `_rust_ffi.py` 6 모듈 split** (코드 이동만, ROI: thin shim 위반 청산)
|
||||
|
||||
`sublime/sessions/_rust_ffi.py` (1337 LOC) → `sublime/sessions/_rust_ffi/` 패키지:
|
||||
|
||||
1. `__init__.py` (loader + AbiError + 공통 `call_string_abi`)
|
||||
2. `_workspace.py` (normalize_remote_root, workspace_cache_key)
|
||||
3. `_file_policy.py` (open_guard_reason_code, is_likely_binary, reload/save 결정, 경로 매퍼)
|
||||
4. `_tool_runtime.py` (parse_ruff_diagnostics)
|
||||
5. `_bridge_parsers.py` (envelope, response packet, handshake, error_code, mirror result)
|
||||
6. `_broker.py` (open_session, request, reset, shutdown_all, is_active, handshake, stderr_tail + outcome dataclasses)
|
||||
|
||||
**제외:** 디코더 본체 Rust 이관(`_parse_*_outcome`)은 PR 17+로 미룸 (rust-max 양보 영역). 이번 PR들은 *코드 이동만*. 각 결과 모듈 ≤400 LOC + 비-shim 라인 ≤30% (thin shim 정량 정의 통과).
|
||||
|
||||
**AC** (PR마다): import 경로만 바뀌고 동작 동일. 기존 테스트 그린. `boundary-claim:` 블록에 이동 LOC 명시.
|
||||
|
||||
#### **PR 5.5 (W1.5.0) — ~~diagnostics 파싱 중복 청산~~ (인벤토리 정정, no-op)**
|
||||
|
||||
> **상태:** 청산 대상 *없음*. plan v1.1의 "diagnostics.py:225–333 ruff 파서 삭제" 항목은 stale 인벤토리.
|
||||
>
|
||||
> **실측 결과:** ruff JSON 파싱은 *이미* Rust로 일원화된 상태(`_rust_ffi.parse_ruff_diagnostics` ← `sessions_native::ruff_diagnostics_json`). 호출자 `ssh_tool_runtime.py:97`이 stdout을 Rust로 직접 전달 → helper dicts 받아 `diagnostic_record_from_helper_dict`로 record 변환.
|
||||
>
|
||||
> **`diagnostic_record_from_helper_dict` 함수의 정체:** 그 ~110 LOC 라인 범위는 ruff 전용 파서가 *아니라* generic helper dict → typed record 변환기. 미래 pyright/다른 source도 같은 함수 사용. Python에 정당히 잔존.
|
||||
>
|
||||
> **PR 5.5의 산출물:** `boundary_inventory.yml` 정정 + 본 plan 항목 갱신. 코드 변경 0. pyright 진단 source 추가는 Wave 2 envelope land 후 별도 PR (`_rust_ffi.parse_pyright_diagnostics` 신설).
|
||||
|
||||
#### **PR 8 — interpreter probe 캐시/랭킹 이관**
|
||||
|
||||
`python_interpreter_registry.py`의 캐시·랭킹 로직 (~100 LOC) → `sessions_python_interp` 신규 crate.
|
||||
|
||||
**유지:** `_parse_probe_stdout` 정규식 ~30 LOC는 Python에 유지 (rust-max 양보 영역, ROI 낮음). 상태바 키 바인딩도 Python.
|
||||
|
||||
**AC**: 캐시 동작 동일. 회귀 테스트 (`tests/test_python_interpreter_registry.py` 기준).
|
||||
|
||||
#### **PR 9 — tree/list 잔여 호출자 정리**
|
||||
|
||||
PR 2 청산 후 잔여하는 Python 측 tree/list 호출자(현재 인벤토리 시점에 ssh_runner.py가 일부, python_interpreter_browser.py가 일부)의 helper 채널 호출 일원화.
|
||||
|
||||
**AC**: SSH 폴백이 다시 들어올 코드 경로 0개. lint #3 위반 0건.
|
||||
|
||||
#### **PR 10 — file_state 패리티 테스트 (테스트-먼저)** [silent corruption 영역]
|
||||
|
||||
기존 `evaluate_open_file` / `evaluate_save_file` / `kind_codes` 매핑에 대해 *Python 동작 baseline* 패리티 테스트 추가. 이관 PR 11이 이를 깨지 않음을 보장. amend §D 적용.
|
||||
|
||||
**AC**: 테스트가 Python 현 동작 그대로 fixture화. ≥30 시나리오 (open/save/conflict/binary).
|
||||
|
||||
#### **PR 11 — file_state 결정 매핑 이관**
|
||||
|
||||
`file_state.py`의 `kind_codes` 3중 복제 통합 + Python ↔ Rust 결정 매핑 정리 (~120 LOC 감소). SaveConflict.message 등 사용자 보이는 문자열은 Python single source 유지 (amend §C/§E).
|
||||
|
||||
**AC**: PR 10 패리티 테스트 100% 그린. `boundary-claim: removes ~120 LOC`.
|
||||
|
||||
#### **PR 12 — eager_hydrate 패리티 테스트 (테스트-먼저)**
|
||||
|
||||
amend §D 적용.
|
||||
|
||||
### Wave 2 게이트 — PR 13a/13b가 게이트, 이 라인 *후*에만 PR 14+ 진행
|
||||
|
||||
#### **PR 13a — Multiplex envelope 스펙 + reference impl + parity test**
|
||||
|
||||
`session_protocol`에 `v` / `channel` / `kind` / `body` envelope **스펙 확정** + 최소 reference impl + parity test 1개. 본 PR이 envelope의 *spec freeze*. 이 PR 머지 *후*에만 PR-A 본체(PR 16) 가능 — supervisor API가 envelope 표준에 정합하게 빚어진다는 보장.
|
||||
|
||||
**AC**: backward-compat. 기존 NDJSON 메시지 통과. parity test 그린. spec drift 방지 — 본 PR 외부에서 envelope 필드 추가/변경 금지.
|
||||
|
||||
#### **PR 13b — Multiplex envelope 완전 구현**
|
||||
|
||||
PR 13a 위에 채널 supervisor + per-request timeout + 취소·deadline 의미 land. 13a/13b 분할은 rust-maximalist의 envelope spec drift 가드.
|
||||
|
||||
**AC**: 새 멀티플렉스 케이스 unit + integration test. cancel 의미가 helper에 도착(boundary doc gap 1번 부분 해소).
|
||||
|
||||
#### **PR 14 — eager_hydrate BFS 이관**
|
||||
|
||||
`eager_hydrate.py`의 placeholder BFS + 배치 페이싱 → `local_bridge::remote_cache_mirror` 통합. 결과 보고만 Python 유지 (~180 LOC 감소).
|
||||
|
||||
**AC**: PR 12 패리티. 성능 비교 (Python 기준 동등 이상). multiplex envelope 위에서 동작.
|
||||
|
||||
#### **PR 14.5 — H1 file_open transaction**
|
||||
|
||||
[BACKLOG H1](BACKLOG.md) — file_open을 단일 transaction으로 묶어 silent corruption 차단. transaction-level 큰 PR 인정 (shipping-operator 양보).
|
||||
|
||||
**AC**: 기존 silent-corruption 시나리오 회귀 테스트 5종 그린.
|
||||
|
||||
#### **PR 15 — H3-reconnect (auto-reconnect thread + connect SM token)**
|
||||
|
||||
[BACKLOG H3](BACKLOG.md) first-PR scope. (a) auto-reconnect thread → broker driven, (b) `_CONNECT_GENERATION`/`_CONNECT_INFLIGHT` token 의미만 Rust로. 워크플로우 진행 메시지(사용자 보이는 문자열)는 Python 유지 (amend §C enum 정합 + amend §E 사용자 문자열).
|
||||
|
||||
**AC**: BACKLOG H3 first-PR scope 안. PR-A 분리의 전제 충족.
|
||||
|
||||
#### **PR 15.5 — PR-A integration tests (테스트-먼저)**
|
||||
|
||||
3개 신설 integration test:
|
||||
- `test_orchestrator_supervisor.py` (≥30 케이스 — Rust supervisor 안 Python callable invariant)
|
||||
- `test_connect_preempt_property.py` (proptest 5,000회 — connect generation/preempt 의미)
|
||||
- `test_orchestrator_python_panic.py` (M1 invariant 회귀 — Rust supervisor 안 Python callable raise → trace event + 후속 task 정상 dispatch)
|
||||
|
||||
amend §D paired parity test 의무. PR-A 본체 land 전 단독 PR로 머지.
|
||||
|
||||
#### **PR 16 — PR-A 본체: queue/dispatcher/lane gating Rust 이관** (~600 LOC)
|
||||
|
||||
queue/dispatcher/lane gating + `_CONNECT_GENERATION` token 의미 + `_connect_generation_is_stale` → `sessions_orchestrator` 신규 crate. Python 측 deque/Lock/Event/dropped 추적/generation token/preempt SM 전부 *삭제*. 사용자 보이는 워크플로우 진행 메시지는 PR 15 양보 영역으로 Python 유지 → 사이즈 ~860 → ~600.
|
||||
|
||||
**Lint #2 동시 활성화** — 다시는 Python에 deque 기반 task queue가 안 생김.
|
||||
|
||||
**AC**: PR 15.5 테스트 100% 그린. v0.7.24 `disciscard`-class 오타 회귀 시 cargo check가 즉시 차단. M1 invariant `catch_unwind` 격리 검증.
|
||||
|
||||
### PR 17+ — 본 plan scope 밖 (별도 갱신)
|
||||
|
||||
PR 16(PR-A) land 후 본 plan을 갱신해서:
|
||||
- **PR 17 / PR-B** ✅ `9691726` — eager_hydrate apply pass body → `sessions_native::eager_hydrate::run_apply_pass`. Python driver 삭제(`run_eager_hydrate`/`batched`/`EagerHydrateSummary`); 1 Rust round-trip per pass + Python sidecar 쓰기.
|
||||
- **PR 18 / H3-queue 본 이관** ⏸ **architectural blocker** — callable dispatch가 Python 잔존(rust-pragmatist 양보 영역, PR 16c Lint #2 grandfather)이라 deque 본체를 Rust로 옮기려면 PyO3 callback registry가 필요. `_BACKGROUND_PENDING_KEYS` / `_BACKGROUND_INFLIGHT_KEYS` 같은 dedup state만 옮기는 부분적 이관은 critical section 안 FFI cost를 추가하고 LOC 절감은 ~30 LOC로 한계 — 가성비 낮음. 잔존 쟁점 #8 (PyO3 ADR) 결정 시점에 재평가.
|
||||
- **PR 19 / `_rust_ffi` 디코더 Rust 이관** ⏸ — `_parse_open_outcome` / `_parse_request_outcome` 만 잔존(~30 LOC). 현 구현은 *이미* Rust ABI에서 받은 JSON을 typed dataclass로 wrap만 함. 완전 이관에는 C 태그드 유니온 또는 PyO3 — 잔존 쟁점 #8과 묶여 PR 18과 동일한 ADR 의존.
|
||||
- **H2-save / H2-connect**: BACKLOG H2 분할 (Track H2 main track 흡수, *병행* — main track 이관 saturate 후 가시 LOC 절감을 위한 다음 슬라이스).
|
||||
- **데드라인 Layer 3** auto-revert 활성화
|
||||
|
||||
**현 시점 상태:** main track 이관(책임 위치를 Rust로) 의 high-impact 슬라이스는 PR 0–17에서 모두 land. 잔여 PR 18/19는 PyO3 ADR 결정에 묶임. Track H2 (Python 내부 응집 — 파일 분할)이 다음 가시 가치 슬라이스.
|
||||
|
||||
이 시점에 commands.py 예상 LOC: 7394 - (worker loop ~550) - (connect SM ~330 부분) - (hydrate preflight ~300, PR 12–14 영향) ≈ **5500–6000 LOC**.
|
||||
|
||||
> **rust-maximalist의 "2000 LOC 미만" 목표는 본 plan scope 안에서는 미달성.** 그가 1라운드에서 인정한 도전 질문(Wave 5 후 5000+ LOC 잔존) 그대로다. 본 plan은 *책임 위치* 정상화에 집중하고, *파일 분할*은 Track H2(Python 내부 응집)에서 별도 진행.
|
||||
|
||||
### Track H2 (Python 내부 응집) — *병행 트랙*
|
||||
|
||||
main track과 *별개로* 진행:
|
||||
- `commands_runtime_queue.py`, `commands_sidebar_mirror.py`, `commands_connect.py` 등 추출 — **amend §E 모듈 분리 가드 적용** (retry/timeout/error mapping 분산 금지). 위반은 Lint #2.5가 차단.
|
||||
- `_rust_ffi/` split (PR 3–7)이 이미 패턴 시범.
|
||||
- `kind_codes` 3중 복제 통합 (PR 11에 흡수).
|
||||
|
||||
main track과 충돌 시 main track 우선. Track H2는 *코드 이동* 위주, 책임 위치는 변하지 않음.
|
||||
|
||||
## 6. 잔존 쟁점 — 리더 결정
|
||||
|
||||
3라운드 SYNTHESIS까지 도달 후 미합의 7개에 대한 리더 판단. 모두 약한 선호 영역이며 plan 진행을 막지 않음.
|
||||
|
||||
| # | 쟁점 | 리더 결정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| 1 | **Lint #5 (boundary inventory metasync YAML)** PR 0 vs Wave 2.5 | **PR 0에 *수동* YAML 초안 + 시그니처 cross-check만, *자동 LOC 측정*은 Wave 2.5**. boundary-keeper SYNTHESIS 합의안. | 자동 LOC 게이트는 비용·노이즈 추정 어려움. 수동 YAML + 시그니처 cross-check만으로도 PR 1–14 거버넌스 추적 가능. |
|
||||
| 2 | **PR 16 (PR-A 본체) 사이즈** | **~600 LOC (워크플로우 진행 메시지 Python 유지)**, PR 15.5 paired test PR 강제. | rust-maximalist 3라운드 SYNTHESIS 정정 (~860 → ~600). amend §D paired parity 의무. |
|
||||
| 3 | **Enum 정책** Python single source vs parity test | **Python single source + Rust echo (amend §C)**. parity test는 보조 안전망. | python-pragmatist §5 + boundary-keeper §1.3 + rust-maximalist 양보. 사용자 보이는 문자열 i18n/UX 일관성. |
|
||||
| 4 | **diagnostics 청산 위치** | **PR 5.5 (rust_ffi split 후속, bootstrap 청산 후)**. boundary-keeper SYNTHESIS 합의. | PR 0 Lint #1이 추가 위반 차단 중. 실제 코드 삭제는 워크플로우 안정 후가 안전. silent corruption 영역인 file_state(PR 10/11)가 가중치 우선. |
|
||||
| 5 | **Track H2 (Python 내부 응집 ~1300 LOC) 대체 vs 병행** | **병행 (main track과 별개)**. Lint #2.5가 가드. | rust-maximalist는 책임 위치, python-pragmatist는 파일 정리 — 다른 axis. main track 우선. |
|
||||
| 6 | **Wave 5 재확인 amend 형태** (a 유지 / b 삭제 / c 일반화) | **(c) 일반화** — boundary-keeper 약한 선호 채택. | chat→tmux pivot으로 #29 product 위치 흔들림. plan v2 갱신 시점에 "diff/agent apply 단계는 Wave 2 envelope·취소·캐시 위에서 정의되며, product surface는 후속 결정"으로 일반화. |
|
||||
| 7 | **lsp_proxy crate 신설 시점** Wave 1.5 vs Wave 2.5 | **Wave 2.5 (envelope·취소·deadline land 후)** — boundary-keeper 약한 선호 채택. | boundary doc line 45가 부분 normative. envelope 합의 *전*에 lsp_proxy를 신설하면 envelope 표준을 lsp_proxy가 *암묵 결정*. `local_bridge::lsp_stdio` 모듈 확장이 신설 crate보다 정합. |
|
||||
| 8 | **Rust schema 자동화 도구** (`serde + schemars` vs `PyO3 + pythonize`) | **PR 17+ 결정 — 본 plan scope 밖**. `_parse_*_outcome` 디코더 Rust 이관 시점에 별도 ADR. | rust-maximalist 3라운드 잔존 쟁점. PR 1–16 진행에는 영향 없음. |
|
||||
|
||||
## 7. 성공 기준 (Acceptance Criteria — plan 전체)
|
||||
|
||||
- ✅ `_rust_ffi.py` 1337 LOC → `_rust_ffi/` 패키지 (각 모듈 ≤400 LOC). thin shim 정량 정의 통과.
|
||||
- ✅ `python3 -c` SSH 폴백 0건. Lint #3 그린.
|
||||
- ✅ `commands.py` worker loop + connect SM token + queue → `sessions_orchestrator`. Python 측 deque/Event/Lock 기반 task queue 0건. Lint #2 그린.
|
||||
- ✅ Helper response 파싱 = Rust 단일 권한. Lint #1 그린.
|
||||
- ✅ Wave 2 envelope (`v`/`channel`/`kind`/`body`) land. Wave 3+ 후속 가능.
|
||||
- ✅ 사용자 보이는 모든 문자열은 Python에 응집. Rust ABI는 식별자만. Lint #4 그린.
|
||||
- ✅ 모든 이관 PR이 `boundary-claim:` 헤더 + Layer 1/2 데드라인 마커 통과.
|
||||
- ✅ 회귀: 최근 6개월 사례(v0.6.12 #13/#14, v0.7.24 `disciscard`, v0.6.5 palette 누락)와 같은 종류 회귀 0건. Cluster A LSP race가 본 plan으로 *도입되지 않음*.
|
||||
|
||||
추정 Python LOC 변화 (PR 0 → PR 15 완료 시점):
|
||||
- 삭제: settings_model 정규화 ~140 (PR 1) + file_state 매핑 ~120 (PR 11) + worker queue + connect token ~530 (PR 16) + eager_hydrate ~180 (PR 14) ≈ **~970 LOC**
|
||||
- bootstrap 180은 PR 2 시점에 *이미* 청산된 상태였음 (plan stale).
|
||||
- diagnostics 110은 PR 5.5 시점에 *이미* Rust 일원화된 상태였음 (plan stale).
|
||||
- 이동 (책임 위치 미변경): `_rust_ffi.py` 1337 → `_rust_ffi/` 6 모듈 (총 LOC 비슷)
|
||||
- Track H2 추가 정리: 별도 ~1300 LOC 절감 (병행)
|
||||
- Sublime/sessions 합산 23437 → ~21000 (main track) → ~19700 (Track H2 포함)
|
||||
|
||||
LOC 자체는 절대 metric이 아니다. **인지 부담 metric**: 한 화면에 안 들어오는 모듈 개수 / 한 책임당 평균 파일 수 / 한 PR description의 "Python 측 변경" 평균 LOC가 줄어드는 것이 진짜 목표.
|
||||
|
||||
## 8. 다음 단계
|
||||
|
||||
1. 본 plan을 사용자 검토.
|
||||
2. PR 0 — 거버넌스 가드레일 PR 작성 (코드 변경 0, boundary doc amend + lint 스크립트 + workflow YAML).
|
||||
3. PR 1부터 순서대로 진행. 각 PR이 머지될 때 본 문서 §5 표 갱신.
|
||||
4. PR 14 (Wave 2 envelope) land 직후 본 plan v2 작성 — PR 16+ 슬라이스 정식화.
|
||||
|
||||
## 9. 참조 — 팀 산출물
|
||||
|
||||
- `tmp/python-thinning/SHARED_CONTEXT.md` — 4명 공통 입력 자료.
|
||||
- `tmp/python-thinning/POSITION_*.md` — 1라운드 입장 paper (4건).
|
||||
- `tmp/python-thinning/RESPONSE_*.md` / `RESPONSES_*.md` — 2라운드 도전 답변 (4건).
|
||||
- `tmp/python-thinning/SYNTHESIS_*.md` — 3라운드 합의 매트릭스 (3건: shipping-operator / boundary-keeper / rust-maximalist).
|
||||
- `tmp/python-thinning/AMEND_DRAFT_boundary_keeper.md` — **PR 0이 그대로 pull 가능한 boundary doc amend 통합 본문** (§A–§N 13개 섹션).
|
||||
@@ -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
|
||||
669
planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md
Normal file
669
planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md
Normal file
@@ -0,0 +1,669 @@
|
||||
# Review-driven plan (v0.6.4 → v0.7+)
|
||||
|
||||
External adversarial review of the repo, structured into three layers:
|
||||
|
||||
1. **Distribution readiness** — what gates broad / public release.
|
||||
2. **Feature priority** — what to finish before adding more breadth.
|
||||
3. **Architecture** — Python ↔ Rust ownership migration strategy.
|
||||
|
||||
This plan distils the review into actionable items with status,
|
||||
acceptance criteria, and file/code pointers so each can be picked up
|
||||
without re-reading the full review.
|
||||
|
||||
> Review verdict: "강한 내부 알파/베타로는 괜찮지만, 일반 공개 / 회사
|
||||
> 표준 배포는 보류. 기술 친화적 제한 베타로는 추천." Five blockers
|
||||
> before broad distribution; #29 + #32 are the most important open
|
||||
> features; ownership migration (not helper migration) is the
|
||||
> correct architectural next step.
|
||||
|
||||
## Direction correction (post-review, 2026-04-25)
|
||||
|
||||
The review took repo state as ground truth and ranked features
|
||||
accordingly. After re-reading, the maintainer flagged two divergences
|
||||
between the review's prioritization and the actual product direction:
|
||||
|
||||
1. **#29 diff-centric review/apply is abandoned, not "the most
|
||||
important open feature".** The pivot from chat-style agent UI
|
||||
("show the proposed diff inline, user approves hunks") to
|
||||
tmux-session-passthrough ("agent runs in a tmux pane, edits the
|
||||
remote files directly, Sessions just owns layout + lifecycle")
|
||||
was an explicit product decision: drop diff review, keep
|
||||
multi-session. The diff-centric primitives that survived the
|
||||
pivot — `agent_proposal_watcher` (290 LOC, unified diff parser),
|
||||
`agent_change_badge` (248 LOC, post-apply phantom renderer) —
|
||||
are dead code from the abandoned design. Issue #29 stays open
|
||||
on the tracker but is **not** the product's next feature.
|
||||
|
||||
2. **The persistent-terminal flow always opens
|
||||
`sessions-term-<host>`**, which means a user with their own
|
||||
long-lived `tmux new-session -A -s work` on the remote can't
|
||||
reach it via the palette. The current "single Sessions-owned
|
||||
tmux session per host" model is too narrow — discovery + attach
|
||||
for foreign tmux sessions is missing.
|
||||
|
||||
Environment constraints the maintainer has flagged as binding:
|
||||
|
||||
- **No cross-platform CI runners.** §1.4 macOS / Windows smoke CI
|
||||
is not feasible in the current Gitea Actions environment.
|
||||
- **No code-signing budget.** Apple Developer Program + Windows EV
|
||||
cert (~$600/yr combined) is out of scope right now.
|
||||
|
||||
Effects on this plan:
|
||||
|
||||
- §2.1 (was #29 diff-centric MVP) → moved to "Items
|
||||
DEPRIORITIZED / dropped" with the rationale above.
|
||||
- New §2.1 → tmux session discovery + attach (the actual gap the
|
||||
maintainer is feeling).
|
||||
- `agent_proposal_watcher.py` and `agent_change_badge.py` →
|
||||
marked as removal candidates in a new "code to consider
|
||||
retiring" section. Don't delete unilaterally; needs maintainer
|
||||
signoff.
|
||||
- §1.4 cross-platform smoke CI + signing → marked
|
||||
`[blocked-by-environment]` until runners + cert budget exist.
|
||||
- §1.1 per-platform sublime-package → same: Linux-only is
|
||||
feasible now if desired, full matrix is blocked.
|
||||
|
||||
## Status legend
|
||||
|
||||
- `[done @ <commit>]` — landed, commit ref noted.
|
||||
- `[partial]` — first cut shipped, follow-up scope captured.
|
||||
- `[plan]` — captured here; pick up later.
|
||||
- `[needs-input]` — needs maintainer decision before scoping.
|
||||
|
||||
---
|
||||
|
||||
## Layer 1 — Five must-haves before broad distribution
|
||||
|
||||
### 1.1 `Sessions.sublime-package` install bundle `[plan]` (Linux only feasible now)
|
||||
|
||||
**Review:** "지금 상태는 설치 경험이 너무 개발자 중심입니다." A normal
|
||||
user shouldn't have to `git clone + cargo build`. Ship a single-file
|
||||
install (`Sessions-<platform>.sublime-package`) per platform.
|
||||
|
||||
**State today:** `scripts/build_sublime_package.py` already builds the
|
||||
package + can bundle platform-tagged Rust binaries
|
||||
(`--bundle-built-rust-binaries --rust-platform-tag <tag>`). Three
|
||||
pieces missing:
|
||||
1. CI wiring — workflow doesn't call `build_sublime_package.py` yet.
|
||||
2. Signing alignment — `sign_release_artifacts.py` only knows about
|
||||
binaries (`ARTIFACT_CANDIDATES`); needs an `--extra-asset` so the
|
||||
`.sublime-package` joins SHA256SUMS and users can verify the
|
||||
install bundle through the same `gpg --verify` flow.
|
||||
3. Cross-platform matrix → blocked by §1.4.
|
||||
|
||||
**Acceptance (Linux-only first iteration, feasible now):**
|
||||
- Extend `sign_release_artifacts.py` to accept additional inputs
|
||||
(`--extra-asset`), include them in SHA256SUMS.
|
||||
- Add CI steps between the release-workspace build and the sign
|
||||
step: `build_sublime_package.py --bundle-built-rust-binaries
|
||||
--rust-platform-tag linux-x86_64 --output
|
||||
dist/v$VER/Sessions-linux-x86_64.sublime-package` →
|
||||
`sign_release_artifacts.py --extra-asset Sessions-linux-x86_64.sublime-package`
|
||||
→ existing `create_gitea_release.py` picks it up automatically
|
||||
from `dist/v$VER/`.
|
||||
- README "install" section gains a short "Linux: drop the
|
||||
`.sublime-package` into `~/.config/sublime-text/Installed Packages/`"
|
||||
path alongside the existing dev-checkout path.
|
||||
|
||||
**macOS / Windows packages:** wait on §1.4 unblock.
|
||||
|
||||
### 1.2 Safe-by-default profile (defaults flip) `[plan]`
|
||||
|
||||
**Review:** Mirror policy is bounded but defaults are still
|
||||
aggressive: `auto_open_remote_folder=true`,
|
||||
`mirror_auto_refresh=true`, `mirror_include_files=true`,
|
||||
`mirror_max_traversal_depth=12`,
|
||||
`mirror_prune_stale_cache=true`. Security-sensitive orgs and large
|
||||
workspaces want a calmer first-experience.
|
||||
|
||||
**Proposal:** Don't just add another knob. See §2.3 — "sync mode as
|
||||
product feature" subsumes this. Keep here only as a cross-link.
|
||||
|
||||
**Acceptance:** §2.3 lands → 1.2 retires.
|
||||
|
||||
### 1.3 Agent / Jupyter / remote installer as experimental / admin-enabled `[plan]`
|
||||
|
||||
**Review:** "managed remote extension catalog가 `curl ... | bash`로
|
||||
Claude Code 설치, `npm install -g @openai/codex`, `pip install
|
||||
--user`로 Jupyter/debugpy/pyright 설치를 포함... 제품이 '원격 코드
|
||||
편집기'를 넘어 '원격 툴 설치 관리자'로 보이게 만듭니다. broad
|
||||
distribution이라면 이 install surface는 최소한 명시적 동의, 관리자
|
||||
opt-out, 기능 플래그 뒤로 숨기는 게 맞습니다."
|
||||
|
||||
**Proposal:** Three-tier palette gating layered onto §3 (palette
|
||||
split):
|
||||
- **Core** (always visible): connect / open / select interpreter
|
||||
/ open terminal / show agent switcher.
|
||||
- **Advanced** (`sessions_show_advanced_commands`, default `true`
|
||||
today, target `false` for v0.7 broad-release): everything else
|
||||
in current palette EXCEPT items below.
|
||||
- **Installer surface** (`sessions_remote_extension_install_enabled`,
|
||||
default `true` today, target opt-in for v0.7): "Install Remote
|
||||
Extension", "Remove Remote Extension", "Remote Extension Status".
|
||||
When `false`, palette still shows them but invocation displays a
|
||||
one-shot consent dialog naming the exact remote commands that
|
||||
will run; "Always allow on this host" sets a per-host flag in
|
||||
`workspace_state`.
|
||||
- **Dev** (`sessions_show_dev_commands`, default `false` —
|
||||
`[done @ 280d105]`): "Preview Remote Agent Payload" and any
|
||||
future debug command.
|
||||
|
||||
**Acceptance:** New settings + per-host opt-in registry. Tests assert
|
||||
visibility for each known palette caption under the four
|
||||
setting-combination matrices. `SECURITY.md` gains an appendix listing
|
||||
every remote install command + what it touches.
|
||||
|
||||
### 1.4 macOS / Windows smoke CI + platform code signing `[blocked-by-environment]`
|
||||
|
||||
**Review:** "README는 Linux/macOS/Windows 지원으로 적지만, CI는 모든
|
||||
핵심 job이 ubuntu-latest에서 돌고... broad distribution 전에는 최소한
|
||||
macOS + Windows smoke CI가 필요합니다." Plus "binaries are currently
|
||||
unsigned" — broad release needs Apple Developer ID + Windows
|
||||
Authenticode.
|
||||
|
||||
**Status (2026-04-25 maintainer note):** Both blocked by environment,
|
||||
not by design or code:
|
||||
- The Gitea Actions setup running this repo doesn't have macOS or
|
||||
Windows runners. Adding them is an infrastructure decision outside
|
||||
this plan's reach.
|
||||
- Code-signing certs (~$600/yr) are not in budget.
|
||||
|
||||
**What is feasible without runners / certs:**
|
||||
- Local build verification: maintainers running on macOS / Windows
|
||||
can `cargo build` + `python -m compileall` + import smoke locally
|
||||
before tagging. Document this as a release-time manual gate in
|
||||
the next per-version repro checklist (the v0.6.5-specific one
|
||||
was retired with the Track D residue cleanup; replace as needed).
|
||||
- Document the "currently Linux-only signed bundle" reality in
|
||||
README + SECURITY.md so external users aren't surprised by the
|
||||
asset list.
|
||||
|
||||
**When unblocked:**
|
||||
- New `.gitea/workflows/cross-platform-smoke.yml` with matrix
|
||||
`[ubuntu-latest, macos-latest, windows-latest] × [build + smoke]`.
|
||||
- Per-platform `Sessions-<platform>.sublime-package` published.
|
||||
- macOS notarization + Windows Authenticode signing pipelines lift
|
||||
credentials from CI secrets (master never on contributor
|
||||
workstations).
|
||||
|
||||
### 1.5 Stabilize release discipline `[plan]`
|
||||
|
||||
**Review:** "stable release로 보이는 v0.6.2 페이지가 Some checks
|
||||
failed 상태를 노출하고 있고... 외부 사용자는 '빠르게 변하고 아직
|
||||
흔들리는 제품'으로 읽게 됩니다. stable channel과 dev/nightly channel을
|
||||
분리하는 게 좋습니다."
|
||||
|
||||
**Proposal:**
|
||||
- Tag protocol: `v0.X.Y` continues as the iteration channel.
|
||||
- New rolling `vX.Y-stable` tag updates only after a manual macOS +
|
||||
Windows + Linux test pass against a candidate `v0.X.Y`.
|
||||
- Release notes for unstable tags explicitly link the latest stable
|
||||
tag (or "no matching stable yet").
|
||||
- New `scripts/promote_stable.py` that signs the rolling tag and
|
||||
pushes; only run from a maintainer workstation.
|
||||
|
||||
**Acceptance:** `SECURITY.md` verification command updated to use the
|
||||
stable tag. README "current focus" header marks the latest stable
|
||||
prominently. CI green before promotion is enforced by
|
||||
`promote_stable.py` (it inspects the workflow run history).
|
||||
|
||||
---
|
||||
|
||||
## Layer 2 — Feature priority (finish vs. add breadth)
|
||||
|
||||
> Review: "breadth보다 완결성. #29와 #32가 그대로 열려 있는데 주변
|
||||
> 기능을 더 늘리면 제품 중심이 흐려질 가능성이 큽니다."
|
||||
|
||||
### 2.1 Tmux session discovery + attach to foreign sessions `[plan]` — **highest priority**
|
||||
|
||||
**Maintainer-flagged gap (2026-04-25):** "영구 terminal 구현을 위해서
|
||||
아예 default로 tmux를 열다보니까 기존 다른 tmux에 연결할 수 없는건
|
||||
단점." Today every terminal flow opens / attaches the
|
||||
Sessions-owned `sessions-term-<host>` (or numbered children); a user
|
||||
running their own `tmux new-session -A -s work` on the remote can't
|
||||
reach it via the palette.
|
||||
|
||||
**Proposal:**
|
||||
- New palette command `Sessions: Attach to Tmux Session`. Lists ALL
|
||||
remote tmux sessions (no `sessions-term-*` filter — Sessions-owned
|
||||
show alongside foreign), opens a Terminus pane attached to the
|
||||
picked one via `ssh -tt <alias> tmux attach -t <session_name>`.
|
||||
Read-only attach; Sessions never tries to kill / write-back to a
|
||||
foreign session.
|
||||
- The existing "Sessions: Open Remote Terminal" / "New Remote
|
||||
Terminal Pane" / "Kill Remote Terminal" stay scoped to the
|
||||
`sessions-term-*` namespace (so users can still kill Sessions's
|
||||
own sessions without risk of clobbering a foreign one).
|
||||
- Optional follow-up: setting `sessions_terminal_default_tmux_session`
|
||||
to override the default `sessions-term-<alias>` → user names. If
|
||||
set, "Open Remote Terminal" attaches there instead. Out of scope
|
||||
for the first cut; revisit after the attach command lands.
|
||||
|
||||
**Acceptance:** New `SessionsAttachRemoteTmuxCommand` registered via
|
||||
`plugin.py`. New palette entry. New supporting function
|
||||
`list_all_remote_tmux_sessions(host_alias)` in `terminal_tmux_session.py`
|
||||
(parallel to the existing `list_remote_terminal_sessions` which
|
||||
filters to Sessions-owned). Tests cover: empty list, mixed
|
||||
foreign + Sessions-owned, error path, attach command argv shape.
|
||||
|
||||
### 2.2 #32 large-file streaming + cancel + finalize `[plan]`
|
||||
|
||||
**State today:** `file/read` is fundamentally a full-file pull. Big
|
||||
files / high-latency links hit the 45s timeout budget; user sees
|
||||
"Sessions disconnected: Rust bridge request timed out". Issue body
|
||||
already scopes the contract.
|
||||
|
||||
**Proposal:** Move the streaming contract into Rust
|
||||
(`local_bridge` + `session_helper` boundary). Python keeps "show
|
||||
progress + final open / reload"; everything else (chunk read, cancel
|
||||
on view close, progressive finalization, small-file fast path
|
||||
preserved) lives in Rust.
|
||||
|
||||
**Acceptance:** `file/read` extended to optionally chunked
|
||||
delivery; `file/read.cancel` available; `local_bridge` orchestrates
|
||||
chunks + finalize + cancellation. Tests cover the small-file fast
|
||||
path unchanged + chunked delivery + mid-stream cancel. Closes #32.
|
||||
|
||||
### 2.3 Sync safety as product mode (subsumes §1.2) `[plan]`
|
||||
|
||||
**Review:** "사용자가 고를 수 있는 sync mode를 제품 기능으로." Three
|
||||
named modes:
|
||||
|
||||
- **Safe browse**: shallow only (depth ≤2), no auto-refresh, no
|
||||
file include, manual deferred-dir expand. For browsing huge
|
||||
remotes / EDR-sensitive orgs.
|
||||
- **Balanced** (current default behavior, named): shallow first +
|
||||
deepen + auto-refresh + bounded file include.
|
||||
- **Materialized workspace**: depth=12, full file include, auto
|
||||
prune. For small / familiar workspaces.
|
||||
|
||||
**First-connect heuristic:** issue a shallow estimate before
|
||||
choosing a default. If top-level fanout > N or estimated total
|
||||
entries > M, recommend Safe browse with a one-shot dismissable
|
||||
hint.
|
||||
|
||||
**Acceptance:** `sessions_sync_mode: "safe" | "balanced" |
|
||||
"materialized"` setting. Connect flow runs estimate, recommends
|
||||
mode in the connect-progress panel. Mode setting overrides the
|
||||
existing per-knob caps. Tests assert each mode resolves to the
|
||||
documented caps.
|
||||
|
||||
### 2.4 Extension probe TTL cache + save-time self-write suppression `[partial]`
|
||||
|
||||
**Review:** "BACKLOG Track B/M에 따르면 Remote Extension Status는
|
||||
지금도 catalog entry마다 원격 probe를 날려서 느리고, install panel도
|
||||
느리며, save-time `ruff format` 같은 비동기 포맷팅은 사용자가 다른
|
||||
버퍼로 옮겼을 때 'file changed on disk' prompt를 유발할 수 있습니다."
|
||||
|
||||
**State today:**
|
||||
- Save self-cooldown shipped in v0.6.2 (5s window, time-based).
|
||||
- Probe cache: not implemented. Every `Remote Extension Status`
|
||||
invocation issues N parallel SSH probes.
|
||||
|
||||
**Proposal:**
|
||||
- **Probe TTL cache** `[plan]`: per-workspace, key = `(host_alias,
|
||||
spec_id)`, value = `(state, timestamp)`. Default TTL 60s. New
|
||||
`Sessions: Refresh Remote Extension Status` palette command
|
||||
invalidates the cache for the active workspace.
|
||||
- **Hash-based self-write suppression** `[plan]`: extend the 5s
|
||||
cooldown with content-hash tracking. After save, record
|
||||
`(remote_path, sha256(local_bytes))`; subsequent
|
||||
inotify-driven reload prompts whose remote stat → hash matches
|
||||
the recorded hash are silently dropped (regardless of the 5s
|
||||
window). Restores normal reload prompt for genuine external
|
||||
edits.
|
||||
|
||||
**Acceptance:** Probe count for `Remote Extension Status` drops to
|
||||
zero on cache hit; status panel renders in <100ms post-cache.
|
||||
Save → external editor edit on remote → reload prompt fires
|
||||
correctly within 1s.
|
||||
|
||||
### 2.5 `.sublime-project` LSP command leakage `[plan]`
|
||||
|
||||
**Review:** "Sessions가 LSP `command` argv를 settings.LSP.<client>.command에
|
||||
흘려 써서 bridge path나 socket 이름 같은 내부 경로가 프로젝트 파일에
|
||||
노출됩니다." User-visible during the v0.6.4 macOS test pass —
|
||||
"복잡한 config 숨기기로 하지 않았나."
|
||||
|
||||
**Proposal:**
|
||||
- Project file stores only Sessions-owned sentinels:
|
||||
```json
|
||||
"LSP": {
|
||||
"LSP-pyright": {
|
||||
"enabled": true,
|
||||
"sessions_remote_stdio_managed": true,
|
||||
"settings": { ...user-visible only... }
|
||||
}
|
||||
}
|
||||
```
|
||||
- Actual `command` argv (with `--bridge-socket`,
|
||||
`--workspace-id`, `--lsp-local-uri-prefix`,
|
||||
`--lsp-remote-uri-prefix`) lives in in-memory state, computed
|
||||
fresh at each LSP attach.
|
||||
- LSP package's "load command from project file" path stays
|
||||
intact — Sessions intercepts the attach via existing
|
||||
`lsp_project_wiring` and substitutes the live argv.
|
||||
|
||||
**Acceptance:** `.sublime-project` post-connect contains only the
|
||||
sentinel + user-visible settings. Reconnect / restart still
|
||||
resolves the right command. Tests cover the round-trip
|
||||
(project file write → reload → attach with new socket).
|
||||
|
||||
### 2.6 Windows W1: PersistentBroker for Windows `[plan-MVP, foundation laid]`
|
||||
|
||||
**Why:** Maintainer (2026-04-25) flagged Windows as the likely
|
||||
largest user fraction, so the Windows-blank PersistentBroker is a
|
||||
real product gap rather than a backwater.
|
||||
|
||||
**Foundation (this batch):** `interprocess` 2.4.2 swap PoC validated
|
||||
on macOS — broker server side now uses `IpcListener` /
|
||||
`IpcStream` (cross-platform `LocalSocketListener`), keeping the
|
||||
`#[cfg(unix)]` permissions hardening (`chmod 0600`) inline. Behind
|
||||
the abstraction the Unix path is unchanged at the OS level
|
||||
(`AF_UNIX` socket file at `/tmp/sessions-local-bridge-<host>-<pid>.sock`
|
||||
with the same `0o600` permissions); on Windows the same code would
|
||||
open a Named Pipe at `\\.\pipe\sessions-local-bridge-<host>-<pid>`.
|
||||
1432 sublime tests + full cargo test + clippy `-D warnings` green
|
||||
post-swap. Binary size delta < 50 KB.
|
||||
|
||||
**Remaining work (MVP, ~3-4 days):**
|
||||
1. **Ungate broker call site** in `main.rs:240` — the
|
||||
`PersistentBroker::start` call is still wrapped in
|
||||
`#[cfg(unix)]` so Windows builds skip it. Drop the gate; the
|
||||
broker itself is now cross-platform.
|
||||
2. **Cross-platform `run_lsp_stdio` client** (`main.rs:826`,
|
||||
currently `#[cfg(unix)]`-only) — replace
|
||||
`UnixStream::connect(&cli.bridge_socket)` with `IpcStream::connect`
|
||||
via the same `GenericFilePath` resolver used on the server.
|
||||
Remove the `#[cfg(unix)]` gate; remove the Windows stub at
|
||||
`main.rs:912` that returns the "lsp-stdio mode currently requires
|
||||
Unix domain sockets" error.
|
||||
3. **Sublime side**: `lsp_project_wiring.py` JSON-encodes the
|
||||
`--bridge-socket <path>` argv. Verify Windows pipe path
|
||||
`\\.\pipe\...` survives the JSON round-trip without escape
|
||||
issues (test against `.sublime-project` write/read cycle).
|
||||
4. **Windows test pass** — pyright LSP attach reaches handshake;
|
||||
`broker_socket` field non-empty in the trace.
|
||||
|
||||
**Hardening (optional follow-up, ~1 week — based on ssh-mux patterns
|
||||
at https://git.teahaven.kr/Rust-related/ssh-mux):**
|
||||
- Anti-squatting token: 32-byte CSPRNG hex token in
|
||||
`%LOCALAPPDATA%\sessions\daemon_token`, included in the pipe name.
|
||||
- DACL restricted to current user SID (fail-closed if SID resolution
|
||||
fails). Requires `windows-sys` security APIs.
|
||||
- `PIPE_REJECT_REMOTE_CLIENTS` flag on the named-pipe server.
|
||||
- `LocalAppData` resolved via `SHGetKnownFolderPath` rather than
|
||||
`%LOCALAPPDATA%` env (resists env-var poisoning).
|
||||
|
||||
The hardening track is meaningful but not required for MVP — the
|
||||
default `interprocess` Windows path uses sensible defaults
|
||||
(per-user) and matches the security posture of the current Unix
|
||||
socket implementation (which only restricts via filesystem
|
||||
permissions, not SID).
|
||||
|
||||
**Acceptance:**
|
||||
- BACKLOG W1 done-when (`PersistentBroker::start` returns a working
|
||||
endpoint on Windows; handshake `broker_socket` non-empty;
|
||||
pyright LSP attaches at minimum).
|
||||
- README "platform support" claim moves Windows from "best-effort"
|
||||
back to "supported" once Windows test pass green.
|
||||
- `BACKLOG.md` Track W1 closes; W2/W3/W4 reassessed as separate
|
||||
follow-ups.
|
||||
|
||||
### 2.7 Slow-link / SSM timeout / backoff as product feature `[plan]`
|
||||
|
||||
**Review:** "느린 SSM hop에서 mirror-sync, file/watch, file/read
|
||||
timeout storm이 나고 reconnect loop로 이어지는 사례." Per-method
|
||||
timeout settings + auto-refresh backoff after N consecutive
|
||||
timeouts.
|
||||
|
||||
**Proposal:**
|
||||
- New setting `sessions_bridge_method_timeouts: { "mirror-sync":
|
||||
90, "file/read": 30, ... }` (defaults preserve current
|
||||
behavior; users on slow links bump them).
|
||||
- Auto-refresh circuit breaker: after 3 consecutive
|
||||
`bridge.request_timeout` events, pause auto-refresh for 5
|
||||
minutes; surface a one-shot status hint with the override
|
||||
setting name.
|
||||
- Debugger flow: rather than emit ssh-tunnel instructions in an
|
||||
output panel, Sessions opens the tunnel itself + a Terminus
|
||||
pane already running `<active_python> -m debugpy --listen ...`
|
||||
with a placeholder script.
|
||||
|
||||
**Acceptance:** Setting applied per request_id at envelope build
|
||||
time. Auto-refresh breaker reflects in
|
||||
`mirror_queue.auto_refresh_paused` trace. Debugger flow opens a
|
||||
ready-to-attach Terminus session.
|
||||
|
||||
---
|
||||
|
||||
## Layer 3 — Python ↔ Rust ownership migration
|
||||
|
||||
> Review: "지금은 'Rust로 많이 옮겼다'가 아니라 'Rust를 많이
|
||||
> 호출한다'에 더 가깝습니다. 다음 단계는 helper-level FFI를 더
|
||||
> 늘리는 게 아니라, runtime ownership을 Rust broker / local_bridge로
|
||||
> 넘겨서 Python을 UI shell로 만드는 것입니다."
|
||||
|
||||
### 3.1 Stop helper-level FFI growth `[plan]`
|
||||
|
||||
**Pattern to refuse:** Adding more `error_code()`, `result_object()`,
|
||||
`queue_pressure()` style small ABI functions to `_rust_ffi.py`. They
|
||||
move location without reducing weight.
|
||||
|
||||
**Pattern to take:** Add coarse-grained JSON-ABI entry points
|
||||
(stage-3 below). When tempted to extend `_rust_ffi.py`, pause and
|
||||
ask: "is the new function part of an ownership migration, or is it
|
||||
another helper migration?" If helper, defer.
|
||||
|
||||
### 3.2 Stage 1 — runtime broker ownership `[plan]`
|
||||
|
||||
**Move out of Python (`commands.py`, ~50-60 LOC of state):**
|
||||
- `_BACKGROUND_TASK_QUEUE`, `_MIRROR_TASK_QUEUE` and their pending /
|
||||
inflight key tracking.
|
||||
- `mirror_queue.dequeue` / `mirror_queue.done` / pressure label
|
||||
computation.
|
||||
- Open-file watch threads (one per window) that loop
|
||||
`execute_remote_watch_files`.
|
||||
- Auto-refresh scheduler.
|
||||
- Deferred-dir expand scheduling.
|
||||
- Request serial allocator for file-open sequencing.
|
||||
|
||||
**Land in Rust (`sessions_native::broker` + `local_bridge`):**
|
||||
- Single broker owns the queue, dedup, backpressure, priority,
|
||||
cancellation, watch lifecycle. Already partially scaffolded in
|
||||
`sessions_native::broker` (`Session`, `PendingSlot`, `Broker`,
|
||||
`request()`).
|
||||
|
||||
**Python after:** `broker.submit(task)`, `broker.subscribe(window_key,
|
||||
event_kind)`, `broker.cancel(handle)`. Nothing else.
|
||||
|
||||
### 3.3 Stage 2 — materialization pipeline ownership `[plan]`
|
||||
|
||||
**Move out of Python (`ssh_file_transport.py`, the big function):**
|
||||
- `open_remote_file_into_local_cache()` — remote read +
|
||||
guardrail + local cache write + sidecar/meta. ~150 LOC.
|
||||
- `_refresh_local_cache_after_format()` — re-fetch + reload.
|
||||
- `_reload_changed_remote_views()` — periodic stat → revert.
|
||||
|
||||
**Land in Rust:** New high-level op `materialize_for_open(...)`.
|
||||
Single Rust pipeline: stale check → remote read (chunked when §2.2
|
||||
lands) → local cache write → sidecar update → return
|
||||
`{local_path, outcome, warning}` to Python.
|
||||
|
||||
**Python after:** `on_window_command("open_file")` interception
|
||||
unchanged; result handler does
|
||||
`outcome = runtime_materialize_for_open(...)` then
|
||||
`window.open_file(outcome.local_path)`. No bytes flow through
|
||||
Python.
|
||||
|
||||
### 3.4 Stage 3 — method envelope ownership `[plan]`
|
||||
|
||||
**Move out of Python:** all sites that build `payload_json` for
|
||||
`file/watch`, `file/read`, `file/write`, `mirror-sync`,
|
||||
`exec/once`, etc. Currently scattered across
|
||||
`ssh_file_transport.py`, `commands.py`, `gitea_rust_artifacts.py`.
|
||||
|
||||
**Land in Rust:** typed coarse-grained operations exposed via the
|
||||
`runtime_*` JSON-ABI:
|
||||
- `runtime_open_session(host_alias, settings_json)`
|
||||
- `runtime_request(session_handle, method, args_json)` —
|
||||
internal, not exposed
|
||||
- `runtime_materialize_for_open(...)`
|
||||
- `runtime_refresh_open_views(...)`
|
||||
- `runtime_run_mirror(...)`
|
||||
- `runtime_run_tool_pipeline(...)`
|
||||
- `runtime_watch_files(...)`
|
||||
|
||||
**Python after:** Calls one of the above; Rust composes the
|
||||
envelope, invokes the broker, returns the typed result. `_rust_ffi`
|
||||
hosts ~7 functions, not 30+.
|
||||
|
||||
### 3.5 Stage 4 — agent / diff / runtime state ownership `[obsolete — Track D dropped 2026-04-27]`
|
||||
|
||||
This stage is no longer applicable. Track D was dropped on
|
||||
2026-04-27 and the v0.6.7 commit deleted `agent_proposal_watcher`,
|
||||
`agent_change_badge`, `agent_tmux`, `agent_window_layout`,
|
||||
`agent_switcher_view`, and the workspace/agent pair registry.
|
||||
The 2026-04-30 cleanup excised the residual `tmux`/`claude-code`/
|
||||
`codex-cli` `kind="agent"` catalog entries, the parallel
|
||||
`jupyterlab` `kind="jupyter"` row, and the `AGENT_TMUX_LAYOUT.md`
|
||||
design doc. There is nothing left to migrate to Rust under this
|
||||
stage; the remaining `workspace_state.py` deferred-dir registry can
|
||||
be carried by Stage 1 if it ever needs to move.
|
||||
|
||||
### 3.6 Success metrics — not LOC `[plan]`
|
||||
|
||||
The review explicitly rejects LOC as a success metric for this
|
||||
work. Track instead:
|
||||
|
||||
- Number of direct `threading.Thread()` / `subprocess.Popen()` /
|
||||
`Lock()` sites in Python.
|
||||
- Number of module-global mutable registries in Python (search:
|
||||
`^_[A-Z_]+(_QUEUE|_REGISTRY|_LOCK|_PENDING|_INFLIGHT)` etc).
|
||||
- Number of `payload_json = ...` / raw envelope assembly points
|
||||
in Python.
|
||||
- Number of paths that read remote bytes + write local cache from
|
||||
Python.
|
||||
- Number of request timeout / retry / cancel decision sites in
|
||||
Python.
|
||||
|
||||
Each of the four migration stages should drive multiple metrics
|
||||
toward zero. Capture a baseline now (before §3.2 lands) and re-run
|
||||
after each stage.
|
||||
|
||||
### 3.7 Migration order `[plan]`
|
||||
|
||||
Concrete order, lowest risk first:
|
||||
|
||||
1. **Stage 1 (broker ownership)** — biggest effect, central
|
||||
choke point. Land before stages 2/3 because they depend on the
|
||||
broker for cancellation + lifecycle.
|
||||
2. **Stage 2 (materialization)** — paired with §2.2 large-file
|
||||
streaming work; the new chunked `file/read` is implemented
|
||||
inside the new Rust materialization pipeline rather than in
|
||||
Python.
|
||||
3. **Stage 3 (envelope ownership)** — naturally falls out of
|
||||
stages 1+2; remaining method-builder code in Python is
|
||||
replaced by `runtime_*` calls.
|
||||
4. ~~**Stage 4 (agent / diff / state)**~~ — obsolete; see §3.5
|
||||
above. Track D was dropped 2026-04-27 and the agent modules
|
||||
plus catalog residue were deleted in v0.6.7 / 2026-04-30.
|
||||
|
||||
---
|
||||
|
||||
## Items review explicitly DEPRIORITIZED
|
||||
|
||||
Per review: "지금은 덜 급한 것":
|
||||
|
||||
- ~~More agent types (catalog already covers Claude Code + Codex CLI).~~ — moot; Track D dropped 2026-04-27 and the agent catalog rows were excised on 2026-04-30.
|
||||
- More palette commands (palette is already too wide — see §1.3).
|
||||
- Big new LSP redesign (Remote LSP track #34/#35/#36/#37 closed; no
|
||||
unmet need).
|
||||
- Wrapper-level Rust migration that doesn't move ownership (§3.1 —
|
||||
the "ctypes 종합상가" anti-pattern).
|
||||
|
||||
Plus, dropped explicitly by maintainer (2026-04-25):
|
||||
|
||||
- **#29 diff-centric review/apply** — the chat→tmux pivot abandoned
|
||||
this design direction. Agents run in tmux panes and edit remote
|
||||
files directly; Sessions's job is multi-session lifecycle
|
||||
(spawn / switch / kill), not diff orchestration. The diff
|
||||
primitives that survived the pivot are dead code (see "Code to
|
||||
consider retiring" below). Issue #29 stays open on the tracker
|
||||
but is not the product's next feature.
|
||||
|
||||
If you find yourself drafting any of the above, pause and check
|
||||
this list first.
|
||||
|
||||
## Code retired (post-direction-correction)
|
||||
|
||||
Modules that supported the abandoned chat-with-diff agent design.
|
||||
**Deleted in v0.6.7** — git history preserves the work for anyone
|
||||
who wants to revive the diff direction.
|
||||
|
||||
- `sublime/sessions/agent_proposal_watcher.py` (290 LOC) — unified
|
||||
diff parser. Designed to tail `tmux pipe-pane` output and surface
|
||||
diff hunks for the never-shipped review panel.
|
||||
- `sublime/sessions/agent_change_badge.py` (248 LOC,
|
||||
`AgentChangeBadgeRenderer`) — post-apply phantom badge.
|
||||
- `sublime/tests/test_agent_proposal_watcher.py`,
|
||||
`sublime/tests/test_agent_proposal_watcher_adversarial.py`,
|
||||
`sublime/tests/test_agent_change_badge.py` (56 tests total).
|
||||
- The dangling reference comment in `agent_tmux.py:10` was rewritten
|
||||
to note the historical context and the retirement.
|
||||
|
||||
Test-health gate stays green after the deletion: adversarial 190
|
||||
(floor 184), real-subprocess 55 (floor 53), contract-fixture 27
|
||||
(floor 27), mock-only:high-value 0.95 (cap 0.98). No floor
|
||||
adjustment needed.
|
||||
|
||||
**Follow-up cleanup (2026-04-30, v0.7.25)** — the v0.6.7 cut left
|
||||
the catalog install/remove rows behind. Now also deleted:
|
||||
|
||||
- `BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG` rows for `tmux`,
|
||||
`claude-code`, `codex-cli` (`kind="agent"`) and `jupyterlab`
|
||||
(`kind="jupyter"`), plus the twelve `_BUILTIN_BASH_*` install/
|
||||
remove/probe blocks that backed them.
|
||||
- `sublime/tests/test_managed_remote_extension_catalog.py` —
|
||||
`test_catalog_contains_jupyter_extension_entry` and
|
||||
`test_catalog_contains_agent_extension_entries`.
|
||||
- `planning/AGENT_TMUX_LAYOUT.md` (Track D layout design doc).
|
||||
- The frozen-experimental docstring in
|
||||
`managed_remote_extension_catalog.py` and the matching
|
||||
`Sessions.sublime-settings` comment block.
|
||||
|
||||
---
|
||||
|
||||
## Already shipped from this batch
|
||||
|
||||
- `[done @ 7793879]` agent tmux no-TTY hardening (`-T` + `</dev/null`)
|
||||
- `[done @ 7793879]` palette registration for v0.6.2 terminal pane /
|
||||
kill commands
|
||||
- `[done @ 7793879]` localhost:PORT canonical URL fix
|
||||
- `[done @ 280d105]` `sessions_show_dev_commands` gate (first dev
|
||||
command hidden: Preview Remote Agent Payload)
|
||||
- `[done @ 280d105]` README ↔ implementation drift fix
|
||||
(session_helper download path)
|
||||
- `[done @ <this commit>]` README "Remote LSP next" section updated
|
||||
for closed #34/#35/#36/#37 — Remote LSP track is shipped, not
|
||||
upcoming.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Code to retire (`agent_proposal_watcher.py` +
|
||||
`agent_change_badge.py`)** — `[done @ v0.6.7]` deleted; see "Code
|
||||
retired" section above.
|
||||
- **§1.1 Linux-only sublime-package** — `[plan]` deferred. Maintainer
|
||||
decision (2026-04-25): "Linux-only는 사용성 측면에서 큰 문제없을
|
||||
것 같음. 그대로 가도 됨." — keep current `git clone + cargo
|
||||
build` install path; no rush to ship a Linux-only sublime-package
|
||||
release asset.
|
||||
- **Windows W1 PersistentBroker (§2.6)** — `[plan-MVP]` resolved
|
||||
(2026-04-25). Maintainer confirmed Windows is the likely largest
|
||||
user fraction. PoC swap of broker server side to `interprocess`
|
||||
validated on macOS; remaining MVP work ~3-4 days (ungate call
|
||||
site, swap client side, Windows test pass). Hardening track
|
||||
(anti-squatting token + DACL, ~1 week, ssh-mux patterns) is
|
||||
optional follow-up.
|
||||
@@ -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
|
||||
64
planning/SHIPPED.md
Normal file
64
planning/SHIPPED.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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.7.x — Track G git/SCM, sync mode, Rust ownership
|
||||
|
||||
| ver | landed | module(s) |
|
||||
|---|---|---|
|
||||
| 0.7.25 | **Cleanup: excise Track D residue and the parallel Jupyter catalog row.** Track D (in-Sublime agent integration via tmux) was dropped 2026-04-27; the v0.6.7 commit deleted the live agent code (`agent_tmux`, `agent_window_layout`, `agent_switcher_view`, palette commands) but left three `kind="agent"` catalog rows (`tmux`, `claude-code`, `codex-cli`), nine bash install/remove/probe blocks, the `kind="jupyter"` row for `jupyterlab` (now superseded by `marimo_hosting`), and the matching frozen-experimental docstring + `Sessions.sublime-settings` comment block as install-flow leftovers. All of that residue is now removed: `BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG` drops to four entries (`pyright-langserver`, `ruff`, `rust-analyzer`, `debugpy`), the catalog file shrinks from 358 → 182 lines, and the matching tests (`test_catalog_contains_jupyter_extension_entry`, `test_catalog_contains_agent_extension_entries`) are deleted. `_managed_extension_project_client_keys_for_spec` docstring updated (jupyter → debugger as the non-LSP example), `marimo_hosting.py` comment cleanup (drop dead `tmux`-children + `jupyter_hosting.py` postmortem references — the latter file no longer exists), `commands.py` Open-Remote-Terminal docstring drops "no tmux session multiplexing" framing. README.md + `planning/BACKLOG.md` Track D entry note the 2026-04-30 residue removal date. **No backward compatibility shim** — existing installs that ran the old install flow keep their remote-side `tmux`/`claude-code`/`codex-cli`/`jupyterlab` binaries; users can uninstall manually with the same commands the catalog used to run (apt/dnf/brew for tmux, `rm -rf ~/.claude/bin` for claude-code, `npm uninstall -g @openai/codex` for codex-cli, `pip uninstall jupyterlab jupyter_server jupyterlab_server` for jupyterlab). `debugpy` `kind="debugger"` row stays untouched. **LSP-style project-level override** for the on-save/on-open pipeline: the original Sessions design was that toolchain wiring follows Sublime LSP precedence (package → user → `.sublime-project` `"settings"`), but only the `settings.LSP` row writer (`merge_sessions_lsp_into_project_data`) honored project scope — the on-save toggle path (`_effective_sessions_settings_for_remote_python` → `load_sessions_settings_from_sublime`) read user settings only, so per-workspace toggling required editing global user settings. Now `_effective_sessions_settings_for_remote_python` accepts an optional `window` argument and overlays `window.project_data().get("settings", {})` on top of the user merge for `sessions_remote_python_auto_diagnostics_on_save`, `sessions_remote_python_auto_diagnostics_on_open`, and `sessions_remote_python_tool_pipeline`. All five callers in `commands_python_pipeline.py` now pass `window`; the two listeners (`on_post_save`, `on_activated_async`) reorder window-resolution before the toggle check. Type-safety: bool keys reject non-bool values silently (fall through to user); pipeline runs through `normalize_remote_python_tool_pipeline`. Six new regression tests in `test_commands.py` pin project-overrides-user / user-wins-when-absent / pipeline-override / wrong-type-rejected / null-project_data-safe / no-window-legacy-path. `Sessions.sublime-settings` header comment documents the precedence chain inline. | `sublime/sessions/managed_remote_extension_catalog.py`, `sublime/sessions/commands.py`, `sublime/sessions/commands_python_pipeline.py`, `sublime/sessions/marimo_hosting.py`, `sublime/Sessions.sublime-settings`, `sublime/tests/test_managed_remote_extension_catalog.py`, `sublime/tests/test_commands.py`, `README.md`, `planning/BACKLOG.md` |
|
||||
|
||||
## v0.6.x — tmux-backed remote agent sessions
|
||||
|
||||
| ver | landed | module(s) |
|
||||
|---|---|---|
|
||||
| 0.6.12+2 | Lazy-mirror escape hatch: `Sessions: Delete Remote File`. The 2026-04-26 v0.6.12 test pass surfaced that local-side sidebar deletes do not propagate to the remote (intentional, but undiscoverable without an explicit knob). Add `SessionsDeleteRemoteFileCommand` (palette + Side Bar context menu) that confirms via `ok_cancel_dialog`, issues `rm -f -- <path>` over the bridge's `exec/once` channel, and tears down the local cache copy + sidecar + open views on success. Non-zero rm exit (permission denied / readonly fs / path-is-dir) keeps BOTH sides intact so the user can investigate; ENOENT is `rm -f`-swallowed so `delete during refresh` still completes the local cleanup. Refuses gracefully when the resolution target is outside the workspace cache (no-op + status hint). Trace events: `file.delete.remote_begin` / `file.delete.remote_done` / `file.delete.remote_failed` / `file.delete.remote_transport_error` so the operation is auditable end-to-end. Five regression tests pin happy path / cancel / non-zero rm / outside-cache refusal / sidebar-`paths` reverse-mapping. `planning/TEST_CHECKLIST.md` §E.6 carries the verification steps. | `commands_file_actions.py`, `commands.py` (re-export), `Sessions.sublime-commands`, `Side Bar.sublime-menu`, `plugin.py`, `tests/test_cmd_save.py`, `tests/test_plugin_entrypoint.py`, `tests/test_runtime_import_smoke.py` |
|
||||
| 0.6.12+1 | Coverage gate stays at 80% — fix the actual problem, not the metric. The v0.6.12 release shipped with `--fail-under-lines 80` failing in CI (~71% real coverage) because cargo-llvm-cov can't instrument code that runs in spawned subprocesses (`local_bridge/src/persistent.rs` 0%, `lsp_stdio.rs` 33%, `mirror.rs` 35%, `cli.rs` 44%, `session_helper/src/lsp_child.rs` 51% — together responsible for the entire shortfall). Earlier patch tried to suppress these via `--ignore-filename-regex`; rolled back per maintainer direction. Instead added 78 new in-process unit tests across 6 files: cli.rs (18 — full argv-parser branch coverage for both `BridgeCliArgs` and `LspStdioCliArgs`, including blank-value rejection and required-flag absence), mirror.rs (10 — `MirrorSyncParams::from()` partial/all/none-override conversions, JSON round-trips, `tree_list_entry_to_mirror` exhaustive kind-mapping, and `handle_mirror_sync` invalid-params + dispatch-error branches via a `/bin/cat`-backed `HelperDispatcher`), lsp_stdio.rs (11 — `lsp_transform_message` edge cases (no rewrite / empty argv / non-object body / placeholder method label / round-trip URI direction inversion), `json_insert_optional` Some/None branches, `run_lsp_stdio` socket-attach negative path against an in-process `UnixListener`), persistent.rs (11 — `HelperDispatcher::deliver` routing/orphan/dedupe semantics, `request_blocking` success / helper-error / fabricated-error / timeout branches with synchronized responder threads, `persistent_broker_endpoint_path` shape, `lsp_response_body_to_framed_string` envelope-unwrap branches), session_helper/lsp_child.rs (16 — `parse_spawn_payload` exhaustive negative paths (non-object / missing argv / wrong type / blank cwd / non-string entries), `normalize_jsonrpc_body` insert-vs-preserve branches, `dispatch_lsp_channel_request` happy path via `sessions_fake_lsp` + spawn-required + invalid-spawn + lsp_spawn_failed branches), session_protocol/lsp_stdio_framing.rs (13 — Content-Length parsing + write/read round-trips + cap rejection + invalid UTF-8 + EOF semantics + LF-only line endings). All test bodies follow the project's "no `unwrap` / `expect` / `panic!` / `#![allow(clippy::...)]`" convention via `?`-returning tests, `match` for negative paths, and `unreachable!()` for genuinely-unreachable arms. Result: 80.36% line coverage, 80.97% function coverage — clears the 80% gate without weakening the metric. | `rust/crates/local_bridge/src/{cli,mirror,lsp_stdio,persistent}.rs`, `rust/crates/session_helper/src/lsp_child.rs`, `rust/crates/session_protocol/src/lsp_stdio_framing.rs` |
|
||||
| 0.6.12 | Cluster D2 follow-up to v0.6.11 test pass. **Data-loss guard (CRITICAL)**: brand-new files saved into the cache mirror via Save As — and any local-only file with no metadata sidecar — were silently deleted by the `REMOTE_NOT_FOUND` branch in both `_apply_hydrate_result` (in-window hydrate) and `SessionsOpenRemoteFileCommand` (explicit Open Remote File). Both call sites now consult a new `_has_remote_metadata_sidecar` helper before invoking `_remove_local_remote_cache_mirror_path`; without a sidecar the local copy is preserved and the user sees a "kept local-only file at <path>" warning instead. **Auto-reconnect with backoff**: subscribe to the transport-trace stream for `bridge.rust.collector_error` / `helper_stdout_eof` / `handshake_recv_timeout`; when a host that was explicitly connected loses its bridge mid-session, schedule a reconnect with 1s→2s→5s→10s→30s backoff (cap 30s, 12-attempt ceiling). `bridge.session_reset` is excluded from the trigger set so our own reconnect's reset call doesn't loop. Cold-start contract from v0.6.11 stays intact (no spawn until explicit reconnect). **Window reuse on connect/reconnect**: `_open_materialized_workspace` now detects when the current window already holds a Sessions workspace and applies the new project_data in place via `set_project_data` rather than spawning a new window through `open_project_or_workspace` — fixes the "old window orphaned, LSP-pyright crashed" pattern reported when switching remote folders. Bridge ref of the prior host is dropped before the swap so it doesn't leak. **New file/folder first-time push**: `_save_remote_file_for_workspace` no longer refuses saves with no sidecar; treats `(no sidecar, no remote)` as first-time create (sends `expected_metadata=None` so the helper's `Missing` precondition fires) and `(no sidecar, remote exists)` as a conservative refusal ("open it first" hint, no blind overwrite). Rust `transactional_write` runs `fs::create_dir_all(parent)` on the `Missing` branch so "new folder + new file inside" lands without a separate mkdir step. **Jupyter timeout & log capture**: bumped `_STARTUP_TIMEOUT_SECONDS` 15s → 60s with `SESSIONS_JUPYTER_STARTUP_TIMEOUT_S` env override; timeout error message now includes the last cat rc and ssh stderr so "last log snippet: ''" is replaced with actionable diagnostics. **tmux list-sessions diagnostics**: `list_terminal_sessions` and `list_all_remote_tmux_sessions` previously swallowed every non-zero SSH exit into an empty list; now log to `_LOG.warning` with stderr tail (excluding the benign "no server running" path) so the "No remote terminal to kill" mystery on hosts with live sessions is diagnosable from the console. **trace log time consistency**: `_trace_event` in `commands.py` now writes the human-readable `time` field alongside `ts`, matching `ssh_file_transport`'s shape. **Right-click expand diagnostics**: three new trace events (`expand.invoked` / `expand.sidebar_resolved` / `expand.quick_panel_deferred`) capture raw kwargs + resolved remote_path so the wrong-path bug from v0.6.11 testing can be diagnosed from the trace log alone. | `commands`, `commands_file_actions`, `jupyter_hosting`, `terminal_tmux_session`, `rust/crates/session_helper/src/lib.rs`, plus regression tests in `test_cmd_mirror`, `test_cmd_save`, `test_cmd_connect`, `test_bridge_lifecycle`, `test_commands_remote_lsp_refresh` |
|
||||
| 0.6.11 | Stop auto-spawning the Rust bridge on Sublime restart. Two on-activated listeners in `commands_python_pipeline` were calling into the bridge unconditionally, which kept reviving SSH + `session_helper` whenever a restored Sessions project window came back into focus — the user explicitly wanted reconnect to be a manual action. Confirmed from `debug-trace.log`: a `mirror-sync` `Broken pipe` at 12:59:42 left the bridge dead, then 39s later (after a Python view focus) `sessions.probe_python_version` was enqueued → `bridge.helper_editor_download_*` → `bridge.helper_ssh_push_*` → `bridge.session_spawn` → `bridge.rust.handshake_ok` → `lsp.managed_server_restart` × 3, all without any explicit reconnect command. **Fix**: `SessionsPythonInterpreterStatusListener.on_activated_async` now only schedules `_probe_active_python_version_task` when `_workspace_runtime_connected(window, context)` is true; otherwise it paints the cached interpreter / `(…)` placeholder in the status bar and returns. `SessionsRemotePythonPipelineListener.on_activated_async` (gated by `sessions_remote_python_auto_diagnostics_on_open`, default false) gets the same gate so opting in still respects the manual-reconnect contract. The other on-activated / on-load callbacks (sidebar placeholder hydrate, LSP workspace activation tracer, active-remote-view revalidate) already had this gate — this commit just brings the two stragglers into line. After explicit `Sessions: Reconnect Current Workspace`, the next view activation fires the probe normally and the status bar fills in. New regression test `test_python_interpreter_status_listener_skips_probe_when_disconnected`. | `sublime/sessions/commands_python_pipeline.py`, `sublime/tests/test_cmd_python_interpreter.py` |
|
||||
| 0.6.10 | Polish-track batch + diagnostic instrumentation for two open repros. **Terminal hover (M1)**: `terminal_link_click` now strips ANSI/VT100 escapes at `classify_terminal_token` entry so abspath / URL detection works against ANSI-coloured `ls` output (allocation-free fast path when no `\x1b` present). New `_RELPATH_PATTERN` + `_resolve_relpath_in_cache(view, token)` resolve relative tokens against the workspace mirror via `RemoteToLocalCacheMapper` — only marked clickable when the local cache file actually exists; directory / `..` traversal / no-context cases all safely fall through. Click handler routes `relpath` outcomes through `_handle_local_path` directly (no bridge round-trip needed). Structured `terminal_link.hover.*` / `terminal_link.click` logs at every decision point now name `matched_kind` / `matched_text` / `resolved_target` / `action` / `outcome` / `source=hover_cache\|reclassify` so the next macOS Cmd+click "paint OK / click silent" repro can be diagnosed from logs alone. Module-top one-liner documents the box-vs-underline theme caveat and `on_hover_delay_ms` dwell expectation. **Terminal close (M4)**: `terminal_tmux_session.close_terminal_session(host, name, *, kind)` unifies the three close paths (`detach` / `plain` / `kill`) via `TerminalCloseOutcome`. `"plain"` = `tmux kill-session` (non-persistent default-on-pane-close), `"kill"` = same SSH effect but explicit user action (palette command path), `"detach"` = current default (no SSH call, session persists). New `sessions_terminal_close_default` setting accepts `"detach"` (default, current behavior) or `"plain"`; `"kill"` deliberately excluded from default policy (regression-guarded). `kill_terminal_session()` retained for backward compat — `close_terminal_session("plain"\|"kill")` delegates so session-name validation + argv shape stay in one place. Palette wiring (new `Sessions: Close Remote Terminal (don't persist)` command) + on-pane-close listener that reads the new setting are deferred to a follow-up commit; the internal API in this release is feature-complete and tested but not yet user-reachable from the palette. **Diagnostic instrumentation (no behavior change)**: `jupyter_hosting.build_notebook_url` logs `local_port` + `notebook_path` at entry; `commands_python_pipeline._open_remote_jupyter_in_browser` logs the constructed URL right after `build_notebook_url`, again at `finish()` entry, again immediately before `webbrowser.open`, and `.exception(...)` inside the previously-silent `except Exception: pass` branch — pinpoints which step swallows the open in the slow-link "queue.done elapsed_ms=27748 but no browser tab" repro. `ssh_file_transport._execute_rust_bridge_request_persistent` extracts `payload_bytes` + `params.max_traversal_depth` from the request payload and threads both into the `bridge.request_timeout` trace event, so the mirror-sync deep-traversal hang (`stall_phase=awaiting_response_dispatch` after 45s) reports the depth + payload size that hit the timeout. `sessions_native::broker::dispatch_response_line` gains an env-gated (`SESSIONS_BROKER_DISPATCH_DEBUG`) stderr trail covering enter / parse-OK / parse-FAILED / no-id-drop / id-and-slot-presence — zero noise unless explicitly enabled, drained through the existing `STDERR_TAIL_CAPACITY=100` ring so `_rust_ffi.stderr_tail(host_alias)` surfaces the trace post-repro. | `terminal_link_click`, `terminal_tmux_session`, `jupyter_hosting`, `commands_python_pipeline`, `ssh_file_transport`, `rust/crates/sessions_native/broker.rs` |
|
||||
| 0.6.7 | Retire dead chat-with-diff agent modules. The chat→tmux pivot abandoned the diff-centric review direction (issue #29 stays open on the tracker but is no longer the product's next feature); the diff primitives that survived the pivot — `agent_proposal_watcher` (290 LOC, unified diff parser) + `agent_change_badge` (248 LOC, post-apply phantom badge) — had no live caller in the agent flow and were carrying 56 tests across 3 files for an undefined product surface. Deleted; git history preserves the work. `agent_tmux.py` docstring rewritten to flag the chat→tmux pivot as the historical reason the broker is agent-agnostic. Test-health floor stays green (adversarial 190 ≥ 184, real-subprocess 55 ≥ 53, contract-fixture 27 ≥ 27, mock-only ratio 0.95 ≤ 0.98). `planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md` closes the "Code to retire" open question and defers the Linux-only `Sessions.sublime-package` ship per maintainer call ("그대로 가도 됨"). | `agent_tmux`, `planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`, `planning/SHIPPED.md` (deletions: `agent_proposal_watcher`, `agent_change_badge`, their tests) |
|
||||
| 0.6.6 | New `Sessions: Attach to Tmux Session` palette command. Lists **all** remote tmux sessions (Sessions-owned `sessions-term-*` alongside foreign sessions the user spun up outside Sessions) and opens a Terminus pane attached via `ssh -tt <alias> tmux attach-session -t <name>`. Read-only attach: foreign sessions never enter the Sessions-owned per-host / per-session view caches, so existing `Open Remote Terminal` / `New Remote Terminal Pane` / `Kill Remote Terminal` flows stay scoped to `sessions-term-*` and can never reach into a foreign session by accident. Quick-panel rows distinguish "Sessions-owned" from "foreign" in the description column. Plus distribution-readiness plan rewrite reflecting the maintainer's direction correction: chat→tmux pivot abandoned #29 diff-centric review (the diff primitives `agent_proposal_watcher` / `agent_change_badge` are dead code from the abandoned design), and the cross-platform CI matrix + code-signing items moved to `[blocked-by-environment]`. | `terminal_tmux_session`, `commands`, `plugin`, `Sessions.sublime-commands`, `planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md` |
|
||||
| 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).
|
||||
546
planning/TEST_CHECKLIST.md
Normal file
546
planning/TEST_CHECKLIST.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# TEST_CHECKLIST — v0.6.12 (slim)
|
||||
|
||||
Focused checklist for the v0.6.11 → v0.6.12 follow-up. Only covers
|
||||
**(a) what shipped in v0.6.12** and **(b) the diagnostic data the
|
||||
v0.6.11 round still needs us to collect** to close the remaining
|
||||
issues. The full regression checklist (Sections 0 / 2 / 5 / 6 / 7 / 8
|
||||
of the v0.6.11 doc) is unchanged and should be re-run end-to-end
|
||||
before any user-visible release after the v0.6.12 changes settle.
|
||||
|
||||
Each scenario has **verify** + **acceptance** lines as before. The
|
||||
numbered prefix matches the issue ID from the 2026-04-26 working
|
||||
session so cross-referencing back to fix commits stays one-to-one.
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
- [ ] `git pull` to `main` at `v0.6.12` or later; `git tag -l v0.6.12`
|
||||
shows the tag.
|
||||
- [ ] `cargo build --manifest-path rust/Cargo.toml --release --workspace`
|
||||
produces `local_bridge`, `session_helper`, `libsessions_native.dylib`
|
||||
/ `.so` / `.dll` per platform without warnings.
|
||||
- [ ] Sublime Sessions package re-loaded (Quit + relaunch is the
|
||||
cleanest way to ensure the new Rust binary is picked up by the
|
||||
next bridge spawn).
|
||||
- [ ] `tail -f <Sublime cache>/Sessions/logs/debug-trace.log` open in
|
||||
a side terminal so trace events land while you click. macOS:
|
||||
`~/Library/Caches/Sublime Text/Cache/Sessions/logs/debug-trace.log`
|
||||
|
||||
---
|
||||
|
||||
## A. v0.6.12 ship verifications
|
||||
|
||||
### A.1 Data-loss guard for new-file save (#13)
|
||||
|
||||
- [ ] Connect to a remote workspace (`Sessions: Connect Remote
|
||||
Workspace`).
|
||||
- [ ] In Sublime, `File → New File`, type one line of content.
|
||||
- [ ] `File → Save As…` → save under the workspace cache root at a
|
||||
path that does NOT exist on the remote yet (e.g.
|
||||
`<cache_root>/scratch/v0612-write.md`).
|
||||
- [ ] **Verify**: file appears on the remote at
|
||||
`<remote_root>/scratch/v0612-write.md` (run `ssh <host> cat
|
||||
.../scratch/v0612-write.md`). Local cache copy is **NOT**
|
||||
deleted. No "stale local cache copy will be removed" dialog.
|
||||
Status bar shows `Sessions ready: Saved remote file …`.
|
||||
- [ ] **Verify (subdirectory mkdir)**: parent `scratch/` dir was
|
||||
created remotely by the helper (no manual `mkdir -p` was
|
||||
needed).
|
||||
- [ ] Re-edit the file + save again. Trace log shows a normal
|
||||
conflict-evaluator path now (sidecar exists from the first
|
||||
save).
|
||||
- **Acceptance**: brand-new file lands remotely on first save; local
|
||||
copy preserved; no destructive prompt; second save runs the normal
|
||||
baseline path.
|
||||
|
||||
### A.2 Refuse blind overwrite of unfetched remote (#14 sub-case)
|
||||
|
||||
- [ ] On the remote, manually create a file the editor has never
|
||||
seen: `ssh <host> "echo remote-version > <remote_root>/blind.txt"`.
|
||||
- [ ] In Sublime (without using `Open Remote File`), `File → New File`
|
||||
→ type `local-version` → `Save As…` to
|
||||
`<cache_root>/blind.txt`.
|
||||
- [ ] **Verify**: status bar reads "Remote path … already exists.
|
||||
Open it first so Sessions has a baseline before overwriting."
|
||||
Remote file is unchanged (`ssh <host> cat
|
||||
<remote_root>/blind.txt` still prints `remote-version`).
|
||||
- **Acceptance**: blind overwrite is refused with an actionable hint;
|
||||
remote file stays intact.
|
||||
|
||||
### A.3 Auto-reconnect with backoff (#3 → #4)
|
||||
|
||||
- [ ] With a connected workspace, kill the bridge from the remote
|
||||
side: `ssh <host> "pkill -f session_helper"` (or yank network
|
||||
briefly).
|
||||
- [ ] Trace log shows `bridge.rust.collector_error` (or
|
||||
`helper_stdout_eof` / `handshake_recv_timeout`).
|
||||
- [ ] **Verify**: within ~1s, trace log shows
|
||||
`auto_reconnect.scheduled host_alias=<host> attempt=1 delay_s=1.0`.
|
||||
Status bar reads "Sessions: lost connection to <host> —
|
||||
auto-reconnecting (attempt 1)…".
|
||||
- [ ] **Verify**: `auto_reconnect.fire host_alias=<host> attempt=1`
|
||||
then a normal connect sequence (`bridge.session_spawn` →
|
||||
`bridge.rust.handshake_ok` → `lsp.managed_server_restart`).
|
||||
- [ ] (Stress) Kill the bridge again **without** waiting for
|
||||
handshake. **Verify**: no duplicate scheduling — only one
|
||||
pending entry per host (`auto_reconnect.scheduled` does not
|
||||
double-fire when collector_error spams).
|
||||
- [ ] (Cold-start regression check) `Quit Sublime → reopen project`.
|
||||
Trace log should NOT contain any `auto_reconnect.scheduled` /
|
||||
`bridge.session_spawn` until the user explicitly runs
|
||||
`Sessions: Reconnect Current Workspace`. v0.6.11's silent
|
||||
cold-start contract must still hold.
|
||||
- **Acceptance**: in-session disconnects auto-revive with backoff;
|
||||
cold start stays silent; status surfaces the retry attempt count.
|
||||
|
||||
### A.4 Window reuse on Open Remote Folder / Reconnect (#10)
|
||||
|
||||
- [ ] With workspace A open and connected, run `Sessions: Connect
|
||||
Remote Workspace` → pick the same host → pick a **different**
|
||||
remote folder (workspace B).
|
||||
- [ ] **Verify**: the same Sublime window swaps to workspace B
|
||||
(sidebar root changes, project name in title changes). NO new
|
||||
window opens. Old workspace A's tabs may stay (not a
|
||||
regression — the project_data swap doesn't auto-close them).
|
||||
- [ ] **Verify (no LSP crash storm)**: console does NOT show
|
||||
"rust-analyzer crashed 5 times" or pyright equivalent. The
|
||||
bridge for workspace B's host is spawned cleanly.
|
||||
- [ ] (Reconnect) On the same window, run `Sessions: Reconnect
|
||||
Current Workspace`. Window stays put, project_data is re-
|
||||
applied via `set_project_data`, no new-window flicker.
|
||||
- **Acceptance**: workspace switches happen in-place; no orphaned
|
||||
bridge or LSP crash.
|
||||
|
||||
### A.5 Jupyter timeout + log capture (#11)
|
||||
|
||||
- [ ] On a slow-link host, run `Sessions: Open Remote Jupyter`. If
|
||||
it succeeds in <60s, you'll see the browser tab as before.
|
||||
- [ ] If it times out: status bar / output panel reads
|
||||
`Sessions warning: Jupyter Lab start failed on <host>: timed
|
||||
out after 60s waiting for Jupyter startup; last cat rc=… log
|
||||
snippet: …` — the snippet must NOT be `''`. It should contain
|
||||
either the partial Jupyter output or `(empty — jupyter wrote
|
||||
nothing within timeout)` or `(log file unreadable, ssh
|
||||
stderr: …)`.
|
||||
- [ ] (Override) Set `SESSIONS_JUPYTER_STARTUP_TIMEOUT_S=120` in
|
||||
Sublime's launch env, restart Sublime, retry. Subsequent
|
||||
timeout error message should say `timed out after 120s …`.
|
||||
- **Acceptance**: timeout grace > 15s; error message is actionable
|
||||
rather than "last log snippet: ''".
|
||||
|
||||
### A.6 Right-click expand diagnostic trace (#2)
|
||||
|
||||
Goal of this section is **to capture the data needed to fix the
|
||||
underlying wrong-path bug** (still pending).
|
||||
|
||||
- [ ] Reproduce the v0.6.11 scenario: connect, sidebar populates
|
||||
with a deferred big directory (`.mamba/pkgs` or similar).
|
||||
Right-click that exact directory → `Sessions: Expand this
|
||||
folder`.
|
||||
- [ ] **Capture** these three trace events from
|
||||
`<Sublime cache>/Sessions/logs/debug-trace.log` immediately
|
||||
after the click:
|
||||
```
|
||||
grep '"expand\.\(invoked\|sidebar_resolved\|quick_panel_deferred\)"' \
|
||||
~/Library/Caches/Sublime\ Text/Cache/Sessions/logs/debug-trace.log \
|
||||
| tail -10
|
||||
```
|
||||
- [ ] Attach the captured JSON lines to the issue (paste into
|
||||
`test.log` under the §A.6 bookmark). The fields we care about:
|
||||
- `expand.invoked` → `paths`, `dirs`, `files`, `cache_root`
|
||||
- `expand.sidebar_resolved` → `input_paths`,
|
||||
`resolved_remote_path`
|
||||
- `expand.quick_panel_deferred` → `deferred` list (only fires
|
||||
if sidebar resolution returned None)
|
||||
- **Acceptance** for this section: data captured for the maintainer.
|
||||
The bug itself is fixed in a follow-up commit once we know whether
|
||||
Sublime is sending the wrong `paths` or our resolver is mismapping.
|
||||
|
||||
### A.7 trace log `time` field consistency (#15)
|
||||
|
||||
- [ ] Trigger any short flow that writes traces (e.g. open a remote
|
||||
file, or run a sidebar expand).
|
||||
- [ ] **Verify**: every line in `debug-trace.log` written by
|
||||
`commands.py` after v0.6.12 contains both `"ts": …` and
|
||||
`"time": "YYYY-MM-DD HH:MM:SS.mmm"` keys. Pre-v0.6.12 logs
|
||||
from earlier sessions still mix shapes — only assert on lines
|
||||
whose `ts` is post-upgrade.
|
||||
- **Acceptance**: no `commands.*` trace event is missing the `time`
|
||||
field after v0.6.12.
|
||||
|
||||
---
|
||||
|
||||
## B. Diagnostic capture for still-open issues
|
||||
|
||||
These sections do NOT verify a fix — they exist purely to collect the
|
||||
information the maintainer needs to land the next round of fixes.
|
||||
Run them in this order so each step's output feeds the next.
|
||||
|
||||
### B.1 tmux list-sessions empty-list mystery (#8 / #9)
|
||||
|
||||
The user terminal can run `ssh <host> 'tmux list-sessions -F
|
||||
"#{session_name}"'` and gets multiple sessions; Sublime's subprocess
|
||||
gets nothing. v0.6.12 added stderr logging that should now expose the
|
||||
real failure.
|
||||
|
||||
- [ ] On the remote, ensure `>= 2` `sessions-term-*` tmux sessions
|
||||
exist (open two via `Sessions: Open Remote Terminal` and
|
||||
`Sessions: New Remote Terminal Pane` if you don't already have
|
||||
them).
|
||||
- [ ] In Sublime, run `Sessions: Kill Remote Terminal`. If quick
|
||||
panel is empty, **immediately** check the Sublime console
|
||||
(`View → Show Console`).
|
||||
- [ ] **Capture**: any `tmux list-sessions on <host> exited N:
|
||||
stderr=…` line that appears in the console. Include the full
|
||||
stderr.
|
||||
- [ ] If no warning fires (= SSH succeeded but produced empty
|
||||
stdout), capture instead the output of running the same
|
||||
command from a Sublime-style subprocess from your terminal:
|
||||
```
|
||||
env -i HOME=$HOME PATH=$PATH ssh <host> 'tmux list-sessions -F "#{session_name}"' ; echo "rc=$?"
|
||||
```
|
||||
Then add `LANG=$LANG` and `SSH_AUTH_SOCK=$SSH_AUTH_SOCK`
|
||||
back one at a time until the command starts working — that
|
||||
isolates which env var Sublime is missing.
|
||||
- [ ] (Numbering check #8) After the kill diagnostic, run `Sessions:
|
||||
New Remote Terminal Pane` once more and check whether it
|
||||
lands on `-3` or re-attaches to `-2`. Whichever it does,
|
||||
capture the surrounding 5–10 trace lines from
|
||||
`debug-trace.log`.
|
||||
- **Acceptance** (data only): stderr captured OR env-var bisect
|
||||
pinpoints the missing variable.
|
||||
|
||||
### B.2 Terminus URL handling (#6 / #7)
|
||||
|
||||
- [ ] In a Sessions remote terminal pane, run `echo
|
||||
https://example.com`. Hover the URL.
|
||||
- [ ] Cmd+click the URL.
|
||||
- [ ] **Check console** for any `terminal_link.click` log line. There
|
||||
are three possible outcomes — please record which one fired:
|
||||
- **(a)** A `terminal_link.click matched_kind=url
|
||||
action=open_browser outcome=dispatched` line appears AND the
|
||||
browser opens correctly. → our path is correct, no bug.
|
||||
- **(b)** A `terminal_link.click` line appears AND the
|
||||
browser opens to `about:blank` (or wrong URL). → bug is in
|
||||
our `_handle_url` / canonicalization. Capture the
|
||||
`resolved_target` field.
|
||||
- **(c)** No `terminal_link.click` line appears at all, but a
|
||||
"shortcut button" popup appears. → Terminus is intercepting
|
||||
before our handler. Capture a screenshot of the popup +
|
||||
check `Packages/User/Terminus.sublime-settings` for any
|
||||
`link_*` keys (default-empty user settings is the expected
|
||||
baseline; any custom override is a smoking gun).
|
||||
- [ ] Repeat with `python3 -m http.server 8080` and Cmd+click on the
|
||||
`0.0.0.0:8080` line; record which of (a)/(b)/(c) fires.
|
||||
- [ ] Repeat with `ls -la /etc/hostname` (absolute path); record
|
||||
whether Cmd+click opens the file in Sublime, opens nothing,
|
||||
or shows a popup.
|
||||
- **Acceptance** (data only): for each of the three token kinds (URL
|
||||
/ localhost:port / abspath), one of (a)/(b)/(c) is recorded.
|
||||
|
||||
### B.3 Agent tmux `not a terminal` (#12)
|
||||
|
||||
- [ ] `Sessions: Install Remote Extension` → confirm at least one
|
||||
agent CLI (`Claude Code CLI (remote)` or `OpenAI Codex CLI
|
||||
(remote)`) is installed.
|
||||
- [ ] `Sessions: New Agent Session` → pick one agent.
|
||||
- [ ] If spawn fails with "open terminal failed: not a terminal",
|
||||
**immediately** capture from `debug-trace.log` and Sublime
|
||||
console:
|
||||
- Any `bridge.*` lines in the 30s before the failure.
|
||||
- The full `Sessions warning: Agent session start failed on
|
||||
… stderr='…'` text.
|
||||
- Run from your terminal (verify the same command Sessions
|
||||
runs):
|
||||
```
|
||||
ssh -T <host> 'tmux new-session -A -d -s sessions-agent-test-claude -- bash -lc "echo hi" </dev/null' ; echo "rc=$?"
|
||||
ssh <host> 'tmux list-sessions | grep sessions-agent-test-claude'
|
||||
ssh <host> 'tmux kill-session -t sessions-agent-test-claude'
|
||||
```
|
||||
Capture each command's exit code.
|
||||
- **Acceptance** (data only): if the manual `ssh -T` command from
|
||||
your terminal succeeds (rc=0) but Sublime's spawn fails, that's a
|
||||
Sublime subprocess env issue (same root as B.1). If both fail,
|
||||
it's a tmux-on-this-host issue (capture `tmux -V`).
|
||||
|
||||
### B.4 LSP rust-analyzer pre-handshake disable (#3)
|
||||
|
||||
- [ ] Quit Sublime entirely. Confirm broker socket file under
|
||||
`/var/folders/.../sessions-local-bridge-<host>-<pid>.sock` (or
|
||||
`/tmp/...`) is gone (it dies with the previous Sublime PID).
|
||||
- [ ] Reopen Sublime + the Sessions project. Do NOT trigger
|
||||
`Sessions: Reconnect` yet.
|
||||
- [ ] **Verify (post-v0.6.12 doc fix)**: console shows ONE
|
||||
`lsp.pre_handshake_disable_applied … flipped=[LSP-pyright,
|
||||
LSP-ruff, rust-analyzer]` line at `plugin_loaded`. (The
|
||||
v0.6.11 docs called this `lsp.disable_stale_rows` — that was
|
||||
a stale name in the test plan; the actual event is
|
||||
`lsp.pre_handshake_disable_applied`. Confirm you can find it
|
||||
under the new name.)
|
||||
- [ ] **Verify (no crash storm)**: NO "rust-analyzer crashed 5 / 5
|
||||
times in the last 180.0 seconds" dialog.
|
||||
- [ ] If the crash dialog still fires:
|
||||
- Capture the `.sublime-project` LSP block on disk **at the
|
||||
moment the dialog shows** (Sublime Console:
|
||||
`import json; print(json.dumps(window.project_data().get("settings", {}).get("LSP", {}), indent=2))`).
|
||||
- Note the timestamp of the dialog and the timestamp of
|
||||
`lsp.pre_handshake_disable_applied` in the trace log. If
|
||||
the dialog precedes the disable event by even a few
|
||||
hundred ms, that's the load-order race we hypothesized.
|
||||
- **Acceptance** (data only): we either confirm the disable lands
|
||||
before LSP retries (no crash), or we capture the timing gap that
|
||||
proves the load-order race.
|
||||
|
||||
---
|
||||
|
||||
## C. When something fails — collection bundle
|
||||
|
||||
Same shape as v0.6.11 §10: `<platform>.log` with bookmarks per step,
|
||||
`local_bridge --version` from the binary actually loaded (its path
|
||||
is logged on every connect as `bridge_path`), `tmux list-sessions`
|
||||
output from the remote, current `.sublime-project` contents, and
|
||||
one screenshot per failure.
|
||||
|
||||
For v0.6.12 specifically, **always include the matching trace lines**
|
||||
for the listed events — the diagnostics added this round are
|
||||
designed to make every failure self-describing without re-running
|
||||
the repro:
|
||||
|
||||
| Section | Required trace events |
|
||||
|---------|----------------------|
|
||||
| A.1 / A.2 | `file.open.*`, `file.save.*` (if any), absence of `file.open.remote_missing` deletion |
|
||||
| A.3 | `auto_reconnect.scheduled`, `auto_reconnect.fire`, `auto_reconnect.gave_up` (if cap hit), `bridge.rust.collector_error`, `bridge.rust.handshake_ok` |
|
||||
| A.4 | `connect.phase=project_window_opened`, presence of `set_project_data` call (Sublime console: `window.project_data()` before/after) |
|
||||
| A.5 | `Sessions warning: Jupyter Lab start failed …` (full message including `last cat rc=…`) |
|
||||
| A.6 | `expand.invoked`, `expand.sidebar_resolved`, `expand.quick_panel_deferred` |
|
||||
| B.1 | `tmux list-sessions on <host> exited N: stderr=…` warning |
|
||||
| B.2 | `terminal_link.click` lines (or absence thereof) |
|
||||
| B.3 | `Sessions warning: Agent session start failed on … stderr='…'` |
|
||||
| B.4 | `lsp.pre_handshake_disable_applied` (or absence), with timestamp |
|
||||
|
||||
---
|
||||
|
||||
## D. New findings from the v0.6.12 test pass (2026-04-26)
|
||||
|
||||
Issues surfaced while running the `A.*` / `B.*` sections above. The
|
||||
SSH-quoting and per-subtree-mirror fixes shipped in commit `76bdf5b`
|
||||
(see §E for verification steps); the items below are still open and
|
||||
need a follow-up commit / design decision.
|
||||
|
||||
### D.1 Local-side delete is not propagated to the remote (A.1 follow-up)
|
||||
|
||||
- Repro: in §A.1 after the brand-new file lands remotely, delete the
|
||||
file from Sublime's sidebar. The remote copy survives. Within ~30s
|
||||
the sidebar refresh re-pulls the remote file as a placeholder, so
|
||||
the deletion appears to "rollback".
|
||||
- Cause / policy: Sessions is a read-mostly mirror — local `Save`
|
||||
pushes to remote (write-through), but local `Delete` is intentionally
|
||||
NOT propagated to avoid silent remote-data loss. Any deletion that
|
||||
should reach the remote needs an explicit user-confirmed command.
|
||||
- Disposition: ship as new feature `Sessions: Delete Remote File`
|
||||
(palette + sidebar context menu, with confirmation). See §E.6 for
|
||||
the verification check; implementation lands in the same commit
|
||||
as this checklist update.
|
||||
|
||||
### D.2 New folder created locally is not synced (A.1 follow-up)
|
||||
|
||||
- Repro: same as D.1 but with a folder via Sublime's `New Folder`.
|
||||
Local mkdir succeeds; the remote stays untouched until a file is
|
||||
saved inside (§A.1's mkdir-p chain on first-file-write is what
|
||||
eventually creates the directory remotely).
|
||||
- Disposition: matches the read-mostly-mirror policy; no separate
|
||||
fix planned. The first save inside the new folder creates the
|
||||
remote directory chain via the v0.6.12 mkdir-p path.
|
||||
|
||||
### D.3 `Sessions: Refresh Remote Worktree` confused with sidebar refresh (A.2)
|
||||
|
||||
- Repro: `Sessions: Refresh Remote Worktree` from the palette while a
|
||||
regular file is focused. Status reads `Focus the Sessions Remote
|
||||
Tree view first.`
|
||||
- Cause: the command refreshes the **dedicated Remote Tree view**
|
||||
(separate panel opened by `Sessions: Open Remote Tree`), not the
|
||||
sidebar mirror. The name is misleading.
|
||||
- Disposition: rename / consolidate in a follow-up commit. Tracked
|
||||
in `planning/SHIPPED.md`; no code change in this round.
|
||||
|
||||
### D.4 Sub-second remote create vs local Save-As race (A.2 caveat)
|
||||
|
||||
- Repro: open Sublime, run §A.2's `ssh <host> echo > blind.txt`,
|
||||
immediately type a `local-version` Save-As to the same path. If the
|
||||
sidebar's deep-mirror tick lands between the two, the local buffer
|
||||
ends up pre-populated with the remote bytes (i.e. the conflict
|
||||
refusal in §A.2 never fires).
|
||||
- Cause: the order Sublime sees is `(remote stat = exists) before
|
||||
Save-As local write`, so Sessions silently treats the path as
|
||||
already-fetched. The §A.2 refusal still fires reliably when the
|
||||
remote create happens AFTER the local Save-As.
|
||||
- Disposition: low priority; the visible behavior (no data loss,
|
||||
user sees the remote bytes) is benign. Re-evaluate once the broader
|
||||
delete-propagation policy lands.
|
||||
|
||||
### D.5 Terminus URL hover shows box, Cmd+click silent — no
|
||||
`terminal_link.click` log (B.2)
|
||||
|
||||
- Repro: §B.2. URL highlighted on hover but Cmd+click does NOT
|
||||
surface a `terminal_link.click` line in the Sublime console.
|
||||
- Cause hypothesis: Terminus 's own URL handler is intercepting the
|
||||
click before our `on_text_command` listener sees it.
|
||||
- Disposition: needs Terminus settings inspection from the user
|
||||
(`Packages/User/Terminus.sublime-settings` — any `auto_link` /
|
||||
`link_*` overrides? defaults that intercept?). Capture & share so
|
||||
the next commit can either disable the intercept or shift to a
|
||||
hover-popup-based open path.
|
||||
|
||||
### D.6 `Sessions: Reconnect Current Workspace` doesn't auto-revive
|
||||
helper after kill (A.3 finding before fix)
|
||||
|
||||
- Now fixed in commit `76bdf5b` — the listener is bound to
|
||||
`bridge.request_broken_pipe` instead of the Rust-only
|
||||
`bridge.rust.collector_error`. See §E.3 for the verification
|
||||
check that exercises the new path end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## E. v0.6.12 follow-on fix verifications (commit `76bdf5b`)
|
||||
|
||||
Five fixes shipped after the v0.6.12 test pass surfaced their root
|
||||
causes. Re-run these specifically — each maps 1:1 to a §A or §B
|
||||
section above whose original step is now expected to pass.
|
||||
|
||||
### E.1 tmux list-sessions argv quoting (B.1 / #8 / #9)
|
||||
|
||||
- [ ] On the remote, ensure `>= 2` `sessions-term-*` tmux sessions
|
||||
exist (open via `Sessions: Open Remote Terminal` and then
|
||||
`Sessions: New Remote Terminal Pane`).
|
||||
- [ ] Run `Sessions: Kill Remote Terminal`. **Verify**: quick panel
|
||||
now LISTS those sessions instead of "No remote terminal to
|
||||
kill". Picking one kills exactly that session on the remote.
|
||||
- [ ] Run `Sessions: New Remote Terminal Pane` repeatedly.
|
||||
**Verify**: each invocation lands on the next free index
|
||||
(`-2`, `-3`, `-4`, …) instead of always re-attaching to `-2`.
|
||||
- [ ] Run `Sessions: Attach to Tmux Session`. **Verify**: lists
|
||||
both Sessions-owned and foreign sessions (matches §3.5 of the
|
||||
v0.6.11 doc).
|
||||
- [ ] **Verify (no warning)**: console no longer shows
|
||||
`tmux list-sessions on <host> exited 1: stderr='command
|
||||
list-sessions: -F expects an argument'`.
|
||||
- **Acceptance**: the tmux-listing flows behave the way the v0.6.11
|
||||
doc described in the first place.
|
||||
|
||||
### E.2 Jupyter spawn argv quoting (A.5)
|
||||
|
||||
- [ ] `Sessions: Open Remote Jupyter`. **Verify**: browser tab opens
|
||||
to `http://127.0.0.1:<port>/lab?token=…` within the new 60s
|
||||
timeout (vs the pre-fix `cat: ~/.sessions/jupyter-…log:
|
||||
No such file or directory`).
|
||||
- [ ] On the remote, `cat ~/.sessions/jupyter-<token>.log` shows the
|
||||
Jupyter startup banner (the redirect now lands).
|
||||
- **Acceptance**: Jupyter Lab actually starts; no more
|
||||
`log file unreadable` diagnostic.
|
||||
|
||||
### E.3 Auto-reconnect on `bridge.request_broken_pipe` (A.3 / #4)
|
||||
|
||||
- [ ] With a connected workspace, `ssh <host> "pkill -f
|
||||
session_helper"`.
|
||||
- [ ] Click around `.py` views or trigger any sidebar refresh — the
|
||||
first request after the kill surfaces
|
||||
`bridge.request_broken_pipe` in the trace log.
|
||||
- [ ] **Verify**: within ~1s the trace log shows
|
||||
`auto_reconnect.scheduled host_alias=<host> attempt=1
|
||||
delay_s=1.0`, then `auto_reconnect.fire`, then a fresh
|
||||
`bridge.session_spawn` → `bridge.rust.handshake_ok`. Status
|
||||
bar reads `Sessions: lost connection to <host> —
|
||||
auto-reconnecting (attempt 1)…`.
|
||||
- [ ] (Cold-start regression check) Quit Sublime → reopen project.
|
||||
The auto-reconnect listener must NOT fire on a cold start —
|
||||
confirmed by absence of `auto_reconnect.scheduled` /
|
||||
`bridge.session_spawn` in the trace until the user runs
|
||||
`Sessions: Reconnect Current Workspace` explicitly.
|
||||
- **Acceptance**: helper death silently revives via backoff;
|
||||
v0.6.11's silent-cold-start contract still holds.
|
||||
|
||||
### E.4 Window reuse only on same-workspace reconnect (A.4 / #10)
|
||||
|
||||
- [ ] With workspace A open, run `Sessions: Connect Remote Workspace`
|
||||
and pick a **different** remote folder on the same host
|
||||
(workspace B).
|
||||
- [ ] **Verify**: Sublime spawns a NEW window for workspace B.
|
||||
Workspace A's window keeps its sidebar / tabs unchanged.
|
||||
- [ ] (Reconnect path) On workspace B's window, run `Sessions:
|
||||
Reconnect Current Workspace`. **Verify**: window stays put;
|
||||
sidebar still shows exactly ONE folder for B (no
|
||||
accumulation); no LSP crash storm.
|
||||
- [ ] (Sidebar regression check) Sidebar entries don't accumulate
|
||||
across the two connects — A's sidebar has one folder, B's
|
||||
sidebar has one folder.
|
||||
- **Acceptance**: switching workspaces ≠ swap; reconnecting same
|
||||
workspace = swap.
|
||||
|
||||
### E.5 Right-click expand mirrors to the correct subdirectory (A.6)
|
||||
|
||||
- [ ] Connect to a workspace whose root has a deferred subtree
|
||||
(e.g. `.mamba/pkgs` or `.conda`).
|
||||
- [ ] Either right-click the deferred dir → `Sessions: Expand this
|
||||
folder`, or pick from the (still-present) quick panel.
|
||||
- [ ] **Verify (mirror destination)**: the expanded children land
|
||||
under `<cache_root>/<rel-path>/...` (e.g.
|
||||
`<cache_root>/.conda/condabin/...`) — NOT directly at
|
||||
`<cache_root>/condabin/...` as v0.6.12 reported.
|
||||
- [ ] **Verify (trace event)**: a new
|
||||
`expand.local_destination remote_path=<...>
|
||||
local_files_root=<cache_root>/<rel-path>` line appears in the
|
||||
trace log right before the mirror runs.
|
||||
- [ ] **Verify (no destructive prune)**: the next sidebar refresh
|
||||
does NOT delete a wave of placeholder stubs from the
|
||||
workspace root. (The v0.6.12 EDR-noise scenario was driven
|
||||
by the wrong `local_files_root` filling the root with mis-
|
||||
placed stubs that the next prune then wiped.)
|
||||
- **Acceptance**: expand lands content in the right place;
|
||||
refresh-driven prune is contained to the sub-tree, not the
|
||||
workspace root.
|
||||
|
||||
### E.6 `Sessions: Delete Remote File` (NEW feature, D.1 follow-up)
|
||||
|
||||
The lazy-mirror policy does not auto-propagate local deletes; this
|
||||
is the explicit, user-confirmed escape hatch.
|
||||
|
||||
- [ ] Open a remote file via `Sessions: Open Remote File`.
|
||||
- [ ] Run `Sessions: Delete Remote File` (palette OR sidebar
|
||||
right-click on the file's local cache copy).
|
||||
- [ ] **Verify (confirmation)**: an `ok_cancel_dialog` appears
|
||||
naming the remote path being deleted. Clicking Cancel must
|
||||
leave both the remote file and the local cache copy intact.
|
||||
- [ ] Re-run and confirm. **Verify**: `ssh <host> ls <remote_path>`
|
||||
reports the file is gone, the local cache copy is gone, and
|
||||
any open Sublime view of the file is closed. Trace log
|
||||
shows a `file.delete.remote_done` event with
|
||||
`host_alias`, `remote_path`, and `cache_path` fields.
|
||||
- [ ] **Verify (cache-only deletion when remote already missing)**:
|
||||
`ssh <host> rm <remote_path>` BEFORE running the command.
|
||||
Then run `Sessions: Delete Remote File`. The command should
|
||||
report a non-destructive "already gone on remote — removed
|
||||
stale local cache" status (no error popup) and still drop
|
||||
the cache copy.
|
||||
- [ ] **Verify (refusal outside cache)**: invoking the command on a
|
||||
view whose `file_name()` is NOT under the workspace cache
|
||||
root must refuse with a clear status message and touch
|
||||
nothing on the remote.
|
||||
- **Acceptance**: delete reaches the remote only after explicit
|
||||
confirmation; refuses gracefully when the user is on a non-cache
|
||||
file; surfaces a self-describing trace event.
|
||||
|
||||
---
|
||||
|
||||
## F. Skipped from v0.6.11 checklist
|
||||
|
||||
These sections from the v0.6.11 doc are NOT in this slim run because
|
||||
nothing in v0.6.12 (or its follow-on `76bdf5b`) touched them:
|
||||
|
||||
- §0 prereqs (deduplicated above), §1.1.1, §1.1.2, §1.2, §1.3, §1.6,
|
||||
§2, §3.3, §3.5, §3.6, §3.7, §4, §6, §7 (full agent flow), §8
|
||||
(release verification — only do that on the v0.6.12 tag push, not
|
||||
every checklist run).
|
||||
|
||||
Re-run them ad hoc if a related area regresses; they are fully
|
||||
covered by the v0.6.11 doc kept in git history.
|
||||
271
planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md
Normal file
271
planning/TRACK_G_V1_BIDIRECTIONAL_SYNC.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Track G v1 — Bidirectional `.git` Sync
|
||||
|
||||
**Status:** Draft plan, post-v0.7.23. Authored from a code audit + external-tool methodology survey.
|
||||
|
||||
**Symptom triggering this plan** (verbatim from `test.log`):
|
||||
|
||||
> Sublime Merge에서 만든 로컬 `test` 브랜치는 살아 있는데 **remote에는 전파되지 않음**.
|
||||
|
||||
The user's framing: 단순 양방향 sync로는 race condition을 못 풀고 한쪽이 다른쪽을 덮어쓰니, 협업 에디터들의 방법론을 차용하자.
|
||||
|
||||
> Note: the Terminus pane-survival diagnosis that originally accompanied this
|
||||
> audit was landed separately as commit `0e2fdd9`
|
||||
> (`fix(sublime/terminal): pin stdio to /dev/tty + auto_close=False`). This
|
||||
> document is now scoped to bidirectional `.git` sync only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 1. What the audit found (concrete)
|
||||
|
||||
Current Track G v0 architecture (commands.py:7115+):
|
||||
|
||||
```
|
||||
on every mirror sync.done:
|
||||
for each discovered repo:
|
||||
1. read post-checkout marker → git checkout <new_head> on remote
|
||||
2. probe remote ref fingerprint; skip if unchanged
|
||||
3. tar -czf .git | base64 → WIPE local .git → untar
|
||||
4. re-install post-checkout hook
|
||||
5. materialise dirty working-tree files
|
||||
```
|
||||
|
||||
Three classes of problem with this model:
|
||||
|
||||
### A. Hook-based op capture is unreliable in our environment
|
||||
|
||||
`windows.log` evidence: every `git.checkout_proxy` event in the trace shows `proxied: false` — **the post-checkout hook never fired during the user's `test`-branch reproduction**. There are three plausible explanations and they're not mutually exclusive:
|
||||
|
||||
- **A1.** Sublime Merge uses libgit2 internally, and **libgit2 does not invoke client-side hooks by default.** This means a fundamental class of user actions performed via Sublime Merge — `git checkout`, `git checkout -b`, branch deletes — never hit our hook. **If true, the entire marker-based mechanism is dead-on-arrival for our primary user.**
|
||||
- **A2.** `post-checkout` only fires on checkout; `git branch -d`, `git branch -m`, and `git commit` never trigger it regardless of front-end. Failure modes #2 (delete), #4 (commit) are uncovered by design.
|
||||
- **A3.** Multiple checkouts in quick succession overwrite the single per-repo marker file (`git_branch_proxy.py` keeps one marker per repo); intermediate states are lost.
|
||||
|
||||
**A1 is the load-bearing finding.** Before any plan that depends on hooks, we have to verify it. But we should plan as if it's true, because the alternative — building a working hook around libgit2 — is harder than removing the dependency on hooks entirely.
|
||||
|
||||
### B. Wipe-and-replace is structurally hostile to local writes
|
||||
|
||||
`git_dot_git_sync.py:194` — every fetch tick removes the entire local `.git` (preserving only `SESSIONS_PENDING_CHECKOUT`) and replaces it with the remote tarball. Anything the local user wrote into `.git` between fetches that isn't on the preserved-files list is destroyed:
|
||||
|
||||
- A branch ref the user created locally that doesn't exist on remote yet (failure mode #1).
|
||||
- A commit object Sublime Merge wrote locally that hasn't been pushed (failure mode #4).
|
||||
- Stash entries, reflog entries, refs/notes entries.
|
||||
|
||||
The v0.7.23 mirror-boundary fix prevents the *outer* mirror from pruning `.git`, but the *inner* tar replace still does the same damage. This is the single biggest correctness hole.
|
||||
|
||||
### C. No three-way diff over ref state
|
||||
|
||||
Track G has no memory of "what the local refs looked like at the end of the last successful refresh." Without that, it can't tell:
|
||||
|
||||
- Did the user create `refs/heads/test` locally? Or did remote have it last time and we just lost it?
|
||||
- Did the user delete `refs/heads/feature/old`? Or is it just absent from the remote and we should let it be?
|
||||
|
||||
Every failure mode reduces to "we couldn't tell who changed what since the last sync."
|
||||
|
||||
## 2. What we steal from the methodology survey
|
||||
|
||||
The survey (full report in research notes) covered Git refspecs, VS Code/Zed/Gateway remote-dev, CRDTs, OT, file-sync conflict copies, and Jujutsu. The honest landings:
|
||||
|
||||
- **CRDTs**: wrong tool. Ref state is a CAS-on-pointers problem under structural constraints, not a free-form text merge. Adopting Automerge here multiplies storage and replaces a tractable problem (Git already solved it) with an intractable one (semantic merge of pointer values).
|
||||
- **Headless backends (VS Code Server, Zed Headless, JetBrains Backend)**: foreclosed. Sublime Merge is a separate native app that wants a real on-disk `.git`; the whole reason we have a local mirror is to feed it. The headless answer would invalidate the project.
|
||||
- **OT**: the algorithm doesn't apply (refs aren't a stream of insert/delete ops), but the **central-arbitrator pattern does** — and we already have one (the remote box's `.git`).
|
||||
- **Git's own model**: directly applicable. Two clones of the same repo never silently overwrite each other because of refspec namespacing + fast-forward checks + `--force-with-lease`. We are reinventing this badly.
|
||||
- **File-sync conflict copies (Syncthing/Dropbox)**: directly applicable for the working-tree edge cases.
|
||||
- **Jujutsu's operation log**: directly applicable as the foundation we're missing.
|
||||
|
||||
## 3. The redesign — three changes, in dependency order
|
||||
|
||||
### Change #1 — Op log + ref snapshot at every refresh boundary *(foundation)*
|
||||
|
||||
Promoted from "safety net" to foundation because of finding A1: without reliable hooks, **we have to detect ref-state changes by polling**, and polling needs a baseline.
|
||||
|
||||
Add a sessions-owned sidecar under each repo: `.git/sessions/op-log.jsonl` and `.git/sessions/last-snapshot.json`. The snapshot stores `{ref_name → sha}` and the symbolic `HEAD` target for both local and remote at the end of the last successful refresh.
|
||||
|
||||
```text
|
||||
each refresh tick (per repo):
|
||||
before = read_snapshot() # {local: {refs}, remote: {refs}}
|
||||
local_now = read_local_refs() # cheap: walk refs/heads/*
|
||||
remote_now = exec(host, "git for-each-ref ... ; HEAD") # cheap: one exec/once
|
||||
|
||||
diff = three_way(before, local_now, remote_now)
|
||||
apply(diff) # ← Changes #2 + #3
|
||||
write_snapshot({local: local_now, remote: remote_now})
|
||||
append op_log({ts, diff, actions, errors})
|
||||
```
|
||||
|
||||
The diff classifies every ref into one of:
|
||||
|
||||
- `unchanged` — both sides match the snapshot. Skip.
|
||||
- `local_only_new` — local has it, remote doesn't, snapshot didn't have it on either. **User created.** Action in Change #2.
|
||||
- `local_only_deleted` — snapshot had it on both, neither has it now. (Edge case — only happens if user deleted on both sides between ticks.)
|
||||
- `local_deleted` — snapshot had it on local, local doesn't. **User deleted.** Action in Change #2.
|
||||
- `remote_only_new` — remote has it, local doesn't, snapshot didn't have it. **Remote teammate created.** Mirror into local.
|
||||
- `remote_deleted` — snapshot had it on remote, remote doesn't. Mirror local prune.
|
||||
- `local_advanced` — local SHA is descendant of snapshot SHA, remote SHA == snapshot SHA. **User committed.** Action in Change #2.
|
||||
- `remote_advanced` — same on remote side. Fast-forward local.
|
||||
- `diverged` — both sides moved differently. Surface to user; do nothing automatic. Action in Change #3.
|
||||
|
||||
Op log is append-only JSONL, rotated at N=1000 lines or 30 days. Gives us a "Sessions: Undo Last Sync" command that walks the most recent entry and restores ref state via `git update-ref`. Critically: it gives us **debuggability** — when refs vanish, we know which tick wiped them.
|
||||
|
||||
**Invariants:**
|
||||
|
||||
- Every ref-mutating action writes to the log *before* the action (write-ahead).
|
||||
- The log lives under `.git/sessions/` so git itself ignores it.
|
||||
- Snapshots are atomic: write to `last-snapshot.json.tmp`, fsync, rename.
|
||||
- **The whole `read snapshot → diff → apply → write snapshot` sequence runs under a per-repo flock on `.git/sessions/refresh.lock`.** Sessions stacks overlapping refresh ticks (the `mirror_queue` evidence in `windows.log` shows multiple `dequeue` events for the same workspace within the same second); without the lock, two ticks read the same baseline, both compute "local_only_new" for the same ref, both call `update-ref` with the same `expected_old`, the second's CAS fails, and the diff classifier treats it as divergence — false-positive UI noise that trains users to dismiss real divergence. The lock is `fcntl.flock(LOCK_EX | LOCK_NB)`; on contention skip the tick (the next one picks up the new state). This is *not* deferred to v1+; it's part of Change #1 itself.
|
||||
|
||||
**On "undo".** The op log enables a forensic command — `Sessions: Show Last Sync` — that displays the previous tick's diff and resulting ref state side-by-side, lets the user copy SHAs, and offers a *local-only* "restore local refs from snapshot" action. It does **not** undo remote-side changes that have already been pushed (those may have been built on by other consumers; rolling them back via `--force-with-lease` is a separate user-driven decision, not a button in the editor). The naming reflects this: forensic + local-restore, not "undo." If users need remote rollback they run `git push --force-with-lease` themselves with the SHA the readout gave them.
|
||||
|
||||
### Change #2 — Replace tar wipe with `git bundle` over the existing bridge *(eliminates the wipe)*
|
||||
|
||||
Borrow Git's own model. After Change #1's diff classifies what happened, perform the actual sync via Git primitives instead of tar-replace.
|
||||
|
||||
**Transport choice.** The Rust bridge today is `exec/once` only — single round-trip `argv → {exit_code, stdout, stderr}`. There is no streaming/duplex endpoint. That rules out `git fetch ssh://host/path` *through the bridge* (pack-protocol needs a duplex pipe), and it rules out `git fetch ssh://...` running its own SSH child too — that path would respawn `ssh` outside the bridge's ControlMaster on every refresh, regressing the v0.7.21 askpass-flash fix and racing the bridge's auth state.
|
||||
|
||||
The right primitive is **`git bundle`**:
|
||||
|
||||
- `git bundle create - <refspec>` packs refs + objects into a single self-contained file written to stdout. Fits the existing `exec/once` shape (one argv, one stdout payload, one timeout) — exactly what we already use for the `tar -czf .git | base64` path, just with a vastly smaller payload because bundles only contain the *requested* refs plus reachable objects.
|
||||
- Bundles support **incremental ranges**: `git bundle create - <new_sha> ^<last_seen_sha>` writes only objects new since the snapshot. Steady-state bandwidth drops from "26 MB tar" to "kilobytes of new commits."
|
||||
- Local apply: `git bundle unbundle <file>` reads the bundle and writes new objects + advances the named refs. No streaming required either way.
|
||||
|
||||
```text
|
||||
on remote (one exec/once per refresh):
|
||||
set sessions-scoped config (idempotent, one-time per repo):
|
||||
git config receive.denyCurrentBranch updateInstead
|
||||
for each ref in diff.local_only_new ∪ diff.local_advanced:
|
||||
# Send local commits + ref to remote. Reuse `git bundle` in the
|
||||
# other direction: build bundle locally, ship to remote, unbundle.
|
||||
local: git bundle create - <local_sha> ^<snapshot_sha_or_empty>
|
||||
| base64 -w0 → tx
|
||||
remote (via exec/once):
|
||||
printf %s "<bundle_b64>" | base64 -d | git -C <root> bundle unbundle /dev/stdin <ref>
|
||||
git update-ref -m "sessions sync" refs/heads/<name> <local_sha> <snapshot_sha> # CAS
|
||||
for each ref in diff.local_deleted:
|
||||
remote: git update-ref -d refs/heads/<name> <snapshot_sha> # CAS
|
||||
for the active HEAD checkout (the post-checkout case):
|
||||
if user moved HEAD locally: git -C <root> checkout <new_head> # current behaviour, kept
|
||||
|
||||
on local (replaces the tar pull):
|
||||
remote (one exec/once):
|
||||
git -C <root> bundle create - --branches \
|
||||
$(for r in <changed_refs>; do printf '^%s ' "<snapshot_sha_for_$r>"; done)
|
||||
| base64 -w0
|
||||
local:
|
||||
base64 -d | git -C <local-mirror> bundle unbundle /dev/stdin
|
||||
# bundle wrote into refs/heads/* directly per the bundle's ref names — undesirable.
|
||||
# Use --map-refs or rewrite: bundle creates with the source ref name; we want
|
||||
# them under refs/sessions/<host>/heads/*. Fix: bundle uses fully-qualified
|
||||
# ref names, so on the remote side rewrite the bundle's ref list to
|
||||
# refs/sessions/<host>/heads/* before piping. (`git bundle` accepts
|
||||
# "refs/heads/foo" or any other refname; emit them as
|
||||
# "refs/sessions/<host>/heads/foo" by passing explicit names.)
|
||||
for each ref in diff.remote_only_new ∪ diff.remote_advanced:
|
||||
git update-ref refs/heads/<name> refs/sessions/<host>/heads/<name> # only if local is ancestor (FF)
|
||||
for each ref in diff.remote_deleted:
|
||||
git update-ref -d refs/heads/<name> <snapshot_sha> # CAS
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The `refs/sessions/<host>/heads/*` namespace gives Sublime Merge an explicit, separate view of the remote tracking refs. It also means we never write into `refs/heads/*` except through fast-forward/CAS, so user-created branches survive every refresh by construction.
|
||||
- `refs/heads/*` becomes user-territory; the sync layer only **proposes** changes there via the diff classifier. Fast-forwards apply automatically; divergence surfaces to UI (Change #3).
|
||||
- `--force-with-lease`-equivalent for ref updates: `git update-ref -m "sessions sync" <ref> <new_sha> <expected_old_sha>`. Atomic CAS primitive. If the expected-old check fails (someone moved the ref between our snapshot and our update), abort and treat as `diverged`.
|
||||
- **Initial seed.** First sync after migration: snapshot is empty, bundles are full ref histories. Same one-shot cost as the v0 tar pull, never repeated. Backfill `refs/sessions/<host>/heads/*` from this first bundle.
|
||||
|
||||
**`updateInstead` is not a free pass.** It updates the working tree only when the index and worktree match the new commit's tree on the paths being updated; on dirty conflict the push is rejected. So even with the config flipped, a remote with edits in flight on the active branch refuses our update. Explicit handling:
|
||||
|
||||
- The CAS-guarded ref update writes the proposed `new_sha` into the remote's ref store regardless of working-tree state — `update-ref` doesn't touch the worktree.
|
||||
- The *separate* working-tree update (the "make the worktree reflect the new HEAD" step, equivalent to `git checkout`) is the part that fails on dirty trees. That's the existing G6 path.
|
||||
- Therefore: split the proxy into ref-mutation (always proceeds via CAS) and worktree-mutation (subject to dirty-tree rejection, retried on next tick). When the worktree update is deferred, the ref already advanced — `for-each-ref` reports the new tip, the local mirror sees it on the next refresh, but the remote *worktree* still shows the old contents until the user resolves dirty state. Surface this state explicitly: status bar `"Branch advanced; remote worktree out of sync (dirty): <files>"`.
|
||||
|
||||
**Failure modes addressed.** This Change kills failure modes #1 (local-only branches survive — they live in `refs/heads/*` which is never wiped), #2 (deletion is detected via the diff and propagated via CAS-guarded `update-ref -d`), #3 (CAS via `expected_old_sha` rejects concurrent moves), and #4 (commit objects are bundled and unbundled before any clobber risk). Failure mode #5 stays for Change #3.
|
||||
|
||||
### Change #3 — Conflict-copy semantics + divergence UI *(closes the working-tree edge case)*
|
||||
|
||||
Two narrow additions for the cases Change #2 surfaces but doesn't auto-resolve:
|
||||
|
||||
```text
|
||||
during materialise(file):
|
||||
if local.mtime > last_fetch.mtime
|
||||
and hash(local) != hash(remote)
|
||||
and hash(local) != hash(last_fetched_remote_for_this_path):
|
||||
write remote bytes to <file>.sessions-conflict-<ts>
|
||||
leave <file> alone
|
||||
enqueue notification
|
||||
|
||||
during reconcile_ref where diff == "diverged":
|
||||
status bar:
|
||||
"Branch <name> diverged: local=<short_sha> remote=<short_sha>.
|
||||
Run `Sessions: Resolve Diverged Refs` to choose."
|
||||
command-palette resolution prompt: [Keep local | Take remote | Open Sublime Merge]
|
||||
```
|
||||
|
||||
`<file>.sessions-conflict-<ts>` is added to `.gitignore` automatically by Sessions (one-time append on first conflict). Resolution is always user-driven; the sync layer never auto-resolves a divergence.
|
||||
|
||||
## 4. What we explicitly do *not* do
|
||||
|
||||
- **No CRDT for refs.** Wrong tool, wrong constraints.
|
||||
- **No CRDT for working-tree text.** Sublime doesn't expose buffer state as a manipulable structure; we'd be shipping a parallel editor. Conflict-copy is the right depth.
|
||||
- **No headless backend.** Foreclosed by Sublime Merge's local-`.git` requirement.
|
||||
- **No live ref polling between refresh ticks.** The existing refresh cadence is good enough; adding an inotify or filesystem watcher is scope-creep until we have a concrete user complaint about latency.
|
||||
- **No replacement of the post-checkout hook proxy.** Keep it as a *latency optimisation* — when it does fire (real `git` binary, e.g., user runs `git checkout` in a terminal against the local mirror), the marker gives us sub-second response. When it doesn't fire (libgit2 inside Sublime Merge), the polling diff in Change #1 catches it on the next tick. Belt + suspenders.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased delivery
|
||||
|
||||
| Phase | Scope | Ships fixes for |
|
||||
|------|------|---|
|
||||
| **A0** | Verify finding A1: does Sublime Merge fire client-side hooks? See §5.1 protocol below | (decides A1+ rationale) |
|
||||
| **A1** | Change #1 — op log + snapshot. Pure addition; no behaviour change. Lets us see what's happening. | Debuggability, not user-visible |
|
||||
| **A2** | Change #2 — refspec sync replaces tar wipe. Largest single change. | Failure modes #1, #2, #3, #4 |
|
||||
| **A3** | Change #3 — conflict copies + divergence UI | Failure mode #5, makes A2's diverged-branch case actionable |
|
||||
|
||||
A0 must complete before A2 design is finalised (it changes the rationale, not the design). A1 ships first because it's pure addition with no risk. A2 + A3 ship together because A3 closes the UX hole A2 opens.
|
||||
|
||||
### 5.1 A0 verification protocol
|
||||
|
||||
Sublime Merge has multiple branch-mutation entry points and may use different code paths for each (libgit2 vs shell-out can vary by operation, by platform, and by Sublime Merge version). A one-bit "did we see a marker" answer doesn't generalise. Run the matrix:
|
||||
|
||||
- **Sublime Merge build to test against:** the latest stable on the user's primary platform. Record the build number in the report.
|
||||
- **Setup per repo:** `install_post_checkout_hook` writes the v0 hook; tail `<.git>/SESSIONS_PENDING_CHECKOUT` and the hook's stderr (redirect via `exec 2>>/tmp/sessions-hook-trace.log` in the hook).
|
||||
- **Operations to exercise** (in order, fresh marker between each):
|
||||
1. Branch checkout — sidebar double-click on an existing branch.
|
||||
2. Branch checkout — command palette `Switch Branch`.
|
||||
3. Branch checkout — context menu on a commit, "Checkout Commit."
|
||||
4. Branch create — sidebar "New Branch" dialog.
|
||||
5. Branch create — `git checkout -b` from the embedded terminal (control: this *must* fire the hook; if it doesn't, the hook itself is broken, not Sublime Merge).
|
||||
6. Branch delete — sidebar right-click "Delete."
|
||||
7. Commit — stage + commit a small change.
|
||||
8. Push — push that commit.
|
||||
- **Per-operation record:** marker file present (Y/N), marker contents (paste verbatim if Y), hook stderr (paste).
|
||||
|
||||
Outcomes that change the plan:
|
||||
|
||||
- Hook fires for ops 1–4: A1 is *partially* false; we have a real ops-capture channel for the user's primary path. Plan rationale shifts but Change #1 (polling diff) is still valuable as backstop for delete/commit/push.
|
||||
- Hook fires only for op 5 (the control): A1 is true for Sublime Merge entirely; Change #1 becomes the sole capture mechanism, hook stays for terminal users only.
|
||||
- Hook fires for none, including op 5: the hook installation itself is broken; investigate that *first* before any A1 conclusion.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks & open questions
|
||||
|
||||
1. **A0 outcome.** If Sublime Merge *does* fire hooks (we were wrong about libgit2), Change #1's polling diff is still a strict improvement, but the urgency drops. Plan stays the same; rationale shifts.
|
||||
2. **`receive.denyCurrentBranch=updateInstead` surprise.** Mutates the user's remote git config. Mitigation: scope per-repo, surface a one-time notification, document in release notes, support opt-out (fall back to current `git checkout` proxy).
|
||||
3. **Object-pack push size.** First sync after adopting Change #2 will push any local-only commits the user accumulated under v0. Could be tens of MB. Mitigation: gate behind a dry-run + confirm.
|
||||
4. **Migration from existing wiped-and-restored `.git` directories.** Some installs will have `refs/sessions/<host>/*` empty until the first Change #2 fetch. Backfill on first run; idempotent.
|
||||
5. **Worktree (`.git` file) repos** — still v1+, deferred. Track G v0 already filters these out (`commands.py:7167`). No regression.
|
||||
6. **Op-log size on busy repos** — refs/heads/* with thousands of entries × N refresh ticks. Mitigation: log only the *diff* (typical: 0–3 entries per tick), rotate at 1000 lines.
|
||||
7. **Concurrent Sessions instances on the same workspace** — two editors open against one host. Today: undefined. Post-A2: each instance's per-repo flock (Change #1 invariant) serialises refresh ticks within an editor; cross-editor contention is also covered because flock is at the OS level on the same `.git/sessions/refresh.lock` file. The losing instance skips its tick and picks up state on the next one.
|
||||
|
||||
8. **Critic adjudication notes (post-review).** This plan was reviewed adversarially before sign-off. The top issue raised — "Change #2's transport story is incoherent" — is addressed by switching from `git fetch ssh://...` to `git bundle` over the existing `exec/once` bridge (§3 Change #2 transport choice). Other significant issues addressed inline: `denyCurrentBranch=updateInstead` on dirty trees (§3 "`updateInstead` is not a free pass"), concurrent refresh atomicity promoted from v1+ to v1 invariant (§3 Change #1 invariants, risk #7), A0 verification protocol made explicit (§5.1), "Undo Last Sync" renamed to forensic "Show Last Sync" (§3 Change #1, "On undo"). Outstanding from the review: bandwidth estimate for `for-each-ref` polling (low priority — order-of-magnitude analysis can land with the A1 implementation; if a thousand-ref repo crosses 100 KB/tick we'll add response compression).
|
||||
|
||||
---
|
||||
|
||||
## 7. Why this is shippable
|
||||
|
||||
- A1 is a pure addition (no behaviour change). Ships behind a feature flag, dark-launches the diff classifier.
|
||||
- A2's footprint replaces `git_dot_git_sync.py:_replace_local_dot_git` (one ~100-line function) with a `git fetch` invocation + a small reconciler. The total spec is **smaller** than what we have.
|
||||
- A3 is two narrow additions, both cheap.
|
||||
- Every change is independently reversible: feature flag at the workspace-state level, fall back to v0 tar-wipe for the duration of a release if A2 ships broken.
|
||||
|
||||
The single most important sentence in this plan: **stop wiping `.git`.** Every other recommendation flows from that, and from the realisation that hooks are a latency optimisation, not the primary ops capture.
|
||||
195
planning/boundary_inventory.yml
Normal file
195
planning/boundary_inventory.yml
Normal file
@@ -0,0 +1,195 @@
|
||||
# Boundary Inventory — single-source-of-truth for Python ↔ Rust 책임 위치
|
||||
#
|
||||
# normative 출처: planning/PYTHON_RUST_BOUNDARY.md "Migration inventory" 표.
|
||||
# 본 YAML은 그 표의 *수동 변환*이며, Wave 2.5에서 LOC 임계 자동 측정과 함께
|
||||
# 자동 동기화로 승격된다. 현 단계(PR 0)에서는 Lint #5 minimal — 시그니처
|
||||
# cross-check 용도.
|
||||
#
|
||||
# Lint #5 (PR 0 minimal): boundary_inventory.yml의 `parsers_banned_in_python`
|
||||
# 목록과 sublime/sessions/ 코드의 def 시그니처를 cross-check. 위반 시 fail.
|
||||
#
|
||||
# 갱신 규칙:
|
||||
# - 슬라이스가 land될 때마다 본 YAML과 PYTHON_RUST_BOUNDARY.md "Migration
|
||||
# inventory" 표를 *같은 PR 안에서* 갱신. drift 발생 시 PR 0의 boundary-claim
|
||||
# 헤더 검증 (Lint #6)이 차단.
|
||||
|
||||
version: 1
|
||||
last_updated: "2026-05-01" # PR 0 land 시점
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python 모듈별 책임 분류
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
modules:
|
||||
# === 1. Sublime API에 결합된 모듈 (Python 영역) ===
|
||||
|
||||
- path: sublime/sessions/commands.py
|
||||
role: sublime-orchestration
|
||||
loc_estimate: 7394
|
||||
rust_home: null # Stays Python (Sublime command shells + EventListeners)
|
||||
notes: |
|
||||
worker loop SM (queue + dispatcher + lane gating + _CONNECT_GENERATION token)은
|
||||
PR 16에서 sessions_orchestrator로 이관 (~600 LOC). 진행 메시지는 Python 유지.
|
||||
Track H2 (commands_runtime_queue.py 등 분할)은 main track과 병행.
|
||||
|
||||
- path: sublime/sessions/commands_file_actions.py
|
||||
role: sublime-orchestration
|
||||
loc_estimate: 769
|
||||
rust_home: null
|
||||
|
||||
- path: sublime/sessions/commands_python_pipeline.py
|
||||
role: sublime-orchestration
|
||||
loc_estimate: 1418
|
||||
rust_home: null
|
||||
notes: |
|
||||
Sublime command shells. 그러나 ruff/pyright pipeline 빌더는 Wave 1.5에서
|
||||
sessions_native::diagnostics_parser로 분리 가능. 평가는 Wave 2 후.
|
||||
|
||||
- path: sublime/sessions/connect_progress.py
|
||||
role: sublime-orchestration
|
||||
loc_estimate: 316
|
||||
rust_home: null
|
||||
|
||||
- path: sublime/sessions/lsp_project_wiring.py
|
||||
role: sublime-orchestration
|
||||
loc_estimate: 640
|
||||
rust_home: local_bridge::lsp_stdio # Wave 2.5 모듈 확장
|
||||
notes: deep-merge 로직만 이관, project file 편집은 Python 유지.
|
||||
|
||||
- path: sublime/sessions/marimo_hosting.py
|
||||
role: sublime-orchestration
|
||||
loc_estimate: 614
|
||||
rust_home: null
|
||||
|
||||
# === 2. 이미 Rust로 부분/전체 이관된 모듈 ===
|
||||
|
||||
- path: sublime/sessions/_rust_ffi.py
|
||||
role: thin-shim-violator # 현재 1337 LOC, thin shim 정량 정의 위반
|
||||
loc_estimate: 1337
|
||||
rust_home: sessions_native::abi_decoders # 디코더만, PR 17+
|
||||
notes: |
|
||||
Wave 1.5 (PR 3–7): 6 모듈 split (loader / workspace / file_policy /
|
||||
tool_runtime / bridge_parsers / broker). 각 ≤ 400 LOC.
|
||||
디코더 (_parse_*_outcome) Rust 이관은 PR 17+.
|
||||
|
||||
- path: sublime/sessions/file_state.py
|
||||
role: sublime-domain
|
||||
loc_estimate: 671
|
||||
rust_home: sessions_native::file_policy # 이미 결정 코드 위임
|
||||
wave: 1.5
|
||||
notes: |
|
||||
PR 10 parity → PR 11 이관. kind_codes 3중 복제 통합 + decision 매핑
|
||||
lookup table. SaveConflict.message 등은 Python single source.
|
||||
|
||||
- path: sublime/sessions/workspace_state.py
|
||||
role: sublime-domain
|
||||
loc_estimate: 636
|
||||
rust_home: workspace_identity
|
||||
wave: 1
|
||||
notes: normalize_remote_root는 Rust 전용; cache_key hashing은 Python 잔존.
|
||||
|
||||
- path: sublime/sessions/ssh_runner.py
|
||||
role: glue
|
||||
loc_estimate: 654
|
||||
rust_home: local_bridge + session_helper
|
||||
wave: 1
|
||||
notes: bootstrap python3 -c 폴백 PR 2에서 청산.
|
||||
|
||||
- path: sublime/sessions/python_interpreter_browser.py
|
||||
role: glue
|
||||
loc_estimate: 244
|
||||
rust_home: session_helper::tree_list
|
||||
wave: 1
|
||||
notes: PR 2 청산 후 helper tree/list 호출.
|
||||
|
||||
- path: sublime/sessions/ssh_file_transport.py
|
||||
role: glue
|
||||
loc_estimate: 2240
|
||||
rust_home: local_bridge + session_helper
|
||||
wave: 1
|
||||
notes: bridge session broker. _payload_method_label은 PR 17+ Rust 이관.
|
||||
|
||||
- path: sublime/sessions/diagnostics.py
|
||||
role: sublime-domain # ruff parsing은 *이미* Rust 일원화 (PR 5.5에서 확인)
|
||||
loc_estimate: 607
|
||||
rust_home: sessions_native::ruff_diagnostics_json # 이미 Rust 위임
|
||||
wave: 1 (완료, 청산 대상 없음)
|
||||
notes: |
|
||||
PR 5.5 인벤토리 정정: line 225-333은 ruff 파서가 *아니라* generic
|
||||
helper dict → DiagnosticRecord 변환 함수. 현재 데이터 흐름:
|
||||
(1) ssh exec → ruff stdout
|
||||
(2) _rust_ffi.parse_ruff_diagnostics(stdout) → helper dicts (Rust)
|
||||
(3) diagnostic_record_from_helper_dict(dict) → record (Python, generic)
|
||||
Step 2가 ruff 전용 파싱 (이미 Rust). Step 3은 generic이라 다른
|
||||
source(pyright, future tools)도 사용 — Python에 정당히 잔존.
|
||||
pyright용 _rust_ffi.parse_pyright_diagnostics 추가는 Wave 2 후.
|
||||
|
||||
- path: sublime/sessions/settings_model.py
|
||||
role: split-target
|
||||
loc_estimate: 494
|
||||
rust_home: sessions_native::settings_normalize
|
||||
wave: 1.5
|
||||
notes: |
|
||||
PR 1: 정규화 함수 ~80 LOC → Rust. load_sessions_settings_from_sublime은
|
||||
Python (Sublime API 결합).
|
||||
|
||||
- path: sublime/sessions/python_interpreter_registry.py
|
||||
role: split-target
|
||||
loc_estimate: 455
|
||||
rust_home: sessions_native::interpreter_probe
|
||||
wave: 1.5
|
||||
notes: |
|
||||
PR 8: 캐시·랭킹 ~100 LOC → Rust. _parse_probe_stdout 정규식 ~30 LOC는
|
||||
Python 잔존 (rust-max 양보 영역).
|
||||
|
||||
- path: sublime/sessions/eager_hydrate.py
|
||||
role: split-target
|
||||
loc_estimate: 247
|
||||
rust_home: local_bridge::remote_cache_mirror
|
||||
wave: 2
|
||||
notes: PR 12 parity → PR 14 이관 (Wave 2 envelope 후).
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lint #1 cross-check 데이터: Python 측에 신규 정의 금지 시그니처
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
parsers_banned_in_python:
|
||||
- parse_ruff
|
||||
- parse_pyright
|
||||
- parse_diagnostic
|
||||
- parse_open_outcome
|
||||
- parse_request_outcome
|
||||
- parse_response_packet
|
||||
- extract_handshake
|
||||
- payload_method_label
|
||||
|
||||
parsers_exempt_paths:
|
||||
- sublime/sessions/_rust_ffi.py # 단일 파일 (PR 0~2 동안)
|
||||
- sublime/sessions/_rust_ffi/ # 6 모듈 split 이후 (PR 3+)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 알려진 grandfather 위반 (PR 0 land 시점 기준)
|
||||
#
|
||||
# 본 항목은 신규 위반이 *아니*고 PR 0 활성화 시 main에 이미 있던 위반.
|
||||
# 후속 PR에서 청산 예정. CI는 diff 기반이라 자동으로 grandfather 처리되지만,
|
||||
# 가시성 위해 명시.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
grandfather_violations:
|
||||
- path: sublime/sessions/ssh_file_transport.py
|
||||
line: 1378
|
||||
pattern: "_payload_method_label"
|
||||
lint: "#1"
|
||||
cleanup_pr: "PR 17+ (디코더 Rust 이관)"
|
||||
|
||||
- path: sublime/sessions/commands_python_pipeline.py
|
||||
line: 639
|
||||
pattern: "time.monotonic"
|
||||
lint: "#2.5"
|
||||
cleanup_pr: "Track H2 분리 시 retry/timeout을 _rust_ffi/bridge로 이동"
|
||||
|
||||
- path: sublime/sessions/marimo_hosting.py
|
||||
line: 427
|
||||
pattern: "python3 -c (remote port pick)"
|
||||
lint: "#3"
|
||||
cleanup_pr: "별도 슬라이스 (marimo `--port 0` 직접 사용 가능 검증 후)"
|
||||
@@ -1,8 +1,11 @@
|
||||
[project]
|
||||
name = "sessions-sublime"
|
||||
version = "0.4.18"
|
||||
version = "0.7.33"
|
||||
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 = [
|
||||
|
||||
54
rust/Cargo.lock
generated
54
rust/Cargo.lock
generated
@@ -41,6 +41,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "doctest-file"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -156,6 +162,19 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interprocess"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "069323743400cb7ab06a8fe5c1ed911d36b6919ec531661d034c89083629595b"
|
||||
dependencies = [
|
||||
"doctest-file",
|
||||
"libc",
|
||||
"recvmsg",
|
||||
"widestring",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -202,10 +221,11 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "local_bridge"
|
||||
version = "0.4.18"
|
||||
version = "0.7.33"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"glob",
|
||||
"interprocess",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -304,6 +324,12 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "recvmsg"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -406,7 +432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_helper"
|
||||
version = "0.4.18"
|
||||
version = "0.7.33"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"notify",
|
||||
@@ -417,7 +443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_protocol"
|
||||
version = "0.4.18"
|
||||
version = "0.7.33"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde",
|
||||
@@ -425,11 +451,21 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sessions_native"
|
||||
version = "0.4.18"
|
||||
name = "sessions_askpass"
|
||||
version = "0.7.33"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sessions_native"
|
||||
version = "0.7.33"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"notify",
|
||||
"serde_json",
|
||||
"session_protocol",
|
||||
"tempfile",
|
||||
"workspace_identity",
|
||||
]
|
||||
|
||||
@@ -537,6 +573,12 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -731,7 +773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "workspace_identity"
|
||||
version = "0.4.18"
|
||||
version = "0.7.33"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
|
||||
@@ -3,6 +3,7 @@ members = [
|
||||
"crates/local_bridge",
|
||||
"crates/session_protocol",
|
||||
"crates/session_helper",
|
||||
"crates/sessions_askpass",
|
||||
"crates/sessions_native",
|
||||
"crates/workspace_identity",
|
||||
]
|
||||
@@ -11,7 +12,16 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
version = "0.4.18"
|
||||
version = "0.7.33"
|
||||
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,12 +3,17 @@ 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
|
||||
|
||||
[dependencies]
|
||||
glob = "0.3"
|
||||
interprocess = "2"
|
||||
regex = "1"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
session_protocol = { path = "../session_protocol" }
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
//! Validated JSON payloads from a remote agent for client-side display (v0).
|
||||
//!
|
||||
//! Schema v1: whitespace-only `title` / `unified_diff` rejected; `schema_version`
|
||||
//! must be a JSON **integer** (not bool/float). The Sublime package calls this
|
||||
//! logic only via `local_bridge parse-agent-editor-envelope`—see
|
||||
//! `planning/PYTHON_RUST_BOUNDARY.md` (single source of truth).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// `sessions.agent_editor_preview`
|
||||
pub const AGENT_EDITOR_PREVIEW_KIND: &str = "sessions.agent_editor_preview";
|
||||
|
||||
/// Supported envelope schema version.
|
||||
pub const SUPPORTED_SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// Pre-rendered text for editor-side preview (diff computed remotely).
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentEditorPayload {
|
||||
pub kind: String,
|
||||
pub schema_version: i32,
|
||||
pub title: String,
|
||||
pub unified_diff: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_remote_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a JSON object into [`AgentEditorPayload`] or return `None` if invalid.
|
||||
pub fn parse_agent_editor_payload(raw: &Value) -> Option<AgentEditorPayload> {
|
||||
let map = raw.as_object()?;
|
||||
let kind = map.get("kind")?.as_str()?;
|
||||
if kind != AGENT_EDITOR_PREVIEW_KIND {
|
||||
return None;
|
||||
}
|
||||
let version = map.get("schema_version")?;
|
||||
let schema_version = match version {
|
||||
Value::Number(n) => {
|
||||
if !n.is_i64() {
|
||||
return None;
|
||||
}
|
||||
let i = n.as_i64()?;
|
||||
if i != SUPPORTED_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
i32::try_from(i).ok()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let title = map.get("title")?.as_str()?;
|
||||
let unified_diff = map.get("unified_diff")?.as_str()?;
|
||||
if title.trim().is_empty() || unified_diff.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = match map.get("target_remote_path") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(s)) => Some(s.clone()),
|
||||
Some(_) => return None,
|
||||
};
|
||||
Some(AgentEditorPayload {
|
||||
kind: kind.to_string(),
|
||||
schema_version,
|
||||
title: title.to_string(),
|
||||
unified_diff: unified_diff.to_string(),
|
||||
target_remote_path: path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse remote command stdout into a payload, or a short error reason.
|
||||
///
|
||||
/// Accepts either a single JSON object or extra lines where the **last** non-empty
|
||||
/// line is the object (prefix log lines).
|
||||
pub fn parse_agent_editor_envelope_from_stdout(
|
||||
text: &str,
|
||||
) -> (Option<AgentEditorPayload>, Option<String>) {
|
||||
let stripped = text.trim();
|
||||
if stripped.is_empty() {
|
||||
return (
|
||||
None,
|
||||
Some("Remote agent stdout was empty (expected one JSON object).".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
let mut first_decode_error: Option<String> = None;
|
||||
let first: Value = match serde_json::from_str(stripped) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
first_decode_error = Some(e.to_string());
|
||||
Value::Null
|
||||
}
|
||||
};
|
||||
|
||||
if let Value::Object(_) = &first
|
||||
&& let Some(parsed) = parse_agent_editor_payload(&first)
|
||||
{
|
||||
return (Some(parsed), None);
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = stripped
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect();
|
||||
|
||||
if lines.is_empty() {
|
||||
let msg = first_decode_error
|
||||
.map(|e| format!("JSON decode failed: {e}"))
|
||||
.unwrap_or_else(|| "JSON decode failed: unknown".to_string());
|
||||
return (None, Some(msg));
|
||||
}
|
||||
|
||||
let last: Value = match serde_json::from_str(lines[lines.len() - 1]) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (None, Some(format!("JSON decode failed: {e}"))),
|
||||
};
|
||||
|
||||
if let Some(parsed) = parse_agent_editor_payload(&last) {
|
||||
return (Some(parsed), None);
|
||||
}
|
||||
|
||||
if !last.is_object() {
|
||||
return (
|
||||
None,
|
||||
Some("JSON root must be an object (mapping).".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"Schema validation failed: expected kind {AGENT_EDITOR_PREVIEW_KIND:?}, schema_version \
|
||||
{SUPPORTED_SCHEMA_VERSION}, non-empty strings title and unified_diff, optional string \
|
||||
target_remote_path."
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let raw = json!({
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "Preview",
|
||||
"unified_diff": "--- a/x\n+++ b/x\n",
|
||||
"target_remote_path": "/srv/app/readme.md",
|
||||
});
|
||||
let parsed = parse_agent_editor_payload(&raw);
|
||||
assert_eq!(
|
||||
parsed,
|
||||
Some(AgentEditorPayload {
|
||||
kind: AGENT_EDITOR_PREVIEW_KIND.to_string(),
|
||||
schema_version: 1,
|
||||
title: "Preview".to_string(),
|
||||
unified_diff: "--- a/x\n+++ b/x\n".to_string(),
|
||||
target_remote_path: Some("/srv/app/readme.md".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_path_omitted() {
|
||||
let raw = json!({
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
});
|
||||
let parsed = parse_agent_editor_payload(&raw);
|
||||
assert!(
|
||||
matches!(&parsed, Some(p) if p.target_remote_path.is_none()),
|
||||
"expected Some(payload) without target_remote_path, got {:?}",
|
||||
parsed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_kind() {
|
||||
let raw = json!({
|
||||
"kind": "other",
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
});
|
||||
assert!(parse_agent_editor_payload(&raw).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_schema() {
|
||||
let raw = json!({
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": 99,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
});
|
||||
assert!(parse_agent_editor_payload(&raw).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_object() {
|
||||
assert!(parse_agent_editor_payload(&json!([])).is_none());
|
||||
assert!(parse_agent_editor_payload(&json!("x")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bool_schema() {
|
||||
let raw = json!({
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": true,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
});
|
||||
assert!(parse_agent_editor_payload(&raw).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_whitespace_title_or_diff() {
|
||||
let raw = json!({
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": " ",
|
||||
"unified_diff": "x",
|
||||
});
|
||||
assert!(parse_agent_editor_payload(&raw).is_none());
|
||||
let raw = json!({
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "ok",
|
||||
"unified_diff": "\n\t\n",
|
||||
});
|
||||
assert!(parse_agent_editor_payload(&raw).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_not_json() {
|
||||
let (p, e) = parse_agent_editor_envelope_from_stdout("not json");
|
||||
assert!(p.is_none());
|
||||
assert!(e.is_some(), "expected err Some");
|
||||
if let Some(err) = e {
|
||||
assert!(err.contains("JSON decode failed"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_schema_failed_message() {
|
||||
let raw = json!({
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": 99,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
});
|
||||
let (p, e) = parse_agent_editor_envelope_from_stdout(&raw.to_string());
|
||||
assert!(p.is_none());
|
||||
assert!(e.is_some(), "expected err Some");
|
||||
if let Some(err) = e {
|
||||
assert!(err.contains("Schema validation failed"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_last_line_wins_with_prefix_logs() {
|
||||
let body = json!({
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "ok",
|
||||
"unified_diff": "diff",
|
||||
});
|
||||
let text = format!("noise line\n{}", body);
|
||||
let (p, e) = parse_agent_editor_envelope_from_stdout(&text);
|
||||
assert!(e.is_none());
|
||||
assert!(p.is_some(), "expected payload Some");
|
||||
if let Some(payload) = p {
|
||||
assert_eq!(payload.title, "ok");
|
||||
}
|
||||
}
|
||||
}
|
||||
591
rust/crates/local_bridge/src/cli.rs
Normal file
591
rust/crates/local_bridge/src/cli.rs
Normal file
@@ -0,0 +1,591 @@
|
||||
//! Command-line argument parsers for the ``local_bridge`` binary.
|
||||
//!
|
||||
//! Two argv shapes are supported:
|
||||
//!
|
||||
//! - ``BridgeCliArgs`` — top-level forwarder mode (and its persistent variant);
|
||||
//! - ``LspStdioCliArgs`` — the ``lsp-stdio`` subcommand that connects to a
|
||||
//! running broker over a local socket.
|
||||
//!
|
||||
//! These were lifted out of ``main.rs`` verbatim during a code-organization
|
||||
//! split; behavior is unchanged. See module-level docs in ``main.rs`` for the
|
||||
//! overall dispatch order.
|
||||
|
||||
use local_bridge::BridgeRunError;
|
||||
|
||||
pub(crate) struct BridgeCliArgs {
|
||||
pub(crate) host_alias: String,
|
||||
pub(crate) revision: String,
|
||||
pub(crate) remote_helper_path: Option<String>,
|
||||
}
|
||||
|
||||
impl BridgeCliArgs {
|
||||
pub(crate) fn parse(args: &[String]) -> Result<Self, BridgeRunError> {
|
||||
let mut host_alias: Option<String> = None;
|
||||
let mut revision: Option<String> = None;
|
||||
let mut remote_helper_path: Option<String> = None;
|
||||
let mut idx = 0usize;
|
||||
|
||||
while idx < args.len() {
|
||||
match args[idx].as_str() {
|
||||
"--host" => {
|
||||
if let Some(value) = args.get(idx + 1) {
|
||||
host_alias = Some(value.clone());
|
||||
idx += 2;
|
||||
} else {
|
||||
return Err(BridgeRunError::HelperLaunchFailed(
|
||||
"--host requires a value".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
"--helper-revision" => {
|
||||
if let Some(value) = args.get(idx + 1) {
|
||||
revision = Some(value.clone());
|
||||
idx += 2;
|
||||
} else {
|
||||
return Err(BridgeRunError::HelperLaunchFailed(
|
||||
"--helper-revision requires a value".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
"--remote-helper-path" => {
|
||||
if let Some(value) = args.get(idx + 1) {
|
||||
remote_helper_path = Some(value.clone());
|
||||
idx += 2;
|
||||
} else {
|
||||
return Err(BridgeRunError::HelperLaunchFailed(
|
||||
"--remote-helper-path requires a value".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
"--persistent" => {
|
||||
idx += 1;
|
||||
}
|
||||
_ => {
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let host_alias = match host_alias {
|
||||
Some(value) if !value.trim().is_empty() => value,
|
||||
_ => {
|
||||
return Err(BridgeRunError::HelperLaunchFailed(
|
||||
"--host is required".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let revision = match revision {
|
||||
Some(value) if !value.trim().is_empty() => value,
|
||||
_ => {
|
||||
return Err(BridgeRunError::HelperLaunchFailed(
|
||||
"--helper-revision is required".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
host_alias,
|
||||
revision,
|
||||
remote_helper_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The parser runs on every platform so Python can hand the same argv to a
|
||||
// Windows build without tripping "unknown arg" before the stub emits the
|
||||
// "Unix domain sockets only" error. Only the Unix ``run_lsp_stdio`` consumes
|
||||
// the spawn/URI-rewrite fields; on Windows they're parsed for symmetry and
|
||||
// then discarded, which looks like dead code to the compiler.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) struct LspStdioCliArgs {
|
||||
pub(crate) bridge_socket: String,
|
||||
pub(crate) server_id: String,
|
||||
pub(crate) workspace_id: String,
|
||||
pub(crate) spawn_argv: Vec<String>,
|
||||
pub(crate) spawn_cwd: Option<String>,
|
||||
pub(crate) lsp_local_uri_prefix: Option<String>,
|
||||
pub(crate) lsp_remote_uri_prefix: Option<String>,
|
||||
}
|
||||
|
||||
impl LspStdioCliArgs {
|
||||
pub(crate) fn parse(args: &[String]) -> Result<Self, BridgeRunError> {
|
||||
let mut bridge_socket: Option<String> = None;
|
||||
let mut server_id: Option<String> = None;
|
||||
let mut workspace_id: Option<String> = None;
|
||||
let mut spawn_argv: Vec<String> = Vec::new();
|
||||
let mut spawn_cwd: Option<String> = None;
|
||||
let mut lsp_local_uri_prefix: Option<String> = None;
|
||||
let mut lsp_remote_uri_prefix: Option<String> = None;
|
||||
let mut idx = 0usize;
|
||||
while idx < args.len() {
|
||||
match args[idx].as_str() {
|
||||
"--bridge-socket" => {
|
||||
let value = args.get(idx + 1).ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed(
|
||||
"--bridge-socket requires a value".to_string(),
|
||||
)
|
||||
})?;
|
||||
bridge_socket = Some(value.clone());
|
||||
idx += 2;
|
||||
}
|
||||
"--server-id" => {
|
||||
let value = args.get(idx + 1).ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed(
|
||||
"--server-id requires a value".to_string(),
|
||||
)
|
||||
})?;
|
||||
server_id = Some(value.clone());
|
||||
idx += 2;
|
||||
}
|
||||
"--workspace-id" => {
|
||||
let value = args.get(idx + 1).ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed(
|
||||
"--workspace-id requires a value".to_string(),
|
||||
)
|
||||
})?;
|
||||
workspace_id = Some(value.clone());
|
||||
idx += 2;
|
||||
}
|
||||
"--spawn-arg" => {
|
||||
let value = args.get(idx + 1).ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed(
|
||||
"--spawn-arg requires a value".to_string(),
|
||||
)
|
||||
})?;
|
||||
spawn_argv.push(value.clone());
|
||||
idx += 2;
|
||||
}
|
||||
"--spawn-cwd" => {
|
||||
let value = args.get(idx + 1).ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed(
|
||||
"--spawn-cwd requires a value".to_string(),
|
||||
)
|
||||
})?;
|
||||
spawn_cwd = Some(value.clone());
|
||||
idx += 2;
|
||||
}
|
||||
"--lsp-local-uri-prefix" => {
|
||||
let value = args.get(idx + 1).ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed(
|
||||
"--lsp-local-uri-prefix requires a value".to_string(),
|
||||
)
|
||||
})?;
|
||||
lsp_local_uri_prefix = Some(value.clone());
|
||||
idx += 2;
|
||||
}
|
||||
"--lsp-remote-uri-prefix" => {
|
||||
let value = args.get(idx + 1).ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed(
|
||||
"--lsp-remote-uri-prefix requires a value".to_string(),
|
||||
)
|
||||
})?;
|
||||
lsp_remote_uri_prefix = Some(value.clone());
|
||||
idx += 2;
|
||||
}
|
||||
other => {
|
||||
return Err(BridgeRunError::HelperLaunchFailed(format!(
|
||||
"unknown lsp-stdio argument: {other}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let bridge_socket = bridge_socket
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed("--bridge-socket is required".to_string())
|
||||
})?;
|
||||
let server_id = server_id.filter(|v| !v.trim().is_empty()).ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed("--server-id is required".to_string())
|
||||
})?;
|
||||
let workspace_id = workspace_id
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
BridgeRunError::HelperLaunchFailed("--workspace-id is required".to_string())
|
||||
})?;
|
||||
Ok(Self {
|
||||
bridge_socket,
|
||||
server_id,
|
||||
workspace_id,
|
||||
spawn_argv,
|
||||
spawn_cwd,
|
||||
lsp_local_uri_prefix,
|
||||
lsp_remote_uri_prefix,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn argv(parts: &[&str]) -> Vec<String> {
|
||||
parts.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
|
||||
fn err_message(err: BridgeRunError) -> String {
|
||||
match err {
|
||||
BridgeRunError::HelperLaunchFailed(msg) => msg,
|
||||
other => unreachable!("unexpected error variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_bridge_err(result: Result<BridgeCliArgs, BridgeRunError>) -> BridgeRunError {
|
||||
match result {
|
||||
Ok(_) => unreachable!("expected BridgeCliArgs::parse to fail"),
|
||||
Err(err) => err,
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_lsp_err(result: Result<LspStdioCliArgs, BridgeRunError>) -> BridgeRunError {
|
||||
match result {
|
||||
Ok(_) => unreachable!("expected LspStdioCliArgs::parse to fail"),
|
||||
Err(err) => err,
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_bridge(args: &[&str], context: &str) -> BridgeCliArgs {
|
||||
match BridgeCliArgs::parse(&argv(args)) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => unreachable!("{context}: {}", err_message(err)),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- BridgeCliArgs ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_parses_required_flags_in_canonical_order() {
|
||||
let parsed = ok_bridge(
|
||||
&["--host", "celery", "--helper-revision", "0.6.12"],
|
||||
"required flags should parse",
|
||||
);
|
||||
assert_eq!(parsed.host_alias, "celery");
|
||||
assert_eq!(parsed.revision, "0.6.12");
|
||||
assert!(parsed.remote_helper_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_accepts_flags_in_any_order() {
|
||||
let parsed = ok_bridge(
|
||||
&[
|
||||
"--helper-revision",
|
||||
"0.6.12",
|
||||
"--remote-helper-path",
|
||||
"/srv/helper",
|
||||
"--host",
|
||||
"celery",
|
||||
],
|
||||
"flag order is irrelevant",
|
||||
);
|
||||
assert_eq!(parsed.host_alias, "celery");
|
||||
assert_eq!(parsed.revision, "0.6.12");
|
||||
assert_eq!(parsed.remote_helper_path.as_deref(), Some("/srv/helper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_accepts_persistent_flag_without_value() {
|
||||
let parsed = ok_bridge(
|
||||
&[
|
||||
"--persistent",
|
||||
"--host",
|
||||
"celery",
|
||||
"--helper-revision",
|
||||
"0.6.12",
|
||||
],
|
||||
"--persistent toggles only the dispatch path; no value expected",
|
||||
);
|
||||
assert_eq!(parsed.host_alias, "celery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_silently_skips_unknown_args() {
|
||||
// Forwarder mode receives Python-side knobs that may not exist in older
|
||||
// bridge binaries; the contract is "ignore extras, fail only on missing
|
||||
// required values" so a deploy mid-rollout doesn't reject good payloads.
|
||||
let parsed = ok_bridge(
|
||||
&[
|
||||
"--host",
|
||||
"celery",
|
||||
"--helper-revision",
|
||||
"0.6.12",
|
||||
"--future-flag",
|
||||
"--also-unknown",
|
||||
"value",
|
||||
],
|
||||
"unknown args must not break compatibility",
|
||||
);
|
||||
assert_eq!(parsed.host_alias, "celery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_rejects_missing_host_value() {
|
||||
let err = expect_bridge_err(BridgeCliArgs::parse(&argv(&["--host"])));
|
||||
assert!(err_message(err).contains("--host requires a value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_rejects_missing_helper_revision_value() {
|
||||
let err = expect_bridge_err(BridgeCliArgs::parse(&argv(&[
|
||||
"--host",
|
||||
"celery",
|
||||
"--helper-revision",
|
||||
])));
|
||||
assert!(err_message(err).contains("--helper-revision requires a value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_rejects_missing_remote_helper_path_value() {
|
||||
let err = expect_bridge_err(BridgeCliArgs::parse(&argv(&[
|
||||
"--host",
|
||||
"celery",
|
||||
"--helper-revision",
|
||||
"0.6.12",
|
||||
"--remote-helper-path",
|
||||
])));
|
||||
assert!(err_message(err).contains("--remote-helper-path requires a value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_rejects_blank_host_value() {
|
||||
// Whitespace-only values are treated as missing so `--host "" ...`
|
||||
// can't accidentally succeed and produce nonsense ssh aliases later.
|
||||
let err = expect_bridge_err(BridgeCliArgs::parse(&argv(&[
|
||||
"--host",
|
||||
" ",
|
||||
"--helper-revision",
|
||||
"0.6.12",
|
||||
])));
|
||||
assert!(err_message(err).contains("--host is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_rejects_blank_revision_value() {
|
||||
let err = expect_bridge_err(BridgeCliArgs::parse(&argv(&[
|
||||
"--host",
|
||||
"celery",
|
||||
"--helper-revision",
|
||||
" \t ",
|
||||
])));
|
||||
assert!(err_message(err).contains("--helper-revision is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_rejects_when_required_flags_absent_from_empty_argv() {
|
||||
let err = expect_bridge_err(BridgeCliArgs::parse(&argv(&[])));
|
||||
assert!(err_message(err).contains("--host is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_cli_last_value_wins_when_flag_is_repeated() {
|
||||
// Idempotent re-passing (e.g., during a rebroadcast) should leave the
|
||||
// most recent value in place rather than reject — matches argv parsers
|
||||
// in the rest of the workspace.
|
||||
let parsed = ok_bridge(
|
||||
&[
|
||||
"--host",
|
||||
"first",
|
||||
"--host",
|
||||
"second",
|
||||
"--helper-revision",
|
||||
"0.6.12",
|
||||
],
|
||||
"repeated --host should resolve to the last value",
|
||||
);
|
||||
assert_eq!(parsed.host_alias, "second");
|
||||
}
|
||||
|
||||
// ----- LspStdioCliArgs --------------------------------------------------
|
||||
|
||||
fn ok_lsp(args: &[&str]) -> LspStdioCliArgs {
|
||||
match LspStdioCliArgs::parse(&argv(args)) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => unreachable!(
|
||||
"expected LspStdioCliArgs::parse to succeed; got: {}",
|
||||
err_message(err)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_of(args: &[String], needle: &str) -> usize {
|
||||
match args.iter().position(|a| a == needle) {
|
||||
Some(pos) => pos,
|
||||
None => unreachable!("expected '{needle}' in args: {args:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_stdio_cli_parses_minimal_required_set() {
|
||||
let parsed = ok_lsp(&[
|
||||
"--bridge-socket",
|
||||
"/tmp/sock",
|
||||
"--server-id",
|
||||
"LSP-pyright",
|
||||
"--workspace-id",
|
||||
"abc123",
|
||||
]);
|
||||
assert_eq!(parsed.bridge_socket, "/tmp/sock");
|
||||
assert_eq!(parsed.server_id, "LSP-pyright");
|
||||
assert_eq!(parsed.workspace_id, "abc123");
|
||||
assert!(parsed.spawn_argv.is_empty());
|
||||
assert!(parsed.spawn_cwd.is_none());
|
||||
assert!(parsed.lsp_local_uri_prefix.is_none());
|
||||
assert!(parsed.lsp_remote_uri_prefix.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_stdio_cli_collects_repeated_spawn_args_in_order() {
|
||||
let parsed = ok_lsp(&[
|
||||
"--bridge-socket",
|
||||
"/tmp/sock",
|
||||
"--server-id",
|
||||
"LSP-pyright",
|
||||
"--workspace-id",
|
||||
"abc",
|
||||
"--spawn-arg",
|
||||
"pyright-langserver",
|
||||
"--spawn-arg",
|
||||
"--stdio",
|
||||
"--spawn-arg",
|
||||
"--watchExtensions",
|
||||
"--spawn-arg",
|
||||
"py,pyi",
|
||||
]);
|
||||
assert_eq!(
|
||||
parsed.spawn_argv,
|
||||
vec![
|
||||
"pyright-langserver",
|
||||
"--stdio",
|
||||
"--watchExtensions",
|
||||
"py,pyi"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_stdio_cli_captures_optional_uri_prefix_pair() {
|
||||
let parsed = ok_lsp(&[
|
||||
"--bridge-socket",
|
||||
"/tmp/sock",
|
||||
"--server-id",
|
||||
"LSP-ruff",
|
||||
"--workspace-id",
|
||||
"abc",
|
||||
"--lsp-local-uri-prefix",
|
||||
"file:///cache/abc",
|
||||
"--lsp-remote-uri-prefix",
|
||||
"file:///srv/repo",
|
||||
"--spawn-cwd",
|
||||
"/srv/repo",
|
||||
]);
|
||||
assert_eq!(parsed.spawn_cwd.as_deref(), Some("/srv/repo"));
|
||||
assert_eq!(
|
||||
parsed.lsp_local_uri_prefix.as_deref(),
|
||||
Some("file:///cache/abc"),
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.lsp_remote_uri_prefix.as_deref(),
|
||||
Some("file:///srv/repo"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_stdio_cli_rejects_unknown_flag() {
|
||||
// Unlike BridgeCliArgs, this parser is strict because Python only
|
||||
// wires it for the lsp-stdio subcommand — an unexpected flag is a
|
||||
// sign of a mismatched plugin/binary version that we want to surface
|
||||
// immediately rather than silently ignore.
|
||||
let err = expect_lsp_err(LspStdioCliArgs::parse(&argv(&[
|
||||
"--bridge-socket",
|
||||
"/tmp/sock",
|
||||
"--server-id",
|
||||
"x",
|
||||
"--workspace-id",
|
||||
"y",
|
||||
"--no-such-flag",
|
||||
])));
|
||||
let msg = err_message(err);
|
||||
assert!(msg.contains("unknown lsp-stdio argument"));
|
||||
assert!(msg.contains("--no-such-flag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_stdio_cli_rejects_each_required_flag_individually() {
|
||||
for missing in ["--bridge-socket", "--server-id", "--workspace-id"] {
|
||||
let mut args = vec![
|
||||
"--bridge-socket".to_string(),
|
||||
"/tmp/sock".to_string(),
|
||||
"--server-id".to_string(),
|
||||
"x".to_string(),
|
||||
"--workspace-id".to_string(),
|
||||
"y".to_string(),
|
||||
];
|
||||
// Drop the (flag, value) pair for ``missing`` so the caller can
|
||||
// verify each "required" check fires independently.
|
||||
let pos = position_of(&args, missing);
|
||||
args.drain(pos..pos + 2);
|
||||
let err = expect_lsp_err(LspStdioCliArgs::parse(&args));
|
||||
let msg = err_message(err);
|
||||
assert!(
|
||||
msg.contains(missing),
|
||||
"expected '{missing}' in error message: {msg}"
|
||||
);
|
||||
assert!(msg.contains("required"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_stdio_cli_rejects_each_value_taking_flag_when_value_omitted() {
|
||||
let prefix = vec![
|
||||
"--bridge-socket".to_string(),
|
||||
"/tmp/sock".to_string(),
|
||||
"--server-id".to_string(),
|
||||
"x".to_string(),
|
||||
"--workspace-id".to_string(),
|
||||
"y".to_string(),
|
||||
];
|
||||
for orphan in [
|
||||
"--bridge-socket",
|
||||
"--server-id",
|
||||
"--workspace-id",
|
||||
"--spawn-arg",
|
||||
"--spawn-cwd",
|
||||
"--lsp-local-uri-prefix",
|
||||
"--lsp-remote-uri-prefix",
|
||||
] {
|
||||
let mut args = prefix.clone();
|
||||
args.push(orphan.to_string());
|
||||
let err = expect_lsp_err(LspStdioCliArgs::parse(&args));
|
||||
let msg = err_message(err);
|
||||
assert!(
|
||||
msg.contains(&format!("{orphan} requires a value")),
|
||||
"expected '{orphan} requires a value' in error: {msg}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_stdio_cli_rejects_blank_required_values() {
|
||||
for (flag, expected) in [
|
||||
("--bridge-socket", "--bridge-socket is required"),
|
||||
("--server-id", "--server-id is required"),
|
||||
("--workspace-id", "--workspace-id is required"),
|
||||
] {
|
||||
let mut args = vec![
|
||||
"--bridge-socket".to_string(),
|
||||
"/tmp/sock".to_string(),
|
||||
"--server-id".to_string(),
|
||||
"x".to_string(),
|
||||
"--workspace-id".to_string(),
|
||||
"y".to_string(),
|
||||
];
|
||||
// Replace the value slot for ``flag`` with whitespace so the
|
||||
// ``filter(...).ok_or_else(...)`` blank-rejection branch fires.
|
||||
let pos = position_of(&args, flag);
|
||||
args[pos + 1] = " ".to_string();
|
||||
let err = expect_lsp_err(LspStdioCliArgs::parse(&args));
|
||||
assert!(
|
||||
err_message(err).contains(expected),
|
||||
"expected '{expected}' for {flag}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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``;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//! - validate the helper handshake
|
||||
//! - forward requests and return responses/errors
|
||||
//! - mirror remote directory trees into a local cache ([`remote_cache_mirror`])
|
||||
//! - parse agent→editor JSON envelopes ([`agent_remote_payload`])
|
||||
//!
|
||||
//! # Examples
|
||||
//!
|
||||
@@ -18,7 +17,6 @@
|
||||
//! assert!(default_remote_helper_path().contains("session_helper"));
|
||||
//! ```
|
||||
|
||||
pub mod agent_remote_payload;
|
||||
pub mod diag_log;
|
||||
pub mod helper_command;
|
||||
pub mod lsp_uri_rewrite;
|
||||
@@ -27,7 +25,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 +746,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) => {
|
||||
|
||||
672
rust/crates/local_bridge/src/lsp_stdio.rs
Normal file
672
rust/crates/local_bridge/src/lsp_stdio.rs
Normal file
@@ -0,0 +1,672 @@
|
||||
//! LSP-over-stdio relay loop and the ``lsp-stdio`` subcommand entrypoint.
|
||||
//!
|
||||
//! Three concerns live here:
|
||||
//!
|
||||
//! 1. ``lsp_transform_message`` — the pure per-frame transform: URI rewrite
|
||||
//! (direction picked via [`LspMessageFlow`]) and optional spawn-hint
|
||||
//! injection on the very first frame. Tested in this module.
|
||||
//!
|
||||
//! 2. ``broker_lsp_relay_loop`` — the inner per-client loop the persistent
|
||||
//! broker (in ``persistent.rs``) hands an attached ``IpcStream`` to. It
|
||||
//! pumps framed LSP messages through the helper via ``HelperDispatcher``,
|
||||
//! delegating each frame's transform to ``lsp_transform_message``.
|
||||
//!
|
||||
//! 3. ``run_lsp_stdio`` — the ``lsp-stdio`` subcommand: a thin client that
|
||||
//! connects to a running broker socket, sends an attach handshake, and
|
||||
//! then proxies ``stdin``↔socket↔``stdout``. Cross-platform via
|
||||
//! ``interprocess`` 2.x — `AF_UNIX` on Unix, Named Pipe on Windows.
|
||||
//!
|
||||
//! Cut out of ``main.rs`` during a code-organization split; behavior is
|
||||
//! unchanged.
|
||||
|
||||
use crate::cli::LspStdioCliArgs;
|
||||
use crate::persistent::{HelperDispatcher, lsp_response_body_to_framed_string};
|
||||
use interprocess::TryClone;
|
||||
use interprocess::local_socket::{
|
||||
GenericFilePath, Stream as IpcStream, ToFsName, traits::Stream as IpcStreamTrait,
|
||||
};
|
||||
use local_bridge::BridgeRunError;
|
||||
use local_bridge::bridge_diag_event;
|
||||
use local_bridge::lsp_uri_rewrite::rewrite_uri_strings;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use session_protocol::RequestEnvelope;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub(crate) struct BrokerLspRelayCfg {
|
||||
pub(crate) dispatcher: HelperDispatcher,
|
||||
pub(crate) server_id: String,
|
||||
pub(crate) workspace_id: String,
|
||||
pub(crate) spawn_argv: Option<Vec<String>>,
|
||||
pub(crate) spawn_cwd: Option<String>,
|
||||
pub(crate) uri_rewrite: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// Direction of a single LSP frame across the broker boundary.
|
||||
///
|
||||
/// Made explicit at the type level so the URI-rewrite step picks the right
|
||||
/// (from, to) pair without the call site having to remember the convention.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum LspMessageFlow {
|
||||
/// Editor-side process wrote a frame heading to the helper-hosted server.
|
||||
LocalToBroker,
|
||||
/// Helper-hosted server returned a frame heading back to the editor.
|
||||
BrokerToLocal,
|
||||
}
|
||||
|
||||
/// Spawn-argv hint injected into the very first ``LocalToBroker`` frame so
|
||||
/// the helper knows which binary + cwd to launch for this LSP session.
|
||||
pub(crate) struct LspSpawnInjection<'a> {
|
||||
pub(crate) argv: &'a [String],
|
||||
pub(crate) cwd: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Pure-function transform for one LSP frame.
|
||||
///
|
||||
/// Mutates ``body`` in place: applies URI rewriting when ``uri_rewrite`` is
|
||||
/// ``Some`` (picking the direction implied by ``flow``) and, if
|
||||
/// ``spawn_injection`` is supplied, attaches a ``_sessions_lsp_spawn`` object
|
||||
/// to the JSON object root. The function performs no I/O and never inspects
|
||||
/// the ``first``-message flag — the caller decides whether to pass
|
||||
/// ``Some(LspSpawnInjection)`` at most once, which preserves the existing
|
||||
/// idempotency guarantee.
|
||||
///
|
||||
/// Returns the same ``method`` hint that the prior inline implementation
|
||||
/// emitted on the ``bridge.rust.lsp_stdio_broker_out`` diagnostic, so the
|
||||
/// transport relay does not need to re-inspect ``body``.
|
||||
pub(crate) fn lsp_transform_message(
|
||||
flow: LspMessageFlow,
|
||||
body: &mut serde_json::Value,
|
||||
uri_rewrite: Option<(&str, &str)>,
|
||||
spawn_injection: Option<LspSpawnInjection<'_>>,
|
||||
) -> String {
|
||||
if let Some((local_p, remote_p)) = uri_rewrite {
|
||||
let (from, to) = match flow {
|
||||
LspMessageFlow::LocalToBroker => (local_p, remote_p),
|
||||
LspMessageFlow::BrokerToLocal => (remote_p, local_p),
|
||||
};
|
||||
rewrite_uri_strings(body, from, to);
|
||||
}
|
||||
if let Some(spawn) = spawn_injection
|
||||
&& !spawn.argv.is_empty()
|
||||
&& let Some(obj) = body.as_object_mut()
|
||||
{
|
||||
obj.insert(
|
||||
"_sessions_lsp_spawn".to_string(),
|
||||
json!({
|
||||
"argv": spawn.argv,
|
||||
"cwd": spawn.cwd,
|
||||
}),
|
||||
);
|
||||
}
|
||||
body.get("method")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("(response-or-notification)")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn broker_lsp_relay_loop(
|
||||
mut reader: std::io::BufReader<IpcStream>,
|
||||
writer: &mut IpcStream,
|
||||
cfg: BrokerLspRelayCfg,
|
||||
) -> Result<(), BridgeRunError> {
|
||||
use std::io::ErrorKind;
|
||||
let BrokerLspRelayCfg {
|
||||
dispatcher,
|
||||
server_id,
|
||||
workspace_id,
|
||||
spawn_argv,
|
||||
spawn_cwd,
|
||||
uri_rewrite,
|
||||
} = cfg;
|
||||
let channel = format!("lsp:{server_id}");
|
||||
let seq = AtomicU64::new(0);
|
||||
let mut first = true;
|
||||
bridge_diag_event(
|
||||
"bridge.rust.lsp_stdio_broker_session",
|
||||
json!({
|
||||
"server_id": server_id,
|
||||
"workspace_id": workspace_id,
|
||||
"uri_rewrite": uri_rewrite.is_some(),
|
||||
}),
|
||||
);
|
||||
let uri_rewrite_pair = uri_rewrite
|
||||
.as_ref()
|
||||
.map(|(loc, rem)| (loc.as_str(), rem.as_str()));
|
||||
loop {
|
||||
let payload = match session_protocol::read_lsp_message(&mut reader) {
|
||||
Ok(text) => text,
|
||||
Err(error) if error.kind() == ErrorKind::UnexpectedEof => break,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut body: serde_json::Value =
|
||||
serde_json::from_str(&payload).map_err(BridgeRunError::Json)?;
|
||||
let spawn_injection = if first {
|
||||
spawn_argv
|
||||
.as_deref()
|
||||
.filter(|argv| !argv.is_empty())
|
||||
.map(|argv| LspSpawnInjection {
|
||||
argv,
|
||||
cwd: spawn_cwd.as_deref(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let method_hint = lsp_transform_message(
|
||||
LspMessageFlow::LocalToBroker,
|
||||
&mut body,
|
||||
uri_rewrite_pair,
|
||||
spawn_injection,
|
||||
);
|
||||
first = false;
|
||||
bridge_diag_event(
|
||||
"bridge.rust.lsp_stdio_broker_out",
|
||||
json!({
|
||||
"server_id": server_id,
|
||||
"method": method_hint,
|
||||
"payload_chars": payload.len(),
|
||||
}),
|
||||
);
|
||||
let envelope_id = format!(
|
||||
"lsp-broker-{}-{}-{}",
|
||||
workspace_id,
|
||||
server_id,
|
||||
seq.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let envelope = RequestEnvelope {
|
||||
id: envelope_id,
|
||||
method: session_protocol::METHOD_CHANNEL_DISPATCH.to_string(),
|
||||
params: json!({
|
||||
"v": session_protocol::CHANNEL_ENVELOPE_V1,
|
||||
"channel": channel,
|
||||
"kind": session_protocol::CHANNEL_KIND_REQUEST,
|
||||
"body": body,
|
||||
}),
|
||||
timeout_ms: 120_000,
|
||||
trace: session_protocol::TraceLevel::Info,
|
||||
};
|
||||
let mut result = dispatcher.request_blocking(&envelope)?;
|
||||
let _ = lsp_transform_message(
|
||||
LspMessageFlow::BrokerToLocal,
|
||||
&mut result,
|
||||
uri_rewrite_pair,
|
||||
None,
|
||||
);
|
||||
let out = lsp_response_body_to_framed_string(&result)?;
|
||||
bridge_diag_event(
|
||||
"bridge.rust.lsp_stdio_broker_in",
|
||||
json!({
|
||||
"server_id": server_id,
|
||||
"response_chars": out.len(),
|
||||
}),
|
||||
);
|
||||
session_protocol::write_lsp_message(writer, &out).map_err(BridgeRunError::Io)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_lsp_stdio(args: &[String]) -> Result<(), BridgeRunError> {
|
||||
let cli = LspStdioCliArgs::parse(args)?;
|
||||
bridge_diag_event(
|
||||
"bridge.rust.lsp_stdio_start",
|
||||
json!({
|
||||
"server_id": cli.server_id,
|
||||
"workspace_id": cli.workspace_id,
|
||||
"spawn_argc": cli.spawn_argv.len(),
|
||||
"spawn_cwd_set": cli.spawn_cwd.as_ref().is_some_and(|s| !s.trim().is_empty()),
|
||||
"uri_rewrite_set": cli.lsp_local_uri_prefix.is_some()
|
||||
&& cli.lsp_remote_uri_prefix.is_some(),
|
||||
}),
|
||||
);
|
||||
// Cross-platform connect via interprocess: Unix → AF_UNIX, Windows → Named Pipe.
|
||||
let endpoint = std::path::Path::new(&cli.bridge_socket)
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.map_err(|error| {
|
||||
BridgeRunError::HelperLaunchFailed(format!(
|
||||
"broker endpoint name failed: {error} (bridge_socket={})",
|
||||
cli.bridge_socket
|
||||
))
|
||||
})?;
|
||||
let mut stream = IpcStream::connect(endpoint)?;
|
||||
let mut attach = json!({
|
||||
"kind": "attach",
|
||||
"server_id": cli.server_id,
|
||||
"workspace_id": cli.workspace_id,
|
||||
});
|
||||
if let Some(obj) = attach.as_object_mut() {
|
||||
let argv_opt = (!cli.spawn_argv.is_empty()).then(|| cli.spawn_argv.clone());
|
||||
json_insert_optional(obj, "argv", argv_opt).map_err(BridgeRunError::Json)?;
|
||||
let cwd_opt = cli
|
||||
.spawn_cwd
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty());
|
||||
json_insert_optional(obj, "cwd", cwd_opt).map_err(BridgeRunError::Json)?;
|
||||
let local_prefix_opt = cli
|
||||
.lsp_local_uri_prefix
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty());
|
||||
json_insert_optional(obj, "lsp_local_uri_prefix", local_prefix_opt)
|
||||
.map_err(BridgeRunError::Json)?;
|
||||
let remote_prefix_opt = cli
|
||||
.lsp_remote_uri_prefix
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty());
|
||||
json_insert_optional(obj, "lsp_remote_uri_prefix", remote_prefix_opt)
|
||||
.map_err(BridgeRunError::Json)?;
|
||||
}
|
||||
writeln!(
|
||||
stream,
|
||||
"{}",
|
||||
serde_json::to_string(&attach).map_err(BridgeRunError::Json)?
|
||||
)?;
|
||||
stream.flush()?;
|
||||
|
||||
let stream_for_read = stream.try_clone()?;
|
||||
let mut ack_reader = std::io::BufReader::new(stream_for_read);
|
||||
let mut ack_line = String::new();
|
||||
ack_reader.read_line(&mut ack_line)?;
|
||||
let ack: crate::persistent::BrokerAttachResponse =
|
||||
serde_json::from_str(ack_line.trim()).map_err(BridgeRunError::Json)?;
|
||||
if !ack.ok {
|
||||
return Err(BridgeRunError::HelperLaunchFailed(
|
||||
ack.error
|
||||
.unwrap_or_else(|| "broker attach failed".to_string()),
|
||||
));
|
||||
}
|
||||
bridge_diag_event(
|
||||
"bridge.rust.lsp_stdio_attach_ok",
|
||||
json!({
|
||||
"server_id": cli.server_id,
|
||||
"workspace_id": cli.workspace_id,
|
||||
}),
|
||||
);
|
||||
|
||||
let mut stream_writer = stream.try_clone()?;
|
||||
let writer_handle = std::thread::spawn(move || {
|
||||
let mut stdin = std::io::stdin().lock();
|
||||
let _ = std::io::copy(&mut stdin, &mut stream_writer);
|
||||
});
|
||||
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let mut stream_reader = stream;
|
||||
std::io::copy(&mut stream_reader, &mut stdout)?;
|
||||
let _ = writer_handle.join();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert ``value`` into ``obj`` under ``key`` only when ``value`` is ``Some``.
|
||||
///
|
||||
/// The helper does not interpret the inner ``T``; callers that want to skip
|
||||
/// empty / whitespace-only strings should pre-trim and filter to ``None`` at
|
||||
/// the call site (the existing call sites differ in whether they perform that
|
||||
/// extra check, so keeping the helper minimal preserves their individual
|
||||
/// semantics).
|
||||
fn json_insert_optional<T: Serialize>(
|
||||
obj: &mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
value: Option<T>,
|
||||
) -> Result<(), serde_json::Error> {
|
||||
if let Some(inner) = value {
|
||||
let encoded = serde_json::to_value(inner)?;
|
||||
obj.insert(key.to_string(), encoded);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_rewrites_text_document_uri_local_to_broker() {
|
||||
let mut body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/didOpen",
|
||||
"params": {
|
||||
"textDocument": {
|
||||
"uri": "file:///local/cache/ws/src/main.rs",
|
||||
"languageId": "rust"
|
||||
}
|
||||
}
|
||||
});
|
||||
let local = "file:///local/cache/ws";
|
||||
let remote = "file:///remote/proj";
|
||||
let method = lsp_transform_message(
|
||||
LspMessageFlow::LocalToBroker,
|
||||
&mut body,
|
||||
Some((local, remote)),
|
||||
None,
|
||||
);
|
||||
assert_eq!(method, "textDocument/didOpen");
|
||||
assert_eq!(
|
||||
body.pointer("/params/textDocument/uri")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("file:///remote/proj/src/main.rs"),
|
||||
);
|
||||
// Reverse direction undoes the rewrite.
|
||||
let _ = lsp_transform_message(
|
||||
LspMessageFlow::BrokerToLocal,
|
||||
&mut body,
|
||||
Some((local, remote)),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/params/textDocument/uri")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("file:///local/cache/ws/src/main.rs"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_spawn_injection_idempotent_via_caller_gating() {
|
||||
let argv = vec!["rust-analyzer".to_string(), "--stdio".to_string()];
|
||||
let cwd = "/remote/proj".to_string();
|
||||
let mut body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
});
|
||||
|
||||
// Caller passes Some(...) only on the first frame.
|
||||
let _ = lsp_transform_message(
|
||||
LspMessageFlow::LocalToBroker,
|
||||
&mut body,
|
||||
None,
|
||||
Some(LspSpawnInjection {
|
||||
argv: &argv,
|
||||
cwd: Some(cwd.as_str()),
|
||||
}),
|
||||
);
|
||||
let spawn = body.get("_sessions_lsp_spawn");
|
||||
assert!(spawn.is_some(), "first transform must inject spawn hint");
|
||||
assert_eq!(
|
||||
spawn
|
||||
.and_then(|s| s.get("argv"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len()),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(
|
||||
spawn.and_then(|s| s.get("cwd")).and_then(|v| v.as_str()),
|
||||
Some("/remote/proj")
|
||||
);
|
||||
|
||||
// Second frame: caller passes None, so no further injection.
|
||||
let mut body2 = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "textDocument/didChange",
|
||||
"params": {}
|
||||
});
|
||||
let _ = lsp_transform_message(LspMessageFlow::LocalToBroker, &mut body2, None, None);
|
||||
assert!(
|
||||
body2.get("_sessions_lsp_spawn").is_none(),
|
||||
"subsequent transforms must not re-inject spawn hint"
|
||||
);
|
||||
}
|
||||
|
||||
// ----- lsp_transform_message: edge cases -------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_returns_method_for_request_frame() {
|
||||
let mut body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "textDocument/definition",
|
||||
"params": {}
|
||||
});
|
||||
let label = lsp_transform_message(LspMessageFlow::LocalToBroker, &mut body, None, None);
|
||||
assert_eq!(label, "textDocument/definition");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_uses_response_placeholder_when_method_absent() {
|
||||
// Server-side responses (replies to a request) have ``id`` + ``result``
|
||||
// but no ``method`` — the diag log carries the placeholder so that
|
||||
// grep'ing for the method label still works against stream snapshots.
|
||||
let mut body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"result": { "items": [] }
|
||||
});
|
||||
let label = lsp_transform_message(LspMessageFlow::BrokerToLocal, &mut body, None, None);
|
||||
assert_eq!(label, "(response-or-notification)");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_uses_response_placeholder_when_method_is_not_a_string() {
|
||||
// Defensive: a malformed/notification-shaped frame where method got
|
||||
// set to a non-string (a list or null) must not crash and must fall
|
||||
// back to the placeholder.
|
||||
let mut body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": 42,
|
||||
"params": {}
|
||||
});
|
||||
let label = lsp_transform_message(LspMessageFlow::LocalToBroker, &mut body, None, None);
|
||||
assert_eq!(label, "(response-or-notification)");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_skips_uri_rewrite_when_pair_is_none() {
|
||||
// Workspaces without a Sessions cache root pass ``uri_rewrite=None``;
|
||||
// the body must come back byte-identical (no hidden re-serialization
|
||||
// of inner URIs).
|
||||
let original = json!({
|
||||
"method": "textDocument/didOpen",
|
||||
"params": {
|
||||
"textDocument": { "uri": "file:///left/alone", "languageId": "rust" }
|
||||
}
|
||||
});
|
||||
let mut body = original.clone();
|
||||
let _ = lsp_transform_message(LspMessageFlow::LocalToBroker, &mut body, None, None);
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_skips_spawn_injection_for_empty_argv() {
|
||||
// The ``!spawn.argv.is_empty()`` short-circuit is what keeps an
|
||||
// accidental ``--spawn-arg ""`` from filling the helper's spawn hint
|
||||
// with garbage. Confirms that branch is exercised.
|
||||
let argv: Vec<String> = Vec::new();
|
||||
let mut body = json!({ "method": "initialize" });
|
||||
let _ = lsp_transform_message(
|
||||
LspMessageFlow::LocalToBroker,
|
||||
&mut body,
|
||||
None,
|
||||
Some(LspSpawnInjection {
|
||||
argv: &argv,
|
||||
cwd: Some("/no/effect"),
|
||||
}),
|
||||
);
|
||||
assert!(body.get("_sessions_lsp_spawn").is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_skips_spawn_injection_when_body_is_not_an_object() {
|
||||
// LSP frames are always JSON objects in practice, but the transform
|
||||
// is conservatively defensive — exposing a non-object body must not
|
||||
// panic and must leave the value untouched.
|
||||
let argv = vec!["bin".to_string()];
|
||||
let mut body = json!([1, 2, 3]);
|
||||
let _ = lsp_transform_message(
|
||||
LspMessageFlow::LocalToBroker,
|
||||
&mut body,
|
||||
None,
|
||||
Some(LspSpawnInjection {
|
||||
argv: &argv,
|
||||
cwd: None,
|
||||
}),
|
||||
);
|
||||
assert_eq!(body, json!([1, 2, 3]));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_broker_to_local_inverts_uri_pair_direction() {
|
||||
// Server replied with a definition target encoded under the *remote*
|
||||
// root; the relay must rewrite back to the *local* cache mirror so
|
||||
// Sublime opens the right local path.
|
||||
let mut body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"uri": "file:///remote/proj/src/lib.rs",
|
||||
"range": { "start": { "line": 0, "character": 0 } }
|
||||
}
|
||||
});
|
||||
let _ = lsp_transform_message(
|
||||
LspMessageFlow::BrokerToLocal,
|
||||
&mut body,
|
||||
Some(("file:///local/cache/ws", "file:///remote/proj")),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/result/uri").and_then(|v| v.as_str()),
|
||||
Some("file:///local/cache/ws/src/lib.rs"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lsp_transform_spawn_injection_emits_null_cwd_when_unset() {
|
||||
// Pyright is invoked via the common spawn pattern WITHOUT a
|
||||
// ``--spawn-cwd``; the helper code on the other end must see a
|
||||
// JSON ``null`` (not the key being absent) so its match-arms cover
|
||||
// both the unset and the absent shapes the same way.
|
||||
let argv = vec!["pyright-langserver".to_string(), "--stdio".to_string()];
|
||||
let mut body = json!({ "method": "initialize" });
|
||||
let _ = lsp_transform_message(
|
||||
LspMessageFlow::LocalToBroker,
|
||||
&mut body,
|
||||
None,
|
||||
Some(LspSpawnInjection {
|
||||
argv: &argv,
|
||||
cwd: None,
|
||||
}),
|
||||
);
|
||||
if let Some(spawn) = body.get("_sessions_lsp_spawn") {
|
||||
assert_eq!(spawn.get("cwd"), Some(&json!(null)));
|
||||
} else {
|
||||
unreachable!("spawn injected");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- json_insert_optional --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn json_insert_optional_skips_none_branch() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut obj = serde_json::Map::new();
|
||||
json_insert_optional::<&str>(&mut obj, "skip_me", None)?;
|
||||
assert!(obj.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_insert_optional_serializes_some_value() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut obj = serde_json::Map::new();
|
||||
json_insert_optional(&mut obj, "argv", Some(vec!["a", "b"]))?;
|
||||
json_insert_optional(&mut obj, "cwd", Some("/srv/proj"))?;
|
||||
json_insert_optional(&mut obj, "limit", Some(7u32))?;
|
||||
assert_eq!(obj["argv"], json!(["a", "b"]));
|
||||
assert_eq!(obj["cwd"], json!("/srv/proj"));
|
||||
assert_eq!(obj["limit"], json!(7));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----- run_lsp_stdio: socket-attach negative path ---------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_lsp_stdio_propagates_broker_attach_error() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// The broker's ``attach`` ack carries ``ok=false`` + an error string
|
||||
// when the workspace_id / server_id pair doesn't match a live
|
||||
// session; the lsp-stdio client must surface that as a
|
||||
// HelperLaunchFailed with the broker's own message rather than
|
||||
// hanging on the subsequent stdin/stdout copy.
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::thread;
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"sessions-lsp-stdio-test-{}.sock",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let listener = UnixListener::bind(&tmp)?;
|
||||
let socket_path = tmp.clone();
|
||||
let server = thread::spawn(move || -> Result<(), String> {
|
||||
let (mut stream, _) = listener.accept().map_err(|e| format!("accept: {e}"))?;
|
||||
let mut buf = String::new();
|
||||
std::io::BufReader::new(&stream)
|
||||
.read_line(&mut buf)
|
||||
.map_err(|e| format!("read attach: {e}"))?;
|
||||
// Sanity-check the attach line carries the fields run_lsp_stdio
|
||||
// is supposed to pack — without this assertion the test would
|
||||
// pass even if the client sent garbage.
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(buf.trim()).map_err(|e| format!("json: {e}"))?;
|
||||
assert_eq!(parsed["kind"], "attach");
|
||||
assert_eq!(parsed["server_id"], "LSP-pyright");
|
||||
assert_eq!(parsed["workspace_id"], "ws-not-running");
|
||||
writeln!(stream, r#"{{"ok":false,"error":"unknown workspace_id"}}"#)
|
||||
.map_err(|e| format!("write nack: {e}"))?;
|
||||
stream.flush().map_err(|e| format!("flush nack: {e}"))?;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let args = [
|
||||
"--bridge-socket".to_string(),
|
||||
socket_path.to_string_lossy().into_owned(),
|
||||
"--server-id".to_string(),
|
||||
"LSP-pyright".to_string(),
|
||||
"--workspace-id".to_string(),
|
||||
"ws-not-running".to_string(),
|
||||
];
|
||||
let result = run_lsp_stdio(&args);
|
||||
match server.join() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => unreachable!("server thread error: {e}"),
|
||||
Err(_) => unreachable!("server thread panicked"),
|
||||
}
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
match result {
|
||||
Err(BridgeRunError::HelperLaunchFailed(msg)) => {
|
||||
assert!(
|
||||
msg.contains("unknown workspace_id"),
|
||||
"broker error must surface verbatim: {msg}"
|
||||
);
|
||||
}
|
||||
Ok(()) => unreachable!("expected HelperLaunchFailed"),
|
||||
Err(other) => unreachable!("expected HelperLaunchFailed; got {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_lsp_stdio_rejects_missing_required_args_before_any_io() {
|
||||
// CLI parse failures must not touch the filesystem (no socket
|
||||
// dial, no stdin lock) so a misconfigured launch can be retried
|
||||
// immediately without state leakage.
|
||||
let result = run_lsp_stdio(&[]);
|
||||
match result {
|
||||
Err(BridgeRunError::HelperLaunchFailed(msg)) => {
|
||||
assert!(msg.contains("--bridge-socket"));
|
||||
}
|
||||
Ok(()) => unreachable!("expected HelperLaunchFailed"),
|
||||
Err(other) => unreachable!("expected HelperLaunchFailed; got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
499
rust/crates/local_bridge/src/mirror.rs
Normal file
499
rust/crates/local_bridge/src/mirror.rs
Normal file
@@ -0,0 +1,499 @@
|
||||
//! ``mirror-sync`` request handler.
|
||||
//!
|
||||
//! ``mirror-sync`` is one of the few request methods the bridge handles
|
||||
//! itself instead of forwarding to the helper: it walks remote directories
|
||||
//! via ``tree-list`` (which IS forwarded) and materializes a local cache
|
||||
//! shadow. The dispatch lives in ``persistent::run_persistent``; everything
|
||||
//! reachable from there hangs off this module.
|
||||
//!
|
||||
//! Cut out of ``main.rs`` during a code-organization split; behavior is
|
||||
//! unchanged.
|
||||
|
||||
use crate::persistent::HelperDispatcher;
|
||||
use local_bridge::{BridgeCliOutput, BridgeRunError};
|
||||
use serde_json::json;
|
||||
use session_protocol::{ErrorEnvelope, RequestEnvelope};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) const MIRROR_SYNC_METHOD: &str = "mirror-sync";
|
||||
|
||||
/// Parameters for `mirror-sync` (bridge-handled, not forwarded to helper).
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct MirrorSyncParams {
|
||||
pub(crate) remote_root: String,
|
||||
pub(crate) local_files_root: String,
|
||||
#[serde(default)]
|
||||
pub(crate) max_traversal_depth: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub(crate) max_entries: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub(crate) include_files: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(crate) ignore_patterns: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub(crate) prune_missing: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(crate) max_dir_fanout: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub(crate) writes_per_second_cap: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub(crate) consecutive_failure_budget: Option<u32>,
|
||||
}
|
||||
|
||||
impl From<MirrorSyncParams> for local_bridge::remote_cache_mirror::RemoteCacheMirrorOptions {
|
||||
fn from(params: MirrorSyncParams) -> Self {
|
||||
let mut opts = Self::default();
|
||||
if let Some(v) = params.max_traversal_depth {
|
||||
opts.max_traversal_depth = v;
|
||||
}
|
||||
if let Some(v) = params.max_entries {
|
||||
opts.max_entries = v;
|
||||
}
|
||||
if let Some(v) = params.include_files {
|
||||
opts.include_files = v;
|
||||
}
|
||||
if let Some(v) = params.ignore_patterns {
|
||||
opts.ignore_patterns = v;
|
||||
}
|
||||
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;
|
||||
}
|
||||
opts
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_mirror_sync(
|
||||
dispatcher: &HelperDispatcher,
|
||||
envelope: &RequestEnvelope,
|
||||
) -> BridgeCliOutput {
|
||||
let params: MirrorSyncParams = match serde_json::from_value(envelope.params.clone()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return BridgeCliOutput {
|
||||
ok: false,
|
||||
id: Some(envelope.id.clone()),
|
||||
result: None,
|
||||
error: Some(ErrorEnvelope {
|
||||
id: Some(envelope.id.clone()),
|
||||
code: "invalid_params".to_string(),
|
||||
message: format!("mirror-sync params: {e}"),
|
||||
retryable: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let local_root = std::path::PathBuf::from(¶ms.local_files_root);
|
||||
let remote_root = params.remote_root.clone();
|
||||
let opts: local_bridge::remote_cache_mirror::RemoteCacheMirrorOptions = params.into();
|
||||
let req_id_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let disp = dispatcher.clone();
|
||||
|
||||
let result = local_bridge::remote_cache_mirror::mirror_remote_tree_to_local_cache(
|
||||
|_host, remote_dir| {
|
||||
let seq = req_id_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let tree_req = RequestEnvelope {
|
||||
id: format!("{}_tree_{seq}", envelope.id),
|
||||
method: session_protocol::METHOD_TREE_LIST.to_string(),
|
||||
params: json!({ "remote_directory": remote_dir }),
|
||||
timeout_ms: 30_000,
|
||||
trace: session_protocol::TraceLevel::Info,
|
||||
};
|
||||
let value = disp
|
||||
.request_blocking(&tree_req)
|
||||
.map_err(|e: BridgeRunError| e.to_string())?;
|
||||
let tree_result: session_protocol::TreeListResult =
|
||||
serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
Ok(tree_result
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(tree_list_entry_to_mirror)
|
||||
.collect())
|
||||
},
|
||||
"",
|
||||
&remote_root,
|
||||
&local_root,
|
||||
&opts,
|
||||
);
|
||||
|
||||
BridgeCliOutput {
|
||||
ok: result.ok(),
|
||||
id: Some(envelope.id.clone()),
|
||||
result: Some(json!({
|
||||
"directories_created": result.directories_created,
|
||||
"file_placeholders_created": result.file_placeholders_created,
|
||||
"entries_scanned": result.entries_scanned,
|
||||
"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
|
||||
} else {
|
||||
Some(ErrorEnvelope {
|
||||
id: Some(envelope.id.clone()),
|
||||
code: "mirror_sync_failed".to_string(),
|
||||
message: result
|
||||
.error_detail
|
||||
.unwrap_or_else(|| "unknown mirror error".to_string()),
|
||||
retryable: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn tree_list_entry_to_mirror(
|
||||
e: session_protocol::TreeListEntry,
|
||||
) -> local_bridge::remote_cache_mirror::RemoteDirectoryEntry {
|
||||
use local_bridge::remote_cache_mirror::{RemoteDirectoryEntry, RemoteFileKind};
|
||||
let kind = match e.kind {
|
||||
session_protocol::RemoteFileKind::RegularFile => RemoteFileKind::RegularFile,
|
||||
session_protocol::RemoteFileKind::Directory => RemoteFileKind::Directory,
|
||||
session_protocol::RemoteFileKind::Symlink => RemoteFileKind::Symlink,
|
||||
session_protocol::RemoteFileKind::Other => RemoteFileKind::Other,
|
||||
};
|
||||
RemoteDirectoryEntry {
|
||||
name: e.name,
|
||||
remote_absolute_path: e.remote_absolute_path,
|
||||
kind,
|
||||
is_symlink_loop: e.is_symlink_loop,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use local_bridge::remote_cache_mirror::{
|
||||
RemoteCacheMirrorOptions, RemoteFileKind as MirrorFileKind,
|
||||
};
|
||||
use serde_json::json;
|
||||
use session_protocol::{RemoteFileKind as ProtoFileKind, TraceLevel, TreeListEntry};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn envelope_with_method(id: &str, method: &str, params: serde_json::Value) -> RequestEnvelope {
|
||||
RequestEnvelope {
|
||||
id: id.to_string(),
|
||||
method: method.to_string(),
|
||||
params,
|
||||
timeout_ms: 1_500,
|
||||
trace: TraceLevel::Info,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn ``/bin/cat`` to obtain a real ``ChildStdin`` we can hand to
|
||||
/// ``HelperDispatcher`` without inventing a fake transport. The caller
|
||||
/// drops both the dispatcher and the child guard at end-of-test; cat
|
||||
/// exits when its stdin closes.
|
||||
struct CatChild {
|
||||
child: std::process::Child,
|
||||
}
|
||||
impl CatChild {
|
||||
fn try_spawn() -> Option<(HelperDispatcher, Self)> {
|
||||
let mut child = Command::new("/bin/cat")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let stdin = child.stdin.take()?;
|
||||
let dispatcher = HelperDispatcher {
|
||||
helper_stdin: Arc::new(Mutex::new(stdin)),
|
||||
pending: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
};
|
||||
Some((dispatcher, Self { child }))
|
||||
}
|
||||
|
||||
fn spawn() -> (HelperDispatcher, Self) {
|
||||
match Self::try_spawn() {
|
||||
Some(pair) => pair,
|
||||
None => unreachable!("/bin/cat must be available on the test host"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for CatChild {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- MirrorSyncParams::from() -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mirror_sync_params_partial_overrides_preserve_defaults() {
|
||||
let defaults = RemoteCacheMirrorOptions::default();
|
||||
let params = MirrorSyncParams {
|
||||
remote_root: "/srv/proj".to_string(),
|
||||
local_files_root: "/cache/proj".to_string(),
|
||||
max_traversal_depth: Some(3),
|
||||
max_entries: None,
|
||||
include_files: Some(false),
|
||||
ignore_patterns: Some(vec!["target".to_string(), "node_modules".to_string()]),
|
||||
prune_missing: None,
|
||||
max_dir_fanout: None,
|
||||
writes_per_second_cap: Some(7),
|
||||
consecutive_failure_budget: None,
|
||||
};
|
||||
let opts: RemoteCacheMirrorOptions = params.into();
|
||||
|
||||
// Overridden fields take the explicit values.
|
||||
assert_eq!(opts.max_traversal_depth, 3);
|
||||
assert!(!opts.include_files);
|
||||
assert_eq!(opts.ignore_patterns, vec!["target", "node_modules"]);
|
||||
assert_eq!(opts.writes_per_second_cap, 7);
|
||||
|
||||
// Unset fields keep the struct defaults.
|
||||
assert_eq!(opts.max_entries, defaults.max_entries);
|
||||
assert_eq!(opts.prune_missing, defaults.prune_missing);
|
||||
assert_eq!(opts.max_dir_fanout, defaults.max_dir_fanout);
|
||||
assert_eq!(
|
||||
opts.consecutive_failure_budget,
|
||||
defaults.consecutive_failure_budget
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_sync_params_all_overrides_propagate_every_branch() {
|
||||
// Exercises every ``if let Some(v) = ... { opts.x = v; }`` arm so a
|
||||
// future struct-field rename can't silently drop one of the knobs.
|
||||
let params = MirrorSyncParams {
|
||||
remote_root: "/srv/proj".to_string(),
|
||||
local_files_root: "/cache/proj".to_string(),
|
||||
max_traversal_depth: Some(11),
|
||||
max_entries: Some(2222),
|
||||
include_files: Some(true),
|
||||
ignore_patterns: Some(vec![".git".to_string()]),
|
||||
prune_missing: Some(false),
|
||||
max_dir_fanout: Some(64),
|
||||
writes_per_second_cap: Some(33),
|
||||
consecutive_failure_budget: Some(9),
|
||||
};
|
||||
let opts: RemoteCacheMirrorOptions = params.into();
|
||||
assert_eq!(opts.max_traversal_depth, 11);
|
||||
assert_eq!(opts.max_entries, 2222);
|
||||
assert!(opts.include_files);
|
||||
assert_eq!(opts.ignore_patterns, vec![".git"]);
|
||||
assert!(!opts.prune_missing);
|
||||
assert_eq!(opts.max_dir_fanout, 64);
|
||||
assert_eq!(opts.writes_per_second_cap, 33);
|
||||
assert_eq!(opts.consecutive_failure_budget, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_sync_params_all_none_returns_pure_defaults() {
|
||||
let defaults = RemoteCacheMirrorOptions::default();
|
||||
let params = MirrorSyncParams {
|
||||
remote_root: "/srv/proj".to_string(),
|
||||
local_files_root: "/cache/proj".to_string(),
|
||||
max_traversal_depth: None,
|
||||
max_entries: None,
|
||||
include_files: None,
|
||||
ignore_patterns: None,
|
||||
prune_missing: None,
|
||||
max_dir_fanout: None,
|
||||
writes_per_second_cap: None,
|
||||
consecutive_failure_budget: None,
|
||||
};
|
||||
let opts: RemoteCacheMirrorOptions = params.into();
|
||||
assert_eq!(opts.max_traversal_depth, defaults.max_traversal_depth);
|
||||
assert_eq!(opts.max_entries, defaults.max_entries);
|
||||
assert_eq!(opts.include_files, defaults.include_files);
|
||||
assert_eq!(opts.ignore_patterns, defaults.ignore_patterns);
|
||||
assert_eq!(opts.prune_missing, defaults.prune_missing);
|
||||
assert_eq!(opts.max_dir_fanout, defaults.max_dir_fanout);
|
||||
assert_eq!(opts.writes_per_second_cap, defaults.writes_per_second_cap);
|
||||
assert_eq!(
|
||||
opts.consecutive_failure_budget,
|
||||
defaults.consecutive_failure_budget
|
||||
);
|
||||
}
|
||||
|
||||
// ----- JSON deserialization ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mirror_sync_params_json_omits_optionals() {
|
||||
// The Python side commonly sends only the two required fields when
|
||||
// using shallow-sync; serde must accept that without errors thanks
|
||||
// to the ``#[serde(default)]`` attributes on every Optional field.
|
||||
let payload = json!({
|
||||
"remote_root": "/srv/proj",
|
||||
"local_files_root": "/cache/proj",
|
||||
});
|
||||
let parsed: MirrorSyncParams = match serde_json::from_value(payload) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => unreachable!("only required fields should suffice: {err}"),
|
||||
};
|
||||
assert_eq!(parsed.remote_root, "/srv/proj");
|
||||
assert_eq!(parsed.local_files_root, "/cache/proj");
|
||||
assert!(parsed.max_traversal_depth.is_none());
|
||||
assert!(parsed.ignore_patterns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_sync_params_json_full_payload_round_trips() {
|
||||
let payload = json!({
|
||||
"remote_root": "/srv/proj",
|
||||
"local_files_root": "/cache/proj",
|
||||
"max_traversal_depth": 5,
|
||||
"max_entries": 1500,
|
||||
"include_files": false,
|
||||
"ignore_patterns": ["__pycache__", "*.pyc"],
|
||||
"prune_missing": true,
|
||||
"max_dir_fanout": 0,
|
||||
"writes_per_second_cap": 12,
|
||||
"consecutive_failure_budget": 4,
|
||||
});
|
||||
let parsed: MirrorSyncParams = match serde_json::from_value(payload) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => unreachable!("full payload should deserialize: {err}"),
|
||||
};
|
||||
assert_eq!(parsed.max_traversal_depth, Some(5));
|
||||
assert_eq!(parsed.max_entries, Some(1500));
|
||||
assert_eq!(parsed.include_files, Some(false));
|
||||
assert_eq!(
|
||||
parsed.ignore_patterns.as_deref(),
|
||||
Some(&["__pycache__".to_string(), "*.pyc".to_string()][..])
|
||||
);
|
||||
assert_eq!(parsed.prune_missing, Some(true));
|
||||
assert_eq!(parsed.max_dir_fanout, Some(0));
|
||||
assert_eq!(parsed.writes_per_second_cap, Some(12));
|
||||
assert_eq!(parsed.consecutive_failure_budget, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_sync_params_json_rejects_missing_required_field() {
|
||||
let payload = json!({ "remote_root": "/srv/proj" });
|
||||
match serde_json::from_value::<MirrorSyncParams>(payload) {
|
||||
Ok(_) => unreachable!("local_files_root must be required"),
|
||||
Err(err) => assert!(err.to_string().contains("local_files_root")),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- tree_list_entry_to_mirror kind mapping ---------------------------
|
||||
|
||||
#[test]
|
||||
fn tree_list_entry_to_mirror_maps_every_protocol_kind() {
|
||||
// ``RemoteCacheMirror`` and the wire-protocol enum live in different
|
||||
// crates; the mapping table here is the only place a new variant on
|
||||
// either side is reconciled. Pinning every arm catches the silent
|
||||
// "added a Symlink-like kind, forgot to forward it" regression.
|
||||
let cases = [
|
||||
(ProtoFileKind::RegularFile, MirrorFileKind::RegularFile),
|
||||
(ProtoFileKind::Directory, MirrorFileKind::Directory),
|
||||
(ProtoFileKind::Symlink, MirrorFileKind::Symlink),
|
||||
(ProtoFileKind::Other, MirrorFileKind::Other),
|
||||
];
|
||||
for (proto, expected) in cases {
|
||||
let entry = TreeListEntry {
|
||||
name: "node".to_string(),
|
||||
remote_absolute_path: "/srv/proj/node".to_string(),
|
||||
kind: proto,
|
||||
is_symlink_loop: false,
|
||||
};
|
||||
let got = tree_list_entry_to_mirror(entry);
|
||||
assert_eq!(got.kind, expected);
|
||||
assert_eq!(got.name, "node");
|
||||
assert_eq!(got.remote_absolute_path, "/srv/proj/node");
|
||||
assert!(!got.is_symlink_loop);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_list_entry_to_mirror_propagates_symlink_loop_flag() {
|
||||
// ``is_symlink_loop`` gates the prune logic in mirror_remote_tree —
|
||||
// dropping it on the way through this conversion would let mirror
|
||||
// recurse into a cycle. Lock the flag's pass-through end-to-end.
|
||||
let entry = TreeListEntry {
|
||||
name: "loop".to_string(),
|
||||
remote_absolute_path: "/srv/proj/loop".to_string(),
|
||||
kind: ProtoFileKind::Symlink,
|
||||
is_symlink_loop: true,
|
||||
};
|
||||
let got = tree_list_entry_to_mirror(entry);
|
||||
assert!(got.is_symlink_loop);
|
||||
}
|
||||
|
||||
// ----- handle_mirror_sync dispatch logic --------------------------------
|
||||
|
||||
#[test]
|
||||
fn handle_mirror_sync_returns_invalid_params_for_bad_payload() {
|
||||
// The dispatcher is never touched on the bad-params branch; passing a
|
||||
// cat-backed dispatcher anyway proves the function doesn't accidentally
|
||||
// forward a request before the params fail to deserialize.
|
||||
let (dispatcher, _cat) = CatChild::spawn();
|
||||
let envelope = envelope_with_method("req-1", MIRROR_SYNC_METHOD, json!({}));
|
||||
let out = handle_mirror_sync(&dispatcher, &envelope);
|
||||
assert!(!out.ok);
|
||||
assert_eq!(out.id.as_deref(), Some("req-1"));
|
||||
if let Some(err) = out.error {
|
||||
assert_eq!(err.code, "invalid_params");
|
||||
assert!(err.message.contains("mirror-sync params"));
|
||||
assert!(!err.retryable);
|
||||
} else {
|
||||
unreachable!("error envelope is required for failures");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_mirror_sync_surfaces_dispatch_error_as_mirror_sync_failed() {
|
||||
// The caller spawns mirror with a temp local cache path that exists
|
||||
// but a dispatcher whose helper child is dead — the first tree/list
|
||||
// request times out at the dispatcher level, and the mirror engine
|
||||
// surfaces that as a non-ok result. The handler must wrap that into
|
||||
// ``mirror_sync_failed`` (retryable=true) so the Python side can
|
||||
// distinguish "your params were bad" (retryable=false) from "the
|
||||
// remote side hiccuped, try again".
|
||||
let tmp = std::env::temp_dir().join(format!("sessions-mirror-test-{}", std::process::id()));
|
||||
if let Err(err) = std::fs::create_dir_all(&tmp) {
|
||||
unreachable!("failed to create temp dir: {err}");
|
||||
}
|
||||
let (dispatcher, cat) = CatChild::spawn();
|
||||
// Kill the cat child early so any helper write goes to a closed pipe;
|
||||
// the request will time out and propagate failure into the result.
|
||||
drop(cat);
|
||||
let envelope = envelope_with_method(
|
||||
"req-2",
|
||||
MIRROR_SYNC_METHOD,
|
||||
json!({
|
||||
"remote_root": "/srv/proj",
|
||||
"local_files_root": tmp.to_string_lossy(),
|
||||
"max_traversal_depth": 1,
|
||||
// Tight timeout so the test finishes quickly when the
|
||||
// helper write fails or times out.
|
||||
}),
|
||||
);
|
||||
// Override the envelope's per-request timeout to the dispatcher minimum
|
||||
// (1000ms clamp) so this test cannot exceed ~1s.
|
||||
let envelope = RequestEnvelope {
|
||||
timeout_ms: 1_000,
|
||||
..envelope
|
||||
};
|
||||
let out = handle_mirror_sync(&dispatcher, &envelope);
|
||||
// The mirror engine MAY succeed with zero entries on some platforms
|
||||
// (e.g., when the first write fails immediately and falls into the
|
||||
// recoverable branch); we only assert the response shape here so the
|
||||
// test doesn't flake on ``ok=true`` paths.
|
||||
assert_eq!(out.id.as_deref(), Some("req-2"));
|
||||
if !out.ok {
|
||||
if let Some(err) = out.error {
|
||||
assert_eq!(err.code, "mirror_sync_failed");
|
||||
assert!(err.retryable, "transient failures should encourage retry");
|
||||
} else {
|
||||
unreachable!("non-ok must include an error envelope");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
901
rust/crates/local_bridge/src/persistent.rs
Normal file
901
rust/crates/local_bridge/src/persistent.rs
Normal file
@@ -0,0 +1,901 @@
|
||||
//! Persistent (long-lived) bridge mode: a single helper process serving many
|
||||
//! requests, plus a local-socket broker that lets ``lsp-stdio`` clients attach
|
||||
//! to running language-server channels.
|
||||
//!
|
||||
//! The orchestration is:
|
||||
//!
|
||||
//! - ``run_persistent`` brings up the SSH helper, prints the handshake banner
|
||||
//! on Python-stdout, and runs two halves: a *collector* thread that demuxes
|
||||
//! helper responses by id, and the main *forwarder* loop that ingests JSON
|
||||
//! request envelopes from Python-stdin.
|
||||
//! - ``HelperDispatcher`` is the synchronization point both halves share: it
|
||||
//! registers in-flight ids, writes framed messages to the helper's stdin,
|
||||
//! and routes responses back via ``mpsc`` channels. ``mirror.rs`` and
|
||||
//! ``lsp_stdio.rs`` each take a clone for their own dispatch flows.
|
||||
//! - ``PersistentBroker`` owns a local-socket listener (``AF_UNIX`` on Unix,
|
||||
//! Named Pipe on Windows via ``interprocess``) so external ``lsp-stdio``
|
||||
//! children can ``attach`` to a running helper session.
|
||||
//!
|
||||
//! Cut out of ``main.rs`` during a code-organization split; behavior is
|
||||
//! unchanged.
|
||||
|
||||
use crate::cli::BridgeCliArgs;
|
||||
use crate::lsp_stdio::{BrokerLspRelayCfg, broker_lsp_relay_loop};
|
||||
use crate::mirror::{MIRROR_SYNC_METHOD, handle_mirror_sync};
|
||||
use crate::write_bridge_output;
|
||||
use interprocess::TryClone;
|
||||
use interprocess::local_socket::{
|
||||
GenericFilePath, Listener as IpcListener, ListenerNonblockingMode, ListenerOptions,
|
||||
Stream as IpcStream, ToFsName, traits::Listener as IpcListenerTrait,
|
||||
};
|
||||
use local_bridge::{
|
||||
BridgeCliOutput, BridgeRunError, bridge_diag_event, protocol_message_kind,
|
||||
with_helper_session_handshake,
|
||||
};
|
||||
use serde_json::json;
|
||||
use session_protocol::{ErrorEnvelope, ProtocolMessage, RequestEnvelope};
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::fs;
|
||||
use std::io::BufRead;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ChildStdin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Shared handle for sending requests to the helper and receiving responses.
|
||||
///
|
||||
/// Used by both the main forwarder (Python → helper) and the mirror thread
|
||||
/// (bridge-internal tree/list → helper).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct HelperDispatcher {
|
||||
pub(crate) helper_stdin: Arc<Mutex<ChildStdin>>,
|
||||
pub(crate) pending: Arc<Mutex<HashMap<String, mpsc::Sender<BridgeCliOutput>>>>,
|
||||
}
|
||||
|
||||
impl HelperDispatcher {
|
||||
pub(crate) fn request_blocking(
|
||||
&self,
|
||||
envelope: &RequestEnvelope,
|
||||
) -> Result<serde_json::Value, BridgeRunError> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
self.pending
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(envelope.id.clone(), tx);
|
||||
self.forward_to_helper(envelope)?;
|
||||
|
||||
let timeout = std::time::Duration::from_millis(envelope.timeout_ms.clamp(1000, 120_000));
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(out) if out.ok => Ok(out.result.unwrap_or(serde_json::Value::Null)),
|
||||
Ok(out) => {
|
||||
let err = out.error.unwrap_or_else(|| ErrorEnvelope {
|
||||
id: Some(envelope.id.clone()),
|
||||
code: "unknown".to_string(),
|
||||
message: "helper returned failure without error detail".to_string(),
|
||||
retryable: true,
|
||||
});
|
||||
Err(BridgeRunError::HelperError(err))
|
||||
}
|
||||
Err(_) => {
|
||||
self.pending
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&envelope.id);
|
||||
Err(BridgeRunError::HelperLaunchFailed(format!(
|
||||
"helper response timed out after {:.1}s",
|
||||
timeout.as_secs_f32()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn forward_to_helper(
|
||||
&self,
|
||||
envelope: &RequestEnvelope,
|
||||
) -> Result<(), BridgeRunError> {
|
||||
let encoded =
|
||||
session_protocol::encode_message(&ProtocolMessage::Request(envelope.clone()))?;
|
||||
let mut guard = self.helper_stdin.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.write_all(encoded.as_bytes())?;
|
||||
guard.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn deliver(&self, id: &str, out: BridgeCliOutput) -> Option<BridgeCliOutput> {
|
||||
let sender = self
|
||||
.pending
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(id);
|
||||
match sender {
|
||||
Some(tx) => {
|
||||
let _ = tx.send(out);
|
||||
None
|
||||
}
|
||||
None => Some(out),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_persistent(args: &[String]) -> Result<(), BridgeRunError> {
|
||||
let cli = BridgeCliArgs::parse(args)?;
|
||||
// See the matching comment in main.rs::run: revision drives the path,
|
||||
// not the compiled-in bridge version, so a slightly-stale bridge
|
||||
// binary still talks to the freshly pushed helper.
|
||||
let default_remote_helper_path = format!(
|
||||
"{}/session_helper",
|
||||
local_bridge::remote_helper_cache_dir(&cli.revision)
|
||||
);
|
||||
let remote_helper_path = cli
|
||||
.remote_helper_path
|
||||
.as_deref()
|
||||
.unwrap_or(default_remote_helper_path.as_str());
|
||||
|
||||
with_helper_session_handshake(
|
||||
&cli.host_alias,
|
||||
remote_helper_path,
|
||||
&cli.revision,
|
||||
session_protocol::TraceLevel::Info,
|
||||
|session, handshake| {
|
||||
let py_stdout = Arc::new(Mutex::new(std::io::stdout()));
|
||||
|
||||
let dispatcher = HelperDispatcher {
|
||||
helper_stdin: Arc::new(Mutex::new(session.take_stdin()?)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
|
||||
let (_broker_keepalive, broker_socket_str) = {
|
||||
let broker = PersistentBroker::start(&cli.host_alias, dispatcher.clone())?;
|
||||
let path = broker.socket_path_str();
|
||||
(broker, path)
|
||||
};
|
||||
|
||||
let handshake_info = BridgeCliOutput {
|
||||
ok: true,
|
||||
id: None,
|
||||
result: Some(json!({
|
||||
"handshake": {
|
||||
"remote_home": handshake.remote_home,
|
||||
"arch": handshake.arch,
|
||||
"helper_version": handshake.helper_version,
|
||||
"remote_platform": format!("{:?}", handshake.remote_platform),
|
||||
"capabilities": handshake.capabilities.iter()
|
||||
.map(|c| format!("{:?}", c))
|
||||
.collect::<Vec<_>>(),
|
||||
"broker_socket": broker_socket_str,
|
||||
}
|
||||
})),
|
||||
error: None,
|
||||
};
|
||||
write_bridge_output(&py_stdout, &handshake_info)?;
|
||||
|
||||
// Collector thread: helper stdout → dispatch by id or pass to Python.
|
||||
let messages = session.take_messages()?;
|
||||
let dispatcher_for_collector = dispatcher.clone();
|
||||
let py_stdout_collector = Arc::clone(&py_stdout);
|
||||
let collector_handle = std::thread::spawn(move || {
|
||||
loop {
|
||||
let msg = match messages.recv() {
|
||||
Ok(Ok(msg)) => msg,
|
||||
Ok(Err(e)) => {
|
||||
bridge_diag_event(
|
||||
"bridge.rust.collector_error",
|
||||
json!({ "detail": e.to_string() }),
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
};
|
||||
let (id, out) = match msg {
|
||||
ProtocolMessage::Response(resp) => {
|
||||
let id = resp.id.clone();
|
||||
(
|
||||
id.clone(),
|
||||
BridgeCliOutput {
|
||||
ok: true,
|
||||
id: Some(id),
|
||||
result: Some(resp.result),
|
||||
error: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
ProtocolMessage::Error(err) => {
|
||||
let id = err.id.clone().unwrap_or_default();
|
||||
(
|
||||
id.clone(),
|
||||
BridgeCliOutput {
|
||||
ok: false,
|
||||
id: Some(id),
|
||||
result: None,
|
||||
error: Some(err),
|
||||
},
|
||||
)
|
||||
}
|
||||
ProtocolMessage::Shutdown(_) => break,
|
||||
other => {
|
||||
bridge_diag_event(
|
||||
"bridge.rust.collector_unexpected",
|
||||
json!({ "kind": protocol_message_kind(&other) }),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(passthrough) = dispatcher_for_collector.deliver(&id, out)
|
||||
&& write_bridge_output(&py_stdout_collector, &passthrough).is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Main thread: Python stdin → helper or bridge-handled commands.
|
||||
let py_stdin = std::io::stdin();
|
||||
let input = std::io::BufRead::lines(std::io::BufReader::new(py_stdin.lock()));
|
||||
for line in input {
|
||||
let raw = match line {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let out = BridgeCliOutput {
|
||||
ok: false,
|
||||
id: None,
|
||||
result: None,
|
||||
error: Some(ErrorEnvelope {
|
||||
id: None,
|
||||
code: "bridge_stdin_error".to_string(),
|
||||
message: format!("stdin read failed: {e}"),
|
||||
retryable: true,
|
||||
}),
|
||||
};
|
||||
let _ = write_bridge_output(&py_stdout, &out);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let envelope: RequestEnvelope = match serde_json::from_str(trimmed) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
bridge_diag_event(
|
||||
"bridge.rust.persistent_invalid_envelope",
|
||||
json!({ "detail": e.to_string(), "line_bytes": trimmed.len() }),
|
||||
);
|
||||
let out = BridgeCliOutput {
|
||||
ok: false,
|
||||
id: None,
|
||||
result: None,
|
||||
error: Some(ErrorEnvelope {
|
||||
id: None,
|
||||
code: "invalid_bridge_request".to_string(),
|
||||
message: format!("invalid request envelope: {e}"),
|
||||
retryable: true,
|
||||
}),
|
||||
};
|
||||
let _ = write_bridge_output(&py_stdout, &out);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
bridge_diag_event(
|
||||
"bridge.rust.request_start",
|
||||
json!({
|
||||
"request_id": envelope.id,
|
||||
"method": envelope.method,
|
||||
"timeout_ms": envelope.timeout_ms,
|
||||
}),
|
||||
);
|
||||
|
||||
if envelope.method == MIRROR_SYNC_METHOD {
|
||||
let disp = dispatcher.clone();
|
||||
let stdout = Arc::clone(&py_stdout);
|
||||
std::thread::spawn(move || {
|
||||
let out = handle_mirror_sync(&disp, &envelope);
|
||||
let _ = write_bridge_output(&stdout, &out);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal request: forward to helper (fire-and-forget).
|
||||
if let Err(e) = dispatcher.forward_to_helper(&envelope) {
|
||||
let out = BridgeCliOutput {
|
||||
ok: false,
|
||||
id: Some(envelope.id.clone()),
|
||||
result: None,
|
||||
error: Some(ErrorEnvelope {
|
||||
id: Some(envelope.id),
|
||||
code: "helper_write_failed".to_string(),
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
}),
|
||||
};
|
||||
let _ = write_bridge_output(&py_stdout, &out);
|
||||
continue;
|
||||
}
|
||||
bridge_diag_event(
|
||||
"bridge.rust.request_flushed",
|
||||
json!({ "request_id": envelope.id, "method": envelope.method }),
|
||||
);
|
||||
}
|
||||
|
||||
// Python stdin closed — collector thread will end when helper exits.
|
||||
let _ = collector_handle.join();
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BrokerAttachRequest {
|
||||
kind: String,
|
||||
server_id: String,
|
||||
workspace_id: String,
|
||||
#[serde(default)]
|
||||
argv: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
/// Local cache ``file://`` prefix (editor); rewritten to ``lsp_remote_uri_prefix`` outbound.
|
||||
#[serde(default)]
|
||||
lsp_local_uri_prefix: Option<String>,
|
||||
/// Remote workspace ``file://`` prefix (helper); rewritten back on inbound responses.
|
||||
#[serde(default)]
|
||||
lsp_remote_uri_prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct BrokerAttachResponse {
|
||||
pub(crate) ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) error: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct PersistentBroker {
|
||||
socket_path: PathBuf,
|
||||
running: Arc<AtomicBool>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PersistentBroker {
|
||||
pub(crate) fn start(
|
||||
host_alias: &str,
|
||||
dispatcher: HelperDispatcher,
|
||||
) -> Result<Self, BridgeRunError> {
|
||||
let sanitized_host = host_alias
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let socket_path = persistent_broker_endpoint_path(&sanitized_host);
|
||||
// ``LocalSocketListener::bind`` (via interprocess 2.x) is the
|
||||
// cross-platform front end: on Unix it opens an `AF_UNIX` socket at
|
||||
// the given path; on Windows it creates a Named Pipe under
|
||||
// ``\\.\pipe\<basename>`` resolved from the same path via
|
||||
// ``GenericFilePath``. The bytes on the wire are unchanged on Unix
|
||||
// versus the previous ``UnixListener::bind`` path so existing
|
||||
// tests + the ``run_lsp_stdio`` client (now ``IpcStream::connect``)
|
||||
// keep working.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// The Unix socket file would otherwise EADDRINUSE on a fresh
|
||||
// bind after a crash. Windows named pipes are reaped by the
|
||||
// OS, so this isn't needed there.
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
let endpoint = socket_path
|
||||
.as_path()
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.map_err(|error| {
|
||||
BridgeRunError::HelperLaunchFailed(format!("broker endpoint name failed: {error}"))
|
||||
})?;
|
||||
let listener: IpcListener = ListenerOptions::new()
|
||||
.name(endpoint)
|
||||
.nonblocking(ListenerNonblockingMode::Accept)
|
||||
.create_sync()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let running_thread = Arc::clone(&running);
|
||||
let dispatcher_for_listener = dispatcher.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
while running_thread.load(Ordering::Relaxed) {
|
||||
match listener.accept() {
|
||||
Ok(stream) => {
|
||||
let dispatch = dispatcher_for_listener.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(error) = handle_broker_client(stream, dispatch) {
|
||||
bridge_diag_event(
|
||||
"bridge.rust.broker_client_error",
|
||||
json!({ "detail": error.to_string() }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
socket_path,
|
||||
running,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn socket_path_str(&self) -> String {
|
||||
self.socket_path.to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the broker endpoint path for ``host_alias``.
|
||||
///
|
||||
/// On Unix this is a per-PID file under ``$TMPDIR`` that ``interprocess``
|
||||
/// turns into an `AF_UNIX` socket (``.sock`` suffix kept for grep-ability).
|
||||
/// On Windows we produce a Named Pipe path under the ``\\.\pipe\``
|
||||
/// namespace (the only form ``GenericFilePath`` accepts on Windows; see
|
||||
/// the interprocess docs at
|
||||
/// ``interprocess::local_socket::GenericFilePath``).
|
||||
#[cfg(unix)]
|
||||
fn persistent_broker_endpoint_path(sanitized_host: &str) -> PathBuf {
|
||||
let pid = std::process::id();
|
||||
std::env::temp_dir().join(format!("sessions-local-bridge-{sanitized_host}-{pid}.sock"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn persistent_broker_endpoint_path(sanitized_host: &str) -> PathBuf {
|
||||
let pid = std::process::id();
|
||||
PathBuf::from(format!(
|
||||
r"\\.\pipe\sessions-local-bridge-{sanitized_host}-{pid}"
|
||||
))
|
||||
}
|
||||
|
||||
impl Drop for PersistentBroker {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
// Unix socket files want explicit removal (the listener thread
|
||||
// already exited above); Windows Named Pipes are reaped when the
|
||||
// last handle closes, so the equivalent step is the listener's
|
||||
// own Drop and there's no path to unlink.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = fs::remove_file(&self.socket_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_broker_client(
|
||||
stream: IpcStream,
|
||||
dispatcher: HelperDispatcher,
|
||||
) -> Result<(), BridgeRunError> {
|
||||
let read_half = stream.try_clone()?;
|
||||
let mut buf_reader = std::io::BufReader::new(read_half);
|
||||
let mut first_line = String::new();
|
||||
buf_reader.read_line(&mut first_line)?;
|
||||
let req: BrokerAttachRequest =
|
||||
serde_json::from_str(first_line.trim()).map_err(BridgeRunError::Json)?;
|
||||
let mut write_half = stream;
|
||||
if req.kind != "attach" || req.server_id.trim().is_empty() || req.workspace_id.trim().is_empty()
|
||||
{
|
||||
let response = BrokerAttachResponse {
|
||||
ok: false,
|
||||
error: Some("invalid attach request".to_string()),
|
||||
};
|
||||
let encoded = serde_json::to_string(&response)?;
|
||||
writeln!(write_half, "{encoded}")?;
|
||||
write_half.flush()?;
|
||||
return Ok(());
|
||||
}
|
||||
let response = BrokerAttachResponse {
|
||||
ok: true,
|
||||
error: None,
|
||||
};
|
||||
let encoded = serde_json::to_string(&response)?;
|
||||
writeln!(write_half, "{encoded}")?;
|
||||
write_half.flush()?;
|
||||
let uri_rewrite = match (req.lsp_local_uri_prefix, req.lsp_remote_uri_prefix) {
|
||||
(Some(loc), Some(rem))
|
||||
if !loc.trim().is_empty() && !rem.trim().is_empty() && loc.trim() != rem.trim() =>
|
||||
{
|
||||
Some((loc.trim().to_string(), rem.trim().to_string()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
broker_lsp_relay_loop(
|
||||
buf_reader,
|
||||
&mut write_half,
|
||||
BrokerLspRelayCfg {
|
||||
dispatcher,
|
||||
server_id: req.server_id,
|
||||
workspace_id: req.workspace_id,
|
||||
spawn_argv: req.argv,
|
||||
spawn_cwd: req.cwd,
|
||||
uri_rewrite,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn lsp_response_body_to_framed_string(
|
||||
result: &serde_json::Value,
|
||||
) -> Result<String, BridgeRunError> {
|
||||
if let (Some(kind), Some(body)) = (
|
||||
result.get("kind").and_then(|v| v.as_str()),
|
||||
result.get("body"),
|
||||
) && kind == session_protocol::CHANNEL_KIND_LSP_STDIO_MESSAGE
|
||||
{
|
||||
return serde_json::to_string(body).map_err(BridgeRunError::Json);
|
||||
}
|
||||
serde_json::to_string(result).map_err(BridgeRunError::Json)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Spawn ``/bin/cat`` to obtain a real ``ChildStdin`` we can hand to
|
||||
/// ``HelperDispatcher`` (no fake transport tricks). The child guard is
|
||||
/// dropped when the test ends; cat exits when its stdin closes.
|
||||
struct CatChild {
|
||||
child: std::process::Child,
|
||||
}
|
||||
impl CatChild {
|
||||
fn try_spawn() -> Option<(HelperDispatcher, Self)> {
|
||||
let mut child = Command::new("/bin/cat")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let stdin = child.stdin.take()?;
|
||||
let dispatcher = HelperDispatcher {
|
||||
helper_stdin: Arc::new(Mutex::new(stdin)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
Some((dispatcher, Self { child }))
|
||||
}
|
||||
|
||||
fn spawn() -> (HelperDispatcher, Self) {
|
||||
match Self::try_spawn() {
|
||||
Some(pair) => pair,
|
||||
None => unreachable!("/bin/cat must be available on the test host"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for CatChild {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_pending(
|
||||
pending: &Mutex<HashMap<String, mpsc::Sender<BridgeCliOutput>>>,
|
||||
) -> std::sync::MutexGuard<'_, HashMap<String, mpsc::Sender<BridgeCliOutput>>> {
|
||||
match pending.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_poisoned) => unreachable!("pending map is uncontended in tests"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_output(id: &str, body: serde_json::Value) -> BridgeCliOutput {
|
||||
BridgeCliOutput {
|
||||
ok: true,
|
||||
id: Some(id.to_string()),
|
||||
result: Some(body),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ----- HelperDispatcher::deliver --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn deliver_routes_response_to_pending_waiter_and_returns_none()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let (dispatcher, _cat) = CatChild::spawn();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
lock_pending(&dispatcher.pending).insert("req-1".to_string(), tx);
|
||||
|
||||
let leftover = dispatcher.deliver("req-1", ok_output("req-1", json!({"a": 1})));
|
||||
assert!(
|
||||
leftover.is_none(),
|
||||
"delivered output for a registered id must NOT bounce back"
|
||||
);
|
||||
let received = rx.recv_timeout(std::time::Duration::from_millis(500))?;
|
||||
assert_eq!(received.result, Some(json!({"a": 1})));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliver_returns_unmatched_output_for_unknown_id() {
|
||||
// The Python forwarder relies on this passthrough: a response that
|
||||
// doesn't match any pending in-flight id (rare but possible after a
|
||||
// timeout-then-late-reply race) flows back to stdout instead of
|
||||
// being silently dropped.
|
||||
let (dispatcher, _cat) = CatChild::spawn();
|
||||
let leftover = dispatcher.deliver("not-registered", ok_output("orphan", json!({})));
|
||||
if let Some(leftover) = leftover {
|
||||
assert_eq!(leftover.id.as_deref(), Some("orphan"));
|
||||
} else {
|
||||
unreachable!("unmatched output must be returned to caller");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliver_removes_entry_from_pending_so_second_delivery_is_orphaned() {
|
||||
// The first deliver consumes the pending slot; a second deliver to
|
||||
// the same id (e.g., a duplicated reply from a buggy helper) is
|
||||
// treated as orphaned and bounced back to the caller. This pins
|
||||
// the "no double-fire" invariant the forwarder relies on.
|
||||
let (dispatcher, _cat) = CatChild::spawn();
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
lock_pending(&dispatcher.pending).insert("req-dup".to_string(), tx);
|
||||
let _ = dispatcher.deliver("req-dup", ok_output("req-dup", json!(null)));
|
||||
let second = dispatcher.deliver("req-dup", ok_output("req-dup", json!(null)));
|
||||
assert!(
|
||||
second.is_some(),
|
||||
"second delivery must NOT find a pending slot (already removed)"
|
||||
);
|
||||
}
|
||||
|
||||
// ----- HelperDispatcher::request_blocking -----------------------------
|
||||
|
||||
#[test]
|
||||
fn request_blocking_delivers_success_payload_to_caller() -> Result<(), BridgeRunError> {
|
||||
// Drives the success branch end-to-end: spawn a delivery thread that
|
||||
// pretends to be the helper response collector, feeding a successful
|
||||
// BridgeCliOutput through ``deliver`` while the request is in flight.
|
||||
let (dispatcher, _cat) = CatChild::spawn();
|
||||
let envelope = RequestEnvelope {
|
||||
id: "req-success".to_string(),
|
||||
method: "tree/list".to_string(),
|
||||
params: json!({"remote_directory": "/srv/proj"}),
|
||||
timeout_ms: 1_500,
|
||||
trace: session_protocol::TraceLevel::Info,
|
||||
};
|
||||
let dispatcher_for_thread = dispatcher.clone();
|
||||
let responder = std::thread::spawn(move || {
|
||||
// Wait until ``request_blocking`` has registered the pending slot.
|
||||
for _ in 0..50 {
|
||||
let registered =
|
||||
lock_pending(&dispatcher_for_thread.pending).contains_key("req-success");
|
||||
if registered {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
let _ = dispatcher_for_thread.deliver(
|
||||
"req-success",
|
||||
ok_output("req-success", json!({"entries": []})),
|
||||
);
|
||||
});
|
||||
let value = dispatcher.request_blocking(&envelope)?;
|
||||
if responder.join().is_err() {
|
||||
unreachable!("responder thread panicked");
|
||||
}
|
||||
assert_eq!(value, json!({"entries": []}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_blocking_propagates_helper_error_envelope() {
|
||||
let (dispatcher, _cat) = CatChild::spawn();
|
||||
let envelope = RequestEnvelope {
|
||||
id: "req-helper-err".to_string(),
|
||||
method: "tree/list".to_string(),
|
||||
params: json!({}),
|
||||
timeout_ms: 1_500,
|
||||
trace: session_protocol::TraceLevel::Info,
|
||||
};
|
||||
let dispatcher_for_thread = dispatcher.clone();
|
||||
let responder = std::thread::spawn(move || {
|
||||
for _ in 0..50 {
|
||||
let registered =
|
||||
lock_pending(&dispatcher_for_thread.pending).contains_key("req-helper-err");
|
||||
if registered {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
let _ = dispatcher_for_thread.deliver(
|
||||
"req-helper-err",
|
||||
BridgeCliOutput {
|
||||
ok: false,
|
||||
id: Some("req-helper-err".to_string()),
|
||||
result: None,
|
||||
error: Some(ErrorEnvelope {
|
||||
id: Some("req-helper-err".to_string()),
|
||||
code: "tree_list_failed".to_string(),
|
||||
message: "remote /srv/proj does not exist".to_string(),
|
||||
retryable: false,
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
match dispatcher.request_blocking(&envelope) {
|
||||
Err(BridgeRunError::HelperError(env)) => {
|
||||
assert_eq!(env.code, "tree_list_failed");
|
||||
assert!(env.message.contains("does not exist"));
|
||||
assert!(!env.retryable);
|
||||
}
|
||||
Ok(value) => unreachable!("expected HelperError; got Ok({value:?})"),
|
||||
Err(other) => unreachable!("expected HelperError; got {other:?}"),
|
||||
}
|
||||
if responder.join().is_err() {
|
||||
unreachable!("responder thread panicked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_blocking_synthesizes_unknown_error_when_helper_omits_envelope() {
|
||||
// Defensive branch: ``ok=false`` with no error envelope shouldn't
|
||||
// panic — the dispatcher fabricates a placeholder so callers always
|
||||
// see a usable code/message.
|
||||
let (dispatcher, _cat) = CatChild::spawn();
|
||||
let envelope = RequestEnvelope {
|
||||
id: "req-empty-err".to_string(),
|
||||
method: "tree/list".to_string(),
|
||||
params: json!({}),
|
||||
timeout_ms: 1_500,
|
||||
trace: session_protocol::TraceLevel::Info,
|
||||
};
|
||||
let dispatcher_for_thread = dispatcher.clone();
|
||||
let responder = std::thread::spawn(move || {
|
||||
for _ in 0..50 {
|
||||
if lock_pending(&dispatcher_for_thread.pending).contains_key("req-empty-err") {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
let _ = dispatcher_for_thread.deliver(
|
||||
"req-empty-err",
|
||||
BridgeCliOutput {
|
||||
ok: false,
|
||||
id: Some("req-empty-err".to_string()),
|
||||
result: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
});
|
||||
match dispatcher.request_blocking(&envelope) {
|
||||
Err(BridgeRunError::HelperError(env)) => {
|
||||
assert_eq!(env.code, "unknown");
|
||||
assert!(env.retryable, "fabricated errors default to retryable");
|
||||
}
|
||||
other => unreachable!("expected fabricated HelperError envelope; got {other:?}"),
|
||||
}
|
||||
if responder.join().is_err() {
|
||||
unreachable!("responder thread panicked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_blocking_times_out_and_clears_pending_entry() {
|
||||
// ``recv_timeout`` Err branch: when no response arrives within the
|
||||
// clamped timeout, the pending entry must be removed so a stale
|
||||
// late-reply can be classified as orphaned (see the deliver
|
||||
// ``returns_unmatched_output`` test above for the corresponding
|
||||
// bookend).
|
||||
let (dispatcher, _cat) = CatChild::spawn();
|
||||
let envelope = RequestEnvelope {
|
||||
id: "req-timeout".to_string(),
|
||||
method: "tree/list".to_string(),
|
||||
params: json!({}),
|
||||
// ``timeout_ms`` is clamped to >= 1000ms by the implementation,
|
||||
// so the test takes about a second.
|
||||
timeout_ms: 1,
|
||||
trace: session_protocol::TraceLevel::Info,
|
||||
};
|
||||
let result = dispatcher.request_blocking(&envelope);
|
||||
match result {
|
||||
Err(BridgeRunError::HelperLaunchFailed(msg)) => {
|
||||
assert!(msg.contains("timed out"));
|
||||
}
|
||||
other => unreachable!("expected timeout HelperLaunchFailed; got {other:?}"),
|
||||
}
|
||||
// Pending slot was cleared so a bogus late delivery is now orphaned.
|
||||
let leftover = dispatcher.deliver("req-timeout", ok_output("req-timeout", json!(null)));
|
||||
assert!(
|
||||
leftover.is_some(),
|
||||
"timed-out request must clear its pending slot"
|
||||
);
|
||||
}
|
||||
|
||||
// ----- persistent_broker_endpoint_path --------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn endpoint_path_includes_host_and_pid_under_temp_dir() {
|
||||
let path = persistent_broker_endpoint_path("celery-prod");
|
||||
let s = path.to_string_lossy().to_string();
|
||||
let pid = std::process::id();
|
||||
assert!(s.starts_with(&std::env::temp_dir().to_string_lossy().to_string()));
|
||||
assert!(s.ends_with(&format!("sessions-local-bridge-celery-prod-{pid}.sock")));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn endpoint_path_uses_named_pipe_namespace_on_windows() {
|
||||
// GenericFilePath only accepts ``\\.\pipe\...`` on Windows, so the
|
||||
// endpoint must land under that namespace; otherwise the broker
|
||||
// bind would fail with "name kind not supported".
|
||||
let path = persistent_broker_endpoint_path("celery-prod");
|
||||
let s = path.to_string_lossy().to_string();
|
||||
let pid = std::process::id();
|
||||
assert!(s.starts_with(r"\\.\pipe\"));
|
||||
assert!(s.ends_with(&format!("sessions-local-bridge-celery-prod-{pid}")));
|
||||
assert!(!s.ends_with(".sock"));
|
||||
}
|
||||
|
||||
// ----- lsp_response_body_to_framed_string ------------------------------
|
||||
|
||||
#[test]
|
||||
fn lsp_response_body_unwraps_lsp_stdio_message_envelope()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
// The relay receives the standard channel-dispatch envelope back
|
||||
// from the helper; the BODY portion is the actual LSP frame the
|
||||
// editor expects to read. The unwrap branch keeps the editor's
|
||||
// socket clean of bridge-internal envelope keys.
|
||||
let result = json!({
|
||||
"v": 1,
|
||||
"channel": "lsp:pyright",
|
||||
"kind": session_protocol::CHANNEL_KIND_LSP_STDIO_MESSAGE,
|
||||
"body": { "id": 1, "result": { "capabilities": {} } }
|
||||
});
|
||||
let framed = lsp_response_body_to_framed_string(&result)?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&framed)?;
|
||||
assert_eq!(parsed, json!({ "id": 1, "result": { "capabilities": {} } }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_response_body_passes_through_when_kind_is_not_lsp_stdio_message()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
// A non-LSP channel response (e.g. tree-list) bypasses the unwrap
|
||||
// branch and keeps the envelope intact — the relay forwards exactly
|
||||
// what the helper produced.
|
||||
let result = json!({
|
||||
"v": 1,
|
||||
"channel": "tree-list",
|
||||
"kind": "channel.response",
|
||||
"body": { "entries": [] }
|
||||
});
|
||||
let framed = lsp_response_body_to_framed_string(&result)?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&framed)?;
|
||||
assert_eq!(parsed, result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_response_body_passes_through_when_body_field_is_missing()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
// Truncated-shape defense: ``kind`` is present but ``body`` isn't.
|
||||
// Falls through to the whole-result encoding rather than panicking
|
||||
// on the missing field.
|
||||
let result = json!({
|
||||
"kind": session_protocol::CHANNEL_KIND_LSP_STDIO_MESSAGE
|
||||
});
|
||||
let framed = lsp_response_body_to_framed_string(&result)?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&framed)?;
|
||||
assert_eq!(parsed, result);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
max_traversal_depth: 5,
|
||||
// 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,40 @@ 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;
|
||||
}
|
||||
}
|
||||
// Track G owns the contents of ``.git`` via the
|
||||
// ``fetch_remote_dot_git`` tarball pull (one
|
||||
// ``tar -czf - .git | base64`` per repo). Walking
|
||||
// into ``.git`` here lets the per-directory
|
||||
// ``prune_extra_local_children`` pass delete loose
|
||||
// ref files that are unpacked locally but packed
|
||||
// remotely — e.g. a freshly-created branch in
|
||||
// Sublime Merge silently disappears as soon as the
|
||||
// remote runs ``git pack-refs`` / ``git gc`` and
|
||||
// ``.git/refs/heads/<new>`` no longer appears in
|
||||
// the remote ``list_directory`` result for
|
||||
// ``.git/refs/heads``. Mirror creates the ``.git``
|
||||
// stub so ``discover_git_repos`` can find the
|
||||
// repo, then steps back — Track G's tarball pull
|
||||
// is the only writer for everything underneath.
|
||||
if entry.name == ".git" {
|
||||
continue;
|
||||
}
|
||||
if remaining > 1 {
|
||||
queue.push_back((entry.remote_absolute_path.clone(), remaining - 1));
|
||||
@@ -361,14 +495,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 +539,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::*;
|
||||
|
||||
332
rust/crates/local_bridge/tests/mirror_policy_guardrails.rs
Normal file
332
rust/crates/local_bridge/tests/mirror_policy_guardrails.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
//! 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_git_directory_is_stubbed_but_not_traversed() -> TestResult {
|
||||
// Track G owns ``.git`` content via ``fetch_remote_dot_git`` (one tar
|
||||
// pull per repo). If the BFS walks into ``.git`` and the per-directory
|
||||
// prune pass runs, loose ref files that are unpacked locally but
|
||||
// packed remotely (or branches that exist only in the local mirror
|
||||
// because the user just created them in Sublime Merge) get deleted —
|
||||
// observable as a fresh branch silently disappearing on the next
|
||||
// sync. Pin: ``.git`` produces a stub directory but its children are
|
||||
// never enumerated by the mirror walker.
|
||||
let root = "/srv/ws";
|
||||
let dot_git = format!("{root}/.git");
|
||||
let mut dirs: HashMap<String, Vec<RemoteDirectoryEntry>> = HashMap::new();
|
||||
dirs.insert(
|
||||
root.to_string(),
|
||||
vec![dir_entry(".git", root), file_entry("README.md", root)],
|
||||
);
|
||||
// If the walker DID descend into .git, this listing would be visible
|
||||
// and the parity test below would fail.
|
||||
dirs.insert(
|
||||
dot_git.clone(),
|
||||
vec![
|
||||
dir_entry("refs", &dot_git),
|
||||
file_entry("HEAD", &dot_git),
|
||||
file_entry("config", &dot_git),
|
||||
],
|
||||
);
|
||||
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let cache = tmp.path().join("cache");
|
||||
// Pre-seed the local mirror with a "real" .git that ``fetch_remote_dot_git``
|
||||
// would have planted: a loose ref the remote does not (currently) list.
|
||||
// If the walker enters .git and prunes, this file disappears.
|
||||
let local_dot_git = cache.join(".git");
|
||||
std::fs::create_dir_all(local_dot_git.join("refs/heads"))?;
|
||||
std::fs::write(local_dot_git.join("refs/heads/feature-x"), b"deadbeef\n")?;
|
||||
std::fs::write(local_dot_git.join("HEAD"), b"ref: refs/heads/feature-x\n")?;
|
||||
|
||||
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 ON — this is the auto_deepen path, where the bug
|
||||
// surfaced; if the test passes with prune_missing=true the
|
||||
// boundary is genuinely respected.
|
||||
prune_missing: true,
|
||||
max_dir_fanout: 100,
|
||||
writes_per_second_cap: 0,
|
||||
consecutive_failure_budget: 0,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.ok(), "{:?}", result.error_detail);
|
||||
// .git stub created (so discover_git_repos can find it), but no
|
||||
// children placeholders or prune side-effects under it.
|
||||
assert!(local_dot_git.is_dir(), ".git stub must remain a directory");
|
||||
assert!(
|
||||
local_dot_git.join("refs/heads/feature-x").is_file(),
|
||||
"loose ref under .git must survive — Track G owns this content",
|
||||
);
|
||||
assert!(
|
||||
local_dot_git.join("HEAD").is_file(),
|
||||
".git/HEAD must survive — Track G owns this content",
|
||||
);
|
||||
// No 0-byte placeholder for .git/config (would only exist if the
|
||||
// walker descended and saw the remote listing). Sentinel for the
|
||||
// "no traversal" guarantee.
|
||||
assert!(
|
||||
!local_dot_git.join("config").exists(),
|
||||
".git children must not be enumerated by the mirror walker",
|
||||
);
|
||||
// Sibling outside .git still mirrors normally so we know the walker
|
||||
// is otherwise running.
|
||||
assert!(cache.join("README.md").is_file());
|
||||
Ok(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -194,3 +194,256 @@ pub fn dispatch_lsp_channel_request(
|
||||
"body": parsed,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn assert_envelope(env: &ErrorEnvelope, expected_id: &str, expected_code: &str) {
|
||||
assert_eq!(env.id.as_deref(), Some(expected_id));
|
||||
assert_eq!(env.code, expected_code);
|
||||
}
|
||||
|
||||
fn extract_envelope<T>(result: Result<T, ErrorEnvelope>) -> ErrorEnvelope {
|
||||
match result {
|
||||
Ok(_) => unreachable!("expected ErrorEnvelope"),
|
||||
Err(env) => env,
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_spawn(value: &serde_json::Value, request_id: &str) -> (Vec<String>, Option<String>) {
|
||||
match parse_spawn_payload(value, request_id) {
|
||||
Ok(pair) => pair,
|
||||
Err(env) => unreachable!("parse_spawn_payload should succeed; got {env:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_fake_lsp_binary() -> Option<PathBuf> {
|
||||
if let Ok(path) = std::env::var("CARGO_BIN_EXE_sessions_fake_lsp") {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
let self_exe = std::env::current_exe().ok()?;
|
||||
let deps_dir = self_exe.parent()?;
|
||||
let profile_dir = deps_dir.parent()?;
|
||||
let candidate = profile_dir.join("sessions_fake_lsp");
|
||||
candidate.exists().then_some(candidate)
|
||||
}
|
||||
|
||||
// ----- parse_spawn_payload ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_spawn_payload_accepts_argv_with_string_cwd() {
|
||||
let value = json!({
|
||||
"argv": ["pyright-langserver", "--stdio"],
|
||||
"cwd": "/srv/proj",
|
||||
});
|
||||
let (argv, cwd) = ok_spawn(&value, "req-1");
|
||||
assert_eq!(
|
||||
argv,
|
||||
vec!["pyright-langserver".to_string(), "--stdio".to_string()]
|
||||
);
|
||||
assert_eq!(cwd.as_deref(), Some("/srv/proj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spawn_payload_accepts_argv_without_cwd() {
|
||||
let value = json!({ "argv": ["rust-analyzer"] });
|
||||
let (argv, cwd) = ok_spawn(&value, "req-1");
|
||||
assert_eq!(argv, vec!["rust-analyzer".to_string()]);
|
||||
assert!(cwd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spawn_payload_treats_blank_cwd_as_unset() {
|
||||
// Python wraps user input through ``cli.spawn_cwd`` and strips empty
|
||||
// strings; the helper applies the same defensive filter so an
|
||||
// accidentally-blank cwd doesn't get passed to ``Command::current_dir``
|
||||
// (which would cause spawn to chdir to the empty path → CWD failure).
|
||||
let value = json!({ "argv": ["bin"], "cwd": "" });
|
||||
let (_argv, cwd) = ok_spawn(&value, "req-1");
|
||||
assert!(cwd.is_none(), "empty cwd must collapse to None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spawn_payload_drops_non_string_argv_entries_then_validates_non_empty() {
|
||||
// The filter_map on the entries is permissive — a single non-string
|
||||
// is silently dropped — but an argv that becomes empty after the
|
||||
// drop must still surface ``invalid_lsp_spawn`` rather than spawn
|
||||
// a process with zero args (which would never succeed).
|
||||
let value = json!({ "argv": [42, true, null] });
|
||||
let env = extract_envelope(parse_spawn_payload(&value, "req-2"));
|
||||
assert_envelope(&env, "req-2", "invalid_lsp_spawn");
|
||||
assert!(env.message.contains("non-empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spawn_payload_drops_non_string_argv_entries_keeps_strings() {
|
||||
// Mixed-type argv: the strings are kept in order, the non-strings
|
||||
// are silently dropped. The helper then runs with the surviving
|
||||
// arg list rather than rejecting the whole frame.
|
||||
let value = json!({ "argv": ["a", 7, "b", null, "c"] });
|
||||
let (argv, _) = ok_spawn(&value, "req-3");
|
||||
assert_eq!(
|
||||
argv,
|
||||
vec!["a".to_string(), "b".to_string(), "c".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spawn_payload_rejects_non_object_value() {
|
||||
let value = json!([1, 2, 3]);
|
||||
let env = extract_envelope(parse_spawn_payload(&value, "req-x"));
|
||||
assert_envelope(&env, "req-x", "invalid_lsp_spawn");
|
||||
assert!(env.message.contains("must be a JSON object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spawn_payload_rejects_missing_argv() {
|
||||
let value = json!({ "cwd": "/srv/proj" });
|
||||
let env = extract_envelope(parse_spawn_payload(&value, "req-y"));
|
||||
assert_envelope(&env, "req-y", "invalid_lsp_spawn");
|
||||
assert!(env.message.contains("argv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spawn_payload_rejects_argv_that_is_not_an_array() {
|
||||
let value = json!({ "argv": "single string is wrong shape" });
|
||||
let env = extract_envelope(parse_spawn_payload(&value, "req-z"));
|
||||
assert_envelope(&env, "req-z", "invalid_lsp_spawn");
|
||||
assert!(env.message.contains("argv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spawn_payload_rejects_empty_argv_array() {
|
||||
let value = json!({ "argv": [] });
|
||||
let env = extract_envelope(parse_spawn_payload(&value, "req-empty"));
|
||||
assert_envelope(&env, "req-empty", "invalid_lsp_spawn");
|
||||
assert!(env.message.contains("non-empty"));
|
||||
}
|
||||
|
||||
// ----- normalize_jsonrpc_body ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn normalize_jsonrpc_body_inserts_default_version_when_absent() {
|
||||
let mut body = json!({
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
});
|
||||
normalize_jsonrpc_body(&mut body);
|
||||
assert_eq!(body["jsonrpc"], json!("2.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_jsonrpc_body_preserves_caller_supplied_version() {
|
||||
let mut body = json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": 1,
|
||||
"method": "initialize"
|
||||
});
|
||||
normalize_jsonrpc_body(&mut body);
|
||||
// The function only fills in a missing version; explicit values are
|
||||
// preserved verbatim so a future protocol bump doesn't get clobbered
|
||||
// here without also changing the call site.
|
||||
assert_eq!(body["jsonrpc"], json!("1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_jsonrpc_body_is_noop_when_body_is_not_an_object() {
|
||||
let mut body = json!([1, 2, 3]);
|
||||
normalize_jsonrpc_body(&mut body);
|
||||
assert_eq!(body, json!([1, 2, 3]));
|
||||
}
|
||||
|
||||
// ----- dispatch_lsp_channel_request error branches ---------------------
|
||||
|
||||
#[test]
|
||||
fn dispatch_returns_lsp_spawn_required_when_no_child_and_no_spawn_payload() {
|
||||
reset_lsp_children_for_tests();
|
||||
let body = json!({
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
});
|
||||
let env = extract_envelope(dispatch_lsp_channel_request(
|
||||
"lsp:never-spawned",
|
||||
body,
|
||||
"req-no-spawn",
|
||||
));
|
||||
assert_envelope(&env, "req-no-spawn", "lsp_spawn_required");
|
||||
assert!(env.message.contains("_sessions_lsp_spawn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_propagates_invalid_spawn_payload_error() {
|
||||
reset_lsp_children_for_tests();
|
||||
let body = json!({
|
||||
"_sessions_lsp_spawn": "not an object",
|
||||
"method": "initialize",
|
||||
});
|
||||
let env = extract_envelope(dispatch_lsp_channel_request(
|
||||
"lsp:bad-spawn",
|
||||
body,
|
||||
"req-bad-spawn",
|
||||
));
|
||||
assert_envelope(&env, "req-bad-spawn", "invalid_lsp_spawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_surfaces_spawn_failure_when_binary_does_not_exist() {
|
||||
// ``lsp_spawn_failed`` is the helper's wrapper for a downstream
|
||||
// ``Command::spawn`` failure; the request_id is intentionally NOT
|
||||
// forwarded into the envelope id (the existing code emits it as
|
||||
// None) so that's where this test pins behavior — flipping that
|
||||
// would silently change Python's correlation logic.
|
||||
reset_lsp_children_for_tests();
|
||||
let body = json!({
|
||||
"_sessions_lsp_spawn": {
|
||||
"argv": ["/definitely/not/a/real/binary/sessions-test-12345"]
|
||||
},
|
||||
"method": "initialize",
|
||||
});
|
||||
let env = extract_envelope(dispatch_lsp_channel_request(
|
||||
"lsp:bin-missing",
|
||||
body,
|
||||
"req-bin-missing",
|
||||
));
|
||||
assert_eq!(env.id, None);
|
||||
assert_eq!(env.code, "lsp_spawn_failed");
|
||||
assert!(env.message.contains("Failed to spawn LSP child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_returns_envelope_shape_after_successful_round_trip() {
|
||||
// Drives the full happy-path: spawn → write → read → wrap into the
|
||||
// CHANNEL_KIND_LSP_STDIO_MESSAGE envelope. Coverage targets the
|
||||
// post-spawn write/read/encode lines that the negative tests miss.
|
||||
let Some(fake) = resolve_fake_lsp_binary() else {
|
||||
eprintln!("sessions_fake_lsp not built; skipping happy-path dispatch test");
|
||||
return;
|
||||
};
|
||||
reset_lsp_children_for_tests();
|
||||
let body = json!({
|
||||
"_sessions_lsp_spawn": {
|
||||
"argv": [fake.to_string_lossy()],
|
||||
"cwd": "/"
|
||||
},
|
||||
"id": 99,
|
||||
"method": "initialize",
|
||||
"params": { "rootUri": "file:///tmp", "capabilities": {} }
|
||||
});
|
||||
let response = match dispatch_lsp_channel_request("lsp:happy-shape", body, "req-happy") {
|
||||
Ok(value) => value,
|
||||
Err(env) => unreachable!("dispatch should succeed once fake_lsp replies; got {env:?}"),
|
||||
};
|
||||
assert_eq!(response["v"], json!(CHANNEL_ENVELOPE_V1));
|
||||
assert_eq!(response["channel"], json!("lsp:happy-shape"));
|
||||
assert_eq!(response["kind"], json!(CHANNEL_KIND_LSP_STDIO_MESSAGE));
|
||||
// fake_lsp echoes id back and reports an empty capabilities object
|
||||
// — both shapes pin the response wiring, not the LSP behavior.
|
||||
assert_eq!(response["body"]["id"], json!(99));
|
||||
assert_eq!(response["body"]["jsonrpc"], json!("2.0"));
|
||||
assert!(response["body"]["result"]["capabilities"].is_object());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
199
rust/crates/session_protocol/src/envelope.rs
Normal file
199
rust/crates/session_protocol/src/envelope.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Multiplex envelope (Wave 2 spec freeze — PYTHON_THINNING_PLAN.md §5 PR 13a).
|
||||
//!
|
||||
//! The envelope is the on-wire shape that lets a single `local_bridge ↔
|
||||
//! session_helper` stdio link carry multiple logical channels (file, exec_once,
|
||||
//! lsp, control, future mirror) without one slow run blocking interactive
|
||||
//! traffic. Wave 2 builds cancellation, deadlines, and back-pressure on top of
|
||||
//! this shape; freezing it now (PR 13a) lets PR 16 (PR-A worker loop body)
|
||||
//! land while PR 13b adds the rest of Wave 2 incrementally.
|
||||
//!
|
||||
//! ## Wire shape
|
||||
//!
|
||||
//! ```text
|
||||
//! { "v": "sessions.channel.v1",
|
||||
//! "channel": "control", // "file" / "exec_once" / "lsp:<id>" / ...
|
||||
//! "kind": "request", // "lsp_stdio.ping" / etc.
|
||||
//! "body": { ... } } // channel/kind-specific payload
|
||||
//! ```
|
||||
//!
|
||||
//! `v` is the [`crate::CHANNEL_ENVELOPE_V1`] constant so future revisions can
|
||||
//! be detected at parse time. `channel` and `kind` are free-form strings; the
|
||||
//! crate-level `CHANNEL_*` and `CHANNEL_KIND_*` constants define the shapes
|
||||
//! every helper/bridge implementation must already accept.
|
||||
//!
|
||||
//! ## Spec drift guard
|
||||
//!
|
||||
//! `Envelope` is the **single source of truth** for the wire shape. Any code
|
||||
//! that builds or parses these four fields must round-trip through
|
||||
//! `serde_json::to_value` / `serde_json::from_value` of this struct — see
|
||||
//! `tests/envelope_parity.rs` for the parity contract that PR 16 (PR-A) will
|
||||
//! reuse to ensure its supervisor stays envelope-compatible.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::CHANNEL_ENVELOPE_V1;
|
||||
|
||||
/// Multiplex envelope wire shape (Wave 2 spec freeze).
|
||||
///
|
||||
/// Constructed via [`Envelope::new`] (which fills `v` with the canonical
|
||||
/// version constant) or directly from raw JSON via `serde_json::from_value`.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
pub struct Envelope {
|
||||
/// Envelope version. Always [`CHANNEL_ENVELOPE_V1`] for the Wave 2 freeze.
|
||||
pub v: String,
|
||||
/// Logical channel routing the envelope (e.g. `"file"`, `"control"`,
|
||||
/// `"lsp:<server-id>"`).
|
||||
pub channel: String,
|
||||
/// Channel-specific message kind (e.g. `"request"`, `"lsp_stdio.ping"`).
|
||||
pub kind: String,
|
||||
/// Opaque per-(channel, kind) payload. May be any JSON value, including
|
||||
/// `null` for no-body messages such as control pings.
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
/// Build an envelope with `v` set to [`CHANNEL_ENVELOPE_V1`].
|
||||
///
|
||||
/// Prefer this over a raw struct literal so callers cannot accidentally
|
||||
/// stamp a stale envelope version onto a new message.
|
||||
#[must_use]
|
||||
pub fn new(channel: impl Into<String>, kind: impl Into<String>, body: Value) -> Self {
|
||||
Self {
|
||||
v: CHANNEL_ENVELOPE_V1.to_string(),
|
||||
channel: channel.into(),
|
||||
kind: kind.into(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether `self.v` matches [`CHANNEL_ENVELOPE_V1`].
|
||||
///
|
||||
/// Wave 2 reference implementations should reject envelopes with an
|
||||
/// unknown `v` (forward-compat marker for a future rev).
|
||||
#[must_use]
|
||||
pub fn is_current_version(&self) -> bool {
|
||||
self.v == CHANNEL_ENVELOPE_V1
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference implementation of the Wave 2 envelope router (PR 13a).
|
||||
///
|
||||
/// Routes one envelope to its channel handler and returns a response envelope
|
||||
/// (or an error envelope) on the same channel. The Wave 2 freeze ships exactly
|
||||
/// one channel handler — `"control"`, which echoes the request body — so the
|
||||
/// router covers every channel/kind path that the parity test exercises while
|
||||
/// staying small enough to be reviewed in PR 13a.
|
||||
///
|
||||
/// PR 13b extends this with the `file` / `exec_once` / `lsp:*` channels;
|
||||
/// PR 16 plugs the orchestrator into the `control` channel for queue
|
||||
/// dispatch. The shape of the function — `Envelope -> Envelope` — is the
|
||||
/// `compile-time spec drift guard` rust-maximalist asked for: any future
|
||||
/// channel handler that wants to live on this transport must accept and
|
||||
/// return [`Envelope`] (not raw JSON).
|
||||
pub fn reference_dispatch(request: &Envelope) -> Envelope {
|
||||
if !request.is_current_version() {
|
||||
return Envelope::new(
|
||||
request.channel.clone(),
|
||||
"error",
|
||||
serde_json::json!({
|
||||
"code": "envelope_version_mismatch",
|
||||
"expected": CHANNEL_ENVELOPE_V1,
|
||||
"received": request.v,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if request.channel == "control" && request.kind == "echo" {
|
||||
return Envelope::new("control", "echo_response", request.body.clone());
|
||||
}
|
||||
Envelope::new(
|
||||
request.channel.clone(),
|
||||
"error",
|
||||
serde_json::json!({
|
||||
"code": "channel_kind_unhandled",
|
||||
"channel": request.channel,
|
||||
"kind": request.kind,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_stamps_current_version() {
|
||||
let env = Envelope::new("control", "echo", Value::Null);
|
||||
assert_eq!(env.v, CHANNEL_ENVELOPE_V1);
|
||||
assert!(env.is_current_version());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_through_json_value() -> Result<(), serde_json::Error> {
|
||||
let env = Envelope::new("control", "echo", serde_json::json!({"x": 1}));
|
||||
let value = serde_json::to_value(&env)?;
|
||||
let back: Envelope = serde_json::from_value(value)?;
|
||||
assert_eq!(env, back);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_through_ndjson_string() -> Result<(), serde_json::Error> {
|
||||
let env = Envelope::new("file", "request", serde_json::json!({"path": "/a"}));
|
||||
let line = serde_json::to_string(&env)?;
|
||||
let back: Envelope = serde_json::from_str(&line)?;
|
||||
assert_eq!(env, back);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_version_in_dispatch() {
|
||||
let req = Envelope {
|
||||
v: "sessions.channel.v999".to_string(),
|
||||
channel: "control".to_string(),
|
||||
kind: "echo".to_string(),
|
||||
body: Value::Null,
|
||||
};
|
||||
let resp = reference_dispatch(&req);
|
||||
assert_eq!(resp.kind, "error");
|
||||
assert_eq!(resp.body["code"], "envelope_version_mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_echo_reflects_body() {
|
||||
let req = Envelope::new("control", "echo", serde_json::json!({"hello": "world"}));
|
||||
let resp = reference_dispatch(&req);
|
||||
assert_eq!(resp.kind, "echo_response");
|
||||
assert_eq!(resp.body, serde_json::json!({"hello": "world"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_channel_kind_returns_error() {
|
||||
let req = Envelope::new("file", "tree/list", Value::Null);
|
||||
let resp = reference_dispatch(&req);
|
||||
assert_eq!(resp.kind, "error");
|
||||
assert_eq!(resp.body["code"], "channel_kind_unhandled");
|
||||
assert_eq!(resp.body["channel"], "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_body_round_trips_intact() -> Result<(), serde_json::Error> {
|
||||
let env = Envelope::new("control", "ping", Value::Null);
|
||||
let line = serde_json::to_string(&env)?;
|
||||
let back: Envelope = serde_json::from_str(&line)?;
|
||||
assert_eq!(back.body, Value::Null);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_fields_are_rejected_for_strict_freeze() -> Result<(), serde_json::Error> {
|
||||
// serde_json default for derive(Deserialize) ignores extra fields,
|
||||
// which is desirable for forward-compat. This test pins that
|
||||
// contract so PR 16 can rely on lenient parsing of unknown body
|
||||
// shapes without a proto rev.
|
||||
let raw = r#"{"v":"sessions.channel.v1","channel":"control","kind":"echo","body":null,"extra":42}"#;
|
||||
let env: Envelope = serde_json::from_str(raw)?;
|
||||
assert!(env.is_current_version());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,11 @@ use serde_json::Value;
|
||||
use std::str::Utf8Error;
|
||||
|
||||
pub mod compatibility;
|
||||
pub mod envelope;
|
||||
pub mod lsp_stdio_framing;
|
||||
|
||||
pub use compatibility::{HandshakeCompatibility, normalized_protocol_version};
|
||||
pub use envelope::{Envelope, reference_dispatch};
|
||||
pub use lsp_stdio_framing::{read_lsp_message, write_lsp_message};
|
||||
|
||||
/// Version string advertised by the first shared Sessions protocol skeleton.
|
||||
@@ -343,6 +345,15 @@ pub struct ExecOnceParams {
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// Timeout budget for the child process in milliseconds.
|
||||
pub timeout_ms: u64,
|
||||
/// Optional override for the helper's per-call stdout cap. ``None`` keeps
|
||||
/// the helper default (see ``EXEC_STDOUT_MAX``); larger values let
|
||||
/// callers like the Track G ``.git`` fetch ship multi-megabyte tarballs
|
||||
/// without triggering ``SIGPIPE`` on the remote producer.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stdout_max_bytes: Option<u64>,
|
||||
/// Optional override for the helper's per-call stderr cap.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stderr_max_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// Result payload for one-shot remote process execution.
|
||||
@@ -799,6 +810,8 @@ mod tests {
|
||||
cwd: "/srv/ws".to_string(),
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_ms: 10_000,
|
||||
stdout_max_bytes: None,
|
||||
stderr_max_bytes: None,
|
||||
};
|
||||
let result = ExecOnceResult {
|
||||
exit_code: 0,
|
||||
|
||||
@@ -58,3 +58,150 @@ pub fn write_lsp_message<W: Write>(writer: &mut W, payload: &str) -> io::Result<
|
||||
writer.write_all(bytes)?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufReader;
|
||||
|
||||
fn read_one(input: &[u8]) -> io::Result<String> {
|
||||
let mut reader = BufReader::new(input);
|
||||
read_lsp_message(&mut reader)
|
||||
}
|
||||
|
||||
fn err_kind_and_msg(payload: &[u8]) -> (io::ErrorKind, String) {
|
||||
match read_one(payload) {
|
||||
Ok(body) => unreachable!("expected error; got body {body:?}"),
|
||||
Err(err) => (err.kind(), err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_accepts_canonical_lsp_frame_with_crlf_separator()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let payload = b"Content-Length: 17\r\n\r\n{\"method\":\"ping\"}";
|
||||
let body = read_one(payload)?;
|
||||
assert_eq!(body, r#"{"method":"ping"}"#);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_accepts_extra_headers_and_ignores_unknown_ones()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
// Some LSP clients emit ``Content-Type`` alongside Content-Length;
|
||||
// the reader must skip headers it doesn't understand instead of
|
||||
// failing the frame.
|
||||
let payload = b"Content-Type: application/vscode-jsonrpc; charset=utf-8\r\nContent-Length: 5\r\n\r\nhello";
|
||||
assert_eq!(read_one(payload)?, "hello");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_accepts_lf_only_line_endings() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// The header parser explicitly trims both \r and \n suffixes so a
|
||||
// unix-tooled producer (no \r) round-trips the same bytes.
|
||||
let payload = b"Content-Length: 5\n\nhello";
|
||||
assert_eq!(read_one(payload)?, "hello");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_missing_content_length_header() {
|
||||
let payload = b"Content-Type: text/plain\r\n\r\nhello";
|
||||
let (kind, msg) = err_kind_and_msg(payload);
|
||||
assert_eq!(kind, io::ErrorKind::InvalidData);
|
||||
assert!(msg.contains("missing Content-Length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_non_numeric_content_length() {
|
||||
let payload = b"Content-Length: abc\r\n\r\nhello";
|
||||
let (kind, msg) = err_kind_and_msg(payload);
|
||||
assert_eq!(kind, io::ErrorKind::InvalidData);
|
||||
assert!(msg.contains("invalid Content-Length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_content_length_exceeding_cap() {
|
||||
let oversized = MAX_LSP_MESSAGE_BYTES + 1;
|
||||
let frame = format!("Content-Length: {oversized}\r\n\r\n");
|
||||
let (kind, msg) = err_kind_and_msg(frame.as_bytes());
|
||||
assert_eq!(kind, io::ErrorKind::InvalidData);
|
||||
assert!(msg.contains("exceeds cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_returns_unexpected_eof_when_stream_closes_in_headers() {
|
||||
let (kind, _msg) = err_kind_and_msg(b"");
|
||||
assert_eq!(kind, io::ErrorKind::UnexpectedEof);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_propagates_short_body_as_unexpected_eof() {
|
||||
// Header advertises 50 bytes but stream supplies 5 — read_exact
|
||||
// bubbles up the truncation as UnexpectedEof. Pinning this means
|
||||
// the relay loop can rely on EOF semantics for stream-closed
|
||||
// detection rather than ad-hoc length checks.
|
||||
let payload = b"Content-Length: 50\r\n\r\nshort";
|
||||
let (kind, _msg) = err_kind_and_msg(payload);
|
||||
assert_eq!(kind, io::ErrorKind::UnexpectedEof);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_invalid_utf8_body() {
|
||||
// Header announces 4 bytes; the payload is a deliberately invalid
|
||||
// UTF-8 sequence so the ``String::from_utf8`` branch fires.
|
||||
let mut payload = b"Content-Length: 4\r\n\r\n".to_vec();
|
||||
payload.extend_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]);
|
||||
let (kind, _msg) = err_kind_and_msg(&payload);
|
||||
assert_eq!(kind, io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_emits_canonical_content_length_header_then_body()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
write_lsp_message(&mut buf, r#"{"method":"ping"}"#)?;
|
||||
assert_eq!(
|
||||
std::str::from_utf8(&buf)?,
|
||||
"Content-Length: 17\r\n\r\n{\"method\":\"ping\"}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_round_trips_via_in_memory_buffer() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
// End-to-end framing invariant: anything ``write_lsp_message``
|
||||
// produces is parseable by ``read_lsp_message`` byte-for-byte.
|
||||
let bodies = ["{}", r#"{"id":1}"#, "{\n \"key\": \"value\"\n}"];
|
||||
for body in bodies {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
write_lsp_message(&mut buf, body)?;
|
||||
let mut reader = BufReader::new(&buf[..]);
|
||||
let parsed = read_lsp_message(&mut reader)?;
|
||||
assert_eq!(parsed, body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_handles_zero_length_body() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
write_lsp_message(&mut buf, "")?;
|
||||
assert_eq!(std::str::from_utf8(&buf)?, "Content-Length: 0\r\n\r\n");
|
||||
let mut reader = BufReader::new(&buf[..]);
|
||||
assert_eq!(read_lsp_message(&mut reader)?, "");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_skips_content_length_value_with_leading_whitespace()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
// RFC-style values often have a leading space after the colon —
|
||||
// ``rest.trim()`` is the line that needs coverage here.
|
||||
let payload = b"Content-Length: 7\r\n\r\nhello!!";
|
||||
assert_eq!(read_one(payload)?, "hello!!");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
93
rust/crates/session_protocol/tests/envelope_parity.rs
Normal file
93
rust/crates/session_protocol/tests/envelope_parity.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
//! Wave 2 envelope parity test (PR 13a — spec freeze gate).
|
||||
//!
|
||||
//! Wire-shape pin for the `v` / `channel` / `kind` / `body` envelope. Any
|
||||
//! future change to those four field names breaks this fixture by design —
|
||||
//! Wave 2 implementations (PR 13b channel supervisor, PR 16 PR-A worker
|
||||
//! body) must round-trip through this exact NDJSON shape, so the freeze
|
||||
//! lives here in tests rather than buried in implementation files.
|
||||
//!
|
||||
//! Internal serde behaviour is covered by `envelope::tests` inside the
|
||||
//! crate. This integration test exists for the *cross-crate parity*
|
||||
//! contract — it imports through the public `session_protocol` re-export
|
||||
//! exactly as `local_bridge` / `session_helper` / `sessions_native` will.
|
||||
|
||||
use session_protocol::{CHANNEL_ENVELOPE_V1, Envelope, reference_dispatch};
|
||||
|
||||
#[test]
|
||||
fn envelope_canonical_ndjson_shape_is_frozen() -> Result<(), serde_json::Error> {
|
||||
// The four-field shape every Wave 2 channel handler must accept. If you
|
||||
// need to extend the wire shape, bump CHANNEL_ENVELOPE_V1 and add a new
|
||||
// parity fixture below — do not edit this one.
|
||||
let canonical = serde_json::json!({
|
||||
"v": "sessions.channel.v1",
|
||||
"channel": "control",
|
||||
"kind": "echo",
|
||||
"body": {"hello": "world"},
|
||||
});
|
||||
let env: Envelope = serde_json::from_value(canonical.clone())?;
|
||||
assert_eq!(env.v, CHANNEL_ENVELOPE_V1);
|
||||
assert_eq!(env.channel, "control");
|
||||
assert_eq!(env.kind, "echo");
|
||||
assert_eq!(env.body, serde_json::json!({"hello": "world"}));
|
||||
|
||||
let re_serialized = serde_json::to_value(&env)?;
|
||||
assert_eq!(re_serialized, canonical);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_dispatch_round_trips_control_echo() {
|
||||
let request = Envelope::new(
|
||||
"control",
|
||||
"echo",
|
||||
serde_json::json!({"id": "req-1", "payload": [1, 2, 3]}),
|
||||
);
|
||||
let response = reference_dispatch(&request);
|
||||
assert!(response.is_current_version());
|
||||
assert_eq!(response.channel, "control");
|
||||
assert_eq!(response.kind, "echo_response");
|
||||
assert_eq!(
|
||||
response.body,
|
||||
serde_json::json!({"id": "req-1", "payload": [1, 2, 3]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_dispatch_rejects_stale_version() {
|
||||
let request = Envelope {
|
||||
v: "sessions.channel.v0".to_string(),
|
||||
channel: "control".to_string(),
|
||||
kind: "echo".to_string(),
|
||||
body: serde_json::Value::Null,
|
||||
};
|
||||
let response = reference_dispatch(&request);
|
||||
assert_eq!(response.kind, "error");
|
||||
assert_eq!(response.body["code"], "envelope_version_mismatch");
|
||||
// The error envelope itself is *current* version — only the rejected
|
||||
// request held the stale `v`.
|
||||
assert!(response.is_current_version());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_channel_kind_yields_structured_error_envelope() {
|
||||
let request = Envelope::new("file", "tree/list", serde_json::Value::Null);
|
||||
let response = reference_dispatch(&request);
|
||||
assert_eq!(response.kind, "error");
|
||||
assert_eq!(response.body["code"], "channel_kind_unhandled");
|
||||
// PR 13b will replace this branch with a real `file` channel handler.
|
||||
assert_eq!(response.body["channel"], "file");
|
||||
assert_eq!(response.body["kind"], "tree/list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ndjson_round_trip_preserves_byte_for_byte_field_names() -> Result<(), serde_json::Error> {
|
||||
// Byte-level pin: serde-derived Serialize emits keys in struct order.
|
||||
// PR 16 (PR-A) relies on this when comparing recorded fixtures.
|
||||
let env = Envelope::new("control", "echo", serde_json::json!({"x": 1}));
|
||||
let line = serde_json::to_string(&env)?;
|
||||
assert_eq!(
|
||||
line,
|
||||
r#"{"v":"sessions.channel.v1","channel":"control","kind":"echo","body":{"x":1}}"#,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
19
rust/crates/sessions_askpass/Cargo.toml
Normal file
19
rust/crates/sessions_askpass/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "sessions_askpass"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "PE binary that brokers SSH_ASKPASS prompts back to the Sessions plugin via filesystem rendezvous."
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "sessions_askpass"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
269
rust/crates/sessions_askpass/src/main.rs
Normal file
269
rust/crates/sessions_askpass/src/main.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
//! SSH_ASKPASS shim for the Sessions Sublime plugin.
|
||||
//!
|
||||
//! Why this exists: Windows OpenSSH spawns ``SSH_ASKPASS`` via its own
|
||||
//! ``posix_spawnp`` shim, which in turn calls ``CreateProcessW`` directly.
|
||||
//! ``CreateProcessW`` only accepts real PE binaries — ``.cmd`` / ``.bat``
|
||||
//! scripts aren't loadable that way and fail with ``ERROR_FILE_NOT_FOUND``.
|
||||
//! Shipping this tiny ``.exe`` lets the plugin's prompt-bridge protocol work
|
||||
//! on Windows without giving up password / passphrase authentication.
|
||||
//!
|
||||
//! Subsystem: GUI on Windows so OpenSSH's ``CREATE_NEW_CONSOLE`` flag for
|
||||
//! the ``SSH_ASKPASS`` child does not flash a ``cmd.exe`` window for every
|
||||
//! auth round. The protocol is filesystem-rendezvous + stdout (the password
|
||||
//! ssh reads); both work the same regardless of subsystem because ssh
|
||||
//! pre-redirects this child's stdio to pipes via ``STARTUPINFO``.
|
||||
|
||||
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
|
||||
//!
|
||||
//! Protocol (matched verbatim by the Sublime side in ``ssh_runner.py`` /
|
||||
//! ``ssh_file_transport.py``):
|
||||
//!
|
||||
//! - ``SESSIONS_ASKPASS_REQUEST`` — file we write the prompt text into
|
||||
//! - ``SESSIONS_ASKPASS_RESPONSE`` — file the plugin writes the answer into
|
||||
//! - ``SESSIONS_ASKPASS_CANCEL`` — file the plugin touches to cancel
|
||||
//!
|
||||
//! Behaviour:
|
||||
//!
|
||||
//! 1. Read the prompt text from ``argv[1]`` (ssh passes a single argument —
|
||||
//! the prompt to display).
|
||||
//! 2. Write that prompt to ``SESSIONS_ASKPASS_REQUEST`` (atomic write via
|
||||
//! ``tmp + rename``).
|
||||
//! 3. Poll for ``SESSIONS_ASKPASS_RESPONSE`` or ``SESSIONS_ASKPASS_CANCEL``.
|
||||
//! 4. On response: write its contents to stdout (ssh reads stdout as the
|
||||
//! password) and exit ``0``.
|
||||
//! 5. On cancel: exit ``1`` so ssh treats it as a refused prompt.
|
||||
//! 6. Bounded by ``SESSIONS_ASKPASS_TIMEOUT_SECS`` (default ``120``); if
|
||||
//! nothing arrives the process exits ``1`` so the ssh attempt fails
|
||||
//! cleanly instead of hanging.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let prompt = env::args().nth(1).unwrap_or_default();
|
||||
let request = match required_env_path("SESSIONS_ASKPASS_REQUEST") {
|
||||
Some(path) => path,
|
||||
None => return ExitCode::from(2),
|
||||
};
|
||||
let response = match required_env_path("SESSIONS_ASKPASS_RESPONSE") {
|
||||
Some(path) => path,
|
||||
None => return ExitCode::from(2),
|
||||
};
|
||||
let cancel = match required_env_path("SESSIONS_ASKPASS_CANCEL") {
|
||||
Some(path) => path,
|
||||
None => return ExitCode::from(2),
|
||||
};
|
||||
if let Err(_e) = write_prompt(&request, &prompt) {
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
let timeout = resolve_timeout();
|
||||
match poll_for_response(&response, &cancel, timeout) {
|
||||
PollOutcome::Response(text) => {
|
||||
// ssh expects the password on stdout. Don't append a newline:
|
||||
// some prompt flows treat trailing whitespace as part of the
|
||||
// password. The Sublime side is responsible for stripping any
|
||||
// trailing newline the user typed before writing the response.
|
||||
if std::io::stdout().write_all(text.as_bytes()).is_err() {
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
PollOutcome::Cancelled => ExitCode::from(1),
|
||||
PollOutcome::TimedOut => ExitCode::from(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_env_path(name: &str) -> Option<PathBuf> {
|
||||
match env::var_os(name) {
|
||||
Some(value) if !value.is_empty() => Some(PathBuf::from(value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_timeout() -> Duration {
|
||||
let raw = env::var("SESSIONS_ASKPASS_TIMEOUT_SECS").ok();
|
||||
let secs = raw
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.filter(|s| *s > 0)
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
fn write_prompt(request: &Path, prompt: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = request.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = request.with_extension("tmp");
|
||||
fs::write(&tmp, prompt.as_bytes())?;
|
||||
fs::rename(&tmp, request)
|
||||
}
|
||||
|
||||
enum PollOutcome {
|
||||
Response(String),
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
fn poll_for_response(response: &Path, cancel: &Path, timeout: Duration) -> PollOutcome {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Ok(text) = fs::read_to_string(response) {
|
||||
// Best-effort cleanup: the plugin treats response.txt as
|
||||
// single-shot, but a stale file would be reused on the next
|
||||
// prompt. Ignore failures — the OS may briefly hold the
|
||||
// handle on Windows.
|
||||
let _ = fs::remove_file(response);
|
||||
return PollOutcome::Response(text);
|
||||
}
|
||||
if cancel.exists() {
|
||||
let _ = fs::remove_file(cancel);
|
||||
return PollOutcome::Cancelled;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return PollOutcome::TimedOut;
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Env-var mutation in tests must serialize: the binary reads env vars
|
||||
// at startup, but unit tests share the process, so concurrent
|
||||
// ``env::set_var`` from different test threads would race.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn resolve_timeout_defaults_when_var_unset() {
|
||||
let _guard = match ENV_LOCK.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
// SAFETY: tests serialize env access via ENV_LOCK; no other thread
|
||||
// mutates SESSIONS_ASKPASS_TIMEOUT_SECS while this test runs.
|
||||
unsafe {
|
||||
env::remove_var("SESSIONS_ASKPASS_TIMEOUT_SECS");
|
||||
}
|
||||
assert_eq!(resolve_timeout(), Duration::from_secs(DEFAULT_TIMEOUT_SECS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_timeout_parses_positive_int() {
|
||||
let _guard = match ENV_LOCK.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
unsafe {
|
||||
env::set_var("SESSIONS_ASKPASS_TIMEOUT_SECS", "30");
|
||||
}
|
||||
assert_eq!(resolve_timeout(), Duration::from_secs(30));
|
||||
unsafe {
|
||||
env::remove_var("SESSIONS_ASKPASS_TIMEOUT_SECS");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_timeout_rejects_zero_and_garbage() {
|
||||
let _guard = match ENV_LOCK.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
for bad in ["0", "-5", "abc", ""] {
|
||||
unsafe {
|
||||
env::set_var("SESSIONS_ASKPASS_TIMEOUT_SECS", bad);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_timeout(),
|
||||
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
"bad value {bad:?} must fall back to default",
|
||||
);
|
||||
}
|
||||
unsafe {
|
||||
env::remove_var("SESSIONS_ASKPASS_TIMEOUT_SECS");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_prompt_creates_file_atomically() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let request = dir.path().join("request.txt");
|
||||
write_prompt(&request, "Password for user@host: ")?;
|
||||
let content = fs::read_to_string(&request)?;
|
||||
assert_eq!(content, "Password for user@host: ");
|
||||
// No leftover ``.tmp``.
|
||||
let tmp = request.with_extension("tmp");
|
||||
assert!(!tmp.exists(), "rename target must clean up its tmp file");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_returns_response_when_file_appears() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let response = dir.path().join("response.txt");
|
||||
let cancel = dir.path().join("cancel.txt");
|
||||
let response_for_writer = response.clone();
|
||||
let writer = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(80));
|
||||
fs::write(&response_for_writer, "hunter2").unwrap_or(());
|
||||
});
|
||||
let outcome = poll_for_response(&response, &cancel, Duration::from_secs(5));
|
||||
writer.join().ok();
|
||||
match outcome {
|
||||
PollOutcome::Response(text) => assert_eq!(text, "hunter2"),
|
||||
PollOutcome::Cancelled => unreachable!("expected Response, got Cancelled"),
|
||||
PollOutcome::TimedOut => unreachable!("expected Response, got TimedOut"),
|
||||
}
|
||||
// Response file is consumed.
|
||||
assert!(!response.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_returns_cancelled_when_cancel_file_appears() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let response = dir.path().join("response.txt");
|
||||
let cancel = dir.path().join("cancel.txt");
|
||||
let cancel_for_writer = cancel.clone();
|
||||
let writer = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(80));
|
||||
fs::write(&cancel_for_writer, "").unwrap_or(());
|
||||
});
|
||||
let outcome = poll_for_response(&response, &cancel, Duration::from_secs(5));
|
||||
writer.join().ok();
|
||||
match outcome {
|
||||
PollOutcome::Cancelled => {}
|
||||
PollOutcome::Response(text) => unreachable!("expected Cancelled, got {text:?}"),
|
||||
PollOutcome::TimedOut => unreachable!("expected Cancelled, got TimedOut"),
|
||||
}
|
||||
assert!(!cancel.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_times_out_when_nothing_appears() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let response = dir.path().join("response.txt");
|
||||
let cancel = dir.path().join("cancel.txt");
|
||||
let outcome = poll_for_response(&response, &cancel, Duration::from_millis(150));
|
||||
match outcome {
|
||||
PollOutcome::TimedOut => {}
|
||||
PollOutcome::Response(text) => unreachable!("expected TimedOut, got {text:?}"),
|
||||
PollOutcome::Cancelled => unreachable!("expected TimedOut, got Cancelled"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -11,6 +15,11 @@ crate-type = ["cdylib", "rlib"]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
notify = "8.2.0"
|
||||
serde_json = "1"
|
||||
session_protocol = { path = "../session_protocol" }
|
||||
workspace_identity = { path = "../workspace_identity" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -41,6 +41,10 @@ pub enum AbiError {
|
||||
/// Broker: serializing the outcome for the caller failed. Indicates a
|
||||
/// bug in `sessions_native`, not a caller error.
|
||||
BrokerSerializeFailed = -21,
|
||||
/// Settings normalize / generic helper: serializing the result to JSON
|
||||
/// failed. Indicates a bug in `sessions_native` (`serde_json::to_string`
|
||||
/// should not fail on values it itself constructed).
|
||||
Serialization = -22,
|
||||
}
|
||||
|
||||
impl AbiError {
|
||||
|
||||
136
rust/crates/sessions_native/src/atomic_write.rs
Normal file
136
rust/crates/sessions_native/src/atomic_write.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
//! Atomic write helper (Wave 2 PR 14.5b — H1 transaction 전제).
|
||||
//!
|
||||
//! Python `_atomic_write_bytes` 와 동일한 contract:
|
||||
//! - target 의 parent 디렉터리가 없으면 `mkdir -p`.
|
||||
//! - 같은 parent 안에 sibling tempfile 작성 후 atomic rename으로
|
||||
//! 교체. 인터프리터/호스트가 write 도중 죽어도 target 은 *prior bytes*
|
||||
//! 또는 *complete new bytes* 둘 중 하나만 노출.
|
||||
//! - 실패 시 sibling tempfile best-effort 정리 (`.NAME.XXXXXX.part`
|
||||
//! debris 방지).
|
||||
//!
|
||||
//! H1 first-PR scope (PR 14.5)는 같은 로직을 Python `tempfile.mkstemp +
|
||||
//! Path.replace` 로 구현. PR 14.5b 는 Rust 측에 같은 함수를 둠으로써:
|
||||
//! - PR 14.5c (full Rust transaction — broker request invocation 까지)
|
||||
//! 가 같은 atomic-write 헬퍼를 호출 가능.
|
||||
//! - 다른 Rust ABI (예: 미러 캐시 BFS 후 placeholder write)도 재사용.
|
||||
//!
|
||||
//! 본 PR (14.5b)는 *Rust 모듈 + 단위 테스트*만. Python 호출자 변경은
|
||||
//! 파장이 작으므로 (PR 14.5에서 이미 atomic write 사용) PR 14.5c 에 묶음.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// Write `body` to `target` atomically. Returns the number of bytes
|
||||
/// written on success (matches `body.len()`).
|
||||
///
|
||||
/// Tempfile naming: `.<basename>.atomic-XXXX.part` where XXXX is the
|
||||
/// nanosecond timestamp of the call (good-enough uniqueness for the
|
||||
/// in-process workspace cache; a cosmic-ray collision still results in a
|
||||
/// `rename(2)` that overwrites a half-written sibling — same target file
|
||||
/// invariant either way).
|
||||
pub fn atomic_write_bytes(target: &Path, body: &[u8]) -> io::Result<usize> {
|
||||
let parent = match target.parent() {
|
||||
Some(p) if !p.as_os_str().is_empty() => p,
|
||||
_ => Path::new("."),
|
||||
};
|
||||
fs::create_dir_all(parent)?;
|
||||
|
||||
let basename = target
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "atomic".to_string());
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let tmp_path = parent.join(format!(".{basename}.atomic-{stamp}.part"));
|
||||
|
||||
let mut file = fs::File::create(&tmp_path)?;
|
||||
let bytes_written = match file.write_all(body) {
|
||||
Ok(()) => body.len(),
|
||||
Err(e) => {
|
||||
// best-effort cleanup; same parent so unlink can't fail for
|
||||
// cross-fs reasons.
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Drop the file handle before rename so Windows ``MoveFileEx`` can
|
||||
// proceed without a sharing violation.
|
||||
drop(file);
|
||||
if let Err(e) = fs::rename(&tmp_path, target) {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(bytes_written)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
#[test]
|
||||
fn writes_full_body_to_existing_directory() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let target = temp.path().join("a.txt");
|
||||
let n = atomic_write_bytes(&target, b"hello world\n")?;
|
||||
assert_eq!(n, 12);
|
||||
assert_eq!(fs::read(&target)?, b"hello world\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_parent_directories() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let target = temp.path().join("nested/deep/file.txt");
|
||||
atomic_write_bytes(&target, b"x")?;
|
||||
assert!(target.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrites_existing_target() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let target = temp.path().join("a.txt");
|
||||
fs::write(&target, b"old content")?;
|
||||
atomic_write_bytes(&target, b"new")?;
|
||||
assert_eq!(fs::read(&target)?, b"new");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_leave_tempfile_after_success() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let target = temp.path().join("a.txt");
|
||||
atomic_write_bytes(&target, b"x")?;
|
||||
let leftovers: Vec<_> = fs::read_dir(temp.path())?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().contains(".atomic-"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "stale tempfiles: {:?}", leftovers);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_writes_zero_byte_file() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let target = temp.path().join("a.txt");
|
||||
let n = atomic_write_bytes(&target, b"")?;
|
||||
assert_eq!(n, 0);
|
||||
assert_eq!(fs::metadata(&target)?.len(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_body_round_trips_intact() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let target = temp.path().join("a.bin");
|
||||
let body: Vec<u8> = (0u8..=255).collect();
|
||||
atomic_write_bytes(&target, &body)?;
|
||||
assert_eq!(fs::read(&target)?, body);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
884
rust/crates/sessions_native/src/broker_tests.rs
Normal file
884
rust/crates/sessions_native/src/broker_tests.rs
Normal file
@@ -0,0 +1,884 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_broker_tracks_no_hosts() {
|
||||
let broker = Broker::new();
|
||||
assert!(broker.tracked_hosts().is_empty());
|
||||
assert!(!broker.is_active("prod"));
|
||||
}
|
||||
|
||||
// ---------- HandshakeState state machine ----------
|
||||
//
|
||||
// These tests exercise `poll_handshake_ready` against an in-memory
|
||||
// `Cursor`, no real subprocess required. They guard the parsing
|
||||
// contract independently from the threading orchestration in
|
||||
// `Broker::drive_handshake`.
|
||||
|
||||
#[test]
|
||||
fn handshake_state_completes_on_first_line() {
|
||||
let payload = b"{\"bridge\":\"ok\",\"rev\":\"abc\"}\n";
|
||||
let reader = std::io::Cursor::new(payload);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut state = HandshakeState::new(reader, deadline);
|
||||
let outcome = state.poll_handshake_ready();
|
||||
assert!(
|
||||
matches!(&outcome, HandshakeOutcome::Opened { trimmed } if
|
||||
trimmed.contains("\"bridge\":\"ok\"") && !trimmed.ends_with('\n')),
|
||||
"expected Opened with trimmed body, got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_state_emits_timeout_when_deadline_already_elapsed() {
|
||||
// Empty cursor would normally produce Eof on read_line, but with
|
||||
// an already-elapsed deadline the state machine must short-circuit
|
||||
// to Timeout before attempting the read.
|
||||
let reader = std::io::Cursor::new(Vec::<u8>::new());
|
||||
let deadline = Instant::now() - Duration::from_millis(1);
|
||||
let mut state = HandshakeState::new(reader, deadline);
|
||||
assert!(matches!(
|
||||
state.poll_handshake_ready(),
|
||||
HandshakeOutcome::Timeout
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_state_classifies_eof_when_child_closed_stdout() {
|
||||
let reader = std::io::Cursor::new(Vec::<u8>::new());
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut state = HandshakeState::new(reader, deadline);
|
||||
assert!(matches!(
|
||||
state.poll_handshake_ready(),
|
||||
HandshakeOutcome::Eof
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_state_classifies_non_json_as_invalid_json() {
|
||||
let reader = std::io::Cursor::new(b"not json\n".to_vec());
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut state = HandshakeState::new(reader, deadline);
|
||||
let outcome = state.poll_handshake_ready();
|
||||
assert!(
|
||||
matches!(&outcome, HandshakeOutcome::InvalidJson { raw, error }
|
||||
if raw == "not json" && !error.is_empty()),
|
||||
"expected InvalidJson(raw=not json), got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_state_classifies_blank_line_as_invalid_json() {
|
||||
// Just a newline → trimmed empty → "empty handshake line" error,
|
||||
// raw retains the original (newline-terminated) line.
|
||||
let reader = std::io::Cursor::new(b"\n".to_vec());
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut state = HandshakeState::new(reader, deadline);
|
||||
let outcome = state.poll_handshake_ready();
|
||||
assert!(
|
||||
matches!(&outcome, HandshakeOutcome::InvalidJson { raw, error }
|
||||
if raw == "\n" && error == "empty handshake line"),
|
||||
"expected InvalidJson(raw=\\n,error=empty handshake line), got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_session_is_not_active() {
|
||||
let broker = Broker::new();
|
||||
broker.insert_placeholder("prod");
|
||||
assert_eq!(broker.tracked_hosts(), vec!["prod".to_string()]);
|
||||
// Default lifecycle is Terminated, not Active.
|
||||
assert!(!broker.is_active("prod"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_broker_is_singleton() {
|
||||
let a = global_broker() as *const _;
|
||||
let b = global_broker() as *const _;
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
// Guard against someone accidentally bumping STDERR_TAIL_CAPACITY to a
|
||||
// value that could OOM on verbose bridge output. Const assertions fire
|
||||
// at compile time rather than at test time.
|
||||
const _: () = assert!(STDERR_TAIL_CAPACITY >= 10);
|
||||
const _: () = assert!(STDERR_TAIL_CAPACITY <= 1000);
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sh(script: &str) -> Command {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg("-c").arg(script);
|
||||
cmd.stdin(Stdio::piped());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
cmd
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn open_session_reads_handshake_and_marks_active() {
|
||||
let broker = Broker::new();
|
||||
let cmd = sh(r#"printf '{"bridge":"ok","rev":"abc"}\n'; exec sleep 5"#);
|
||||
let outcome = broker.open_session_with_command("host-a", cmd, Duration::from_secs(3));
|
||||
assert!(
|
||||
matches!(&outcome, OpenOutcome::Opened { .. }),
|
||||
"expected Opened, got {:?}",
|
||||
outcome
|
||||
);
|
||||
if let OpenOutcome::Opened { handshake_json } = &outcome {
|
||||
assert!(handshake_json.contains("\"bridge\":\"ok\""));
|
||||
assert!(!handshake_json.ends_with('\n'));
|
||||
}
|
||||
assert!(broker.is_active("host-a"));
|
||||
let stored = broker.handshake_json("host-a");
|
||||
assert!(stored.is_some(), "handshake cache missing");
|
||||
assert!(
|
||||
stored
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("\"bridge\":\"ok\"")
|
||||
);
|
||||
// Broker Drop kills the child.
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn open_session_is_idempotent_while_alive() {
|
||||
let broker = Broker::new();
|
||||
let cmd = sh(r#"printf '{"bridge":"ok","seq":1}\n'; exec sleep 5"#);
|
||||
let first = broker.open_session_with_command("host-b", cmd, Duration::from_secs(3));
|
||||
assert!(matches!(first, OpenOutcome::Opened { .. }));
|
||||
|
||||
let cmd2 = sh(r#"printf '{"bridge":"ok","seq":2}\n'; exec sleep 5"#);
|
||||
let second = broker.open_session_with_command("host-b", cmd2, Duration::from_secs(3));
|
||||
// We keep the existing active session; the second spawn request is
|
||||
// issued via open_session_with_command which does NOT consult the
|
||||
// reuse branch (that lives on the public open_session). Still, the
|
||||
// first spawn's handshake must stay cached because remove_and_kill
|
||||
// wasn't called.
|
||||
//
|
||||
// Note: open_session_with_command always replaces, so we can't test
|
||||
// reuse here directly. Instead, drive reuse via the public API's
|
||||
// is_active + handshake_json check.
|
||||
drop(second); // cleanup any fresh spawn
|
||||
assert!(broker.tracked_hosts().contains(&"host-b".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn public_open_session_reuses_live_session() {
|
||||
// We can't easily point the public API at /bin/sh, but we can verify
|
||||
// the reuse branch by pre-populating state the way a successful open
|
||||
// would: active session + cached handshake + live child.
|
||||
let broker = Broker::new();
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; exec sleep 5"#);
|
||||
let first = broker.open_session_with_command("host-c", cmd, Duration::from_secs(3));
|
||||
assert!(matches!(first, OpenOutcome::Opened { .. }));
|
||||
assert!(broker.is_active("host-c"));
|
||||
|
||||
// Public open_session with a deliberately-broken path — reuse branch
|
||||
// must short-circuit before Command::spawn is attempted.
|
||||
let cfg = OpenSessionConfig {
|
||||
host_alias: "host-c".into(),
|
||||
bridge_path: "/definitely/nonexistent/bridge".into(),
|
||||
helper_revision: "rev".into(),
|
||||
extra_env: vec![],
|
||||
handshake_timeout: Duration::from_secs(1),
|
||||
};
|
||||
let reuse = broker.open_session(cfg);
|
||||
assert!(
|
||||
matches!(&reuse, OpenOutcome::Reused { .. }),
|
||||
"expected Reused, got {:?}",
|
||||
reuse
|
||||
);
|
||||
if let OpenOutcome::Reused { handshake_json } = &reuse {
|
||||
assert!(handshake_json.contains("\"bridge\":\"ok\""));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_session_reports_spawn_failure_for_missing_binary() {
|
||||
let broker = Broker::new();
|
||||
let cfg = OpenSessionConfig {
|
||||
host_alias: "nohost".into(),
|
||||
bridge_path: "/definitely/nonexistent/bridge-xyz".into(),
|
||||
helper_revision: "rev".into(),
|
||||
extra_env: vec![],
|
||||
handshake_timeout: Duration::from_millis(500),
|
||||
};
|
||||
let outcome = broker.open_session(cfg);
|
||||
assert!(
|
||||
matches!(&outcome, OpenOutcome::SpawnFailed(_)),
|
||||
"expected SpawnFailed, got {:?}",
|
||||
outcome
|
||||
);
|
||||
assert!(!broker.is_active("nohost"));
|
||||
assert!(!broker.tracked_hosts().contains(&"nohost".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn open_session_detects_process_death_before_handshake() {
|
||||
let broker = Broker::new();
|
||||
// Writes to stderr then exits 3 without ever writing to stdout.
|
||||
let cmd = sh(r#"printf 'boom\n' >&2; exit 3"#);
|
||||
let outcome = broker.open_session_with_command("host-d", cmd, Duration::from_secs(2));
|
||||
assert!(
|
||||
matches!(&outcome, OpenOutcome::ProcessDiedDuringHandshake { .. }),
|
||||
"expected ProcessDiedDuringHandshake, got {:?}",
|
||||
outcome
|
||||
);
|
||||
if let OpenOutcome::ProcessDiedDuringHandshake {
|
||||
exit_code,
|
||||
stderr_tail,
|
||||
} = &outcome
|
||||
{
|
||||
// exit_code may race between None (unreaped yet) and Some(3).
|
||||
assert!(exit_code.is_none() || *exit_code == Some(3));
|
||||
// Best-effort: stderr may or may not have been drained yet;
|
||||
// allow empty but if populated must contain "boom".
|
||||
if !stderr_tail.is_empty() {
|
||||
assert!(stderr_tail.contains("boom"));
|
||||
}
|
||||
}
|
||||
assert!(!broker.is_active("host-d"));
|
||||
assert!(!broker.tracked_hosts().contains(&"host-d".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn open_session_times_out_when_child_is_silent() {
|
||||
let broker = Broker::new();
|
||||
let cmd = sh("exec sleep 5");
|
||||
let outcome = broker.open_session_with_command("host-e", cmd, Duration::from_millis(400));
|
||||
assert!(
|
||||
matches!(&outcome, OpenOutcome::HandshakeTimeout { .. }),
|
||||
"expected HandshakeTimeout, got {:?}",
|
||||
outcome
|
||||
);
|
||||
assert!(!broker.is_active("host-e"));
|
||||
assert!(!broker.tracked_hosts().contains(&"host-e".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn open_session_rejects_non_json_handshake_line() {
|
||||
let broker = Broker::new();
|
||||
let cmd = sh(r#"printf 'not json\n'; exec sleep 5"#);
|
||||
let outcome = broker.open_session_with_command("host-f", cmd, Duration::from_secs(2));
|
||||
assert!(
|
||||
matches!(&outcome, OpenOutcome::HandshakeInvalidJson { .. }),
|
||||
"expected HandshakeInvalidJson, got {:?}",
|
||||
outcome
|
||||
);
|
||||
if let OpenOutcome::HandshakeInvalidJson { raw, .. } = &outcome {
|
||||
assert_eq!(raw, "not json");
|
||||
}
|
||||
assert!(!broker.is_active("host-f"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn stderr_tail_snapshot_trims_to_last_10_lines() {
|
||||
let session = Session::new("h".into());
|
||||
if let Ok(mut tail) = session.stderr_tail.lock() {
|
||||
for i in 0..15 {
|
||||
tail.push_back(format!("line-{}", i));
|
||||
}
|
||||
}
|
||||
let snap = session.stderr_tail_snapshot(10_000);
|
||||
// Last 10 lines: line-5 .. line-14
|
||||
assert!(snap.starts_with("line-5"));
|
||||
assert!(snap.ends_with("line-14"));
|
||||
assert_eq!(snap.matches('\n').count(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_tail_snapshot_truncates_by_byte_cap() {
|
||||
let session = Session::new("h".into());
|
||||
if let Ok(mut tail) = session.stderr_tail.lock() {
|
||||
tail.push_back("a".repeat(500));
|
||||
tail.push_back("b".repeat(500));
|
||||
}
|
||||
let snap = session.stderr_tail_snapshot(300);
|
||||
assert_eq!(snap.len(), 300);
|
||||
// Truncated from the front, so the tail ('b'...) must still be there.
|
||||
assert!(snap.ends_with(&"b".repeat(300)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_slot_complete_is_idempotent_and_first_wins() {
|
||||
let slot = PendingSlot::new();
|
||||
slot.complete("first".to_string());
|
||||
slot.complete("second".to_string());
|
||||
slot.fail("ignored".to_string());
|
||||
let out = slot.wait_with_timeout(Duration::from_millis(50));
|
||||
assert!(
|
||||
matches!(&out, PendingWaitOutcome::Completed(s) if s == "first"),
|
||||
"got {:?}",
|
||||
out
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_slot_fail_is_idempotent_and_first_wins() {
|
||||
let slot = PendingSlot::new();
|
||||
slot.fail("first-err".to_string());
|
||||
slot.fail("second-err".to_string());
|
||||
slot.complete("ignored".to_string());
|
||||
let out = slot.wait_with_timeout(Duration::from_millis(50));
|
||||
assert!(
|
||||
matches!(&out, PendingWaitOutcome::Failed(s) if s == "first-err"),
|
||||
"got {:?}",
|
||||
out
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_slot_wait_times_out_without_completion() {
|
||||
let slot = PendingSlot::new();
|
||||
let out = slot.wait_with_timeout(Duration::from_millis(50));
|
||||
assert!(matches!(out, PendingWaitOutcome::Timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_slot_wait_unblocks_after_async_complete() {
|
||||
let slot = PendingSlot::new();
|
||||
let slot_clone = Arc::clone(&slot);
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
slot_clone.complete("done".into());
|
||||
});
|
||||
let out = slot.wait_with_timeout(Duration::from_secs(2));
|
||||
assert!(
|
||||
matches!(&out, PendingWaitOutcome::Completed(s) if s == "done"),
|
||||
"got {:?}",
|
||||
out
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_response_line_completes_matching_pending() {
|
||||
let session = Arc::new(Session::new("h".into()));
|
||||
let slot = PendingSlot::new();
|
||||
if let Ok(mut pending) = session.pending.lock() {
|
||||
pending.insert("req-1".to_string(), Arc::clone(&slot));
|
||||
}
|
||||
dispatch_response_line(&session, r#"{"id":"req-1","ok":true}"#);
|
||||
let out = slot.wait_with_timeout(Duration::from_millis(50));
|
||||
assert!(
|
||||
matches!(&out, PendingWaitOutcome::Completed(s) if s.contains("\"id\":\"req-1\"")),
|
||||
"got {:?}",
|
||||
out
|
||||
);
|
||||
// Pending map should have been drained for this id.
|
||||
if let Ok(pending) = session.pending.lock() {
|
||||
assert!(!pending.contains_key("req-1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_response_line_ignores_unmatched_ids() {
|
||||
let session = Arc::new(Session::new("h".into()));
|
||||
let slot = PendingSlot::new();
|
||||
if let Ok(mut pending) = session.pending.lock() {
|
||||
pending.insert("req-1".to_string(), Arc::clone(&slot));
|
||||
}
|
||||
// Line with a different id — slot must stay Waiting.
|
||||
dispatch_response_line(&session, r#"{"id":"other","ok":true}"#);
|
||||
let out = slot.wait_with_timeout(Duration::from_millis(50));
|
||||
assert!(matches!(out, PendingWaitOutcome::Timeout));
|
||||
if let Ok(pending) = session.pending.lock() {
|
||||
assert!(pending.contains_key("req-1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_response_line_ignores_non_json() {
|
||||
let session = Arc::new(Session::new("h".into()));
|
||||
let slot = PendingSlot::new();
|
||||
if let Ok(mut pending) = session.pending.lock() {
|
||||
pending.insert("req-1".to_string(), Arc::clone(&slot));
|
||||
}
|
||||
dispatch_response_line(&session, "not json");
|
||||
dispatch_response_line(&session, r#"{"no":"id"}"#);
|
||||
let out = slot.wait_with_timeout(Duration::from_millis(50));
|
||||
assert!(matches!(out, PendingWaitOutcome::Timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanout_eof_fails_every_pending_and_terminates() {
|
||||
let session = Arc::new(Session::new("h".into()));
|
||||
if let Ok(mut lifecycle) = session.lifecycle.lock() {
|
||||
*lifecycle = SessionLifecycle::Active;
|
||||
}
|
||||
let slot_a = PendingSlot::new();
|
||||
let slot_b = PendingSlot::new();
|
||||
if let Ok(mut pending) = session.pending.lock() {
|
||||
pending.insert("a".into(), Arc::clone(&slot_a));
|
||||
pending.insert("b".into(), Arc::clone(&slot_b));
|
||||
}
|
||||
fanout_eof_to_pending(&session);
|
||||
for slot in [slot_a, slot_b] {
|
||||
let out = slot.wait_with_timeout(Duration::from_millis(50));
|
||||
assert!(
|
||||
matches!(&out, PendingWaitOutcome::Failed(s) if s.contains("bridge stdout ended")),
|
||||
"got {:?}",
|
||||
out
|
||||
);
|
||||
}
|
||||
if let Ok(lifecycle) = session.lifecycle.lock() {
|
||||
assert_eq!(*lifecycle, SessionLifecycle::Terminated);
|
||||
}
|
||||
if let Ok(pending) = session.pending.lock() {
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_pending_returns_none_for_unknown_host() {
|
||||
let broker = Broker::new();
|
||||
let slot = broker.submit_pending("nope", "x");
|
||||
assert!(slot.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_pending_removes_registration() {
|
||||
let broker = Broker::new();
|
||||
broker.insert_placeholder("h1");
|
||||
let slot = broker.submit_pending("h1", "req-42");
|
||||
assert!(slot.is_some());
|
||||
broker.cancel_pending("h1", "req-42");
|
||||
// Dispatching a response with the cancelled id is a no-op.
|
||||
if let Ok(guard) = broker.sessions.lock()
|
||||
&& let Some(session) = guard.get("h1")
|
||||
{
|
||||
dispatch_response_line(session, r#"{"id":"req-42","ok":true}"#);
|
||||
if let Ok(pending) = session.pending.lock() {
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
}
|
||||
// Original slot stays Waiting since no one completed it.
|
||||
if let Some(slot) = slot {
|
||||
let out = slot.wait_with_timeout(Duration::from_millis(50));
|
||||
assert!(matches!(out, PendingWaitOutcome::Timeout));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn response_reader_matches_live_subprocess_by_id() {
|
||||
let broker = Broker::new();
|
||||
// fake bridge: emit handshake immediately, wait 300ms, then
|
||||
// emit a response for id=req-1; stay alive so the session
|
||||
// remains active.
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; sleep 0.3; \
|
||||
printf '{"id":"req-1","ok":true,"result":{"n":7}}\n'; exec sleep 5"#);
|
||||
let outcome = broker.open_session_with_command("host-r1", cmd, Duration::from_secs(2));
|
||||
assert!(
|
||||
matches!(&outcome, OpenOutcome::Opened { .. }),
|
||||
"handshake failed: {:?}",
|
||||
outcome
|
||||
);
|
||||
|
||||
// Register pending BEFORE the response arrives.
|
||||
let slot_opt = broker.submit_pending("host-r1", "req-1");
|
||||
assert!(slot_opt.is_some(), "submit_pending returned None");
|
||||
let slot = slot_opt.unwrap_or_else(PendingSlot::new);
|
||||
let wait = slot.wait_with_timeout(Duration::from_secs(2));
|
||||
assert!(
|
||||
matches!(&wait, PendingWaitOutcome::Completed(s) if s.contains("\"id\":\"req-1\"")),
|
||||
"wait outcome: {:?}",
|
||||
wait
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_returns_session_missing_for_unknown_host() {
|
||||
let broker = Broker::new();
|
||||
let outcome = broker.request(
|
||||
"nope",
|
||||
"req-1",
|
||||
r#"{"id":"req-1"}"#,
|
||||
Duration::from_millis(200),
|
||||
);
|
||||
assert!(
|
||||
matches!(&outcome, RequestOutcome::SessionMissing),
|
||||
"got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_returns_broken_pipe_for_inactive_session() {
|
||||
let broker = Broker::new();
|
||||
broker.insert_placeholder("h-inactive");
|
||||
// Placeholder session has lifecycle=Terminated.
|
||||
let outcome = broker.request(
|
||||
"h-inactive",
|
||||
"req-1",
|
||||
r#"{"id":"req-1"}"#,
|
||||
Duration::from_millis(200),
|
||||
);
|
||||
assert!(
|
||||
matches!(&outcome, RequestOutcome::BrokenPipe(_)),
|
||||
"got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn request_echoes_response_from_fake_bridge() {
|
||||
let broker = Broker::new();
|
||||
// Fake bridge: handshake, then read one line and echo a fixed
|
||||
// response envelope (id="req-1"), then sleep.
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; \
|
||||
read -r _line; \
|
||||
printf '{"id":"req-1","ok":true,"result":{"echo":true}}\n'; \
|
||||
exec sleep 5"#);
|
||||
let opened = broker.open_session_with_command("host-q1", cmd, Duration::from_secs(2));
|
||||
assert!(matches!(&opened, OpenOutcome::Opened { .. }));
|
||||
|
||||
let outcome = broker.request(
|
||||
"host-q1",
|
||||
"req-1",
|
||||
r#"{"id":"req-1","method":"ping"}"#,
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
assert!(
|
||||
matches!(&outcome, RequestOutcome::Response(s) if s.contains("\"echo\":true")),
|
||||
"got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn request_times_out_and_removes_pending_entry() {
|
||||
let broker = Broker::new();
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; exec sleep 5"#);
|
||||
let opened = broker.open_session_with_command("host-q2", cmd, Duration::from_secs(2));
|
||||
assert!(matches!(&opened, OpenOutcome::Opened { .. }));
|
||||
|
||||
let outcome = broker.request(
|
||||
"host-q2",
|
||||
"req-1",
|
||||
r#"{"id":"req-1"}"#,
|
||||
Duration::from_millis(200),
|
||||
);
|
||||
assert!(
|
||||
matches!(&outcome, RequestOutcome::Timeout),
|
||||
"got {:?}",
|
||||
outcome
|
||||
);
|
||||
// Pending map must be drained on timeout so a late response
|
||||
// cannot keep the slot alive.
|
||||
if let Ok(guard) = broker.sessions.lock()
|
||||
&& let Some(session) = guard.get("host-q2")
|
||||
&& let Ok(pending) = session.pending.lock()
|
||||
{
|
||||
assert!(!pending.contains_key("req-1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn request_reports_broken_pipe_when_reader_fanout_hits_first() {
|
||||
let broker = Broker::new();
|
||||
// Bridge emits handshake then exits immediately; reader thread
|
||||
// will observe EOF and fanout-fail any pending slot with the
|
||||
// canned error message.
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; exit 0"#);
|
||||
let opened = broker.open_session_with_command("host-q3", cmd, Duration::from_secs(2));
|
||||
assert!(matches!(&opened, OpenOutcome::Opened { .. }));
|
||||
|
||||
// Give the reader thread a moment to start; the EOF race is
|
||||
// fine either way (pre-check path or fanout path) — both end
|
||||
// up BrokenPipe.
|
||||
let outcome = broker.request(
|
||||
"host-q3",
|
||||
"req-1",
|
||||
r#"{"id":"req-1"}"#,
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
assert!(
|
||||
matches!(&outcome, RequestOutcome::BrokenPipe(_)),
|
||||
"got {:?}",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_tail_returns_empty_for_unknown_host() {
|
||||
let broker = Broker::new();
|
||||
assert_eq!(broker.stderr_tail("never-opened", 800), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_tail_returns_session_snapshot() {
|
||||
let broker = Broker::new();
|
||||
broker.insert_placeholder("h-st");
|
||||
if let Ok(guard) = broker.sessions.lock()
|
||||
&& let Some(session) = guard.get("h-st")
|
||||
&& let Ok(mut tail) = session.stderr_tail.lock()
|
||||
{
|
||||
tail.push_back("warn: slow remote".into());
|
||||
tail.push_back("err: exit 127".into());
|
||||
}
|
||||
let snap = broker.stderr_tail("h-st", 800);
|
||||
assert!(snap.contains("warn: slow remote"));
|
||||
assert!(snap.contains("err: exit 127"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_json_returns_none_for_unknown_host() {
|
||||
let broker = Broker::new();
|
||||
assert!(broker.handshake_json("never-opened").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_false_for_unknown_host() {
|
||||
let broker = Broker::new();
|
||||
assert!(!broker.reset("never-opened"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn reset_removes_session_and_fails_pending() {
|
||||
let broker = Broker::new();
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; exec sleep 5"#);
|
||||
let opened = broker.open_session_with_command("host-x1", cmd, Duration::from_secs(2));
|
||||
assert!(matches!(&opened, OpenOutcome::Opened { .. }));
|
||||
|
||||
let slot_opt = broker.submit_pending("host-x1", "req-1");
|
||||
assert!(slot_opt.is_some());
|
||||
let slot = slot_opt.unwrap_or_else(PendingSlot::new);
|
||||
|
||||
assert!(broker.reset("host-x1"));
|
||||
assert!(!broker.is_active("host-x1"));
|
||||
assert!(!broker.tracked_hosts().contains(&"host-x1".to_string()));
|
||||
|
||||
let wait = slot.wait_with_timeout(Duration::from_millis(200));
|
||||
assert!(
|
||||
matches!(&wait, PendingWaitOutcome::Failed(s) if s.contains("bridge stdout ended")),
|
||||
"got {:?}",
|
||||
wait
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn reset_is_idempotent() {
|
||||
let broker = Broker::new();
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; exec sleep 5"#);
|
||||
let opened = broker.open_session_with_command("host-x2", cmd, Duration::from_secs(2));
|
||||
assert!(matches!(&opened, OpenOutcome::Opened { .. }));
|
||||
|
||||
assert!(broker.reset("host-x2"));
|
||||
assert!(!broker.reset("host-x2"));
|
||||
assert!(!broker.reset("host-x2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn shutdown_all_resets_every_tracked_host() {
|
||||
let broker = Broker::new();
|
||||
for host in ["ha", "hb", "hc"] {
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; exec sleep 5"#);
|
||||
let outcome = broker.open_session_with_command(host, cmd, Duration::from_secs(2));
|
||||
assert!(matches!(&outcome, OpenOutcome::Opened { .. }));
|
||||
}
|
||||
assert_eq!(broker.tracked_hosts().len(), 3);
|
||||
assert_eq!(broker.shutdown_all(), 3);
|
||||
assert!(broker.tracked_hosts().is_empty());
|
||||
// Second call is a no-op since nothing is tracked.
|
||||
assert_eq!(broker.shutdown_all(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn shutdown_all_fails_pending_across_hosts() {
|
||||
let broker = Broker::new();
|
||||
let mut slots: Vec<Arc<PendingSlot>> = Vec::new();
|
||||
for host in ["ha", "hb"] {
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; exec sleep 5"#);
|
||||
let outcome = broker.open_session_with_command(host, cmd, Duration::from_secs(2));
|
||||
assert!(matches!(&outcome, OpenOutcome::Opened { .. }));
|
||||
let slot_opt = broker.submit_pending(host, "req-1");
|
||||
assert!(slot_opt.is_some());
|
||||
slots.push(slot_opt.unwrap_or_else(PendingSlot::new));
|
||||
}
|
||||
assert_eq!(broker.shutdown_all(), 2);
|
||||
for slot in slots {
|
||||
let wait = slot.wait_with_timeout(Duration::from_millis(200));
|
||||
assert!(
|
||||
matches!(&wait, PendingWaitOutcome::Failed(_)),
|
||||
"got {:?}",
|
||||
wait
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn response_reader_fans_out_eof_when_child_exits() {
|
||||
let broker = Broker::new();
|
||||
// fake bridge: emit handshake, sleep briefly, then exit.
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; sleep 0.1; exit 0"#);
|
||||
let outcome = broker.open_session_with_command("host-r2", cmd, Duration::from_secs(2));
|
||||
assert!(matches!(&outcome, OpenOutcome::Opened { .. }));
|
||||
|
||||
// Submit pending, then wait past the child's exit so the reader
|
||||
// thread observes EOF and drains us with a synthetic error.
|
||||
let slot_opt = broker.submit_pending("host-r2", "req-1");
|
||||
assert!(slot_opt.is_some(), "submit_pending returned None");
|
||||
let slot = slot_opt.unwrap_or_else(PendingSlot::new);
|
||||
let wait = slot.wait_with_timeout(Duration::from_secs(2));
|
||||
assert!(
|
||||
matches!(&wait, PendingWaitOutcome::Failed(s) if s.contains("bridge stdout ended")),
|
||||
"wait outcome: {:?}",
|
||||
wait
|
||||
);
|
||||
// Lifecycle should be Terminated post-fanout.
|
||||
if let Ok(guard) = broker.sessions.lock()
|
||||
&& let Some(session) = guard.get("host-r2")
|
||||
&& let Ok(lifecycle) = session.lifecycle.lock()
|
||||
{
|
||||
assert_eq!(*lifecycle, SessionLifecycle::Terminated);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- adversarial / large-payload / concurrency ----------
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn request_handles_response_larger_than_4kb_buffer() {
|
||||
// Emits a response line with a ~16KiB payload inside the result
|
||||
// body. The Python ctypes caller's default buffer is 4KiB; the
|
||||
// grow-retry loop must kick in and round-trip the full blob.
|
||||
let broker = Broker::new();
|
||||
let big_body: String = "A".repeat(16 * 1024);
|
||||
let cmd = sh(&format!(
|
||||
r#"printf '{{"bridge":"ok"}}\n'; \
|
||||
read -r _line; \
|
||||
printf '{{"id":"req-big","ok":true,"result":{{"blob":"{big}"}}}}\n'; \
|
||||
exec sleep 5"#,
|
||||
big = big_body,
|
||||
));
|
||||
let opened = broker.open_session_with_command("host-big", cmd, Duration::from_secs(3));
|
||||
assert!(matches!(&opened, OpenOutcome::Opened { .. }));
|
||||
|
||||
let outcome = broker.request(
|
||||
"host-big",
|
||||
"req-big",
|
||||
r#"{"id":"req-big","method":"ping"}"#,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert!(
|
||||
matches!(&outcome, RequestOutcome::Response(_)),
|
||||
"expected Response, got {:?}",
|
||||
outcome
|
||||
);
|
||||
if let RequestOutcome::Response(line) = outcome {
|
||||
// serde_json::Value has a Default impl (Null); unwrap_or_default
|
||||
// keeps us off the denied .unwrap()/.expect() list while still
|
||||
// letting the size assertion catch a truncated round-trip.
|
||||
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
|
||||
let blob = parsed
|
||||
.pointer("/result/blob")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
assert_eq!(blob.len(), 16 * 1024, "truncated large response");
|
||||
assert!(blob.chars().all(|c| c == 'A'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn request_serializes_concurrent_writes_under_contention() {
|
||||
// Eight client threads fire distinct-id requests at the same
|
||||
// session in parallel. The fake bridge echoes a response per
|
||||
// incoming line. Every caller must receive its own matching
|
||||
// response — no id crossover, no lost writes, no deadlock.
|
||||
//
|
||||
// This exercises:
|
||||
// * Session.stdin Mutex serializing write_payload calls
|
||||
// * dispatch_response_line picking the right pending slot
|
||||
// * PendingSlot Condvar waking only the requested waiter
|
||||
let broker = Arc::new(Broker::new());
|
||||
// The shell loop reads lines forever; for each line, it emits
|
||||
// a response whose id matches. Using awk to extract "id":"X" is
|
||||
// simpler than JSON-parsing in pure sh.
|
||||
let cmd = sh(r#"printf '{"bridge":"ok"}\n'; \
|
||||
while IFS= read -r line; do
|
||||
id=$(printf '%s' "$line" | sed 's/.*"id":"\([^"]*\)".*/\1/')
|
||||
printf '{"id":"%s","ok":true,"result":{"echo":"%s"}}\n' "$id" "$id"
|
||||
done"#);
|
||||
let opened = broker.open_session_with_command("host-conc", cmd, Duration::from_secs(3));
|
||||
assert!(matches!(&opened, OpenOutcome::Opened { .. }));
|
||||
|
||||
let mut handles = Vec::with_capacity(8);
|
||||
for i in 0..8u32 {
|
||||
let b = Arc::clone(&broker);
|
||||
handles.push(thread::spawn(move || {
|
||||
let id = format!("req-{}", i);
|
||||
let payload = format!(r#"{{"id":"{}","method":"ping"}}"#, id);
|
||||
let outcome = b.request("host-conc", &id, &payload, Duration::from_secs(5));
|
||||
(id, outcome)
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
let joined = h.join();
|
||||
assert!(joined.is_ok(), "request thread panicked");
|
||||
let (id, outcome) = joined.unwrap_or_else(|_| (String::new(), RequestOutcome::Timeout));
|
||||
assert!(
|
||||
matches!(&outcome, RequestOutcome::Response(_)),
|
||||
"expected Response for {id}, got {:?}",
|
||||
outcome
|
||||
);
|
||||
if let RequestOutcome::Response(line) = outcome {
|
||||
assert!(
|
||||
line.contains(&format!("\"id\":\"{}\"", id)),
|
||||
"response for {id} did not carry its own id: {line}",
|
||||
);
|
||||
assert!(
|
||||
line.contains(&format!("\"echo\":\"{}\"", id)),
|
||||
"response for {id} missing matching echo: {line}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_slot_wakes_only_the_waiter_it_was_completed_on() {
|
||||
// Two threads each wait on a different PendingSlot. Completing
|
||||
// slot_a must not wake the thread waiting on slot_b.
|
||||
let slot_a = PendingSlot::new();
|
||||
let slot_b = PendingSlot::new();
|
||||
|
||||
let a_clone = Arc::clone(&slot_a);
|
||||
let b_clone = Arc::clone(&slot_b);
|
||||
|
||||
let a_handle = thread::spawn(move || a_clone.wait_with_timeout(Duration::from_secs(1)));
|
||||
let b_handle = thread::spawn(move || b_clone.wait_with_timeout(Duration::from_millis(200)));
|
||||
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
slot_a.complete("done-a".into());
|
||||
|
||||
let a_joined = a_handle.join();
|
||||
let b_joined = b_handle.join();
|
||||
assert!(a_joined.is_ok(), "thread a panicked");
|
||||
assert!(b_joined.is_ok(), "thread b panicked");
|
||||
let a_out = a_joined.unwrap_or(PendingWaitOutcome::Timeout);
|
||||
let b_out = b_joined.unwrap_or(PendingWaitOutcome::Timeout);
|
||||
assert!(
|
||||
matches!(&a_out, PendingWaitOutcome::Completed(s) if s == "done-a"),
|
||||
"a_out: {:?}",
|
||||
a_out
|
||||
);
|
||||
// b should still see Timeout since no one completed it.
|
||||
assert!(matches!(b_out, PendingWaitOutcome::Timeout));
|
||||
}
|
||||
367
rust/crates/sessions_native/src/eager_hydrate.rs
Normal file
367
rust/crates/sessions_native/src/eager_hydrate.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
//! Eager-hydrate placeholder discovery (Wave 2 PR 14) + apply pass body
|
||||
//! (Wave 2 PR 17 / PR-B).
|
||||
//!
|
||||
//! Walks a local cache root and yields zero-byte regular files whose basename
|
||||
//! is in an allow-list. Mirrors the Python ``find_placeholder_candidates``
|
||||
//! contract pinned by ``test_eager_hydrate_parity``:
|
||||
//!
|
||||
//! - Symbolic links never followed (Sessions cache has no symlinks; the
|
||||
//! guard is cheap and matches Python's ``Path.is_file`` after stat).
|
||||
//! - ``__extern`` subtree is skipped (external/out-of-workspace cache).
|
||||
//! - Directories that fail to enumerate are silently skipped (partial
|
||||
//! cache → produces what candidates it can).
|
||||
//! - Empty allow-list returns no candidates.
|
||||
//!
|
||||
//! PR-B (apply pass body) extends the Rust ownership: the loop, batch
|
||||
//! pacing, per-placeholder ``file_open`` transaction, and outcome
|
||||
//! collection all run in Rust. Python becomes a thin caller — one FFI
|
||||
//! round-trip per pass, then writes sidecar metadata for hydrated entries.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::file_open;
|
||||
use crate::map_local_to_remote_path;
|
||||
|
||||
/// Return zero-byte regular files under `cache_root` whose basename is in
|
||||
/// `allowed_basenames`. Order is BFS-stable but not lexicographic.
|
||||
///
|
||||
/// Both arguments are passed as owned `String`s to keep the C ABI surface
|
||||
/// tight (see `lib.rs::sessions_eager_hydrate_find_candidates`). When
|
||||
/// `allowed_basenames` is empty an empty Vec is returned without walking the
|
||||
/// tree.
|
||||
pub fn find_placeholder_candidates(
|
||||
cache_root: &Path,
|
||||
allowed_basenames: &[String],
|
||||
) -> Vec<PathBuf> {
|
||||
let allowed: HashSet<&str> = allowed_basenames.iter().map(String::as_str).collect();
|
||||
if allowed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if !cache_root.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let mut stack: Vec<PathBuf> = vec![cache_root.to_path_buf()];
|
||||
|
||||
while let Some(current) = stack.pop() {
|
||||
let entries = match fs::read_dir(¤t) {
|
||||
Ok(it) => it,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if file_type.is_dir() {
|
||||
let name_owned = match path.file_name() {
|
||||
Some(name) => name.to_string_lossy().into_owned(),
|
||||
None => continue,
|
||||
};
|
||||
if name_owned == "__extern" {
|
||||
continue;
|
||||
}
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
// Symlinks / sockets / devices — Sessions cache should never
|
||||
// hold these; mirror Python's ``Path.is_file`` skip.
|
||||
continue;
|
||||
}
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
if !allowed.contains(name) {
|
||||
continue;
|
||||
}
|
||||
// Zero-byte filter — Python does ``stat.st_size != 0`` skip.
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if metadata.len() != 0 {
|
||||
continue;
|
||||
}
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Drive one eager-hydrate apply pass over placeholders under
|
||||
/// ``cache_root``. Returns a JSON object summarising the pass:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "hydrated": [{"local_path": "...", "metadata": {...}}, ...],
|
||||
/// "skipped_existing": N,
|
||||
/// "failed": M
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Re-checks zero-byte before fetch (so a concurrent path filling the
|
||||
/// placeholder lands in ``skipped_existing`` rather than re-fetched),
|
||||
/// counts failures without aborting, and pauses ``batch_sleep_ms``
|
||||
/// between batches.
|
||||
///
|
||||
/// Per-batch, runs up to ``parallelism`` ``file_open`` transactions
|
||||
/// concurrently (the broker session multiplexes by envelope id, so
|
||||
/// concurrent file/read requests are safe). ``parallelism = 1``
|
||||
/// preserves the strictly sequential PR-B behaviour. Setting it
|
||||
/// higher cuts the wall-clock of a 50-placeholder pass roughly
|
||||
/// linearly until per-placeholder latency becomes helper-bound rather
|
||||
/// than round-trip-bound.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run_apply_pass(
|
||||
cache_root: &Path,
|
||||
host_alias: &str,
|
||||
remote_workspace_root: &str,
|
||||
allowed_basenames: &[String],
|
||||
batch_size: usize,
|
||||
batch_sleep_ms: u64,
|
||||
max_open_bytes: u64,
|
||||
binary_probe_bytes: usize,
|
||||
allow_empty: bool,
|
||||
timeout_ms: u64,
|
||||
parallelism: usize,
|
||||
) -> Value {
|
||||
let placeholders = find_placeholder_candidates(cache_root, allowed_basenames);
|
||||
let hydrated: Mutex<Vec<Value>> = Mutex::new(Vec::new());
|
||||
let skipped_existing = AtomicUsize::new(0);
|
||||
let failed = AtomicUsize::new(0);
|
||||
|
||||
let batch_size_safe = if batch_size == 0 { 1 } else { batch_size };
|
||||
let parallelism_safe = parallelism.max(1);
|
||||
|
||||
for (batch_index, batch) in placeholders.chunks(batch_size_safe).enumerate() {
|
||||
if batch_index > 0 && batch_sleep_ms > 0 {
|
||||
thread::sleep(Duration::from_millis(batch_sleep_ms));
|
||||
}
|
||||
let workers = parallelism_safe.min(batch.len()).max(1);
|
||||
if workers <= 1 {
|
||||
// Fast path — avoid scope/Mutex overhead for tiny batches.
|
||||
for path in batch {
|
||||
process_placeholder(
|
||||
path,
|
||||
host_alias,
|
||||
remote_workspace_root,
|
||||
cache_root,
|
||||
max_open_bytes,
|
||||
binary_probe_bytes,
|
||||
allow_empty,
|
||||
timeout_ms,
|
||||
&hydrated,
|
||||
&skipped_existing,
|
||||
&failed,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let work_queue: Mutex<Vec<&PathBuf>> = Mutex::new(batch.iter().collect());
|
||||
thread::scope(|s| {
|
||||
for _ in 0..workers {
|
||||
let work_queue_ref = &work_queue;
|
||||
let hydrated_ref = &hydrated;
|
||||
let skipped_ref = &skipped_existing;
|
||||
let failed_ref = &failed;
|
||||
s.spawn(move || {
|
||||
loop {
|
||||
let next = match work_queue_ref.lock() {
|
||||
Ok(mut q) => q.pop(),
|
||||
Err(_) => break,
|
||||
};
|
||||
let Some(path) = next else { break };
|
||||
process_placeholder(
|
||||
path,
|
||||
host_alias,
|
||||
remote_workspace_root,
|
||||
cache_root,
|
||||
max_open_bytes,
|
||||
binary_probe_bytes,
|
||||
allow_empty,
|
||||
timeout_ms,
|
||||
hydrated_ref,
|
||||
skipped_ref,
|
||||
failed_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let hydrated_vec = hydrated.into_inner().unwrap_or_default();
|
||||
json!({
|
||||
"hydrated": hydrated_vec,
|
||||
"skipped_existing": skipped_existing.into_inner(),
|
||||
"failed": failed.into_inner(),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_placeholder(
|
||||
path: &Path,
|
||||
host_alias: &str,
|
||||
remote_workspace_root: &str,
|
||||
cache_root: &Path,
|
||||
max_open_bytes: u64,
|
||||
binary_probe_bytes: usize,
|
||||
allow_empty: bool,
|
||||
timeout_ms: u64,
|
||||
hydrated: &Mutex<Vec<Value>>,
|
||||
skipped_existing: &AtomicUsize,
|
||||
failed: &AtomicUsize,
|
||||
) {
|
||||
// Re-check zero-byte: a concurrent path (sidebar hydrate /
|
||||
// on-demand fetch) may have filled the placeholder while we
|
||||
// were iterating. Mirror Python's pre-fetch guard.
|
||||
let still_placeholder = match path.metadata() {
|
||||
Ok(m) => m.is_file() && m.len() == 0,
|
||||
Err(_) => false,
|
||||
};
|
||||
if !still_placeholder {
|
||||
skipped_existing.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
let remote = match map_local_to_remote_path(remote_workspace_root, cache_root, path) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
failed.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = file_open::run_file_open_transaction(
|
||||
host_alias,
|
||||
&remote,
|
||||
path,
|
||||
max_open_bytes,
|
||||
binary_probe_bytes,
|
||||
allow_empty,
|
||||
timeout_ms,
|
||||
);
|
||||
let outcome_str = outcome.get("outcome").and_then(Value::as_str).unwrap_or("");
|
||||
if outcome_str == "OK" {
|
||||
let metadata = outcome.get("metadata").cloned().unwrap_or(Value::Null);
|
||||
let entry = json!({
|
||||
"local_path": path.to_string_lossy(),
|
||||
"metadata": metadata,
|
||||
});
|
||||
if let Ok(mut h) = hydrated.lock() {
|
||||
h.push(entry);
|
||||
}
|
||||
} else {
|
||||
failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
fn touch(path: &Path, size: usize) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut f = File::create(path)?;
|
||||
if size > 0 {
|
||||
f.write_all(&vec![b'x'; size])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn names_only(paths: &[PathBuf]) -> Vec<String> {
|
||||
let mut names: Vec<String> = paths
|
||||
.iter()
|
||||
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
#[test]
|
||||
fn empty_allowlist_yields_nothing() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
touch(&temp.path().join("Cargo.toml"), 0)?;
|
||||
let result = find_placeholder_candidates(temp.path(), &[]);
|
||||
assert!(result.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_is_file_not_dir_yields_nothing() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let root_file = temp.path().join("root_is_file");
|
||||
touch(&root_file, 4)?;
|
||||
let result = find_placeholder_candidates(&root_file, &["Cargo.toml".to_string()]);
|
||||
assert!(result.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_nonzero_size_files() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
touch(&temp.path().join("Cargo.toml"), 1)?;
|
||||
touch(&temp.path().join("pyproject.toml"), 0)?;
|
||||
let result = find_placeholder_candidates(
|
||||
temp.path(),
|
||||
&["Cargo.toml".to_string(), "pyproject.toml".to_string()],
|
||||
);
|
||||
assert_eq!(names_only(&result), vec!["pyproject.toml".to_string()]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basename_match_is_case_sensitive() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
touch(&temp.path().join("cargo.toml"), 0)?;
|
||||
touch(&temp.path().join("Cargo.toml"), 0)?;
|
||||
let result = find_placeholder_candidates(temp.path(), &["Cargo.toml".to_string()]);
|
||||
assert_eq!(names_only(&result), vec!["Cargo.toml".to_string()]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_extern_subtree() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
touch(&temp.path().join("__extern").join("Cargo.toml"), 0)?;
|
||||
touch(&temp.path().join("ok").join("Cargo.toml"), 0)?;
|
||||
let result = find_placeholder_candidates(temp.path(), &["Cargo.toml".to_string()]);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].to_string_lossy().contains("/ok/"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_directories_are_traversed() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
touch(&temp.path().join("a/b/c/Cargo.toml"), 0)?;
|
||||
touch(&temp.path().join("a/b/package.json"), 0)?;
|
||||
let result = find_placeholder_candidates(
|
||||
temp.path(),
|
||||
&["Cargo.toml".to_string(), "package.json".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
names_only(&result),
|
||||
vec!["Cargo.toml".to_string(), "package.json".to_string()],
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
299
rust/crates/sessions_native/src/file_open.rs
Normal file
299
rust/crates/sessions_native/src/file_open.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
//! Full Rust file_open transaction (Wave 2 PR 14.5c — H1 본체).
|
||||
//!
|
||||
//! 한 함수로 read + guard + atomic_write 를 atomic하게 묶는다:
|
||||
//!
|
||||
//! 1. broker.request 로 helper에 ``file/read`` 보내고 응답 받음.
|
||||
//! 2. 응답 envelope 에서 ``metadata`` 와 ``body_b64`` 추출.
|
||||
//! 3. base64 decode → bytes.
|
||||
//! 4. ``open_guard_reason`` 호출 (kind/size/max/allow_empty).
|
||||
//! 5. binary head probe (``is_likely_binary``).
|
||||
//! 6. 가드 통과면 ``atomic_write_bytes`` 로 local cache 에 기록.
|
||||
//! 7. structured outcome JSON 반환.
|
||||
//!
|
||||
//! Python 측 ``open_remote_file_into_local_cache`` 가 본 함수를 호출하는
|
||||
//! thin wrapper로 줄어든다 (PR 14.5/.5b 의 H1 transaction 본체).
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::atomic_write;
|
||||
use crate::broker::{RequestOutcome, global_broker};
|
||||
|
||||
const REMOTE_KIND_REGULAR_FILE: i32 = 0;
|
||||
const REMOTE_KIND_DIRECTORY: i32 = 1;
|
||||
const REMOTE_KIND_SYMLINK: i32 = 2;
|
||||
|
||||
const OPEN_REASON_NONE: i32 = 0;
|
||||
const OPEN_REASON_FILE_TOO_LARGE: i32 = 1;
|
||||
const OPEN_REASON_UNSUPPORTED_REMOTE_KIND: i32 = 2;
|
||||
const OPEN_REASON_ZERO_BYTE_READ_NOT_ALLOWED: i32 = 3;
|
||||
|
||||
fn map_kind_to_code(kind: &str) -> i32 {
|
||||
match kind {
|
||||
"regular_file" => REMOTE_KIND_REGULAR_FILE,
|
||||
"directory" => REMOTE_KIND_DIRECTORY,
|
||||
"symlink" => REMOTE_KIND_SYMLINK,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_guard_reason(
|
||||
remote_kind_code: i32,
|
||||
size_bytes: u64,
|
||||
max_open_bytes: u64,
|
||||
allow_empty: bool,
|
||||
) -> i32 {
|
||||
if remote_kind_code == REMOTE_KIND_DIRECTORY || remote_kind_code == REMOTE_KIND_SYMLINK {
|
||||
return OPEN_REASON_UNSUPPORTED_REMOTE_KIND;
|
||||
}
|
||||
if remote_kind_code != REMOTE_KIND_REGULAR_FILE {
|
||||
// OTHER / unknown — treat as unsupported.
|
||||
return OPEN_REASON_UNSUPPORTED_REMOTE_KIND;
|
||||
}
|
||||
if size_bytes > max_open_bytes {
|
||||
return OPEN_REASON_FILE_TOO_LARGE;
|
||||
}
|
||||
if size_bytes == 0 && !allow_empty {
|
||||
return OPEN_REASON_ZERO_BYTE_READ_NOT_ALLOWED;
|
||||
}
|
||||
OPEN_REASON_NONE
|
||||
}
|
||||
|
||||
fn is_likely_binary(head: &[u8]) -> bool {
|
||||
head.contains(&0)
|
||||
}
|
||||
|
||||
/// Outcome shape mirrored from Python ``OpenOutcome`` so callers can map
|
||||
/// 1:1 by string label without a typed binding (kept loose because Python
|
||||
/// already has the typed dataclass).
|
||||
fn outcome_json(outcome: &str, extras: &[(&str, Value)]) -> Value {
|
||||
let mut obj = serde_json::Map::new();
|
||||
obj.insert("outcome".to_string(), Value::String(outcome.to_string()));
|
||||
for (k, v) in extras {
|
||||
obj.insert((*k).to_string(), v.clone());
|
||||
}
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
/// Run the file_open transaction against `host_alias`.
|
||||
///
|
||||
/// Returns a JSON value with `outcome` ∈ {OK, BLOCKED_BY_POLICY,
|
||||
/// BLOCKED_BINARY_HEURISTIC, REMOTE_NOT_FOUND, TRANSPORT_ERROR}; OK
|
||||
/// additionally carries the bytes-written count and observed metadata.
|
||||
pub fn run_file_open_transaction(
|
||||
host_alias: &str,
|
||||
remote_absolute_path: &str,
|
||||
local_cache_path: &Path,
|
||||
max_open_bytes: u64,
|
||||
binary_probe_bytes: usize,
|
||||
allow_empty: bool,
|
||||
timeout_ms: u64,
|
||||
) -> Value {
|
||||
// 1. Build file/read envelope and dispatch to the helper.
|
||||
let envelope_id = format!(
|
||||
"file_open_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
let payload = json!({
|
||||
"id": envelope_id,
|
||||
"method": "file/read",
|
||||
"params": {"remote_absolute_path": remote_absolute_path},
|
||||
"timeout_ms": timeout_ms,
|
||||
"trace": "off",
|
||||
});
|
||||
let payload_json = match serde_json::to_string(&payload) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return outcome_json(
|
||||
"TRANSPORT_ERROR",
|
||||
&[(
|
||||
"detail",
|
||||
Value::String(format!("payload serialization failed: {e}")),
|
||||
)],
|
||||
);
|
||||
}
|
||||
};
|
||||
let outcome = global_broker().request(
|
||||
host_alias,
|
||||
&envelope_id,
|
||||
&payload_json,
|
||||
Duration::from_millis(timeout_ms.max(1_000)),
|
||||
);
|
||||
|
||||
let response_text = match outcome {
|
||||
RequestOutcome::Response(s) => s,
|
||||
RequestOutcome::Timeout => {
|
||||
return outcome_json(
|
||||
"TRANSPORT_ERROR",
|
||||
&[(
|
||||
"detail",
|
||||
Value::String(format!("file/read exceeded {timeout_ms} ms")),
|
||||
)],
|
||||
);
|
||||
}
|
||||
RequestOutcome::BrokenPipe(detail) => {
|
||||
return outcome_json(
|
||||
"TRANSPORT_ERROR",
|
||||
&[("detail", Value::String(format!("broken pipe: {detail}")))],
|
||||
);
|
||||
}
|
||||
RequestOutcome::SessionMissing => {
|
||||
return outcome_json(
|
||||
"TRANSPORT_ERROR",
|
||||
&[(
|
||||
"detail",
|
||||
Value::String("broker has no active session".to_string()),
|
||||
)],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Parse the envelope.
|
||||
let envelope: Value = match serde_json::from_str(&response_text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return outcome_json(
|
||||
"TRANSPORT_ERROR",
|
||||
&[("detail", Value::String(format!("response not JSON: {e}")))],
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Some(err) = envelope.get("error").and_then(Value::as_object) {
|
||||
let code = err
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let message = err
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// Helper marks missing files via ``file_read_failed`` + lstat
|
||||
// detail; map both ENOENT-shaped errors to REMOTE_NOT_FOUND so
|
||||
// the caller can drop stale cache files. Other errors surface
|
||||
// as TRANSPORT_ERROR for now.
|
||||
let outcome = if code == "file_read_failed"
|
||||
&& (message.contains("No such file")
|
||||
|| message.contains("ENOENT")
|
||||
|| message.contains("lstat"))
|
||||
{
|
||||
"REMOTE_NOT_FOUND"
|
||||
} else {
|
||||
"TRANSPORT_ERROR"
|
||||
};
|
||||
return outcome_json(
|
||||
outcome,
|
||||
&[
|
||||
("error_code", Value::String(code)),
|
||||
("detail", Value::String(message)),
|
||||
],
|
||||
);
|
||||
}
|
||||
let result = match envelope.get("result").and_then(Value::as_object) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return outcome_json(
|
||||
"TRANSPORT_ERROR",
|
||||
&[(
|
||||
"detail",
|
||||
Value::String("response missing both `result` and `error`".to_string()),
|
||||
)],
|
||||
);
|
||||
}
|
||||
};
|
||||
let metadata = match result.get("metadata").and_then(Value::as_object) {
|
||||
Some(m) => m.clone(),
|
||||
None => {
|
||||
return outcome_json(
|
||||
"TRANSPORT_ERROR",
|
||||
&[(
|
||||
"detail",
|
||||
Value::String("response missing `metadata`".to_string()),
|
||||
)],
|
||||
);
|
||||
}
|
||||
};
|
||||
let body_b64 = result.get("body_b64").and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
// 3. Decode bytes.
|
||||
let body = match BASE64_STANDARD.decode(body_b64) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return outcome_json(
|
||||
"TRANSPORT_ERROR",
|
||||
&[(
|
||||
"detail",
|
||||
Value::String(format!("body_b64 decode failed: {e}")),
|
||||
)],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Open guard.
|
||||
let kind_str = metadata
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("other");
|
||||
let size = metadata
|
||||
.get("size_bytes")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let kind_code = map_kind_to_code(kind_str);
|
||||
let reason = open_guard_reason(kind_code, size, max_open_bytes, allow_empty);
|
||||
if reason != OPEN_REASON_NONE {
|
||||
let reason_label = match reason {
|
||||
OPEN_REASON_FILE_TOO_LARGE => "file_too_large",
|
||||
OPEN_REASON_UNSUPPORTED_REMOTE_KIND => "unsupported_remote_kind",
|
||||
OPEN_REASON_ZERO_BYTE_READ_NOT_ALLOWED => "zero_byte_read_not_allowed",
|
||||
_ => "policy_blocked",
|
||||
};
|
||||
return outcome_json(
|
||||
"BLOCKED_BY_POLICY",
|
||||
&[
|
||||
(
|
||||
"unsupported_reason",
|
||||
Value::String(reason_label.to_string()),
|
||||
),
|
||||
("metadata", Value::Object(metadata)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Binary head heuristic.
|
||||
let head_limit = binary_probe_bytes.min(body.len());
|
||||
if is_likely_binary(&body[..head_limit]) {
|
||||
return outcome_json(
|
||||
"BLOCKED_BINARY_HEURISTIC",
|
||||
&[("metadata", Value::Object(metadata))],
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Atomic write — same contract as PR 14.5/.5b.
|
||||
if let Err(e) = atomic_write::atomic_write_bytes(local_cache_path, &body) {
|
||||
return outcome_json(
|
||||
"TRANSPORT_ERROR",
|
||||
&[(
|
||||
"detail",
|
||||
Value::String(format!("local cache write failed: {e}")),
|
||||
)],
|
||||
);
|
||||
}
|
||||
|
||||
outcome_json(
|
||||
"OK",
|
||||
&[
|
||||
(
|
||||
"bytes_written",
|
||||
Value::Number(serde_json::Number::from(body.len())),
|
||||
),
|
||||
("metadata", Value::Object(metadata)),
|
||||
],
|
||||
)
|
||||
}
|
||||
115
rust/crates/sessions_native/src/interpreter_probe.rs
Normal file
115
rust/crates/sessions_native/src/interpreter_probe.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
//! Python interpreter probe heuristics (Wave 1.5 amend §F — `interpreter_probe`).
|
||||
//!
|
||||
//! Python `sublime/sessions/python_interpreter_registry.py`의 ``derive_venv_name``
|
||||
//! 휴리스틱을 흡수. 본 모듈은 입출력이 string인 pure function — Sublime API
|
||||
//! 의존 0건, 캐시/락은 Python에 정당히 잔존(instance state + threading.Lock는
|
||||
//! ABI 라운드트립 비용 > LOC 절감 ROI).
|
||||
//!
|
||||
//! 책임 경계:
|
||||
//! - heuristic = Rust (이 모듈).
|
||||
//! - 캐시·랭킹·SSH probe = Python (`python_interpreter_registry`).
|
||||
//! - probe regex (parse_version_output) = Python 잔존 (rust-max 양보 영역,
|
||||
//! Wave 1.5 amend §F notes).
|
||||
|
||||
/// Return a human-friendly venv label for ``remote_path``.
|
||||
///
|
||||
/// Heuristics, in priority order:
|
||||
/// - ``<name>/.venv/bin/python(3)`` → ``<name>``
|
||||
/// - ``.../envs/<name>/bin/python(3)`` (conda layout) → ``<name>``
|
||||
/// - fallback: parent of ``bin/`` directory.
|
||||
/// - fallback²: immediate parent (no ``bin`` separator at all).
|
||||
///
|
||||
/// Returns empty string for an empty input or a path with fewer than two
|
||||
/// components — caller treats that as "no useful name".
|
||||
pub fn derive_venv_name(remote_path: &str) -> String {
|
||||
if remote_path.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let parts: Vec<&str> = remote_path.split('/').filter(|p| !p.is_empty()).collect();
|
||||
if parts.len() < 2 {
|
||||
return String::new();
|
||||
}
|
||||
let last = parts[parts.len() - 1];
|
||||
// Case 1: <name>/.venv/bin/python(3)
|
||||
if parts.len() >= 4
|
||||
&& last.starts_with("python")
|
||||
&& parts[parts.len() - 2] == "bin"
|
||||
&& parts[parts.len() - 3] == ".venv"
|
||||
{
|
||||
return parts[parts.len() - 4].to_string();
|
||||
}
|
||||
// Case 2: .../envs/<name>/bin/python(3)
|
||||
if parts.len() >= 4
|
||||
&& last.starts_with("python")
|
||||
&& parts[parts.len() - 2] == "bin"
|
||||
&& parts[parts.len() - 4] == "envs"
|
||||
{
|
||||
return parts[parts.len() - 3].to_string();
|
||||
}
|
||||
// Case 3: fallback — parent of ``bin``.
|
||||
if parts.len() >= 3 && parts[parts.len() - 2] == "bin" {
|
||||
return parts[parts.len() - 3].to_string();
|
||||
}
|
||||
// No ``bin/`` separator at all: punt to the immediate parent directory.
|
||||
parts[parts.len() - 2].to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_returns_empty() {
|
||||
assert_eq!(derive_venv_name(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_component_returns_empty() {
|
||||
assert_eq!(derive_venv_name("python"), "");
|
||||
assert_eq!(derive_venv_name("/python"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_venv_layout_returns_project_name() {
|
||||
assert_eq!(derive_venv_name("/path/to/MIN-T/.venv/bin/python"), "MIN-T",);
|
||||
assert_eq!(derive_venv_name("/srv/app/.venv/bin/python3"), "app",);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conda_envs_layout_returns_env_name() {
|
||||
assert_eq!(
|
||||
derive_venv_name("/home/u/.local/share/conda/envs/foo/bin/python"),
|
||||
"foo",
|
||||
);
|
||||
assert_eq!(
|
||||
derive_venv_name("/opt/conda/envs/myenv/bin/python3"),
|
||||
"myenv",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_parent_of_bin() {
|
||||
assert_eq!(derive_venv_name("/opt/python311/bin/python3"), "python311");
|
||||
assert_eq!(derive_venv_name("/usr/local/bin/python"), "local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_no_bin_uses_immediate_parent() {
|
||||
assert_eq!(derive_venv_name("/opt/python311/python"), "python311");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slashes_tolerated() {
|
||||
assert_eq!(
|
||||
derive_venv_name("/path/to/proj/.venv/bin/python///"),
|
||||
"proj",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python3_with_minor_suffix() {
|
||||
// _PYTHON_NAME_RE in the Python module accepts "python3.11" too;
|
||||
// the venv-name heuristic is "starts_with python", so this matches.
|
||||
assert_eq!(derive_venv_name("/srv/app/.venv/bin/python3.11"), "app",);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
//! Thin C ABI for workspace path helpers used by the Sublime Python package.
|
||||
|
||||
mod abi_error;
|
||||
mod atomic_write;
|
||||
pub mod broker;
|
||||
mod broker_ffi;
|
||||
mod eager_hydrate;
|
||||
mod file_open;
|
||||
mod interpreter_probe;
|
||||
mod local_watcher;
|
||||
pub mod orchestrator;
|
||||
mod settings_normalize;
|
||||
|
||||
pub use abi_error::AbiError;
|
||||
pub use broker_ffi::{
|
||||
@@ -251,7 +258,7 @@ fn write_output(out_buf: *mut c_char, out_cap: usize, value: &str) -> c_int {
|
||||
0
|
||||
}
|
||||
|
||||
fn normalize_local_path(path: &Path) -> PathBuf {
|
||||
pub(crate) fn normalize_local_path(path: &Path) -> PathBuf {
|
||||
let base = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else if let Ok(cwd) = std::env::current_dir() {
|
||||
@@ -272,6 +279,45 @@ fn normalize_local_path(path: &Path) -> PathBuf {
|
||||
out
|
||||
}
|
||||
|
||||
/// Map ``local_path`` (under ``files_cache_root``) back to a remote POSIX
|
||||
/// path. Returns ``None`` when the path does not belong to this cache root.
|
||||
///
|
||||
/// Mirrors the ABI ``sessions_file_map_local_to_remote`` logic so the
|
||||
/// orchestrator-side (eager hydrate, mirror BFS body) does not need to
|
||||
/// re-implement it.
|
||||
pub(crate) fn map_local_to_remote_path(
|
||||
remote_root: &str,
|
||||
files_cache_root: &Path,
|
||||
local_path: &Path,
|
||||
) -> Option<String> {
|
||||
let cache_root = normalize_local_path(files_cache_root);
|
||||
let local = normalize_local_path(local_path);
|
||||
let extern_root = cache_root.join("__extern");
|
||||
if let Ok(rel) = local.strip_prefix(&extern_root) {
|
||||
let rel_s = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
.join("/");
|
||||
return Some(format!("/{}", rel_s));
|
||||
}
|
||||
let rel = local.strip_prefix(&cache_root).ok()?;
|
||||
let rel_s = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
.join("/");
|
||||
let root_trim = remote_root.trim_end_matches('/');
|
||||
let remote = if root_trim.is_empty() || root_trim == "/" {
|
||||
format!("/{}", rel_s)
|
||||
} else if rel_s.is_empty() {
|
||||
root_trim.to_string()
|
||||
} else {
|
||||
format!("{}/{}", root_trim, rel_s)
|
||||
};
|
||||
Some(remote)
|
||||
}
|
||||
|
||||
fn split_posix(path: &str) -> Vec<&str> {
|
||||
path.split('/').filter(|part| !part.is_empty()).collect()
|
||||
}
|
||||
@@ -476,6 +522,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())
|
||||
}
|
||||
|
||||
@@ -799,35 +868,14 @@ pub unsafe extern "C" fn sessions_file_map_local_to_remote(
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
|
||||
let cache_root = normalize_local_path(Path::new(files_cache_root_s));
|
||||
let local = normalize_local_path(Path::new(local_path_s));
|
||||
let extern_root = cache_root.join("__extern");
|
||||
if let Ok(rel) = local.strip_prefix(&extern_root) {
|
||||
let rel_s = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
.join("/");
|
||||
let remote = format!("/{}", rel_s);
|
||||
return write_output(out_buf, out_cap, &remote);
|
||||
match map_local_to_remote_path(
|
||||
remote_root_s,
|
||||
Path::new(files_cache_root_s),
|
||||
Path::new(local_path_s),
|
||||
) {
|
||||
Some(remote) => write_output(out_buf, out_cap, &remote),
|
||||
None => 1,
|
||||
}
|
||||
let Ok(rel) = local.strip_prefix(&cache_root) else {
|
||||
return 1;
|
||||
};
|
||||
let rel_s = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
.join("/");
|
||||
let root_trim = remote_root_s.trim_end_matches('/');
|
||||
let remote = if root_trim.is_empty() || root_trim == "/" {
|
||||
format!("/{}", rel_s)
|
||||
} else if rel_s.is_empty() {
|
||||
root_trim.to_string()
|
||||
} else {
|
||||
format!("{}/{}", root_trim, rel_s)
|
||||
};
|
||||
write_output(out_buf, out_cap, &remote)
|
||||
}
|
||||
|
||||
/// Return `1` if local path is under `files_cache_root/__extern`, else `0`.
|
||||
@@ -1176,3 +1224,539 @@ pub unsafe extern "C" fn sessions_queue_tail_labels_json(
|
||||
let out = queue_tail_labels_json(labels_joined_s, max_tail);
|
||||
write_output(out_buf, out_cap, &out)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Settings normalization (Wave 1.5 amend §F)
|
||||
// ===========================================================================
|
||||
|
||||
fn settings_normalize_dispatch<F>(
|
||||
raw_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
op: F,
|
||||
) -> c_int
|
||||
where
|
||||
F: FnOnce(&serde_json::Value) -> serde_json::Value,
|
||||
{
|
||||
if raw_json.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(raw_s) = (unsafe { CStr::from_ptr(raw_json) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let parsed: serde_json::Value = serde_json::from_str(raw_s).unwrap_or(serde_json::Value::Null);
|
||||
let normalized = op(&parsed);
|
||||
let Ok(serialized) = serde_json::to_string(&normalized) else {
|
||||
return AbiError::Serialization.code();
|
||||
};
|
||||
write_output(out_buf, out_cap, &serialized)
|
||||
}
|
||||
|
||||
/// Normalize `sessions_remote_python_tool_pipeline` from raw JSON.
|
||||
///
|
||||
/// # Safety
|
||||
/// `raw_json` must be a valid UTF-8 C string. `out_buf` must be writable for
|
||||
/// `out_cap` bytes when non-null. Output is a JSON array of step ids.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_settings_normalize_pipeline(
|
||||
raw_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
settings_normalize_dispatch(
|
||||
raw_json,
|
||||
out_buf,
|
||||
out_cap,
|
||||
settings_normalize::normalize_python_tool_pipeline,
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalize `sessions_remote_code_servers` from raw JSON.
|
||||
///
|
||||
/// # Safety
|
||||
/// `raw_json` must be a valid UTF-8 C string. `out_buf` writable.
|
||||
/// Output is a JSON array of canonical code-server spec objects.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_settings_normalize_code_server(
|
||||
raw_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
settings_normalize_dispatch(
|
||||
raw_json,
|
||||
out_buf,
|
||||
out_cap,
|
||||
settings_normalize::normalize_code_server_specs,
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalize `sessions_remote_extensions` from raw JSON.
|
||||
///
|
||||
/// # Safety
|
||||
/// `raw_json` must be a valid UTF-8 C string. `out_buf` writable.
|
||||
/// Output is a JSON array of canonical remote extension spec objects.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_settings_normalize_extensions(
|
||||
raw_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
settings_normalize_dispatch(
|
||||
raw_json,
|
||||
out_buf,
|
||||
out_cap,
|
||||
settings_normalize::normalize_remote_extension_specs,
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Python interpreter probe heuristics (Wave 1.5 amend §F)
|
||||
// ===========================================================================
|
||||
|
||||
// ===========================================================================
|
||||
// File open transaction (Wave 2 PR 14.5c — H1 본체)
|
||||
// ===========================================================================
|
||||
|
||||
/// Run the full Rust file_open transaction (read + guard + atomic write).
|
||||
///
|
||||
/// # Safety
|
||||
/// `host_alias`, `remote_path`, `local_cache_path` must be valid UTF-8 C
|
||||
/// strings. `out_buf` must be writable for `out_cap` bytes when non-null.
|
||||
/// Output is a JSON object with an `outcome` field.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_file_open_transaction(
|
||||
host_alias: *const c_char,
|
||||
remote_path: *const c_char,
|
||||
local_cache_path: *const c_char,
|
||||
max_open_bytes: u64,
|
||||
binary_probe_bytes: usize,
|
||||
allow_empty: c_int,
|
||||
timeout_ms: u64,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if host_alias.is_null() || remote_path.is_null() || local_cache_path.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(host_s) = (unsafe { CStr::from_ptr(host_alias) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(remote_s) = (unsafe { CStr::from_ptr(remote_path) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(local_s) = (unsafe { CStr::from_ptr(local_cache_path) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let outcome = file_open::run_file_open_transaction(
|
||||
host_s,
|
||||
remote_s,
|
||||
Path::new(local_s),
|
||||
max_open_bytes,
|
||||
binary_probe_bytes,
|
||||
allow_empty != 0,
|
||||
timeout_ms,
|
||||
);
|
||||
let Ok(serialized) = serde_json::to_string(&outcome) else {
|
||||
return AbiError::Serialization.code();
|
||||
};
|
||||
write_output(out_buf, out_cap, &serialized)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Atomic write (Wave 2 PR 14.5b — H1 transaction 전제)
|
||||
// ===========================================================================
|
||||
|
||||
/// Atomically write `body` to `target` (tempfile + rename).
|
||||
///
|
||||
/// # Safety
|
||||
/// `target` must be a valid UTF-8 C string. `body` may be NULL when
|
||||
/// `body_len == 0` (zero-byte file). On non-zero `body_len`, `body` must
|
||||
/// point to readable memory for `body_len` bytes.
|
||||
///
|
||||
/// Returns 0 on success. Negative on error (NULL pointer / invalid UTF-8 /
|
||||
/// io error encoded as ``i32::MIN`` so callers can distinguish from the
|
||||
/// AbiError range).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_file_atomic_write(
|
||||
target: *const c_char,
|
||||
body: *const u8,
|
||||
body_len: usize,
|
||||
) -> c_int {
|
||||
if target.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
if body.is_null() && body_len != 0 {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(target_s) = (unsafe { CStr::from_ptr(target) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let bytes: &[u8] = if body_len == 0 {
|
||||
&[]
|
||||
} else {
|
||||
unsafe { std::slice::from_raw_parts(body, body_len) }
|
||||
};
|
||||
match atomic_write::atomic_write_bytes(Path::new(target_s), bytes) {
|
||||
Ok(_) => 0,
|
||||
// Surface io errors via a sentinel distinguishable from AbiError
|
||||
// codes (-1..=-22). i32::MIN is far outside that range and pairs
|
||||
// with stderr/log on the Python side for diagnosis.
|
||||
Err(_) => i32::MIN,
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Local cache filesystem watcher (Wave 2 PR-C — cross-platform sync)
|
||||
// ===========================================================================
|
||||
|
||||
/// Start watching ``cache_root`` recursively. Returns a non-zero
|
||||
/// ``i64`` handle on success (the same handle threads through
|
||||
/// ``drain`` / ``stop``); ``0`` when the cache root is missing or the
|
||||
/// platform watcher could not be created (caller should fall back to
|
||||
/// the Sublime ``on_post_save`` listener only).
|
||||
///
|
||||
/// # Safety
|
||||
/// `cache_root` must be a valid UTF-8 C string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_local_watcher_start(cache_root: *const c_char) -> i64 {
|
||||
if cache_root.is_null() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(cache_root_s) = (unsafe { CStr::from_ptr(cache_root) }).to_str() else {
|
||||
return 0;
|
||||
};
|
||||
local_watcher::start(Path::new(cache_root_s))
|
||||
}
|
||||
|
||||
/// Drain the handle's pending events. Writes the deduplicated, sorted
|
||||
/// list of paths into ``out_buf`` joined by ``\x1F`` (unit separator,
|
||||
/// matches the encoding used by ``sessions_eager_hydrate_*``).
|
||||
/// Returns 0 on success, ``AbiError::NullPointer.code()`` when ``out_buf``
|
||||
/// is null, and ``-1`` when ``handle`` is unknown (caller treats as
|
||||
/// "watcher gone" and stops polling).
|
||||
///
|
||||
/// # Safety
|
||||
/// `out_buf` must be writable for `out_cap` bytes when non-null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_local_watcher_drain(
|
||||
handle: i64,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if out_buf.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
match local_watcher::drain(handle) {
|
||||
Some(joined) => write_output(out_buf, out_cap, &joined),
|
||||
None => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop watching, releasing the OS handle. Idempotent — safe to call
|
||||
/// repeatedly with the same handle. Returns ``1`` when a watcher was
|
||||
/// removed, ``0`` when ``handle`` was unknown.
|
||||
///
|
||||
/// # Safety
|
||||
/// Pure-int interface; no pointers. Marked ``unsafe extern "C"`` to
|
||||
/// match the rest of the watcher ABI surface.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_local_watcher_stop(handle: i64) -> c_int {
|
||||
if local_watcher::stop(handle) { 1 } else { 0 }
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Orchestrator FFI (Wave 2 PR 16 — PR-A core)
|
||||
// ===========================================================================
|
||||
|
||||
/// Bump the connect generation token and return the new value.
|
||||
///
|
||||
/// # Safety
|
||||
/// Pure FFI call (no pointer arguments). Always safe.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn sessions_orch_bump_connect_generation() -> u64 {
|
||||
orchestrator::OrchestratorState::global().bump_connect_generation()
|
||||
}
|
||||
|
||||
/// Return `1` when `token` is stale (older than the current generation),
|
||||
/// else `0`. Negative on error (none defined yet).
|
||||
///
|
||||
/// # Safety
|
||||
/// Pure FFI call.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn sessions_orch_is_connect_token_stale(token: u64) -> c_int {
|
||||
if orchestrator::OrchestratorState::global().is_connect_token_stale(token) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark `host` as the in-flight connect host with the supplied `token`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `host` must be a valid UTF-8 C string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_orch_set_connect_inflight(
|
||||
token: u64,
|
||||
host: *const c_char,
|
||||
) -> c_int {
|
||||
if host.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(host_s) = (unsafe { CStr::from_ptr(host) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
orchestrator::OrchestratorState::global().set_connect_inflight(token, host_s);
|
||||
0
|
||||
}
|
||||
|
||||
/// Clear the in-flight slot if it currently belongs to `token`.
|
||||
/// Returns `1` when cleared, `0` when token did not match.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn sessions_orch_clear_connect_inflight_if(token: u64) -> c_int {
|
||||
if orchestrator::OrchestratorState::global().clear_connect_inflight_if(token) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the current in-flight host into `out_buf` (empty string when no
|
||||
/// host is in flight). Returns 0 on success / required buffer size on
|
||||
/// truncation / negative on error.
|
||||
///
|
||||
/// # Safety
|
||||
/// `out_buf` must be writable for `out_cap` bytes when non-null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_orch_inflight_host(
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
let host = orchestrator::OrchestratorState::global()
|
||||
.connect_inflight_host()
|
||||
.unwrap_or_default();
|
||||
write_output(out_buf, out_cap, &host)
|
||||
}
|
||||
|
||||
/// Increment the interactive-lane depth for `host`. Returns the new depth.
|
||||
///
|
||||
/// # Safety
|
||||
/// `host` must be a valid UTF-8 C string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_orch_enter_interactive_lane(host: *const c_char) -> c_int {
|
||||
if host.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(host_s) = (unsafe { CStr::from_ptr(host) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let depth = orchestrator::OrchestratorState::global().enter_interactive_lane(host_s);
|
||||
c_int::try_from(depth).unwrap_or(c_int::MAX)
|
||||
}
|
||||
|
||||
/// Decrement the interactive-lane depth for `host`. Returns the new depth.
|
||||
///
|
||||
/// # Safety
|
||||
/// `host` must be a valid UTF-8 C string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_orch_exit_interactive_lane(host: *const c_char) -> c_int {
|
||||
if host.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(host_s) = (unsafe { CStr::from_ptr(host) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let depth = orchestrator::OrchestratorState::global().exit_interactive_lane(host_s);
|
||||
c_int::try_from(depth).unwrap_or(c_int::MAX)
|
||||
}
|
||||
|
||||
/// Return `1` when the mirror lane is currently paused for `host`, else `0`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `host` must be a valid UTF-8 C string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_orch_lane_is_paused(host: *const c_char) -> c_int {
|
||||
if host.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(host_s) = (unsafe { CStr::from_ptr(host) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
if orchestrator::OrchestratorState::global().lane_is_paused(host_s) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Eager hydrate placeholder discovery (Wave 2 PR 14)
|
||||
// ===========================================================================
|
||||
|
||||
/// Find zero-byte placeholder files under `cache_root` matching the
|
||||
/// `\x1f`-joined `allowed_basenames`. Output is `\x1f`-joined absolute paths.
|
||||
///
|
||||
/// # Safety
|
||||
/// `cache_root` and `allowed_basenames_joined` must be valid UTF-8 C strings.
|
||||
/// `out_buf` must be writable for `out_cap` bytes when non-null. Empty
|
||||
/// allow-list or non-existent cache_root yields an empty output (rc 0,
|
||||
/// length 0 — caller checks `out_buf[0] == 0`).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_eager_hydrate_find_candidates(
|
||||
cache_root: *const c_char,
|
||||
allowed_basenames_joined: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if cache_root.is_null() || allowed_basenames_joined.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(cache_root_s) = (unsafe { CStr::from_ptr(cache_root) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(allowed_s) = (unsafe { CStr::from_ptr(allowed_basenames_joined) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let allowed: Vec<String> = allowed_s
|
||||
.split('\x1f')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let candidates = eager_hydrate::find_placeholder_candidates(Path::new(cache_root_s), &allowed);
|
||||
let joined = candidates
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\x1f");
|
||||
write_output(out_buf, out_cap, &joined)
|
||||
}
|
||||
|
||||
/// Run the eager-hydrate apply pass body (Wave 2 PR-B + PR-B.1).
|
||||
///
|
||||
/// One Rust round-trip drives the entire pass: find candidates →
|
||||
/// per-batch sleep → re-check zero-byte → map local→remote → file_open
|
||||
/// transaction (up to ``parallelism`` concurrent in-flight, broker
|
||||
/// multiplexes by envelope id) → collect outcomes. Python writes
|
||||
/// sidecar metadata for the returned ``hydrated`` list.
|
||||
///
|
||||
/// # Safety
|
||||
/// `cache_root`, `host_alias`, `remote_workspace_root`, and
|
||||
/// `allowed_basenames_joined` must be valid UTF-8 C strings (the latter
|
||||
/// uses 0x1F as the unit separator). `out_buf` must be writable for
|
||||
/// `out_cap` bytes when non-null. Returns 0 on success and writes a
|
||||
/// JSON object documented on
|
||||
/// :func:`eager_hydrate::run_apply_pass`.
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn sessions_eager_hydrate_apply(
|
||||
cache_root: *const c_char,
|
||||
host_alias: *const c_char,
|
||||
remote_workspace_root: *const c_char,
|
||||
allowed_basenames_joined: *const c_char,
|
||||
batch_size: usize,
|
||||
batch_sleep_ms: u64,
|
||||
max_open_bytes: u64,
|
||||
binary_probe_bytes: usize,
|
||||
allow_empty: c_int,
|
||||
timeout_ms: u64,
|
||||
parallelism: usize,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if cache_root.is_null()
|
||||
|| host_alias.is_null()
|
||||
|| remote_workspace_root.is_null()
|
||||
|| allowed_basenames_joined.is_null()
|
||||
{
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(cache_root_s) = (unsafe { CStr::from_ptr(cache_root) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(host_s) = (unsafe { CStr::from_ptr(host_alias) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(remote_root_s) = (unsafe { CStr::from_ptr(remote_workspace_root) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(allowed_s) = (unsafe { CStr::from_ptr(allowed_basenames_joined) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let allowed: Vec<String> = allowed_s
|
||||
.split('\x1f')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let summary = eager_hydrate::run_apply_pass(
|
||||
Path::new(cache_root_s),
|
||||
host_s,
|
||||
remote_root_s,
|
||||
&allowed,
|
||||
batch_size,
|
||||
batch_sleep_ms,
|
||||
max_open_bytes,
|
||||
binary_probe_bytes,
|
||||
allow_empty != 0,
|
||||
timeout_ms,
|
||||
parallelism,
|
||||
);
|
||||
let Ok(serialized) = serde_json::to_string(&summary) else {
|
||||
return AbiError::Serialization.code();
|
||||
};
|
||||
write_output(out_buf, out_cap, &serialized)
|
||||
}
|
||||
|
||||
/// Derive a human-friendly venv label from a remote interpreter path.
|
||||
///
|
||||
/// # Safety
|
||||
/// `remote_path` must be a valid UTF-8 C string. `out_buf` must be writable
|
||||
/// for `out_cap` bytes when non-null. Output is empty string when input has
|
||||
/// no useful name to extract (single-component paths).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_interpreter_derive_venv_name(
|
||||
remote_path: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if remote_path.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(remote_path_s) = (unsafe { CStr::from_ptr(remote_path) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let derived = interpreter_probe::derive_venv_name(remote_path_s);
|
||||
write_output(out_buf, out_cap, &derived)
|
||||
}
|
||||
|
||||
/// Merge user remote extension specs over a Python-supplied builtin catalog.
|
||||
///
|
||||
/// # Safety
|
||||
/// `builtin_json` and `user_json` must be valid UTF-8 C strings. `out_buf`
|
||||
/// writable. `builtin_json` is the Python-side builtin catalog (canonical
|
||||
/// shape — same as `normalize_remote_extension_specs` output). `user_json`
|
||||
/// is the raw user setting (this fn re-normalizes it).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn sessions_settings_merge_extension_catalog(
|
||||
builtin_json: *const c_char,
|
||||
user_json: *const c_char,
|
||||
out_buf: *mut c_char,
|
||||
out_cap: usize,
|
||||
) -> c_int {
|
||||
if builtin_json.is_null() || user_json.is_null() {
|
||||
return AbiError::NullPointer.code();
|
||||
}
|
||||
let Ok(builtin_s) = (unsafe { CStr::from_ptr(builtin_json) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let Ok(user_s) = (unsafe { CStr::from_ptr(user_json) }).to_str() else {
|
||||
return AbiError::InvalidUtf8.code();
|
||||
};
|
||||
let builtin: serde_json::Value =
|
||||
serde_json::from_str(builtin_s).unwrap_or(serde_json::Value::Null);
|
||||
let user: serde_json::Value = serde_json::from_str(user_s).unwrap_or(serde_json::Value::Null);
|
||||
let merged = settings_normalize::merge_extension_catalog(&builtin, &user);
|
||||
let Ok(serialized) = serde_json::to_string(&merged) else {
|
||||
return AbiError::Serialization.code();
|
||||
};
|
||||
write_output(out_buf, out_cap, &serialized)
|
||||
}
|
||||
|
||||
324
rust/crates/sessions_native/src/local_watcher.rs
Normal file
324
rust/crates/sessions_native/src/local_watcher.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
//! Local cache filesystem watcher (Wave 2 PR-C — cross-platform sync).
|
||||
//!
|
||||
//! Sublime Text only fires its ``on_post_save`` event for files saved
|
||||
//! through Sublime itself; external mutators (Sublime Merge stage/discard,
|
||||
//! ``vim``, build tools writing into the cache) bypass the listener and
|
||||
//! their changes never reach the remote. The result was the ``파일이 이미
|
||||
//! 존재한다는 이유`` save-conflict the user hit after a Sublime Merge
|
||||
//! discard: the local cache file diverged silently from the remote and
|
||||
//! the next Sessions save tripped the metadata-mismatch check.
|
||||
//!
|
||||
//! This module wraps the cross-platform ``notify`` crate
|
||||
//! (``RecommendedWatcher`` ⇒ FSEvents on macOS / inotify on Linux /
|
||||
//! ``ReadDirectoryChangesW`` on Windows) and exposes a polling-friendly
|
||||
//! drain API to Python:
|
||||
//!
|
||||
//! 1. ``start(cache_root)`` — recursively watches the workspace cache.
|
||||
//! Returns an opaque handle (``i64`` non-zero on success).
|
||||
//! 2. ``drain(handle)`` — pops every path observed since the last
|
||||
//! drain, deduped + sorted. Python polls this every ~50–100 ms
|
||||
//! from a daemon thread; idle workspaces have zero cost between
|
||||
//! polls because the watcher thread sits on the OS event source.
|
||||
//! 3. ``stop(handle)`` — drops the watcher, releases the OS resources.
|
||||
//!
|
||||
//! Filtering: ``__extern/``, ``.git/``, ``.sessions-metadata`` sidecars,
|
||||
//! and any path under a directory whose basename starts with ``.``
|
||||
//! (dotdir) are silently dropped at the watcher boundary so callers
|
||||
//! never see them. The user-facing save flow already echoes through
|
||||
//! ``SessionsRemoteCachedFileSaveListener``'s ``_RECENT_SELF_SAVE_…``
|
||||
//! cooldown for actual self-save suppression.
|
||||
//!
|
||||
//! Concurrency: all watchers live in a process-wide ``Mutex<HashMap>``
|
||||
//! keyed by an atomically-incrementing ``i64`` handle. The ``notify``
|
||||
//! callback pushes paths into a ``Mutex<Vec<PathBuf>>`` owned by the
|
||||
//! handle's ``WatchEntry`` — the watcher thread never blocks on the
|
||||
//! drain side because the lock is only held for the push duration.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
/// One watcher's pending event buffer + the watcher itself (kept alive
|
||||
/// for the duration of the watch — dropping the ``RecommendedWatcher``
|
||||
/// releases the OS handle).
|
||||
struct WatchEntry {
|
||||
pending: Arc<Mutex<Vec<PathBuf>>>,
|
||||
_watcher: RecommendedWatcher,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WatcherRegistry {
|
||||
entries: Mutex<HashMap<i64, WatchEntry>>,
|
||||
next_handle: AtomicI64,
|
||||
}
|
||||
|
||||
fn registry() -> &'static WatcherRegistry {
|
||||
static INSTANCE: OnceLock<WatcherRegistry> = OnceLock::new();
|
||||
INSTANCE.get_or_init(|| WatcherRegistry {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
next_handle: AtomicI64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop paths the caller never wants to round-trip to the remote:
|
||||
///
|
||||
/// * ``__extern/`` — out-of-workspace cache subtree.
|
||||
/// * ``.git/`` and contents — Track G owns its own sync flow.
|
||||
/// * ``.sessions-metadata`` sidecars — internal mtime/sha bookkeeping.
|
||||
/// * Anything under a dotdir (``.cache/``, ``.idea/``) — generated state
|
||||
/// that's noisy for git but uninteresting for sync.
|
||||
///
|
||||
/// Returns ``true`` when ``path`` should be reported to Python.
|
||||
fn path_is_eligible(cache_root: &Path, path: &Path) -> bool {
|
||||
let Ok(relative) = path.strip_prefix(cache_root) else {
|
||||
return false;
|
||||
};
|
||||
for component in relative.components() {
|
||||
let component_str = component.as_os_str().to_string_lossy();
|
||||
if component_str == "__extern" || component_str == ".git" {
|
||||
return false;
|
||||
}
|
||||
if component_str.starts_with('.') && !component_str.eq_ignore_ascii_case(".python-version")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(name) = path.file_name() {
|
||||
let name_lossy = name.to_string_lossy();
|
||||
if name_lossy.ends_with(".sessions-metadata") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Start watching ``cache_root`` recursively. Returns a non-zero handle
|
||||
/// on success, ``0`` when the watcher could not be created (caller may
|
||||
/// treat ``0`` as "feature unavailable" and skip the polling thread).
|
||||
pub fn start(cache_root: &Path) -> i64 {
|
||||
let cache_root_buf: PathBuf = cache_root.to_path_buf();
|
||||
if !cache_root_buf.is_dir() {
|
||||
return 0;
|
||||
}
|
||||
let pending: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let pending_for_callback = Arc::clone(&pending);
|
||||
let cache_root_for_callback = cache_root_buf.clone();
|
||||
let watcher_result: notify::Result<RecommendedWatcher> = RecommendedWatcher::new(
|
||||
move |event: notify::Result<Event>| {
|
||||
let Ok(event) = event else {
|
||||
return;
|
||||
};
|
||||
if !matches!(
|
||||
event.kind,
|
||||
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut accepted: Vec<PathBuf> = Vec::with_capacity(event.paths.len());
|
||||
for path in event.paths {
|
||||
if path_is_eligible(&cache_root_for_callback, &path) {
|
||||
accepted.push(path);
|
||||
}
|
||||
}
|
||||
if accepted.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut buffer) = pending_for_callback.lock() {
|
||||
buffer.extend(accepted);
|
||||
}
|
||||
},
|
||||
notify::Config::default(),
|
||||
);
|
||||
let mut watcher = match watcher_result {
|
||||
Ok(w) => w,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
if watcher
|
||||
.watch(&cache_root_buf, RecursiveMode::Recursive)
|
||||
.is_err()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
let handle = registry().next_handle.fetch_add(1, Ordering::Relaxed);
|
||||
let entry = WatchEntry {
|
||||
pending,
|
||||
_watcher: watcher,
|
||||
};
|
||||
if let Ok(mut entries) = registry().entries.lock() {
|
||||
entries.insert(handle, entry);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
handle
|
||||
}
|
||||
|
||||
/// Drain the handle's pending events. Returns paths since the last
|
||||
/// drain, deduplicated + sorted, joined by ``\x1F`` so the C ABI side
|
||||
/// can ship them as a single string. ``None`` when ``handle`` is
|
||||
/// unknown (handle was stopped or never existed).
|
||||
pub fn drain(handle: i64) -> Option<String> {
|
||||
let entries = registry().entries.lock().ok()?;
|
||||
let entry = entries.get(&handle)?;
|
||||
let mut buffer = entry.pending.lock().ok()?;
|
||||
if buffer.is_empty() {
|
||||
return Some(String::new());
|
||||
}
|
||||
let mut taken = std::mem::take(&mut *buffer);
|
||||
drop(buffer);
|
||||
drop(entries);
|
||||
taken.sort();
|
||||
taken.dedup();
|
||||
let joined: String = taken
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\x1f");
|
||||
Some(joined)
|
||||
}
|
||||
|
||||
/// Stop watching and release OS resources. Returns ``true`` when a
|
||||
/// watcher was removed; ``false`` when ``handle`` was unknown
|
||||
/// (idempotent — safe to call repeatedly on the same handle).
|
||||
pub fn stop(handle: i64) -> bool {
|
||||
let mut entries = match registry().entries.lock() {
|
||||
Ok(e) => e,
|
||||
Err(_) => return false,
|
||||
};
|
||||
entries.remove(&handle).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
fn wait_for_event(handle: i64, expected_substring: &str, max_ms: u64) -> Option<String> {
|
||||
let deadline = Instant::now() + Duration::from_millis(max_ms);
|
||||
loop {
|
||||
if let Some(joined) = drain(handle)
|
||||
&& !joined.is_empty()
|
||||
&& joined.contains(expected_substring)
|
||||
{
|
||||
return Some(joined);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_returns_zero_when_root_missing() -> TestResult {
|
||||
assert_eq!(start(Path::new("/this/path/does/not/exist/sessions")), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_returns_none_for_unknown_handle() -> TestResult {
|
||||
assert!(drain(0xdead_beef).is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_event_round_trips_to_drain() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let target = temp.path().join("hello.txt");
|
||||
fs::write(&target, b"v1")?;
|
||||
let handle = start(temp.path());
|
||||
assert!(handle > 0, "watcher start failed");
|
||||
// Settle: notify can fire spurious events on the initial watch
|
||||
// setup; drain those before mutating.
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
let _ = drain(handle);
|
||||
|
||||
fs::write(&target, b"v2")?;
|
||||
let observed = wait_for_event(handle, "hello.txt", 5_000);
|
||||
assert!(
|
||||
observed.is_some(),
|
||||
"watcher did not surface modify event within 5 s"
|
||||
);
|
||||
assert!(stop(handle));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_under_extern_are_filtered() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let extern_dir = temp.path().join("__extern").join("sub");
|
||||
fs::create_dir_all(&extern_dir)?;
|
||||
let extern_file = extern_dir.join("foo.txt");
|
||||
let visible_file = temp.path().join("visible.txt");
|
||||
fs::write(&visible_file, b"v1")?;
|
||||
|
||||
let handle = start(temp.path());
|
||||
assert!(handle > 0);
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
let _ = drain(handle);
|
||||
|
||||
// Mutate both — only the non-__extern one should surface.
|
||||
fs::write(&extern_file, b"hidden")?;
|
||||
fs::write(&visible_file, b"v2")?;
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let joined = drain(handle).unwrap_or_default();
|
||||
assert!(
|
||||
joined.contains("visible.txt"),
|
||||
"expected visible.txt in drain, got: {joined:?}"
|
||||
);
|
||||
assert!(
|
||||
!joined.contains("__extern"),
|
||||
"__extern should have been filtered, got: {joined:?}"
|
||||
);
|
||||
assert!(stop(handle));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotgit_subtree_is_filtered() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let dotgit = temp.path().join("repo").join(".git").join("refs");
|
||||
fs::create_dir_all(&dotgit)?;
|
||||
let dotgit_file = dotgit.join("HEAD");
|
||||
let repo_dir = temp.path().join("repo");
|
||||
let plain_file = repo_dir.join("README.md");
|
||||
fs::create_dir_all(&repo_dir)?;
|
||||
fs::write(&plain_file, b"v1")?;
|
||||
|
||||
let handle = start(temp.path());
|
||||
assert!(handle > 0);
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
let _ = drain(handle);
|
||||
|
||||
fs::write(&dotgit_file, b"refs/heads/main")?;
|
||||
fs::write(&plain_file, b"v2")?;
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let joined = drain(handle).unwrap_or_default();
|
||||
assert!(
|
||||
joined.contains("README.md"),
|
||||
"expected README.md in drain, got: {joined:?}"
|
||||
);
|
||||
assert!(
|
||||
!joined.contains(".git"),
|
||||
".git/ should have been filtered, got: {joined:?}"
|
||||
);
|
||||
assert!(stop(handle));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_is_idempotent() -> TestResult {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let handle = start(temp.path());
|
||||
assert!(handle > 0);
|
||||
assert!(stop(handle));
|
||||
assert!(!stop(handle), "second stop should return false");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
344
rust/crates/sessions_native/src/orchestrator.rs
Normal file
344
rust/crates/sessions_native/src/orchestrator.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
//! Worker-queue orchestrator state (Wave 2 PR 16 — PR-A core).
|
||||
//!
|
||||
//! Owns:
|
||||
//! - **Connect generation token** — a monotonic counter the bridge bumps on
|
||||
//! every "Remote workspace connect" quick-panel pick. Older
|
||||
//! `_connect_selected_host_async` calls compare their captured token
|
||||
//! against the current one and abort when stale.
|
||||
//! - **In-flight host tracking** — which host currently holds the connect
|
||||
//! slot, so a preempt can decide whether to kill the bridge of an older
|
||||
//! host that is still mid-handshake.
|
||||
//! - **SSH lane gating** — per-host counter that pauses the mirror lane
|
||||
//! while an interactive (file/read, hydrate, …) request is running.
|
||||
//! - **Queue pressure / tail labels** — small string formatting helpers
|
||||
//! that already lived in Rust before PR 16; kept beside the rest of the
|
||||
//! orchestrator state for amend §C single-source-of-truth.
|
||||
//!
|
||||
//! Out of scope (Python jurisdiction):
|
||||
//! - Python callables themselves (the `target` and `args` of each task).
|
||||
//! - Worker thread spawning / Sublime ``set_timeout`` scheduling — those
|
||||
//! sit at the Sublime API boundary.
|
||||
//! - User-visible status strings (amend §A1: Python single source).
|
||||
//!
|
||||
//! The orchestrator is a process-wide singleton accessed through
|
||||
//! `OrchestratorState::global()`. All public methods take `&self` — the
|
||||
//! interior mutability is `Mutex` per state group so callers never reach
|
||||
//! into the singleton's locks.
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Snapshot of the connect-token state at one moment in time.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct ConnectSnapshot {
|
||||
pub generation: u64,
|
||||
pub inflight_token: u64,
|
||||
}
|
||||
|
||||
/// Worker-queue orchestrator state. One instance per process, accessed via
|
||||
/// [`OrchestratorState::global`].
|
||||
#[derive(Default)]
|
||||
pub struct OrchestratorState {
|
||||
connect: Mutex<ConnectState>,
|
||||
lane: Mutex<LaneState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ConnectState {
|
||||
generation: u64,
|
||||
inflight_token: u64,
|
||||
inflight_host: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LaneState {
|
||||
/// `host_alias → interactive_depth`. Mirror lane is paused while
|
||||
/// `depth > 0`; resumed when it drops back to 0.
|
||||
interactive_depth: std::collections::HashMap<String, u32>,
|
||||
/// Hosts whose mirror lane is currently paused (interactive_depth > 0).
|
||||
paused_hosts: HashSet<String>,
|
||||
}
|
||||
|
||||
impl OrchestratorState {
|
||||
/// Process-wide singleton.
|
||||
pub fn global() -> &'static Self {
|
||||
static INSTANCE: OnceLock<OrchestratorState> = OnceLock::new();
|
||||
INSTANCE.get_or_init(OrchestratorState::default)
|
||||
}
|
||||
|
||||
// --- Connect generation token --------------------------------------
|
||||
|
||||
/// Bump the generation and return the new token. The bridge calls this
|
||||
/// when the user picks a host from the quick panel; older
|
||||
/// `_connect_selected_host_async` calls comparing against this token
|
||||
/// will be stale.
|
||||
pub fn bump_connect_generation(&self) -> u64 {
|
||||
let mut guard = match self.connect.lock() {
|
||||
Ok(g) => g,
|
||||
// Poisoned mutex: a panic happened inside another holder.
|
||||
// Still safe to bump — the data is plain integers/Option.
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.generation = guard.generation.saturating_add(1);
|
||||
guard.generation
|
||||
}
|
||||
|
||||
/// Return whether `token` is older than the current generation.
|
||||
pub fn is_connect_token_stale(&self, token: u64) -> bool {
|
||||
let guard = match self.connect.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
token != guard.generation
|
||||
}
|
||||
|
||||
/// Mark `host` as the in-flight connect host with `token`. Replaces
|
||||
/// any prior in-flight tuple; caller is expected to have just
|
||||
/// retrieved `token` via [`Self::bump_connect_generation`].
|
||||
pub fn set_connect_inflight(&self, token: u64, host: &str) {
|
||||
let mut guard = match self.connect.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.inflight_token = token;
|
||||
guard.inflight_host = Some(host.to_string());
|
||||
}
|
||||
|
||||
/// Clear the in-flight slot if and only if it currently belongs to
|
||||
/// `token`. Returning `false` means a newer connect already
|
||||
/// overwrote the slot (the caller's task is stale).
|
||||
pub fn clear_connect_inflight_if(&self, token: u64) -> bool {
|
||||
let mut guard = match self.connect.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
if guard.inflight_token == token {
|
||||
guard.inflight_token = 0;
|
||||
guard.inflight_host = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current `(generation, inflight_token)` snapshot. Used by
|
||||
/// the preempt path to decide whether to reset the bridge of the
|
||||
/// currently in-flight host.
|
||||
pub fn connect_snapshot(&self) -> ConnectSnapshot {
|
||||
let guard = match self.connect.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
ConnectSnapshot {
|
||||
generation: guard.generation,
|
||||
inflight_token: guard.inflight_token,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the currently in-flight host, if any. Distinct from
|
||||
/// `connect_snapshot()` because the host name is a heap-allocated
|
||||
/// `String`; `Copy` snapshots stay tiny.
|
||||
pub fn connect_inflight_host(&self) -> Option<String> {
|
||||
let guard = match self.connect.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.inflight_host.clone()
|
||||
}
|
||||
|
||||
// --- SSH lane gating -----------------------------------------------
|
||||
|
||||
/// Mark `host` as having one more interactive request running. Returns
|
||||
/// the new depth. Mirror lane should pause (`depth > 0`).
|
||||
pub fn enter_interactive_lane(&self, host: &str) -> u32 {
|
||||
let mut guard = match self.lane.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
let depth = guard
|
||||
.interactive_depth
|
||||
.get(host)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1);
|
||||
guard.interactive_depth.insert(host.to_string(), depth);
|
||||
if depth == 1 {
|
||||
guard.paused_hosts.insert(host.to_string());
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
/// Decrement the interactive depth for `host`. Returns the new depth.
|
||||
/// When depth hits 0 the host is removed from the paused set so the
|
||||
/// mirror lane can resume.
|
||||
pub fn exit_interactive_lane(&self, host: &str) -> u32 {
|
||||
let mut guard = match self.lane.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
let prev = guard.interactive_depth.get(host).copied().unwrap_or(0);
|
||||
let next = prev.saturating_sub(1);
|
||||
if next == 0 {
|
||||
guard.interactive_depth.remove(host);
|
||||
guard.paused_hosts.remove(host);
|
||||
} else {
|
||||
guard.interactive_depth.insert(host.to_string(), next);
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Return whether the mirror lane should currently pause for `host`.
|
||||
pub fn lane_is_paused(&self, host: &str) -> bool {
|
||||
let guard = match self.lane.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
guard.paused_hosts.contains(host)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue pressure / tail labels — kept here so amend §C "single source of
|
||||
// truth" applies to the whole orchestrator surface. These mirror the pre-
|
||||
// PR 16 implementations in ``sessions_native::lib`` (queue_pressure_label /
|
||||
// queue_tail_labels_json). No behaviour change in PR 16; the move places
|
||||
// them under the orchestrator umbrella for amend §C/§F traceability.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a queue-tail-labels JSON string from `\x1f`-joined labels.
|
||||
///
|
||||
/// Only kept here as a re-export so PR 16 callers can find the queue
|
||||
/// helpers under one module path. The implementation continues to live
|
||||
/// in `lib::queue_tail_labels_json` (single source of truth — moving it
|
||||
/// would change the wire format).
|
||||
pub fn collect_tail_labels(joined: &str, max_tail: usize) -> Vec<String> {
|
||||
let collected: VecDeque<&str> = joined
|
||||
.split('\x1f')
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect();
|
||||
let take = collected.len().min(max_tail);
|
||||
let start = collected.len().saturating_sub(take);
|
||||
collected
|
||||
.iter()
|
||||
.skip(start)
|
||||
.map(|s| (*s).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fresh() -> OrchestratorState {
|
||||
OrchestratorState::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bump_returns_strictly_increasing_generation() {
|
||||
let s = fresh();
|
||||
let a = s.bump_connect_generation();
|
||||
let b = s.bump_connect_generation();
|
||||
let c = s.bump_connect_generation();
|
||||
assert!(a < b && b < c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_is_stale_until_caller_observes_their_own_bump() {
|
||||
let s = fresh();
|
||||
let mine = s.bump_connect_generation();
|
||||
assert!(!s.is_connect_token_stale(mine));
|
||||
let _newer = s.bump_connect_generation();
|
||||
assert!(s.is_connect_token_stale(mine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflight_set_and_clear_round_trip() {
|
||||
let s = fresh();
|
||||
let token = s.bump_connect_generation();
|
||||
s.set_connect_inflight(token, "prod");
|
||||
assert_eq!(s.connect_inflight_host().as_deref(), Some("prod"));
|
||||
let cleared = s.clear_connect_inflight_if(token);
|
||||
assert!(cleared);
|
||||
assert!(s.connect_inflight_host().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_with_stale_token_is_a_noop() {
|
||||
let s = fresh();
|
||||
let token = s.bump_connect_generation();
|
||||
s.set_connect_inflight(token, "prod");
|
||||
// A new bump shifts the inflight slot's owner so the old caller
|
||||
// can't accidentally clear it.
|
||||
let newer = s.bump_connect_generation();
|
||||
s.set_connect_inflight(newer, "stage");
|
||||
let cleared = s.clear_connect_inflight_if(token);
|
||||
assert!(!cleared);
|
||||
assert_eq!(s.connect_inflight_host().as_deref(), Some("stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_enter_pauses_and_exit_resumes() {
|
||||
let s = fresh();
|
||||
assert!(!s.lane_is_paused("h"));
|
||||
let d1 = s.enter_interactive_lane("h");
|
||||
assert_eq!(d1, 1);
|
||||
assert!(s.lane_is_paused("h"));
|
||||
let d2 = s.enter_interactive_lane("h");
|
||||
assert_eq!(d2, 2);
|
||||
let d3 = s.exit_interactive_lane("h");
|
||||
assert_eq!(d3, 1);
|
||||
assert!(s.lane_is_paused("h"));
|
||||
let d4 = s.exit_interactive_lane("h");
|
||||
assert_eq!(d4, 0);
|
||||
assert!(!s.lane_is_paused("h"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_exit_below_zero_clamps() {
|
||||
let s = fresh();
|
||||
let d = s.exit_interactive_lane("never_entered");
|
||||
assert_eq!(d, 0);
|
||||
assert!(!s.lane_is_paused("never_entered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lanes_are_per_host() {
|
||||
let s = fresh();
|
||||
s.enter_interactive_lane("a");
|
||||
assert!(s.lane_is_paused("a"));
|
||||
assert!(!s.lane_is_paused("b"));
|
||||
s.enter_interactive_lane("b");
|
||||
assert!(s.lane_is_paused("b"));
|
||||
s.exit_interactive_lane("a");
|
||||
assert!(!s.lane_is_paused("a"));
|
||||
assert!(s.lane_is_paused("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_reflects_current_state() {
|
||||
let s = fresh();
|
||||
let token_a = s.bump_connect_generation();
|
||||
s.set_connect_inflight(token_a, "h");
|
||||
let snap = s.connect_snapshot();
|
||||
assert_eq!(snap.generation, token_a);
|
||||
assert_eq!(snap.inflight_token, token_a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_tail_labels_takes_last_n() {
|
||||
let labels = "a\x1fb\x1fc\x1fd";
|
||||
assert_eq!(
|
||||
collect_tail_labels(labels, 2),
|
||||
vec!["c".to_string(), "d".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_tail_labels_skips_empty_segments() {
|
||||
let labels = "\x1fa\x1f\x1fb\x1f";
|
||||
assert_eq!(
|
||||
collect_tail_labels(labels, 5),
|
||||
vec!["a".to_string(), "b".to_string()],
|
||||
);
|
||||
}
|
||||
}
|
||||
477
rust/crates/sessions_native/src/settings_normalize.rs
Normal file
477
rust/crates/sessions_native/src/settings_normalize.rs
Normal file
@@ -0,0 +1,477 @@
|
||||
//! Settings normalization (Wave 1.5 amend §F — `settings_normalize`).
|
||||
//!
|
||||
//! Python `sublime/sessions/settings_model.py`의 4개 정규화 함수를 흡수.
|
||||
//! 입출력은 JSON string (Python에서 `json.dumps` → Rust 정규화 → `json.loads`).
|
||||
//!
|
||||
//! 책임 위치 (boundary doc §"What stays in Python" + §F 표):
|
||||
//! - 정규화 알고리즘 = Rust (이 모듈).
|
||||
//! - Builtin remote extension catalog = Python (`managed_remote_extension_catalog.py`)
|
||||
//! — Python이 builtin spec list를 직렬화해 `merge_extension_catalog`에 인자로 넘긴다.
|
||||
//! - 사용자 보이는 문자열 = Python (이 모듈은 식별자/구조만 다룬다).
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const ALLOWED_PYTHON_TOOL_STEPS: &[&str] = &["ruff_lint", "pyright_check"];
|
||||
const DEFAULT_PYTHON_TOOL_PIPELINE: &[&str] = &["ruff_lint", "pyright_check"];
|
||||
const ALLOWED_CODE_SERVER_TYPES: &[&str] = &["exec_once", "lsp_stdio"];
|
||||
|
||||
/// Normalize remote python tool pipeline.
|
||||
///
|
||||
/// `raw` is parsed from JSON. Returns a JSON array of allowed step ids,
|
||||
/// preserving first-occurrence order, deduplicated. Falls back to
|
||||
/// the default pipeline when input is invalid.
|
||||
pub fn normalize_python_tool_pipeline(raw: &Value) -> Value {
|
||||
let default = || {
|
||||
Value::Array(
|
||||
DEFAULT_PYTHON_TOOL_PIPELINE
|
||||
.iter()
|
||||
.map(|s| Value::String((*s).to_string()))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let items: Vec<&Value> = match raw {
|
||||
Value::Null => return default(),
|
||||
Value::String(s) => {
|
||||
return normalize_python_tool_pipeline(&Value::Array(vec![Value::String(s.clone())]));
|
||||
}
|
||||
Value::Array(a) => a.iter().collect(),
|
||||
_ => return default(),
|
||||
};
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for item in items {
|
||||
let Some(s) = item.as_str() else { continue };
|
||||
let trimmed = s.trim().to_string();
|
||||
if !ALLOWED_PYTHON_TOOL_STEPS.contains(&trimmed.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if seen.contains(&trimmed) {
|
||||
continue;
|
||||
}
|
||||
seen.push(trimmed.clone());
|
||||
out.push(trimmed);
|
||||
}
|
||||
if out.is_empty() {
|
||||
default()
|
||||
} else {
|
||||
Value::Array(out.into_iter().map(Value::String).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize code-server registry specs.
|
||||
///
|
||||
/// Returns a JSON array of objects with keys: `id`, `server_type`, `argv`,
|
||||
/// `lifecycle`, `match_globs`. Invalid entries are filtered out.
|
||||
pub fn normalize_code_server_specs(raw: &Value) -> Value {
|
||||
let Some(items) = raw.as_array() else {
|
||||
return Value::Array(Vec::new());
|
||||
};
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for item in items {
|
||||
let Some(obj) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(server_id) = obj.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let server_id = server_id.trim();
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(server_type) = obj.get("type").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if !ALLOWED_CODE_SERVER_TYPES.contains(&server_type) {
|
||||
continue;
|
||||
}
|
||||
if seen.iter().any(|s| s == server_id) {
|
||||
continue;
|
||||
}
|
||||
let argv = match obj.get("argv") {
|
||||
Some(Value::Array(items)) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(|v| Value::String(value_to_string(v)))
|
||||
.collect(),
|
||||
),
|
||||
_ => Value::Array(Vec::new()),
|
||||
};
|
||||
let lifecycle = match obj.get("lifecycle") {
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => s.trim().to_string(),
|
||||
_ => "manual".to_string(),
|
||||
};
|
||||
let match_globs = match obj.get("match_globs") {
|
||||
Some(Value::Array(items)) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(|v| Value::String(value_to_string(v)))
|
||||
.collect(),
|
||||
),
|
||||
_ => Value::Array(Vec::new()),
|
||||
};
|
||||
let mut spec = Map::new();
|
||||
spec.insert("id".to_string(), Value::String(server_id.to_string()));
|
||||
spec.insert(
|
||||
"server_type".to_string(),
|
||||
Value::String(server_type.to_string()),
|
||||
);
|
||||
spec.insert("argv".to_string(), argv);
|
||||
spec.insert("lifecycle".to_string(), Value::String(lifecycle));
|
||||
spec.insert("match_globs".to_string(), match_globs);
|
||||
seen.push(server_id.to_string());
|
||||
out.push(Value::Object(spec));
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Normalize remote extension install/remove specs.
|
||||
///
|
||||
/// Returns a JSON array of objects with keys: `id`, `label`, `install_argv`,
|
||||
/// `remove_argv`, `probe_argv`, `cwd` (possibly `null`).
|
||||
pub fn normalize_remote_extension_specs(raw: &Value) -> Value {
|
||||
let Some(items) = raw.as_array() else {
|
||||
return Value::Array(Vec::new());
|
||||
};
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for item in items {
|
||||
let Some(obj) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(server_id) = obj.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let server_id = server_id.trim();
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.iter().any(|s| s == server_id) {
|
||||
continue;
|
||||
}
|
||||
let install_argv = match obj.get("install_argv") {
|
||||
Some(Value::Array(items)) => filter_nonempty_strs(items),
|
||||
_ => continue,
|
||||
};
|
||||
let remove_argv = match obj.get("remove_argv") {
|
||||
Some(Value::Array(items)) => filter_nonempty_strs(items),
|
||||
_ => continue,
|
||||
};
|
||||
if install_argv.is_empty() || remove_argv.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let probe_argv = match obj.get("probe_argv") {
|
||||
Some(Value::Array(items)) => filter_nonempty_strs(items),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let label = match obj.get("label") {
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => s.trim().to_string(),
|
||||
_ => server_id.to_string(),
|
||||
};
|
||||
let cwd = match obj.get("cwd") {
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => Value::String(s.trim().to_string()),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let mut spec = Map::new();
|
||||
spec.insert("id".to_string(), Value::String(server_id.to_string()));
|
||||
spec.insert("label".to_string(), Value::String(label));
|
||||
spec.insert(
|
||||
"install_argv".to_string(),
|
||||
Value::Array(install_argv.into_iter().map(Value::String).collect()),
|
||||
);
|
||||
spec.insert(
|
||||
"remove_argv".to_string(),
|
||||
Value::Array(remove_argv.into_iter().map(Value::String).collect()),
|
||||
);
|
||||
spec.insert(
|
||||
"probe_argv".to_string(),
|
||||
Value::Array(probe_argv.into_iter().map(Value::String).collect()),
|
||||
);
|
||||
spec.insert("cwd".to_string(), cwd);
|
||||
seen.push(server_id.to_string());
|
||||
out.push(Value::Object(spec));
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Merge user-supplied extension specs over a builtin catalog.
|
||||
///
|
||||
/// `builtin_specs` is the Python-supplied builtin catalog (already in
|
||||
/// canonical form — same shape as `normalize_remote_extension_specs` output).
|
||||
/// `user_raw` is the raw user setting; this fn re-normalizes it and merges:
|
||||
///
|
||||
/// - User specs sharing an `id` with a builtin replace that builtin entry
|
||||
/// in-place (preserving builtin order).
|
||||
/// - Additional user-only ids are appended in user-order at the end.
|
||||
fn merge_extension_catalog_inner(builtin_specs: &Value, user_raw: &Value) -> Value {
|
||||
let user_specs = normalize_remote_extension_specs(user_raw);
|
||||
let user_array = match user_specs {
|
||||
Value::Array(a) => a,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let builtin_array = match builtin_specs {
|
||||
Value::Array(a) => a.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let user_ids: Vec<String> = user_array
|
||||
.iter()
|
||||
.filter_map(|v| v.get("id").and_then(Value::as_str).map(str::to_string))
|
||||
.collect();
|
||||
|
||||
let mut by_id: Vec<(String, Value)> = builtin_array
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
v.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|id| (id.to_string(), v.clone()))
|
||||
})
|
||||
.collect();
|
||||
for user_spec in &user_array {
|
||||
let Some(uid) = user_spec.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(slot) = by_id.iter_mut().find(|(id, _)| id == uid) {
|
||||
slot.1 = user_spec.clone();
|
||||
}
|
||||
}
|
||||
let mut ordered: Vec<Value> = by_id.into_iter().map(|(_, v)| v).collect();
|
||||
let builtin_ids: Vec<String> = builtin_array
|
||||
.iter()
|
||||
.filter_map(|v| v.get("id").and_then(Value::as_str).map(str::to_string))
|
||||
.collect();
|
||||
for user_spec in user_array {
|
||||
let Some(uid) = user_spec.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if builtin_ids.iter().any(|b| b == uid) {
|
||||
continue;
|
||||
}
|
||||
if user_ids.iter().filter(|id| id == &uid).count() > 0 {
|
||||
ordered.push(user_spec);
|
||||
}
|
||||
}
|
||||
Value::Array(ordered)
|
||||
}
|
||||
|
||||
pub fn merge_extension_catalog(builtin_specs: &Value, user_raw: &Value) -> Value {
|
||||
merge_extension_catalog_inner(builtin_specs, user_raw)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fn value_to_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => "None".to_string(),
|
||||
Value::Bool(true) => "True".to_string(),
|
||||
Value::Bool(false) => "False".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_nonempty_strs(items: &[Value]) -> Vec<String> {
|
||||
items
|
||||
.iter()
|
||||
.map(value_to_string)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// Test helper — return a borrowed slice of the inner array, or
|
||||
/// `&[]` when the value is not an array. The empty fallback keeps
|
||||
/// us inside the workspace's `unwrap_used = "deny"` lint while
|
||||
/// still letting later asserts produce a clear failure (`arr[0]`
|
||||
/// or `arr.len()` mismatches surface the real bug).
|
||||
fn arr(value: &Value) -> &[Value] {
|
||||
value.as_array().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_default_when_null() {
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&Value::Null),
|
||||
json!(["ruff_lint", "pyright_check"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_dedupes_and_filters() {
|
||||
let raw = json!(["pyright_check", "ruff_lint", "pyright_check", "garbage"]);
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&raw),
|
||||
json!(["pyright_check", "ruff_lint"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_string_becomes_singleton() {
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&json!("ruff_lint")),
|
||||
json!(["ruff_lint"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_garbage_returns_default() {
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&json!({"x": 1})),
|
||||
json!(["ruff_lint", "pyright_check"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_all_invalid_returns_default() {
|
||||
assert_eq!(
|
||||
normalize_python_tool_pipeline(&json!(["unknown", "garbage", 42])),
|
||||
json!(["ruff_lint", "pyright_check"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_server_filters_invalid_entries() {
|
||||
let raw = json!([
|
||||
{"id": "ok", "type": "exec_once"},
|
||||
{"id": "", "type": "exec_once"},
|
||||
{"id": "bad-type", "type": "garbage"},
|
||||
{"id": "ok", "type": "lsp_stdio"}, // dup -> dropped
|
||||
{"type": "exec_once"}, // missing id
|
||||
"not-a-dict",
|
||||
]);
|
||||
let normalized = normalize_code_server_specs(&raw);
|
||||
let items = arr(&normalized);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["id"], "ok");
|
||||
assert_eq!(items[0]["server_type"], "exec_once");
|
||||
assert_eq!(items[0]["lifecycle"], "manual");
|
||||
assert_eq!(items[0]["argv"], json!([]));
|
||||
assert_eq!(items[0]["match_globs"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_server_lifecycle_and_globs_pass_through() {
|
||||
let raw = json!([{
|
||||
"id": "lsp",
|
||||
"type": "lsp_stdio",
|
||||
"argv": ["pyright-langserver", "--stdio"],
|
||||
"lifecycle": "auto",
|
||||
"match_globs": ["*.py", "*.pyi"],
|
||||
}]);
|
||||
let normalized = normalize_code_server_specs(&raw);
|
||||
let items = arr(&normalized);
|
||||
assert_eq!(items[0]["lifecycle"], "auto");
|
||||
assert_eq!(items[0]["argv"], json!(["pyright-langserver", "--stdio"]));
|
||||
assert_eq!(items[0]["match_globs"], json!(["*.py", "*.pyi"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_server_invalid_lifecycle_falls_back_to_manual() {
|
||||
let raw = json!([{
|
||||
"id": "lsp", "type": "lsp_stdio", "lifecycle": " ",
|
||||
}]);
|
||||
let normalized = normalize_code_server_specs(&raw);
|
||||
assert_eq!(arr(&normalized)[0]["lifecycle"], "manual");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_server_argv_non_list_becomes_empty() {
|
||||
let raw = json!([{"id": "x", "type": "exec_once", "argv": "not-a-list"}]);
|
||||
let normalized = normalize_code_server_specs(&raw);
|
||||
assert_eq!(arr(&normalized)[0]["argv"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_specs_filter_invalid() {
|
||||
let raw = json!([
|
||||
{
|
||||
"id": "ok",
|
||||
"install_argv": ["bash", "-lc", "install"],
|
||||
"remove_argv": ["bash", "-lc", "remove"],
|
||||
},
|
||||
{"id": "no-install", "remove_argv": ["x"]},
|
||||
{"id": "no-remove", "install_argv": ["x"]},
|
||||
{"id": "empty-install", "install_argv": [], "remove_argv": ["x"]},
|
||||
"not-dict",
|
||||
]);
|
||||
let normalized = normalize_remote_extension_specs(&raw);
|
||||
let items = arr(&normalized);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["id"], "ok");
|
||||
assert_eq!(items[0]["label"], "ok");
|
||||
assert_eq!(items[0]["probe_argv"], json!([]));
|
||||
assert_eq!(items[0]["cwd"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_specs_label_default_to_id() {
|
||||
let raw = json!([{
|
||||
"id": "x",
|
||||
"install_argv": ["i"], "remove_argv": ["r"],
|
||||
"label": " ", "probe_argv": ["p"], "cwd": "/tmp",
|
||||
}]);
|
||||
let normalized = normalize_remote_extension_specs(&raw);
|
||||
let items = arr(&normalized);
|
||||
assert_eq!(items[0]["label"], "x");
|
||||
assert_eq!(items[0]["probe_argv"], json!(["p"]));
|
||||
assert_eq!(items[0]["cwd"], "/tmp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_uses_builtin_when_user_empty() {
|
||||
let builtin = json!([
|
||||
{"id": "a", "label": "A", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
{"id": "b", "label": "B", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
]);
|
||||
let merged = merge_extension_catalog(&builtin, &Value::Null);
|
||||
assert_eq!(merged, builtin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_user_overrides_by_id_preserving_order() {
|
||||
let builtin = json!([
|
||||
{"id": "a", "label": "A-builtin", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
{"id": "b", "label": "B-builtin", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
]);
|
||||
let user = json!([
|
||||
{"id": "a", "label": "A-user", "install_argv": ["x"], "remove_argv": ["y"]},
|
||||
]);
|
||||
let merged = merge_extension_catalog(&builtin, &user);
|
||||
let items = arr(&merged);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0]["id"], "a");
|
||||
assert_eq!(items[0]["label"], "A-user"); // overridden
|
||||
assert_eq!(items[0]["install_argv"], json!(["x"]));
|
||||
assert_eq!(items[1]["id"], "b"); // builtin kept
|
||||
assert_eq!(items[1]["label"], "B-builtin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_appends_user_only_ids_in_order() {
|
||||
let builtin = json!([
|
||||
{"id": "a", "label": "A", "install_argv": ["i"], "remove_argv": ["r"], "probe_argv": [], "cwd": null},
|
||||
]);
|
||||
let user = json!([
|
||||
{"id": "z", "install_argv": ["z"], "remove_argv": ["z"]},
|
||||
{"id": "a", "install_argv": ["a2"], "remove_argv": ["a2"]},
|
||||
{"id": "y", "install_argv": ["y"], "remove_argv": ["y"]},
|
||||
]);
|
||||
let merged = merge_extension_catalog(&builtin, &user);
|
||||
let items = arr(&merged);
|
||||
let ids: Vec<&str> = items
|
||||
.iter()
|
||||
.map(|v| v["id"].as_str().unwrap_or("<missing>"))
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["a", "z", "y"]);
|
||||
}
|
||||
}
|
||||
@@ -693,6 +693,82 @@ fn broker_open_session_rejects_malformed_extra_env_json() {
|
||||
assert_eq!(rc, -20, "expected BrokerInvalidJson (-20), got {rc}");
|
||||
}
|
||||
|
||||
// ------------------- truncation contract (output-buffer ABI) -------------------
|
||||
//
|
||||
// Python's ctypes caller relies on the "ask, resize, ask" handshake: when the
|
||||
// out buffer is too small, the function must return a *positive* rc equal to
|
||||
// the required size (including NUL). A regression that returns 0 with a
|
||||
// silently truncated buffer, or a negative error code, would corrupt every
|
||||
// Python helper that does the size dance. Each test below feeds an
|
||||
// intentionally undersized buffer to one ABI function and asserts the
|
||||
// positive-required-size invariant.
|
||||
|
||||
#[test]
|
||||
fn bridge_payload_method_label_returns_required_size_when_buffer_too_small() {
|
||||
let payload = CString::new(r#"{"method":"file/read"}"#).unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_bridge_payload_method_label(payload.as_ptr(), tiny.as_mut_ptr(), tiny.len())
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_message_returns_required_size_when_buffer_too_small() {
|
||||
let payload = CString::new(r#"{"error":{"message":"a much longer message"}}"#).unwrap();
|
||||
let fallback = CString::new("fallback").unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_bridge_error_message(
|
||||
payload.as_ptr(),
|
||||
fallback.as_ptr(),
|
||||
tiny.as_mut_ptr(),
|
||||
tiny.len(),
|
||||
)
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_extract_handshake_returns_required_size_when_buffer_too_small() {
|
||||
let payload =
|
||||
CString::new(r#"{"ok":true,"result":{"handshake":{"remote_home":"/r","arch":"x86"}}}"#)
|
||||
.unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_bridge_extract_handshake(payload.as_ptr(), tiny.as_mut_ptr(), tiny.len())
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_parse_response_packet_returns_required_size_when_buffer_too_small() {
|
||||
let payload = CString::new(r#"{"id":"req-a","ok":true,"result":{"entries":[1,2,3]}}"#).unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_bridge_parse_response_packet(payload.as_ptr(), tiny.as_mut_ptr(), tiny.len())
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_cache_key_returns_required_size_when_buffer_too_small() {
|
||||
let host = CString::new("prod").unwrap();
|
||||
let root = CString::new("/srv/app").unwrap();
|
||||
let profile = CString::new("python").unwrap();
|
||||
let mut tiny = [0i8; 1];
|
||||
let rc = unsafe {
|
||||
sessions_workspace_cache_key(
|
||||
host.as_ptr(),
|
||||
root.as_ptr(),
|
||||
profile.as_ptr(),
|
||||
tiny.as_mut_ptr(),
|
||||
tiny.len(),
|
||||
)
|
||||
};
|
||||
assert!(rc > 0, "expected positive required size, got {rc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_open_session_null_host_returns_null_pointer_code() {
|
||||
let bridge = CString::new("/bin/true").unwrap();
|
||||
|
||||
@@ -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())
|
||||
113
scripts/duplication_deadline.py
Executable file
113
scripts/duplication_deadline.py
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Duplication deadline enforcement (Layer 1/2).
|
||||
|
||||
main HEAD에 남은 TEMP_DUPLICATION_UNTIL 마커를 grep하고, 현재 버전과
|
||||
비교해 만료된 마커가 있으면 fail. release 차단 가드.
|
||||
|
||||
마커 형식 (예시; ``vX.Y.Z`` 자리는 실제 버전):
|
||||
# TEMP_DUPLICATION_UNTIL = vX.Y.Z
|
||||
# DELETION_PR = #NNN
|
||||
|
||||
위치: 주석/docstring/PR description 어디든 가능. 본 스크립트는 *코드 트리*만
|
||||
검사한다 (planning/, .gitea/, scripts/, sublime/, rust/, tests/).
|
||||
|
||||
normative 출처: planning/PYTHON_RUST_BOUNDARY.md "Single source of truth" +
|
||||
planning/PYTHON_THINNING_PLAN.md §4.4 (3-layer 데드라인).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
try:
|
||||
import tomllib # type: ignore[import-not-found] # Python 3.11+ stdlib
|
||||
except ModuleNotFoundError: # pragma: no cover - dev environments only
|
||||
import tomli as tomllib # type: ignore[no-redef,import-not-found]
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCAN_DIRS = ("planning", ".gitea", "scripts", "sublime", "rust", "tests")
|
||||
SCAN_EXTENSIONS = {".py", ".rs", ".md", ".yml", ".yaml", ".toml"}
|
||||
|
||||
MARKER_RE = re.compile(
|
||||
r"TEMP_DUPLICATION_UNTIL\s*=\s*v?(?P<version>\d+\.\d+\.\d+)",
|
||||
)
|
||||
|
||||
|
||||
def _current_version() -> Tuple[int, int, int]:
|
||||
pyproject = REPO_ROOT / "pyproject.toml"
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
raw = data.get("project", {}).get("version") or data.get("tool", {}).get(
|
||||
"poetry", {}
|
||||
).get("version")
|
||||
if raw is None:
|
||||
raise SystemExit("pyproject.toml에서 version을 찾지 못함")
|
||||
parts = raw.lstrip("v").split(".")
|
||||
if len(parts) != 3 or not all(p.isdigit() for p in parts):
|
||||
raise SystemExit(f"비표준 버전: {raw!r}")
|
||||
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
|
||||
|
||||
def _scan() -> List[Tuple[Path, int, str, Tuple[int, int, int]]]:
|
||||
findings: List[Tuple[Path, int, str, Tuple[int, int, int]]] = []
|
||||
for top in SCAN_DIRS:
|
||||
root = REPO_ROOT / top
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.suffix not in SCAN_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
for n, line in enumerate(text.splitlines(), 1):
|
||||
m = MARKER_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
v = m.group("version").split(".")
|
||||
version = (int(v[0]), int(v[1]), int(v[2]))
|
||||
findings.append(
|
||||
(path.relative_to(REPO_ROOT), n, line.strip(), version),
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
current = _current_version()
|
||||
findings = _scan()
|
||||
expired: List[Tuple[Path, int, str, Tuple[int, int, int]]] = []
|
||||
for entry in findings:
|
||||
deadline = entry[3]
|
||||
if deadline <= current:
|
||||
expired.append(entry)
|
||||
|
||||
if not findings:
|
||||
print("duplication-deadline: 마커 없음 — pass")
|
||||
return 0
|
||||
|
||||
cur_str = "{}.{}.{}".format(*current)
|
||||
print(f"duplication-deadline: 현재 v{cur_str}")
|
||||
for path, line_no, content, deadline in findings:
|
||||
deadline_str = "{}.{}.{}".format(*deadline)
|
||||
status = "EXPIRED" if (path, line_no, content, deadline) in expired else "ok"
|
||||
print(f" [{status}] {path}:{line_no} TEMP_DUPLICATION_UNTIL=v{deadline_str}")
|
||||
|
||||
if expired:
|
||||
print(
|
||||
f"\n{len(expired)}건 데드라인 만료. "
|
||||
f"해당 이중 구현은 v{cur_str} 이전에 삭제됐어야 함. "
|
||||
"release 차단.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
439
scripts/lint_python_thinning.py
Executable file
439
scripts/lint_python_thinning.py
Executable file
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Boundary lint — Python thinning ban-list checker.
|
||||
|
||||
Wave 1.5 거버넌스 가드. PR/push diff에서 *추가된 라인*만 검사하므로
|
||||
기존 코드의 grandfather 처리가 자동으로 된다.
|
||||
|
||||
Usage:
|
||||
scripts/lint_python_thinning.py [--base-ref REF] [--lint LINT [LINT ...]]
|
||||
scripts/lint_python_thinning.py --pr-body PATH # Lint #6 only
|
||||
|
||||
활성 룰 (PR 0):
|
||||
- #1 helper response parser 시그니처 ban (Python 측)
|
||||
- #2.5 Track H2 retry/timeout 분산 ban (commands_*.py)
|
||||
- #4 Rust ABI 영문 자연어 ban (Rust 측)
|
||||
- #6 PR boundary-claim 헤더 검증
|
||||
|
||||
활성 룰 (PR 2):
|
||||
- #3 Python python3 -c SSH 폴백 ban (sublime/sessions/, askpass 예외)
|
||||
|
||||
활성 룰 (PR 16c):
|
||||
- #2 commands_*.py 신규 deque task queue ban (기존 _BACKGROUND_TASK_QUEUE,
|
||||
_MIRROR_TASK_QUEUE는 grandfather; callable dispatch는 Sublime UI
|
||||
thread 잔존 — rust-pragmatist 양보 영역).
|
||||
|
||||
후속 활성화 룰:
|
||||
- #5 boundary inventory metasync (Wave 2.5에서 자동화)
|
||||
|
||||
normative 출처: planning/PYTHON_RUST_BOUNDARY.md (Wave 1.5 amend),
|
||||
planning/PYTHON_THINNING_PLAN.md §4.3.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 규칙 정의
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Lint #1 — Helper response parser 시그니처 ban (Python 측 sublime/sessions/)
|
||||
# `_rust_ffi/`(또는 `_rust_ffi.py`)의 thin ctypes wrapper만 예외.
|
||||
LINT_1_PARSER_SIGNATURES = re.compile(
|
||||
r"^\s*def\s+_?(parse_ruff|parse_pyright|parse_diagnostic|"
|
||||
r"parse_open_outcome|parse_request_outcome|parse_response_packet|"
|
||||
r"extract_handshake|payload_method_label)\b",
|
||||
)
|
||||
LINT_1_PATH_PATTERN = re.compile(r"^sublime/sessions/")
|
||||
LINT_1_EXEMPT_PATH_PATTERN = re.compile(r"^sublime/sessions/_rust_ffi(/|\.py$)")
|
||||
|
||||
# Lint #2 — commands_*.py 신규 deque/Event task queue 신설 ban (PR 16c).
|
||||
# commands.py 본체의 _BACKGROUND_TASK_QUEUE/_MIRROR_TASK_QUEUE는 grandfather
|
||||
# (callable dispatch는 Sublime UI thread 잔존). Track H2 분리 모듈에서 새 큐가
|
||||
# 생기면 fail.
|
||||
LINT_2_QUEUE_PATTERNS = [
|
||||
re.compile(r"^_[A-Z_]*_TASK_QUEUE\s*=\s*deque\("),
|
||||
re.compile(r"^_[A-Z_]*_TASK_EVENT\s*=\s*threading\.Event\("),
|
||||
]
|
||||
LINT_2_PATH_PATTERN = re.compile(r"^sublime/sessions/commands_[^/]+\.py$")
|
||||
|
||||
|
||||
# Lint #2.5 — Track H2 retry/timeout 분산 ban
|
||||
# commands_*.py 분리 모듈에서 retry/timeout 원시 직접 사용 금지.
|
||||
# (commands.py 본체는 이미 이런 코드를 보유 — diff 기반이라 자동 grandfather.)
|
||||
LINT_2_5_RETRY_PATTERNS = [
|
||||
re.compile(r"\btime\.monotonic\s*\("),
|
||||
re.compile(r"\brequests\.exceptions\b"),
|
||||
re.compile(r"\btenacity\b"),
|
||||
re.compile(r"\bfor\s+\w+\s+in\s+range\s*\(\s*\w*retries?\b"),
|
||||
re.compile(r"\bbackoff\.\w+"),
|
||||
]
|
||||
LINT_2_5_PATH_PATTERN = re.compile(r"^sublime/sessions/commands_[^/]+\.py$")
|
||||
|
||||
# Lint #3 — Python `python3 -c` 원격 폴백 ban (boundary §17–19 Wave 1 closure)
|
||||
# 원격에서 실행될 명령에 `python3 -c` literal이 새로 추가되는 것을 차단.
|
||||
# 진짜 ban 의도: ssh 인자 또는 helper exec_once payload 안의 `python3 -c`.
|
||||
# Diff 모드라 grandfather 자동: ssh_runner.py 로컬 askpass + marimo port pick은
|
||||
# 기존 코드라 통과; 새 PR이 같은 패턴을 추가하면 fail.
|
||||
LINT_3_REMOTE_PYTHON_C = [
|
||||
re.compile(r'["\']\s*python3\s+-c\s'),
|
||||
re.compile(r'["\']\s*python3["\']\s*,\s*["\']-c["\']'),
|
||||
]
|
||||
LINT_3_PATH_PATTERN = re.compile(r"^sublime/sessions/")
|
||||
# askpass 모듈은 *로컬* python3 -c (Tk GUI dialog) 용도라 예외.
|
||||
LINT_3_EXEMPT_PATH_PATTERN = re.compile(r"^sublime/sessions/(ssh_runner\.py)$")
|
||||
|
||||
# Lint #4 — Rust ABI 영문 자연어 ban (Rust 측 sessions_native ABI 함수)
|
||||
# 식별자 코드만 반환해야 함. ABI 응답에 영문 자연어 문장(공백 + 3+ 어휘) 포함 금지.
|
||||
# 휴리스틱: ABI 함수 본문 string literal "Word word word..." 패턴 grep.
|
||||
LINT_4_NATURAL_LANGUAGE = re.compile(r'"[A-Z][a-z]+(?:\s+[a-z]+){2,}[\.,!?]?"')
|
||||
LINT_4_PATH_PATTERN = re.compile(r"^rust/crates/sessions_native/src/")
|
||||
|
||||
# Lint #6 — PR boundary-claim 헤더 검증
|
||||
# PR description에 다음 블록이 있어야 함:
|
||||
# boundary-claim:
|
||||
# removes: <list>
|
||||
# delete-count: <int>
|
||||
# ban-list: <list>
|
||||
LINT_6_BOUNDARY_CLAIM = re.compile(
|
||||
r"^boundary-claim:\s*$\s*"
|
||||
r"(?:^\s+removes:\s*.*?\s*$\s*)?"
|
||||
r"(?:^\s+delete-count:\s*\d+\s*$\s*)?"
|
||||
r"(?:^\s+ban-list:\s*.*?\s*$\s*)?",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diff 추출
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _git(args: List[str]) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _resolve_base_ref(explicit: Optional[str]) -> Optional[str]:
|
||||
if explicit:
|
||||
return explicit
|
||||
env_base = os.environ.get("LINT_THINNING_BASE_REF")
|
||||
if env_base:
|
||||
return env_base
|
||||
if os.environ.get("CI"):
|
||||
merge_base = _git(["merge-base", "HEAD", "origin/main"]).strip()
|
||||
if merge_base:
|
||||
return merge_base
|
||||
return None
|
||||
|
||||
|
||||
def _added_lines(base_ref: Optional[str]) -> List[Tuple[Path, int, str]]:
|
||||
"""Return (path, line_no_in_new_file, content) for every line added vs base.
|
||||
|
||||
base_ref None이면 working tree 전체를 검사한다 (PR 0 활성화 시 sanity).
|
||||
"""
|
||||
if base_ref is None:
|
||||
# 전수 검사 — grandfather 없음. PR 0에서는 호출하지 않는 게 정상.
|
||||
results: List[Tuple[Path, int, str]] = []
|
||||
for py in sorted(REPO_ROOT.glob("sublime/**/*.py")):
|
||||
rel = py.relative_to(REPO_ROOT)
|
||||
for n, line in enumerate(py.read_text(encoding="utf-8").splitlines(), 1):
|
||||
results.append((rel, n, line))
|
||||
for rs in sorted(REPO_ROOT.glob("rust/crates/**/*.rs")):
|
||||
rel = rs.relative_to(REPO_ROOT)
|
||||
for n, line in enumerate(rs.read_text(encoding="utf-8").splitlines(), 1):
|
||||
results.append((rel, n, line))
|
||||
return results
|
||||
|
||||
raw = _git(["diff", "--unified=0", base_ref, "--", "sublime/", "rust/crates/"])
|
||||
added: List[Tuple[Path, int, str]] = []
|
||||
current_path: Optional[Path] = None
|
||||
new_line_no = 0
|
||||
for line in raw.splitlines():
|
||||
if line.startswith("+++ b/"):
|
||||
current_path = Path(line[len("+++ b/") :])
|
||||
continue
|
||||
if line.startswith("@@"):
|
||||
m = re.search(r"\+(\d+)", line)
|
||||
new_line_no = int(m.group(1)) - 1 if m else 0
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++") and current_path:
|
||||
new_line_no += 1
|
||||
added.append((current_path, new_line_no, line[1:]))
|
||||
elif not line.startswith("-") and current_path:
|
||||
new_line_no += 1
|
||||
return added
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lint 실행
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Violation:
|
||||
__slots__ = ("lint_id", "path", "line_no", "content", "reason")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lint_id: str,
|
||||
path: Path,
|
||||
line_no: int,
|
||||
content: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
self.lint_id = lint_id
|
||||
self.path = path
|
||||
self.line_no = line_no
|
||||
self.content = content
|
||||
self.reason = reason
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"[{self.lint_id}] {self.path}:{self.line_no}: {self.reason}\n"
|
||||
f" {self.content.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def _check_lint_1(added: Iterable[Tuple[Path, int, str]]) -> List[Violation]:
|
||||
violations: List[Violation] = []
|
||||
for path, line_no, content in added:
|
||||
rel = str(path).replace("\\", "/")
|
||||
if not LINT_1_PATH_PATTERN.match(rel):
|
||||
continue
|
||||
if LINT_1_EXEMPT_PATH_PATTERN.match(rel):
|
||||
continue
|
||||
if LINT_1_PARSER_SIGNATURES.match(content):
|
||||
violations.append(
|
||||
Violation(
|
||||
lint_id="#1",
|
||||
path=path,
|
||||
line_no=line_no,
|
||||
content=content,
|
||||
reason=(
|
||||
"helper response parser 시그니처 신규 금지 — "
|
||||
"Rust ABI 호출 + typed wrapper 1단계만 허용"
|
||||
),
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _check_lint_2(added: Iterable[Tuple[Path, int, str]]) -> List[Violation]:
|
||||
violations: List[Violation] = []
|
||||
for path, line_no, content in added:
|
||||
rel = str(path).replace("\\", "/")
|
||||
if not LINT_2_PATH_PATTERN.match(rel):
|
||||
continue
|
||||
for pattern in LINT_2_QUEUE_PATTERNS:
|
||||
if pattern.search(content.lstrip()):
|
||||
violations.append(
|
||||
Violation(
|
||||
lint_id="#2",
|
||||
path=path,
|
||||
line_no=line_no,
|
||||
content=content,
|
||||
reason=(
|
||||
"Track H2 분리 모듈에 새 deque/Event task queue 금지 "
|
||||
"— 큐 state는 sessions_native::orchestrator"
|
||||
),
|
||||
)
|
||||
)
|
||||
break
|
||||
return violations
|
||||
|
||||
|
||||
def _check_lint_2_5(added: Iterable[Tuple[Path, int, str]]) -> List[Violation]:
|
||||
violations: List[Violation] = []
|
||||
for path, line_no, content in added:
|
||||
rel = str(path).replace("\\", "/")
|
||||
if not LINT_2_5_PATH_PATTERN.match(rel):
|
||||
continue
|
||||
for pattern in LINT_2_5_RETRY_PATTERNS:
|
||||
if pattern.search(content):
|
||||
violations.append(
|
||||
Violation(
|
||||
lint_id="#2.5",
|
||||
path=path,
|
||||
line_no=line_no,
|
||||
content=content,
|
||||
reason=(
|
||||
"Track H2 분리 모듈에서 retry/timeout 원시 직접 사용 금지 "
|
||||
"— _rust_ffi/bridge 호출 표면에 응집"
|
||||
),
|
||||
)
|
||||
)
|
||||
break
|
||||
return violations
|
||||
|
||||
|
||||
def _check_lint_3(added: Iterable[Tuple[Path, int, str]]) -> List[Violation]:
|
||||
violations: List[Violation] = []
|
||||
for path, line_no, content in added:
|
||||
rel = str(path).replace("\\", "/")
|
||||
if not LINT_3_PATH_PATTERN.match(rel):
|
||||
continue
|
||||
if LINT_3_EXEMPT_PATH_PATTERN.match(rel):
|
||||
continue
|
||||
for pattern in LINT_3_REMOTE_PYTHON_C:
|
||||
if pattern.search(content):
|
||||
violations.append(
|
||||
Violation(
|
||||
lint_id="#3",
|
||||
path=path,
|
||||
line_no=line_no,
|
||||
content=content,
|
||||
reason=(
|
||||
"원격 명령에 `python3 -c` 폴백 신규 금지 "
|
||||
"(boundary §17–19) — helper 채널 사용 필요"
|
||||
),
|
||||
)
|
||||
)
|
||||
break
|
||||
return violations
|
||||
|
||||
|
||||
def _check_lint_4(added: Iterable[Tuple[Path, int, str]]) -> List[Violation]:
|
||||
violations: List[Violation] = []
|
||||
for path, line_no, content in added:
|
||||
rel = str(path).replace("\\", "/")
|
||||
if not LINT_4_PATH_PATTERN.match(rel):
|
||||
continue
|
||||
if LINT_4_NATURAL_LANGUAGE.search(content):
|
||||
violations.append(
|
||||
Violation(
|
||||
lint_id="#4",
|
||||
path=path,
|
||||
line_no=line_no,
|
||||
content=content,
|
||||
reason=(
|
||||
"Rust ABI에 영문 자연어 문장 금지 — "
|
||||
"식별자 코드(int, kebab-case)만 반환"
|
||||
),
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _check_lint_6_pr_body(pr_body_path: Path) -> List[Violation]:
|
||||
if not pr_body_path.exists():
|
||||
return [
|
||||
Violation(
|
||||
lint_id="#6",
|
||||
path=pr_body_path,
|
||||
line_no=0,
|
||||
content="",
|
||||
reason=f"PR description 파일 없음: {pr_body_path}",
|
||||
)
|
||||
]
|
||||
body = pr_body_path.read_text(encoding="utf-8")
|
||||
if not LINT_6_BOUNDARY_CLAIM.search(body):
|
||||
return [
|
||||
Violation(
|
||||
lint_id="#6",
|
||||
path=pr_body_path,
|
||||
line_no=0,
|
||||
content="(PR description)",
|
||||
reason=(
|
||||
"PR description에 boundary-claim 블록이 필요함:\n"
|
||||
" boundary-claim:\n"
|
||||
" removes: <list of file:line ranges>\n"
|
||||
" delete-count: <int>\n"
|
||||
" ban-list: <activated lints, optional>\n"
|
||||
),
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
ALL_LINTS = ("1", "2", "2.5", "3", "4", "6")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--base-ref",
|
||||
default=None,
|
||||
help="diff base; CI에서는 자동으로 origin/main과의 merge-base 사용",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lint",
|
||||
action="append",
|
||||
default=None,
|
||||
choices=ALL_LINTS,
|
||||
help="실행할 룰 (반복 가능, 기본은 활성 룰 전체)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-body",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Lint #6: PR description 파일 경로",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all-files",
|
||||
action="store_true",
|
||||
help="diff 대신 전체 파일 검사 (PR 0 sanity 용도, grandfather 없음)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
selected = set(args.lint) if args.lint else set(ALL_LINTS)
|
||||
violations: List[Violation] = []
|
||||
|
||||
if {"1", "2", "2.5", "3", "4"} & selected:
|
||||
base_ref = None if args.all_files else _resolve_base_ref(args.base_ref)
|
||||
added = _added_lines(base_ref)
|
||||
if "1" in selected:
|
||||
violations.extend(_check_lint_1(added))
|
||||
if "2" in selected:
|
||||
violations.extend(_check_lint_2(added))
|
||||
if "2.5" in selected:
|
||||
violations.extend(_check_lint_2_5(added))
|
||||
if "3" in selected:
|
||||
violations.extend(_check_lint_3(added))
|
||||
if "4" in selected:
|
||||
violations.extend(_check_lint_4(added))
|
||||
|
||||
if "6" in selected:
|
||||
pr_body = args.pr_body
|
||||
if pr_body is None:
|
||||
env_path = os.environ.get("LINT_THINNING_PR_BODY")
|
||||
if env_path:
|
||||
pr_body = Path(env_path)
|
||||
if pr_body is not None:
|
||||
violations.extend(_check_lint_6_pr_body(pr_body))
|
||||
|
||||
if violations:
|
||||
print("Boundary lint (Wave 1.5) — 위반 발견:", file=sys.stderr)
|
||||
for v in violations:
|
||||
print(str(v), file=sys.stderr)
|
||||
print(
|
||||
f"\n{len(violations)}건 위반. "
|
||||
"boundary 문서: planning/PYTHON_RUST_BOUNDARY.md",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
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": 259,
|
||||
"min_real_subprocess": 55,
|
||||
"min_contract_fixture": 27,
|
||||
"min_adversarial": 143,
|
||||
"max_mock_only_ratio": 0.82
|
||||
"min_adversarial": 177,
|
||||
"max_mock_only_ratio": 0.92
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -19,44 +19,56 @@
|
||||
"caption": "Sessions: Open Remote Folder",
|
||||
"command": "sessions_open_remote_folder"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Open Remote Tree",
|
||||
"command": "sessions_open_remote_tree"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Refresh Remote Workspace",
|
||||
"command": "sessions_remote_tree_refresh"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Open Remote File",
|
||||
"command": "sessions_open_remote_file"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Delete Remote File",
|
||||
"command": "sessions_delete_remote_file"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Open Remote Terminal",
|
||||
"command": "sessions_open_remote_terminal"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Preview Remote Agent Payload",
|
||||
"command": "sessions_preview_remote_agent_payload"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Reconnect Current Workspace",
|
||||
"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: Diagnose LSP Workspace",
|
||||
"command": "sessions_diagnose_lsp_workspace"
|
||||
"caption": "Sessions: Open Remote Marimo",
|
||||
"command": "sessions_open_remote_marimo"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Stop Remote Marimo",
|
||||
"command": "sessions_stop_remote_marimo"
|
||||
},
|
||||
{
|
||||
"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: Expand Deferred Directory",
|
||||
"command": "sessions_expand_deferred_directory"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -34,8 +34,24 @@
|
||||
"sessions_debug_trace_enabled": false,
|
||||
|
||||
// Maximum directory depth when mirroring the remote tree into the local cache
|
||||
// (1 = immediate children only; higher = deeper BFS).
|
||||
"sessions_mirror_max_traversal_depth": 12,
|
||||
// (1 = immediate children only; higher = deeper BFS). Default 5 keeps the
|
||||
// auto-deepen pass under the mirror-sync timeout on slow tunnels (e.g. AWS
|
||||
// SSM); deeper levels can still be reached via "Sessions: Expand Deferred
|
||||
// Directory" on demand.
|
||||
"sessions_mirror_max_traversal_depth": 5,
|
||||
|
||||
// Mirror-sync request timeout in seconds. Deep walks over slow tunnels
|
||||
// (AWS SSM, mobile tether) routinely run 30-50 s, so this is set higher
|
||||
// than the generic Rust bridge request timeout. Lower it on fast LANs if
|
||||
// you'd rather see a fast failure than wait the full budget.
|
||||
"sessions_mirror_sync_timeout_s": 90,
|
||||
|
||||
// Per-method timeouts for the remaining bridge calls. Bump these on slow
|
||||
// tunnels if you see ``bridge.request_timeout`` for the matching method
|
||||
// in the trace log; defaults match the previous hard-coded values.
|
||||
"sessions_file_read_timeout_s": 30,
|
||||
"sessions_file_stat_timeout_s": 30,
|
||||
"sessions_helper_handshake_timeout_s": 60,
|
||||
|
||||
// Caps traversal depth for the initial shallow auto mirrors (auto_open_folder,
|
||||
// periodic auto_refresh, and the "auto" command source). The scheduled deep pass
|
||||
@@ -44,7 +60,49 @@
|
||||
"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,
|
||||
|
||||
// Product-level sync mode. One knob that maps to safe / balanced / full
|
||||
// defaults for the most user-visible bandwidth and write-volume controls.
|
||||
//
|
||||
// "safe" — quiet first connect for EDR-managed or shared machines:
|
||||
// forces ``sessions_mirror_auto_refresh``,
|
||||
// ``sessions_mirror_include_files``, and
|
||||
// ``sessions_connect_auto_open_remote_folder`` to ``false``
|
||||
// regardless of their per-key value.
|
||||
// "balanced" — the historical default; per-key settings below take effect
|
||||
// unchanged. Recommended for most desktop use.
|
||||
// "full" — same as ``balanced`` today; reserved for future "more
|
||||
// aggressive" defaults (extra hydrate, eager prune, etc).
|
||||
//
|
||||
// Per-key settings below remain authoritative under balanced/full.
|
||||
// See ``SECURITY.md`` § "Sync mode" for the rationale.
|
||||
"sessions_sync_mode": "balanced",
|
||||
|
||||
// Run periodic background mirror refresh once a workspace is opened.
|
||||
"sessions_mirror_auto_refresh": true,
|
||||
@@ -65,14 +123,38 @@
|
||||
// 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.
|
||||
//
|
||||
// The following are always ignored (MIRROR_BUILTIN_IGNORE_PATTERNS):
|
||||
// .git, node_modules, __pycache__, .venv, target, .uv-python,
|
||||
// node_modules, __pycache__, .venv, target, .uv-python,
|
||||
// .pytest_cache, .ruff_cache, .pre-commit-cache, .mypy_cache, .tox, .nox
|
||||
// This setting adds to that list.
|
||||
"sessions_mirror_ignore_patterns": [".git", "**/*.sublime-commands"],
|
||||
// ``.git`` was removed from the builtin list in v0.7.x so Track G's
|
||||
// ``Sessions: Refresh Git State`` can mirror the workspace's git
|
||||
// metadata. Adding it back here would silently break Sublime Merge
|
||||
// integration; keep this list focused on byproducts you don't want
|
||||
// mirrored.
|
||||
"sessions_mirror_ignore_patterns": ["**/*.sublime-commands"],
|
||||
|
||||
|
||||
// After host connect, open a new window and immediately launch Open Remote Folder.
|
||||
@@ -85,14 +167,26 @@
|
||||
"sessions_remote_terminal_shell": "bash -il",
|
||||
|
||||
// After saving a mirrored workspace .py file, run the remote diagnostics pipeline
|
||||
// (ruff + pyright by default). See planning/REMOTE_DEV_MVP_LSP.md.
|
||||
// (ruff + pyright by default).
|
||||
//
|
||||
// Three keys in this group — ``sessions_remote_python_auto_diagnostics_on_save``,
|
||||
// ``sessions_remote_python_auto_diagnostics_on_open``, and
|
||||
// ``sessions_remote_python_tool_pipeline`` — follow LSP-style precedence:
|
||||
// package default → ``Packages/User/Sessions.sublime-settings`` → the
|
||||
// ``.sublime-project`` ``"settings"`` block (per-workspace override). Drop
|
||||
// ``"sessions_remote_python_auto_diagnostics_on_save": true`` into a
|
||||
// workspace's ``.sublime-project`` to enable on-save lint/typecheck just for
|
||||
// that project without flipping the global default.
|
||||
"sessions_remote_python_auto_diagnostics_on_save": false,
|
||||
|
||||
// When true, run the same pipeline when a .py buffer under the cache is focused
|
||||
// (debounced ~1.5s per view).
|
||||
// (debounced ~1.5s per view). Same project-level override semantics as
|
||||
// ``sessions_remote_python_auto_diagnostics_on_save``.
|
||||
"sessions_remote_python_auto_diagnostics_on_open": false,
|
||||
|
||||
// Ordered steps: "ruff_lint", "pyright_check" (each runs on the remote host).
|
||||
// Per-project override allowed via the ``.sublime-project`` ``"settings"``
|
||||
// block (LSP-style precedence).
|
||||
"sessions_remote_python_tool_pipeline": ["ruff_lint", "pyright_check"],
|
||||
|
||||
// Phase 6.3 channel-based code-server registry. New servers should be added here
|
||||
@@ -132,7 +226,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 +244,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": []
|
||||
}
|
||||
|
||||
10
sublime/Side Bar.sublime-menu
Normal file
10
sublime/Side Bar.sublime-menu
Normal file
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"caption": "Sessions: Expand this folder",
|
||||
"command": "sessions_expand_deferred_directory"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Delete Remote File",
|
||||
"command": "sessions_delete_remote_file"
|
||||
}
|
||||
]
|
||||
@@ -2,58 +2,68 @@
|
||||
|
||||
from .sessions.commands import (
|
||||
SessionsBridgeLifecycleListener,
|
||||
SessionsClearPythonInterpreterCommand,
|
||||
SessionsConnectRemoteWorkspaceCommand,
|
||||
SessionsDiagnoseLspWorkspaceCommand,
|
||||
SessionsInstallRemoteLspServerCommand,
|
||||
SessionsDeleteRemoteFileCommand,
|
||||
SessionsExpandDeferredDirectoryCommand,
|
||||
SessionsInstallRemoteExtensionCommand,
|
||||
SessionsLspNavigationListener,
|
||||
SessionsOnDemandFetchListener,
|
||||
SessionsOpenLocalSshConfigCommand,
|
||||
SessionsOpenRecentRemoteWorkspaceCommand,
|
||||
SessionsOpenRemoteFileCommand,
|
||||
SessionsOpenRemoteFolderCommand,
|
||||
SessionsOpenRemoteMarimoCommand,
|
||||
SessionsOpenRemoteTerminalCommand,
|
||||
SessionsOpenRemoteTreeCommand,
|
||||
SessionsOpenSettingsCommand,
|
||||
SessionsPreviewRemoteAgentPayloadCommand,
|
||||
SessionsPythonInterpreterStatusListener,
|
||||
SessionsReconnectCurrentWorkspaceCommand,
|
||||
SessionsRemoteCachedFileSaveListener,
|
||||
SessionsRemoteLspServerStatusCommand,
|
||||
SessionsRemoteExtensionStatusCommand,
|
||||
SessionsRemoteTreeActivateCommand,
|
||||
SessionsRemoteTreeEventListener,
|
||||
SessionsRemoteTreeRefreshCommand,
|
||||
SessionsRemoveRemoteLspServerCommand,
|
||||
SessionsRemoveRemoteExtensionCommand,
|
||||
SessionsSelectPythonInterpreterCommand,
|
||||
SessionsSetupRemoteDebuggingCommand,
|
||||
SessionsSidebarPlaceholderHydrateListener,
|
||||
SessionsStopRemoteMarimoCommand,
|
||||
SessionsSyncRemoteTreeToSidebarCommand,
|
||||
SessionsWorkspaceActivationListener,
|
||||
register_sessions_transport_hooks,
|
||||
)
|
||||
from .sessions.terminal_link_click import SessionsTerminalLinkClickListener
|
||||
|
||||
__all__ = [
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
"SessionsDiagnoseLspWorkspaceCommand",
|
||||
"SessionsInstallRemoteLspServerCommand",
|
||||
"SessionsDeleteRemoteFileCommand",
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
"SessionsOpenRemoteMarimoCommand",
|
||||
"SessionsOpenRemoteTerminalCommand",
|
||||
"SessionsOpenRemoteTreeCommand",
|
||||
"SessionsOpenSettingsCommand",
|
||||
"SessionsPreviewRemoteAgentPayloadCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsPythonInterpreterStatusListener",
|
||||
"SessionsReconnectCurrentWorkspaceCommand",
|
||||
"SessionsRemoteCachedFileSaveListener",
|
||||
"SessionsRemoteLspServerStatusCommand",
|
||||
"SessionsRemoteExtensionStatusCommand",
|
||||
"SessionsRemoteTreeActivateCommand",
|
||||
"SessionsRemoteTreeEventListener",
|
||||
"SessionsRemoteTreeRefreshCommand",
|
||||
"SessionsRemoveRemoteLspServerCommand",
|
||||
"SessionsRemoveRemoteExtensionCommand",
|
||||
"SessionsSelectPythonInterpreterCommand",
|
||||
"SessionsSetupRemoteDebuggingCommand",
|
||||
"SessionsSidebarPlaceholderHydrateListener",
|
||||
"SessionsStopRemoteMarimoCommand",
|
||||
"SessionsSyncRemoteTreeToSidebarCommand",
|
||||
"SessionsTerminalLinkClickListener",
|
||||
"SessionsWorkspaceActivationListener",
|
||||
]
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
183
sublime/sessions/_rust_ffi/__init__.py
Normal file
183
sublime/sessions/_rust_ffi/__init__.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Python ctypes bindings for the ``sessions_native`` shared library.
|
||||
|
||||
Wave 1.5 amend §F: 1337 LOC 단일 모듈이 thin shim 정량 정의(≤400 LOC)를
|
||||
위반해서 6 모듈로 split. 호출자 코드는 ``from ._rust_ffi import X``를
|
||||
유지하므로 변경 없음. 각 모듈은 단일 책임:
|
||||
|
||||
- ``_loader``: ``SessionsNativeLibraryError`` / ``AbiError`` /
|
||||
``call_string_abi`` / ``_bind_abi_symbol`` / ``_call_json_returning_abi`` /
|
||||
cdylib discovery + load.
|
||||
- ``_workspace``: ``normalize_remote_root`` / ``workspace_cache_key``.
|
||||
- ``_file_policy``: ``open_guard_reason_code`` / ``is_likely_binary`` /
|
||||
reload·save 결정 / 경로 매퍼 4종.
|
||||
- ``_tool_runtime``: ``parse_ruff_diagnostics`` + Wave 1.5 settings normalize
|
||||
(PR 1).
|
||||
- ``_bridge_parsers``: bridge envelope 파싱 9종 + 큐 라벨 helper 3종.
|
||||
- ``_broker``: 세션 broker (open / request / reset / shutdown / handshake /
|
||||
stderr_tail) + outcome dataclasses.
|
||||
|
||||
새 함수 추가 시 적절한 모듈에 land + 본 ``__init__``의 ``__all__`` 갱신.
|
||||
디코더 본체(``_parse_*_outcome``) Rust 이관은 PR 17+에서 진행 (rust-max
|
||||
양보 영역).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# os/sys are re-exported into the package namespace so existing tests can
|
||||
# `monkeypatch.setattr("sessions._rust_ffi.sys.platform", ...)` (and same for
|
||||
# `os.name`). The standard library modules are process-wide singletons, so the
|
||||
# patch reaches `_loader`'s own `sys`/`os` lookups too.
|
||||
import os # noqa: F401 — re-exported for monkeypatching
|
||||
import sys # noqa: F401 — re-exported for monkeypatching
|
||||
|
||||
from . import _local_watcher as local_watcher # noqa: F401 — module export
|
||||
from ._bridge_parsers import (
|
||||
background_queue_pressure,
|
||||
build_eof_error_envelope,
|
||||
error_code,
|
||||
error_message,
|
||||
extract_handshake,
|
||||
mirror_queue_pressure,
|
||||
parse_mirror_result,
|
||||
parse_response_packet,
|
||||
payload_method_label,
|
||||
queue_tail_labels,
|
||||
response_envelope_valid,
|
||||
response_status,
|
||||
result_object,
|
||||
)
|
||||
from ._broker import (
|
||||
OpenOutcome,
|
||||
OpenOutcomeKind,
|
||||
RequestOutcome,
|
||||
RequestOutcomeKind,
|
||||
handshake,
|
||||
is_active,
|
||||
open_session,
|
||||
request,
|
||||
reset,
|
||||
shutdown_all,
|
||||
stderr_tail,
|
||||
)
|
||||
from ._file_policy import (
|
||||
file_open_transaction,
|
||||
is_external_cache_path,
|
||||
is_likely_binary,
|
||||
map_external_remote_to_local_path,
|
||||
map_local_to_remote_path,
|
||||
map_remote_to_local_path,
|
||||
open_guard_reason_code,
|
||||
reload_recommendation_code,
|
||||
save_decision_code,
|
||||
)
|
||||
from ._loader import (
|
||||
AbiError,
|
||||
SessionsNativeLibraryError,
|
||||
_bind_abi_symbol,
|
||||
_call_json_returning_abi,
|
||||
_native_lib,
|
||||
_native_library_candidates,
|
||||
_native_library_filename,
|
||||
_rust_cargo_target_debug_dir,
|
||||
_rust_cargo_target_release_dir,
|
||||
_rust_platform_tags,
|
||||
_shipped_native_search_dirs,
|
||||
call_string_abi,
|
||||
)
|
||||
from ._orchestrator import (
|
||||
bump_connect_generation,
|
||||
clear_connect_inflight_if,
|
||||
connect_inflight_host,
|
||||
enter_interactive_lane,
|
||||
exit_interactive_lane,
|
||||
is_connect_token_stale,
|
||||
lane_is_paused,
|
||||
set_connect_inflight,
|
||||
)
|
||||
from ._tool_runtime import (
|
||||
derive_venv_name,
|
||||
eager_hydrate_apply,
|
||||
eager_hydrate_find_candidates,
|
||||
merge_remote_extension_catalog_json,
|
||||
normalize_code_server_specs_json,
|
||||
normalize_python_tool_pipeline,
|
||||
normalize_remote_extension_specs_json,
|
||||
parse_ruff_diagnostics,
|
||||
)
|
||||
from ._workspace import normalize_remote_root, workspace_cache_key
|
||||
|
||||
__all__ = (
|
||||
# _local_watcher (Wave 2 PR-C — cross-platform sync)
|
||||
"local_watcher",
|
||||
# _loader (public)
|
||||
"AbiError",
|
||||
"SessionsNativeLibraryError",
|
||||
"call_string_abi",
|
||||
# _loader (private — exposed for tests via monkeypatch)
|
||||
"_bind_abi_symbol",
|
||||
"_call_json_returning_abi",
|
||||
"_native_lib",
|
||||
"_native_library_candidates",
|
||||
"_native_library_filename",
|
||||
"_rust_cargo_target_debug_dir",
|
||||
"_rust_cargo_target_release_dir",
|
||||
"_rust_platform_tags",
|
||||
"_shipped_native_search_dirs",
|
||||
# _workspace
|
||||
"normalize_remote_root",
|
||||
"workspace_cache_key",
|
||||
# _file_policy
|
||||
"file_open_transaction",
|
||||
"is_external_cache_path",
|
||||
"is_likely_binary",
|
||||
"map_external_remote_to_local_path",
|
||||
"map_local_to_remote_path",
|
||||
"map_remote_to_local_path",
|
||||
"open_guard_reason_code",
|
||||
"reload_recommendation_code",
|
||||
"save_decision_code",
|
||||
# _tool_runtime
|
||||
"derive_venv_name",
|
||||
"eager_hydrate_apply",
|
||||
"eager_hydrate_find_candidates",
|
||||
"merge_remote_extension_catalog_json",
|
||||
"normalize_code_server_specs_json",
|
||||
"normalize_python_tool_pipeline",
|
||||
"normalize_remote_extension_specs_json",
|
||||
"parse_ruff_diagnostics",
|
||||
# _orchestrator (Wave 2 PR 16 — PR-A core)
|
||||
"bump_connect_generation",
|
||||
"clear_connect_inflight_if",
|
||||
"connect_inflight_host",
|
||||
"enter_interactive_lane",
|
||||
"exit_interactive_lane",
|
||||
"is_connect_token_stale",
|
||||
"lane_is_paused",
|
||||
"set_connect_inflight",
|
||||
# _bridge_parsers
|
||||
"background_queue_pressure",
|
||||
"build_eof_error_envelope",
|
||||
"error_code",
|
||||
"error_message",
|
||||
"extract_handshake",
|
||||
"mirror_queue_pressure",
|
||||
"parse_mirror_result",
|
||||
"parse_response_packet",
|
||||
"payload_method_label",
|
||||
"queue_tail_labels",
|
||||
"response_envelope_valid",
|
||||
"response_status",
|
||||
"result_object",
|
||||
# _broker
|
||||
"OpenOutcome",
|
||||
"OpenOutcomeKind",
|
||||
"RequestOutcome",
|
||||
"RequestOutcomeKind",
|
||||
"handshake",
|
||||
"is_active",
|
||||
"open_session",
|
||||
"request",
|
||||
"reset",
|
||||
"shutdown_all",
|
||||
"stderr_tail",
|
||||
)
|
||||
247
sublime/sessions/_rust_ffi/_bridge_parsers.py
Normal file
247
sublime/sessions/_rust_ffi/_bridge_parsers.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Bridge envelope parsing + command-runtime queue label helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from . import _loader
|
||||
from ._loader import (
|
||||
SessionsNativeLibraryError,
|
||||
_bind_abi_symbol,
|
||||
_call_json_returning_abi,
|
||||
call_string_abi,
|
||||
)
|
||||
|
||||
|
||||
def payload_method_label(payload_json: str) -> str:
|
||||
"""Return logical method label from bridge envelope payload JSON."""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_bridge_payload_method_label",
|
||||
[ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
return call_string_abi(
|
||||
func,
|
||||
(ctypes.c_char_p(payload_json.encode("utf-8")),),
|
||||
failure_prefix="sessions_bridge_payload_method_label",
|
||||
)
|
||||
|
||||
|
||||
def error_message(payload_json: str, fallback: str) -> str:
|
||||
"""Return bridge error.message when present, else fallback."""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_bridge_error_message",
|
||||
[ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
return call_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(payload_json.encode("utf-8")),
|
||||
ctypes.c_char_p(fallback.encode("utf-8")),
|
||||
),
|
||||
failure_prefix="sessions_bridge_error_message",
|
||||
)
|
||||
|
||||
|
||||
def response_envelope_valid(payload_json: str) -> bool:
|
||||
"""Return True only when bridge response envelope has bool `ok`."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_bridge_response_envelope_valid
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_bridge_response_envelope_valid symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [ctypes.c_char_p]
|
||||
func.restype = ctypes.c_int
|
||||
rc = int(func(ctypes.c_char_p(payload_json.encode("utf-8"))))
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_bridge_response_envelope_valid failed: code {}".format(rc)
|
||||
)
|
||||
return rc == 1
|
||||
|
||||
|
||||
def extract_handshake(payload_json: str) -> Optional[Mapping[str, Any]]:
|
||||
"""Extract handshake object from bridge handshake line payload."""
|
||||
return _call_json_returning_abi(
|
||||
"sessions_bridge_extract_handshake",
|
||||
(payload_json,),
|
||||
argtypes=[ctypes.c_char_p],
|
||||
empty_codes=frozenset({1, 2}),
|
||||
)
|
||||
|
||||
|
||||
def parse_response_packet(payload_json: str) -> Optional[Mapping[str, Any]]:
|
||||
"""Parse bridge stdout line once and return `{id, payload}` mapping."""
|
||||
return _call_json_returning_abi(
|
||||
"sessions_bridge_parse_response_packet",
|
||||
(payload_json,),
|
||||
argtypes=[ctypes.c_char_p],
|
||||
empty_codes=frozenset({1, 2}),
|
||||
)
|
||||
|
||||
|
||||
def response_status(payload_json: str) -> Optional[Mapping[str, Any]]:
|
||||
"""Parse bridge response status `{is_error, error_code}`."""
|
||||
return _call_json_returning_abi(
|
||||
"sessions_bridge_response_status",
|
||||
(payload_json,),
|
||||
argtypes=[ctypes.c_char_p],
|
||||
empty_codes=frozenset({1, 2}),
|
||||
initial_buf=512,
|
||||
)
|
||||
|
||||
|
||||
def result_object(payload_json: str) -> Optional[Mapping[str, Any]]:
|
||||
"""Extract bridge envelope `result` object payload."""
|
||||
return _call_json_returning_abi(
|
||||
"sessions_bridge_result_object",
|
||||
(payload_json,),
|
||||
argtypes=[ctypes.c_char_p],
|
||||
empty_codes=frozenset({1, 2, 3}),
|
||||
)
|
||||
|
||||
|
||||
def build_eof_error_envelope(envelope_id: str, message: str) -> Mapping[str, Any]:
|
||||
"""Build synthetic EOF bridge error envelope using Rust ABI."""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_bridge_build_eof_error_envelope",
|
||||
[ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
return json.loads(
|
||||
call_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(envelope_id.encode("utf-8")),
|
||||
ctypes.c_char_p(message.encode("utf-8")),
|
||||
),
|
||||
failure_prefix="sessions_bridge_build_eof_error_envelope",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def error_code(payload_json: str) -> Optional[str]:
|
||||
"""Extract bridge error code when present.
|
||||
|
||||
Unlike :func:`payload_method_label` and :func:`error_message`, this
|
||||
wrapper cannot use :func:`call_string_abi`: the bridge returns
|
||||
``rc == 1`` to signal "no error code present" (return ``None``) but
|
||||
``call_string_abi`` interprets every small positive ``rc`` as an
|
||||
"unexpected size code" and raises. We keep the bespoke loop, but
|
||||
bind the symbol via :func:`_bind_abi_symbol` to share the
|
||||
AttributeError → SessionsNativeLibraryError translation.
|
||||
"""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_bridge_error_code",
|
||||
[ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
capacity = 256
|
||||
in_payload = ctypes.c_char_p(payload_json.encode("utf-8"))
|
||||
while True:
|
||||
out_buf = ctypes.create_string_buffer(capacity)
|
||||
rc = int(func(in_payload, out_buf, capacity))
|
||||
if rc == 0:
|
||||
return out_buf.value.decode("utf-8")
|
||||
if rc == 1:
|
||||
return None
|
||||
if rc > 1:
|
||||
if rc > capacity:
|
||||
capacity = rc
|
||||
continue
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_bridge_error_code unexpected rc={}".format(rc)
|
||||
)
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_bridge_error_code failed: code {}".format(rc)
|
||||
)
|
||||
|
||||
|
||||
def parse_mirror_result(payload_json: str) -> Optional[Mapping[str, Any]]:
|
||||
"""Parse normalized mirror result mapping from bridge payload."""
|
||||
return _call_json_returning_abi(
|
||||
"sessions_bridge_parse_mirror_result",
|
||||
(payload_json,),
|
||||
argtypes=[ctypes.c_char_p],
|
||||
empty_codes=frozenset({1, 2, 3}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command-runtime queue label helpers (kept alongside parsers — they are also
|
||||
# Rust-thin wrappers and share the same import surface for callers).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_QUEUE_KIND_MIRROR = 0
|
||||
_QUEUE_KIND_BACKGROUND = 1
|
||||
|
||||
|
||||
def _queue_pressure_label(
|
||||
kind: int,
|
||||
queue_size: int,
|
||||
dropped: int,
|
||||
queue_max: int,
|
||||
) -> str:
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_queue_pressure_label
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_queue_pressure_label symbol is unavailable in sessions_native"
|
||||
) from exc
|
||||
func.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_char),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
out = ctypes.create_string_buffer(32)
|
||||
rc = func(kind, queue_size, dropped, queue_max, out, len(out))
|
||||
if rc != 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_queue_pressure_label failed with code {}".format(rc)
|
||||
)
|
||||
return out.value.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def mirror_queue_pressure(queue_size: int, dropped: int, queue_max: int) -> str:
|
||||
return _queue_pressure_label(_QUEUE_KIND_MIRROR, queue_size, dropped, queue_max)
|
||||
|
||||
|
||||
def background_queue_pressure(queue_size: int, dropped: int, queue_max: int) -> str:
|
||||
return _queue_pressure_label(_QUEUE_KIND_BACKGROUND, queue_size, dropped, queue_max)
|
||||
|
||||
|
||||
def queue_tail_labels(labels: list[str], max_tail: int) -> list[str]:
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_queue_tail_labels_json
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_queue_tail_labels_json symbol is unavailable in sessions_native"
|
||||
) from exc
|
||||
func.argtypes = [
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_char),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
joined = "\x1f".join(labels)
|
||||
out = ctypes.create_string_buffer(4096)
|
||||
rc = int(func(joined.encode("utf-8"), max_tail, out, len(out)))
|
||||
if rc != 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_queue_tail_labels_json failed with code {}".format(rc)
|
||||
)
|
||||
decoded = json.loads(out.value.decode("utf-8"))
|
||||
if isinstance(decoded, list):
|
||||
return [str(v) for v in decoded]
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_queue_tail_labels_json returned non-list"
|
||||
)
|
||||
332
sublime/sessions/_rust_ffi/_broker.py
Normal file
332
sublime/sessions/_rust_ffi/_broker.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Session broker (open / request / reset / shutdown / handshake / stderr_tail).
|
||||
|
||||
In-process wrapper for ``sessions_native::broker``. The broker owns
|
||||
persistent SSH bridge subprocesses keyed by host alias and routes NDJSON
|
||||
requests/responses by id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
from . import _loader
|
||||
from ._loader import SessionsNativeLibraryError, call_string_abi
|
||||
|
||||
|
||||
class OpenOutcomeKind(str, Enum):
|
||||
OPENED = "opened"
|
||||
REUSED = "reused"
|
||||
SPAWN_FAILED = "spawn_failed"
|
||||
HANDSHAKE_TIMEOUT = "handshake_timeout"
|
||||
PROCESS_DIED = "process_died"
|
||||
HANDSHAKE_INVALID_JSON = "handshake_invalid_json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenOutcome:
|
||||
"""Result of :func:`open_session`.
|
||||
|
||||
Only one of ``handshake_json`` / ``error`` / ``stderr_tail`` / ``raw``
|
||||
is populated, depending on ``kind``.
|
||||
"""
|
||||
|
||||
kind: OpenOutcomeKind
|
||||
handshake_json: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
stderr_tail: Optional[str] = None
|
||||
exit_code: Optional[int] = None
|
||||
raw: Optional[str] = None
|
||||
|
||||
|
||||
class RequestOutcomeKind(str, Enum):
|
||||
RESPONSE = "response"
|
||||
TIMEOUT = "timeout"
|
||||
BROKEN_PIPE = "broken_pipe"
|
||||
SESSION_MISSING = "session_missing"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestOutcome:
|
||||
"""Result of :func:`request`."""
|
||||
|
||||
kind: RequestOutcomeKind
|
||||
response: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def _configure_broker_open_session(lib: ctypes.CDLL):
|
||||
func = lib.sessions_broker_open_session
|
||||
func.argtypes = [
|
||||
ctypes.c_char_p, # host_alias
|
||||
ctypes.c_char_p, # bridge_path
|
||||
ctypes.c_char_p, # helper_revision
|
||||
ctypes.c_char_p, # extra_env_json (nullable)
|
||||
ctypes.c_uint64, # handshake_timeout_ms
|
||||
ctypes.c_char_p, # out_buf
|
||||
ctypes.c_size_t, # out_cap
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
return func
|
||||
|
||||
|
||||
def _configure_broker_request(lib: ctypes.CDLL):
|
||||
func = lib.sessions_broker_request
|
||||
func.argtypes = [
|
||||
ctypes.c_char_p, # host_alias
|
||||
ctypes.c_char_p, # envelope_id
|
||||
ctypes.c_char_p, # payload_json
|
||||
ctypes.c_uint64, # timeout_ms
|
||||
ctypes.c_char_p, # out_buf
|
||||
ctypes.c_size_t, # out_cap
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
return func
|
||||
|
||||
|
||||
def _configure_broker_reset(lib: ctypes.CDLL):
|
||||
func = lib.sessions_broker_reset
|
||||
func.argtypes = [ctypes.c_char_p]
|
||||
func.restype = ctypes.c_int
|
||||
return func
|
||||
|
||||
|
||||
def _configure_broker_shutdown_all(lib: ctypes.CDLL):
|
||||
func = lib.sessions_broker_shutdown_all
|
||||
func.argtypes = []
|
||||
func.restype = ctypes.c_int
|
||||
return func
|
||||
|
||||
|
||||
def _configure_broker_is_active(lib: ctypes.CDLL):
|
||||
func = lib.sessions_broker_is_active
|
||||
func.argtypes = [ctypes.c_char_p]
|
||||
func.restype = ctypes.c_int
|
||||
return func
|
||||
|
||||
|
||||
def _configure_broker_handshake(lib: ctypes.CDLL):
|
||||
func = lib.sessions_broker_handshake
|
||||
func.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t]
|
||||
func.restype = ctypes.c_int
|
||||
return func
|
||||
|
||||
|
||||
def _configure_broker_stderr_tail(lib: ctypes.CDLL):
|
||||
func = lib.sessions_broker_stderr_tail
|
||||
func.argtypes = [
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
return func
|
||||
|
||||
|
||||
def _encode_extra_env(
|
||||
extra_env: Optional[Sequence[Tuple[str, str]]],
|
||||
) -> Optional[bytes]:
|
||||
if not extra_env:
|
||||
return None
|
||||
payload = [[key, value] for key, value in extra_env]
|
||||
return json.dumps(payload).encode("utf-8")
|
||||
|
||||
|
||||
def _parse_open_outcome(raw: str) -> OpenOutcome:
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"broker open_session returned non-JSON payload: {}".format(exc)
|
||||
) from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise SessionsNativeLibraryError(
|
||||
"broker open_session payload was not a JSON object"
|
||||
)
|
||||
kind_str = obj.get("kind")
|
||||
if not isinstance(kind_str, str):
|
||||
raise SessionsNativeLibraryError(
|
||||
"broker open_session payload missing string 'kind'"
|
||||
)
|
||||
try:
|
||||
kind = OpenOutcomeKind(kind_str)
|
||||
except ValueError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"broker open_session returned unknown kind {!r}".format(kind_str)
|
||||
) from exc
|
||||
handshake_json = obj.get("handshake_json")
|
||||
if handshake_json is not None and not isinstance(handshake_json, str):
|
||||
handshake_json = None
|
||||
err = obj.get("error")
|
||||
if err is not None and not isinstance(err, str):
|
||||
err = None
|
||||
stderr_tail = obj.get("stderr_tail")
|
||||
if stderr_tail is not None and not isinstance(stderr_tail, str):
|
||||
stderr_tail = None
|
||||
exit_code = obj.get("exit_code")
|
||||
if exit_code is not None and not isinstance(exit_code, int):
|
||||
exit_code = None
|
||||
raw_field = obj.get("raw")
|
||||
if raw_field is not None and not isinstance(raw_field, str):
|
||||
raw_field = None
|
||||
return OpenOutcome(
|
||||
kind=kind,
|
||||
handshake_json=handshake_json,
|
||||
error=err,
|
||||
stderr_tail=stderr_tail,
|
||||
exit_code=exit_code,
|
||||
raw=raw_field,
|
||||
)
|
||||
|
||||
|
||||
def _parse_request_outcome(raw: str) -> RequestOutcome:
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"broker request returned non-JSON payload: {}".format(exc)
|
||||
) from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise SessionsNativeLibraryError("broker request payload was not a JSON object")
|
||||
kind_str = obj.get("kind")
|
||||
if not isinstance(kind_str, str):
|
||||
raise SessionsNativeLibraryError("broker request payload missing string 'kind'")
|
||||
try:
|
||||
kind = RequestOutcomeKind(kind_str)
|
||||
except ValueError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"broker request returned unknown kind {!r}".format(kind_str)
|
||||
) from exc
|
||||
response = obj.get("response")
|
||||
if response is not None and not isinstance(response, str):
|
||||
response = None
|
||||
err = obj.get("error")
|
||||
if err is not None and not isinstance(err, str):
|
||||
err = None
|
||||
return RequestOutcome(kind=kind, response=response, error=err)
|
||||
|
||||
|
||||
_BROKER_ABI_ERROR_MESSAGES = {
|
||||
-20: "broker: malformed JSON input (extra_env array or envelope payload)",
|
||||
-21: "broker: failed to serialize outcome (internal bug)",
|
||||
}
|
||||
|
||||
|
||||
def open_session(
|
||||
host_alias: str,
|
||||
bridge_path: str,
|
||||
helper_revision: str,
|
||||
*,
|
||||
extra_env: Optional[Sequence[Tuple[str, str]]] = None,
|
||||
handshake_timeout_ms: int = 60_000,
|
||||
) -> OpenOutcome:
|
||||
"""Open or reuse a broker session."""
|
||||
lib = _loader._native_lib()
|
||||
func = _configure_broker_open_session(lib)
|
||||
extra_env_bytes = _encode_extra_env(extra_env)
|
||||
raw = call_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(host_alias.encode("utf-8")),
|
||||
ctypes.c_char_p(bridge_path.encode("utf-8")),
|
||||
ctypes.c_char_p(helper_revision.encode("utf-8")),
|
||||
ctypes.c_char_p(extra_env_bytes) if extra_env_bytes is not None else None,
|
||||
int(handshake_timeout_ms),
|
||||
),
|
||||
error_messages=_BROKER_ABI_ERROR_MESSAGES,
|
||||
failure_prefix="sessions_broker_open_session",
|
||||
)
|
||||
return _parse_open_outcome(raw)
|
||||
|
||||
|
||||
def request(
|
||||
host_alias: str,
|
||||
envelope_id: str,
|
||||
payload_json: str,
|
||||
timeout_ms: int,
|
||||
) -> RequestOutcome:
|
||||
"""Send ``payload_json`` and block for the matching response or timeout."""
|
||||
lib = _loader._native_lib()
|
||||
func = _configure_broker_request(lib)
|
||||
raw = call_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(host_alias.encode("utf-8")),
|
||||
ctypes.c_char_p(envelope_id.encode("utf-8")),
|
||||
ctypes.c_char_p(payload_json.encode("utf-8")),
|
||||
int(timeout_ms),
|
||||
),
|
||||
error_messages=_BROKER_ABI_ERROR_MESSAGES,
|
||||
failure_prefix="sessions_broker_request",
|
||||
)
|
||||
return _parse_request_outcome(raw)
|
||||
|
||||
|
||||
def reset(host_alias: str) -> bool:
|
||||
"""Tear down the broker session for ``host_alias``."""
|
||||
lib = _loader._native_lib()
|
||||
func = _configure_broker_reset(lib)
|
||||
rc = int(func(ctypes.c_char_p(host_alias.encode("utf-8"))))
|
||||
if rc == 0:
|
||||
return False
|
||||
if rc == 1:
|
||||
return True
|
||||
raise SessionsNativeLibraryError("sessions_broker_reset failed: code {}".format(rc))
|
||||
|
||||
|
||||
def shutdown_all() -> int:
|
||||
"""Reset every tracked broker session. Returns the count removed."""
|
||||
lib = _loader._native_lib()
|
||||
func = _configure_broker_shutdown_all(lib)
|
||||
rc = int(func())
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_broker_shutdown_all failed: code {}".format(rc)
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
def is_active(host_alias: str) -> bool:
|
||||
"""Return whether ``host_alias`` has an active, alive session."""
|
||||
lib = _loader._native_lib()
|
||||
func = _configure_broker_is_active(lib)
|
||||
rc = int(func(ctypes.c_char_p(host_alias.encode("utf-8"))))
|
||||
if rc == 0:
|
||||
return False
|
||||
if rc == 1:
|
||||
return True
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_broker_is_active failed: code {}".format(rc)
|
||||
)
|
||||
|
||||
|
||||
def handshake(host_alias: str) -> Optional[str]:
|
||||
"""Return the cached handshake JSON line, or ``None``."""
|
||||
lib = _loader._native_lib()
|
||||
func = _configure_broker_handshake(lib)
|
||||
raw = call_string_abi(
|
||||
func,
|
||||
(ctypes.c_char_p(host_alias.encode("utf-8")),),
|
||||
error_messages=_BROKER_ABI_ERROR_MESSAGES,
|
||||
failure_prefix="sessions_broker_handshake",
|
||||
)
|
||||
return raw if raw else None
|
||||
|
||||
|
||||
def stderr_tail(host_alias: str, max_chars: int = 0) -> str:
|
||||
"""Return a stderr tail snapshot; ``max_chars = 0`` uses the default cap."""
|
||||
lib = _loader._native_lib()
|
||||
func = _configure_broker_stderr_tail(lib)
|
||||
return call_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(host_alias.encode("utf-8")),
|
||||
int(max_chars),
|
||||
),
|
||||
error_messages=_BROKER_ABI_ERROR_MESSAGES,
|
||||
failure_prefix="sessions_broker_stderr_tail",
|
||||
)
|
||||
379
sublime/sessions/_rust_ffi/_file_policy.py
Normal file
379
sublime/sessions/_rust_ffi/_file_policy.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""File-policy helpers (open guard, save decision, path mappers).
|
||||
|
||||
All decisions delegate to ``sessions_native::file_policy`` ABI functions;
|
||||
this module is the ctypes glue + small wrappers around the Rust codes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from . import _loader
|
||||
from ._loader import (
|
||||
AbiError,
|
||||
SessionsNativeLibraryError,
|
||||
_call_json_returning_abi,
|
||||
call_string_abi,
|
||||
)
|
||||
|
||||
# Keys typed as plain ``int`` (not ``AbiError``) so the dict is assignable
|
||||
# to ``call_string_abi``'s ``Mapping[int, str]`` parameter — ``Mapping``'s
|
||||
# key type is invariant, and ``IntEnum`` does not satisfy that even though
|
||||
# its values *are* ``int`` at runtime.
|
||||
_FILE_POLICY_ERROR_MESSAGES: dict[int, str] = {
|
||||
int(AbiError.REMOTE_PATH_REJECTED): (
|
||||
"remote path mapping rejected (out of workspace or contains '..')"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _call_file_policy_string_abi(func: Any, args: Tuple[Any, ...]) -> str:
|
||||
return call_string_abi(func, args, error_messages=_FILE_POLICY_ERROR_MESSAGES)
|
||||
|
||||
|
||||
def open_guard_reason_code(
|
||||
*,
|
||||
remote_kind_code: int,
|
||||
size_bytes: int,
|
||||
max_open_bytes: int,
|
||||
allow_empty_files: bool,
|
||||
) -> int:
|
||||
"""Return Rust open-guard reason code for metadata-only checks."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_file_open_guard_reason
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_open_guard_reason symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_int,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
rc = int(
|
||||
func(
|
||||
int(remote_kind_code),
|
||||
int(size_bytes),
|
||||
int(max_open_bytes),
|
||||
1 if allow_empty_files else 0,
|
||||
)
|
||||
)
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_open_guard_reason failed: code {}".format(rc)
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
def is_likely_binary(content_head: bytes) -> bool:
|
||||
"""Return Rust binary-heuristic decision for payload head bytes."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_file_is_likely_binary
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_is_likely_binary symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t]
|
||||
func.restype = ctypes.c_int
|
||||
if not content_head:
|
||||
rc = int(func(None, 0))
|
||||
else:
|
||||
payload = (ctypes.c_ubyte * len(content_head)).from_buffer_copy(content_head)
|
||||
rc = int(func(payload, len(content_head)))
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_is_likely_binary failed: code {}".format(rc)
|
||||
)
|
||||
return rc == 1
|
||||
|
||||
|
||||
def reload_recommendation_code(
|
||||
*,
|
||||
had_metadata_at_open: bool,
|
||||
baseline: Optional[tuple[int, int, int]],
|
||||
current: Optional[tuple[int, int, int]],
|
||||
) -> int:
|
||||
"""Return Rust reload recommendation code from metadata tuples."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_file_reload_recommendation
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_reload_recommendation symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
baseline_mtime, baseline_size, baseline_kind = baseline or (0, 0, 0)
|
||||
current_mtime, current_size, current_kind = current or (0, 0, 0)
|
||||
rc = int(
|
||||
func(
|
||||
1 if had_metadata_at_open else 0,
|
||||
1 if baseline is not None else 0,
|
||||
int(baseline_mtime),
|
||||
int(baseline_size),
|
||||
int(baseline_kind),
|
||||
1 if current is not None else 0,
|
||||
int(current_mtime),
|
||||
int(current_size),
|
||||
int(current_kind),
|
||||
)
|
||||
)
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_reload_recommendation failed: code {}".format(rc)
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
def save_decision_code(
|
||||
*,
|
||||
baseline: Optional[tuple[int, int, int]],
|
||||
candidate: Optional[tuple[int, int, int]],
|
||||
) -> int:
|
||||
"""Return Rust save decision code from baseline/candidate metadata tuples."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_file_save_decision
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_save_decision symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int64,
|
||||
ctypes.c_int,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
baseline_mtime, baseline_size, baseline_kind = baseline or (0, 0, 0)
|
||||
candidate_mtime, candidate_size, candidate_kind = candidate or (0, 0, 0)
|
||||
rc = int(
|
||||
func(
|
||||
1 if baseline is not None else 0,
|
||||
int(baseline_mtime),
|
||||
int(baseline_size),
|
||||
int(baseline_kind),
|
||||
1 if candidate is not None else 0,
|
||||
int(candidate_mtime),
|
||||
int(candidate_size),
|
||||
int(candidate_kind),
|
||||
)
|
||||
)
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_save_decision failed: code {}".format(rc)
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
def map_remote_to_local_path(
|
||||
*,
|
||||
remote_root: str,
|
||||
remote_file: str,
|
||||
files_cache_root: Path,
|
||||
max_segments: int,
|
||||
) -> Path:
|
||||
"""Map workspace remote path to local cache path using Rust ABI."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_file_map_remote_to_local
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_map_remote_to_local symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
out = _call_file_policy_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(remote_root.encode("utf-8")),
|
||||
ctypes.c_char_p(remote_file.encode("utf-8")),
|
||||
ctypes.c_char_p(str(files_cache_root).encode("utf-8")),
|
||||
int(max_segments),
|
||||
),
|
||||
)
|
||||
return Path(out)
|
||||
|
||||
|
||||
def map_external_remote_to_local_path(
|
||||
*,
|
||||
remote_file: str,
|
||||
files_cache_root: Path,
|
||||
max_segments: int,
|
||||
) -> Path:
|
||||
"""Map external remote path to local `__extern` cache path via Rust ABI."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_file_map_external_remote_to_local
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_map_external_remote_to_local symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
out = _call_file_policy_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(remote_file.encode("utf-8")),
|
||||
ctypes.c_char_p(str(files_cache_root).encode("utf-8")),
|
||||
int(max_segments),
|
||||
),
|
||||
)
|
||||
return Path(out)
|
||||
|
||||
|
||||
def map_local_to_remote_path(
|
||||
*,
|
||||
remote_root: str,
|
||||
files_cache_root: Path,
|
||||
local_path: Path,
|
||||
) -> Optional[str]:
|
||||
"""Map local cache path back to remote path using Rust ABI."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_file_map_local_to_remote
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_map_local_to_remote symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
|
||||
in_remote_root = ctypes.c_char_p(remote_root.encode("utf-8"))
|
||||
in_cache_root = ctypes.c_char_p(str(files_cache_root).encode("utf-8"))
|
||||
in_local = ctypes.c_char_p(str(local_path).encode("utf-8"))
|
||||
capacity = 4096
|
||||
while True:
|
||||
out_buf = ctypes.create_string_buffer(capacity)
|
||||
rc = int(func(in_remote_root, in_cache_root, in_local, out_buf, capacity))
|
||||
if rc == 0:
|
||||
return out_buf.value.decode("utf-8")
|
||||
if rc == 1:
|
||||
return None
|
||||
if rc > 1:
|
||||
if rc > capacity:
|
||||
capacity = rc
|
||||
continue
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_map_local_to_remote unexpected rc={}".format(rc)
|
||||
)
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_map_local_to_remote failed: code {}".format(rc)
|
||||
)
|
||||
|
||||
|
||||
def file_open_transaction(
|
||||
*,
|
||||
host_alias: str,
|
||||
remote_absolute_path: str,
|
||||
local_cache_path: Path,
|
||||
max_open_bytes: int,
|
||||
binary_probe_bytes: int,
|
||||
allow_empty: bool,
|
||||
timeout_ms: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the full Rust file_open transaction (read + guard + atomic write).
|
||||
|
||||
Wraps :c:func:`sessions_file_open_transaction` (PR 14.5c). Rust
|
||||
orchestrates broker.request file/read → metadata/size guard →
|
||||
binary head heuristic → atomic write into ``local_cache_path``.
|
||||
|
||||
Returns a dict with keys:
|
||||
|
||||
* ``outcome``: one of ``OK``, ``BLOCKED_BY_POLICY``,
|
||||
``BLOCKED_BINARY_HEURISTIC``, ``REMOTE_NOT_FOUND``,
|
||||
``TRANSPORT_ERROR``.
|
||||
* ``metadata`` (OK / BLOCKED_*): remote stat snapshot.
|
||||
* ``bytes_written`` (OK only).
|
||||
* ``unsupported_reason`` (BLOCKED_BY_POLICY): kebab-case reason code.
|
||||
* ``detail`` / ``error_code`` (TRANSPORT_ERROR / REMOTE_NOT_FOUND).
|
||||
"""
|
||||
decoded = _call_json_returning_abi(
|
||||
"sessions_file_open_transaction",
|
||||
(
|
||||
host_alias,
|
||||
remote_absolute_path,
|
||||
str(local_cache_path),
|
||||
ctypes.c_uint64(int(max_open_bytes)),
|
||||
ctypes.c_size_t(int(binary_probe_bytes)),
|
||||
ctypes.c_int(1 if allow_empty else 0),
|
||||
ctypes.c_uint64(int(timeout_ms)),
|
||||
),
|
||||
argtypes=[
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_int,
|
||||
ctypes.c_uint64,
|
||||
],
|
||||
)
|
||||
if decoded is None:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_open_transaction returned non-object payload"
|
||||
)
|
||||
return decoded
|
||||
|
||||
|
||||
def is_external_cache_path(*, files_cache_root: Path, local_path: Path) -> bool:
|
||||
"""Return whether local path belongs to external cache subtree."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_file_is_external_cache_path
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_is_external_cache_path symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
func.restype = ctypes.c_int
|
||||
rc = int(
|
||||
func(
|
||||
ctypes.c_char_p(str(files_cache_root).encode("utf-8")),
|
||||
ctypes.c_char_p(str(local_path).encode("utf-8")),
|
||||
)
|
||||
)
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_file_is_external_cache_path failed: code {}".format(rc)
|
||||
)
|
||||
return rc == 1
|
||||
329
sublime/sessions/_rust_ffi/_loader.py
Normal file
329
sublime/sessions/_rust_ffi/_loader.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""Library discovery, ABI error type, and shared `call_string_abi` helpers.
|
||||
|
||||
Other ``_rust_ffi`` sub-modules import everything they need from here:
|
||||
|
||||
- :class:`SessionsNativeLibraryError` (raised on any ABI error)
|
||||
- :class:`AbiError` (mirror of Rust ``AbiError`` enum, parity-tested)
|
||||
- :func:`call_string_abi` (string-out, retry-on-grow ABI calling convention)
|
||||
- :func:`_bind_abi_symbol`, :func:`_call_json_returning_abi` (JSON-out helper)
|
||||
- :func:`_native_lib` (cached cdylib handle)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from enum import IntEnum
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, FrozenSet, Iterable, List, Mapping, Optional, Tuple
|
||||
|
||||
|
||||
class SessionsNativeLibraryError(RuntimeError):
|
||||
"""Raised when ``sessions_native`` cannot be loaded or returns an error."""
|
||||
|
||||
|
||||
class AbiError(IntEnum):
|
||||
"""Mirror of ``rust/crates/sessions_native/src/abi_error.rs::AbiError``.
|
||||
|
||||
Adding a variant requires updating both files; ``test_abi_error_parity``
|
||||
asserts the numeric values stay in sync.
|
||||
"""
|
||||
|
||||
NULL_POINTER = -1
|
||||
INVALID_UTF8 = -2
|
||||
REMOTE_PATH_REJECTED = -3
|
||||
SIZE_OVERFLOW = -4
|
||||
BROKER_INVALID_JSON = -20
|
||||
BROKER_SERIALIZE_FAILED = -21
|
||||
SERIALIZATION = -22
|
||||
|
||||
|
||||
_DEFAULT_ABI_ERROR_MESSAGES: Mapping[int, str] = {
|
||||
AbiError.NULL_POINTER: "null pointer",
|
||||
AbiError.INVALID_UTF8: "invalid utf-8",
|
||||
AbiError.REMOTE_PATH_REJECTED: "remote path rejected by policy",
|
||||
AbiError.SIZE_OVERFLOW: "size overflow",
|
||||
AbiError.BROKER_INVALID_JSON: "broker: malformed JSON input",
|
||||
AbiError.BROKER_SERIALIZE_FAILED: "broker: failed to serialize outcome",
|
||||
AbiError.SERIALIZATION: "settings/helper: failed to serialize result",
|
||||
}
|
||||
|
||||
|
||||
def call_string_abi(
|
||||
func: Any,
|
||||
args: Tuple[Any, ...],
|
||||
*,
|
||||
error_messages: Optional[Mapping[int, str]] = None,
|
||||
failure_prefix: str = "string ABI",
|
||||
) -> str:
|
||||
"""Invoke a string-returning ``sessions_native`` function with retry.
|
||||
|
||||
Appends ``(out_buf, capacity)`` to ``args`` and calls ``func``. On
|
||||
``rc == 0`` returns the decoded UTF-8 string. On positive ``rc`` grows
|
||||
the buffer to that size and retries. On negative ``rc`` raises
|
||||
``SessionsNativeLibraryError`` with a message drawn from
|
||||
``error_messages`` (caller-specific overrides) or
|
||||
``_DEFAULT_ABI_ERROR_MESSAGES`` (AbiError defaults).
|
||||
"""
|
||||
capacity = 4096
|
||||
while True:
|
||||
out_buf = ctypes.create_string_buffer(capacity)
|
||||
rc = int(func(*args, out_buf, capacity))
|
||||
if rc == 0:
|
||||
return out_buf.value.decode("utf-8")
|
||||
if rc > 0:
|
||||
if rc > capacity:
|
||||
capacity = rc
|
||||
continue
|
||||
raise SessionsNativeLibraryError(
|
||||
"{} returned unexpected size code {}".format(failure_prefix, rc)
|
||||
)
|
||||
custom = (error_messages or {}).get(rc)
|
||||
if custom is not None:
|
||||
raise SessionsNativeLibraryError(custom)
|
||||
default = _DEFAULT_ABI_ERROR_MESSAGES.get(rc)
|
||||
if default is not None:
|
||||
raise SessionsNativeLibraryError(
|
||||
"{} failed: {}".format(failure_prefix, default)
|
||||
)
|
||||
raise SessionsNativeLibraryError(
|
||||
"{} failed: code {}".format(failure_prefix, rc)
|
||||
)
|
||||
|
||||
|
||||
_BOUND_ABI_ATTR = "_sessions_bound_abi_cache"
|
||||
|
||||
# Hard ceiling on caller-allocated buffer growth so a runaway "buffer too
|
||||
# small" rc cannot drive ctypes to allocate gigabytes of heap.
|
||||
_JSON_ABI_MAX_BUF = 64 * 1024 * 1024 # 64 MiB
|
||||
|
||||
|
||||
def _bind_abi_symbol(symbol_name: str, argtypes: Iterable[type]) -> Any:
|
||||
"""Resolve and cache a ``sessions_native`` symbol with argtypes/restype.
|
||||
|
||||
The cache is stashed on the ``_native_lib`` instance itself so its
|
||||
lifetime is tied to the library object: when tests swap ``_native_lib``
|
||||
for a fake (``monkeypatch.setattr(_rust_ffi, "_native_lib", ...)``), the
|
||||
fake naturally has its own empty cache and won't return a previously
|
||||
bound function from the real cdylib.
|
||||
|
||||
``argtypes`` describes the *input* arguments only; helpers append
|
||||
``(out_buf, capacity)`` themselves where applicable. ``restype`` is
|
||||
always ``c_int`` for the buffer-resize ABI family.
|
||||
"""
|
||||
lib = _native_lib()
|
||||
cache: Dict[str, Any]
|
||||
existing = getattr(lib, _BOUND_ABI_ATTR, None)
|
||||
if isinstance(existing, dict):
|
||||
cache = existing
|
||||
else:
|
||||
cache = {}
|
||||
try:
|
||||
setattr(lib, _BOUND_ABI_ATTR, cache)
|
||||
except (AttributeError, TypeError):
|
||||
# Some test fakes use ``__slots__`` or otherwise reject
|
||||
# attribute assignment; fall back to per-call binding.
|
||||
pass
|
||||
cached = cache.get(symbol_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
func = getattr(lib, symbol_name)
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"{} symbol unavailable".format(symbol_name)
|
||||
) from exc
|
||||
func.argtypes = list(argtypes)
|
||||
func.restype = ctypes.c_int
|
||||
cache[symbol_name] = func
|
||||
return func
|
||||
|
||||
|
||||
def _encode_json_abi_arg(value: Any) -> Any:
|
||||
"""Convert a Python value into a ctypes-friendly argument.
|
||||
|
||||
``str`` becomes a UTF-8 ``c_char_p``; ``bytes`` is passed through as
|
||||
``c_char_p``; everything else is forwarded unchanged so callers can
|
||||
pass already-prepared ctypes scalars (ints, ``c_uint64``, etc).
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return ctypes.c_char_p(value.encode("utf-8"))
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return ctypes.c_char_p(bytes(value))
|
||||
return value
|
||||
|
||||
|
||||
def _call_json_returning_abi(
|
||||
symbol_name: str,
|
||||
args: Tuple[Any, ...],
|
||||
*,
|
||||
argtypes: List[type],
|
||||
empty_codes: FrozenSet[int] = frozenset(),
|
||||
initial_buf: int = 4096,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Invoke a JSON-returning ``sessions_native`` symbol with retry.
|
||||
|
||||
Pattern shared by the bridge helpers: caller allocates a buffer,
|
||||
Rust writes UTF-8 JSON into it and returns ``rc``:
|
||||
|
||||
* ``rc == 0`` — buffer holds JSON; decoded mapping returned (or
|
||||
``None`` if the payload is not a JSON object — matches the
|
||||
pre-refactor "isinstance(decoded, dict) else None" branches).
|
||||
* ``rc in empty_codes`` — Rust signalled "no data"; ``None``.
|
||||
* ``rc > max(empty_codes, default=0)`` — buffer-too-small sentinel
|
||||
whose value is the required size. Grows up to
|
||||
:data:`_JSON_ABI_MAX_BUF` then raises.
|
||||
* Anything else (negative AbiError, or positive code at-or-below
|
||||
``max(empty_codes)`` that isn't an empty signal) raises.
|
||||
"""
|
||||
func = _bind_abi_symbol(
|
||||
symbol_name,
|
||||
list(argtypes) + [ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
encoded_args = tuple(_encode_json_abi_arg(arg) for arg in args)
|
||||
too_small_threshold = max(empty_codes, default=0)
|
||||
capacity = initial_buf
|
||||
while True:
|
||||
out_buf = ctypes.create_string_buffer(capacity)
|
||||
rc = int(func(*encoded_args, out_buf, capacity))
|
||||
if rc == 0:
|
||||
decoded = json.loads(out_buf.value.decode("utf-8"))
|
||||
if isinstance(decoded, dict):
|
||||
return decoded
|
||||
return None
|
||||
if rc in empty_codes:
|
||||
return None
|
||||
if rc > too_small_threshold:
|
||||
if rc > capacity:
|
||||
if rc > _JSON_ABI_MAX_BUF:
|
||||
raise SessionsNativeLibraryError(
|
||||
"{} required buffer size {} exceeds cap {}".format(
|
||||
symbol_name, rc, _JSON_ABI_MAX_BUF
|
||||
)
|
||||
)
|
||||
capacity = rc
|
||||
continue
|
||||
raise SessionsNativeLibraryError(
|
||||
"{} unexpected rc={}".format(symbol_name, rc)
|
||||
)
|
||||
raise SessionsNativeLibraryError("{} failed: code {}".format(symbol_name, rc))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Library discovery + load.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _rust_workspace_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3] / "rust"
|
||||
|
||||
|
||||
def _sublime_package_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _rust_cargo_target_debug_dir() -> Path:
|
||||
override = (os.environ.get("CARGO_TARGET_DIR") or "").strip()
|
||||
if override:
|
||||
return Path(override) / "debug"
|
||||
return _rust_workspace_root() / "target" / "debug"
|
||||
|
||||
|
||||
def _rust_cargo_target_release_dir() -> Path:
|
||||
override = (os.environ.get("CARGO_TARGET_DIR") or "").strip()
|
||||
if override:
|
||||
return Path(override) / "release"
|
||||
return _rust_workspace_root() / "target" / "release"
|
||||
|
||||
|
||||
def _rust_platform_tags() -> Tuple[str, ...]:
|
||||
system = platform.system().lower()
|
||||
raw_machine = platform.machine().lower()
|
||||
tags = []
|
||||
if system == "linux":
|
||||
if raw_machine in ("x86_64", "amd64"):
|
||||
tags.extend(("linux-x86_64", "linux-x64"))
|
||||
elif raw_machine in ("aarch64", "arm64"):
|
||||
tags.extend(("linux-aarch64", "linux-arm64"))
|
||||
else:
|
||||
tags.append("linux-{}".format(raw_machine))
|
||||
elif system == "darwin":
|
||||
if raw_machine in ("x86_64", "amd64"):
|
||||
tags.extend(("darwin-x86_64", "darwin-x64"))
|
||||
elif raw_machine in ("aarch64", "arm64"):
|
||||
tags.extend(("darwin-aarch64", "darwin-arm64"))
|
||||
else:
|
||||
tags.append("darwin-{}".format(raw_machine))
|
||||
elif system == "windows":
|
||||
if raw_machine in ("x86_64", "amd64"):
|
||||
tags.extend(("windows-x86_64", "windows-x64"))
|
||||
elif raw_machine in ("aarch64", "arm64"):
|
||||
tags.append("windows-aarch64")
|
||||
else:
|
||||
tags.append("windows-{}".format(raw_machine))
|
||||
else:
|
||||
tags.append("{}-{}".format(system, raw_machine))
|
||||
return tuple(tags)
|
||||
|
||||
|
||||
def _shipped_native_search_dirs() -> Tuple[Path, ...]:
|
||||
root = _sublime_package_root()
|
||||
base = root / "sessions" / "bin"
|
||||
ordered_dirs = []
|
||||
seen_tags = set()
|
||||
for tag in _rust_platform_tags():
|
||||
if tag not in seen_tags:
|
||||
seen_tags.add(tag)
|
||||
ordered_dirs.append(base / "local-bridge" / tag)
|
||||
ordered_dirs.append(base / tag)
|
||||
ordered_dirs.append(root / "bin")
|
||||
return tuple(ordered_dirs)
|
||||
|
||||
|
||||
def _native_library_filename() -> str:
|
||||
if os.name == "nt":
|
||||
return "sessions_native.dll"
|
||||
if sys.platform == "darwin":
|
||||
return "libsessions_native.dylib"
|
||||
return "libsessions_native.so"
|
||||
|
||||
|
||||
def _native_library_candidates() -> Tuple[Path, ...]:
|
||||
explicit = (os.environ.get("SESSIONS_NATIVE_PATH") or "").strip()
|
||||
if explicit:
|
||||
return (Path(explicit),)
|
||||
name = _native_library_filename()
|
||||
# Prefer the most recently built cargo target (debug vs release): whichever
|
||||
# the developer just rebuilt is what they want loaded. Shipped bins are the
|
||||
# production fallback when no dev build exists.
|
||||
dev_builds = [
|
||||
path
|
||||
for path in (
|
||||
_rust_cargo_target_debug_dir() / name,
|
||||
_rust_cargo_target_release_dir() / name,
|
||||
)
|
||||
if path.is_file()
|
||||
]
|
||||
dev_builds.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
shipped = tuple(directory / name for directory in _shipped_native_search_dirs())
|
||||
return tuple(dev_builds) + shipped
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _native_lib() -> ctypes.CDLL:
|
||||
last = None
|
||||
for candidate in _native_library_candidates():
|
||||
last = candidate
|
||||
if candidate.is_file():
|
||||
return ctypes.CDLL(str(candidate))
|
||||
raise SessionsNativeLibraryError(
|
||||
"Sessions: sessions_native shared library not found (tried {}). "
|
||||
"From the repo root run: cargo build -p sessions_native "
|
||||
"(or install a package that ships sessions_native beside local_bridge).".format(
|
||||
last
|
||||
)
|
||||
)
|
||||
89
sublime/sessions/_rust_ffi/_local_watcher.py
Normal file
89
sublime/sessions/_rust_ffi/_local_watcher.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Cross-platform local cache filesystem watcher (Wave 2 PR-C).
|
||||
|
||||
Wraps the ``sessions_native::local_watcher`` ABI so the Sublime side
|
||||
can detect external file mutations (Sublime Merge stage/discard,
|
||||
``vim``, build tools writing into the cache) and push the changes back
|
||||
to the remote — Sublime's own ``on_post_save`` listener never sees
|
||||
those writes because they bypass the editor entirely.
|
||||
|
||||
Backed by the cross-platform ``notify`` crate (FSEvents on macOS,
|
||||
inotify on Linux, ReadDirectoryChangesW on Windows). Polling-friendly
|
||||
drain API: Python spawns a daemon thread that calls :func:`drain`
|
||||
every ~50–100 ms; idle workspaces have zero cost between polls
|
||||
because the watcher thread sits on the OS event source inside Rust.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from typing import Tuple
|
||||
|
||||
from . import _loader
|
||||
from ._loader import SessionsNativeLibraryError
|
||||
|
||||
|
||||
def start(cache_root: str) -> int:
|
||||
"""Start watching ``cache_root`` recursively. Returns a non-zero
|
||||
handle on success, ``0`` when the cache root is missing or the
|
||||
platform watcher could not be created.
|
||||
"""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_local_watcher_start
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_local_watcher_start symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [ctypes.c_char_p]
|
||||
func.restype = ctypes.c_int64
|
||||
return int(func(ctypes.c_char_p(cache_root.encode("utf-8"))))
|
||||
|
||||
|
||||
def drain(handle: int) -> Tuple[str, ...]:
|
||||
"""Drain pending change paths. Returns empty tuple when the
|
||||
watcher has nothing new (or when the handle is unknown)."""
|
||||
if handle <= 0:
|
||||
return ()
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_local_watcher_drain
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_local_watcher_drain symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_size_t]
|
||||
func.restype = ctypes.c_int
|
||||
capacity = 8192
|
||||
while True:
|
||||
out_buf = ctypes.create_string_buffer(capacity)
|
||||
rc = int(func(ctypes.c_int64(handle), out_buf, capacity))
|
||||
if rc == 0:
|
||||
payload = out_buf.value.decode("utf-8")
|
||||
if not payload:
|
||||
return ()
|
||||
return tuple(payload.split("\x1f"))
|
||||
if rc < 0:
|
||||
return ()
|
||||
# rc > 0 — buffer too small. ``write_output`` returns the
|
||||
# required size in this case (matches the ``call_string_abi``
|
||||
# contract). Grow and retry.
|
||||
if rc > capacity:
|
||||
capacity = rc
|
||||
continue
|
||||
return ()
|
||||
|
||||
|
||||
def stop(handle: int) -> bool:
|
||||
"""Stop the watcher and release OS resources. Idempotent."""
|
||||
if handle <= 0:
|
||||
return False
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_local_watcher_stop
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_local_watcher_stop symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [ctypes.c_int64]
|
||||
func.restype = ctypes.c_int
|
||||
return int(func(ctypes.c_int64(handle))) == 1
|
||||
113
sublime/sessions/_rust_ffi/_orchestrator.py
Normal file
113
sublime/sessions/_rust_ffi/_orchestrator.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Worker-queue orchestrator FFI (Wave 2 PR 16 — PR-A core).
|
||||
|
||||
Connect generation token + in-flight tracking + SSH lane gating now live
|
||||
in ``sessions_native::orchestrator`` (process-wide singleton). Python is
|
||||
still responsible for queueing the actual callables and for pumping work
|
||||
through Sublime's ``set_timeout`` scheduler — Rust owns the *state*, not
|
||||
the *dispatch*.
|
||||
|
||||
See ``rust/crates/sessions_native/src/orchestrator.rs`` for the
|
||||
authoritative semantics; this module is a thin ctypes shim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from typing import Optional
|
||||
|
||||
from . import _loader
|
||||
from ._loader import SessionsNativeLibraryError, _bind_abi_symbol, call_string_abi
|
||||
|
||||
|
||||
def bump_connect_generation() -> int:
|
||||
"""Bump the connect token and return the new value."""
|
||||
func = _bind_abi_symbol("sessions_orch_bump_connect_generation", [])
|
||||
func.restype = ctypes.c_uint64
|
||||
return int(func())
|
||||
|
||||
|
||||
def is_connect_token_stale(token: int) -> bool:
|
||||
"""Return whether ``token`` is older than the current generation."""
|
||||
func = _bind_abi_symbol("sessions_orch_is_connect_token_stale", [ctypes.c_uint64])
|
||||
rc = int(func(ctypes.c_uint64(int(token))))
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_orch_is_connect_token_stale failed: code {}".format(rc)
|
||||
)
|
||||
return rc == 1
|
||||
|
||||
|
||||
def set_connect_inflight(token: int, host_alias: str) -> None:
|
||||
"""Mark ``host_alias`` as the in-flight connect host for ``token``."""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_orch_set_connect_inflight",
|
||||
[ctypes.c_uint64, ctypes.c_char_p],
|
||||
)
|
||||
rc = int(
|
||||
func(ctypes.c_uint64(int(token)), ctypes.c_char_p(host_alias.encode("utf-8")))
|
||||
)
|
||||
if rc != 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_orch_set_connect_inflight failed: code {}".format(rc)
|
||||
)
|
||||
|
||||
|
||||
def clear_connect_inflight_if(token: int) -> bool:
|
||||
"""Clear the in-flight slot if it currently belongs to ``token``."""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_orch_clear_connect_inflight_if", [ctypes.c_uint64]
|
||||
)
|
||||
rc = int(func(ctypes.c_uint64(int(token))))
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_orch_clear_connect_inflight_if failed: code {}".format(rc)
|
||||
)
|
||||
return rc == 1
|
||||
|
||||
|
||||
def connect_inflight_host() -> Optional[str]:
|
||||
"""Return the currently in-flight connect host, or ``None``."""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_orch_inflight_host", [ctypes.c_char_p, ctypes.c_size_t]
|
||||
)
|
||||
out = call_string_abi(func, (), failure_prefix="sessions_orch_inflight_host")
|
||||
return out if out else None
|
||||
|
||||
|
||||
def enter_interactive_lane(host_alias: str) -> int:
|
||||
"""Increment interactive-lane depth for ``host_alias``. Returns new depth."""
|
||||
func = _bind_abi_symbol("sessions_orch_enter_interactive_lane", [ctypes.c_char_p])
|
||||
depth = int(func(ctypes.c_char_p(host_alias.encode("utf-8"))))
|
||||
if depth < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_orch_enter_interactive_lane failed: code {}".format(depth)
|
||||
)
|
||||
return depth
|
||||
|
||||
|
||||
def exit_interactive_lane(host_alias: str) -> int:
|
||||
"""Decrement interactive-lane depth for ``host_alias``. Returns new depth."""
|
||||
func = _bind_abi_symbol("sessions_orch_exit_interactive_lane", [ctypes.c_char_p])
|
||||
depth = int(func(ctypes.c_char_p(host_alias.encode("utf-8"))))
|
||||
if depth < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_orch_exit_interactive_lane failed: code {}".format(depth)
|
||||
)
|
||||
return depth
|
||||
|
||||
|
||||
def lane_is_paused(host_alias: str) -> bool:
|
||||
"""Return whether the mirror lane is currently paused for ``host_alias``."""
|
||||
func = _bind_abi_symbol("sessions_orch_lane_is_paused", [ctypes.c_char_p])
|
||||
rc = int(func(ctypes.c_char_p(host_alias.encode("utf-8"))))
|
||||
if rc < 0:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_orch_lane_is_paused failed: code {}".format(rc)
|
||||
)
|
||||
return rc == 1
|
||||
|
||||
|
||||
# Silence pyright "_loader unused" — kept as an import so test
|
||||
# monkeypatching paths (``sessions._rust_ffi._loader.<symbol>``) reach
|
||||
# this module the same way the other sub-modules wire it.
|
||||
_ = _loader
|
||||
250
sublime/sessions/_rust_ffi/_tool_runtime.py
Normal file
250
sublime/sessions/_rust_ffi/_tool_runtime.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Tool runtime wrappers — Ruff diagnostics + settings normalization (Wave 1.5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
from typing import Any, Dict, Sequence, Tuple
|
||||
|
||||
from . import _loader
|
||||
from ._loader import (
|
||||
SessionsNativeLibraryError,
|
||||
_bind_abi_symbol,
|
||||
_call_json_returning_abi,
|
||||
call_string_abi,
|
||||
)
|
||||
|
||||
|
||||
def parse_ruff_diagnostics(
|
||||
stdout_text: str, primary_remote_path: str
|
||||
) -> Tuple[Dict[str, Any], ...]:
|
||||
"""Parse Ruff ``--output-format json`` stdout into diagnostic records.
|
||||
|
||||
Returns an empty tuple on any failure (non-JSON, wrong shape, ABI error).
|
||||
"""
|
||||
lib = _loader._native_lib()
|
||||
func = lib.sessions_tool_parse_ruff_diagnostics
|
||||
func.argtypes = [
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
|
||||
stdout_arg = ctypes.c_char_p(stdout_text.encode("utf-8"))
|
||||
path_arg = ctypes.c_char_p(primary_remote_path.encode("utf-8"))
|
||||
capacity = 4096
|
||||
while True:
|
||||
out_buf = ctypes.create_string_buffer(capacity)
|
||||
rc = int(func(stdout_arg, path_arg, out_buf, capacity))
|
||||
if rc == 0:
|
||||
try:
|
||||
payload = json.loads(out_buf.value.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return ()
|
||||
if not isinstance(payload, list):
|
||||
return ()
|
||||
return tuple(item for item in payload if isinstance(item, dict))
|
||||
if rc > 0:
|
||||
if rc > capacity:
|
||||
capacity = rc
|
||||
continue
|
||||
return ()
|
||||
return ()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings normalization (Wave 1.5 amend §F).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _settings_normalize_call(symbol: str, raw_json: str) -> Any:
|
||||
"""Run a settings-normalize ABI symbol and return the parsed JSON value.
|
||||
|
||||
On any failure (NULL, invalid utf8, serialization bug, decode error)
|
||||
raise ``SessionsNativeLibraryError`` — settings load is wrapped at the
|
||||
Sublime boundary, so propagating is preferable to silent fallback here.
|
||||
"""
|
||||
func = _bind_abi_symbol(
|
||||
symbol,
|
||||
[ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
serialized = call_string_abi(
|
||||
func,
|
||||
(ctypes.c_char_p(raw_json.encode("utf-8")),),
|
||||
failure_prefix=symbol,
|
||||
)
|
||||
try:
|
||||
return json.loads(serialized)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"{} returned non-JSON output".format(symbol)
|
||||
) from exc
|
||||
|
||||
|
||||
def normalize_python_tool_pipeline(raw_value: Any) -> Tuple[str, ...]:
|
||||
"""Normalize ``sessions_remote_python_tool_pipeline`` user setting."""
|
||||
raw_json = json.dumps(raw_value)
|
||||
out = _settings_normalize_call("sessions_settings_normalize_pipeline", raw_json)
|
||||
if not isinstance(out, list):
|
||||
return ()
|
||||
return tuple(item for item in out if isinstance(item, str))
|
||||
|
||||
|
||||
def normalize_code_server_specs_json(raw_value: Any) -> Tuple[Dict[str, Any], ...]:
|
||||
"""Normalize ``sessions_remote_code_servers`` user setting."""
|
||||
raw_json = json.dumps(raw_value)
|
||||
out = _settings_normalize_call("sessions_settings_normalize_code_server", raw_json)
|
||||
if not isinstance(out, list):
|
||||
return ()
|
||||
return tuple(item for item in out if isinstance(item, dict))
|
||||
|
||||
|
||||
def normalize_remote_extension_specs_json(
|
||||
raw_value: Any,
|
||||
) -> Tuple[Dict[str, Any], ...]:
|
||||
"""Normalize ``sessions_remote_extensions`` user setting."""
|
||||
raw_json = json.dumps(raw_value)
|
||||
out = _settings_normalize_call("sessions_settings_normalize_extensions", raw_json)
|
||||
if not isinstance(out, list):
|
||||
return ()
|
||||
return tuple(item for item in out if isinstance(item, dict))
|
||||
|
||||
|
||||
def derive_venv_name(remote_path: str) -> str:
|
||||
"""Return a human-friendly venv label for ``remote_path`` (Wave 1.5 amend §F)."""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_interpreter_derive_venv_name",
|
||||
[ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
return call_string_abi(
|
||||
func,
|
||||
(ctypes.c_char_p(remote_path.encode("utf-8")),),
|
||||
failure_prefix="sessions_interpreter_derive_venv_name",
|
||||
)
|
||||
|
||||
|
||||
def eager_hydrate_find_candidates(
|
||||
cache_root: str, allowed_basenames: Sequence[str]
|
||||
) -> Tuple[str, ...]:
|
||||
"""Walk ``cache_root`` for zero-byte placeholders matching the allow-list.
|
||||
|
||||
Wave 2 PR 14 — BFS + size filter live in
|
||||
``sessions_native::eager_hydrate``. Batching/sleep pacing stays in Python
|
||||
so the FFI surface is one call per pass instead of one per file.
|
||||
Empty allow-list or non-existent root yields an empty tuple.
|
||||
"""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_eager_hydrate_find_candidates",
|
||||
[ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t],
|
||||
)
|
||||
joined = "\x1f".join(name for name in allowed_basenames if name)
|
||||
out = call_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(cache_root.encode("utf-8")),
|
||||
ctypes.c_char_p(joined.encode("utf-8")),
|
||||
),
|
||||
failure_prefix="sessions_eager_hydrate_find_candidates",
|
||||
)
|
||||
if not out:
|
||||
return ()
|
||||
return tuple(out.split("\x1f"))
|
||||
|
||||
|
||||
def eager_hydrate_apply(
|
||||
*,
|
||||
cache_root: str,
|
||||
host_alias: str,
|
||||
remote_workspace_root: str,
|
||||
allowed_basenames: Sequence[str],
|
||||
batch_size: int,
|
||||
batch_sleep_ms: int,
|
||||
max_open_bytes: int,
|
||||
binary_probe_bytes: int,
|
||||
allow_empty: bool,
|
||||
timeout_ms: int,
|
||||
parallelism: int = 1,
|
||||
) -> Dict[str, Any]:
|
||||
"""Drive one Rust eager-hydrate apply pass (PR-B / PR 17 + PR-B.1).
|
||||
|
||||
Rust owns: candidate discovery, batch loop, batch_sleep pacing,
|
||||
re-check zero-byte, local→remote mapping, ``file_open`` transaction,
|
||||
outcome counting. ``parallelism`` controls how many ``file_open``
|
||||
transactions Rust runs concurrently per batch (broker session
|
||||
multiplexes by envelope id, so concurrent file/read is safe).
|
||||
Python writes sidecar metadata for ``hydrated`` entries and emits
|
||||
the trace event.
|
||||
|
||||
Returns a dict with keys ``hydrated`` (list of
|
||||
``{"local_path": ..., "metadata": ...}``), ``skipped_existing``,
|
||||
``failed``.
|
||||
"""
|
||||
decoded = _call_json_returning_abi(
|
||||
"sessions_eager_hydrate_apply",
|
||||
(
|
||||
cache_root,
|
||||
host_alias,
|
||||
remote_workspace_root,
|
||||
"\x1f".join(name for name in allowed_basenames if name),
|
||||
ctypes.c_size_t(int(batch_size)),
|
||||
ctypes.c_uint64(int(batch_sleep_ms)),
|
||||
ctypes.c_uint64(int(max_open_bytes)),
|
||||
ctypes.c_size_t(int(binary_probe_bytes)),
|
||||
ctypes.c_int(1 if allow_empty else 0),
|
||||
ctypes.c_uint64(int(timeout_ms)),
|
||||
ctypes.c_size_t(int(max(1, parallelism))),
|
||||
),
|
||||
argtypes=[
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_int,
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_size_t,
|
||||
],
|
||||
initial_buf=64 * 1024,
|
||||
)
|
||||
if decoded is None:
|
||||
return {"hydrated": [], "skipped_existing": 0, "failed": 0}
|
||||
return decoded
|
||||
|
||||
|
||||
def merge_remote_extension_catalog_json(
|
||||
builtin_specs: Sequence[Dict[str, Any]], user_raw: Any
|
||||
) -> Tuple[Dict[str, Any], ...]:
|
||||
"""Merge user remote-extension specs over a Python-supplied builtin catalog."""
|
||||
func = _bind_abi_symbol(
|
||||
"sessions_settings_merge_extension_catalog",
|
||||
[
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
],
|
||||
)
|
||||
builtin_json = json.dumps(list(builtin_specs))
|
||||
user_json = json.dumps(user_raw)
|
||||
serialized = call_string_abi(
|
||||
func,
|
||||
(
|
||||
ctypes.c_char_p(builtin_json.encode("utf-8")),
|
||||
ctypes.c_char_p(user_json.encode("utf-8")),
|
||||
),
|
||||
failure_prefix="sessions_settings_merge_extension_catalog",
|
||||
)
|
||||
try:
|
||||
out = json.loads(serialized)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_settings_merge_extension_catalog returned non-JSON output"
|
||||
) from exc
|
||||
if not isinstance(out, list):
|
||||
return ()
|
||||
return tuple(item for item in out if isinstance(item, dict))
|
||||
66
sublime/sessions/_rust_ffi/_workspace.py
Normal file
66
sublime/sessions/_rust_ffi/_workspace.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Workspace path helpers (`normalize_remote_root`, `workspace_cache_key`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
|
||||
from . import _loader
|
||||
from ._loader import SessionsNativeLibraryError, call_string_abi
|
||||
|
||||
|
||||
def normalize_remote_root(remote_root: str) -> str:
|
||||
"""Return a canonical POSIX-like remote root string (Rust single source)."""
|
||||
lib = _loader._native_lib()
|
||||
func = lib.sessions_workspace_normalize_remote_root
|
||||
func.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t]
|
||||
func.restype = ctypes.c_int
|
||||
|
||||
in_arg = ctypes.c_char_p(remote_root.encode("utf-8"))
|
||||
capacity = 4096
|
||||
while True:
|
||||
out_buf = ctypes.create_string_buffer(capacity)
|
||||
rc = func(in_arg, out_buf, capacity)
|
||||
if rc == 0:
|
||||
return out_buf.value.decode("utf-8")
|
||||
if rc < 0:
|
||||
detail = {-1: "null pointer", -2: "invalid utf-8", -4: "path too long"}.get(
|
||||
rc, "code {}".format(rc)
|
||||
)
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_workspace_normalize_remote_root failed: {}".format(detail)
|
||||
)
|
||||
need = int(rc)
|
||||
if need > capacity:
|
||||
capacity = need
|
||||
continue
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_workspace_normalize_remote_root unexpected rc={}".format(rc)
|
||||
)
|
||||
|
||||
|
||||
def workspace_cache_key(host_alias: str, remote_root: str, profile: str = "") -> str:
|
||||
"""Return workspace cache key from Rust workspace_identity implementation."""
|
||||
lib = _loader._native_lib()
|
||||
try:
|
||||
func = lib.sessions_workspace_cache_key
|
||||
except AttributeError as exc:
|
||||
raise SessionsNativeLibraryError(
|
||||
"sessions_workspace_cache_key symbol unavailable"
|
||||
) from exc
|
||||
func.argtypes = [
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
func.restype = ctypes.c_int
|
||||
|
||||
in_host = ctypes.c_char_p(host_alias.encode("utf-8"))
|
||||
in_root = ctypes.c_char_p(remote_root.encode("utf-8"))
|
||||
in_profile = ctypes.c_char_p(profile.encode("utf-8")) if profile else None
|
||||
return call_string_abi(
|
||||
func,
|
||||
(in_host, in_root, in_profile),
|
||||
failure_prefix="sessions_workspace_cache_key",
|
||||
)
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Agent→editor JSON envelopes: validation runs only in Rust.
|
||||
|
||||
Parsing and schema rules live in the ``agent_remote_payload`` Rust crate and are
|
||||
invoked through the ``local_bridge parse-agent-editor-envelope`` CLI (stdin =
|
||||
remote stdout text). Python here is transport glue only—**no duplicate
|
||||
validation logic**. Build ``local_bridge`` before running Python tests that
|
||||
touch this module (see repo pre-commit / ``cargo build -p local_bridge``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from .ssh_runner import _subprocess_no_window_kwargs
|
||||
|
||||
AGENT_EDITOR_PREVIEW_KIND = "sessions.agent_editor_preview"
|
||||
SUPPORTED_SCHEMA_VERSION = 1
|
||||
|
||||
_BRIDGE_AGENT_PARSE_TIMEOUT_S = 15.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentEditorPayload:
|
||||
"""Pre-rendered text for editor-side preview (diff already computed remotely)."""
|
||||
|
||||
kind: str
|
||||
schema_version: int
|
||||
title: str
|
||||
unified_diff: str
|
||||
target_remote_path: Optional[str] = None
|
||||
|
||||
|
||||
def _payload_from_bridge_dict(data: Dict[str, Any]) -> AgentEditorPayload:
|
||||
raw_path = data.get("target_remote_path")
|
||||
target_remote_path = None if raw_path is None else str(raw_path)
|
||||
return AgentEditorPayload(
|
||||
kind=str(data["kind"]),
|
||||
schema_version=int(data["schema_version"]),
|
||||
title=str(data["title"]),
|
||||
unified_diff=str(data["unified_diff"]),
|
||||
target_remote_path=target_remote_path,
|
||||
)
|
||||
|
||||
|
||||
def _parse_via_local_bridge(
|
||||
text: str,
|
||||
) -> Tuple[Optional[AgentEditorPayload], Optional[str]]:
|
||||
"""Run ``local_bridge parse-agent-editor-envelope``; bridge is mandatory."""
|
||||
from .ssh_file_transport import _try_resolved_local_bridge_binary_path
|
||||
|
||||
bridge = _try_resolved_local_bridge_binary_path()
|
||||
if bridge is None:
|
||||
return (
|
||||
None,
|
||||
(
|
||||
"Sessions: local_bridge binary not found. "
|
||||
"From the repo root run: cargo build -p local_bridge "
|
||||
"(or install a Sessions package that ships the bridge)."
|
||||
),
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[str(bridge), "parse-agent-editor-envelope"],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_BRIDGE_AGENT_PARSE_TIMEOUT_S,
|
||||
check=False,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return None, "Sessions: local_bridge agent parse failed: {}".format(exc)
|
||||
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip()
|
||||
detail = tail[:400] if tail else "exit {}".format(proc.returncode)
|
||||
return None, "Sessions: local_bridge agent parse failed: {}".format(detail)
|
||||
|
||||
try:
|
||||
outer = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, "Sessions: local_bridge returned invalid JSON: {}".format(exc)
|
||||
|
||||
result = outer.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return None, "Sessions: local_bridge output missing result object."
|
||||
|
||||
payload_raw = result.get("agent_editor_payload")
|
||||
err = result.get("agent_editor_error")
|
||||
if payload_raw is not None and isinstance(payload_raw, dict):
|
||||
try:
|
||||
return _payload_from_bridge_dict(payload_raw), None
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
return None, "Sessions: local_bridge payload shape error: {}".format(exc)
|
||||
|
||||
if err is not None and isinstance(err, str):
|
||||
return None, err
|
||||
|
||||
return None, "Sessions: local_bridge returned no payload and no error."
|
||||
|
||||
|
||||
def parse_agent_editor_envelope_from_stdout(
|
||||
text: str,
|
||||
) -> Tuple[Optional[AgentEditorPayload], Optional[str]]:
|
||||
"""Parse remote agent stdout using Rust (``local_bridge``) exclusively."""
|
||||
return _parse_via_local_bridge(text)
|
||||
|
||||
|
||||
def parse_agent_editor_payload(raw: Any) -> Optional[AgentEditorPayload]:
|
||||
"""Parse a mapping using the same Rust path (single-line JSON on stdin)."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
text = json.dumps(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
payload, err = _parse_via_local_bridge(text)
|
||||
if err is not None or payload is None:
|
||||
return None
|
||||
return payload
|
||||
@@ -1,621 +0,0 @@
|
||||
"""Agent window layout, models, and composed view state for Sessions."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from .recent_state import RecentWorkspace, RecentWorkspaceStore
|
||||
|
||||
|
||||
class AgentWindowRegion(str, Enum):
|
||||
"""Primary regions of the agent window shell."""
|
||||
|
||||
LEFT_SESSIONS = "left_sessions"
|
||||
CENTER_ACTIVITY = "center_activity"
|
||||
RIGHT_WORKSPACE = "right_workspace"
|
||||
|
||||
|
||||
ThreePaneRegions = Tuple[AgentWindowRegion, AgentWindowRegion, AgentWindowRegion]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentWindowLayoutSpec:
|
||||
"""Describe the first agent window split per Issue G product decisions.
|
||||
|
||||
Attributes:
|
||||
left_region: Session list placement.
|
||||
center_region: Activity / chat-style summaries.
|
||||
right_region: Editor surface plus directory browsing.
|
||||
summary_first: Prefer structured summaries over raw terminal streams.
|
||||
avoid_full_workbench: Explicitly scope out VS Code-style workbench parity.
|
||||
"""
|
||||
|
||||
left_region: AgentWindowRegion = AgentWindowRegion.LEFT_SESSIONS
|
||||
center_region: AgentWindowRegion = AgentWindowRegion.CENTER_ACTIVITY
|
||||
right_region: AgentWindowRegion = AgentWindowRegion.RIGHT_WORKSPACE
|
||||
summary_first: bool = True
|
||||
avoid_full_workbench: bool = True
|
||||
|
||||
def ordered_regions(self) -> ThreePaneRegions:
|
||||
"""Return left-to-right region order for the prototype shell.
|
||||
|
||||
Args:
|
||||
None.
|
||||
|
||||
Returns:
|
||||
Tuple of regions in visual order.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
return (self.left_region, self.center_region, self.right_region)
|
||||
|
||||
|
||||
DEFAULT_AGENT_WINDOW_LAYOUT = AgentWindowLayoutSpec()
|
||||
|
||||
|
||||
class SessionAvailability(str, Enum):
|
||||
"""High-level availability for a row in the session list."""
|
||||
|
||||
CONNECTED = "connected"
|
||||
OFFLINE_ASSUMED = "offline_assumed"
|
||||
STALE_METADATA = "stale_metadata"
|
||||
CACHE_MISSING = "cache_missing"
|
||||
FOREIGN_SHARED_CACHE = "foreign_shared_cache"
|
||||
|
||||
|
||||
class TimelineEntryKind(str, Enum):
|
||||
"""Kinds of items shown in the center activity stream."""
|
||||
|
||||
USER_CHAT = "user_chat"
|
||||
ASSISTANT_SUMMARY = "assistant_summary"
|
||||
HELPER_ACTION = "helper_action"
|
||||
CLI_ACTION = "cli_action"
|
||||
SYSTEM_EVENT = "system_event"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StructuredActionSummary:
|
||||
"""Structured helper or CLI outcome instead of raw terminal output.
|
||||
|
||||
Attributes:
|
||||
verb: Short verb phrase such as "Format file" or "Run tests".
|
||||
target_remote_path: Optional primary remote path the action touched.
|
||||
exit_code: Process exit code when applicable.
|
||||
duration_ms: Wall duration when known.
|
||||
stderr_preview: Bounded stderr excerpt for debugging rows, not full logs.
|
||||
stdout_line_count: Number of stdout lines suppressed from the summary view.
|
||||
notes: Optional human-readable clarification.
|
||||
"""
|
||||
|
||||
verb: str
|
||||
target_remote_path: Optional[str] = None
|
||||
exit_code: Optional[int] = None
|
||||
duration_ms: Optional[int] = None
|
||||
stderr_preview: Optional[str] = None
|
||||
stdout_line_count: int = 0
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EditorJumpTarget:
|
||||
"""Fast-open target in the local cache mirroring a remote file.
|
||||
|
||||
Attributes:
|
||||
local_cache_path: Path to the cached file on disk.
|
||||
line_one_based: Optional 1-based line to reveal.
|
||||
column_one_based: Optional 1-based column to reveal.
|
||||
remote_path: Optional remote path for UI labels.
|
||||
"""
|
||||
|
||||
local_cache_path: Path
|
||||
line_one_based: Optional[int] = None
|
||||
column_one_based: Optional[int] = None
|
||||
remote_path: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiffProposalRef:
|
||||
"""Reference to a proposed patch with staleness against live files.
|
||||
|
||||
Attributes:
|
||||
proposal_id: Stable identifier for the proposal in local state.
|
||||
paths: Remote paths included in the proposal.
|
||||
source_snapshot_mtime_ns: Best-effort mtime when the proposal was built.
|
||||
current_source_mtime_ns: Observed mtime at review time; optional.
|
||||
"""
|
||||
|
||||
proposal_id: str
|
||||
paths: Tuple[str, ...]
|
||||
source_snapshot_mtime_ns: Optional[int] = None
|
||||
current_source_mtime_ns: Optional[int] = None
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
"""Return True when the underlying file likely changed since generation.
|
||||
|
||||
Args:
|
||||
None.
|
||||
|
||||
Returns:
|
||||
Whether the proposal should be treated as stale.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
snap = self.source_snapshot_mtime_ns
|
||||
cur = self.current_source_mtime_ns
|
||||
if snap is None or cur is None:
|
||||
return False
|
||||
return cur != snap
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectoryPaneDescriptor:
|
||||
"""Describe directory browsing beside editor content (right pane).
|
||||
|
||||
Attributes:
|
||||
pane_id: Stable pane identifier for layout code.
|
||||
root_remote_path: Remote directory root for browsing.
|
||||
root_local_cache_path: Local cache mirror root for the same tree.
|
||||
default_max_depth: Soft cap for shallow listing in the prototype.
|
||||
follow_symlinks: Whether symlink expansion is allowed (default False).
|
||||
"""
|
||||
|
||||
pane_id: str
|
||||
root_remote_path: str
|
||||
root_local_cache_path: Path
|
||||
default_max_depth: int = 2
|
||||
follow_symlinks: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentTimelineEntry:
|
||||
"""One chat-style or activity row in the center pane."""
|
||||
|
||||
entry_id: str
|
||||
timestamp_iso: str
|
||||
kind: TimelineEntryKind
|
||||
title: str
|
||||
body_summary: str
|
||||
action: Optional[StructuredActionSummary] = None
|
||||
jump: Optional[EditorJumpTarget] = None
|
||||
diff: Optional[DiffProposalRef] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentSessionRow:
|
||||
"""One selectable session in the left pane derived from recent metadata."""
|
||||
|
||||
session_id: str
|
||||
host_alias: str
|
||||
remote_root: str
|
||||
cache_key: str
|
||||
last_connected_at: str
|
||||
display_title: str
|
||||
display_subtitle: str
|
||||
expected_cache_dir: Path
|
||||
availability: SessionAvailability
|
||||
disambiguation_hint: Optional[str] = None
|
||||
|
||||
|
||||
def _cache_dir(cache_root: Path, cache_key: str) -> Path:
|
||||
return cache_root / cache_key
|
||||
|
||||
|
||||
def _recency_stale_seconds(last_connected_iso: str, now_epoch_seconds: float) -> bool:
|
||||
"""Heuristic: disconnected sessions older than 7 days are 'stale metadata'."""
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
parsed = datetime.fromisoformat(last_connected_iso.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return True
|
||||
age = now_epoch_seconds - parsed.timestamp()
|
||||
return age > 7 * 24 * 3600
|
||||
|
||||
|
||||
def build_agent_session_rows(
|
||||
entries: Sequence[RecentWorkspace],
|
||||
cache_root: Path,
|
||||
*,
|
||||
now_epoch_seconds: float,
|
||||
live_session_ids: Optional[Mapping[str, bool]] = None,
|
||||
cache_origin_host_by_key: Optional[Mapping[str, str]] = None,
|
||||
current_host_name: str = "local",
|
||||
) -> Tuple[AgentSessionRow, ...]:
|
||||
"""Project recent workspaces into agent window session rows.
|
||||
|
||||
Session identity follows `cache_key` so the same host and remote root with
|
||||
different workspace profiles remain distinct rows when both appear in the
|
||||
supplied entry list.
|
||||
|
||||
Args:
|
||||
entries: Recent workspace entries, typically newest-first.
|
||||
cache_root: Resolved cache root (local or shared).
|
||||
now_epoch_seconds: Current time for staleness heuristics.
|
||||
live_session_ids: Optional map of cache_key -> connected flag.
|
||||
cache_origin_host_by_key: Optional map of cache_key -> host that wrote cache.
|
||||
current_host_name: Label for this machine when checking shared-cache origin.
|
||||
|
||||
Returns:
|
||||
Tuple of session rows aligned with disambiguation rules.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
live: Dict[str, bool] = dict(live_session_ids or {})
|
||||
origins: Dict[str, str] = dict(cache_origin_host_by_key or {})
|
||||
rows: List[AgentSessionRow] = []
|
||||
seen_keys: Set[str] = set()
|
||||
|
||||
for entry in entries:
|
||||
if entry.cache_key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(entry.cache_key)
|
||||
cache_path = _cache_dir(cache_root, entry.cache_key)
|
||||
cache_exists = cache_path.is_dir()
|
||||
origin = origins.get(entry.cache_key)
|
||||
foreign = origin is not None and origin != current_host_name
|
||||
|
||||
if live.get(entry.cache_key):
|
||||
availability = SessionAvailability.CONNECTED
|
||||
elif not cache_exists:
|
||||
availability = SessionAvailability.CACHE_MISSING
|
||||
elif foreign:
|
||||
availability = SessionAvailability.FOREIGN_SHARED_CACHE
|
||||
elif _recency_stale_seconds(entry.last_connected_at, now_epoch_seconds):
|
||||
availability = SessionAvailability.STALE_METADATA
|
||||
else:
|
||||
availability = SessionAvailability.OFFLINE_ASSUMED
|
||||
|
||||
title = "{}: {}".format(entry.host_alias, entry.remote_root)
|
||||
subtitle = "cache {}…".format(entry.cache_key[:8])
|
||||
disambiguation: Optional[str] = None
|
||||
same_root_prior = [
|
||||
r
|
||||
for r in rows
|
||||
if r.host_alias == entry.host_alias and r.remote_root == entry.remote_root
|
||||
]
|
||||
if same_root_prior:
|
||||
disambiguation = "Different workspace profile or cache identity"
|
||||
subtitle = "{} ({})".format(subtitle, entry.cache_key[:12])
|
||||
|
||||
rows.append(
|
||||
AgentSessionRow(
|
||||
session_id=entry.cache_key,
|
||||
host_alias=entry.host_alias,
|
||||
remote_root=entry.remote_root,
|
||||
cache_key=entry.cache_key,
|
||||
last_connected_at=entry.last_connected_at,
|
||||
display_title=title,
|
||||
display_subtitle=subtitle,
|
||||
expected_cache_dir=cache_path,
|
||||
availability=availability,
|
||||
disambiguation_hint=disambiguation,
|
||||
)
|
||||
)
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def trim_timeline_for_long_history(
|
||||
entries: Sequence[AgentTimelineEntry],
|
||||
*,
|
||||
max_entries: int,
|
||||
max_body_chars: int,
|
||||
) -> Tuple[AgentTimelineEntry, ...]:
|
||||
"""Keep the newest slice and clamp oversized bodies for the activity pane.
|
||||
|
||||
Args:
|
||||
entries: Timeline entries oldest-to-newest or arbitrary order.
|
||||
max_entries: Maximum number of entries to retain (newest last).
|
||||
max_body_chars: Maximum characters kept in each body summary.
|
||||
|
||||
Returns:
|
||||
Trimmed tuple of entries.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
ordered = list(entries)
|
||||
if len(ordered) > max_entries:
|
||||
ordered = ordered[-max_entries:]
|
||||
trimmed: List[AgentTimelineEntry] = []
|
||||
for item in ordered:
|
||||
body = item.body_summary
|
||||
if len(body) > max_body_chars:
|
||||
body = body[: max_body_chars - 1] + "…"
|
||||
if body == item.body_summary:
|
||||
trimmed.append(item)
|
||||
else:
|
||||
trimmed.append(
|
||||
AgentTimelineEntry(
|
||||
entry_id=item.entry_id,
|
||||
timestamp_iso=item.timestamp_iso,
|
||||
kind=item.kind,
|
||||
title=item.title,
|
||||
body_summary=body,
|
||||
action=item.action,
|
||||
jump=item.jump,
|
||||
diff=item.diff,
|
||||
)
|
||||
)
|
||||
return tuple(trimmed)
|
||||
|
||||
|
||||
def timeline_placeholder_for_missing_cache(
|
||||
session_row: AgentSessionRow,
|
||||
) -> AgentTimelineEntry:
|
||||
"""Return a single system entry when no cache exists yet for the session.
|
||||
|
||||
Args:
|
||||
session_row: Session metadata for the selection.
|
||||
|
||||
Returns:
|
||||
One timeline entry explaining the missing cache state.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
return AgentTimelineEntry(
|
||||
entry_id="missing-cache",
|
||||
timestamp_iso=session_row.last_connected_at,
|
||||
kind=TimelineEntryKind.SYSTEM_EVENT,
|
||||
title="Cache not materialized",
|
||||
body_summary=(
|
||||
"This workspace has no local cache directory yet. "
|
||||
"Reconnect or open the project to materialize cache before browsing files."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def timeline_placeholder_for_foreign_shared_cache(
|
||||
session_row: AgentSessionRow,
|
||||
*,
|
||||
origin_host: str,
|
||||
) -> AgentTimelineEntry:
|
||||
"""Warn when shared cache may have been written on another workstation.
|
||||
|
||||
Args:
|
||||
session_row: Session row flagged as foreign shared cache.
|
||||
origin_host: Host label stored alongside the shared cache root.
|
||||
|
||||
Returns:
|
||||
System timeline entry describing the shared-cache scenario.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
return AgentTimelineEntry(
|
||||
entry_id="foreign-shared-cache",
|
||||
timestamp_iso=session_row.last_connected_at,
|
||||
kind=TimelineEntryKind.SYSTEM_EVENT,
|
||||
title="Shared cache from another machine",
|
||||
body_summary=(
|
||||
"Cache directory appears to originate from `{}`. ".format(origin_host)
|
||||
+ "Review concurrent edits and prefer summary-first diagnostics before "
|
||||
"assuming local mirror freshness."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def example_structured_helper_summary() -> StructuredActionSummary:
|
||||
"""Return a representative helper summary for documentation and tests.
|
||||
|
||||
Args:
|
||||
None.
|
||||
|
||||
Returns:
|
||||
Sample structured summary.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
return StructuredActionSummary(
|
||||
verb="Read remote file",
|
||||
target_remote_path="/srv/app/README.md",
|
||||
exit_code=0,
|
||||
duration_ms=42,
|
||||
stderr_preview=None,
|
||||
stdout_line_count=0,
|
||||
notes="Remote metadata matched cache mapping.",
|
||||
)
|
||||
|
||||
|
||||
def collect_jump_targets_from_timeline(
|
||||
entries: Iterable[AgentTimelineEntry],
|
||||
) -> Tuple[EditorJumpTarget, ...]:
|
||||
"""Extract explicit editor jump targets linked from timeline rows.
|
||||
|
||||
Args:
|
||||
entries: Timeline entries possibly containing jump targets.
|
||||
|
||||
Returns:
|
||||
Tuple of unique jump targets in encounter order.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
out: List[EditorJumpTarget] = []
|
||||
seen: Set[Tuple[str, Optional[int], Optional[int]]] = set()
|
||||
for item in entries:
|
||||
if item.jump is None:
|
||||
continue
|
||||
key = (
|
||||
str(item.jump.local_cache_path),
|
||||
item.jump.line_one_based,
|
||||
item.jump.column_one_based,
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(item.jump)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentWindowViewState:
|
||||
"""Bundle layout, session list, and center-pane content for one snapshot.
|
||||
|
||||
Attributes:
|
||||
layout: Region ordering and presentation flags.
|
||||
session_rows: Left-pane rows from recent metadata.
|
||||
selected_session_id: Currently focused `cache_key`, if any.
|
||||
timeline_entries: Center-pane activity stream after trimming.
|
||||
directory_pane: Optional right-pane directory descriptor.
|
||||
"""
|
||||
|
||||
layout: AgentWindowLayoutSpec
|
||||
session_rows: Tuple[AgentSessionRow, ...]
|
||||
selected_session_id: Optional[str]
|
||||
timeline_entries: Tuple[AgentTimelineEntry, ...]
|
||||
directory_pane: Optional[DirectoryPaneDescriptor] = None
|
||||
|
||||
|
||||
def build_agent_window_view_state(
|
||||
entries: Sequence[RecentWorkspace],
|
||||
cache_root: Path,
|
||||
*,
|
||||
now_epoch_seconds: float,
|
||||
selected_session_id: Optional[str],
|
||||
raw_timeline: Sequence[AgentTimelineEntry],
|
||||
live_session_ids: Optional[Mapping[str, bool]] = None,
|
||||
cache_origin_host_by_key: Optional[Mapping[str, str]] = None,
|
||||
current_host_name: str = "local",
|
||||
timeline_max_entries: int = 200,
|
||||
timeline_max_body_chars: int = 4000,
|
||||
layout: AgentWindowLayoutSpec = DEFAULT_AGENT_WINDOW_LAYOUT,
|
||||
) -> AgentWindowViewState:
|
||||
"""Assemble agent window snapshot state for tests and future UI wiring.
|
||||
|
||||
When the selected session has no cache directory, the timeline is replaced
|
||||
with a placeholder explaining reconnect is required. Foreign shared-cache
|
||||
sessions prepend a warning entry before trimmed timeline content.
|
||||
|
||||
Args:
|
||||
entries: Recent workspace entries backing the session list.
|
||||
cache_root: Active cache root for path resolution.
|
||||
now_epoch_seconds: Wall clock for staleness heuristics.
|
||||
selected_session_id: Which session row is focused.
|
||||
raw_timeline: Unbounded timeline for the selection.
|
||||
live_session_ids: Optional connectivity map keyed by `cache_key`.
|
||||
cache_origin_host_by_key: Optional writer host labels for shared cache.
|
||||
current_host_name: This machine label for foreign-cache detection.
|
||||
timeline_max_entries: Cap for very long histories.
|
||||
timeline_max_body_chars: Per-entry body clamp.
|
||||
layout: Layout spec; defaults to Issue G three-pane ordering.
|
||||
|
||||
Returns:
|
||||
Frozen view state suitable for rendering.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
rows = build_agent_session_rows(
|
||||
entries,
|
||||
cache_root,
|
||||
now_epoch_seconds=now_epoch_seconds,
|
||||
live_session_ids=live_session_ids,
|
||||
cache_origin_host_by_key=cache_origin_host_by_key,
|
||||
current_host_name=current_host_name,
|
||||
)
|
||||
row_by_id = {r.session_id: r for r in rows}
|
||||
selected = row_by_id.get(selected_session_id) if selected_session_id else None
|
||||
|
||||
timeline = trim_timeline_for_long_history(
|
||||
raw_timeline,
|
||||
max_entries=timeline_max_entries,
|
||||
max_body_chars=timeline_max_body_chars,
|
||||
)
|
||||
|
||||
missing = (
|
||||
selected is not None
|
||||
and selected.availability == SessionAvailability.CACHE_MISSING
|
||||
)
|
||||
foreign = (
|
||||
selected is not None
|
||||
and selected.availability == SessionAvailability.FOREIGN_SHARED_CACHE
|
||||
)
|
||||
if missing:
|
||||
timeline = (timeline_placeholder_for_missing_cache(selected),)
|
||||
elif foreign:
|
||||
origins_map = cache_origin_host_by_key or {}
|
||||
origin = origins_map.get(selected.cache_key, "unknown-host")
|
||||
warn = timeline_placeholder_for_foreign_shared_cache(
|
||||
selected,
|
||||
origin_host=origin,
|
||||
)
|
||||
timeline = (warn, *timeline)
|
||||
|
||||
directory: Optional[DirectoryPaneDescriptor] = None
|
||||
has_cache = (
|
||||
selected is not None
|
||||
and selected.availability != SessionAvailability.CACHE_MISSING
|
||||
)
|
||||
if has_cache:
|
||||
directory = DirectoryPaneDescriptor(
|
||||
pane_id="tree-{}".format(selected.cache_key[:8]),
|
||||
root_remote_path=selected.remote_root,
|
||||
root_local_cache_path=selected.expected_cache_dir,
|
||||
)
|
||||
|
||||
return AgentWindowViewState(
|
||||
layout=layout,
|
||||
session_rows=rows,
|
||||
selected_session_id=selected_session_id,
|
||||
timeline_entries=timeline,
|
||||
directory_pane=directory,
|
||||
)
|
||||
|
||||
|
||||
def load_agent_window_view_state_from_recent_store(
|
||||
store: RecentWorkspaceStore,
|
||||
cache_root: Path,
|
||||
*,
|
||||
now_epoch_seconds: float,
|
||||
selected_session_id: Optional[str],
|
||||
raw_timeline: Sequence[AgentTimelineEntry],
|
||||
live_session_ids: Optional[Mapping[str, bool]] = None,
|
||||
cache_origin_host_by_key: Optional[Mapping[str, str]] = None,
|
||||
current_host_name: str = "local",
|
||||
timeline_max_entries: int = 200,
|
||||
timeline_max_body_chars: int = 4000,
|
||||
layout: AgentWindowLayoutSpec = DEFAULT_AGENT_WINDOW_LAYOUT,
|
||||
) -> AgentWindowViewState:
|
||||
"""Build view state from persisted local-only recent workspace metadata.
|
||||
|
||||
Args:
|
||||
store: Recent workspace JSON store.
|
||||
cache_root: Resolved cache root for session rows.
|
||||
now_epoch_seconds: Wall clock for staleness heuristics.
|
||||
selected_session_id: Which session row is focused.
|
||||
raw_timeline: Unbounded timeline for the selection.
|
||||
live_session_ids: Optional connectivity map keyed by `cache_key`.
|
||||
cache_origin_host_by_key: Optional writer host labels for shared cache.
|
||||
current_host_name: This machine label for foreign-cache detection.
|
||||
timeline_max_entries: Cap for very long histories.
|
||||
timeline_max_body_chars: Per-entry body clamp.
|
||||
layout: Layout spec; defaults to Issue G three-pane ordering.
|
||||
|
||||
Returns:
|
||||
Assembled ``AgentWindowViewState``.
|
||||
|
||||
Raises:
|
||||
OSError: If the store cannot be read.
|
||||
json.JSONDecodeError: If stored JSON is invalid.
|
||||
TypeError: If stored entries do not match the schema.
|
||||
"""
|
||||
index = store.load_index()
|
||||
return build_agent_window_view_state(
|
||||
index.entries,
|
||||
cache_root,
|
||||
now_epoch_seconds=now_epoch_seconds,
|
||||
selected_session_id=selected_session_id,
|
||||
raw_timeline=raw_timeline,
|
||||
live_session_ids=live_session_ids,
|
||||
cache_origin_host_by_key=cache_origin_host_by_key,
|
||||
current_host_name=current_host_name,
|
||||
timeline_max_entries=timeline_max_entries,
|
||||
timeline_max_body_chars=timeline_max_body_chars,
|
||||
layout=layout,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
769
sublime/sessions/commands_file_actions.py
Normal file
769
sublime/sessions/commands_file_actions.py
Normal file
@@ -0,0 +1,769 @@
|
||||
"""Open / save remote-file commands extracted from :mod:`commands`.
|
||||
|
||||
This submodule owns the open-remote-file and save-remote-file workflows for
|
||||
Sessions workspaces, including the on-post-save listener that mirrors local
|
||||
cache writes back to the remote host. Save-conflict resolution helpers and
|
||||
metadata sidecar I/O remain in :mod:`commands` because they are shared with
|
||||
on-demand fetch, eager hydrate, and remote tool refresh paths.
|
||||
|
||||
Patchable helpers are looked up on the parent ``commands`` module via
|
||||
``from . import commands as _root`` so existing
|
||||
``monkeypatch.setattr(commands, "X", ...)`` test patterns keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Dict, Optional
|
||||
|
||||
from . import commands as _root
|
||||
from .connect_preflight import (
|
||||
ConnectPreflightError,
|
||||
ConnectStatus,
|
||||
validate_remote_root,
|
||||
)
|
||||
from .file_state import (
|
||||
OpenOutcome,
|
||||
RemotePathMappingError,
|
||||
RemoteToLocalCacheMapper,
|
||||
SaveConflictKind,
|
||||
SaveFileRequest,
|
||||
SaveFileResult,
|
||||
SaveOutcome,
|
||||
evaluate_save_file,
|
||||
)
|
||||
from .lsp_save_preferences import lsp_format_on_save_enabled
|
||||
from .remote import RemoteWriteErrorCode, RemoteWriteFileRequest
|
||||
from .settings_model import SessionsSettings
|
||||
|
||||
try:
|
||||
sublime_plugin = importlib.import_module("sublime_plugin")
|
||||
except ImportError: # pragma: no cover
|
||||
|
||||
class _FallbackWindowCommand:
|
||||
def __init__(self, window: object) -> None:
|
||||
self.window = window
|
||||
|
||||
class _FallbackEventListener:
|
||||
pass
|
||||
|
||||
class _FallbackSublimePlugin:
|
||||
WindowCommand = _FallbackWindowCommand
|
||||
EventListener = _FallbackEventListener
|
||||
|
||||
sublime_plugin = _FallbackSublimePlugin()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-workspace open-request serial state owned by the open path.
|
||||
#
|
||||
# Conftest snapshot uses ``getattr(commands, …)`` for these names; the parent
|
||||
# façade re-exports them so the lookup keeps resolving correctly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OPEN_REQUEST_SERIAL_BY_WORKSPACE: Dict[str, int] = {}
|
||||
_OPEN_REQUEST_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ResolvedRemoteFileTarget:
|
||||
"""Resolved input for open/save of a workspace-scoped remote file.
|
||||
|
||||
The two flows (``_open_remote_file_for_workspace`` and
|
||||
``_save_remote_file_for_workspace``) share an identical preamble:
|
||||
trim the user-provided string, build a per-workspace cache mapper,
|
||||
normalize the remote path against the workspace root, and project
|
||||
that onto a local cache path. Sharing the resolution keeps the
|
||||
error wording — and the order in which it is reported — consistent
|
||||
between the two directions.
|
||||
"""
|
||||
|
||||
mapper: RemoteToLocalCacheMapper
|
||||
normalized_remote_file: str
|
||||
local_cache_path: Path
|
||||
|
||||
|
||||
def _resolve_workspace_remote_target(
|
||||
context, remote_file: str
|
||||
) -> Optional[_ResolvedRemoteFileTarget]:
|
||||
"""Validate ``remote_file`` against ``context`` for an open/save request.
|
||||
|
||||
Returns ``None`` after emitting a user-visible status when the input
|
||||
is empty, the remote path fails preflight validation, or the path
|
||||
escapes the workspace root. Returning ``None`` (rather than raising)
|
||||
keeps the call sites' early-exit shape unchanged.
|
||||
"""
|
||||
remote_text = (remote_file or "").strip()
|
||||
if not remote_text:
|
||||
_root._status_message("Remote file path is required.")
|
||||
return None
|
||||
mapper = RemoteToLocalCacheMapper(
|
||||
workspace_cache_key=context.cache_key,
|
||||
remote_workspace_root=context.recent_entry.remote_root,
|
||||
files_cache_root=context.local_cache_root,
|
||||
)
|
||||
try:
|
||||
normalized_remote_file = _resolve_workspace_remote_file(
|
||||
context.recent_entry.remote_root,
|
||||
remote_text,
|
||||
)
|
||||
local_cache_path = mapper.local_path_for_remote_file(normalized_remote_file)
|
||||
except ConnectPreflightError as error:
|
||||
_root._emit_status(ConnectStatus(kind="disconnected", detail=error.detail))
|
||||
return None
|
||||
except RemotePathMappingError as error:
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="disconnected",
|
||||
detail=(
|
||||
"Remote file must stay within the current Sessions workspace "
|
||||
"root ({}) [{}]."
|
||||
).format(context.recent_entry.remote_root, error),
|
||||
)
|
||||
)
|
||||
return None
|
||||
return _ResolvedRemoteFileTarget(
|
||||
mapper=mapper,
|
||||
normalized_remote_file=normalized_remote_file,
|
||||
local_cache_path=local_cache_path,
|
||||
)
|
||||
|
||||
|
||||
def _open_remote_file_for_workspace(
|
||||
window: object,
|
||||
context,
|
||||
remote_file: str,
|
||||
*,
|
||||
editor_group: Optional[int] = None,
|
||||
) -> None:
|
||||
target = _resolve_workspace_remote_target(context, remote_file)
|
||||
if target is None:
|
||||
return
|
||||
normalized_remote_file = target.normalized_remote_file
|
||||
local_cache_path = target.local_cache_path
|
||||
|
||||
if context.cache_key in _root._MIRROR_SYNC_IN_FLIGHT:
|
||||
_root._status_message(
|
||||
"Sessions: prioritizing selected file fetch while sidebar mirror runs…"
|
||||
)
|
||||
|
||||
host_alias = context.recent_entry.host_alias
|
||||
prioritize_open = _should_prioritize_remote_open(window, local_cache_path)
|
||||
with _OPEN_REQUEST_LOCK:
|
||||
request_serial = _OPEN_REQUEST_SERIAL_BY_WORKSPACE.get(context.cache_key, 0) + 1
|
||||
_OPEN_REQUEST_SERIAL_BY_WORKSPACE[context.cache_key] = request_serial
|
||||
_root._trace_event(
|
||||
"file.open.requested",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
prioritize=prioritize_open,
|
||||
request_serial=request_serial,
|
||||
mirror_sync_in_flight=context.cache_key in _root._MIRROR_SYNC_IN_FLIGHT,
|
||||
)
|
||||
|
||||
def work() -> None:
|
||||
with _OPEN_REQUEST_LOCK:
|
||||
latest_serial = _OPEN_REQUEST_SERIAL_BY_WORKSPACE.get(context.cache_key, 0)
|
||||
if request_serial != latest_serial:
|
||||
_root._trace_event(
|
||||
"file.open.skipped_stale",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
request_serial=request_serial,
|
||||
latest_serial=latest_serial,
|
||||
)
|
||||
return
|
||||
_root._begin_interactive_ssh_lane(host_alias)
|
||||
try:
|
||||
opened = _root.open_remote_file_into_local_cache(
|
||||
host_alias,
|
||||
remote_absolute_path=normalized_remote_file,
|
||||
local_cache_path=local_cache_path,
|
||||
)
|
||||
finally:
|
||||
_root._end_interactive_ssh_lane(host_alias)
|
||||
|
||||
def finish() -> None:
|
||||
with _OPEN_REQUEST_LOCK:
|
||||
finish_latest_serial = _OPEN_REQUEST_SERIAL_BY_WORKSPACE.get(
|
||||
context.cache_key, 0
|
||||
)
|
||||
if request_serial != finish_latest_serial:
|
||||
_root._trace_event(
|
||||
"file.open.finish_skipped_stale",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
request_serial=request_serial,
|
||||
latest_serial=finish_latest_serial,
|
||||
)
|
||||
return
|
||||
if opened.outcome is OpenOutcome.OK:
|
||||
_root._trace_event(
|
||||
"file.open.ok",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
request_serial=request_serial,
|
||||
)
|
||||
if opened.remote_metadata is not None:
|
||||
_root._write_remote_metadata_sidecar(
|
||||
opened.local_cache_path,
|
||||
opened.remote_metadata,
|
||||
)
|
||||
_root._open_local_cache_file(
|
||||
window,
|
||||
opened.local_cache_path,
|
||||
editor_group=editor_group,
|
||||
)
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="ready",
|
||||
detail="Opened remote file {}".format(normalized_remote_file),
|
||||
)
|
||||
)
|
||||
return
|
||||
if opened.outcome is OpenOutcome.REMOTE_NOT_FOUND:
|
||||
_root._trace_event(
|
||||
"file.open.remote_missing",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
)
|
||||
# Data-loss guard: don't delete a local file we never
|
||||
# fetched from the remote (no metadata sidecar). The
|
||||
# 404 here just means the user opened a path that
|
||||
# doesn't exist remotely yet — keep any local-only
|
||||
# content the user might have saved there.
|
||||
if not _root._has_remote_metadata_sidecar(local_cache_path):
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="warning",
|
||||
detail=(
|
||||
"Remote path {} not found; kept local-only file at {}."
|
||||
).format(normalized_remote_file, local_cache_path),
|
||||
)
|
||||
)
|
||||
return
|
||||
_root._alert_stale_remote_path_removed(normalized_remote_file)
|
||||
_root._remove_local_remote_cache_mirror_path(local_cache_path)
|
||||
_root._close_open_views_for_abs_path(window, local_cache_path)
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="warning",
|
||||
detail=(
|
||||
"Remote path {} no longer exists; "
|
||||
"removed stale local cache."
|
||||
).format(normalized_remote_file),
|
||||
)
|
||||
)
|
||||
return
|
||||
if opened.outcome is OpenOutcome.TRANSPORT_ERROR:
|
||||
_root._trace_event(
|
||||
"file.open.transport_error",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
detail=opened.detail or "",
|
||||
)
|
||||
detail = opened.detail or "Remote file open failed over SSH."
|
||||
_root._emit_status(ConnectStatus(kind="disconnected", detail=detail))
|
||||
return
|
||||
if opened.outcome is OpenOutcome.BLOCKED_BINARY_HEURISTIC:
|
||||
_root._trace_event(
|
||||
"file.open.blocked_binary",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
)
|
||||
_root._status_message("Remote file looks binary and was not opened.")
|
||||
return
|
||||
reason = opened.unsupported_reason
|
||||
if reason is None:
|
||||
_root._trace_event(
|
||||
"file.open.blocked",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
)
|
||||
_root._status_message("Remote file open was blocked.")
|
||||
return
|
||||
_root._trace_event(
|
||||
"file.open.blocked",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
reason=getattr(reason, "value", ""),
|
||||
)
|
||||
_root._status_message(_open_blocked_reason_message(reason))
|
||||
|
||||
_root._set_timeout(finish, 0)
|
||||
|
||||
_root._run_in_background(
|
||||
work,
|
||||
prioritize=prioritize_open,
|
||||
task_key="open:{}".format(normalized_remote_file),
|
||||
task_label="open_remote_file",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_workspace_remote_file(remote_root: str, remote_file: str) -> str:
|
||||
stripped = remote_file.strip()
|
||||
if stripped.startswith("/"):
|
||||
return validate_remote_root(stripped)
|
||||
return validate_remote_root(str(PurePosixPath(remote_root) / stripped))
|
||||
|
||||
|
||||
def _should_prioritize_remote_open(window: object, local_cache_path: Path) -> bool:
|
||||
"""Prioritize only when the same local cache file is already open in a tab."""
|
||||
find_open_file = getattr(window, "find_open_file", None)
|
||||
if not callable(find_open_file):
|
||||
return False
|
||||
try:
|
||||
return find_open_file(str(local_cache_path)) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _open_blocked_reason_message(reason: object) -> str:
|
||||
value = getattr(reason, "value", "")
|
||||
if value == "file_too_large":
|
||||
return "Remote file is too large to open into the local cache."
|
||||
if value == "unsupported_remote_kind":
|
||||
return "Remote path is not a regular file and cannot be opened."
|
||||
if value == "zero_byte_read_not_allowed":
|
||||
return "Remote file is empty and current open policy blocks it."
|
||||
return "Remote file open was blocked by policy."
|
||||
|
||||
|
||||
def _save_remote_file_for_workspace(
|
||||
window: object,
|
||||
context,
|
||||
remote_file: str,
|
||||
*,
|
||||
post_save_view: Optional[object] = None,
|
||||
) -> None:
|
||||
target = _resolve_workspace_remote_target(context, remote_file)
|
||||
if target is None:
|
||||
return
|
||||
normalized_remote_file = target.normalized_remote_file
|
||||
local_cache_path = target.local_cache_path
|
||||
if not local_cache_path.is_file():
|
||||
_root._status_message(
|
||||
"Open the remote file first so Sessions has a local cache copy to save."
|
||||
)
|
||||
return
|
||||
baseline_metadata = _root._read_remote_metadata_sidecar(local_cache_path)
|
||||
# ``baseline_metadata is None`` is the brand-new-file case: the user
|
||||
# created a buffer under the cache mirror via Save As and there is
|
||||
# no sidecar yet (we never fetched this path from remote). The Rust
|
||||
# helper's ``Missing`` precondition handles this — and as of the
|
||||
# 2026-04-26 mkdir patch it also creates any missing parent
|
||||
# directories, so a "new folder + new file inside it" save lands
|
||||
# remotely without a separate mkdir step. Don't refuse here; let
|
||||
# the write run with no baseline.
|
||||
local_body = local_cache_path.read_bytes()
|
||||
local_digest = hashlib.sha256(local_body).hexdigest()
|
||||
last_pushed_digest = _root._read_last_pushed_sha256(local_cache_path)
|
||||
try:
|
||||
candidate_metadata = _root.execute_remote_stat_file(
|
||||
context.recent_entry.host_alias,
|
||||
normalized_remote_file,
|
||||
)
|
||||
except _root.SessionHelperStartError as error:
|
||||
_root._emit_status(ConnectStatus(kind="disconnected", detail=error.detail))
|
||||
return
|
||||
# New-file create path: when there is no sidecar, the conflict
|
||||
# evaluator would otherwise return BASELINE_UNKNOWN and refuse the
|
||||
# write. Two sub-cases:
|
||||
# (a) no sidecar AND remote stat returned None — brand-new file
|
||||
# under a (possibly brand-new) folder. Skip the conflict
|
||||
# evaluator entirely; the Rust helper's ``Missing``
|
||||
# precondition + mkdir-p will create both.
|
||||
# (b) no sidecar AND remote already exists — we'd be overwriting
|
||||
# a remote file the user never fetched. Refuse and prompt
|
||||
# them to open the remote file first so save semantics stay
|
||||
# conservative.
|
||||
if baseline_metadata is None:
|
||||
if candidate_metadata is None:
|
||||
evaluated = SaveFileResult(outcome=SaveOutcome.OK)
|
||||
else:
|
||||
_root._status_message(
|
||||
"Remote path {} already exists. Open it first so Sessions has "
|
||||
"a baseline before overwriting.".format(normalized_remote_file)
|
||||
)
|
||||
return
|
||||
else:
|
||||
evaluated = evaluate_save_file(
|
||||
SaveFileRequest(
|
||||
remote_absolute_path=normalized_remote_file,
|
||||
local_cache_path=local_cache_path,
|
||||
baseline_remote_metadata=baseline_metadata,
|
||||
candidate_remote_metadata=candidate_metadata,
|
||||
)
|
||||
)
|
||||
if evaluated.outcome is SaveOutcome.CONFLICT and evaluated.conflict is not None:
|
||||
if evaluated.conflict.kind is SaveConflictKind.REMOTE_METADATA_CHANGED:
|
||||
_root._handle_save_conflict(
|
||||
window,
|
||||
context,
|
||||
normalized_remote_file,
|
||||
local_cache_path,
|
||||
candidate_metadata,
|
||||
)
|
||||
else:
|
||||
_root._emit_status(
|
||||
ConnectStatus(kind="warning", detail=evaluated.conflict.message)
|
||||
)
|
||||
return
|
||||
if (
|
||||
last_pushed_digest is not None
|
||||
and last_pushed_digest == local_digest
|
||||
and evaluated.outcome is SaveOutcome.OK
|
||||
):
|
||||
_root._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
candidate_metadata,
|
||||
last_pushed_sha256=local_digest,
|
||||
)
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="ready",
|
||||
detail=("Remote file unchanged locally; skipped upload for {}.").format(
|
||||
normalized_remote_file
|
||||
),
|
||||
)
|
||||
)
|
||||
_root._maybe_schedule_remote_python_pipeline_after_cache_push(
|
||||
window, post_save_view, context, normalized_remote_file
|
||||
)
|
||||
return
|
||||
# Mark the remote path as a self-save *before* the write so the watch
|
||||
# loop ignores both pre-write inotify ticks (the pending fsync) and
|
||||
# post-write echoes for the duration of ``_RECENT_SELF_SAVE_COOLDOWN_S``.
|
||||
# Without this, the file/watch ``changed_paths`` set bounces our own
|
||||
# write back into Sublime as an external "reloading <path>" reload —
|
||||
# the chatter the v0.5.5 fix originally targeted, regressed here.
|
||||
_root._mark_recent_self_save(normalized_remote_file)
|
||||
write_result = _root.execute_remote_write_file(
|
||||
context.recent_entry.host_alias,
|
||||
RemoteWriteFileRequest(
|
||||
remote_absolute_path=normalized_remote_file,
|
||||
content=local_body,
|
||||
expected_remote_metadata=baseline_metadata,
|
||||
),
|
||||
)
|
||||
if write_result.ok and write_result.updated_metadata is not None:
|
||||
_root._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
write_result.updated_metadata,
|
||||
last_pushed_sha256=local_digest,
|
||||
)
|
||||
# Renew the cooldown after sidecar update — there is still a window
|
||||
# where a delayed watch tick can arrive after the sidecar matches,
|
||||
# but the cooldown extends past that race.
|
||||
_root._mark_recent_self_save(normalized_remote_file)
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="ready",
|
||||
detail="Saved remote file {}".format(normalized_remote_file),
|
||||
)
|
||||
)
|
||||
run_format = (
|
||||
post_save_view is not None
|
||||
and normalized_remote_file.endswith(".py")
|
||||
and lsp_format_on_save_enabled(post_save_view)
|
||||
)
|
||||
_root._schedule_format_then_pipeline_after_cache_push(
|
||||
window,
|
||||
post_save_view,
|
||||
context,
|
||||
normalized_remote_file,
|
||||
run_format=run_format,
|
||||
)
|
||||
return
|
||||
if write_result.error_code is RemoteWriteErrorCode.TRANSPORT_ERROR:
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="disconnected",
|
||||
detail=(
|
||||
write_result.error_message or "Remote file save failed over SSH."
|
||||
),
|
||||
)
|
||||
)
|
||||
return
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="warning",
|
||||
detail=write_result.error_message or "Remote file save was rejected.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SessionsOpenRemoteFileCommand(sublime_plugin.WindowCommand):
|
||||
"""Fetch one remote file into the local cache and open the mirrored file."""
|
||||
|
||||
def run(self, remote_file: str = "") -> None:
|
||||
"""Open a remote file for the current Sessions workspace."""
|
||||
settings = SessionsSettings()
|
||||
context = _root._workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
if (remote_file or "").strip():
|
||||
_root._open_remote_file_for_workspace(self.window, context, remote_file)
|
||||
return
|
||||
_root._browse_remote_file_for_workspace(
|
||||
self.window,
|
||||
context,
|
||||
context.recent_entry.remote_root,
|
||||
)
|
||||
|
||||
|
||||
class SessionsSaveRemoteFileCommand(sublime_plugin.WindowCommand):
|
||||
"""Push one cached remote file back to the server for the current workspace."""
|
||||
|
||||
def run(self, remote_file: str = "") -> None:
|
||||
"""Save a cached remote file back to the remote workspace."""
|
||||
settings = SessionsSettings()
|
||||
context = _root._workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
if (remote_file or "").strip():
|
||||
_root._save_remote_file_for_workspace(
|
||||
self.window,
|
||||
context,
|
||||
remote_file,
|
||||
post_save_view=_root._active_view(self.window),
|
||||
)
|
||||
return
|
||||
self.window.show_input_panel(
|
||||
"Remote file:",
|
||||
"",
|
||||
lambda value: _root._save_remote_file_for_workspace(
|
||||
self.window,
|
||||
context,
|
||||
value,
|
||||
post_save_view=_root._active_view(self.window),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _delete_remote_file_for_workspace(
|
||||
window: object,
|
||||
context,
|
||||
remote_file: str,
|
||||
) -> None:
|
||||
"""Delete one remote file (and its mirrored cache copy).
|
||||
|
||||
Lazy-mirror policy: local sidebar deletes intentionally do NOT
|
||||
propagate to the remote so silent data loss can't happen behind
|
||||
the user's back. This is the explicit user-confirmed escape hatch
|
||||
triggered by the ``Sessions: Delete Remote File`` palette /
|
||||
sidebar context-menu command.
|
||||
|
||||
Order of operations:
|
||||
|
||||
1. Resolve + validate ``remote_file`` against the workspace root.
|
||||
2. Issue an ``rm -f --`` over the bridge's ``exec/once`` channel.
|
||||
A non-zero exit surfaces as a status warning and aborts the
|
||||
local cleanup so the two sides stay coherent. ``rm -f``
|
||||
already treats ENOENT as success (the user explicitly asked
|
||||
to drop this path; remote-already-gone is the desired end
|
||||
state) so the local cache copy still gets removed in that
|
||||
case.
|
||||
3. On success: tear down the local cache copy + sidecars + close
|
||||
any open Sublime view of the file. Trace
|
||||
``file.delete.remote_done`` so the operation is auditable
|
||||
from ``debug-trace.log`` alone.
|
||||
|
||||
Refusal cases (early-return without touching the remote):
|
||||
|
||||
* Empty / unmappable ``remote_file`` — surfaces the existing
|
||||
``_resolve_workspace_remote_target`` status messages.
|
||||
* The path resolves outside the workspace root — same.
|
||||
"""
|
||||
target = _resolve_workspace_remote_target(context, remote_file)
|
||||
if target is None:
|
||||
return
|
||||
normalized_remote_file = target.normalized_remote_file
|
||||
local_cache_path = target.local_cache_path
|
||||
host_alias = context.recent_entry.host_alias
|
||||
_root._trace_event(
|
||||
"file.delete.remote_begin",
|
||||
cache_key=context.cache_key,
|
||||
host_alias=host_alias,
|
||||
remote_path=normalized_remote_file,
|
||||
cache_path=str(local_cache_path),
|
||||
)
|
||||
try:
|
||||
result = _root.execute_remote_exec_once(
|
||||
host_alias,
|
||||
argv=("rm", "-f", "--", normalized_remote_file),
|
||||
cwd="/",
|
||||
timeout_ms=15_000,
|
||||
)
|
||||
except _root.SessionHelperStartError as error:
|
||||
detail = error.detail or "Remote delete failed."
|
||||
_root._trace_event(
|
||||
"file.delete.remote_transport_error",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
detail=detail,
|
||||
)
|
||||
_root._emit_status(ConnectStatus(kind="disconnected", detail=detail))
|
||||
return
|
||||
if result.exit_code != 0:
|
||||
# ``rm -f`` already swallows ENOENT, so a non-zero exit at this
|
||||
# point is something else (permission denied, path-is-directory,
|
||||
# readonly fs). Don't drop the local cache — leaving the user
|
||||
# with both copies is the safer state until they investigate.
|
||||
stderr_tail = (result.stderr or "").strip()
|
||||
message = (
|
||||
"Sessions warning: remote delete of {} failed (rm exit {}): {}"
|
||||
).format(normalized_remote_file, result.exit_code, stderr_tail)
|
||||
_root._trace_event(
|
||||
"file.delete.remote_failed",
|
||||
cache_key=context.cache_key,
|
||||
remote_path=normalized_remote_file,
|
||||
exit_code=result.exit_code,
|
||||
stderr=stderr_tail[-400:],
|
||||
)
|
||||
_root._emit_status(ConnectStatus(kind="warning", detail=message))
|
||||
return
|
||||
_root._remove_local_remote_cache_mirror_path(local_cache_path)
|
||||
_root._close_open_views_for_abs_path(window, local_cache_path)
|
||||
_root._trace_event(
|
||||
"file.delete.remote_done",
|
||||
cache_key=context.cache_key,
|
||||
host_alias=host_alias,
|
||||
remote_path=normalized_remote_file,
|
||||
cache_path=str(local_cache_path),
|
||||
)
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="ready",
|
||||
detail="Deleted remote file {}".format(normalized_remote_file),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SessionsDeleteRemoteFileCommand(sublime_plugin.WindowCommand):
|
||||
"""Explicit user-confirmed remote delete (lazy-mirror escape hatch)."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
remote_file: str = "",
|
||||
paths=None,
|
||||
) -> None:
|
||||
"""Delete one remote file after confirming with the user.
|
||||
|
||||
``remote_file`` is the absolute remote POSIX path. When invoked
|
||||
from the sidebar context menu, ``paths`` carries the local
|
||||
cache path of the right-clicked entry; the resolver maps it
|
||||
back to the corresponding remote path. When neither is set we
|
||||
fall back to the active view's file (must live under the
|
||||
workspace cache mirror).
|
||||
"""
|
||||
settings = _root.SessionsSettings()
|
||||
context = _root._workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
target_remote = self._resolve_target_remote(context, remote_file, paths)
|
||||
if target_remote is None:
|
||||
return
|
||||
# Normalise the resolved remote into the absolute workspace path the
|
||||
# delete will actually issue, so the confirmation dialog shows the
|
||||
# exact target rather than e.g. a workspace-relative ``doomed.py``.
|
||||
normalised = _resolve_workspace_remote_target(context, target_remote)
|
||||
if normalised is None:
|
||||
return
|
||||
absolute_remote = normalised.normalized_remote_file
|
||||
dialog = getattr(_root.sublime, "ok_cancel_dialog", None)
|
||||
confirmation_text = (
|
||||
"Delete the remote file?\n\n"
|
||||
"{}\n\n"
|
||||
"This removes the file on {} AND drops the local cache "
|
||||
"copy. The action is not undoable from Sessions."
|
||||
).format(absolute_remote, context.recent_entry.host_alias)
|
||||
if callable(dialog):
|
||||
if not dialog(confirmation_text, "Delete remote file"):
|
||||
_root._status_message("Sessions: remote delete cancelled.")
|
||||
return
|
||||
_delete_remote_file_for_workspace(self.window, context, absolute_remote)
|
||||
|
||||
def _resolve_target_remote(
|
||||
self,
|
||||
context,
|
||||
remote_file: str,
|
||||
paths,
|
||||
):
|
||||
"""Pick the remote path to delete from one of three sources."""
|
||||
explicit = (remote_file or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
sidebar_paths = paths if isinstance(paths, list) else None
|
||||
if sidebar_paths:
|
||||
mapper = RemoteToLocalCacheMapper(
|
||||
workspace_cache_key=context.cache_key,
|
||||
remote_workspace_root=context.recent_entry.remote_root,
|
||||
files_cache_root=context.local_cache_root,
|
||||
)
|
||||
for raw in sidebar_paths:
|
||||
try:
|
||||
candidate = Path(raw)
|
||||
except TypeError:
|
||||
continue
|
||||
resolved = mapper.remote_path_for_local_cache_file(candidate)
|
||||
if resolved:
|
||||
return resolved
|
||||
_root._status_message(
|
||||
"Sessions: sidebar selection is not under this workspace's "
|
||||
"cache mirror; refusing remote delete."
|
||||
)
|
||||
return None
|
||||
view = _root._active_view(self.window)
|
||||
if view is None:
|
||||
_root._status_message(
|
||||
"Sessions: focus a remote-mirrored file or pass remote_file."
|
||||
)
|
||||
return None
|
||||
file_name = _root._view_file_name(view)
|
||||
if not file_name:
|
||||
_root._status_message(
|
||||
"Sessions: focus a remote-mirrored file or pass remote_file."
|
||||
)
|
||||
return None
|
||||
mapper = RemoteToLocalCacheMapper(
|
||||
workspace_cache_key=context.cache_key,
|
||||
remote_workspace_root=context.recent_entry.remote_root,
|
||||
files_cache_root=context.local_cache_root,
|
||||
)
|
||||
resolved = mapper.remote_path_for_local_cache_file(Path(file_name))
|
||||
if resolved is None:
|
||||
_root._status_message(
|
||||
"Sessions: active file is not under this workspace's cache "
|
||||
"mirror; refusing remote delete."
|
||||
)
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
class SessionsRemoteCachedFileSaveListener(sublime_plugin.EventListener):
|
||||
"""Push Sessions workspace cache files to the remote host after a normal save."""
|
||||
|
||||
def on_post_save(self, view) -> None:
|
||||
"""Mirror a local cache save back to the remote file when applicable."""
|
||||
window_fn = getattr(view, "window", None)
|
||||
window = window_fn() if callable(window_fn) else None
|
||||
if window is None:
|
||||
return
|
||||
target = _root._remote_save_target_after_local_save(view, window)
|
||||
if target is None:
|
||||
return
|
||||
win, context, remote_path = target
|
||||
|
||||
def _push() -> None:
|
||||
_root._save_remote_file_for_workspace(
|
||||
win, context, remote_path, post_save_view=view
|
||||
)
|
||||
|
||||
_root._set_timeout(_push, 0)
|
||||
1418
sublime/sessions/commands_python_pipeline.py
Normal file
1418
sublime/sessions/commands_python_pipeline.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -156,6 +156,12 @@ class ConnectProgressPanel:
|
||||
self._host_alias = host_alias
|
||||
self._listener: Optional[Callable[[str, Mapping[str, Any]], None]] = None
|
||||
self._started_at = 0.0
|
||||
# Set once the new project window has rendered. After this point we
|
||||
# keep appending content (in case the user re-opens the panel) but
|
||||
# stop forcing ``show_panel`` — otherwise a late
|
||||
# ``connect.phase=scheduled_sidebar_sync`` / ``status`` event pops
|
||||
# the progress strip on top of the workspace the user just got.
|
||||
self._handed_off = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Subscribe to trace events and schedule panel creation on main thread.
|
||||
@@ -176,6 +182,14 @@ class ConnectProgressPanel:
|
||||
if line is None:
|
||||
return
|
||||
self._append_line_async(line)
|
||||
# Hand-off the moment the project window is on screen — late
|
||||
# phase / status events should still log into the panel but
|
||||
# must not force-pop it on top of the new window.
|
||||
if (
|
||||
event == "connect.phase"
|
||||
and str(fields.get("phase") or "") == "project_window_opened"
|
||||
):
|
||||
self._handed_off = True
|
||||
|
||||
self._listener = _on_event
|
||||
register_transport_trace_listener(_on_event)
|
||||
@@ -239,11 +253,18 @@ class ConnectProgressPanel:
|
||||
self._append_line(text)
|
||||
|
||||
def _append_line(self, text: str) -> None:
|
||||
"""On-main-thread append; creates + shows the panel on first call.
|
||||
"""On-main-thread append; creates + shows the panel on every call.
|
||||
|
||||
Creating the panel lazily here (instead of in ``start``) guarantees
|
||||
``create_output_panel`` + ``show_panel`` execute on the main thread
|
||||
even when ``start`` was invoked from a background queue worker.
|
||||
|
||||
``show_panel`` runs on *every* append (not just first-paint) so the
|
||||
progress pane reappears after Sublime's input panel takes over the
|
||||
bottom area for an SSH askpass / OTP prompt — otherwise the user
|
||||
sees an empty bottom strip while the next bridge phase
|
||||
(helper-push, session-spawn, …) is silently doing work for tens
|
||||
of seconds. ``show_panel`` is idempotent.
|
||||
"""
|
||||
find = getattr(self._window, "find_output_panel", None)
|
||||
panel = find(_PROGRESS_PANEL_NAME) if callable(find) else None
|
||||
@@ -257,6 +278,11 @@ class ConnectProgressPanel:
|
||||
if callable(rc):
|
||||
rc("select_all", {})
|
||||
rc("left_delete", {})
|
||||
# Always show on first paint; afterwards only re-show until the
|
||||
# project window has rendered. Once handed off, late events
|
||||
# (sidebar sync, ready-status) keep flowing into the panel buffer
|
||||
# but must not cover the workspace the user just got.
|
||||
if first_paint or not self._handed_off:
|
||||
win_rc = getattr(self._window, "run_command", None)
|
||||
if callable(win_rc):
|
||||
win_rc(
|
||||
|
||||
103
sublime/sessions/eager_hydrate.py
Normal file
103
sublime/sessions/eager_hydrate.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""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 exposes the candidate discovery + settings normaliser that
|
||||
back the eager-hydrate apply pass. The driver itself (batch loop,
|
||||
re-check, fetch transaction) lives in
|
||||
``sessions_native::eager_hydrate::run_apply_pass`` (Wave 2 PR-B / PR 17)
|
||||
— see :func:`sessions._rust_ffi.eager_hydrate_apply`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Iterator, List, Tuple
|
||||
|
||||
from . import _rust_ffi
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def find_placeholder_candidates(
|
||||
cache_root: Path,
|
||||
allowed_basenames: Iterable[str],
|
||||
) -> Iterator[Path]:
|
||||
"""Yield zero-byte files under ``cache_root`` whose basename is allowed.
|
||||
|
||||
Wave 2 PR 14: BFS + size filter run in
|
||||
``sessions_native::eager_hydrate``. Directories that fail to enumerate
|
||||
are silently skipped (Rust matches Python's ``OSError`` swallow).
|
||||
"""
|
||||
allowed_list = [name for name in allowed_basenames if name]
|
||||
if not allowed_list:
|
||||
return
|
||||
try:
|
||||
if not cache_root.is_dir():
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
candidates = _rust_ffi.eager_hydrate_find_candidates(str(cache_root), allowed_list)
|
||||
for path_str in candidates:
|
||||
yield Path(path_str)
|
||||
|
||||
|
||||
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)
|
||||
@@ -3,7 +3,7 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from typing import Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from ._rust_ffi import SessionsNativeLibraryError
|
||||
from ._rust_ffi import (
|
||||
@@ -32,6 +32,33 @@ from ._rust_ffi import (
|
||||
)
|
||||
from .remote import RemoteFileKind, RemoteFileMetadata
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single source of truth for kind_code mapping (Wave 1.5 amend §C / PR 11).
|
||||
# Mirrors ``rust/crates/sessions_native/src/lib.rs`` REMOTE_KIND_* constants.
|
||||
# ``OTHER`` falls through to ``3`` so the Rust ABI receives a known sentinel.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_KIND_CODES: Mapping[RemoteFileKind, int] = {
|
||||
RemoteFileKind.REGULAR_FILE: 0,
|
||||
RemoteFileKind.DIRECTORY: 1,
|
||||
RemoteFileKind.SYMLINK: 2,
|
||||
RemoteFileKind.OTHER: 3,
|
||||
}
|
||||
|
||||
|
||||
def _metadata_to_tuple(
|
||||
meta: Optional[RemoteFileMetadata],
|
||||
) -> Optional[Tuple[int, int, int]]:
|
||||
"""Pack ``(mtime_ns, size_bytes, kind_code)`` for the Rust decision ABIs.
|
||||
|
||||
Returns ``None`` so callers can pass it straight through to
|
||||
``rust_reload_recommendation_code`` / ``rust_save_decision_code`` whose
|
||||
Optional-tuple branch encodes "no metadata available".
|
||||
"""
|
||||
if meta is None:
|
||||
return None
|
||||
return (meta.mtime_ns, meta.size_bytes, _KIND_CODES.get(meta.kind, 3))
|
||||
|
||||
|
||||
class RemotePathMappingError(ValueError):
|
||||
"""Raised when a remote path cannot be mapped safely to the local cache."""
|
||||
@@ -214,6 +241,16 @@ class UnsupportedOpenReason(Enum):
|
||||
ZERO_BYTE_READ_NOT_ALLOWED = "zero_byte_read_not_allowed"
|
||||
|
||||
|
||||
# Single source of truth for open-guard reason codes (Wave 1.5 amend §C).
|
||||
# Mirrors ``rust/crates/sessions_native/src/lib.rs`` OPEN_REASON_* constants.
|
||||
_OPEN_GUARD_REASON_MAP: Mapping[int, Optional[UnsupportedOpenReason]] = {
|
||||
0: None,
|
||||
1: UnsupportedOpenReason.FILE_TOO_LARGE,
|
||||
2: UnsupportedOpenReason.UNSUPPORTED_REMOTE_KIND,
|
||||
3: UnsupportedOpenReason.ZERO_BYTE_READ_NOT_ALLOWED,
|
||||
}
|
||||
|
||||
|
||||
class CacheInvalidationTrigger(Enum):
|
||||
"""Catalog of events that should drop or refresh cached bytes."""
|
||||
|
||||
@@ -256,6 +293,16 @@ class ReloadRecommendation(Enum):
|
||||
REMOTE_MISSING = "remote_missing"
|
||||
|
||||
|
||||
# Single source of truth for reload recommendation codes (Wave 1.5 amend §C).
|
||||
# Mirrors ``rust/crates/sessions_native/src/lib.rs`` RELOAD_* constants.
|
||||
_RELOAD_RECOMMENDATION_MAP: Mapping[int, ReloadRecommendation] = {
|
||||
0: ReloadRecommendation.NO_ACTION_NEEDED,
|
||||
1: ReloadRecommendation.RECOMMEND_RELOAD,
|
||||
2: ReloadRecommendation.RECOMMEND_REVIEW_CONFLICT,
|
||||
3: ReloadRecommendation.REMOTE_MISSING,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileOpenGuardrails:
|
||||
"""Hard limits for MVP open behavior.
|
||||
@@ -286,25 +333,13 @@ def open_guard_reason_for_remote_metadata(
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
kind_codes = {
|
||||
RemoteFileKind.REGULAR_FILE: 0,
|
||||
RemoteFileKind.DIRECTORY: 1,
|
||||
RemoteFileKind.SYMLINK: 2,
|
||||
}
|
||||
reason_map = {
|
||||
0: None,
|
||||
1: UnsupportedOpenReason.FILE_TOO_LARGE,
|
||||
2: UnsupportedOpenReason.UNSUPPORTED_REMOTE_KIND,
|
||||
3: UnsupportedOpenReason.ZERO_BYTE_READ_NOT_ALLOWED,
|
||||
}
|
||||
kind_code = kind_codes.get(meta.kind, 0)
|
||||
reason_code = rust_open_guard_reason_code(
|
||||
remote_kind_code=kind_code,
|
||||
remote_kind_code=_KIND_CODES.get(meta.kind, 0),
|
||||
size_bytes=meta.size_bytes,
|
||||
max_open_bytes=limits.max_open_bytes,
|
||||
allow_empty_files=limits.allow_empty_files,
|
||||
)
|
||||
return reason_map.get(reason_code)
|
||||
return _OPEN_GUARD_REASON_MAP.get(reason_code)
|
||||
|
||||
|
||||
def is_likely_binary_from_head(content_head: bytes) -> bool:
|
||||
@@ -363,42 +398,12 @@ def reload_recommendation(
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
kind_codes = {
|
||||
RemoteFileKind.REGULAR_FILE: 0,
|
||||
RemoteFileKind.DIRECTORY: 1,
|
||||
RemoteFileKind.SYMLINK: 2,
|
||||
RemoteFileKind.OTHER: 3,
|
||||
}
|
||||
baseline_tuple = (
|
||||
(
|
||||
baseline.mtime_ns,
|
||||
baseline.size_bytes,
|
||||
kind_codes.get(baseline.kind, 3),
|
||||
)
|
||||
if baseline is not None
|
||||
else None
|
||||
)
|
||||
current_tuple = (
|
||||
(
|
||||
current.mtime_ns,
|
||||
current.size_bytes,
|
||||
kind_codes.get(current.kind, 3),
|
||||
)
|
||||
if current is not None
|
||||
else None
|
||||
)
|
||||
code = rust_reload_recommendation_code(
|
||||
had_metadata_at_open=had_metadata_at_open,
|
||||
baseline=baseline_tuple,
|
||||
current=current_tuple,
|
||||
baseline=_metadata_to_tuple(baseline),
|
||||
current=_metadata_to_tuple(current),
|
||||
)
|
||||
mapping = {
|
||||
0: ReloadRecommendation.NO_ACTION_NEEDED,
|
||||
1: ReloadRecommendation.RECOMMEND_RELOAD,
|
||||
2: ReloadRecommendation.RECOMMEND_REVIEW_CONFLICT,
|
||||
3: ReloadRecommendation.REMOTE_MISSING,
|
||||
}
|
||||
return mapping[code]
|
||||
return _RELOAD_RECOMMENDATION_MAP[code]
|
||||
|
||||
|
||||
def default_source_of_truth_policy() -> SourceOfTruthPolicy:
|
||||
@@ -460,6 +465,39 @@ class SaveConflictKind(Enum):
|
||||
BASELINE_UNKNOWN = "baseline_unknown"
|
||||
|
||||
|
||||
# Single source of truth for save decision codes (Wave 1.5 amend §C / amend A1
|
||||
# user-visible strings = Python single source).
|
||||
# Mirrors ``rust/crates/sessions_native/src/lib.rs`` SAVE_DECISION_* constants.
|
||||
# ``code 0`` (OK) is handled inline in ``evaluate_save_file`` without a spec.
|
||||
_SAVE_CONFLICT_SPECS: Mapping[int, Tuple[SaveConflictKind, str, ReloadChoice]] = {
|
||||
1: (
|
||||
SaveConflictKind.BASELINE_UNKNOWN,
|
||||
"Cannot save safely without metadata captured at open.",
|
||||
ReloadChoice.CANCEL,
|
||||
),
|
||||
2: (
|
||||
SaveConflictKind.REMOTE_FILE_MISSING,
|
||||
"Remote file disappeared before save; choose reload or cancel.",
|
||||
ReloadChoice.CANCEL,
|
||||
),
|
||||
3: (
|
||||
SaveConflictKind.REMOTE_PATH_IS_DIRECTORY,
|
||||
"Remote path is a directory; refusing save.",
|
||||
ReloadChoice.CANCEL,
|
||||
),
|
||||
4: (
|
||||
SaveConflictKind.REMOTE_PATH_IS_SYMLINK,
|
||||
"Remote path is a symlink; refusing blind save.",
|
||||
ReloadChoice.CANCEL,
|
||||
),
|
||||
5: (
|
||||
SaveConflictKind.REMOTE_METADATA_CHANGED,
|
||||
"Remote file changed since local copy; choose overwrite or reload.",
|
||||
ReloadChoice.KEEP_LOCAL_AND_OVERWRITE_REMOTE,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenFileRequest:
|
||||
"""Parameters needed to stage a remote file into the local cache.
|
||||
@@ -591,81 +629,19 @@ def evaluate_save_file(request: SaveFileRequest) -> SaveFileResult:
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
kind_codes = {
|
||||
RemoteFileKind.REGULAR_FILE: 0,
|
||||
RemoteFileKind.DIRECTORY: 1,
|
||||
RemoteFileKind.SYMLINK: 2,
|
||||
RemoteFileKind.OTHER: 3,
|
||||
}
|
||||
baseline_tuple = (
|
||||
(
|
||||
request.baseline_remote_metadata.mtime_ns,
|
||||
request.baseline_remote_metadata.size_bytes,
|
||||
kind_codes.get(request.baseline_remote_metadata.kind, 3),
|
||||
)
|
||||
if request.baseline_remote_metadata is not None
|
||||
else None
|
||||
)
|
||||
candidate_tuple = (
|
||||
(
|
||||
request.candidate_remote_metadata.mtime_ns,
|
||||
request.candidate_remote_metadata.size_bytes,
|
||||
kind_codes.get(request.candidate_remote_metadata.kind, 3),
|
||||
)
|
||||
if request.candidate_remote_metadata is not None
|
||||
else None
|
||||
)
|
||||
decision_code = rust_save_decision_code(
|
||||
baseline=baseline_tuple,
|
||||
candidate=candidate_tuple,
|
||||
baseline=_metadata_to_tuple(request.baseline_remote_metadata),
|
||||
candidate=_metadata_to_tuple(request.candidate_remote_metadata),
|
||||
)
|
||||
if decision_code == 0:
|
||||
return SaveFileResult(outcome=SaveOutcome.OK)
|
||||
if decision_code == 1:
|
||||
return SaveFileResult(
|
||||
outcome=SaveOutcome.CONFLICT,
|
||||
conflict=SaveConflict(
|
||||
kind=SaveConflictKind.BASELINE_UNKNOWN,
|
||||
message="Cannot save safely without metadata captured at open.",
|
||||
reload_choice_hint=ReloadChoice.CANCEL,
|
||||
),
|
||||
)
|
||||
if decision_code == 2:
|
||||
return SaveFileResult(
|
||||
outcome=SaveOutcome.CONFLICT,
|
||||
conflict=SaveConflict(
|
||||
kind=SaveConflictKind.REMOTE_FILE_MISSING,
|
||||
message="Remote file disappeared before save; choose reload or cancel.",
|
||||
reload_choice_hint=ReloadChoice.CANCEL,
|
||||
),
|
||||
)
|
||||
if decision_code == 3:
|
||||
return SaveFileResult(
|
||||
outcome=SaveOutcome.CONFLICT,
|
||||
conflict=SaveConflict(
|
||||
kind=SaveConflictKind.REMOTE_PATH_IS_DIRECTORY,
|
||||
message="Remote path is a directory; refusing save.",
|
||||
reload_choice_hint=ReloadChoice.CANCEL,
|
||||
),
|
||||
)
|
||||
if decision_code == 4:
|
||||
return SaveFileResult(
|
||||
outcome=SaveOutcome.CONFLICT,
|
||||
conflict=SaveConflict(
|
||||
kind=SaveConflictKind.REMOTE_PATH_IS_SYMLINK,
|
||||
message="Remote path is a symlink; refusing blind save.",
|
||||
reload_choice_hint=ReloadChoice.CANCEL,
|
||||
),
|
||||
)
|
||||
if decision_code == 5:
|
||||
return SaveFileResult(
|
||||
outcome=SaveOutcome.CONFLICT,
|
||||
conflict=SaveConflict(
|
||||
kind=SaveConflictKind.REMOTE_METADATA_CHANGED,
|
||||
message=(
|
||||
"Remote file changed since local copy; choose overwrite or reload."
|
||||
),
|
||||
reload_choice_hint=ReloadChoice.KEEP_LOCAL_AND_OVERWRITE_REMOTE,
|
||||
),
|
||||
)
|
||||
raise ValueError("unexpected save decision code: {}".format(decision_code))
|
||||
spec = _SAVE_CONFLICT_SPECS.get(decision_code)
|
||||
if spec is None:
|
||||
raise ValueError("unexpected save decision code: {}".format(decision_code))
|
||||
kind, message, reload_hint = spec
|
||||
return SaveFileResult(
|
||||
outcome=SaveOutcome.CONFLICT,
|
||||
conflict=SaveConflict(
|
||||
kind=kind, message=message, reload_choice_hint=reload_hint
|
||||
),
|
||||
)
|
||||
|
||||
300
sublime/sessions/git_branch_proxy.py
Normal file
300
sublime/sessions/git_branch_proxy.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Branch-switch proxy for Track G v0 (G4 + G6).
|
||||
|
||||
When the user switches branches in Sublime Merge against the local
|
||||
mirror, we need the *remote* working tree to follow — otherwise the
|
||||
editor's open buffers + the materialised dirty files drift out of
|
||||
sync with whatever ``HEAD`` the remote thinks it's on.
|
||||
|
||||
v0 mechanism:
|
||||
|
||||
1. ``install_post_checkout_hook`` writes a tiny shell script at
|
||||
``<repo>/.git/hooks/post-checkout`` that drops a JSON marker file
|
||||
alongside the hooks dir whenever local git fires ``post-checkout``.
|
||||
The marker captures ``prev_head``, ``new_head``, and the
|
||||
``branch_flag`` git passes to the hook.
|
||||
|
||||
2. The next ``Sessions: Refresh Git State`` invocation calls
|
||||
``apply_pending_checkout`` per repo. That reads the marker, runs
|
||||
``git checkout <new_head>`` on the remote via ``exec/once``, and
|
||||
then re-runs G3 materialisation so the local mirror reflects the
|
||||
new branch's index. Marker is deleted on success.
|
||||
|
||||
3. **G6 — dirty refusal**: when remote git refuses the checkout
|
||||
("Your local changes would be overwritten…") the proxy keeps the
|
||||
marker in place and surfaces git's stderr verbatim through the
|
||||
status bar. The local ``HEAD`` is now ahead of the remote, but no
|
||||
data was lost — the user resolves the dirty remote state (commit,
|
||||
stash, or discard) and re-fires ``Refresh Git State``.
|
||||
|
||||
No automatic polling in v0 — the user runs ``Refresh Git State``
|
||||
when they want the proxy to run. v1 hooks the auto-refresh loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .git_repo_discovery import GitRepo
|
||||
from .ssh_file_transport import RemoteExecOnceResult, execute_remote_exec_once
|
||||
|
||||
ExecOnceFn = Callable[..., RemoteExecOnceResult]
|
||||
|
||||
|
||||
_MARKER_FILENAME = "SESSIONS_PENDING_CHECKOUT"
|
||||
|
||||
# Plain ``sh`` so the hook works on Linux, macOS, and the msys shell
|
||||
# git-for-Windows ships. Git always sets ``GIT_DIR`` in the hook
|
||||
# environment (per ``githooks(5)``), so we don't need to call out to
|
||||
# ``git rev-parse`` — that also keeps the hook self-sufficient when
|
||||
# the user pulls the hook script out of context for testing.
|
||||
_POST_CHECKOUT_HOOK_SCRIPT = """\
|
||||
#!/bin/sh
|
||||
# Sessions Track G post-checkout hook (v0).
|
||||
# Args: prev_HEAD new_HEAD branch_flag
|
||||
# Drops a JSON marker so the Sublime side can proxy the checkout to
|
||||
# the remote on the next ``Sessions: Refresh Git State`` invocation.
|
||||
: "${GIT_DIR:=.git}"
|
||||
TS="$(date +%Y-%m-%dT%H:%M:%S 2>/dev/null || echo unknown)"
|
||||
printf '{"prev_head":"%s","new_head":"%s","branch_flag":"%s","ts":"%s"}\\n' \\
|
||||
"$1" "$2" "$3" "$TS" > "$GIT_DIR/SESSIONS_PENDING_CHECKOUT"
|
||||
"""
|
||||
|
||||
|
||||
_BRANCH_FLAG_BRANCH = "1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingCheckout:
|
||||
"""Decoded marker file dropped by the post-checkout hook."""
|
||||
|
||||
prev_head: str
|
||||
new_head: str
|
||||
branch_flag: str
|
||||
ts: str
|
||||
|
||||
@property
|
||||
def is_branch_switch(self) -> bool:
|
||||
"""``True`` when git invoked the hook for a branch (vs file) checkout.
|
||||
|
||||
Git passes ``1`` for branch checkouts and ``0`` for path-spec
|
||||
checkouts; we only proxy branch switches because path checkouts
|
||||
leave HEAD alone (no remote checkout needed).
|
||||
"""
|
||||
return self.branch_flag == _BRANCH_FLAG_BRANCH
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProxyResult:
|
||||
"""Outcome of one ``apply_pending_checkout`` invocation."""
|
||||
|
||||
repo: GitRepo
|
||||
proxied: bool
|
||||
"""``True`` when the marker was present and we actually attempted a
|
||||
remote checkout (regardless of success). ``False`` when there was
|
||||
nothing to do (no marker / non-branch checkout)."""
|
||||
|
||||
ok: bool
|
||||
"""``True`` when the remote checkout succeeded *and* the marker was
|
||||
cleared. ``False`` on remote-side failure (dirty refusal, missing
|
||||
ref, etc.)."""
|
||||
|
||||
new_head: str
|
||||
"""``new_head`` from the marker; empty when ``proxied`` is ``False``."""
|
||||
|
||||
error_detail: Optional[str]
|
||||
"""Git's stderr (verbatim) on failure; ``None`` on success / no-op."""
|
||||
|
||||
|
||||
def install_post_checkout_hook(local_dot_git: Path) -> None:
|
||||
"""Write the v0 post-checkout hook into ``<.git>/hooks/``.
|
||||
|
||||
Idempotent: if the file already exists with our content, no write.
|
||||
Marks the file executable on POSIX (the bit is harmless on
|
||||
Windows where git for Windows uses ``core.fileMode=false``).
|
||||
"""
|
||||
hooks_dir = local_dot_git / "hooks"
|
||||
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
hook_path = hooks_dir / "post-checkout"
|
||||
if hook_path.is_file():
|
||||
try:
|
||||
existing = hook_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
existing = None
|
||||
if existing == _POST_CHECKOUT_HOOK_SCRIPT:
|
||||
return
|
||||
hook_path.write_text(_POST_CHECKOUT_HOOK_SCRIPT, encoding="utf-8")
|
||||
try:
|
||||
mode = hook_path.stat().st_mode
|
||||
hook_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
except OSError:
|
||||
# Windows + non-POSIX FS: chmod is a no-op anyway. Don't raise.
|
||||
pass
|
||||
|
||||
|
||||
def read_pending_checkout(local_dot_git: Path) -> Optional[PendingCheckout]:
|
||||
"""Return the parsed marker, or ``None`` when there's nothing to do.
|
||||
|
||||
Tolerant: a malformed marker (truncated JSON, missing fields) is
|
||||
treated as "no pending" rather than raising — better to skip the
|
||||
proxy than crash refresh on a transient half-write.
|
||||
"""
|
||||
marker = local_dot_git / _MARKER_FILENAME
|
||||
if not marker.is_file():
|
||||
return None
|
||||
try:
|
||||
raw = marker.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(decoded, dict):
|
||||
return None
|
||||
return PendingCheckout(
|
||||
prev_head=str(decoded.get("prev_head", "")),
|
||||
new_head=str(decoded.get("new_head", "")),
|
||||
branch_flag=str(decoded.get("branch_flag", "")),
|
||||
ts=str(decoded.get("ts", "")),
|
||||
)
|
||||
|
||||
|
||||
def clear_pending_checkout(local_dot_git: Path) -> None:
|
||||
"""Delete the marker; safe to call when nothing is pending."""
|
||||
marker = local_dot_git / _MARKER_FILENAME
|
||||
try:
|
||||
marker.unlink()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
# Best-effort: a stale marker is annoying but not catastrophic;
|
||||
# the next checkout overwrites it.
|
||||
return
|
||||
|
||||
|
||||
def apply_pending_checkout(
|
||||
host_alias: str,
|
||||
repo: GitRepo,
|
||||
*,
|
||||
exec_once: Optional[ExecOnceFn] = None,
|
||||
) -> ProxyResult:
|
||||
"""Drain ``repo``'s pending-checkout marker and proxy to remote.
|
||||
|
||||
Runs ``git checkout <new_head>`` on the remote via the bridge.
|
||||
On success, clears the marker. On failure (stock git refusal for
|
||||
dirty trees, unknown ref, etc.) keeps the marker so a follow-up
|
||||
``Refresh Git State`` retries after the user resolves whatever
|
||||
remote-side state was blocking the checkout.
|
||||
|
||||
Path-spec checkouts (``branch_flag != "1"``) are silently
|
||||
discarded — the hook fires on ``git checkout -- some/file`` too,
|
||||
but those don't move HEAD on remote so there's nothing to proxy.
|
||||
"""
|
||||
runner = exec_once if exec_once is not None else execute_remote_exec_once
|
||||
pending = read_pending_checkout(repo.local_root / ".git")
|
||||
if pending is None:
|
||||
return ProxyResult(
|
||||
repo=repo, proxied=False, ok=True, new_head="", error_detail=None
|
||||
)
|
||||
if not pending.is_branch_switch:
|
||||
clear_pending_checkout(repo.local_root / ".git")
|
||||
return ProxyResult(
|
||||
repo=repo, proxied=False, ok=True, new_head="", error_detail=None
|
||||
)
|
||||
new_head = pending.new_head.strip()
|
||||
if not new_head:
|
||||
clear_pending_checkout(repo.local_root / ".git")
|
||||
return ProxyResult(
|
||||
repo=repo,
|
||||
proxied=False,
|
||||
ok=False,
|
||||
new_head="",
|
||||
error_detail="empty new_head in pending-checkout marker",
|
||||
)
|
||||
|
||||
result = runner(
|
||||
host_alias,
|
||||
["git", "-C", repo.remote_root, "checkout", new_head],
|
||||
cwd=repo.remote_root,
|
||||
timeout_ms=60_000,
|
||||
)
|
||||
if result.timed_out:
|
||||
return ProxyResult(
|
||||
repo=repo,
|
||||
proxied=True,
|
||||
ok=False,
|
||||
new_head=new_head,
|
||||
error_detail="remote git checkout timed out",
|
||||
)
|
||||
if result.exit_code != 0 and _is_unknown_ref_error(result.stderr or ""):
|
||||
# The user created a branch locally in Sublime Merge that the
|
||||
# remote doesn't know about yet. Re-create it on the remote
|
||||
# against ``prev_head`` so the checkout — and the next G2 tar
|
||||
# fetch — can carry the new branch back into the local mirror.
|
||||
# Without this fallback, ``fetch_remote_dot_git`` would clobber
|
||||
# the local-only ref and the user's freshly-created branch
|
||||
# silently disappears on the next refresh cycle.
|
||||
prev_head = pending.prev_head.strip()
|
||||
create_argv = ["git", "-C", repo.remote_root, "checkout", "-b", new_head]
|
||||
if prev_head:
|
||||
create_argv.append(prev_head)
|
||||
result = runner(
|
||||
host_alias,
|
||||
create_argv,
|
||||
cwd=repo.remote_root,
|
||||
timeout_ms=60_000,
|
||||
)
|
||||
if result.timed_out:
|
||||
return ProxyResult(
|
||||
repo=repo,
|
||||
proxied=True,
|
||||
ok=False,
|
||||
new_head=new_head,
|
||||
error_detail="remote git checkout -b timed out",
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
# Stock git refusal — the most common case is "Your local
|
||||
# changes to the following files would be overwritten by
|
||||
# checkout". Keep the marker; the user resolves remote state
|
||||
# and retries. This is the G6 path.
|
||||
return ProxyResult(
|
||||
repo=repo,
|
||||
proxied=True,
|
||||
ok=False,
|
||||
new_head=new_head,
|
||||
error_detail=(result.stderr or "").strip() or "(remote git declined)",
|
||||
)
|
||||
clear_pending_checkout(repo.local_root / ".git")
|
||||
return ProxyResult(
|
||||
repo=repo,
|
||||
proxied=True,
|
||||
ok=True,
|
||||
new_head=new_head,
|
||||
error_detail=None,
|
||||
)
|
||||
|
||||
|
||||
def _is_unknown_ref_error(stderr: str) -> bool:
|
||||
"""Detect ``git checkout`` failure from a ref the remote doesn't have.
|
||||
|
||||
Two flavours: ``error: pathspec '<name>' did not match any file(s)
|
||||
known to git`` (older git wording) and ``error: pathspec '<name>'
|
||||
did not match any known refs`` (newer wording). Both indicate the
|
||||
branch is local-only and we should retry with ``-b``.
|
||||
"""
|
||||
needle = "did not match any"
|
||||
return needle in stderr
|
||||
|
||||
|
||||
__all__ = (
|
||||
"PendingCheckout",
|
||||
"ProxyResult",
|
||||
"apply_pending_checkout",
|
||||
"clear_pending_checkout",
|
||||
"install_post_checkout_hook",
|
||||
"read_pending_checkout",
|
||||
)
|
||||
327
sublime/sessions/git_dot_git_sync.py
Normal file
327
sublime/sessions/git_dot_git_sync.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""Initial-pull and reconcile of remote ``.git`` directories.
|
||||
|
||||
Track G v0, second piece: G1 (``git_repo_discovery``) tells us *where*
|
||||
the repos live; this module pulls the actual ``.git`` content down so
|
||||
Sublime Merge can read history / refs / blame against a real repo.
|
||||
|
||||
Strategy (v0): pipe the remote ``.git`` through ``tar -czf - .git |
|
||||
base64 -w0`` over the bridge's ``exec/once``, base64-decode the
|
||||
response stdout, and extract the tarball into the local mirror at the
|
||||
matching path. One round-trip per repo. The base64 wrap is required
|
||||
because ``execute_remote_exec_once`` returns stdout as a Python
|
||||
``str``; raw tar bytes would corrupt under utf-8 decoding.
|
||||
|
||||
Reconcile (v0): the only reconcile path is the manual "Sessions:
|
||||
Refresh Git State" palette command, which re-runs ``fetch_remote_dot_
|
||||
git`` against every discovered repo. Automatic ``refs/`` diff is v1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
from .git_repo_discovery import GitRepo
|
||||
from .ssh_file_transport import RemoteExecOnceResult, execute_remote_exec_once
|
||||
|
||||
ExecOnceFn = Callable[..., RemoteExecOnceResult]
|
||||
|
||||
# 5-minute budget per tar pull. ``.git`` directories on busy repos can
|
||||
# run hundreds of MB once pack files are included, and this is over a
|
||||
# persistent SSH channel anyway so a generous ceiling is the right
|
||||
# trade — the bridge already enforces the host-level
|
||||
# ``sessions_helper_handshake_timeout_s`` ceiling separately.
|
||||
_DOT_GIT_FETCH_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
# Lift the helper's default 4 MiB stdout cap for ``.git`` fetches: a
|
||||
# real repo's ``.git`` is 30-200+ MiB raw, and the gzip+base64 stream
|
||||
# easily blows past 4 MiB. Without this override the helper closes
|
||||
# its stdout pipe partway through, the remote ``tar`` exits 141
|
||||
# (SIGPIPE), and the response body is empty — exactly the failure
|
||||
# mode that left ``.git`` as 0-byte stubs in the local mirror.
|
||||
_DOT_GIT_FETCH_STDOUT_MAX = 512 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FetchResult:
|
||||
"""Outcome of one ``.git`` initial-pull attempt.
|
||||
|
||||
Attributes:
|
||||
repo: The repo this fetch targeted (echoed for trace clarity).
|
||||
ok: ``True`` when the local ``.git`` was written end-to-end.
|
||||
bytes_received: Length of the base64-decoded tarball; ``0`` on
|
||||
short-circuit failures (timeout, non-zero remote tar exit).
|
||||
error_detail: Human-readable failure reason; ``None`` on
|
||||
success.
|
||||
"""
|
||||
|
||||
repo: GitRepo
|
||||
ok: bool
|
||||
bytes_received: int
|
||||
error_detail: Optional[str]
|
||||
|
||||
|
||||
def fetch_remote_dot_git(
|
||||
host_alias: str,
|
||||
repo: GitRepo,
|
||||
*,
|
||||
exec_once: Optional[ExecOnceFn] = None,
|
||||
) -> FetchResult:
|
||||
"""Pull the remote ``.git`` for ``repo`` into the local mirror.
|
||||
|
||||
Idempotent: if the local ``.git`` already exists, it is removed
|
||||
first so the extracted tarball lands on a clean slate (avoids
|
||||
half-merged states from previous failed pulls). The function does
|
||||
*not* touch any non-``.git`` content under ``repo.local_root`` —
|
||||
only the ``.git`` subtree.
|
||||
|
||||
On a remote ``.git`` *file* (worktree pointer) v0 falls through
|
||||
with a ``not_implemented`` error. Worktree support comes in v1
|
||||
along with the ``gitdir`` chase needed to fetch the real ``.git``
|
||||
out of the linked ``worktrees/<name>`` dir.
|
||||
"""
|
||||
if repo.kind != "regular":
|
||||
return FetchResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
bytes_received=0,
|
||||
error_detail=(
|
||||
"Worktree (.git file) repos aren't supported in Track G v0; "
|
||||
"open the repo's main clone instead."
|
||||
),
|
||||
)
|
||||
|
||||
runner = exec_once if exec_once is not None else execute_remote_exec_once
|
||||
cmd = [
|
||||
"bash",
|
||||
"-c",
|
||||
# ``-C <parent>`` so the tarball stores ``.git/`` as the top
|
||||
# entry (not the absolute path — keeps extraction predictable
|
||||
# regardless of the local mirror layout). ``-w0`` on base64
|
||||
# disables line-wrap so we don't have to strip newlines on
|
||||
# the receiving side. ``set -o pipefail`` so a tar failure
|
||||
# surfaces as the overall non-zero exit, not the base64 exit.
|
||||
"set -o pipefail; tar -czf - -C {parent} .git | base64 -w0".format(
|
||||
parent=_shell_quote(repo.remote_root)
|
||||
),
|
||||
]
|
||||
try:
|
||||
result: RemoteExecOnceResult = runner(
|
||||
host_alias,
|
||||
cmd,
|
||||
cwd=repo.remote_root,
|
||||
timeout_ms=_DOT_GIT_FETCH_TIMEOUT_MS,
|
||||
stdout_max_bytes=_DOT_GIT_FETCH_STDOUT_MAX,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 — surface as FetchResult, not raise.
|
||||
return FetchResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
bytes_received=0,
|
||||
error_detail="bridge exec/once failed: {}".format(error),
|
||||
)
|
||||
|
||||
if result.timed_out:
|
||||
return FetchResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
bytes_received=0,
|
||||
error_detail=(
|
||||
"remote tar timed out after {} ms; try again on a faster network "
|
||||
"or bump sessions_helper_handshake_timeout_s"
|
||||
).format(_DOT_GIT_FETCH_TIMEOUT_MS),
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
return FetchResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
bytes_received=0,
|
||||
error_detail=(
|
||||
"remote tar exited {}: {}".format(
|
||||
result.exit_code, result.stderr.strip() or "(no stderr)"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
tarball = base64.b64decode(result.stdout.encode("ascii"), validate=True)
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
return FetchResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
bytes_received=0,
|
||||
error_detail="base64 decode failed: {}".format(error),
|
||||
)
|
||||
|
||||
try:
|
||||
_replace_local_dot_git(repo.local_root / ".git", tarball)
|
||||
except (OSError, tarfile.TarError) as error:
|
||||
return FetchResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
bytes_received=len(tarball),
|
||||
error_detail="local extraction failed: {}".format(error),
|
||||
)
|
||||
|
||||
return FetchResult(
|
||||
repo=repo,
|
||||
ok=True,
|
||||
bytes_received=len(tarball),
|
||||
error_detail=None,
|
||||
)
|
||||
|
||||
|
||||
def _shell_quote(value: str) -> str:
|
||||
"""POSIX single-quote ``value`` for safe interpolation into ``bash -c``."""
|
||||
# Single-quote and escape embedded single quotes via ``'\''`` —
|
||||
# standard POSIX shell-escape recipe. Avoid shlex.quote because
|
||||
# Sublime Text 4 ships a Python that supports it but we want zero
|
||||
# standard-library churn for the bridge call.
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
_PRESERVED_DOT_GIT_FILES = ("SESSIONS_PENDING_CHECKOUT",)
|
||||
|
||||
|
||||
def _replace_local_dot_git(local_dot_git: Path, tarball: bytes) -> None:
|
||||
"""Remove ``local_dot_git`` if present and extract ``tarball`` in its place."""
|
||||
parent = local_dot_git.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
# Snapshot caller-owned state we don't want the wipe to clobber.
|
||||
# Today: the post-checkout marker that ``apply_pending_checkout``
|
||||
# consumes — if the proxy ran first this is already cleared, but if
|
||||
# the proxy was deferred (remote refused, network blip) the marker
|
||||
# has to survive the tar replace so the next refresh can retry.
|
||||
preserved = _snapshot_preserved_dot_git_files(local_dot_git)
|
||||
_force_remove_dot_git(local_dot_git)
|
||||
with tarfile.open(fileobj=io.BytesIO(tarball), mode="r:gz") as tf:
|
||||
# Refuse absolute paths and ``..`` traversal in archive members
|
||||
# so a malicious remote tar can't escape ``parent``. ``tar -C
|
||||
# <parent> .git`` produces only ``.git/...`` entries; anything
|
||||
# else is a defence-in-depth signal that something is wrong.
|
||||
for member in tf.getmembers():
|
||||
normalized = member.name.replace("\\", "/")
|
||||
if normalized.startswith("/") or ".." in normalized.split("/"):
|
||||
raise tarfile.TarError(
|
||||
"rejecting unsafe archive member: {}".format(member.name)
|
||||
)
|
||||
if not (normalized == ".git" or normalized.startswith(".git/")):
|
||||
raise tarfile.TarError(
|
||||
"rejecting non-.git archive member: {}".format(member.name)
|
||||
)
|
||||
# ``filter="data"`` follows the Python 3.12+ secure-extract default
|
||||
# that becomes mandatory in 3.14: refuses absolute paths, ``..``
|
||||
# traversal, device nodes, and symlinks outside the destination.
|
||||
# Sublime ships 3.8 (no ``filter`` kwarg), so feature-gate the
|
||||
# call.
|
||||
if hasattr(tarfile, "data_filter"):
|
||||
tf.extractall(path=parent, filter="data")
|
||||
else:
|
||||
tf.extractall(path=parent)
|
||||
_restore_preserved_dot_git_files(local_dot_git, preserved)
|
||||
|
||||
|
||||
def _snapshot_preserved_dot_git_files(local_dot_git: Path) -> dict:
|
||||
"""Read sessions-owned files we want to survive the tar replace."""
|
||||
out: dict = {}
|
||||
if not local_dot_git.is_dir():
|
||||
return out
|
||||
for name in _PRESERVED_DOT_GIT_FILES:
|
||||
path = local_dot_git / name
|
||||
try:
|
||||
out[name] = path.read_bytes()
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _restore_preserved_dot_git_files(local_dot_git: Path, preserved: dict) -> None:
|
||||
"""Re-write any files we snapshotted before the wipe."""
|
||||
if not preserved:
|
||||
return
|
||||
for name, body in preserved.items():
|
||||
target = local_dot_git / name
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(body)
|
||||
except OSError:
|
||||
# Best-effort: failing to restore the marker isn't worth
|
||||
# aborting the whole fetch — the user can re-trigger the
|
||||
# checkout manually.
|
||||
continue
|
||||
|
||||
|
||||
def _force_remove_dot_git(local_dot_git: Path) -> None:
|
||||
"""Remove ``local_dot_git`` whether it is a file, symlink, or directory.
|
||||
|
||||
Tolerates read-only entries on Windows. Git's loose objects and
|
||||
pack files ship with mode 0o444, and Windows refuses to unlink a
|
||||
read-only entry even when the parent directory is writable. POSIX
|
||||
has no such trap (parent-dir write covers it). Without this, the
|
||||
second ``fetch_remote_dot_git`` for a workspace dies at
|
||||
``shutil.rmtree`` with ``[WinError 5] Access is denied`` — fired
|
||||
every ~30 s by the v0.7.18 "always refresh on sync.done" path.
|
||||
"""
|
||||
if not (local_dot_git.exists() or local_dot_git.is_symlink()):
|
||||
return
|
||||
if local_dot_git.is_symlink() or local_dot_git.is_file():
|
||||
try:
|
||||
local_dot_git.unlink()
|
||||
except PermissionError:
|
||||
os.chmod(local_dot_git, stat.S_IWRITE)
|
||||
local_dot_git.unlink()
|
||||
return
|
||||
# ``onexc`` is the 3.12+ replacement for the soft-deprecated
|
||||
# ``onerror`` (signature: handler(func, path, exc) instead of
|
||||
# handler(func, path, exc_info)). Sublime Text 4 ships Python 3.8
|
||||
# so we keep ``onerror`` there; on the API/CLI side (3.12+) we
|
||||
# use ``onexc`` to avoid the DeprecationWarning.
|
||||
if sys.version_info >= (3, 12):
|
||||
shutil.rmtree(local_dot_git, onexc=_rmtree_clear_readonly_onexc)
|
||||
else:
|
||||
shutil.rmtree(local_dot_git, onerror=_rmtree_clear_readonly_and_retry)
|
||||
|
||||
|
||||
def _rmtree_clear_readonly_and_retry(
|
||||
func: Callable[..., Any], path: str, exc_info: Tuple[Any, BaseException, Any]
|
||||
) -> None:
|
||||
"""``shutil.rmtree`` ``onerror`` handler (Python <3.12): clear the
|
||||
read-only bit, retry once.
|
||||
|
||||
Re-raises the original exception if ``os.chmod`` itself fails so
|
||||
real errors (parent-dir permission, file held open by another
|
||||
process) are not swallowed.
|
||||
"""
|
||||
try:
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
except OSError:
|
||||
# ``from None`` keeps the rmtree-supplied exception as the only
|
||||
# one in the chain. The chmod failure isn't useful context for
|
||||
# the caller — they need to see the original "why rmtree blew
|
||||
# up" error, not a wrapped "we then also failed to chmod it".
|
||||
raise exc_info[1] from None
|
||||
func(path)
|
||||
|
||||
|
||||
def _rmtree_clear_readonly_onexc(
|
||||
func: Callable[..., Any], path: str, exc: BaseException
|
||||
) -> None:
|
||||
"""``shutil.rmtree`` ``onexc`` handler (Python 3.12+): same contract as
|
||||
``_rmtree_clear_readonly_and_retry`` but on the new positional
|
||||
signature."""
|
||||
try:
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
except OSError:
|
||||
raise exc from None
|
||||
func(path)
|
||||
|
||||
|
||||
__all__ = ("FetchResult", "fetch_remote_dot_git")
|
||||
395
sublime/sessions/git_materialise.py
Normal file
395
sublime/sessions/git_materialise.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""Working-tree materialisation policy for Track G v0.
|
||||
|
||||
Once G2 has placed a real ``.git`` directory under the local mirror,
|
||||
the working-tree files next to it still need attention so Sublime
|
||||
Merge / git see a consistent picture:
|
||||
|
||||
* **Clean tracked** files (in the index, identical between HEAD and
|
||||
worktree on remote) stay as Sessions stubs locally — but with
|
||||
``git update-index --skip-worktree`` set so git treats them as
|
||||
matching the index. Without the skip-worktree flag every clean
|
||||
tracked file would surface as "modified" because its stub bytes
|
||||
differ from the blob content.
|
||||
|
||||
* **Dirty tracked** files (modified / added / deleted between HEAD
|
||||
and worktree) need their *current remote content* materialised
|
||||
locally so Sublime Merge can show the right diff and so the user
|
||||
can stage hunks against real bytes.
|
||||
|
||||
* **Untracked + not-gitignored** files stay as stubs in v0 — many of
|
||||
these are byproducts (build outputs, local notes) the user never
|
||||
intends to commit; pulling them eagerly costs bandwidth for no
|
||||
gain. v1 materialises on first access.
|
||||
|
||||
* **Ignored** files don't show up in ``git status`` and the mirror
|
||||
doesn't fetch them; nothing to do.
|
||||
|
||||
The module is split into a pure parser (``classify_status_porcelain_v2``)
|
||||
and an applier that touches the filesystem + bridge
|
||||
(``materialise_working_tree``). The parser is unit-tested against
|
||||
real ``git status --porcelain=v2 -z`` byte streams; the applier
|
||||
takes injectable callables for the bridge calls so the unit tests
|
||||
can stub them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, List, Optional, Tuple
|
||||
|
||||
from .git_repo_discovery import GitRepo
|
||||
from .remote import RemoteReadFileRequest
|
||||
from .ssh_file_transport import (
|
||||
RemoteExecOnceResult,
|
||||
RemoteReadFileResult,
|
||||
execute_remote_exec_once,
|
||||
execute_remote_read_file,
|
||||
)
|
||||
|
||||
ExecOnceFn = Callable[..., RemoteExecOnceResult]
|
||||
ReadFileFn = Callable[..., RemoteReadFileResult]
|
||||
|
||||
# Default budget for the per-repo ``git status`` call. Plenty for repos
|
||||
# of any reasonable size; the call is purely metadata so even on slow
|
||||
# tunnels it completes in a second or two.
|
||||
_GIT_STATUS_TIMEOUT_MS = 30_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkingTreeClassification:
|
||||
"""Per-bucket file lists from ``git status --porcelain=v2 -z``.
|
||||
|
||||
Paths are repo-root-relative POSIX strings (matching git's own
|
||||
convention) so they Posix-join cleanly onto ``GitRepo.remote_root``.
|
||||
Renamed/copied entries are reported under their *new* path; the
|
||||
old path is dropped because the tracked-file bookkeeping doesn't
|
||||
need it (the old path is no longer in the index).
|
||||
"""
|
||||
|
||||
clean_tracked: Tuple[str, ...] = field(default_factory=tuple)
|
||||
dirty_modified: Tuple[str, ...] = field(default_factory=tuple)
|
||||
dirty_deleted: Tuple[str, ...] = field(default_factory=tuple)
|
||||
untracked_listed: Tuple[str, ...] = field(default_factory=tuple)
|
||||
unmerged: Tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaterialiseResult:
|
||||
"""Outcome of one repo's materialisation pass."""
|
||||
|
||||
repo: GitRepo
|
||||
ok: bool
|
||||
skip_worktree_set: int
|
||||
files_fetched: int
|
||||
error_detail: Optional[str]
|
||||
|
||||
|
||||
def classify_status_porcelain_v2(
|
||||
status_bytes: bytes,
|
||||
tracked_files: Iterable[str],
|
||||
) -> WorkingTreeClassification:
|
||||
"""Pure parser: turn ``git status --porcelain=v2 -z`` output into buckets.
|
||||
|
||||
``tracked_files`` is the list of repo-relative paths that
|
||||
``git ls-files -z`` returned — i.e. everything the index knows
|
||||
about. Any tracked file that *doesn't* appear as dirty in the
|
||||
status output is classified as ``clean_tracked``.
|
||||
|
||||
The v2 format is documented in ``git-status(1)``; relevant lines
|
||||
here:
|
||||
|
||||
* ``1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>`` — ordinary
|
||||
changed file
|
||||
* ``2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>NUL<origPath>``
|
||||
— renamed/copied (two paths separated by an extra NUL)
|
||||
* ``u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>`` —
|
||||
unmerged
|
||||
* ``? <path>`` — untracked
|
||||
* ``! <path>`` — ignored (we never include these because the
|
||||
caller passes ``--untracked-files=normal`` without ``--ignored``)
|
||||
"""
|
||||
dirty_paths: List[str] = []
|
||||
deleted_paths: List[str] = []
|
||||
untracked: List[str] = []
|
||||
unmerged: List[str] = []
|
||||
|
||||
# ``-z`` gives us NUL-terminated records, but renamed/copied
|
||||
# entries embed an extra NUL between the new and old paths. Walk
|
||||
# the buffer with an index so we can consume one or two NUL-
|
||||
# terminated fields per record depending on the leading byte.
|
||||
cursor = 0
|
||||
end = len(status_bytes)
|
||||
while cursor < end:
|
||||
nul = status_bytes.find(b"\x00", cursor)
|
||||
if nul < 0:
|
||||
# Trailing record without a NUL — malformed, but bail
|
||||
# gracefully so a partial write doesn't crash the whole
|
||||
# materialisation pass.
|
||||
break
|
||||
record = status_bytes[cursor:nul].decode("utf-8", errors="replace")
|
||||
cursor = nul + 1
|
||||
if not record:
|
||||
continue
|
||||
kind = record[0]
|
||||
if kind == "1":
|
||||
# "1 XY sub mH mI mW hH hI path"
|
||||
xy, path = _parse_ordinary_status_line(record)
|
||||
if "D" in xy:
|
||||
deleted_paths.append(path)
|
||||
else:
|
||||
dirty_paths.append(path)
|
||||
elif kind == "2":
|
||||
# Renamed / copied — the v2 format puts the *new* path
|
||||
# in the same record as the header and the old path
|
||||
# as a separate NUL-terminated field that follows.
|
||||
xy, new_path = _parse_rename_or_copy_status_line(record)
|
||||
# Skip the trailing old-path field.
|
||||
old_nul = status_bytes.find(b"\x00", cursor)
|
||||
if old_nul < 0:
|
||||
break
|
||||
cursor = old_nul + 1
|
||||
if "D" in xy:
|
||||
deleted_paths.append(new_path)
|
||||
else:
|
||||
dirty_paths.append(new_path)
|
||||
elif kind == "u":
|
||||
# Unmerged — leave alone in v0; the user resolves these
|
||||
# via the editor / Sublime Merge itself.
|
||||
_xy, path = _parse_unmerged_status_line(record)
|
||||
unmerged.append(path)
|
||||
elif kind == "?":
|
||||
# "? path"
|
||||
untracked.append(record[2:])
|
||||
elif kind == "!":
|
||||
# Ignored — caller didn't ask for these, but tolerate.
|
||||
continue
|
||||
else:
|
||||
# Headers like "# branch.head main" land here in v2;
|
||||
# ignore them — the materialisation policy doesn't care
|
||||
# about branch names, only file states.
|
||||
continue
|
||||
|
||||
dirty_set = set(dirty_paths) | set(deleted_paths) | set(unmerged)
|
||||
clean_tracked = tuple(path for path in tracked_files if path not in dirty_set)
|
||||
return WorkingTreeClassification(
|
||||
clean_tracked=clean_tracked,
|
||||
dirty_modified=tuple(dirty_paths),
|
||||
dirty_deleted=tuple(deleted_paths),
|
||||
untracked_listed=tuple(untracked),
|
||||
unmerged=tuple(unmerged),
|
||||
)
|
||||
|
||||
|
||||
def _parse_ordinary_status_line(record: str) -> Tuple[str, str]:
|
||||
"""Extract ``(XY, path)`` from a ``1`` record of porcelain v2."""
|
||||
# ``1 XY sub mH mI mW hH hI path`` — fields 1..7 are fixed-width
|
||||
# *separated by single spaces*; the path is everything after the
|
||||
# 8th space. Use ``split(" ", 8)`` so a path with embedded spaces
|
||||
# stays intact.
|
||||
parts = record.split(" ", 8)
|
||||
xy = parts[1] if len(parts) > 1 else ""
|
||||
path = parts[8] if len(parts) > 8 else ""
|
||||
return xy, path
|
||||
|
||||
|
||||
def _parse_rename_or_copy_status_line(record: str) -> Tuple[str, str]:
|
||||
"""Extract ``(XY, new_path)`` from a ``2`` record."""
|
||||
# ``2 XY sub mH mI mW hH hI <X><score> path`` — same as ordinary
|
||||
# plus one extra rename/copy score field, so split into 9.
|
||||
parts = record.split(" ", 9)
|
||||
xy = parts[1] if len(parts) > 1 else ""
|
||||
path = parts[9] if len(parts) > 9 else ""
|
||||
return xy, path
|
||||
|
||||
|
||||
def _parse_unmerged_status_line(record: str) -> Tuple[str, str]:
|
||||
"""Extract ``(XY, path)`` from a ``u`` record."""
|
||||
# ``u XY sub m1 m2 m3 mW h1 h2 h3 path`` — split into 10.
|
||||
parts = record.split(" ", 10)
|
||||
xy = parts[1] if len(parts) > 1 else ""
|
||||
path = parts[10] if len(parts) > 10 else ""
|
||||
return xy, path
|
||||
|
||||
|
||||
def materialise_working_tree(
|
||||
host_alias: str,
|
||||
repo: GitRepo,
|
||||
*,
|
||||
exec_once: Optional[ExecOnceFn] = None,
|
||||
read_file: Optional[ReadFileFn] = None,
|
||||
git_local: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> MaterialiseResult:
|
||||
"""Apply the v0 materialisation policy against one repo.
|
||||
|
||||
Steps, in order:
|
||||
|
||||
1. Run ``git ls-files -z`` and ``git status --porcelain=v2 -z``
|
||||
on the *remote* via ``exec/once`` and parse them with
|
||||
:func:`classify_status_porcelain_v2`.
|
||||
|
||||
2. For every ``clean_tracked`` path: ``git update-index
|
||||
--skip-worktree -- <path>`` *locally* (the local ``.git`` is
|
||||
authoritative now). Stubs stay as-is on disk; git just
|
||||
agrees they "match the index".
|
||||
|
||||
3. For every ``dirty_modified`` path: pull the live remote
|
||||
content via ``execute_remote_read_file`` and write it into
|
||||
the local mirror at ``repo.local_root / path``. Sublime
|
||||
Merge can now show the real diff and stage hunks against
|
||||
real bytes.
|
||||
|
||||
4. ``dirty_deleted`` and ``untracked_listed`` are left alone —
|
||||
deletions are already accurate (git sees the absence) and
|
||||
untracked-not-ignored stays stub-first per the v0 policy.
|
||||
|
||||
Errors short-circuit with an ``error_detail``; the caller logs
|
||||
one ``git.materialise`` trace event per repo regardless.
|
||||
"""
|
||||
runner = exec_once if exec_once is not None else execute_remote_exec_once
|
||||
reader = read_file if read_file is not None else execute_remote_read_file
|
||||
|
||||
# 1a. tracked files (everything in the index)
|
||||
ls_files_result = runner(
|
||||
host_alias,
|
||||
["git", "-C", repo.remote_root, "ls-files", "-z"],
|
||||
cwd=repo.remote_root,
|
||||
timeout_ms=_GIT_STATUS_TIMEOUT_MS,
|
||||
)
|
||||
if ls_files_result.exit_code != 0 or ls_files_result.timed_out:
|
||||
return MaterialiseResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
skip_worktree_set=0,
|
||||
files_fetched=0,
|
||||
error_detail="git ls-files failed: exit={} stderr={}".format(
|
||||
ls_files_result.exit_code,
|
||||
(ls_files_result.stderr or "").strip() or "(no stderr)",
|
||||
),
|
||||
)
|
||||
tracked_files = tuple(
|
||||
entry for entry in (ls_files_result.stdout or "").split("\x00") if entry
|
||||
)
|
||||
|
||||
# 1b. status — everything dirty / untracked
|
||||
status_result = runner(
|
||||
host_alias,
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
repo.remote_root,
|
||||
"status",
|
||||
"--porcelain=v2",
|
||||
"--untracked-files=normal",
|
||||
"-z",
|
||||
],
|
||||
cwd=repo.remote_root,
|
||||
timeout_ms=_GIT_STATUS_TIMEOUT_MS,
|
||||
)
|
||||
if status_result.exit_code != 0 or status_result.timed_out:
|
||||
return MaterialiseResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
skip_worktree_set=0,
|
||||
files_fetched=0,
|
||||
error_detail="git status failed: exit={} stderr={}".format(
|
||||
status_result.exit_code,
|
||||
(status_result.stderr or "").strip() or "(no stderr)",
|
||||
),
|
||||
)
|
||||
|
||||
classification = classify_status_porcelain_v2(
|
||||
(status_result.stdout or "").encode("utf-8", errors="replace"),
|
||||
tracked_files,
|
||||
)
|
||||
|
||||
# 2. skip-worktree on clean tracked files. Run as one ``update-
|
||||
# index --skip-worktree --stdin`` invocation so we don't fork a
|
||||
# git subprocess per file (clean files dominate, repos with 10k
|
||||
# tracked files would otherwise spawn 10k subprocesses).
|
||||
skip_worktree_set = _set_skip_worktree_local(
|
||||
repo.local_root, classification.clean_tracked, git_local
|
||||
)
|
||||
if skip_worktree_set < 0:
|
||||
return MaterialiseResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
skip_worktree_set=0,
|
||||
files_fetched=0,
|
||||
error_detail="local git update-index --skip-worktree failed",
|
||||
)
|
||||
|
||||
# 3. fetch dirty file content. Sequential reads in v0 — these are
|
||||
# bounded by the user's actually-edited file count, not repo
|
||||
# size, so the round-trip cost is acceptable.
|
||||
fetched = 0
|
||||
for relative in classification.dirty_modified:
|
||||
remote_path = "{}/{}".format(repo.remote_root.rstrip("/"), relative)
|
||||
local_path = repo.local_root / relative
|
||||
try:
|
||||
result = reader(
|
||||
host_alias, RemoteReadFileRequest(remote_absolute_path=remote_path)
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 — short-circuit cleanly.
|
||||
return MaterialiseResult(
|
||||
repo=repo,
|
||||
ok=False,
|
||||
skip_worktree_set=skip_worktree_set,
|
||||
files_fetched=fetched,
|
||||
error_detail="file/read failed for {}: {}".format(relative, error),
|
||||
)
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(result.body)
|
||||
fetched += 1
|
||||
|
||||
return MaterialiseResult(
|
||||
repo=repo,
|
||||
ok=True,
|
||||
skip_worktree_set=skip_worktree_set,
|
||||
files_fetched=fetched,
|
||||
error_detail=None,
|
||||
)
|
||||
|
||||
|
||||
def _set_skip_worktree_local(
|
||||
local_root: Path,
|
||||
paths: Tuple[str, ...],
|
||||
git_local: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> int:
|
||||
"""Run ``git update-index --skip-worktree --stdin`` against ``local_root``.
|
||||
|
||||
Returns the number of paths fed to git on success, or ``-1`` on
|
||||
a non-zero git exit. Empty ``paths`` is a no-op (returns ``0``).
|
||||
"""
|
||||
if not paths:
|
||||
return 0
|
||||
payload = "\n".join(paths) + "\n"
|
||||
try:
|
||||
proc = git_local(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(local_root),
|
||||
"update-index",
|
||||
"--skip-worktree",
|
||||
"--stdin",
|
||||
],
|
||||
input=payload,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return -1
|
||||
if proc.returncode != 0:
|
||||
return -1
|
||||
return len(paths)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"MaterialiseResult",
|
||||
"WorkingTreeClassification",
|
||||
"classify_status_porcelain_v2",
|
||||
"materialise_working_tree",
|
||||
)
|
||||
100
sublime/sessions/git_repo_discovery.py
Normal file
100
sublime/sessions/git_repo_discovery.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Discover git repositories inside a Sessions workspace mirror.
|
||||
|
||||
Track G v0 (Sublime Merge–compatible git/SCM integration) starts here:
|
||||
the mirrored cache root is walked once at workspace open and every
|
||||
directory containing a ``.git`` (regular repo) or every file named
|
||||
``.git`` (worktree pointer) is reported. Downstream modules fetch the
|
||||
real ``.git`` contents (G2), apply the materialisation policy (G3),
|
||||
and proxy branch switches (G4) using the repo list this module emits.
|
||||
|
||||
Pure data layer — no Sublime imports, no bridge calls. The walk runs
|
||||
against ``local_cache_root`` which is already a real local path
|
||||
(stubs and all); G2 fills the ``.git`` interior with real content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitRepo:
|
||||
"""One discovered git repository under the workspace mirror.
|
||||
|
||||
Attributes:
|
||||
local_root: Working-tree root in the local cache mirror (parent
|
||||
of the ``.git`` entry).
|
||||
remote_root: The remote-side absolute path that ``local_root``
|
||||
mirrors. Computed by joining ``remote_workspace_root`` with
|
||||
the relative path from ``local_cache_root`` to ``local_root``.
|
||||
kind: ``"regular"`` when ``.git`` is a directory, ``"worktree"``
|
||||
when ``.git`` is a file (the latter contains a single
|
||||
``gitdir: <abs path>`` line per the git docs).
|
||||
"""
|
||||
|
||||
local_root: Path
|
||||
remote_root: str
|
||||
kind: str
|
||||
|
||||
|
||||
def discover_git_repos(
|
||||
local_cache_root: Path,
|
||||
remote_workspace_root: str,
|
||||
) -> Tuple[GitRepo, ...]:
|
||||
"""Walk ``local_cache_root`` and return every git repo we find.
|
||||
|
||||
The result is sorted by ``local_root`` for deterministic ordering
|
||||
(callers that hash-sign the discovery output for cache invalidation
|
||||
want this). Nested repos (a ``.git`` inside another repo's working
|
||||
tree, e.g. submodules) are reported individually; the caller decides
|
||||
whether to follow the nesting.
|
||||
|
||||
``local_cache_root`` that does not exist yet returns an empty tuple
|
||||
rather than raising — the mirror may not have populated it yet.
|
||||
"""
|
||||
if not local_cache_root.exists() or not local_cache_root.is_dir():
|
||||
return ()
|
||||
|
||||
discovered: list[GitRepo] = []
|
||||
remote_normalized = remote_workspace_root.rstrip("/") or "/"
|
||||
|
||||
# Iterative walk so we can prune ``.git`` subtrees (no point in
|
||||
# descending into ``.git`` when we already classified the parent).
|
||||
stack: list[Path] = [local_cache_root]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
try:
|
||||
children = list(current.iterdir())
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
for child in children:
|
||||
if child.name == ".git":
|
||||
kind = "regular" if child.is_dir() else "worktree"
|
||||
relative = current.relative_to(local_cache_root)
|
||||
# Posix-join the relative path onto the remote root so we
|
||||
# don't accidentally leak host-side path separators.
|
||||
rel_posix = str(relative).replace("\\", "/")
|
||||
if rel_posix in {"", "."}:
|
||||
remote_root = remote_normalized
|
||||
else:
|
||||
remote_root = "{}/{}".format(remote_normalized, rel_posix)
|
||||
discovered.append(
|
||||
GitRepo(
|
||||
local_root=current,
|
||||
remote_root=remote_root,
|
||||
kind=kind,
|
||||
)
|
||||
)
|
||||
# Don't descend into ``.git`` — its interior is implementation
|
||||
# detail of git, not nested repos we care about.
|
||||
continue
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
stack.append(child)
|
||||
|
||||
discovered.sort(key=lambda repo: repo.local_root)
|
||||
return tuple(discovered)
|
||||
|
||||
|
||||
__all__ = ("GitRepo", "discover_git_repos")
|
||||
@@ -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,18 @@ 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.
|
||||
|
||||
Pre-W1 the PersistentBroker was Unix-only and Windows always reported
|
||||
an empty ``broker_socket``. As of v0.7.8 the broker is cross-platform
|
||||
(Named Pipe under ``\\\\.\\pipe\\…`` on Windows via ``interprocess``),
|
||||
so an empty ``broker_socket`` on Windows now means the broker failed
|
||||
to start (rare — e.g. an AV blocking named pipes). We still return
|
||||
``None`` in that case so the diagnostics panel doesn't re-open every
|
||||
activation; the v0.7.6 ``managed_lsp_enabled`` gate keeps the LSP
|
||||
rows ``enabled: false`` until the next handshake supplies a live
|
||||
broker_socket.
|
||||
"""
|
||||
if bridge_path is None:
|
||||
return (
|
||||
"Sessions: local_bridge binary not found; build or ship local_bridge "
|
||||
@@ -408,11 +581,14 @@ 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)."
|
||||
)
|
||||
if not Path(broker).exists():
|
||||
if not _broker_endpoint_exists(broker):
|
||||
return (
|
||||
"Sessions: broker_socket path is stale or missing ({}). "
|
||||
"Try reconnecting the workspace.".format(broker)
|
||||
@@ -420,6 +596,31 @@ def explain_lsp_attach_blockers(
|
||||
return None
|
||||
|
||||
|
||||
def _broker_endpoint_exists(broker: str) -> bool:
|
||||
"""Liveness probe for the broker endpoint that tolerates Windows pipe busy.
|
||||
|
||||
On POSIX the broker socket is a regular Unix-domain-socket file, so
|
||||
``Path(broker).exists()`` works as expected. On Windows the broker is
|
||||
a Named Pipe under ``\\\\.\\pipe\\…`` and probing it with
|
||||
``os.stat`` consumes a pipe *instance*; if every pre-allocated
|
||||
instance is busy when the activation listener fires, the call
|
||||
raises ``OSError`` with ``WinError 231`` ("all pipe instances are
|
||||
busy"). That error means the broker is *very much alive* — just
|
||||
saturated — so we must not interpret it as "endpoint missing".
|
||||
Treat any ``OSError`` other than ``ENOENT`` as "exists" on Windows
|
||||
so the LSP attach path doesn't tear itself down on every focus
|
||||
change.
|
||||
"""
|
||||
try:
|
||||
return Path(broker).exists()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
if sys.platform == "win32" and broker.startswith("\\\\.\\pipe\\"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SESSIONS_LSP_PYRIGHT_CLIENT_KEY",
|
||||
"SESSIONS_LSP_RUFF_CLIENT_KEY",
|
||||
@@ -427,6 +628,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",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Single source of truth for built-in remote LSP servers (install + project stdio).
|
||||
"""Single source of truth for built-in remote extensions (install + project stdio).
|
||||
|
||||
Each :class:`ManagedRemoteLspCatalogEntry` bundles:
|
||||
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``).
|
||||
remote ``argv`` for ``local_bridge lsp-stdio``) when ``kind == "lsp"``.
|
||||
|
||||
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
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -80,11 +80,33 @@ 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_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__)"
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedRemoteLspCatalogEntry:
|
||||
"""Metadata for one Sessions-managed remote LSP."""
|
||||
class ManagedRemoteExtensionCatalogEntry:
|
||||
"""Metadata for one Sessions-managed remote extension."""
|
||||
|
||||
install_catalog_id: str
|
||||
install_label: str
|
||||
@@ -92,51 +114,69 @@ class ManagedRemoteLspCatalogEntry:
|
||||
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
|
||||
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_LSP_CATALOG: Tuple[ManagedRemoteLspCatalogEntry, ...] = (
|
||||
ManagedRemoteLspCatalogEntry(
|
||||
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",
|
||||
),
|
||||
ManagedRemoteLspCatalogEntry(
|
||||
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",
|
||||
),
|
||||
ManagedRemoteLspCatalogEntry(
|
||||
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="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",
|
||||
),
|
||||
)
|
||||
614
sublime/sessions/marimo_hosting.py
Normal file
614
sublime/sessions/marimo_hosting.py
Normal file
@@ -0,0 +1,614 @@
|
||||
"""Pure-Python primitives for remote marimo notebook hosting.
|
||||
|
||||
The plugin opens ``.py`` reactive notebooks against a remote marimo edit
|
||||
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.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- We launch the remote marimo 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 marimo's startup
|
||||
banner in would corrupt the stream.
|
||||
- Remote port is selected by **us** by binding to ``127.0.0.1:0`` on the
|
||||
remote host before launch; marimo's ``--port`` flag does not document a
|
||||
``0``-means-random behaviour, so we pre-pick a free port and pass it
|
||||
explicitly. # TODO(marimo): verify that ``marimo edit --port 0`` is not
|
||||
supported, and that asking marimo for an explicit free port is the
|
||||
correct strategy (vs. parsing the bound port out of the startup log).
|
||||
- Local port is picked by binding to ``127.0.0.1:0`` and releasing — races
|
||||
are possible but acceptable for MVP.
|
||||
- No kernelspec registration: marimo uses whichever Python the ``marimo``
|
||||
CLI itself runs on, so installing marimo into the user's venv is
|
||||
sufficient — no equivalent of ``jupyter kernelspec install`` is needed.
|
||||
- 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 logging
|
||||
import os
|
||||
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.marimo_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 ``MarimoSessionManager.__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
|
||||
|
||||
|
||||
# Cold marimo launches on slow links / first-import-of-deps can easily take
|
||||
# 30-60s. Override via the ``SESSIONS_MARIMO_STARTUP_TIMEOUT_S`` env var when
|
||||
# tuning further on a specific host.
|
||||
def _resolve_startup_timeout_seconds() -> float:
|
||||
raw = os.environ.get("SESSIONS_MARIMO_STARTUP_TIMEOUT_S")
|
||||
if not raw:
|
||||
return 60.0
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return 60.0
|
||||
return value if value > 0 else 60.0
|
||||
|
||||
|
||||
_STARTUP_TIMEOUT_SECONDS = _resolve_startup_timeout_seconds()
|
||||
_TUNNEL_PROBE_TIMEOUT_SECONDS = 5.0
|
||||
_TERMINATE_GRACE_SECONDS = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarimoServerInfo:
|
||||
"""Snapshot of one running remote marimo edit 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
|
||||
|
||||
|
||||
class MarimoHostingError(RuntimeError):
|
||||
"""Raised when a remote marimo server or tunnel fails to come up."""
|
||||
|
||||
|
||||
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 marimo bound to, parsed from a startup log blob.
|
||||
|
||||
marimo writes lines like ``http://127.0.0.1:2718?access_token=...`` (or
|
||||
similar) once the edit server is ready; we grab the first such line's
|
||||
port. Returns ``None`` if no recognisable URL has been emitted yet.
|
||||
|
||||
# TODO(marimo): verify the exact startup-line format. Current parser
|
||||
# looks for ``http://127.0.0.1:<digits>`` which should match either
|
||||
# ``http://127.0.0.1:2718`` or ``http://127.0.0.1:2718/?access_token=...``.
|
||||
"""
|
||||
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 "2718/?access_token=..." — cut at 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: MarimoServerInfo,
|
||||
remote_notebook_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return the tunneled marimo edit URL for a server and optional notebook.
|
||||
|
||||
With ``remote_notebook_path`` (an absolute remote path to a ``.py``
|
||||
reactive notebook), returns
|
||||
``http://127.0.0.1:<local_port>/?file=<abs path>&access_token=<token>``.
|
||||
Without one, falls back to the bare edit root URL.
|
||||
|
||||
# TODO(marimo): verify the exact URL shape — depending on marimo
|
||||
# version this may be ``/edit?file=...`` or ``/?file=...`` and the
|
||||
# auth query param may be ``access_token`` vs. ``token``.
|
||||
"""
|
||||
_LOG.info(
|
||||
"build_notebook_url: server.local_port=%s notebook_path=%r",
|
||||
server.local_port,
|
||||
remote_notebook_path,
|
||||
)
|
||||
base = f"http://127.0.0.1:{server.local_port}"
|
||||
if remote_notebook_path is None:
|
||||
query = urlencode({"access_token": server.token})
|
||||
return f"{base}/?{query}"
|
||||
|
||||
# Pass the absolute remote path through unchanged so marimo can resolve
|
||||
# it on the remote side; ``quote`` percent-encodes spaces / unicode but
|
||||
# preserves ``/`` so the path stays human-readable in the URL.
|
||||
safe_path = quote(remote_notebook_path, safe="/")
|
||||
query = urlencode({"file": safe_path, "access_token": server.token}, safe="/")
|
||||
return f"{base}/?{query}"
|
||||
|
||||
|
||||
# Backwards-friendly alias so callers can ``from .marimo_hosting import
|
||||
# marimo_url_for_notebook`` if they prefer the spelled-out name.
|
||||
marimo_url_for_notebook = build_notebook_url
|
||||
|
||||
|
||||
class MarimoSessionManager:
|
||||
"""Process-global registry of running remote marimo edit 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 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, MarimoServerInfo] = {}
|
||||
|
||||
@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[MarimoServerInfo]:
|
||||
"""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,
|
||||
) -> MarimoServerInfo:
|
||||
"""Return a running marimo server for ``host_alias``, launching if needed.
|
||||
|
||||
Idempotent: if a registered server exists and its local-tunnel PID is
|
||||
still alive, that ``MarimoServerInfo`` is returned without spawning a
|
||||
new server. Concurrent calls for the same alias coalesce under the
|
||||
registry lock; only one launch runs.
|
||||
|
||||
Unlike the Jupyter variant, marimo runs whichever Python it's
|
||||
installed under, so there is no kernelspec registration step — the
|
||||
caller is expected to have ensured ``marimo`` is importable in the
|
||||
target venv before invoking ``ensure_started``.
|
||||
"""
|
||||
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 marimo 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)
|
||||
self._servers[host_alias] = info
|
||||
return info
|
||||
|
||||
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,
|
||||
) -> MarimoServerInfo:
|
||||
token = self._token_factory()
|
||||
local_port = self._port_picker()
|
||||
log_path = f"~/.sessions/marimo-{token}.log"
|
||||
|
||||
remote_pid = self._spawn_remote_server(
|
||||
host_alias=host_alias,
|
||||
workspace_root=workspace_root,
|
||||
token=token,
|
||||
log_path=log_path,
|
||||
)
|
||||
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 MarimoHostingError(
|
||||
f"local tunnel probe on 127.0.0.1:{local_port} failed: {exc}"
|
||||
) from exc
|
||||
|
||||
return MarimoServerInfo(
|
||||
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(),
|
||||
)
|
||||
|
||||
def _spawn_remote_server(
|
||||
self,
|
||||
*,
|
||||
host_alias: str,
|
||||
workspace_root: str,
|
||||
token: str,
|
||||
log_path: str,
|
||||
) -> int:
|
||||
# marimo's CLI does not document a ``--port 0`` behaviour, so we ask
|
||||
# the remote shell to pick a free port via Python's stdlib (binding
|
||||
# to :0 then closing) and pass that integer to ``marimo edit``.
|
||||
# # TODO(marimo): verify whether ``marimo edit --port 0`` is in
|
||||
# fact unsupported — if it picks a free port itself, simplify this
|
||||
# to ``--port 0`` and parse the actual bound port from the log
|
||||
# (the `_await_remote_port` helper already handles that).
|
||||
port_pick_py = (
|
||||
'python3 -c \'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); '
|
||||
"print(s.getsockname()[1]); s.close()'"
|
||||
)
|
||||
# Build the remote launch script. Notes:
|
||||
# - ``--headless`` keeps marimo from trying to open a browser on the
|
||||
# remote host.
|
||||
# - ``--token-password`` (or equivalent) supplies our generated
|
||||
# shared secret; we do NOT pass ``--no-token``.
|
||||
# - ``--host 127.0.0.1`` so the SSH ``-L`` tunnel is the only path in.
|
||||
# # TODO(marimo): verify the exact flag spelling. Recent marimo
|
||||
# versions use ``--token-password <token>`` while older ones used
|
||||
# ``--token <token>``; some versions also gate edit-server auth
|
||||
# behind ``--no-token`` / ``--token-password=<>`` semantics.
|
||||
remote_script = (
|
||||
"mkdir -p ~/.sessions && "
|
||||
f"cd {shlex.quote(workspace_root)} && "
|
||||
f"PORT=$({port_pick_py}) && "
|
||||
f"nohup marimo edit --headless --host 127.0.0.1 "
|
||||
f'--port "$PORT" --token-password {shlex.quote(token)} '
|
||||
f"> {log_path} 2>&1 & echo $!"
|
||||
)
|
||||
# Pass ``bash -lc <script>`` as a single SSH-side argument so the
|
||||
# remote login shell doesn't tokenise the script and pass only the
|
||||
# leading word to ``bash -lc``.
|
||||
argv = list(self._ssh(host_alias)) + [
|
||||
"bash -lc " + shlex.quote(remote_script),
|
||||
]
|
||||
_LOG.debug("spawning remote marimo 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 MarimoHostingError(
|
||||
f"remote marimo launch on {host_alias} exited "
|
||||
f"{completed.returncode}: {completed.stderr!r}"
|
||||
)
|
||||
pid_text = (completed.stdout or "").strip().splitlines()
|
||||
if not pid_text:
|
||||
raise MarimoHostingError(
|
||||
f"remote marimo launch on {host_alias} produced no PID output"
|
||||
)
|
||||
try:
|
||||
return int(pid_text[-1].strip())
|
||||
except ValueError as exc:
|
||||
raise MarimoHostingError(
|
||||
f"remote marimo launch on {host_alias} returned non-numeric "
|
||||
f"PID: {pid_text!r}"
|
||||
) from exc
|
||||
|
||||
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 = ""
|
||||
last_stderr = ""
|
||||
last_rc: Optional[int] = None
|
||||
while self._clock() < deadline:
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
last_rc = completed.returncode
|
||||
if completed.returncode == 0:
|
||||
last_text = completed.stdout or ""
|
||||
port = _parse_remote_port_from_log(last_text)
|
||||
if port is not None:
|
||||
return port
|
||||
else:
|
||||
# `cat` returned non-zero — file likely doesn't exist yet
|
||||
# (marimo still booting and hasn't redirected its first
|
||||
# write). Capture stderr so the timeout error doesn't
|
||||
# surface as an unhelpful empty-snippet message.
|
||||
last_stderr = (completed.stderr or "").strip()
|
||||
self._sleep(_STARTUP_POLL_INTERVAL_SECONDS)
|
||||
if last_text:
|
||||
tail = last_text[-400:]
|
||||
elif last_stderr:
|
||||
tail = "(log file unreadable, ssh stderr: {})".format(last_stderr)
|
||||
else:
|
||||
tail = "(empty — marimo wrote nothing within timeout)"
|
||||
raise MarimoHostingError(
|
||||
"timed out after {timeout:.0f}s waiting for marimo startup on "
|
||||
"{host}; last cat rc={rc}; log snippet: {tail!r}".format(
|
||||
timeout=_STARTUP_TIMEOUT_SECONDS,
|
||||
host=host_alias,
|
||||
rc=last_rc,
|
||||
tail=tail,
|
||||
)
|
||||
)
|
||||
|
||||
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 MarimoHostingError(
|
||||
f"local ssh tunnel for {host_alias} did not report a PID"
|
||||
)
|
||||
return int(pid)
|
||||
|
||||
def _teardown(self, info: MarimoServerInfo) -> None:
|
||||
self._teardown_pids(
|
||||
host_alias=info.host_alias,
|
||||
tunnel_pid=info.tunnel_pid,
|
||||
remote_pid=info.pid,
|
||||
log_path=f"~/.sessions/marimo-{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
|
||||
)
|
||||
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",
|
||||
)
|
||||
427
sublime/sessions/python_interpreter_registry.py
Normal file
427
sublime/sessions/python_interpreter_registry.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""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
|
||||
|
||||
from . import _rust_ffi
|
||||
|
||||
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 live in ``sessions_native::interpreter_probe`` (Wave 1.5
|
||||
amend §F). Returns ``None`` when input has no useful name (empty or
|
||||
single-component path) — Rust returns empty string in that case, this
|
||||
wrapper normalizes back to ``None`` to preserve the legacy contract.
|
||||
"""
|
||||
derived = _rust_ffi.derive_venv_name(remote_path)
|
||||
return derived if derived else None
|
||||
|
||||
|
||||
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_raw = scope_name(0)
|
||||
except Exception: # noqa: BLE001
|
||||
scope_raw = None
|
||||
scope = scope_raw if isinstance(scope_raw, str) else ""
|
||||
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_raw = file_name()
|
||||
except Exception: # noqa: BLE001
|
||||
name_raw = None
|
||||
name = name_raw if isinstance(name_raw, str) else ""
|
||||
if name.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",
|
||||
)
|
||||
@@ -1,16 +1,25 @@
|
||||
"""Settings models for Sessions foundation work."""
|
||||
"""Settings models for Sessions foundation work.
|
||||
|
||||
Wave 1.5 amend §F: 정규화 알고리즘은 Rust(``sessions_native::settings_normalize``)에
|
||||
응집되어 있다. 본 모듈은 (a) Python dataclass 정의, (b) Rust 호출 결과를
|
||||
dataclass로 감싸는 thin wrapper, (c) Sublime API에 결합된 ``load_settings_…``
|
||||
만 보유한다.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .managed_remote_lsp_catalog import BUILTIN_MANAGED_REMOTE_LSP_CATALOG
|
||||
from . import _rust_ffi
|
||||
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")
|
||||
ALLOWED_CODE_SERVER_TYPES = frozenset({"exec_once", "lsp_stdio"})
|
||||
|
||||
_DEFAULT_GITEA_ARTIFACT_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
@@ -20,23 +29,7 @@ _DEFAULT_GITEA_ARTIFACT_USER_AGENT = (
|
||||
|
||||
def normalize_remote_python_tool_pipeline(raw: object) -> Tuple[str, ...]:
|
||||
"""Return a stable ordered pipeline tuple from user settings JSON."""
|
||||
if raw is None:
|
||||
return DEFAULT_REMOTE_PYTHON_TOOL_PIPELINE
|
||||
if isinstance(raw, str):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return DEFAULT_REMOTE_PYTHON_TOOL_PIPELINE
|
||||
out_list: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
step = item.strip()
|
||||
if step not in ALLOWED_REMOTE_PYTHON_TOOL_STEPS or step in seen:
|
||||
continue
|
||||
seen.add(step)
|
||||
out_list.append(step)
|
||||
return tuple(out_list) if out_list else DEFAULT_REMOTE_PYTHON_TOOL_PIPELINE
|
||||
return _rust_ffi.normalize_python_tool_pipeline(raw)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,8 +44,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
|
||||
@@ -62,118 +55,77 @@ class RemoteLspServerSpec:
|
||||
cwd: Optional[str] = None
|
||||
|
||||
|
||||
def _code_server_spec_from_dict(item: Dict[str, Any]) -> Optional[CodeServerSpec]:
|
||||
sid = item.get("id")
|
||||
server_type = item.get("server_type")
|
||||
if not isinstance(sid, str) or not isinstance(server_type, str):
|
||||
return None
|
||||
argv = item.get("argv") or []
|
||||
match_globs = item.get("match_globs") or []
|
||||
lifecycle = item.get("lifecycle") or "manual"
|
||||
return CodeServerSpec(
|
||||
id=sid,
|
||||
server_type=server_type,
|
||||
argv=tuple(str(v) for v in argv),
|
||||
lifecycle=lifecycle if isinstance(lifecycle, str) else "manual",
|
||||
match_globs=tuple(str(v) for v in match_globs),
|
||||
)
|
||||
|
||||
|
||||
def _remote_extension_spec_from_dict(
|
||||
item: Dict[str, Any],
|
||||
) -> Optional[RemoteExtensionSpec]:
|
||||
sid = item.get("id")
|
||||
label = item.get("label")
|
||||
install_argv = item.get("install_argv") or []
|
||||
remove_argv = item.get("remove_argv") or []
|
||||
probe_argv = item.get("probe_argv") or []
|
||||
if not isinstance(sid, str) or not isinstance(label, str):
|
||||
return None
|
||||
cwd_raw = item.get("cwd")
|
||||
cwd = cwd_raw if isinstance(cwd_raw, str) else None
|
||||
return RemoteExtensionSpec(
|
||||
id=sid,
|
||||
label=label,
|
||||
install_argv=tuple(str(v) for v in install_argv),
|
||||
remove_argv=tuple(str(v) for v in remove_argv),
|
||||
probe_argv=tuple(str(v) for v in probe_argv),
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
def normalize_code_server_specs(raw: object) -> Tuple[CodeServerSpec, ...]:
|
||||
"""Normalize user-provided code-server registry settings."""
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
canonical = _rust_ffi.normalize_code_server_specs_json(raw)
|
||||
out: List[CodeServerSpec] = []
|
||||
seen: Set[str] = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
server_id = item.get("id")
|
||||
server_type = item.get("type")
|
||||
argv = item.get("argv", [])
|
||||
if not isinstance(server_id, str) or not server_id.strip():
|
||||
continue
|
||||
if (
|
||||
not isinstance(server_type, str)
|
||||
or server_type not in ALLOWED_CODE_SERVER_TYPES
|
||||
):
|
||||
continue
|
||||
normalized_id = server_id.strip()
|
||||
if normalized_id in seen:
|
||||
continue
|
||||
seen.add(normalized_id)
|
||||
argv_tuple = (
|
||||
tuple(str(value) for value in argv)
|
||||
if isinstance(argv, (list, tuple))
|
||||
else ()
|
||||
)
|
||||
lifecycle = item.get("lifecycle", "manual")
|
||||
if not isinstance(lifecycle, str) or not lifecycle.strip():
|
||||
lifecycle = "manual"
|
||||
match_globs_raw = item.get("match_globs", [])
|
||||
match_globs = (
|
||||
tuple(str(value) for value in match_globs_raw)
|
||||
if isinstance(match_globs_raw, (list, tuple))
|
||||
else ()
|
||||
)
|
||||
out.append(
|
||||
CodeServerSpec(
|
||||
id=normalized_id,
|
||||
server_type=server_type,
|
||||
argv=argv_tuple,
|
||||
lifecycle=lifecycle.strip(),
|
||||
match_globs=match_globs,
|
||||
)
|
||||
)
|
||||
for item in canonical:
|
||||
spec = _code_server_spec_from_dict(item)
|
||||
if spec is not None:
|
||||
out.append(spec)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def normalize_remote_lsp_server_specs(raw: object) -> Tuple[RemoteLspServerSpec, ...]:
|
||||
"""Normalize user-provided remote LSP install/remove specs."""
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
out: List[RemoteLspServerSpec] = []
|
||||
seen: Set[str] = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
server_id = item.get("id")
|
||||
if not isinstance(server_id, str) or not server_id.strip():
|
||||
continue
|
||||
normalized_id = server_id.strip()
|
||||
if normalized_id in seen:
|
||||
continue
|
||||
install_raw = item.get("install_argv")
|
||||
remove_raw = item.get("remove_argv")
|
||||
probe_raw = item.get("probe_argv")
|
||||
if not isinstance(install_raw, (list, tuple)) or not isinstance(
|
||||
remove_raw, (list, tuple)
|
||||
):
|
||||
continue
|
||||
install_argv = tuple(str(v) for v in install_raw if str(v).strip())
|
||||
remove_argv = tuple(str(v) for v in remove_raw if str(v).strip())
|
||||
if not install_argv or not remove_argv:
|
||||
continue
|
||||
probe_argv = (
|
||||
tuple(str(v) for v in probe_raw if str(v).strip())
|
||||
if isinstance(probe_raw, (list, tuple))
|
||||
else ()
|
||||
)
|
||||
label_raw = item.get("label", normalized_id)
|
||||
label = (
|
||||
label_raw.strip()
|
||||
if isinstance(label_raw, str) and label_raw.strip()
|
||||
else normalized_id
|
||||
)
|
||||
cwd_raw = item.get("cwd")
|
||||
cwd = cwd_raw.strip() if isinstance(cwd_raw, str) and cwd_raw.strip() else None
|
||||
seen.add(normalized_id)
|
||||
out.append(
|
||||
RemoteLspServerSpec(
|
||||
id=normalized_id,
|
||||
label=label,
|
||||
install_argv=install_argv,
|
||||
remove_argv=remove_argv,
|
||||
probe_argv=probe_argv,
|
||||
cwd=cwd,
|
||||
)
|
||||
)
|
||||
def normalize_remote_extension_specs(raw: object) -> Tuple[RemoteExtensionSpec, ...]:
|
||||
"""Normalize user-provided remote extension install/remove specs."""
|
||||
canonical = _rust_ffi.normalize_remote_extension_specs_json(raw)
|
||||
out: List[RemoteExtensionSpec] = []
|
||||
for item in canonical:
|
||||
spec = _remote_extension_spec_from_dict(item)
|
||||
if spec is not None:
|
||||
out.append(spec)
|
||||
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,40 +133,44 @@ 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.
|
||||
|
||||
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
|
||||
def _spec_to_canonical_dict(spec: RemoteExtensionSpec) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": spec.id,
|
||||
"label": spec.label,
|
||||
"install_argv": list(spec.install_argv),
|
||||
"remove_argv": list(spec.remove_argv),
|
||||
"probe_argv": list(spec.probe_argv),
|
||||
"cwd": spec.cwd,
|
||||
}
|
||||
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]
|
||||
for sid in builtin_ids:
|
||||
if sid in by_id:
|
||||
ordered.append(by_id[sid])
|
||||
seen_extra: Set[str] = set(builtin_ids)
|
||||
for spec in user_specs:
|
||||
if spec.id in seen_extra:
|
||||
continue
|
||||
ordered.append(by_id[spec.id])
|
||||
seen_extra.add(spec.id)
|
||||
return tuple(ordered)
|
||||
|
||||
|
||||
def merge_remote_extension_catalog(user_raw: object) -> Tuple[RemoteExtensionSpec, ...]:
|
||||
"""Return effective extension install catalog: builtins + user overrides/extras.
|
||||
|
||||
Delegates the merge to Rust (``sessions_settings_merge_extension_catalog``).
|
||||
Builtin catalog stays in Python (``managed_remote_extension_catalog``).
|
||||
"""
|
||||
builtin_canonical = [
|
||||
_spec_to_canonical_dict(spec) for spec in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS
|
||||
]
|
||||
canonical = _rust_ffi.merge_remote_extension_catalog_json(
|
||||
builtin_canonical, user_raw
|
||||
)
|
||||
out: List[RemoteExtensionSpec] = []
|
||||
for item in canonical:
|
||||
spec = _remote_extension_spec_from_dict(item)
|
||||
if spec is not None:
|
||||
out.append(spec)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def default_ssh_config_path() -> Path:
|
||||
@@ -262,6 +218,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 +241,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 +250,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 +289,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 +325,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)) # type: ignore[arg-type]
|
||||
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)) # type: ignore[arg-type]
|
||||
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 +353,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,9 +364,66 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sessions sync-mode (product-level "safe / balanced / full" knob).
|
||||
#
|
||||
# Sessions ships with a handful of EDR-friendly bandwidth caps (max_entries,
|
||||
# max_dir_fanout, writes_per_second_cap) and several auto-on switches
|
||||
# (mirror_auto_refresh, mirror_include_files, connect_auto_open_remote_folder).
|
||||
# Asking security-sensitive users to clamp each switch by hand was the friction
|
||||
# point flagged in the 2026-04 distribution review. ``sessions_sync_mode`` is
|
||||
# the single product-level knob users see; per-key settings still work and act
|
||||
# as explicit overrides under ``balanced`` / ``full``. Under ``safe`` the keys
|
||||
# in ``_SAFE_MODE_FORCED_OFF`` are forced to ``False`` regardless of their
|
||||
# per-key default, so picking ``safe`` once is enough to get a quiet first
|
||||
# connect on EDR-managed machines.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
SESSIONS_SYNC_MODE_KEY = "sessions_sync_mode"
|
||||
SESSIONS_SYNC_MODE_DEFAULT = "balanced"
|
||||
SESSIONS_SYNC_MODE_VALUES: Tuple[str, ...] = ("safe", "balanced", "full")
|
||||
|
||||
_SAFE_MODE_FORCED_OFF: Tuple[str, ...] = (
|
||||
"sessions_mirror_auto_refresh",
|
||||
"sessions_mirror_include_files",
|
||||
"sessions_connect_auto_open_remote_folder",
|
||||
)
|
||||
|
||||
|
||||
def resolve_sessions_sync_mode(getter) -> str:
|
||||
"""Return the validated sync mode (``safe`` / ``balanced`` / ``full``).
|
||||
|
||||
``getter`` matches the ``Settings.get(key, default)`` shape used at the
|
||||
Sublime API boundary; unknown values fall back to ``balanced`` so a typo in
|
||||
user settings cannot silently change product behavior.
|
||||
"""
|
||||
raw = getter(SESSIONS_SYNC_MODE_KEY, SESSIONS_SYNC_MODE_DEFAULT)
|
||||
if isinstance(raw, str) and raw in SESSIONS_SYNC_MODE_VALUES:
|
||||
return raw
|
||||
return SESSIONS_SYNC_MODE_DEFAULT
|
||||
|
||||
|
||||
def sync_mode_bool(getter, key: str, fallback: bool) -> bool:
|
||||
"""Return effective bool for ``key`` after applying sync-mode overrides.
|
||||
|
||||
Under ``safe`` the small list of bandwidth / auto-open keys collapses to
|
||||
``False`` regardless of what the per-key default or user setting says — that
|
||||
is the point of safe mode. Under ``balanced`` and ``full`` the per-key
|
||||
value (or its fallback) is returned unchanged, so existing user settings
|
||||
keep working without modification.
|
||||
"""
|
||||
if key in _SAFE_MODE_FORCED_OFF and resolve_sessions_sync_mode(getter) == "safe":
|
||||
return False
|
||||
return bool(getter(key, fallback))
|
||||
|
||||
|
||||
def gitea_registry_http_headers(settings: SessionsSettings) -> Dict[str, str]:
|
||||
"""Return headers for Gitea generic package GET/PUT (Cloudflare-safe defaults)."""
|
||||
ua = (settings.gitea_http_user_agent or "").strip() or os.environ.get(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user