Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d51e5f2f05 | |||
| aa0202f287 | |||
| e21b3a4d8a | |||
| 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 |
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
|
||||
@@ -5,14 +5,14 @@
|
||||
Current focus:
|
||||
|
||||
- **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), [#29](https://git.teahaven.kr/sublime-rs/sessions/issues/29) diff-centric review, [#32](https://git.teahaven.kr/sublime-rs/sessions/issues/32) large-file streaming).
|
||||
- **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). Distribution-readiness + ownership-migration plan: [`planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`](planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.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
|
||||
|
||||
@@ -30,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
|
||||
|
||||
|
||||
28
SECURITY.md
28
SECURITY.md
@@ -40,6 +40,34 @@ 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
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
# AGENT_TMUX_LAYOUT — remote agents via tmux, three-group Sublime window
|
||||
|
||||
**Status**: design only. Supersedes the earlier agent-chat / diff-viewer
|
||||
design (which has been dropped — we don't build a chat UI).
|
||||
|
||||
**Depends on**: `PYTHON_RUST_BOUNDARY.md` (no protocol changes here —
|
||||
agents run as plain CLIs over SSH; the bridge stays for file / LSP
|
||||
channels). Interacts with the managed-extension catalog (`kind="agent"`).
|
||||
|
||||
## Why tmux instead of a custom chat UI
|
||||
|
||||
The previous plan was to reimplement a chat widget in Sublime using
|
||||
phantoms, panels, and a custom NDJSON protocol to codex / claude
|
||||
daemons. That is a lot of UI code that reinvents a terminal. It also
|
||||
fragments when the agent CLI updates its protocol.
|
||||
|
||||
Observation: **every serious remote agent already ships a working
|
||||
terminal UI** (codex, claude code, anthropic CLI). Running that UI
|
||||
inside a Terminus pane that is attached to a tmux session on the
|
||||
remote host gives us:
|
||||
|
||||
- the real UX the agent vendor ships, including their slash commands,
|
||||
markdown, syntax, keybindings;
|
||||
- persistence across Sublime restarts (tmux keeps the session and the
|
||||
scrollback);
|
||||
- trivial switching between agents (just attach to a different tmux
|
||||
session) without any protocol layer;
|
||||
- the ability to run multiple agents in parallel, one tmux session
|
||||
each.
|
||||
|
||||
The Sublime side only needs to:
|
||||
|
||||
1. spawn / attach tmux sessions,
|
||||
2. lay out the window into three groups,
|
||||
3. persist and expose the `(workspace, agent)` pairs.
|
||||
|
||||
## Window layout
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────────────────┬─────────────────┐
|
||||
│ │ │ │
|
||||
│ file │ Terminus │ Agent │
|
||||
│ sidebar │ (ssh host │ Session │
|
||||
│ + │ tmux attach -t ...) │ Switcher │
|
||||
│ editor │ │ │
|
||||
│ (group 0) │ (group 1) │ (group 2) │
|
||||
│ │ │ │
|
||||
└──────────────┴──────────────────────────┴─────────────────┘
|
||||
```
|
||||
|
||||
- Sublime's built-in left sidebar (workspace file tree) is still there;
|
||||
our layout only affects the editor area to its right.
|
||||
- Group 0: the normal editor pane. File tabs open here.
|
||||
- Group 1: Terminus view attached to the agent's tmux session. This
|
||||
group is **single-view** — switching agents replaces the view.
|
||||
- Group 2: a read-only Sublime view rendering the switcher. Clicks on
|
||||
a pair entry dispatch `sessions_switch_agent_session`.
|
||||
|
||||
Proposed column widths: `[0.40, 0.40, 0.20]`. The switcher column
|
||||
can collapse to 0.0 via a toggle command when the user wants more
|
||||
editor room.
|
||||
|
||||
## Session naming convention
|
||||
|
||||
```
|
||||
sessions-agent-<workspace_cache_key[:8]>-<agent_id>
|
||||
```
|
||||
|
||||
Example: `sessions-agent-07c4844b-claude`. Agent ids come from the
|
||||
catalog entry (see below).
|
||||
|
||||
`tmux new-session -A -s <name> -- <agent_cmd>` is idempotent: attaches
|
||||
if the session exists, spawns with `<agent_cmd>` if it doesn't. We
|
||||
never `kill-session` implicitly — detach only. Explicit
|
||||
`Sessions: Kill Agent Session` command drives cleanup.
|
||||
|
||||
## Extension catalog entries
|
||||
|
||||
Agents are installed via the existing managed-extension flow. New
|
||||
`kind="agent"` variant:
|
||||
|
||||
```python
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="claude-cli",
|
||||
install_label="Claude Code CLI (remote)",
|
||||
install_argv=("bash", "-lc", _CLAUDE_INSTALL_SCRIPT),
|
||||
remove_argv=("bash", "-lc", _CLAUDE_REMOVE_SCRIPT),
|
||||
probe_argv=("bash", "-lc", "command -v claude && claude --version"),
|
||||
install_cwd=None,
|
||||
kind="agent",
|
||||
)
|
||||
```
|
||||
|
||||
The install scripts are the vendor's official install lines (npm /
|
||||
curl-to-bash / etc.). Probes check `command -v <bin>` + `--version`.
|
||||
We do **not** maintain our own agent binaries.
|
||||
|
||||
## Sub-tracks (parallelisable)
|
||||
|
||||
### D1. Tmux session broker — pure Python, unit-testable
|
||||
|
||||
New module `sublime/sessions/agent_tmux.py`. No Sublime imports at
|
||||
module top.
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class TmuxAgentSession:
|
||||
host_alias: str
|
||||
workspace_cache_key: str
|
||||
agent_id: str
|
||||
session_name: str # "sessions-agent-<ws>-<agent>"
|
||||
attach_argv: list[str] # ["ssh", "<host>", "tmux", "attach", "-t", name]
|
||||
spawn_argv: list[str] # ["ssh", "<host>", "tmux", "new-session", "-A", "-s", name, "--", <agent_cmd>]
|
||||
|
||||
class AgentTmuxBroker:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ssh_command_builder: Callable[[str], list[str]] = ...,
|
||||
run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
||||
): ...
|
||||
|
||||
def plan(self, host_alias, workspace_cache_key, agent_id, agent_cmd) -> TmuxAgentSession: ...
|
||||
|
||||
def is_running(self, host_alias, session_name) -> bool:
|
||||
# ssh host tmux has-session -t <name>
|
||||
...
|
||||
|
||||
def attach_or_spawn(self, session: TmuxAgentSession) -> None:
|
||||
# has-session → attach_argv, else new-session command
|
||||
# Called by the Terminus launcher (D3).
|
||||
...
|
||||
|
||||
def list_sessions(self, host_alias) -> list[str]:
|
||||
# ssh host tmux list-sessions -F '#{session_name}'
|
||||
# Filtered to "sessions-agent-*".
|
||||
...
|
||||
|
||||
def kill(self, host_alias, session_name) -> None:
|
||||
# ssh host tmux kill-session -t <name>
|
||||
...
|
||||
```
|
||||
|
||||
Injectable `run` makes everything unit-testable.
|
||||
|
||||
**[files]** `agent_tmux.py` (new), `test_agent_tmux.py` (new).
|
||||
|
||||
### D2. Three-group window layout
|
||||
|
||||
New module `sublime/sessions/agent_window_layout.py`. Provides one
|
||||
command:
|
||||
|
||||
```python
|
||||
class SessionsAgentLayoutCommand(sublime_plugin.WindowCommand):
|
||||
def run(self, cols=(0.40, 0.80, 1.00)) -> None:
|
||||
self.window.set_layout({
|
||||
"cols": [0.0, cols[0], cols[1], cols[2]],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [[0, 0, 1, 1], [1, 0, 2, 1], [2, 0, 3, 1]],
|
||||
})
|
||||
```
|
||||
|
||||
Plus `SessionsAgentLayoutCollapseSwitcherCommand` that widens to
|
||||
`[0.5, 1.0, 1.0]` (hides group 2). Toggleable via keybind in
|
||||
`Default.sublime-keymap`.
|
||||
|
||||
Persists the active layout in workspace state so reload restores it.
|
||||
|
||||
**[files]** `agent_window_layout.py` (new), `workspace_state.py`
|
||||
(extend with a `layout` field), Default keymap.
|
||||
|
||||
### D3. Terminus launcher
|
||||
|
||||
`sessions_open_agent_terminus`, driven by D1 + D2:
|
||||
|
||||
```python
|
||||
def run(self, host_alias, workspace_cache_key, agent_id, agent_cmd):
|
||||
session = broker.plan(host_alias, workspace_cache_key, agent_id, agent_cmd)
|
||||
broker.attach_or_spawn(session) # ensures tmux session exists
|
||||
# Terminus docs: terminus_open accepts {"cmd": [...], "cwd": str}.
|
||||
self.window.focus_group(1)
|
||||
self.window.run_command("terminus_open", {
|
||||
"shell_cmd": " ".join(shlex.quote(a) for a in session.attach_argv),
|
||||
"cwd": None,
|
||||
"title": f"Agent · {agent_id} · {host_alias}",
|
||||
"pre_window_hooks": [["move_to_group", {"group": 1}]],
|
||||
})
|
||||
```
|
||||
|
||||
Handles the tmux-not-installed case: probe via `ssh host command -v
|
||||
tmux`; if missing, show `Sessions: Install Remote Extension` hint with
|
||||
a one-shot install (tmux goes in the extension catalog too, as
|
||||
`kind="agent"` prerequisite).
|
||||
|
||||
**[files]** `commands.py` (add class), `Sessions.sublime-commands`
|
||||
(palette entry).
|
||||
|
||||
### D4. Switcher view (group 2)
|
||||
|
||||
Group 2 holds a named view (`settings().get("sessions_agent_switcher")
|
||||
== True`). Renders a list like:
|
||||
|
||||
```
|
||||
○ 07c4844b · claude [attached]
|
||||
● a75c7f0f · codex (active)
|
||||
○ a75c7f0f · claude
|
||||
─────────────
|
||||
+ New agent session…
|
||||
```
|
||||
|
||||
Clicks resolved via `on_text_command drag_select` → if the cursor
|
||||
line maps to a pair row, fire `sessions_switch_agent_session
|
||||
{"pair_id": "<cache_key>:<agent_id>"}`.
|
||||
|
||||
Live updates: re-render on D5's pair-change callbacks.
|
||||
|
||||
**[files]** `agent_switcher_view.py` (new), integration hook in
|
||||
`commands.py`.
|
||||
|
||||
### D5. Pair persistence + switch orchestration
|
||||
|
||||
Workspace-level store in `workspace_state.py`:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AgentPair:
|
||||
workspace_cache_key: str
|
||||
agent_id: str
|
||||
created_at: float
|
||||
last_activated_at: float
|
||||
|
||||
def register_agent_pair(pair: AgentPair) -> None: ...
|
||||
def active_agent_pair(workspace_cache_key: str) -> Optional[AgentPair]: ...
|
||||
def list_agent_pairs() -> list[AgentPair]: ...
|
||||
```
|
||||
|
||||
New command `SessionsSwitchAgentSessionCommand`:
|
||||
|
||||
1. Find the target pair.
|
||||
2. If the workspace behind `pair.workspace_cache_key` is not the
|
||||
current active workspace, call existing workspace-switch machinery
|
||||
first (project data swap). File sidebar + editor re-targets follow.
|
||||
3. Attach Terminus in group 1 to the pair's tmux session (D3).
|
||||
4. Refresh switcher view (D4).
|
||||
|
||||
**[files]** `commands.py`, `workspace_state.py`, `agent_switcher_view.py`.
|
||||
|
||||
### D6. Lifecycle + teardown
|
||||
|
||||
- `plugin_unloaded`: detach (Terminus `terminus_keypress ctrl-b d`
|
||||
equivalent) — do **not** kill. tmux keeps the agent alive.
|
||||
- `Sessions: Kill Agent Session` command (palette) — explicit kill of
|
||||
the active pair's tmux session; user confirmation prompt.
|
||||
- `Sessions: Kill All Agent Sessions` — explicit sweep on the
|
||||
currently connected host.
|
||||
|
||||
**[files]** `commands.py`, `agent_tmux.py`.
|
||||
|
||||
### D7. Edit-proposal surfacing in the editor
|
||||
|
||||
**Goal**: when the agent proposes an edit (i.e. calls its edit / write /
|
||||
patch tool), show the proposed change as a diff in the Sublime editor,
|
||||
not only inside the Terminus pane. Apply-on-click is a nice-to-have but
|
||||
not required for the first cut; **just making the proposal visible in
|
||||
the editor surface is the MVP**.
|
||||
|
||||
Three phases, ordered by both effort and fidelity:
|
||||
|
||||
#### Phase 1 — pipe-pane scrollback tail (agent-agnostic, visibility-only)
|
||||
|
||||
Mirror the Terminus/tmux pane to a local file via `tmux pipe-pane`, tail
|
||||
it with a Python watcher, and parse out unified-diff blocks. Render them
|
||||
in a dedicated output panel `Sessions: Agent Proposals` with the file
|
||||
path + hunk text. Clicking a path opens the relevant file (via the
|
||||
existing on-demand fetch listener).
|
||||
|
||||
- Works for any agent that prints a unified diff to the terminal
|
||||
(claude, codex, aider, etc.).
|
||||
- **No apply** — the agent still drives its own confirmation step in
|
||||
the terminal. Our panel is purely informational.
|
||||
- **Brittle**: ANSI colour codes, pager truncation, non-standard diff
|
||||
formats can corrupt the parse. We handle the common case and drop
|
||||
silently on weird input.
|
||||
- **[files]** `agent_proposal_watcher.py` (new) — tail + parse + emit to
|
||||
an output panel; `commands.py` — palette entries for `Sessions: Open
|
||||
Agent Proposals`, `Sessions: Clear Agent Proposals`.
|
||||
- **[testability]** `_parse_unified_diff_stream` is pure string→list;
|
||||
unit-testable with fixture blobs. Tail loop mocked with an in-memory
|
||||
file-like.
|
||||
|
||||
#### Phase 2 — post-apply phantom badge (agent-agnostic, already-applied)
|
||||
|
||||
When the agent writes a file on the remote and our existing `file/watch`
|
||||
fires a change event for an already-open local cache file:
|
||||
|
||||
- Snapshot the buffer before re-fetching.
|
||||
- Compute a line-level diff between the snapshot and the new content.
|
||||
- Decorate the modified hunks with a Sublime phantom / region of the
|
||||
form `🤖 claude · <time>` in a distinct colour scope
|
||||
(`region.bluish markup.agent.changed`).
|
||||
- Fades after 30 seconds or on next edit.
|
||||
|
||||
No user action required; purely a visual cue that "the file just
|
||||
under your cursor changed because of the agent, not you". Works for
|
||||
every agent that writes files on the remote, regardless of how the
|
||||
user approved the change.
|
||||
|
||||
- **[files]** `agent_change_badge.py` (new), hooked into the existing
|
||||
`file/watch` handling in `ssh_file_transport`/`commands`.
|
||||
- **Accepts**: that by the time we render the badge the change is
|
||||
already applied. This is the easiest-to-ship "editor sees the diff"
|
||||
path — the user sees what changed, still in the normal file flow.
|
||||
|
||||
#### Phase 3 — pre-apply preview via Claude Code hooks (claude-specific)
|
||||
|
||||
Claude Code ships first-class support for `PreToolUse` and `PostToolUse`
|
||||
hooks (configured in `.claude/settings.json` on the remote). We install
|
||||
a small shell hook that:
|
||||
|
||||
- On `PreToolUse` for `edit_file` / `write_file` / `str_replace`: write
|
||||
the tool-call JSON to a local Unix socket (forwarded via `ssh -L`
|
||||
control-master) and **wait** for an `approve` / `reject` reply from
|
||||
Sublime before letting the hook return.
|
||||
- Sublime receives the JSON, renders a rich diff preview in the
|
||||
relevant editor view (using Sublime's built-in `diff` syntax or a
|
||||
phantom overlay), and shows floating `Apply` / `Reject` buttons.
|
||||
- User's click sends `approve` / `reject` back through the socket; the
|
||||
hook returns; claude proceeds or aborts the tool call.
|
||||
|
||||
This is the most ambitious variant: editor-native preview, user clicks
|
||||
in Sublime (not in the terminal), claude respects the decision. It is
|
||||
**only claude-specific** — codex / aider / others do not expose
|
||||
equivalent hooks at the time of writing.
|
||||
|
||||
- **[files]** `agent_claude_hook.sh` (shipped hook script, `bash -lc`
|
||||
compatible), `claude_hook_server.py` (new: Unix-socket server inside
|
||||
the Sublime plugin process), `agent_proposal_preview.py` (new:
|
||||
phantom/diff rendering).
|
||||
- **[risks]** hook timeout: if Sublime isn't running or the socket isn't
|
||||
listening, claude waits indefinitely. The hook must have a 10 s
|
||||
default-deny fallback.
|
||||
- **[installer]** add the hook script to the managed-extension catalog
|
||||
under `kind="agent"` alongside the claude CLI install. Sessions drops
|
||||
`.claude/settings.json` on first use if missing.
|
||||
|
||||
**Phase adoption plan**:
|
||||
|
||||
- Phase 1 ships with v0.6.0 alongside the tmux layout (it's
|
||||
agent-agnostic and cheap).
|
||||
- Phase 2 ships in a follow-up (v0.6.1) — needs thoughtful diff
|
||||
colouring that doesn't clash with Sublime's save-status markers.
|
||||
- Phase 3 is gated on demand (v0.7.0 candidate). Users who want
|
||||
apply-from-editor get it; others stay on Phase 1/2.
|
||||
|
||||
## Parallel work plan
|
||||
|
||||
Two agents + one integrator:
|
||||
|
||||
### Agent α (pure-Python, no Sublime)
|
||||
|
||||
Owns D1 (broker) and the tmux / SSH CLI details. Output: tested
|
||||
`agent_tmux.py` + comprehensive unit tests.
|
||||
|
||||
### Agent β (Sublime-facing UI)
|
||||
|
||||
Owns D2 (layout) and D4 (switcher view skeleton with fake pair data).
|
||||
Output: clickable layout + list, no integration yet.
|
||||
|
||||
### Integrator (manual)
|
||||
|
||||
Lands D3 + D5 + D6 + extension catalog entries on top of α+β, wires
|
||||
everything, runs full pytest + manual macOS smoke test.
|
||||
|
||||
Total scope ≈ 600–900 Python LoC + 400 test LoC. No Rust changes. No
|
||||
protocol changes. Release as **v0.6.0** (minor bump — new user-visible
|
||||
feature).
|
||||
|
||||
## Out of scope (do not do here)
|
||||
|
||||
- Agent-specific parsing of output beyond unified diffs (markdown
|
||||
rendering, thinking blocks, etc.). Terminus renders the raw agent
|
||||
UI verbatim. If users want richer output, the agent CLI should
|
||||
provide it. Diff surfacing is the one exception — see D7.
|
||||
- In-Sublime chat widgets / side panels. Explicitly dropped.
|
||||
- Replacing the agent's own in-terminal confirmation flow except via
|
||||
the claude-hook path (D7 Phase 3).
|
||||
- Any change to the local_bridge / session_helper protocol.
|
||||
@@ -73,16 +73,21 @@ investment in Terminus-side polish is out of scope.
|
||||
|
||||
---
|
||||
|
||||
## ~~Track D — Agent integration via tmux~~ — **[dropped 2026-04-27]**
|
||||
## ~~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 work that already shipped (`agent_tmux`,
|
||||
`agent_window_layout`, `agent_switcher_view`, workspace/agent pair
|
||||
registry, three palette commands) stays in the codebase but is
|
||||
considered frozen; D1–D7 sub-tracks no longer have follow-up work.
|
||||
`AGENT_TMUX_LAYOUT.md` retained for historical reference only.
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -201,6 +206,14 @@ 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
|
||||
@@ -238,6 +251,133 @@ Final integration agent wires Sublime Merge launch + the manual
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -357,7 +497,7 @@ continuous `bridge.request_timeout` on `mirror-sync` (45s),
|
||||
`file/watch` (35s), `file/read` (30s). Subsequent "Sessions
|
||||
disconnected" → reconnect loop.
|
||||
|
||||
**Diagnosed** via debug-trace capture (see V0_6_5_REPRO §B1): the
|
||||
**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
|
||||
|
||||
@@ -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개 섹션).
|
||||
@@ -172,7 +172,8 @@ not by design or code:
|
||||
- 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
|
||||
`planning/V0_6_5_REPRO.md` (or successor).
|
||||
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.
|
||||
@@ -517,19 +518,18 @@ Python.
|
||||
envelope, invokes the broker, returns the typed result. `_rust_ffi`
|
||||
hosts ~7 functions, not 30+.
|
||||
|
||||
### 3.5 Stage 4 — agent / diff / runtime state ownership `[plan]`
|
||||
### 3.5 Stage 4 — agent / diff / runtime state ownership `[obsolete — Track D dropped 2026-04-27]`
|
||||
|
||||
**Move out of Python:**
|
||||
- `agent_proposal_watcher` diff parser (already pure Python, no
|
||||
Sublime — explicitly tagged as a Rust candidate).
|
||||
- Agent pair registry in `workspace_state.py` (module-global
|
||||
mutable).
|
||||
- `agent_tmux::AgentTmuxBroker` orchestration.
|
||||
- Deferred-dir registry in `workspace_state.py`.
|
||||
|
||||
**Python keeps:** layout (`agent_window_layout.py`), switcher view
|
||||
(`agent_switcher_view.py`), output panel rendering, palette wiring
|
||||
for agent commands.
|
||||
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]`
|
||||
|
||||
@@ -555,23 +555,19 @@ after each stage.
|
||||
|
||||
Concrete order, lowest risk first:
|
||||
|
||||
1. **Pure-Python no-Sublime modules first** (review-recommended):
|
||||
`agent_proposal_watcher` diff parser is the explicit example —
|
||||
no `sublime` import, easy port surface. Use it as the warm-up
|
||||
for the migration tooling.
|
||||
2. **Stage 1 (broker ownership)** — biggest effect, central
|
||||
1. **Stage 1 (broker ownership)** — biggest effect, central
|
||||
choke point. Land before stages 2/3 because they depend on the
|
||||
broker for cancellation + lifecycle.
|
||||
3. **Stage 2 (materialization)** — paired with §2.2 large-file
|
||||
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.
|
||||
4. **Stage 3 (envelope ownership)** — naturally falls out of
|
||||
3. **Stage 3 (envelope ownership)** — naturally falls out of
|
||||
stages 1+2; remaining method-builder code in Python is
|
||||
replaced by `runtime_*` calls.
|
||||
5. **Stage 4 (agent / diff / state)** — paired with §2.1
|
||||
diff-centric review work; agent state moves with the new
|
||||
review module.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -579,7 +575,7 @@ Concrete order, lowest risk first:
|
||||
|
||||
Per review: "지금은 덜 급한 것":
|
||||
|
||||
- More agent types (catalog already covers Claude Code + Codex CLI).
|
||||
- ~~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).
|
||||
@@ -621,6 +617,21 @@ Test-health gate stays green after the deletion: adversarial 190
|
||||
(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
|
||||
|
||||
@@ -9,6 +9,12 @@ 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) |
|
||||
|
||||
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.
|
||||
@@ -1,192 +0,0 @@
|
||||
# V0_6_5_REPRO — focused repro for current macOS test pass issues
|
||||
|
||||
Short, narrow checklist. **Not** a full feature test (`TEST_CHECKLIST.md`
|
||||
is for that). Goal here: confirm the v0.6.5 batch-3 fixes work on the
|
||||
real macOS host that hit them, and capture diagnostic data for the
|
||||
remaining unresolved issues so the next debug round has signal to work
|
||||
with.
|
||||
|
||||
Run the steps **in order**. Paste the requested log fragments / observed
|
||||
behavior under each step. If a step fails unexpectedly, stop, capture
|
||||
the bundle from §10 of the full TEST_CHECKLIST, and ping back here.
|
||||
|
||||
## 0. Setup
|
||||
|
||||
```sh
|
||||
cd <Sessions checkout>
|
||||
git fetch origin && git checkout v0.6.5 # or main once v0.6.5 lands
|
||||
cargo build --manifest-path rust/Cargo.toml --release --workspace
|
||||
```
|
||||
|
||||
In `Packages/User/Sessions.sublime-settings`, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions_debug_trace_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
(Set `SESSIONS_BRIDGE_DIAG_VERBOSE=1` in the Sublime launch env only if
|
||||
asked below — it's noisy.)
|
||||
|
||||
Restart Sublime, reopen the test workspace.
|
||||
|
||||
---
|
||||
|
||||
## A. Verify just-fixed items (should now PASS)
|
||||
|
||||
### A1. Agent tmux spawn — no `not a terminal`
|
||||
|
||||
Palette → `Sessions: New Agent Session` → pick `Claude Code CLI (remote)`.
|
||||
|
||||
- [ ] **No** "Sessions warning: Agent session start failed ... open
|
||||
terminal failed: not a terminal". Terminus pane opens; tmux
|
||||
session `sessions-agent-<ws>-claude-code` runs.
|
||||
- [ ] On the remote: `tmux list-sessions | grep sessions-agent` shows it.
|
||||
|
||||
If this still errors, paste the full warning string + grep
|
||||
`bridge.rust.helper_stdout_message` lines around the failure timestamp
|
||||
from `<Sublime cache>/Sessions/logs/debug-trace.log`.
|
||||
|
||||
### A2. New Remote Terminal Pane / Kill Remote Terminal in palette
|
||||
|
||||
- [ ] Palette → type "Sessions: New Remote Terminal Pane" — entry now
|
||||
appears. Select it; numbered tmux session
|
||||
(`sessions-term-<host>-2`) opens.
|
||||
- [ ] Palette → "Sessions: Kill Remote Terminal" — entry now appears.
|
||||
Select it; quick panel lists live terminals; pick one to kill.
|
||||
|
||||
### A3. localhost:PORT canonical URL
|
||||
|
||||
In any Terminus pane: `python3 -m http.server 8080`.
|
||||
|
||||
- [ ] Hover the `0.0.0.0:8080` line — underlined.
|
||||
- [ ] Cmd+click → browser opens **`http://localhost:8080/`** (canonical
|
||||
form with `localhost` host + trailing slash). Should NOT be
|
||||
`about:blank-` or `about:blank` anymore.
|
||||
- [ ] Repeat with `127.0.0.1:8080` line — opens
|
||||
`http://127.0.0.1:8080/`.
|
||||
|
||||
### A4. `Sessions: Preview Remote Agent Payload` hidden
|
||||
|
||||
- [ ] Palette → type "Sessions: Preview" — should **not** show
|
||||
"Preview Remote Agent Payload" by default.
|
||||
- [ ] In `Packages/User/Sessions.sublime-settings`, add
|
||||
`"sessions_show_dev_commands": true`. Reload settings (or
|
||||
restart). Re-type — entry now appears.
|
||||
- [ ] Revert the setting back to `false` (or remove the line).
|
||||
|
||||
---
|
||||
|
||||
## B. Still-broken — capture diagnostic data
|
||||
|
||||
### B1. mirror-sync deep traversal hang at `awaiting_response_dispatch` — **[diagnosed + fixed v0.7.5]**
|
||||
|
||||
Symptom from previous capture: every ~60s a deep
|
||||
`mirror-sync force_full_sync=true max_traversal_depth=12` request hangs
|
||||
at `bridge.request_timeout` after 45s with
|
||||
`stall_phase=awaiting_response_dispatch`. Shallow sync (depth 2) returns
|
||||
in <300ms. Workspace effectively never finishes hydrating, so
|
||||
"No deferred directory to expand" fires (deferred state never recorded)
|
||||
and `sync.done` never lands (eager-hydrate retry never fires).
|
||||
|
||||
**Root cause confirmed (2026-04-27 capture against `aws-celery`)**:
|
||||
helper is alive and streaming responses continuously throughout the
|
||||
45 s window (verbose `bridge.rust.helper_stdout_message` events every
|
||||
~200 ms; line_bytes 200-120 KB; no `helper_stdout_eof`). Adjacent
|
||||
`file/stat` / `file/watch` requests complete normally during the same
|
||||
window (bridge plumbing healthy). The cycle immediately before the
|
||||
captured timeout — `mirror-sync 300` — completed at `elapsed_ms=44952`,
|
||||
literally 48 ms inside the 45 s budget. The next cycle (`mirror-sync
|
||||
308`) didn't make it. So the hang is **not** OOM, **not** a dispatcher
|
||||
stall — the deep walk legitimately runs ~45-50 s on this remote, just
|
||||
exceeding the timeout. `stall_phase=awaiting_response_dispatch` is only
|
||||
the Python-side label for `RequestOutcomeKind.TIMEOUT`, not an actual
|
||||
"stall" state. Both prior gut hypotheses (helper death, channel buffer
|
||||
overflow) ruled out by the trace.
|
||||
|
||||
**Fix shipped in v0.7.5** (see BACKLOG M5 for the full release notes):
|
||||
- `sessions_mirror_max_traversal_depth` default 12 → 5 so auto-deepen
|
||||
stays well under budget on slow tunnels.
|
||||
- Mirror-sync timeout split from the generic 45 s into a separate 90 s
|
||||
default, configurable via `sessions_mirror_sync_timeout_s`.
|
||||
- Auto-refresh exponential backoff (1×, 2×, 4×, 8×, 16× capped) on
|
||||
consecutive failures + reset on first success — stops the every-
|
||||
minute pile-on while a helper is still working.
|
||||
|
||||
This entry is left as the diagnosis record; the original capture
|
||||
recipe below stays useful for any future timeout-shaped repro.
|
||||
|
||||
Capture:
|
||||
|
||||
1. Set `SESSIONS_BRIDGE_DIAG_VERBOSE=1` in the Sublime launch env (so
|
||||
`bridge.rust.helper_stdout_message` lines also land in the trace).
|
||||
2. Connect to the host that reproduces this (the aws-celery host).
|
||||
3. Wait 5 minutes — long enough for at least 2 timeout cycles.
|
||||
4. From `<Sublime cache>/Sessions/logs/debug-trace.log`, paste the
|
||||
block of lines between two consecutive `mirror_queue.enqueue
|
||||
task=work` events that wrap a `bridge.request_timeout`. Should
|
||||
include all `bridge.rust.*` lines in between.
|
||||
|
||||
What to look for in the paste:
|
||||
|
||||
- Any `bridge.rust.helper_stdout_eof` — helper closed stdout before
|
||||
responding (suggests session_helper died on the remote, possibly
|
||||
out-of-memory on the deep walk).
|
||||
- Any `bridge.rust.helper_stdout_message` with abnormally long line
|
||||
payloads — large response chunks that may exceed channel buffer
|
||||
and stall the dispatcher.
|
||||
- The final `bridge.request_done` (if any) for the `mirror-sync` id
|
||||
before the timeout fires.
|
||||
|
||||
Also: on the remote, while a deep sync is in flight, run
|
||||
`ps -ef | grep session_helper` and paste output. We want to see if
|
||||
the helper is actually busy (CPU > 0) or idle (already responded but
|
||||
the response is stuck somewhere local).
|
||||
|
||||
### B2. Hover absolute remote path → does not open in Sublime
|
||||
|
||||
`ls -la /etc` (or any deep path) in a Terminus pane.
|
||||
|
||||
- [ ] Hover an absolute path line. Underline appears? **yes / no**
|
||||
- [ ] Cmd+click. Sublime opens the file? **yes / no — what happens
|
||||
instead** (about:blank? nothing at all? error in console?)
|
||||
|
||||
If nothing opens: paste any line from `debug-trace.log` matching
|
||||
`terminal_link.click` or `bridge.request` that lands within ~3 seconds
|
||||
of the click.
|
||||
|
||||
### B3. `Sessions: Open Remote Jupyter` — silent
|
||||
|
||||
Palette → "Sessions: Open Remote Jupyter".
|
||||
|
||||
- [ ] Browser tab opens? **yes / no**.
|
||||
- [ ] `Sessions: Stop Remote Jupyter` available afterward?
|
||||
- [ ] Paste lines from `debug-trace.log` matching `jupyter` and
|
||||
`queue.dequeue task=jupyter_open`. The previous capture showed
|
||||
`queue.done elapsed_ms=27748` but no visible browser tab —
|
||||
need to know whether the launch URL is being constructed at
|
||||
all, or constructed but not opened.
|
||||
|
||||
### B4. `Sessions: New Agent Session` quick panel
|
||||
|
||||
If A1 succeeds, this is likely also fine. If A1 still errors, A1 is
|
||||
the upstream cause of "역시 아무 것도 뜨지 않음".
|
||||
|
||||
Confirm: after A1 succeeds, can you run `Sessions: New Agent Session`
|
||||
again and pick `OpenAI Codex CLI (remote)` to start a second pair?
|
||||
|
||||
---
|
||||
|
||||
## What to send back
|
||||
|
||||
For each step under §A, mark **PASS / FAIL** + behavior summary.
|
||||
|
||||
For each step under §B, paste:
|
||||
- the requested log fragments (B1, B3 especially)
|
||||
- a short observation note ("nothing opens", "browser opens to wrong
|
||||
URL X", etc.)
|
||||
|
||||
Optional: attach the full debug-trace.log slice from the start of the
|
||||
session to the end of the test pass — useful for cross-correlation
|
||||
when individual steps look fine but downstream behavior breaks.
|
||||
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,6 +1,6 @@
|
||||
[project]
|
||||
name = "sessions-sublime"
|
||||
version = "0.7.21"
|
||||
version = "0.7.36"
|
||||
description = "Sublime-facing Python code for Sessions."
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "MIT"}
|
||||
|
||||
15
rust/Cargo.lock
generated
15
rust/Cargo.lock
generated
@@ -221,7 +221,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "local_bridge"
|
||||
version = "0.7.21"
|
||||
version = "0.7.36"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"glob",
|
||||
@@ -432,7 +432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_helper"
|
||||
version = "0.7.21"
|
||||
version = "0.7.36"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"notify",
|
||||
@@ -443,7 +443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_protocol"
|
||||
version = "0.7.21"
|
||||
version = "0.7.36"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde",
|
||||
@@ -452,17 +452,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sessions_askpass"
|
||||
version = "0.7.21"
|
||||
version = "0.7.36"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sessions_native"
|
||||
version = "0.7.21"
|
||||
version = "0.7.36"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"notify",
|
||||
"serde_json",
|
||||
"session_protocol",
|
||||
"tempfile",
|
||||
"workspace_identity",
|
||||
]
|
||||
|
||||
@@ -770,7 +773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "workspace_identity"
|
||||
version = "0.7.21"
|
||||
version = "0.7.36"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
version = "0.7.21"
|
||||
version = "0.7.36"
|
||||
authors = ["Myeongseon Choi <key262yek@gmail.com>"]
|
||||
repository = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
homepage = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
//!
|
||||
//! ``main`` only handles version-banner short-circuiting and the top-level
|
||||
//! mode switch (``lsp-stdio`` subcommand vs. forwarder); ``run`` then dispatches
|
||||
//! between ``parse-agent-editor-envelope``, persistent mode, and one-shot
|
||||
//! request mode.
|
||||
//! between persistent mode and one-shot request mode.
|
||||
|
||||
mod cli;
|
||||
mod lsp_stdio;
|
||||
@@ -23,7 +22,6 @@ use crate::cli::BridgeCliArgs;
|
||||
use crate::lsp_stdio::run_lsp_stdio;
|
||||
use crate::persistent::run_persistent;
|
||||
use local_bridge::{BridgeCliOutput, BridgeRunError, run_request_over_ssh};
|
||||
use serde_json::json;
|
||||
use session_protocol::RequestEnvelope;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -79,9 +77,6 @@ fn main() {
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<BridgeCliOutput, BridgeRunError> {
|
||||
if args.first().map(String::as_str) == Some("parse-agent-editor-envelope") {
|
||||
return run_parse_agent_editor_envelope();
|
||||
}
|
||||
if args.iter().any(|arg| arg == "--persistent") {
|
||||
run_persistent(args)?;
|
||||
return Ok(BridgeCliOutput {
|
||||
@@ -137,27 +132,6 @@ pub(crate) fn write_bridge_output(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_parse_agent_editor_envelope() -> Result<BridgeCliOutput, BridgeRunError> {
|
||||
let mut buffer = String::new();
|
||||
std::io::stdin().read_to_string(&mut buffer)?;
|
||||
let (payload, error) =
|
||||
local_bridge::agent_remote_payload::parse_agent_editor_envelope_from_stdout(&buffer);
|
||||
let payload_json = payload
|
||||
.as_ref()
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
.map_err(BridgeRunError::Json)?;
|
||||
Ok(BridgeCliOutput {
|
||||
ok: true,
|
||||
id: None,
|
||||
result: Some(json!({
|
||||
"agent_editor_payload": payload_json,
|
||||
"agent_editor_error": error,
|
||||
})),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_stdin() -> Result<String, BridgeRunError> {
|
||||
let mut buffer = String::new();
|
||||
std::io::stdin().read_to_string(&mut buffer)?;
|
||||
|
||||
@@ -469,6 +469,24 @@ where
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -247,3 +247,86 @@ fn default_options_apply_hardened_caps() {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -135,6 +135,51 @@ enum InternalEvent {
|
||||
WorkerReply(ProtocolMessage),
|
||||
}
|
||||
|
||||
/// In-flight task table — shared between the dispatcher and worker threads
|
||||
/// so a `Cancel` envelope can flip the flag for the matching request id.
|
||||
///
|
||||
/// Wave 2 PR 13b.1 lands the *skeleton* only: workers register their flag
|
||||
/// when they start and de-register when they finish; the cancel branch sets
|
||||
/// the flag and acknowledges. Actual handler-side cancellation polling and
|
||||
/// per-handler abort lands in PR 13b.2; deadline propagation in PR 13b.3.
|
||||
type CancelFlagMap =
|
||||
std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, CancelFlag>>>;
|
||||
|
||||
/// Per-request cancellation flag. Cloned into the worker thread so the
|
||||
/// dispatcher can flip it without holding the map lock.
|
||||
type CancelFlag = std::sync::Arc<std::sync::atomic::AtomicBool>;
|
||||
|
||||
fn new_cancel_flag_map() -> CancelFlagMap {
|
||||
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
/// Request priority class (Wave 2 PR 13b.4).
|
||||
///
|
||||
/// `Interactive` requests (file/read, file/stat, file/write, exec/once) keep
|
||||
/// the existing thread-spawn-per-request model — they are short and the user
|
||||
/// is waiting on each one.
|
||||
///
|
||||
/// `Mirror` requests (tree/list, file/watch) are *serialised* via a shared
|
||||
/// `Mutex` so a slow recursive directory walk cannot fan out and starve the
|
||||
/// `Interactive` lane. This is the simplest back-pressure model that still
|
||||
/// matches the boundary doc's "channel supervisor" intent: a single mirror
|
||||
/// pass at a time, interactive requests run alongside without queueing.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RequestPriority {
|
||||
Interactive,
|
||||
Mirror,
|
||||
}
|
||||
|
||||
fn priority_of(method: &str) -> RequestPriority {
|
||||
match method {
|
||||
// tree/list, file/watch are mirror-shaped (long-running BFS / inotify).
|
||||
session_protocol::METHOD_TREE_LIST | session_protocol::METHOD_FILE_WATCH => {
|
||||
RequestPriority::Mirror
|
||||
}
|
||||
_ => RequestPriority::Interactive,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_stdio_session_with_io(
|
||||
args: &HelperStartupArgs,
|
||||
input: &mut (impl BufRead + Send),
|
||||
@@ -150,6 +195,12 @@ fn run_stdio_session_with_io(
|
||||
|
||||
// Stdin reader runs in a scoped thread so it can borrow `input`.
|
||||
let in_flight = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let cancel_flags = new_cancel_flag_map();
|
||||
// PR 13b.4: serialise mirror-priority requests so a slow tree/list
|
||||
// cannot starve interactive (file/read, file/stat) requests. Held
|
||||
// for the full duration of a single mirror handler.
|
||||
let mirror_serial: std::sync::Arc<std::sync::Mutex<()>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(()));
|
||||
let ev_tx_workers = ev_tx.clone();
|
||||
|
||||
thread::scope(|scope| -> Result<(), HelperRuntimeError> {
|
||||
@@ -197,23 +248,81 @@ fn run_stdio_session_with_io(
|
||||
match ev {
|
||||
InternalEvent::Incoming(ProtocolMessage::Request(request)) => {
|
||||
in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
// Register a cancel flag for this request id so a future
|
||||
// ``Cancel`` envelope can flip it. PR 13b.1 ships the
|
||||
// registration only; PR 13b.2 wires per-handler polling.
|
||||
let flag: CancelFlag =
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let request_id = request.id.clone();
|
||||
if let Ok(mut guard) = cancel_flags.lock() {
|
||||
guard.insert(request_id.clone(), std::sync::Arc::clone(&flag));
|
||||
}
|
||||
let tx = ev_tx_workers.clone();
|
||||
let flags_for_cleanup = std::sync::Arc::clone(&cancel_flags);
|
||||
let priority = priority_of(&request.method);
|
||||
let mirror_lock_for_worker = if priority == RequestPriority::Mirror {
|
||||
Some(std::sync::Arc::clone(&mirror_serial))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
thread::spawn(move || {
|
||||
let reply = match handle_request(request) {
|
||||
// PR 13b.4: mirror-priority workers acquire the
|
||||
// shared serialisation lock first. The handler runs
|
||||
// *inside* the locked region so a long tree/list
|
||||
// walk holds the lock for its full duration —
|
||||
// simple, predictable back-pressure with no
|
||||
// priority-inversion footguns. Interactive workers
|
||||
// skip the lock entirely.
|
||||
let _mirror_guard = mirror_lock_for_worker
|
||||
.as_ref()
|
||||
.map(|m| m.lock().unwrap_or_else(|p| p.into_inner()));
|
||||
// PR 13b.2: pass the registered cancel flag through to
|
||||
// ``handle_request_cancellable`` so handlers with a
|
||||
// polling point (exec/once, file/read) can abort when
|
||||
// the dispatcher flips the flag.
|
||||
let reply = match handle_request_cancellable(request, Some(&flag)) {
|
||||
Ok(resp) => ProtocolMessage::Response(resp),
|
||||
Err(err) => ProtocolMessage::Error(err),
|
||||
};
|
||||
if let Ok(mut guard) = flags_for_cleanup.lock() {
|
||||
guard.remove(&request_id);
|
||||
}
|
||||
let _ = tx.send(InternalEvent::WorkerReply(reply));
|
||||
});
|
||||
}
|
||||
InternalEvent::Incoming(ProtocolMessage::Cancel(cancel)) => {
|
||||
// Flip the registered flag (best-effort — handlers don't
|
||||
// poll yet; PR 13b.2 wires that). The acknowledgement
|
||||
// envelope tells the bridge that the cancel request was
|
||||
// accepted; the response (success or error) for the
|
||||
// original request still arrives separately when the
|
||||
// worker finishes.
|
||||
let was_inflight = match cancel_flags.lock() {
|
||||
Ok(guard) => {
|
||||
if let Some(flag) = guard.get(&cancel.request_id) {
|
||||
flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
write_message(
|
||||
output,
|
||||
&ProtocolMessage::Error(ErrorEnvelope {
|
||||
id: Some(cancel.request_id),
|
||||
code: "cancel_not_supported".to_string(),
|
||||
message: "Cancellation is not yet implemented by session_helper."
|
||||
.to_string(),
|
||||
code: if was_inflight {
|
||||
"cancel_acknowledged".to_string()
|
||||
} else {
|
||||
"cancel_no_match".to_string()
|
||||
},
|
||||
message: if was_inflight {
|
||||
"cancel flag set — best-effort, handler-side polling lands in PR 13b.2"
|
||||
.to_string()
|
||||
} else {
|
||||
"no in-flight request matches the supplied id".to_string()
|
||||
},
|
||||
retryable: false,
|
||||
}),
|
||||
)?;
|
||||
@@ -269,7 +378,31 @@ fn write_message(
|
||||
}
|
||||
|
||||
/// Handles one request envelope and returns either a success response or error.
|
||||
///
|
||||
/// Backward-compatible no-cancel entrypoint — Wave 2 PR 13b.2/.3 callers should
|
||||
/// prefer [`handle_request_cancellable`] so the dispatcher can flip the
|
||||
/// `cancel_flag` for in-flight handlers and propagate the per-request
|
||||
/// `timeout_ms` deadline through to chunked-read handlers.
|
||||
pub fn handle_request(request: RequestEnvelope) -> Result<ResponseEnvelope, ErrorEnvelope> {
|
||||
handle_request_cancellable(request, None)
|
||||
}
|
||||
|
||||
/// PR 13b.2/.3: cancel-flag and deadline-aware variant of [`handle_request`].
|
||||
///
|
||||
/// `cancel_flag` is consulted by handlers that have a polling point —
|
||||
/// `exec/once` checks it on every 10 ms wait inside its child-watcher
|
||||
/// loop, `file/read` checks it between 64 KiB chunks. Deadline is derived
|
||||
/// from `request.timeout_ms` and applied uniformly so a slow-disk read or
|
||||
/// runaway exec terminates with the same envelope.
|
||||
pub fn handle_request_cancellable(
|
||||
request: RequestEnvelope,
|
||||
cancel_flag: Option<&std::sync::atomic::AtomicBool>,
|
||||
) -> Result<ResponseEnvelope, ErrorEnvelope> {
|
||||
let deadline = if request.timeout_ms > 0 {
|
||||
Some(Instant::now() + Duration::from_millis(request.timeout_ms))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result = match request.method.as_str() {
|
||||
METHOD_CHANNEL_DISPATCH => {
|
||||
let params: ChannelDispatchParams = serde_json::from_value(request.params.clone())
|
||||
@@ -287,9 +420,9 @@ pub fn handle_request(request: RequestEnvelope) -> Result<ResponseEnvelope, Erro
|
||||
METHOD_FILE_READ => {
|
||||
let params: FileReadParams = serde_json::from_value(request.params.clone())
|
||||
.map_err(|error| invalid_params_error(Some(request.id.clone()), error))?;
|
||||
serde_json::to_value(handle_file_read(¶ms).map_err(|error| {
|
||||
error_envelope(Some(request.id.clone()), error.code, error.message)
|
||||
})?)
|
||||
serde_json::to_value(handle_file_read(¶ms, cancel_flag, deadline).map_err(
|
||||
|error| error_envelope(Some(request.id.clone()), error.code, error.message),
|
||||
)?)
|
||||
.map_err(|error| internal_error(Some(request.id.clone()), error))?
|
||||
}
|
||||
METHOD_FILE_STAT => {
|
||||
@@ -319,7 +452,7 @@ pub fn handle_request(request: RequestEnvelope) -> Result<ResponseEnvelope, Erro
|
||||
METHOD_EXEC_ONCE => {
|
||||
let params: ExecOnceParams = serde_json::from_value(request.params.clone())
|
||||
.map_err(|error| invalid_params_error(Some(request.id.clone()), error))?;
|
||||
serde_json::to_value(handle_exec_once(¶ms).map_err(|error| {
|
||||
serde_json::to_value(handle_exec_once(¶ms, cancel_flag).map_err(|error| {
|
||||
error_envelope(Some(request.id.clone()), error.code, error.message)
|
||||
})?)
|
||||
.map_err(|error| internal_error(Some(request.id.clone()), error))?
|
||||
@@ -456,7 +589,11 @@ fn handle_tree_list(params: &TreeListParams) -> Result<TreeListResult, HelperFsE
|
||||
Ok(TreeListResult { entries })
|
||||
}
|
||||
|
||||
fn handle_file_read(params: &FileReadParams) -> Result<FileReadResult, HelperFsError> {
|
||||
fn handle_file_read(
|
||||
params: &FileReadParams,
|
||||
cancel_flag: Option<&std::sync::atomic::AtomicBool>,
|
||||
deadline: Option<Instant>,
|
||||
) -> Result<FileReadResult, HelperFsError> {
|
||||
let path = absolute_path(¶ms.remote_absolute_path)?;
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||||
HelperFsError::new("file_read_failed", format!("Unable to stat path: {error}"))
|
||||
@@ -477,10 +614,46 @@ fn handle_file_read(params: &FileReadParams) -> Result<FileReadResult, HelperFsE
|
||||
let mut file = File::open(path).map_err(|error| {
|
||||
HelperFsError::new("file_read_failed", format!("Unable to open file: {error}"))
|
||||
})?;
|
||||
let mut body = Vec::new();
|
||||
file.read_to_end(&mut body).map_err(|error| {
|
||||
HelperFsError::new("file_read_failed", format!("Unable to read file: {error}"))
|
||||
})?;
|
||||
// PR 13b.3: chunked read so cancel_flag and deadline can be polled
|
||||
// between chunks. 64 KiB matches the existing exec_once read buffer
|
||||
// and is well below the 16 MiB MAX_READ_BYTES cap so even worst-case
|
||||
// file sizes get ~256 polling points per request.
|
||||
const CHUNK: usize = 64 * 1024;
|
||||
let cap = usize::try_from(mapped.size_bytes).unwrap_or(usize::MAX);
|
||||
let mut body: Vec<u8> = Vec::with_capacity(cap.min(CHUNK * 16));
|
||||
let mut buf = [0u8; CHUNK];
|
||||
loop {
|
||||
if cancel_flag
|
||||
.map(|f| f.load(std::sync::atomic::Ordering::Relaxed))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(HelperFsError::new(
|
||||
"cancelled",
|
||||
"Cancelled by bridge.".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(d) = deadline
|
||||
&& Instant::now() >= d
|
||||
{
|
||||
return Err(HelperFsError::new(
|
||||
"file_read_timeout",
|
||||
format!("Read exceeded request deadline ({} bytes read)", body.len()),
|
||||
));
|
||||
}
|
||||
let n = file.read(&mut buf).map_err(|error| {
|
||||
HelperFsError::new("file_read_failed", format!("Unable to read file: {error}"))
|
||||
})?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&buf[..n]);
|
||||
if body.len() as u64 > MAX_READ_BYTES {
|
||||
return Err(HelperFsError::new(
|
||||
"file_too_large",
|
||||
"Remote file grew beyond MAX_READ_BYTES during read.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(FileReadResult {
|
||||
metadata: RemoteFileMetadata {
|
||||
size_bytes: body.len() as u64,
|
||||
@@ -842,7 +1015,10 @@ fn handle_file_write(params: &FileWriteParams) -> Result<FileWriteResult, Helper
|
||||
const EXEC_STDOUT_MAX: usize = 4 * 1024 * 1024;
|
||||
const EXEC_STDERR_MAX: usize = 4 * 1024 * 1024;
|
||||
|
||||
fn handle_exec_once(params: &ExecOnceParams) -> Result<ExecOnceResult, HelperFsError> {
|
||||
fn handle_exec_once(
|
||||
params: &ExecOnceParams,
|
||||
cancel_flag: Option<&std::sync::atomic::AtomicBool>,
|
||||
) -> Result<ExecOnceResult, HelperFsError> {
|
||||
if params.argv.is_empty() {
|
||||
return Err(HelperFsError::new(
|
||||
"exec_invalid_argv",
|
||||
@@ -902,10 +1078,24 @@ fn handle_exec_once(params: &ExecOnceParams) -> Result<ExecOnceResult, HelperFsE
|
||||
let stdout_handle = thread::spawn(move || read_child_output(stdout_pipe, stdout_cap));
|
||||
let stderr_handle = thread::spawn(move || read_child_output(stderr_pipe, stderr_cap));
|
||||
|
||||
// PR 13b.2: cancel_flag is checked in the same polling loop that
|
||||
// already enforces the deadline. When the dispatcher flips the flag
|
||||
// (in response to a Cancel envelope), the loop exits early via the
|
||||
// ``cancelled`` branch and the child is SIGTERM'd just like a timeout.
|
||||
let mut cancelled = false;
|
||||
let timed_out = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break false,
|
||||
Ok(None) => {
|
||||
if cancel_flag
|
||||
.map(|f| f.load(std::sync::atomic::Ordering::Relaxed))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
cancelled = true;
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break false;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -933,7 +1123,12 @@ fn handle_exec_once(params: &ExecOnceParams) -> Result<ExecOnceResult, HelperFsE
|
||||
|
||||
let stdout = stdout_handle.join().unwrap_or_default();
|
||||
let mut stderr = stderr_handle.join().unwrap_or_default();
|
||||
if timed_out {
|
||||
if cancelled && !timed_out {
|
||||
if !stderr.is_empty() {
|
||||
stderr.push('\n');
|
||||
}
|
||||
stderr.push_str("Cancelled by bridge.");
|
||||
} else if timed_out {
|
||||
if !stderr.is_empty() {
|
||||
stderr.push('\n');
|
||||
}
|
||||
@@ -1094,10 +1289,11 @@ mod tests {
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use session_protocol::{
|
||||
CHANNEL_ENVELOPE_V1, CHANNEL_FILE, CHANNEL_KIND_LSP_PING, CHANNEL_KIND_LSP_STDIO_MESSAGE,
|
||||
Capability, ErrorEnvelope, ExecOnceParams, ExecOnceResult, FileReadParams, FileStatParams,
|
||||
FileWriteErrorCode, FileWriteParams, METHOD_CHANNEL_DISPATCH, METHOD_EXEC_ONCE,
|
||||
METHOD_FILE_READ, METHOD_FILE_STAT, METHOD_FILE_WRITE, METHOD_TREE_LIST, RemoteFileKind,
|
||||
RequestEnvelope, ResponseEnvelope, TraceLevel, TreeListParams, encode_message,
|
||||
CancelRequest, Capability, ErrorEnvelope, ExecOnceParams, ExecOnceResult, FileReadParams,
|
||||
FileStatParams, FileWriteErrorCode, FileWriteParams, METHOD_CHANNEL_DISPATCH,
|
||||
METHOD_EXEC_ONCE, METHOD_FILE_READ, METHOD_FILE_STAT, METHOD_FILE_WRITE, METHOD_TREE_LIST,
|
||||
RemoteFileKind, RequestEnvelope, ResponseEnvelope, TraceLevel, TreeListParams,
|
||||
encode_message,
|
||||
};
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
@@ -1612,6 +1808,40 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_for_unknown_request_id_returns_no_match() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// PR 13b.1: cancel skeleton — when no in-flight worker matches the
|
||||
// supplied id (e.g. the request already finished, or never arrived),
|
||||
// the helper acknowledges with ``cancel_no_match`` rather than the
|
||||
// pre-13b.1 ``cancel_not_supported`` blanket reject.
|
||||
let cancel = encode_message(&ProtocolMessage::Cancel(CancelRequest {
|
||||
request_id: "nope-1".to_string(),
|
||||
reason: "unit-test".to_string(),
|
||||
}))?;
|
||||
let shutdown = encode_message(&ProtocolMessage::Shutdown(ShutdownNotice {
|
||||
reason: ShutdownReason::BridgeRequested,
|
||||
}))?;
|
||||
let mut input = Cursor::new(format!("{cancel}{shutdown}").into_bytes());
|
||||
let mut output: Vec<u8> = Vec::new();
|
||||
let args = HelperStartupArgs {
|
||||
stdio: true,
|
||||
trace: TraceLevel::Info,
|
||||
};
|
||||
|
||||
run_stdio_session_with_io(&args, &mut input, &mut output)?;
|
||||
|
||||
let output_text = String::from_utf8(output)?;
|
||||
assert!(
|
||||
output_text.contains("\"code\":\"cancel_no_match\""),
|
||||
"expected cancel_no_match error envelope, got: {output_text}"
|
||||
);
|
||||
assert!(
|
||||
!output_text.contains("\"code\":\"cancel_not_supported\""),
|
||||
"stale cancel_not_supported response must be gone after PR 13b.1"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_error(result: Result<ResponseEnvelope, ErrorEnvelope>, code: &str) {
|
||||
let actual_code = result
|
||||
.err()
|
||||
|
||||
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.
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -15,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(())
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -822,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`.
|
||||
@@ -1199,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();
|
||||
|
||||
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())
|
||||
@@ -87,6 +87,23 @@
|
||||
// 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,
|
||||
|
||||
@@ -150,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
|
||||
|
||||
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",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -516,37 +516,6 @@ class SessionsOpenRemoteFileCommand(sublime_plugin.WindowCommand):
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -25,7 +25,7 @@ import time
|
||||
import webbrowser
|
||||
from dataclasses import replace
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from . import commands as _root
|
||||
from .connect_preflight import ConnectStatus
|
||||
@@ -69,7 +69,11 @@ from .remote_tool_wiring import (
|
||||
build_python_lsp_source_action_tool_execution_request,
|
||||
build_requests_for_python_tool_pipeline,
|
||||
)
|
||||
from .settings_model import SessionsSettings, load_sessions_settings_from_sublime
|
||||
from .settings_model import (
|
||||
SessionsSettings,
|
||||
load_sessions_settings_from_sublime,
|
||||
normalize_remote_python_tool_pipeline,
|
||||
)
|
||||
|
||||
_LOG = logging.getLogger("sessions.commands_python_pipeline")
|
||||
|
||||
@@ -114,15 +118,65 @@ _DEBUG_PANEL_NAME = "sessions_debug_setup"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _effective_sessions_settings_for_remote_python() -> SessionsSettings:
|
||||
"""Merge default settings with ``Sessions.sublime-settings`` pipeline keys."""
|
||||
def _project_settings_block_for_window(window: Optional[object]) -> Mapping[str, Any]:
|
||||
"""Return ``window.project_data()['settings']`` if structurally valid, else ``{}``.
|
||||
|
||||
Mirrors the safety pattern already used elsewhere in this module: tolerate
|
||||
a missing ``project_data`` callable, ``None`` payloads, and any non-mapping
|
||||
value at either level instead of raising.
|
||||
"""
|
||||
if window is None:
|
||||
return {}
|
||||
project_data_fn = getattr(window, "project_data", None)
|
||||
if not callable(project_data_fn):
|
||||
return {}
|
||||
project_data = project_data_fn()
|
||||
if not isinstance(project_data, Mapping):
|
||||
return {}
|
||||
settings = project_data.get("settings")
|
||||
if not isinstance(settings, Mapping):
|
||||
return {}
|
||||
return settings
|
||||
|
||||
|
||||
def _effective_sessions_settings_for_remote_python(
|
||||
window: Optional[object] = None,
|
||||
) -> SessionsSettings:
|
||||
"""Merge default → user → project settings for the on-save/on-open pipeline.
|
||||
|
||||
Reads ``Packages/Sessions/Sessions.sublime-settings`` (default) merged
|
||||
with ``Packages/User/Sessions.sublime-settings`` (user) via
|
||||
``load_sessions_settings_from_sublime``, then overlays the active
|
||||
``.sublime-project`` ``"settings"`` block when ``window`` is given.
|
||||
Mirrors how Sublime LSP layers per-project overrides on top of user
|
||||
settings — see ``planning/REVIEW_v0_6_4_DISTRIBUTION_PLAN.md`` for the
|
||||
LSP-style precedence rationale.
|
||||
"""
|
||||
base = SessionsSettings()
|
||||
plug = load_sessions_settings_from_sublime()
|
||||
on_save = plug.remote_python_auto_diagnostics_on_save
|
||||
on_open = plug.remote_python_auto_diagnostics_on_open
|
||||
pipeline = plug.remote_python_tool_pipeline
|
||||
project_settings = _project_settings_block_for_window(window)
|
||||
project_on_save = project_settings.get(
|
||||
"sessions_remote_python_auto_diagnostics_on_save"
|
||||
)
|
||||
if isinstance(project_on_save, bool):
|
||||
on_save = project_on_save
|
||||
project_on_open = project_settings.get(
|
||||
"sessions_remote_python_auto_diagnostics_on_open"
|
||||
)
|
||||
if isinstance(project_on_open, bool):
|
||||
on_open = project_on_open
|
||||
if "sessions_remote_python_tool_pipeline" in project_settings:
|
||||
pipeline = normalize_remote_python_tool_pipeline(
|
||||
project_settings.get("sessions_remote_python_tool_pipeline")
|
||||
)
|
||||
return replace(
|
||||
base,
|
||||
remote_python_auto_diagnostics_on_save=plug.remote_python_auto_diagnostics_on_save,
|
||||
remote_python_auto_diagnostics_on_open=plug.remote_python_auto_diagnostics_on_open,
|
||||
remote_python_tool_pipeline=plug.remote_python_tool_pipeline,
|
||||
remote_python_auto_diagnostics_on_save=on_save,
|
||||
remote_python_auto_diagnostics_on_open=on_open,
|
||||
remote_python_tool_pipeline=pipeline,
|
||||
)
|
||||
|
||||
|
||||
@@ -166,7 +220,7 @@ def _collect_remote_python_pipeline_results(
|
||||
"""
|
||||
if not remote_path.endswith(".py"):
|
||||
return ()
|
||||
merged = _effective_sessions_settings_for_remote_python()
|
||||
merged = _effective_sessions_settings_for_remote_python(window)
|
||||
if not merged.remote_python_auto_diagnostics_on_save:
|
||||
return ()
|
||||
if post_save_view is not None:
|
||||
@@ -301,7 +355,7 @@ def _schedule_remote_python_pipeline(
|
||||
trigger: RunTrigger,
|
||||
) -> None:
|
||||
"""Kick off the remote diagnostics pipeline when targets are valid."""
|
||||
merged = _effective_sessions_settings_for_remote_python()
|
||||
merged = _effective_sessions_settings_for_remote_python(window)
|
||||
targets = _remote_python_pipeline_targets(view, window, merged)
|
||||
if targets is None:
|
||||
return
|
||||
@@ -329,7 +383,7 @@ def _maybe_schedule_remote_python_pipeline_after_cache_push(
|
||||
"""
|
||||
if not remote_path.endswith(".py"):
|
||||
return
|
||||
merged = _effective_sessions_settings_for_remote_python()
|
||||
merged = _effective_sessions_settings_for_remote_python(window)
|
||||
if not merged.remote_python_auto_diagnostics_on_save:
|
||||
return
|
||||
if post_save_view is not None:
|
||||
@@ -550,26 +604,26 @@ class SessionsRemotePythonPipelineListener(sublime_plugin.EventListener):
|
||||
|
||||
def on_post_save(self, view) -> None:
|
||||
"""Lint/typecheck after save when enabled in ``Sessions.sublime-settings``."""
|
||||
merged = _effective_sessions_settings_for_remote_python()
|
||||
if not merged.remote_python_auto_diagnostics_on_save:
|
||||
return
|
||||
window_fn = getattr(view, "window", None)
|
||||
window = window_fn() if callable(window_fn) else None
|
||||
if window is None:
|
||||
return
|
||||
merged = _effective_sessions_settings_for_remote_python(window)
|
||||
if not merged.remote_python_auto_diagnostics_on_save:
|
||||
return
|
||||
if _root._remote_save_target_after_local_save(view, window) is not None:
|
||||
return
|
||||
_schedule_remote_python_pipeline(view, window, RunTrigger.ON_SAVE)
|
||||
|
||||
def on_activated_async(self, view) -> None:
|
||||
"""Optionally run the pipeline when a ``.py`` cache buffer is focused."""
|
||||
merged = _effective_sessions_settings_for_remote_python()
|
||||
if not merged.remote_python_auto_diagnostics_on_open:
|
||||
return
|
||||
window_fn = getattr(view, "window", None)
|
||||
window = window_fn() if callable(window_fn) else None
|
||||
if window is None:
|
||||
return
|
||||
merged = _effective_sessions_settings_for_remote_python(window)
|
||||
if not merged.remote_python_auto_diagnostics_on_open:
|
||||
return
|
||||
# Same rationale as the version-probe listener: don't spawn the
|
||||
# bridge on a restored project window before the user has
|
||||
# explicitly reconnected.
|
||||
|
||||
@@ -7,19 +7,19 @@ disk directly — it never flows through Sublime's ``open_file`` hook, so
|
||||
zero-byte placeholder (created by the sidebar mirror pass), the CLI tool
|
||||
reports a malformed manifest and gives up.
|
||||
|
||||
This module walks an already-mirrored local cache once a workspace activates
|
||||
and schedules a bounded bulk fetch for placeholders whose basename matches a
|
||||
small allow-list of "essential" files (``Cargo.toml``, ``pyproject.toml``,
|
||||
``package.json``, …). The actual fetch primitive is injected so the driver
|
||||
stays importable without the Sublime/SSH runtime.
|
||||
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
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Iterator, List, Optional, Tuple
|
||||
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
|
||||
@@ -49,171 +49,27 @@ DEFAULT_BATCH_SIZE: int = 20
|
||||
DEFAULT_BATCH_SLEEP_S: float = 0.05
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EagerHydrateSummary:
|
||||
"""Outcome of one eager-hydrate pass.
|
||||
|
||||
Attributes:
|
||||
hydrated: Count of placeholders that were fetched successfully.
|
||||
skipped_existing: Placeholders that turned out to have non-zero size
|
||||
by the time the driver reached them (another worker won the race).
|
||||
failed: Placeholders whose ``fetch_fn`` returned ``False``.
|
||||
"""
|
||||
|
||||
hydrated: int = 0
|
||||
skipped_existing: int = 0
|
||||
failed: int = 0
|
||||
|
||||
|
||||
def _is_placeholder(path: Path) -> bool:
|
||||
"""Return ``True`` if ``path`` is a regular zero-byte file."""
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return False
|
||||
if stat.st_size != 0:
|
||||
return False
|
||||
# ``Path.is_file`` resolves symlinks; the Sessions cache never uses
|
||||
# symlinks but the guard is cheap.
|
||||
try:
|
||||
return path.is_file()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def find_placeholder_candidates(
|
||||
cache_root: Path,
|
||||
allowed_basenames: Iterable[str],
|
||||
) -> Iterator[Path]:
|
||||
"""Yield zero-byte files under ``cache_root`` whose basename is allowed.
|
||||
|
||||
The walk is lazy — callers can bound the work by stopping iteration.
|
||||
Directories that raise ``OSError`` during enumeration are skipped so a
|
||||
partial cache still produces what candidates it can.
|
||||
|
||||
Args:
|
||||
cache_root: Local cache root for the workspace (e.g. ``.../files``).
|
||||
allowed_basenames: Exact filename matches to include.
|
||||
|
||||
Yields:
|
||||
Absolute ``Path`` objects matching the allow-list with size 0.
|
||||
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 = {name for name in allowed_basenames if name}
|
||||
if not allowed:
|
||||
allowed_list = [name for name in allowed_basenames if name]
|
||||
if not allowed_list:
|
||||
return
|
||||
try:
|
||||
resolved_root = cache_root
|
||||
if not resolved_root.is_dir():
|
||||
if not cache_root.is_dir():
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
stack: List[Path] = [resolved_root]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
try:
|
||||
entries = list(current.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
try:
|
||||
is_dir = entry.is_dir()
|
||||
except OSError:
|
||||
continue
|
||||
if is_dir:
|
||||
# Don't descend into Sessions' own metadata subtree or any
|
||||
# externally-tracked path — neither should host build
|
||||
# manifests.
|
||||
if entry.name in ("__extern",):
|
||||
continue
|
||||
stack.append(entry)
|
||||
continue
|
||||
if entry.name not in allowed:
|
||||
continue
|
||||
if _is_placeholder(entry):
|
||||
yield entry
|
||||
|
||||
|
||||
def batched(items: Iterable[Path], batch_size: int) -> Iterator[List[Path]]:
|
||||
"""Yield ``items`` in lists of at most ``batch_size``.
|
||||
|
||||
Args:
|
||||
items: Source iterable.
|
||||
batch_size: Maximum list length; values ``<= 0`` collapse to ``1``.
|
||||
"""
|
||||
size = max(1, batch_size)
|
||||
bucket: List[Path] = []
|
||||
for item in items:
|
||||
bucket.append(item)
|
||||
if len(bucket) >= size:
|
||||
yield bucket
|
||||
bucket = []
|
||||
if bucket:
|
||||
yield bucket
|
||||
|
||||
|
||||
FetchFn = Callable[[Path], bool]
|
||||
"""Hydrate one placeholder. Returns ``True`` on success, ``False`` otherwise."""
|
||||
|
||||
|
||||
def run_eager_hydrate(
|
||||
cache_root: Path,
|
||||
*,
|
||||
fetch_fn: FetchFn,
|
||||
allowed_basenames: Iterable[str] = DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
batch_sleep_s: float = DEFAULT_BATCH_SLEEP_S,
|
||||
sleep_fn: Optional[Callable[[float], None]] = None,
|
||||
) -> EagerHydrateSummary:
|
||||
"""Drive one hydrate pass over placeholders under ``cache_root``.
|
||||
|
||||
The driver is deliberately dumb: no retries, no per-file concurrency,
|
||||
no global state. Failures are counted but do not abort the pass — the
|
||||
next placeholder still gets its chance.
|
||||
|
||||
Args:
|
||||
cache_root: Local cache root to walk.
|
||||
fetch_fn: Callable invoked for each placeholder. Return ``True`` on
|
||||
successful hydration. Must not raise; failures should be encoded
|
||||
as ``False`` so the pass can continue.
|
||||
allowed_basenames: Override for the default allow-list.
|
||||
batch_size: Placeholders per batch before pausing.
|
||||
batch_sleep_s: Pause between batches, in seconds.
|
||||
sleep_fn: Injection point for tests; defaults to :func:`time.sleep`.
|
||||
|
||||
Returns:
|
||||
An :class:`EagerHydrateSummary` with per-outcome counts.
|
||||
"""
|
||||
sleeper = sleep_fn if sleep_fn is not None else time.sleep
|
||||
hydrated = 0
|
||||
skipped_existing = 0
|
||||
failed = 0
|
||||
|
||||
placeholders = find_placeholder_candidates(cache_root, allowed_basenames)
|
||||
for batch_index, batch in enumerate(batched(placeholders, batch_size)):
|
||||
if batch_index > 0 and batch_sleep_s > 0:
|
||||
sleeper(batch_sleep_s)
|
||||
for path in batch:
|
||||
# Re-check size right before fetching: a different code path
|
||||
# (``SessionsOnDemandFetchListener`` / sidebar hydrate) may have
|
||||
# filled the placeholder while we were iterating.
|
||||
if not _is_placeholder(path):
|
||||
skipped_existing += 1
|
||||
continue
|
||||
try:
|
||||
ok = bool(fetch_fn(path))
|
||||
except Exception:
|
||||
ok = False
|
||||
if ok:
|
||||
hydrated += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return EagerHydrateSummary(
|
||||
hydrated=hydrated,
|
||||
skipped_existing=skipped_existing,
|
||||
failed=failed,
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
@@ -230,6 +230,32 @@ def apply_pending_checkout(
|
||||
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
|
||||
@@ -252,6 +278,18 @@ def apply_pending_checkout(
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
|
||||
@@ -188,10 +188,19 @@ def _shell_quote(value: str) -> str:
|
||||
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
|
||||
@@ -217,6 +226,37 @@ def _replace_local_dot_git(local_dot_git: Path, tarball: bytes) -> None:
|
||||
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:
|
||||
|
||||
@@ -80,48 +80,6 @@ export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
rustup component remove rust-analyzer 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_JUPYTER_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
GET_PIP_URL=https://bootstrap.pypa.io/get-pip.py
|
||||
set -e
|
||||
PKGS="jupyterlab ipykernel"
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "Sessions: python3 required to install Jupyter Lab." >&2
|
||||
exit 127
|
||||
fi
|
||||
if python3 -m pip install --user $PKGS; then exit 0; fi
|
||||
if command -v pip3 >/dev/null 2>&1 && pip3 install --user $PKGS; then
|
||||
exit 0
|
||||
fi
|
||||
if command -v pip >/dev/null 2>&1 && pip install --user $PKGS; then
|
||||
exit 0
|
||||
fi
|
||||
if python3 -m ensurepip --user --default-pip >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user $PKGS; then exit 0; fi
|
||||
if command -v curl >/dev/null 2>&1 && curl -fsSL "$GET_PIP_URL" \\
|
||||
| python3 - --user >/dev/null 2>&1 \\
|
||||
&& python3 -m pip install --user $PKGS; then exit 0; fi
|
||||
echo "Sessions: could not install Jupyter Lab." >&2
|
||||
exit 1
|
||||
"""
|
||||
_BUILTIN_BASH_JUPYTER_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
PKGS="jupyterlab jupyter_server jupyterlab_server"
|
||||
python3 -m pip uninstall -y $PKGS 2>/dev/null || true
|
||||
if command -v pip3 >/dev/null 2>&1; then
|
||||
pip3 uninstall -y $PKGS 2>/dev/null || true
|
||||
fi
|
||||
if command -v pip >/dev/null 2>&1; then
|
||||
pip uninstall -y $PKGS 2>/dev/null || true
|
||||
fi
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_JUPYTER_PROBE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
set -e
|
||||
command -v jupyter >/dev/null 2>&1 || { echo "jupyter not on PATH" >&2; exit 127; }
|
||||
jupyter lab --version
|
||||
"""
|
||||
_BUILTIN_BASH_DEBUGPY_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
set -e
|
||||
@@ -144,94 +102,6 @@ if [ -z "{ACTIVE_PYTHON}" ]; then
|
||||
fi
|
||||
"{ACTIVE_PYTHON}" -c "import debugpy, sys; print(debugpy.__version__)"
|
||||
"""
|
||||
_BUILTIN_BASH_TMUX_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
tmux -V
|
||||
exit 0
|
||||
fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y tmux
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf install -y tmux
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
sudo yum install -y tmux
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
sudo pacman -S --noconfirm tmux
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
brew install tmux
|
||||
else
|
||||
echo "Sessions: no supported package manager found (apt/dnf/yum/pacman/brew)." >&2
|
||||
echo "Install tmux manually; see https://github.com/tmux/tmux/wiki/Installing" >&2
|
||||
exit 127
|
||||
fi
|
||||
"""
|
||||
_BUILTIN_BASH_TMUX_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get remove -y tmux 2>/dev/null || true
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf remove -y tmux 2>/dev/null || true
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
sudo yum remove -y tmux 2>/dev/null || true
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
sudo pacman -R --noconfirm tmux 2>/dev/null || true
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
brew uninstall tmux 2>/dev/null || true
|
||||
fi
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_TMUX_PROBE = """\
|
||||
export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||
command -v tmux >/dev/null 2>&1 || { echo "tmux not on PATH" >&2; exit 127; }
|
||||
tmux -V
|
||||
"""
|
||||
_BUILTIN_BASH_CLAUDE_INSTALL = """\
|
||||
export PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
|
||||
set -e
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "Sessions: curl is required to install Claude Code CLI." >&2
|
||||
echo "See https://docs.claude.com/en/docs/claude-code/setup for manual install." >&2
|
||||
exit 127
|
||||
fi
|
||||
if ! curl -fsSL https://claude.ai/install.sh | bash; then
|
||||
echo "Sessions: Claude Code install script failed (URL unreachable?)." >&2
|
||||
echo "See https://docs.claude.com/en/docs/claude-code/setup for manual install." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PATH="$HOME/.claude/bin:$PATH"
|
||||
command -v claude >/dev/null 2>&1 && claude --version
|
||||
"""
|
||||
_BUILTIN_BASH_CLAUDE_REMOVE = """\
|
||||
rm -rf "$HOME/.claude/bin"
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_CLAUDE_PROBE = """\
|
||||
export PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH"
|
||||
command -v claude >/dev/null 2>&1 || { echo "claude not on PATH" >&2; exit 127; }
|
||||
claude --version
|
||||
"""
|
||||
_BUILTIN_BASH_CODEX_INSTALL = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
set -e
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "Sessions: npm is required to install the OpenAI Codex CLI." >&2
|
||||
echo "Install Node.js / npm first (see https://nodejs.org/)." >&2
|
||||
exit 127
|
||||
fi
|
||||
npm install -g @openai/codex
|
||||
command -v codex >/dev/null 2>&1 && codex --version
|
||||
"""
|
||||
_BUILTIN_BASH_CODEX_REMOVE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
command -v npm >/dev/null 2>&1 && npm uninstall -g @openai/codex 2>/dev/null || true
|
||||
exit 0
|
||||
"""
|
||||
_BUILTIN_BASH_CODEX_PROBE = """\
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
command -v codex >/dev/null 2>&1 || { echo "codex not on PATH" >&2; exit 127; }
|
||||
codex --version
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -297,15 +167,6 @@ BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG: Tuple[
|
||||
remote_spawn_argv=("rust-analyzer",),
|
||||
sublime_selector="source.rust",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="jupyterlab",
|
||||
install_label="Jupyter Lab (remote)",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_JUPYTER_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_JUPYTER_REMOVE),
|
||||
probe_argv=("bash", "-lc", _BUILTIN_BASH_JUPYTER_PROBE),
|
||||
install_cwd=None,
|
||||
kind="jupyter",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="debugpy",
|
||||
install_label="debugpy (remote Python debugger)",
|
||||
@@ -318,31 +179,4 @@ BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG: Tuple[
|
||||
install_cwd=None,
|
||||
kind="debugger",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="tmux",
|
||||
install_label="tmux (agent session prerequisite)",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_TMUX_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_TMUX_REMOVE),
|
||||
probe_argv=("bash", "-lc", _BUILTIN_BASH_TMUX_PROBE),
|
||||
install_cwd=None,
|
||||
kind="agent",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="claude-code",
|
||||
install_label="Claude Code CLI (remote)",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_CLAUDE_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_CLAUDE_REMOVE),
|
||||
probe_argv=("bash", "-lc", _BUILTIN_BASH_CLAUDE_PROBE),
|
||||
install_cwd=None,
|
||||
kind="agent",
|
||||
),
|
||||
ManagedRemoteExtensionCatalogEntry(
|
||||
install_catalog_id="codex-cli",
|
||||
install_label="OpenAI Codex CLI (remote)",
|
||||
install_argv=("bash", "-lc", _BUILTIN_BASH_CODEX_INSTALL),
|
||||
remove_argv=("bash", "-lc", _BUILTIN_BASH_CODEX_REMOVE),
|
||||
probe_argv=("bash", "-lc", _BUILTIN_BASH_CODEX_PROBE),
|
||||
install_cwd=None,
|
||||
kind="agent",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -262,7 +262,7 @@ class MarimoSessionManager:
|
||||
"""
|
||||
self._ssh = ssh_command_builder or _default_ssh_command_builder
|
||||
# Default run/popen wrap subprocess with CREATE_NO_WINDOW on Windows
|
||||
# so the underlying ssh / tmux children don't pop a console window
|
||||
# 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.
|
||||
@@ -447,8 +447,7 @@ class MarimoSessionManager:
|
||||
)
|
||||
# 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``. (See jupyter_hosting.py for the full
|
||||
# postmortem of the prior tokenisation bug.)
|
||||
# leading word to ``bash -lc``.
|
||||
argv = list(self._ssh(host_alias)) + [
|
||||
"bash -lc " + shlex.quote(remote_script),
|
||||
]
|
||||
|
||||
@@ -17,6 +17,8 @@ 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
|
||||
|
||||
@@ -235,44 +237,13 @@ def clear_active_interpreter(window: object) -> None:
|
||||
def derive_venv_name(remote_path: str) -> Optional[str]:
|
||||
"""Return a human-friendly venv label for ``remote_path``.
|
||||
|
||||
Heuristics, in priority order, with examples:
|
||||
|
||||
* ``/path/to/MIN-T/.venv/bin/python`` → ``MIN-T``
|
||||
(parent of the ``.venv/bin/python(3)`` tail)
|
||||
* ``$HOME/.local/share/conda/envs/foo/bin/python`` → ``foo``
|
||||
(a conda-style ``envs/<name>/bin/python`` layout)
|
||||
* ``/opt/python311/bin/python3`` → ``python311``
|
||||
(anything else: parent of ``bin``)
|
||||
|
||||
Returns ``None`` only when ``remote_path`` is empty or has fewer than two
|
||||
components — there's no useful name we can pull out in that case.
|
||||
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.
|
||||
"""
|
||||
if not remote_path:
|
||||
return None
|
||||
parts = [p for p in remote_path.split("/") if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
# Case 1: <name>/.venv/bin/python(3)
|
||||
if (
|
||||
len(parts) >= 4
|
||||
and parts[-1].startswith("python")
|
||||
and parts[-2] == "bin"
|
||||
and parts[-3] == ".venv"
|
||||
):
|
||||
return parts[-4]
|
||||
# Case 2: .../envs/<name>/bin/python(3)
|
||||
if (
|
||||
len(parts) >= 4
|
||||
and parts[-1].startswith("python")
|
||||
and parts[-2] == "bin"
|
||||
and parts[-4] == "envs"
|
||||
):
|
||||
return parts[-3]
|
||||
# Case 3: fallback — parent of ``bin``.
|
||||
if len(parts) >= 3 and parts[-2] == "bin":
|
||||
return parts[-3]
|
||||
# No ``bin/`` separator at all: punt to the immediate parent directory.
|
||||
return parts[-2]
|
||||
derived = _rust_ffi.derive_venv_name(remote_path)
|
||||
return derived if derived else None
|
||||
|
||||
|
||||
def parse_version_output(output: str) -> Optional[str]:
|
||||
@@ -397,19 +368,20 @@ def is_python_view(view: object) -> bool:
|
||||
scope_name = getattr(view, "scope_name", None)
|
||||
if callable(scope_name):
|
||||
try:
|
||||
scope = scope_name(0) or ""
|
||||
scope_raw = scope_name(0)
|
||||
except Exception: # noqa: BLE001
|
||||
scope = ""
|
||||
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 = file_name() or ""
|
||||
name_raw = file_name()
|
||||
except Exception: # noqa: BLE001
|
||||
name = ""
|
||||
lower = name.lower()
|
||||
if lower.endswith((".py", ".pyi", ".pyx", ".pxd")):
|
||||
name_raw = None
|
||||
name = name_raw if isinstance(name_raw, str) else ""
|
||||
if name.lower().endswith((".py", ".pyi", ".pyx", ".pxd")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,20 +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 . 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) "
|
||||
@@ -24,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)
|
||||
@@ -66,105 +55,64 @@ class RemoteExtensionSpec:
|
||||
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_extension_specs(raw: object) -> Tuple[RemoteExtensionSpec, ...]:
|
||||
"""Normalize user-provided remote extension install/remove specs."""
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
canonical = _rust_ffi.normalize_remote_extension_specs_json(raw)
|
||||
out: List[RemoteExtensionSpec] = []
|
||||
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(
|
||||
RemoteExtensionSpec(
|
||||
id=normalized_id,
|
||||
label=label,
|
||||
install_argv=install_argv,
|
||||
remove_argv=remove_argv,
|
||||
probe_argv=probe_argv,
|
||||
cwd=cwd,
|
||||
)
|
||||
)
|
||||
for item in canonical:
|
||||
spec = _remote_extension_spec_from_dict(item)
|
||||
if spec is not None:
|
||||
out.append(spec)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
@@ -194,31 +142,35 @@ DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS: Tuple[RemoteExtensionSpec, ...] = (
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def merge_remote_extension_catalog(user_raw: object) -> Tuple[RemoteExtensionSpec, ...]:
|
||||
"""Return effective extension install catalog: builtins + user overrides/extras.
|
||||
|
||||
When the user setting is missing, invalid, or normalizes to an empty list,
|
||||
builtins alone are used. User specs with the same ``id`` as a builtin replace
|
||||
that entry; additional user-only ids are appended in user order.
|
||||
Delegates the merge to Rust (``sessions_settings_merge_extension_catalog``).
|
||||
Builtin catalog stays in Python (``managed_remote_extension_catalog``).
|
||||
"""
|
||||
user_specs = normalize_remote_extension_specs(user_raw)
|
||||
by_id: Dict[str, RemoteExtensionSpec] = {
|
||||
spec.id: spec for spec in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS
|
||||
}
|
||||
for spec in user_specs:
|
||||
by_id[spec.id] = spec
|
||||
ordered: List[RemoteExtensionSpec] = []
|
||||
builtin_ids = [spec.id for spec in DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS]
|
||||
for sid in builtin_ids:
|
||||
if sid in by_id:
|
||||
ordered.append(by_id[sid])
|
||||
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)
|
||||
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:
|
||||
@@ -379,12 +331,12 @@ def load_sessions_settings_from_sublime() -> SessionsSettings:
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
@@ -419,6 +371,59 @@ def load_sessions_settings_from_sublime() -> SessionsSettings:
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 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(
|
||||
|
||||
@@ -27,6 +27,9 @@ from . import _rust_ffi
|
||||
from ._rust_ffi import (
|
||||
error_message as rust_bridge_error_message,
|
||||
)
|
||||
from ._rust_ffi import (
|
||||
file_open_transaction as _rust_file_open_transaction,
|
||||
)
|
||||
from ._rust_ffi import (
|
||||
parse_mirror_result as rust_parse_mirror_result,
|
||||
)
|
||||
@@ -48,10 +51,9 @@ from .connect_preflight import (
|
||||
)
|
||||
from .file_state import (
|
||||
FileOpenGuardrails,
|
||||
OpenFileRequest,
|
||||
OpenFileResult,
|
||||
OpenOutcome,
|
||||
evaluate_open_file,
|
||||
UnsupportedOpenReason,
|
||||
)
|
||||
from .recent_state import RemoteHostPlatformStore, RemoteLinuxPlatformTag
|
||||
from .remote import (
|
||||
@@ -2082,6 +2084,74 @@ def execute_remote_write_file(
|
||||
)
|
||||
|
||||
|
||||
_UNSUPPORTED_REASON_MAP: Mapping[str, UnsupportedOpenReason] = {
|
||||
"file_too_large": UnsupportedOpenReason.FILE_TOO_LARGE,
|
||||
"unsupported_remote_kind": UnsupportedOpenReason.UNSUPPORTED_REMOTE_KIND,
|
||||
"zero_byte_read_not_allowed": UnsupportedOpenReason.ZERO_BYTE_READ_NOT_ALLOWED,
|
||||
}
|
||||
|
||||
|
||||
def _metadata_from_rust_dict(
|
||||
raw: Optional[Mapping[str, Any]],
|
||||
) -> Optional[RemoteFileMetadata]:
|
||||
if not raw:
|
||||
return None
|
||||
kind_str = str(raw.get("kind", RemoteFileKind.REGULAR_FILE.value))
|
||||
try:
|
||||
kind = RemoteFileKind(kind_str)
|
||||
except ValueError:
|
||||
kind = RemoteFileKind.OTHER
|
||||
unix_mode_raw = raw.get("unix_mode")
|
||||
return RemoteFileMetadata(
|
||||
mtime_ns=int(raw.get("mtime_ns", 0)),
|
||||
size_bytes=int(raw.get("size_bytes", 0)),
|
||||
kind=kind,
|
||||
unix_mode=int(unix_mode_raw) if unix_mode_raw is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _open_outcome_from_rust_dict(
|
||||
payload: Mapping[str, Any], local_cache_path: Path
|
||||
) -> OpenFileResult:
|
||||
outcome_str = str(payload.get("outcome", "TRANSPORT_ERROR"))
|
||||
raw_metadata = payload.get("metadata")
|
||||
metadata = _metadata_from_rust_dict(
|
||||
raw_metadata if isinstance(raw_metadata, Mapping) else None
|
||||
)
|
||||
if outcome_str == "OK":
|
||||
return OpenFileResult(
|
||||
outcome=OpenOutcome.OK,
|
||||
local_cache_path=local_cache_path,
|
||||
remote_metadata=metadata,
|
||||
)
|
||||
if outcome_str == "BLOCKED_BY_POLICY":
|
||||
reason_label = str(payload.get("unsupported_reason", ""))
|
||||
reason = _UNSUPPORTED_REASON_MAP.get(reason_label)
|
||||
return OpenFileResult(
|
||||
outcome=OpenOutcome.BLOCKED_BY_POLICY,
|
||||
local_cache_path=local_cache_path,
|
||||
unsupported_reason=reason,
|
||||
)
|
||||
if outcome_str == "BLOCKED_BINARY_HEURISTIC":
|
||||
return OpenFileResult(
|
||||
outcome=OpenOutcome.BLOCKED_BINARY_HEURISTIC,
|
||||
local_cache_path=local_cache_path,
|
||||
)
|
||||
if outcome_str == "REMOTE_NOT_FOUND":
|
||||
detail_raw = payload.get("detail")
|
||||
return OpenFileResult(
|
||||
outcome=OpenOutcome.REMOTE_NOT_FOUND,
|
||||
local_cache_path=local_cache_path,
|
||||
detail=str(detail_raw) if detail_raw is not None else None,
|
||||
)
|
||||
detail_raw = payload.get("detail")
|
||||
return OpenFileResult(
|
||||
outcome=OpenOutcome.TRANSPORT_ERROR,
|
||||
local_cache_path=local_cache_path,
|
||||
detail=str(detail_raw) if detail_raw is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def open_remote_file_into_local_cache(
|
||||
host_alias: str,
|
||||
*,
|
||||
@@ -2090,67 +2160,37 @@ def open_remote_file_into_local_cache(
|
||||
guard_limits: FileOpenGuardrails | None = None,
|
||||
read_timeout_s: float = 30.0,
|
||||
) -> OpenFileResult:
|
||||
"""Fetch remote bytes over SSH, run open guardrails, and write the local cache file.
|
||||
"""Fetch remote bytes via the Rust file_open transaction (PR 14.5d).
|
||||
|
||||
Transport failures are surfaced as ``OpenOutcome.TRANSPORT_ERROR`` so callers
|
||||
can stay UI-free while still distinguishing policy blocks from SSH issues.
|
||||
Missing remote paths (``ENOENT`` / ``lstat_failed``) return
|
||||
``OpenOutcome.REMOTE_NOT_FOUND`` so the UI can drop stale cache files.
|
||||
Rust orchestrates broker.request file/read → metadata/size guard →
|
||||
binary head heuristic → atomic write into ``local_cache_path``. The
|
||||
Python wrapper validates the remote root, dispatches to the Rust
|
||||
transaction, and maps the outcome dict to :class:`OpenFileResult`.
|
||||
|
||||
Transport failures surface as ``OpenOutcome.TRANSPORT_ERROR``; missing
|
||||
remote paths surface as ``OpenOutcome.REMOTE_NOT_FOUND`` so the UI can
|
||||
drop stale cache files.
|
||||
"""
|
||||
limits = guard_limits or FileOpenGuardrails()
|
||||
try:
|
||||
normalized = validate_remote_root(remote_absolute_path)
|
||||
try:
|
||||
read_result = execute_remote_read_file(
|
||||
host_alias,
|
||||
RemoteReadFileRequest(normalized),
|
||||
timeout_s=read_timeout_s,
|
||||
)
|
||||
except TypeError:
|
||||
read_result = execute_remote_read_file(
|
||||
host_alias,
|
||||
RemoteReadFileRequest(normalized),
|
||||
)
|
||||
except (InvalidRemoteRootError, SessionHelperStartError) as error:
|
||||
if isinstance(error, SessionHelperStartError) and (
|
||||
detail_suggests_remote_file_missing(error.detail)
|
||||
):
|
||||
return OpenFileResult(
|
||||
outcome=OpenOutcome.REMOTE_NOT_FOUND,
|
||||
local_cache_path=local_cache_path,
|
||||
detail=error.detail,
|
||||
)
|
||||
except InvalidRemoteRootError as error:
|
||||
return OpenFileResult(
|
||||
outcome=OpenOutcome.TRANSPORT_ERROR,
|
||||
local_cache_path=local_cache_path,
|
||||
detail=error.detail,
|
||||
detail=getattr(error, "detail", str(error)),
|
||||
)
|
||||
|
||||
open_req = OpenFileRequest(
|
||||
payload = _rust_file_open_transaction(
|
||||
host_alias=host_alias,
|
||||
remote_absolute_path=normalized,
|
||||
local_cache_path=local_cache_path,
|
||||
remote_metadata=read_result.metadata,
|
||||
)
|
||||
head_limit = limits.binary_probe_bytes
|
||||
content_head = (
|
||||
read_result.body[:head_limit] if read_result.body else read_result.body
|
||||
)
|
||||
opened = evaluate_open_file(
|
||||
open_req,
|
||||
content_head=content_head,
|
||||
guard_limits=limits,
|
||||
)
|
||||
if opened.outcome is not OpenOutcome.OK:
|
||||
return opened
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_bytes(read_result.body)
|
||||
return OpenFileResult(
|
||||
outcome=opened.outcome,
|
||||
local_cache_path=opened.local_cache_path,
|
||||
unsupported_reason=opened.unsupported_reason,
|
||||
detail=opened.detail,
|
||||
remote_metadata=read_result.metadata,
|
||||
max_open_bytes=limits.max_open_bytes,
|
||||
binary_probe_bytes=limits.binary_probe_bytes,
|
||||
allow_empty=limits.allow_empty_files,
|
||||
timeout_ms=int(read_timeout_s * 1000),
|
||||
)
|
||||
return _open_outcome_from_rust_dict(payload, local_cache_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
"""Thin SSH execution boundary between Sublime commands and remote operations.
|
||||
|
||||
This module centralizes non-interactive ``ssh`` subprocess invocations used for
|
||||
host probing, remote path checks, and directory listing before the Rust session
|
||||
helper exists. Call sites should stay limited to command orchestration; swap
|
||||
this layer for a helper-backed transport later without rewriting UX flows.
|
||||
host probing and connection preflight. Tree/file I/O and remote directory
|
||||
listing route through the Rust session helper (``local_bridge`` +
|
||||
``session_helper``); see ``ssh_file_transport.py`` and
|
||||
``python_interpreter_browser.py``.
|
||||
|
||||
The ``python3 -c`` literal that remains in this module is a *local* askpass
|
||||
GUI helper (it spawns Tk on the operator's workstation when the user typed in
|
||||
a passphrase). It does not run on the remote host and is not the
|
||||
boundary-document §17–19 fallback that Wave 1 closed.
|
||||
|
||||
Debug tracing:
|
||||
Set the environment variable ``SESSIONS_SSH_DEBUG`` to a non-empty value to
|
||||
print argv, exit code, and a stderr preview for each *failed* SSH run to
|
||||
``sys.stderr`` (visible in Sublime's Python console when running a dev
|
||||
build, or in CI logs).
|
||||
|
||||
Temporary bootstrap:
|
||||
Remote directory listing currently shells out to ``python3 -c`` on the
|
||||
remote host. That is bootstrap behavior; long-term listing should move onto
|
||||
the session helper protocol once stdio transport is wired from Sublime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -80,6 +80,12 @@ def test_connect_command_attaches_to_host_before_opening_remote_folder(
|
||||
assert commands._connected_host_alias(target_window) == "prod"
|
||||
assert ("new_window", {}) in source_window.window_commands
|
||||
assert ("sessions_open_remote_folder", {}) in target_window.window_commands
|
||||
# Sublime's ``new_window`` doesn't always claim OS-level z-order on
|
||||
# macOS — without an explicit ``bring_to_front`` the new window
|
||||
# opens behind the source window and the user sees no visible
|
||||
# change after connecting (Open-Remote-Folder quick panel ends up
|
||||
# behind their other Sublime window). Pin the call.
|
||||
assert target_window.bring_to_front_calls == 1
|
||||
assert (
|
||||
commands._remote_platform_store(SessionsSettings()).get("prod")
|
||||
== "linux-x86_64"
|
||||
@@ -125,8 +131,11 @@ def test_describe_ongoing_remote_connect_work_lists_inflight_and_queue(
|
||||
) -> None:
|
||||
settings = SessionsSettings(ssh_config_path=tmp_path / "cfg")
|
||||
try:
|
||||
with commands._CONNECT_PREEMPT_LOCK:
|
||||
commands._CONNECT_INFLIGHT = (1, "slow")
|
||||
# PR 16: connect generation/in-flight state lives in
|
||||
# sessions_native::orchestrator. Tests register inflight via
|
||||
# _rust_ffi instead of touching commands.py module-globals.
|
||||
token = commands._rust_ffi.bump_connect_generation()
|
||||
commands._rust_ffi.set_connect_inflight(token, "slow")
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.clear()
|
||||
commands._BACKGROUND_TASK_QUEUE.append(
|
||||
@@ -153,8 +162,7 @@ def test_describe_ongoing_remote_connect_work_lists_inflight_and_queue(
|
||||
finally:
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.clear()
|
||||
with commands._CONNECT_PREEMPT_LOCK:
|
||||
commands._CONNECT_INFLIGHT = (0, None)
|
||||
commands._rust_ffi.clear_connect_inflight_if(token)
|
||||
|
||||
|
||||
def test_connect_preempt_prunes_pending_host_connect_tasks(
|
||||
@@ -162,31 +170,25 @@ def test_connect_preempt_prunes_pending_host_connect_tasks(
|
||||
) -> None:
|
||||
monkeypatch.setattr(commands, "reset_bridge_for_host", lambda host: None)
|
||||
settings = SessionsSettings(ssh_config_path=tmp_path / "cfg")
|
||||
try:
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.clear()
|
||||
commands._BACKGROUND_TASK_QUEUE.append(
|
||||
(
|
||||
commands._connect_selected_host_async,
|
||||
(None, settings, "oldhost", 0),
|
||||
"_connect_selected_host_async",
|
||||
None,
|
||||
)
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.clear()
|
||||
commands._BACKGROUND_TASK_QUEUE.append(
|
||||
(
|
||||
commands._connect_selected_host_async,
|
||||
(None, settings, "oldhost", 0),
|
||||
"_connect_selected_host_async",
|
||||
None,
|
||||
)
|
||||
commands._BACKGROUND_TASK_QUEUE.append(
|
||||
(lambda: None, (), "other_task", None)
|
||||
)
|
||||
t1 = commands._preempt_connect_session_for_new_remote_request()
|
||||
t2 = commands._preempt_connect_session_for_new_remote_request()
|
||||
assert t2 == t1 + 1
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
assert len(commands._BACKGROUND_TASK_QUEUE) == 1
|
||||
assert commands._BACKGROUND_TASK_QUEUE[0][2] == "other_task"
|
||||
finally:
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.clear()
|
||||
with commands._CONNECT_PREEMPT_LOCK:
|
||||
commands._CONNECT_GENERATION = 0
|
||||
)
|
||||
commands._BACKGROUND_TASK_QUEUE.append((lambda: None, (), "other_task", None))
|
||||
t1 = commands._preempt_connect_session_for_new_remote_request()
|
||||
t2 = commands._preempt_connect_session_for_new_remote_request()
|
||||
assert t2 == t1 + 1
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
assert len(commands._BACKGROUND_TASK_QUEUE) == 1
|
||||
assert commands._BACKGROUND_TASK_QUEUE[0][2] == "other_task"
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.clear()
|
||||
|
||||
|
||||
def test_connect_preempt_resets_bridge_for_superseded_inflight_host(
|
||||
@@ -194,17 +196,14 @@ def test_connect_preempt_resets_bridge_for_superseded_inflight_host(
|
||||
) -> None:
|
||||
resets: List[str] = []
|
||||
monkeypatch.setattr(commands, "reset_bridge_for_host", lambda h: resets.append(h))
|
||||
try:
|
||||
with commands._CONNECT_PREEMPT_LOCK:
|
||||
commands._CONNECT_GENERATION = 1
|
||||
commands._CONNECT_INFLIGHT = (1, "slow-host")
|
||||
token = commands._preempt_connect_session_for_new_remote_request()
|
||||
assert token == 2
|
||||
assert resets == ["slow-host"]
|
||||
finally:
|
||||
with commands._CONNECT_PREEMPT_LOCK:
|
||||
commands._CONNECT_GENERATION = 0
|
||||
commands._CONNECT_INFLIGHT = (0, None)
|
||||
# Capture the current generation before we set up the in-flight slot
|
||||
# so the assert below compares against the right baseline (the Rust
|
||||
# singleton is process-wide and may carry state from earlier tests).
|
||||
seed_token = commands._rust_ffi.bump_connect_generation()
|
||||
commands._rust_ffi.set_connect_inflight(seed_token, "slow-host")
|
||||
token = commands._preempt_connect_session_for_new_remote_request()
|
||||
assert token == seed_token + 1
|
||||
assert resets == ["slow-host"]
|
||||
|
||||
|
||||
def test_connect_selected_host_probes_platform_before_bridge(
|
||||
@@ -403,9 +402,11 @@ def test_open_remote_terminal_opens_transient_terminus_pane(
|
||||
) -> None:
|
||||
"""Resolves workspace alias + remote root and dispatches to ``terminus_open``.
|
||||
|
||||
The terminal is intentionally transient: ``auto_close=True`` so the pane
|
||||
closes when the shell exits, no view-reuse cache, no tmux. For long-lived
|
||||
or tmux-heavy workflows the user runs an external terminal themselves.
|
||||
``auto_close=False`` so an unexpected shell exit (dotfile breakage,
|
||||
vanished remote root, SSH disconnect) leaves the pane visible with the
|
||||
exit message instead of flash-closing. No view-reuse cache, no tmux.
|
||||
For long-lived or tmux-heavy workflows the user runs an external
|
||||
terminal themselves.
|
||||
"""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
_write_ssh_config(ssh_config_path, "Host prod\n HostName prod.example.com\n")
|
||||
@@ -444,15 +445,31 @@ def test_open_remote_terminal_opens_transient_terminus_pane(
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
args = terminus_calls[0][1]
|
||||
# ``-il`` forces interactive + login so neither bash nor zsh falls
|
||||
# back to non-interactive "exit at EOF" semantics if the pty
|
||||
# handshake is racy. ``;`` not ``&&`` so a failed ``cd`` doesn't
|
||||
# take the shell down with it. The earlier ``</dev/tty`` redirect
|
||||
# prefix was dropped — it confused interactive zsh on some macOS →
|
||||
# Linux setups (``zsh: bad option: -/``).
|
||||
assert args["cmd"] == [
|
||||
"ssh",
|
||||
"-t",
|
||||
"prod",
|
||||
"cd /srv/app && exec ${SHELL:-/bin/sh} -l",
|
||||
"cd /srv/app; exec ${SHELL:-/bin/sh} -il",
|
||||
]
|
||||
assert args["auto_close"] is True
|
||||
# ``auto_close=False`` so an unexpected shell exit (dotfile error,
|
||||
# missing remote root, SSH drop) keeps the pane visible long enough
|
||||
# for the user to read the exit message. Costs one Ctrl+W on a
|
||||
# normal ``exit`` — worth it for the broken-path UX.
|
||||
assert args["auto_close"] is False
|
||||
assert args["title"] == "ssh prod:/srv/app"
|
||||
assert "cwd" in args
|
||||
# ``panel_name`` makes Terminus dock the shell as a bottom panel.
|
||||
# Without it Terminus opens the SSH session as a new tab in the
|
||||
# editor pane group, which displaces the user's open files. Pin
|
||||
# the well-known panel name so successive invocations reuse one
|
||||
# slot instead of stacking.
|
||||
assert args["panel_name"] == "Sessions Terminus"
|
||||
assert any("opening terminal for prod:/srv/app" in m for m in status_messages)
|
||||
|
||||
|
||||
@@ -997,3 +1014,36 @@ def test_connect_command_reloads_ssh_config_each_run(
|
||||
|
||||
assert window.quick_panels[0] == [["prod", "prod.example.com"]]
|
||||
assert window.quick_panels[1] == [["stage", "stage.example.com"]]
|
||||
|
||||
|
||||
def test_preempt_connect_clears_pending_task_keys() -> None:
|
||||
"""Regression: preempt drains queued connect entries and clears their pending key.
|
||||
|
||||
Earlier code called `set.disciscard()` (typo) on the pending-key set; the
|
||||
resulting AttributeError aborted the queue prune mid-iteration, so a stale
|
||||
key stayed in `_BACKGROUND_PENDING_KEYS` and blocked the next equivalent
|
||||
task from being scheduled.
|
||||
"""
|
||||
task_key = "connect:test-host"
|
||||
inner_args = ("dummy_input_id", 0, "test-host")
|
||||
entry = (commands._connect_selected_host_async, inner_args, "label", task_key)
|
||||
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.append(entry)
|
||||
commands._BACKGROUND_PENDING_KEYS.add(task_key)
|
||||
|
||||
try:
|
||||
commands._preempt_connect_session_for_new_remote_request()
|
||||
|
||||
assert task_key not in commands._BACKGROUND_PENDING_KEYS
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
remaining = [
|
||||
e
|
||||
for e in commands._BACKGROUND_TASK_QUEUE
|
||||
if e[0] is commands._connect_selected_host_async
|
||||
]
|
||||
assert remaining == []
|
||||
finally:
|
||||
with commands._BACKGROUND_TASK_LOCK:
|
||||
commands._BACKGROUND_TASK_QUEUE.clear()
|
||||
commands._BACKGROUND_PENDING_KEYS.discard(task_key)
|
||||
|
||||
@@ -5,12 +5,9 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from conftest import FakeView, FakeWindow
|
||||
from conftest import FakeWindow
|
||||
from sessions import commands
|
||||
from sessions.file_state import (
|
||||
OpenFileResult,
|
||||
OpenOutcome,
|
||||
)
|
||||
from sessions.file_state import OpenFileResult, OpenOutcome
|
||||
from sessions.recent_state import RecentWorkspace, RecentWorkspaceIndex
|
||||
from sessions.remote import RemoteDirectoryEntry, RemoteFileKind, RemoteFileMetadata
|
||||
from sessions.settings_model import SessionsSettings
|
||||
@@ -132,305 +129,3 @@ def test_open_remote_file_browses_remote_tree_before_materializing(
|
||||
|
||||
window.quick_panel_callbacks[0](3)
|
||||
assert opened["remote_file"] == "/srv/ws/a.py"
|
||||
|
||||
|
||||
def test_open_remote_tree_command_opens_selected_file(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_list_remote_directory",
|
||||
lambda host_alias, remote_directory: (
|
||||
RemoteDirectoryEntry(
|
||||
name="pkg",
|
||||
remote_absolute_path="/srv/ws/pkg",
|
||||
kind=RemoteFileKind.DIRECTORY,
|
||||
),
|
||||
RemoteDirectoryEntry(
|
||||
name="a.py",
|
||||
remote_absolute_path="/srv/ws/a.py",
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
),
|
||||
),
|
||||
)
|
||||
opened = {}
|
||||
|
||||
def fake_open(window, context, remote_file, **kwargs):
|
||||
_ = (window, context, kwargs)
|
||||
opened["remote_file"] = remote_file
|
||||
|
||||
monkeypatch.setattr(commands, "_open_remote_file_for_workspace", fake_open)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTreeCommand(window).run()
|
||||
tree_view = window.created_views[-1]
|
||||
tree_view.selected_row_value = 7
|
||||
commands.SessionsRemoteTreeOpenSelectionCommand(window).run()
|
||||
assert opened["remote_file"] == "/srv/ws/a.py"
|
||||
|
||||
|
||||
def test_open_remote_directory_explorer_applies_layout_and_focuses_group_zero(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_list_remote_directory",
|
||||
lambda host_alias, remote_directory: (
|
||||
RemoteDirectoryEntry(
|
||||
name="a.py",
|
||||
remote_absolute_path="/srv/ws/a.py",
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
),
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteDirectoryExplorerCommand(window).run()
|
||||
|
||||
layout_entry = ("set_layout", commands._REMOTE_DIRECTORY_EXPLORER_LAYOUT)
|
||||
assert layout_entry in window.window_commands
|
||||
assert window.focus_group_calls and window.focus_group_calls[0] == 0
|
||||
tree_view = window.created_views[-1]
|
||||
assert tree_view.settings().get("sessions_remote_tree_editor_group") == 1
|
||||
|
||||
|
||||
def test_remote_directory_explorer_creates_tree_in_group_zero_when_editor_was_focused(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Regression: new_file must not open the tree in the wide editor column."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_list_remote_directory",
|
||||
lambda host_alias, remote_directory: (
|
||||
RemoteDirectoryEntry(
|
||||
name="a.py",
|
||||
remote_absolute_path="/srv/ws/a.py",
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
),
|
||||
),
|
||||
)
|
||||
decoy = FakeView()
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}},
|
||||
active_view=decoy,
|
||||
)
|
||||
window._view_index[id(decoy)] = (1, 0)
|
||||
window._focused_group = 1
|
||||
decoy.window_value = window
|
||||
|
||||
commands.SessionsOpenRemoteDirectoryExplorerCommand(window).run()
|
||||
|
||||
tree_view = window.created_views[-1]
|
||||
assert window._view_index[id(tree_view)][0] == 0
|
||||
|
||||
|
||||
def test_explorer_tree_opens_remote_file_into_editor_group(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_list_remote_directory",
|
||||
lambda host_alias, remote_directory: (
|
||||
RemoteDirectoryEntry(
|
||||
name="a.py",
|
||||
remote_absolute_path="/srv/ws/a.py",
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def fake_open(host_alias: str, remote_absolute_path: str, local_cache_path: Path):
|
||||
_ = host_alias
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("hello", encoding="utf-8")
|
||||
return OpenFileResult(
|
||||
outcome=OpenOutcome.OK,
|
||||
local_cache_path=local_cache_path,
|
||||
remote_metadata=RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=5,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "open_remote_file_into_local_cache", fake_open)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteDirectoryExplorerCommand(window).run()
|
||||
tree_view = window.created_views[-1]
|
||||
# Row 5 is ``../`` after the fixed header; the file entry is on the next line.
|
||||
tree_view.selected_row_value = 6
|
||||
commands.SessionsRemoteTreeOpenSelectionCommand(window).run()
|
||||
|
||||
expected_path = tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "a.py"
|
||||
assert expected_path.is_file()
|
||||
assert window.window_commands[-1] == (
|
||||
"open_file",
|
||||
{"file": str(expected_path), "group": 1},
|
||||
)
|
||||
|
||||
|
||||
def test_close_remote_file_command_closes_matching_cache_view_from_tree(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
cache_file = tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "a.py"
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text("x", encoding="utf-8")
|
||||
|
||||
tree_view = FakeView()
|
||||
tree_view.settings().set("sessions_remote_tree", True)
|
||||
tree_view.settings().set("sessions_remote_tree_workspace_key", "cache-123")
|
||||
tree_view.settings().set("sessions_remote_tree_directory", "/srv/ws")
|
||||
tree_view.settings().set(
|
||||
"sessions_remote_tree_entries",
|
||||
[
|
||||
{
|
||||
"label": "a.py",
|
||||
"action": "open",
|
||||
"remote_path": "/srv/ws/a.py",
|
||||
},
|
||||
],
|
||||
)
|
||||
tree_view.settings().set("sessions_remote_tree_start_row", 5)
|
||||
tree_view.selected_row_value = 5
|
||||
tree_view.window_value = None
|
||||
|
||||
file_view = FakeView(file_name=str(cache_file))
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}},
|
||||
active_view=tree_view,
|
||||
)
|
||||
window.created_views.extend([tree_view, file_view])
|
||||
window._view_index[id(tree_view)] = (0, 0)
|
||||
window._view_index[id(file_view)] = (1, 0)
|
||||
tree_view.window_value = window
|
||||
file_view.window_value = window
|
||||
|
||||
commands.SessionsCloseRemoteFileCommand(window).run()
|
||||
|
||||
assert file_view.closed is True
|
||||
|
||||
|
||||
def test_close_remote_file_command_closes_active_cache_buffer(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
cache_file = tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "b.py"
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text("y", encoding="utf-8")
|
||||
|
||||
file_view = FakeView(file_name=str(cache_file))
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}},
|
||||
active_view=file_view,
|
||||
)
|
||||
file_view.window_value = window
|
||||
|
||||
commands.SessionsCloseRemoteFileCommand(window).run()
|
||||
|
||||
assert file_view.closed is True
|
||||
|
||||
@@ -487,47 +487,6 @@ def test_sync_remote_tree_skips_shallow_when_fast_sync_disabled(
|
||||
assert mirror_depths == [5]
|
||||
|
||||
|
||||
def test_remove_sidebar_mirror_folder_command(tmp_path: Path, monkeypatch) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
cache_root = tmp_path / "cache" / "Sessions" / "cache" / "cache-123"
|
||||
cache_root.mkdir(parents=True, exist_ok=True)
|
||||
other_dir = tmp_path / "other"
|
||||
other_dir.mkdir()
|
||||
pdata: Dict[str, object] = {
|
||||
"settings": {PROJECT_SETTINGS_KEY: "cache-123"},
|
||||
"folders": [
|
||||
{"path": str(cache_root.resolve()), "name": "Sessions"},
|
||||
{"path": str(other_dir.resolve()), "name": "Other"},
|
||||
],
|
||||
}
|
||||
window = FakeWindow(project_data=pdata)
|
||||
commands.SessionsRemoveSidebarMirrorFolderCommand(window).run()
|
||||
final = window.set_project_data_calls[-1]
|
||||
paths = {
|
||||
f.get("path")
|
||||
for f in final.get("folders", [])
|
||||
if isinstance(f, dict) and f.get("path")
|
||||
}
|
||||
assert str(cache_root.resolve()) not in paths
|
||||
assert str(other_dir.resolve()) in paths
|
||||
|
||||
|
||||
def test_workspace_activation_listener_primes_refresh_once(monkeypatch) -> None:
|
||||
window = FakeWindow()
|
||||
view = FakeView()
|
||||
@@ -647,6 +606,10 @@ def test_hydrate_precheck_error_skips_read_for_active_view(
|
||||
def test_hydrate_schedule_sets_path_scoped_task_key(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""v0.7.30 reverts hydrate-on-open back to the shared background
|
||||
queue (single worker, sequential dispatch) — v0.7.29's per-view
|
||||
thread spawning crashed on rapid tab-switching due to concurrent
|
||||
Sublime View API calls. The queue's ``task_key`` dedup is back."""
|
||||
context = commands._WorkspaceContext(
|
||||
settings=SessionsSettings(),
|
||||
recent_entry=RecentWorkspace(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
@@ -15,8 +14,6 @@ from sessions.recent_state import RecentWorkspace, RecentWorkspaceIndex
|
||||
from sessions.remote import (
|
||||
RemoteFileKind,
|
||||
RemoteFileMetadata,
|
||||
RemoteReadFileResult,
|
||||
RemoteWriteErrorCode,
|
||||
RemoteWriteFileResult,
|
||||
RunTrigger,
|
||||
ToolExecutionRequest,
|
||||
@@ -165,227 +162,6 @@ def test_remote_cached_file_save_listener_pushes_after_local_save(
|
||||
assert pushed == [("/srv/ws/pkg/a.py", view)]
|
||||
|
||||
|
||||
def test_save_remote_file_writes_using_cached_baseline(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: RemoteWriteFileResult(
|
||||
ok=True,
|
||||
updated_metadata=RemoteFileMetadata(
|
||||
mtime_ns=2,
|
||||
size_bytes=len(request.content),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_schedule_format_then_pipeline_after_cache_push",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
saved_meta = commands._read_remote_metadata_sidecar(local_cache_path)
|
||||
assert saved_meta is not None
|
||||
assert saved_meta.mtime_ns == 2
|
||||
msg = status_messages[-1]
|
||||
assert "Sessions ready:" in msg
|
||||
assert "/srv/ws/pkg/a.py" in msg
|
||||
|
||||
|
||||
def test_save_remote_file_creates_brand_new_file_without_baseline(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A buffer the user just saved into the cache mirror has no metadata
|
||||
sidecar yet — and the remote target may also not exist yet (the user
|
||||
might have just created the folder via Sublime's New Folder + saved a
|
||||
new file inside it). The save flow must treat this as a first-time
|
||||
create: hand a ``None`` ``expected_remote_metadata`` to the bridge so
|
||||
the Rust ``Missing`` precondition path fires (mkdir-p + write), then
|
||||
write the resulting metadata as the first sidecar entry.
|
||||
"""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-new",
|
||||
"2026-04-26T10:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-new" / "scratch" / "fresh.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("# brand new\n", encoding="utf-8")
|
||||
# Deliberately NO sidecar — that's what makes this the new-file case.
|
||||
assert commands._read_remote_metadata_sidecar(local_cache_path) is None
|
||||
|
||||
captured: List[Tuple[str, object]] = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: None,
|
||||
)
|
||||
|
||||
def _fake_write(host_alias, request) -> RemoteWriteFileResult:
|
||||
captured.append(("write", request))
|
||||
return RemoteWriteFileResult(
|
||||
ok=True,
|
||||
updated_metadata=RemoteFileMetadata(
|
||||
mtime_ns=42,
|
||||
size_bytes=len(request.content),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_write_file", _fake_write)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_schedule_format_then_pipeline_after_cache_push",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-new"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="scratch/fresh.py")
|
||||
|
||||
write_calls = [c for c in captured if c[0] == "write"]
|
||||
assert len(write_calls) == 1, "expected exactly one bridge write"
|
||||
request = write_calls[0][1]
|
||||
assert request.remote_absolute_path == "/srv/ws/scratch/fresh.py"
|
||||
assert request.expected_remote_metadata is None, (
|
||||
"Missing precondition signals first-time create to the helper"
|
||||
)
|
||||
saved_meta = commands._read_remote_metadata_sidecar(local_cache_path)
|
||||
assert saved_meta is not None and saved_meta.mtime_ns == 42, (
|
||||
"successful write must seed the sidecar so future saves "
|
||||
"go through the conflict-evaluator path"
|
||||
)
|
||||
assert any("Sessions ready" in msg for msg in status_messages)
|
||||
|
||||
|
||||
def test_save_remote_file_refuses_blind_overwrite_of_unfetched_remote(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""No sidecar AND remote already exists → conservative refusal. The user
|
||||
might be about to clobber a file they have never seen; the right move
|
||||
is to ask them to open the remote file first so a baseline lands."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-conflict",
|
||||
"2026-04-26T10:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-conflict" / "x.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("# local\n", encoding="utf-8")
|
||||
assert commands._read_remote_metadata_sidecar(local_cache_path) is None
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=99,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
write_calls: List[object] = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: write_calls.append(request),
|
||||
)
|
||||
window = FakeWindow(
|
||||
project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-conflict"}}
|
||||
)
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="x.py")
|
||||
|
||||
assert write_calls == [], "must NOT silently overwrite an unfetched remote"
|
||||
assert any("already exists" in msg for msg in status_messages), (
|
||||
"user must see the refusal hint with a 'open it first' suggestion"
|
||||
)
|
||||
|
||||
|
||||
def test_save_remote_file_for_workspace_schedules_ruff_format_when_lsp_format_on_save(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
@@ -561,76 +337,6 @@ def test_save_remote_file_for_workspace_skips_format_without_lsp_flag(
|
||||
assert scheduled == [("/srv/ws/pkg/a.py", False)]
|
||||
|
||||
|
||||
def test_save_remote_file_skips_upload_when_digest_matches_last_push(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = b"print('save')\n"
|
||||
local_cache_path.write_bytes(body)
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=len(body),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
last_pushed_sha256=digest,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=len(body),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
writes: List[object] = []
|
||||
|
||||
def capture_write(host_alias, request):
|
||||
writes.append((host_alias, request))
|
||||
return RemoteWriteFileResult(ok=False)
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_write_file", capture_write)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_maybe_schedule_remote_python_pipeline_after_cache_push",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert writes == []
|
||||
assert "skipped upload" in status_messages[-1].lower()
|
||||
|
||||
|
||||
def test_read_remote_metadata_sidecar_supports_legacy_filename(tmp_path: Path) -> None:
|
||||
local_cache_path = tmp_path / "cache-123" / "pkg" / "a.py"
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -670,589 +376,6 @@ def test_remove_local_cache_mirror_path_removes_legacy_and_hidden_sidecar(
|
||||
assert not legacy_side.exists()
|
||||
|
||||
|
||||
def test_save_remote_file_reports_conflicts(tmp_path: Path, monkeypatch) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=9,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert len(window.quick_panels) == 1, "conflict should show quick panel"
|
||||
items = window.quick_panels[0]
|
||||
labels = [row[0] for row in items]
|
||||
assert "Overwrite remote" in labels
|
||||
assert "Reload from remote" in labels
|
||||
assert "Cancel" in labels
|
||||
|
||||
|
||||
def test_save_conflict_overwrite_writes_remote(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Choosing 'Overwrite remote' in the conflict panel should force-write."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('local')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
newer_remote = RemoteFileMetadata(
|
||||
mtime_ns=9, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: newer_remote,
|
||||
)
|
||||
written_requests: list = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: (
|
||||
written_requests.append(request)
|
||||
or RemoteWriteFileResult(
|
||||
ok=True,
|
||||
updated_metadata=RemoteFileMetadata(
|
||||
mtime_ns=20,
|
||||
size_bytes=15,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](0)
|
||||
assert len(written_requests) == 1
|
||||
msg = status_messages[-1]
|
||||
assert "Overwritten" in msg
|
||||
|
||||
|
||||
def test_save_conflict_cancel_does_nothing(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Choosing 'Cancel' in the conflict panel should emit a warning only."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('local')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=9, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](2)
|
||||
msg = status_messages[-1]
|
||||
assert "cancelled" in msg
|
||||
|
||||
|
||||
def test_save_conflict_reload_downloads_remote(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Choosing 'Reload from remote' should download remote content and revert."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('old local')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
newer_meta = RemoteFileMetadata(
|
||||
mtime_ns=9, size_bytes=20, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: newer_meta,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_read_file",
|
||||
lambda host_alias, request: RemoteReadFileResult(
|
||||
metadata=newer_meta,
|
||||
body=b"print('new remote')\n",
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](1)
|
||||
assert local_cache_path.read_bytes() == b"print('new remote')\n"
|
||||
msg = status_messages[-1]
|
||||
assert "Reloaded" in msg
|
||||
|
||||
|
||||
def test_save_conflict_overwrite_transport_error(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Transport failure during forced overwrite should show disconnected status."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod", "/srv/ws", "cache-123", "2026-04-12T03:00:00+00:00"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("x\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=2, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda h, p: RemoteFileMetadata(
|
||||
mtime_ns=9, size_bytes=2, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda h, req: RemoteWriteFileResult(
|
||||
ok=False,
|
||||
error_code=RemoteWriteErrorCode.TRANSPORT_ERROR,
|
||||
error_message="pipe broken",
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
window.quick_panel_callbacks[0](0)
|
||||
|
||||
msg = status_messages[-1]
|
||||
assert "disconnected" in msg.lower() or "pipe broken" in msg
|
||||
|
||||
|
||||
def test_save_remote_file_reports_permission_denied(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: RemoteWriteFileResult(
|
||||
ok=False,
|
||||
error_code=RemoteWriteErrorCode.PERMISSION_DENIED,
|
||||
error_message="Permission denied",
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
msg = status_messages[-1]
|
||||
assert "Sessions warning:" in msg
|
||||
assert "Permission denied" in msg
|
||||
|
||||
|
||||
def test_save_remote_file_reports_remote_missing(tmp_path: Path, monkeypatch) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: None,
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
msg = status_messages[-1]
|
||||
assert "Sessions warning:" in msg
|
||||
assert "disappeared" in msg
|
||||
|
||||
|
||||
def test_save_remote_file_reports_transport_error(tmp_path: Path, monkeypatch) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: RemoteWriteFileResult(
|
||||
ok=False,
|
||||
error_code=RemoteWriteErrorCode.TRANSPORT_ERROR,
|
||||
error_message="Remote file write failed for /srv/ws/pkg/a.py.",
|
||||
),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
msg = status_messages[-1]
|
||||
assert msg.startswith("Sessions disconnected:")
|
||||
assert "Remote file write failed" in msg
|
||||
assert "/srv/ws/pkg/a.py" in msg
|
||||
|
||||
|
||||
# --- Save conflict race / edge case tests ---
|
||||
|
||||
|
||||
def test_save_conflict_cancel_negative_index_does_nothing(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Pressing Escape (idx=-1) on conflict panel should cancel silently."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod", "/srv/ws", "cache-123", "2026-04-12T03:00:00+00:00"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("conflict\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=9, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda h, p: RemoteFileMetadata(
|
||||
mtime_ns=99, size_bytes=9, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
write_calls: list = []
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda h, req: write_calls.append(req),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](-1)
|
||||
assert write_calls == [], "cancel should not trigger remote write"
|
||||
assert any("cancelled" in m.lower() for m in status_messages)
|
||||
|
||||
|
||||
def test_save_conflict_reload_failure_preserves_dirty_buffer(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""If reload from remote fails, local cache file should stay untouched."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod", "/srv/ws", "cache-123", "2026-04-12T03:00:00+00:00"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-123" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
original_content = "my local edits\n"
|
||||
local_cache_path.write_text(original_content, encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=12, kind=RemoteFileKind.REGULAR_FILE, unix_mode=33188
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda h, p: RemoteFileMetadata(
|
||||
mtime_ns=99,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
from sessions.connect_preflight import SessionHelperStartError
|
||||
|
||||
def read_fails(host, request):
|
||||
raise SessionHelperStartError("Network timeout during reload")
|
||||
|
||||
monkeypatch.setattr(commands, "execute_remote_read_file", read_fails)
|
||||
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
window.quick_panel_callbacks[0](1) # "Reload from remote"
|
||||
|
||||
assert local_cache_path.read_text(encoding="utf-8") == original_content
|
||||
assert any("disconnected" in m.lower() for m in status_messages)
|
||||
|
||||
|
||||
def test_remote_python_pipeline_listener_skips_post_save_when_cache_push_pending(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -1718,78 +841,6 @@ def test_run_format_then_pipeline_async_runs_source_actions_before_format(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_marks_remote_path_as_self_save_for_cooldown(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""The save path stamps the remote path so the watch echo gets ignored."""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda *a, **k: None)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-d1",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
local_cache_path = (
|
||||
tmp_path / "cache" / "Sessions" / "cache" / "cache-d1" / "pkg" / "a.py"
|
||||
)
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_text("print('save')\n", encoding="utf-8")
|
||||
commands._write_remote_metadata_sidecar(
|
||||
local_cache_path,
|
||||
RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_stat_file",
|
||||
lambda host_alias, remote_absolute_path: RemoteFileMetadata(
|
||||
mtime_ns=1,
|
||||
size_bytes=12,
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"execute_remote_write_file",
|
||||
lambda host_alias, request: RemoteWriteFileResult(
|
||||
ok=True,
|
||||
updated_metadata=RemoteFileMetadata(
|
||||
mtime_ns=2,
|
||||
size_bytes=len(request.content),
|
||||
kind=RemoteFileKind.REGULAR_FILE,
|
||||
unix_mode=33188,
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_schedule_format_then_pipeline_after_cache_push",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
commands._RECENT_SELF_SAVE_REMOTE_PATHS.clear()
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-d1"}})
|
||||
|
||||
commands.SessionsSaveRemoteFileCommand(window).run(remote_file="pkg/a.py")
|
||||
|
||||
assert commands._is_recent_self_save("/srv/ws/pkg/a.py")
|
||||
|
||||
|
||||
def test_reload_changed_remote_views_filters_self_save_echo(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -285,6 +285,48 @@ def test_mirror_auto_refresh_with_settings(sublime_settings) -> None:
|
||||
assert commands._mirror_auto_refresh_enabled() is False
|
||||
|
||||
|
||||
def test_mirror_auto_refresh_safe_mode_forces_off(sublime_settings) -> None:
|
||||
# safe sync mode overrides any per-key True; quiet first connect.
|
||||
sublime_settings(
|
||||
{
|
||||
"sessions_sync_mode": "safe",
|
||||
"sessions_mirror_auto_refresh": True,
|
||||
}
|
||||
)
|
||||
assert commands._mirror_auto_refresh_enabled() is False
|
||||
|
||||
|
||||
def test_connect_auto_open_safe_mode_forces_off(sublime_settings) -> None:
|
||||
sublime_settings(
|
||||
{
|
||||
"sessions_sync_mode": "safe",
|
||||
"sessions_connect_auto_open_remote_folder": True,
|
||||
}
|
||||
)
|
||||
assert commands._connect_auto_open_remote_folder() is False
|
||||
|
||||
|
||||
def test_mirror_options_safe_mode_clears_include_files(sublime_settings) -> None:
|
||||
sublime_settings(
|
||||
{
|
||||
"sessions_sync_mode": "safe",
|
||||
"sessions_mirror_include_files": True,
|
||||
}
|
||||
)
|
||||
opts = commands._mirror_options_from_sublime_settings()
|
||||
assert opts.include_files is False
|
||||
|
||||
|
||||
def test_mirror_auto_refresh_balanced_mode_passes_through(sublime_settings) -> None:
|
||||
sublime_settings(
|
||||
{
|
||||
"sessions_sync_mode": "balanced",
|
||||
"sessions_mirror_auto_refresh": True,
|
||||
}
|
||||
)
|
||||
assert commands._mirror_auto_refresh_enabled() is True
|
||||
|
||||
|
||||
def test_mirror_auto_refresh_interval_with_settings(sublime_settings) -> None:
|
||||
sublime_settings({"sessions_mirror_auto_refresh_interval_seconds": 30})
|
||||
assert commands._mirror_auto_refresh_interval_ms() == 30_000
|
||||
@@ -388,6 +430,96 @@ def test_effective_sessions_settings_for_remote_python(
|
||||
assert isinstance(settings, SessionsSettings)
|
||||
|
||||
|
||||
def test_effective_settings_project_overrides_user_for_on_save(
|
||||
sublime_settings,
|
||||
) -> None:
|
||||
"""``.sublime-project`` ``settings`` block beats user setting (LSP-style)."""
|
||||
sublime_settings({"sessions_remote_python_auto_diagnostics_on_save": False})
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {
|
||||
"sessions_remote_python_auto_diagnostics_on_save": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
settings = commands._effective_sessions_settings_for_remote_python(window)
|
||||
|
||||
assert settings.remote_python_auto_diagnostics_on_save is True
|
||||
|
||||
|
||||
def test_effective_settings_user_wins_when_project_lacks_key(
|
||||
sublime_settings,
|
||||
) -> None:
|
||||
"""Missing project key falls through to user/default precedence."""
|
||||
sublime_settings({"sessions_remote_python_auto_diagnostics_on_save": True})
|
||||
window = FakeWindow(project_data={"settings": {"unrelated": "x"}})
|
||||
|
||||
settings = commands._effective_sessions_settings_for_remote_python(window)
|
||||
|
||||
assert settings.remote_python_auto_diagnostics_on_save is True
|
||||
|
||||
|
||||
def test_effective_settings_project_pipeline_overrides_user(
|
||||
sublime_settings,
|
||||
) -> None:
|
||||
"""Project ``sessions_remote_python_tool_pipeline`` replaces user value."""
|
||||
sublime_settings(
|
||||
{"sessions_remote_python_tool_pipeline": ["ruff_lint", "pyright_check"]},
|
||||
)
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {
|
||||
"sessions_remote_python_tool_pipeline": ["ruff_lint"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
settings = commands._effective_sessions_settings_for_remote_python(window)
|
||||
|
||||
assert settings.remote_python_tool_pipeline == ("ruff_lint",)
|
||||
|
||||
|
||||
def test_effective_settings_project_invalid_type_ignored(
|
||||
sublime_settings,
|
||||
) -> None:
|
||||
"""Non-bool project value for a bool key falls through to user setting."""
|
||||
sublime_settings({"sessions_remote_python_auto_diagnostics_on_save": True})
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {
|
||||
"sessions_remote_python_auto_diagnostics_on_save": "yes",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
settings = commands._effective_sessions_settings_for_remote_python(window)
|
||||
|
||||
# Wrong type is rejected → user setting wins.
|
||||
assert settings.remote_python_auto_diagnostics_on_save is True
|
||||
|
||||
|
||||
def test_effective_settings_no_project_data_safe(sublime_settings) -> None:
|
||||
"""Window with ``project_data() is None`` must not raise."""
|
||||
sublime_settings({})
|
||||
window = FakeWindow(project_data=None)
|
||||
|
||||
settings = commands._effective_sessions_settings_for_remote_python(window)
|
||||
|
||||
assert isinstance(settings, SessionsSettings)
|
||||
|
||||
|
||||
def test_effective_settings_no_window_skips_project_merge(
|
||||
sublime_settings,
|
||||
) -> None:
|
||||
"""Calling without ``window`` is the legacy global-only path."""
|
||||
sublime_settings({"sessions_remote_python_auto_diagnostics_on_save": True})
|
||||
|
||||
settings = commands._effective_sessions_settings_for_remote_python(None)
|
||||
|
||||
assert settings.remote_python_auto_diagnostics_on_save is True
|
||||
|
||||
|
||||
def test_interactive_ssh_lane_basic() -> None:
|
||||
commands._begin_interactive_ssh_lane("test-host-lane")
|
||||
commands._end_interactive_ssh_lane("test-host-lane")
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
"""Unit tests for :mod:`sessions.eager_hydrate`."""
|
||||
"""Unit tests for :mod:`sessions.eager_hydrate`.
|
||||
|
||||
Driver tests (``run_eager_hydrate``, ``batched``, ``EagerHydrateSummary``)
|
||||
were dropped at PR-B / PR 17 — the apply pass body now runs entirely in
|
||||
``sessions_native::eager_hydrate::run_apply_pass`` and is exercised by
|
||||
the Rust unit tests + integration smoke against
|
||||
``sessions_eager_hydrate_apply``. The Python side keeps the candidate
|
||||
discovery wrapper + settings normaliser, which are still tested below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
from typing import Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.eager_hydrate import (
|
||||
DEFAULT_BATCH_SIZE,
|
||||
DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
EagerHydrateSummary,
|
||||
batched,
|
||||
find_placeholder_candidates,
|
||||
normalize_eager_hydrate_basenames,
|
||||
run_eager_hydrate,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,169 +72,6 @@ def test_find_placeholder_candidates_returns_empty_when_allow_list_empty(
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_batched_yields_in_order_and_respects_size() -> None:
|
||||
items = [Path("a"), Path("b"), Path("c"), Path("d"), Path("e")]
|
||||
batches = list(batched(items, 2))
|
||||
assert batches == [
|
||||
[Path("a"), Path("b")],
|
||||
[Path("c"), Path("d")],
|
||||
[Path("e")],
|
||||
]
|
||||
|
||||
|
||||
def test_batched_collapses_nonpositive_size_to_one() -> None:
|
||||
items = [Path("a"), Path("b")]
|
||||
assert list(batched(items, 0)) == [[Path("a")], [Path("b")]]
|
||||
assert list(batched(items, -5)) == [[Path("a")], [Path("b")]]
|
||||
|
||||
|
||||
def test_run_eager_hydrate_fetches_all_placeholders(tmp_path: Path) -> None:
|
||||
_make_placeholder(tmp_path / "Cargo.toml")
|
||||
_make_placeholder(tmp_path / "sub" / "Cargo.lock")
|
||||
calls: List[Path] = []
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
calls.append(path)
|
||||
path.write_bytes(b"content")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml", "Cargo.lock"),
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary == EagerHydrateSummary(hydrated=2, skipped_existing=0, failed=0)
|
||||
assert sorted(calls) == sorted(
|
||||
[tmp_path / "Cargo.toml", tmp_path / "sub" / "Cargo.lock"]
|
||||
)
|
||||
|
||||
|
||||
def test_run_eager_hydrate_counts_failures_without_aborting(tmp_path: Path) -> None:
|
||||
good = tmp_path / "Cargo.toml"
|
||||
bad = tmp_path / "pyproject.toml"
|
||||
_make_placeholder(good)
|
||||
_make_placeholder(bad)
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
if path == bad:
|
||||
return False
|
||||
path.write_bytes(b"ok")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml", "pyproject.toml"),
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary.hydrated == 1
|
||||
assert summary.failed == 1
|
||||
assert summary.skipped_existing == 0
|
||||
|
||||
|
||||
def test_run_eager_hydrate_counts_raising_fetch_as_failure(tmp_path: Path) -> None:
|
||||
_make_placeholder(tmp_path / "Cargo.toml")
|
||||
|
||||
def fetch_fn(_path: Path) -> bool:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary == EagerHydrateSummary(hydrated=0, skipped_existing=0, failed=1)
|
||||
|
||||
|
||||
def test_run_eager_hydrate_skips_when_placeholder_already_filled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Two placeholders at enumeration time; while hydrating one, a concurrent
|
||||
# code path fills the other. Recheck inside ``run_eager_hydrate`` must
|
||||
# treat the now-non-empty peer as ``skipped_existing`` rather than
|
||||
# failing or re-fetching.
|
||||
first = tmp_path / "a" / "Cargo.toml"
|
||||
second = tmp_path / "b" / "Cargo.toml"
|
||||
_make_placeholder(first)
|
||||
_make_placeholder(second)
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
# Whichever placeholder runs first, clobber its sibling so the
|
||||
# sibling's recheck trips the ``skipped_existing`` branch regardless
|
||||
# of filesystem ordering.
|
||||
peer = second if path == first else first
|
||||
path.write_bytes(b"fetched body")
|
||||
peer.write_bytes(b"concurrent body")
|
||||
return True
|
||||
|
||||
# Batch size 8 forces both placeholders into one batch, so enumeration
|
||||
# completes before any fetch runs.
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
batch_size=8,
|
||||
batch_sleep_s=0,
|
||||
sleep_fn=lambda _s: None,
|
||||
)
|
||||
|
||||
assert summary.hydrated == 1
|
||||
assert summary.skipped_existing == 1
|
||||
assert summary.failed == 0
|
||||
|
||||
|
||||
def test_run_eager_hydrate_sleeps_between_batches_but_not_before_first(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
for i in range(5):
|
||||
_make_placeholder(tmp_path / "pkg{}".format(i) / "Cargo.toml")
|
||||
|
||||
sleeps: List[float] = []
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
path.write_bytes(b"x")
|
||||
return True
|
||||
|
||||
summary = run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
batch_size=2,
|
||||
batch_sleep_s=0.123,
|
||||
sleep_fn=lambda s: sleeps.append(s),
|
||||
)
|
||||
|
||||
assert summary.hydrated == 5
|
||||
# 5 items in batches of 2 => batches [2, 2, 1]; sleep fires before
|
||||
# batches 2 and 3, i.e. twice.
|
||||
assert sleeps == [0.123, 0.123]
|
||||
|
||||
|
||||
def test_run_eager_hydrate_skips_sleep_when_interval_zero(tmp_path: Path) -> None:
|
||||
for i in range(3):
|
||||
_make_placeholder(tmp_path / "pkg{}".format(i) / "Cargo.toml")
|
||||
sleeps: List[float] = []
|
||||
|
||||
def fetch_fn(path: Path) -> bool:
|
||||
path.write_bytes(b"x")
|
||||
return True
|
||||
|
||||
run_eager_hydrate(
|
||||
tmp_path,
|
||||
fetch_fn=fetch_fn,
|
||||
allowed_basenames=("Cargo.toml",),
|
||||
batch_size=1,
|
||||
batch_sleep_s=0.0,
|
||||
sleep_fn=lambda s: sleeps.append(s),
|
||||
)
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_default_batch_size_is_capped_low_enough_for_edr() -> None:
|
||||
# Documented batch size is 20 per spec; guard against silent bumps.
|
||||
assert DEFAULT_BATCH_SIZE == 20
|
||||
|
||||
125
sublime/tests/test_eager_hydrate_parity.py
Normal file
125
sublime/tests/test_eager_hydrate_parity.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Parity baseline for ``eager_hydrate`` BFS + apply pass.
|
||||
|
||||
Wave 1.5 amend §D paired parity test — PR 14 (BFS Rust 이관) +
|
||||
PR-B / PR 17 (apply pass body Rust 이관) baseline. After PR-B the
|
||||
batched/run_eager_hydrate driver lives entirely in
|
||||
``sessions_native::eager_hydrate::run_apply_pass`` (Rust unit-tested
|
||||
side); the Python parity baseline now pins:
|
||||
- ``find_placeholder_candidates`` boundary (size>0 ignored, basename
|
||||
case-sensitivity, nested traversal, cache_root is file).
|
||||
- ``normalize_eager_hydrate_basenames`` edge cases.
|
||||
- Default constants invariants used by Python wrappers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sessions.eager_hydrate import (
|
||||
DEFAULT_BATCH_SIZE,
|
||||
DEFAULT_BATCH_SLEEP_S,
|
||||
DEFAULT_EAGER_HYDRATE_BASENAMES,
|
||||
find_placeholder_candidates,
|
||||
normalize_eager_hydrate_basenames,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_placeholder_candidates boundaries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _touch(path: Path, size: int = 0) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"x" * size)
|
||||
|
||||
|
||||
def test_find_placeholder_skips_nonzero_size_files(tmp_path: Path) -> None:
|
||||
_touch(tmp_path / "Cargo.toml", size=1) # 1 byte → not a placeholder.
|
||||
_touch(tmp_path / "pyproject.toml", size=0)
|
||||
out = list(find_placeholder_candidates(tmp_path, ("Cargo.toml", "pyproject.toml")))
|
||||
assert [p.name for p in out] == ["pyproject.toml"]
|
||||
|
||||
|
||||
def test_find_placeholder_basename_match_is_case_sensitive(tmp_path: Path) -> None:
|
||||
_touch(tmp_path / "cargo.toml", size=0)
|
||||
_touch(tmp_path / "Cargo.toml", size=0)
|
||||
out = sorted(
|
||||
find_placeholder_candidates(tmp_path, ("Cargo.toml",)),
|
||||
key=lambda p: p.name,
|
||||
)
|
||||
assert [p.name for p in out] == ["Cargo.toml"]
|
||||
|
||||
|
||||
def test_find_placeholder_traverses_nested_directories(tmp_path: Path) -> None:
|
||||
_touch(tmp_path / "a" / "b" / "c" / "Cargo.toml", size=0)
|
||||
_touch(tmp_path / "a" / "b" / "package.json", size=0)
|
||||
out = list(find_placeholder_candidates(tmp_path, ("Cargo.toml", "package.json")))
|
||||
assert {p.name for p in out} == {"Cargo.toml", "package.json"}
|
||||
|
||||
|
||||
def test_find_placeholder_root_is_file_not_dir(tmp_path: Path) -> None:
|
||||
target = tmp_path / "not_a_dir"
|
||||
target.write_text("hello")
|
||||
out = list(find_placeholder_candidates(target, ("Cargo.toml",)))
|
||||
assert out == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_eager_hydrate_basenames edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_basenames_default_when_none() -> None:
|
||||
assert normalize_eager_hydrate_basenames(None) == DEFAULT_EAGER_HYDRATE_BASENAMES
|
||||
|
||||
|
||||
def test_normalize_basenames_empty_list_disables_hydrate() -> None:
|
||||
"""User can disable eager hydrate entirely with ``[]``."""
|
||||
assert normalize_eager_hydrate_basenames([]) == ()
|
||||
|
||||
|
||||
def test_normalize_basenames_dedupes_and_strips() -> None:
|
||||
raw = ["Cargo.toml", " Cargo.toml ", "package.json", "", " "]
|
||||
assert normalize_eager_hydrate_basenames(raw) == (
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_basenames_drops_non_string_entries() -> None:
|
||||
assert normalize_eager_hydrate_basenames(["x.toml", 42, None, "y.json"]) == (
|
||||
"x.toml",
|
||||
"y.json",
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_basenames_garbage_falls_back_to_default() -> None:
|
||||
assert (
|
||||
normalize_eager_hydrate_basenames({"key": "value"})
|
||||
== DEFAULT_EAGER_HYDRATE_BASENAMES
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level constants pin (Wave 1.5: PR 14가 같은 default 보존해야 함)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_default_batch_size_is_low_enough_for_edr_pacing() -> None:
|
||||
assert DEFAULT_BATCH_SIZE <= 32
|
||||
|
||||
|
||||
def test_default_batch_sleep_is_visibly_paced() -> None:
|
||||
assert DEFAULT_BATCH_SLEEP_S > 0.0
|
||||
assert DEFAULT_BATCH_SLEEP_S <= 1.0
|
||||
|
||||
|
||||
def test_default_basenames_contains_core_build_manifests() -> None:
|
||||
"""PR 14 (Rust 이관) 후에도 같은 set을 유지해야 한다."""
|
||||
core = {
|
||||
"Cargo.toml",
|
||||
"pyproject.toml",
|
||||
"package.json",
|
||||
"uv.lock",
|
||||
}
|
||||
assert core.issubset(set(DEFAULT_EAGER_HYDRATE_BASENAMES))
|
||||
301
sublime/tests/test_file_state_parity.py
Normal file
301
sublime/tests/test_file_state_parity.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Parity baseline for ``file_state.evaluate_open_file`` / ``evaluate_save_file``.
|
||||
|
||||
Wave 1.5 amend §D paired parity test PR — Python 본체의 *현재 동작*을
|
||||
fixture로 핀해서 PR 11 (kind_codes 통합 + decision 매핑 lookup table 이관)
|
||||
이 같은 결과를 반환하는지 보장한다.
|
||||
|
||||
기존 ``test_file_pipeline.py`` 7 시나리오를 보존하면서 +25 추가:
|
||||
- open guard (size, kind, binary head, zero-byte allow toggle, edge sizes).
|
||||
- save decision (각 decision_code 0–5 + kind_codes 4종 매트릭스 + boundary).
|
||||
|
||||
이관 PR(PR 11) 후에도 본 테스트는 *동일하게* 통과해야 한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sessions.file_state import (
|
||||
FileOpenGuardrails,
|
||||
OpenFileRequest,
|
||||
OpenOutcome,
|
||||
ReloadChoice,
|
||||
SaveConflictKind,
|
||||
SaveFileRequest,
|
||||
SaveOutcome,
|
||||
UnsupportedOpenReason,
|
||||
evaluate_open_file,
|
||||
evaluate_save_file,
|
||||
)
|
||||
from sessions.remote import RemoteFileKind, RemoteFileMetadata
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# evaluate_open_file — guard matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _open_request(tmp_path: Path, **md_kwargs) -> OpenFileRequest:
|
||||
md = RemoteFileMetadata(**{"mtime_ns": 1, "size_bytes": 4, **md_kwargs})
|
||||
return OpenFileRequest(
|
||||
remote_absolute_path="/r/w/a.txt",
|
||||
local_cache_path=tmp_path / "a.txt",
|
||||
remote_metadata=md,
|
||||
)
|
||||
|
||||
|
||||
def test_open_blocked_when_remote_is_directory(tmp_path: Path) -> None:
|
||||
req = _open_request(tmp_path, kind=RemoteFileKind.DIRECTORY, size_bytes=4096)
|
||||
res = evaluate_open_file(req, content_head=b"", guard_limits=FileOpenGuardrails())
|
||||
assert res.outcome is OpenOutcome.BLOCKED_BY_POLICY
|
||||
assert res.unsupported_reason is UnsupportedOpenReason.UNSUPPORTED_REMOTE_KIND
|
||||
|
||||
|
||||
def test_open_blocked_when_remote_is_symlink(tmp_path: Path) -> None:
|
||||
req = _open_request(tmp_path, kind=RemoteFileKind.SYMLINK, size_bytes=64)
|
||||
res = evaluate_open_file(req, content_head=b"", guard_limits=FileOpenGuardrails())
|
||||
assert res.outcome is OpenOutcome.BLOCKED_BY_POLICY
|
||||
assert res.unsupported_reason is UnsupportedOpenReason.UNSUPPORTED_REMOTE_KIND
|
||||
|
||||
|
||||
def test_open_blocked_when_size_exceeds_limit(tmp_path: Path) -> None:
|
||||
guard = FileOpenGuardrails(max_open_bytes=128)
|
||||
req = _open_request(tmp_path, size_bytes=1024)
|
||||
res = evaluate_open_file(req, content_head=b"text", guard_limits=guard)
|
||||
assert res.outcome is OpenOutcome.BLOCKED_BY_POLICY
|
||||
assert res.unsupported_reason is UnsupportedOpenReason.FILE_TOO_LARGE
|
||||
|
||||
|
||||
def test_open_ok_at_size_limit_boundary(tmp_path: Path) -> None:
|
||||
guard = FileOpenGuardrails(max_open_bytes=8)
|
||||
req = _open_request(tmp_path, size_bytes=8)
|
||||
res = evaluate_open_file(req, content_head=b"abcdefgh", guard_limits=guard)
|
||||
assert res.outcome is OpenOutcome.OK
|
||||
|
||||
|
||||
def test_open_blocked_zero_byte_when_disallowed(tmp_path: Path) -> None:
|
||||
guard = FileOpenGuardrails(allow_empty_files=False)
|
||||
req = _open_request(tmp_path, size_bytes=0)
|
||||
res = evaluate_open_file(req, content_head=b"", guard_limits=guard)
|
||||
assert res.outcome is OpenOutcome.BLOCKED_BY_POLICY
|
||||
assert res.unsupported_reason is UnsupportedOpenReason.ZERO_BYTE_READ_NOT_ALLOWED
|
||||
|
||||
|
||||
def test_open_ok_zero_byte_when_allowed(tmp_path: Path) -> None:
|
||||
guard = FileOpenGuardrails(allow_empty_files=True)
|
||||
req = _open_request(tmp_path, size_bytes=0)
|
||||
res = evaluate_open_file(req, content_head=b"", guard_limits=guard)
|
||||
assert res.outcome is OpenOutcome.OK
|
||||
|
||||
|
||||
def test_open_blocked_binary_with_nul_byte(tmp_path: Path) -> None:
|
||||
req = _open_request(tmp_path, size_bytes=8)
|
||||
res = evaluate_open_file(
|
||||
req, content_head=b"good\x00data", guard_limits=FileOpenGuardrails()
|
||||
)
|
||||
assert res.outcome is OpenOutcome.BLOCKED_BINARY_HEURISTIC
|
||||
|
||||
|
||||
def test_open_ok_with_high_ascii_no_nul(tmp_path: Path) -> None:
|
||||
req = _open_request(tmp_path, size_bytes=8)
|
||||
# 0x80 etc. without NUL — heuristic only flags NUL byte.
|
||||
res = evaluate_open_file(
|
||||
req, content_head=b"\x80\x81\x82text", guard_limits=FileOpenGuardrails()
|
||||
)
|
||||
assert res.outcome is OpenOutcome.OK
|
||||
|
||||
|
||||
def test_open_binary_probe_window_respected(tmp_path: Path) -> None:
|
||||
"""Bytes past ``binary_probe_bytes`` must not influence the heuristic."""
|
||||
guard = FileOpenGuardrails(binary_probe_bytes=4)
|
||||
req = _open_request(tmp_path, size_bytes=8)
|
||||
res = evaluate_open_file(req, content_head=b"text\x00more", guard_limits=guard)
|
||||
assert res.outcome is OpenOutcome.OK
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# evaluate_save_file — kind_codes matrix + decision_code 0..5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save_request(tmp_path: Path, *, baseline=None, candidate=None) -> SaveFileRequest:
|
||||
return SaveFileRequest(
|
||||
remote_absolute_path="/r/w/f.py",
|
||||
local_cache_path=tmp_path / "f.py",
|
||||
baseline_remote_metadata=baseline,
|
||||
candidate_remote_metadata=candidate,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind",
|
||||
[
|
||||
RemoteFileKind.REGULAR_FILE,
|
||||
RemoteFileKind.OTHER,
|
||||
],
|
||||
)
|
||||
def test_save_ok_when_metadata_matches_for_kind(
|
||||
tmp_path: Path, kind: RemoteFileKind
|
||||
) -> None:
|
||||
meta = RemoteFileMetadata(mtime_ns=42, size_bytes=128, kind=kind)
|
||||
res = evaluate_save_file(_save_request(tmp_path, baseline=meta, candidate=meta))
|
||||
assert res.outcome is SaveOutcome.OK
|
||||
|
||||
|
||||
def test_save_conflict_when_size_changed(tmp_path: Path) -> None:
|
||||
baseline = RemoteFileMetadata(mtime_ns=1, size_bytes=10)
|
||||
current = RemoteFileMetadata(mtime_ns=1, size_bytes=20)
|
||||
res = evaluate_save_file(
|
||||
_save_request(tmp_path, baseline=baseline, candidate=current)
|
||||
)
|
||||
assert res.conflict is not None
|
||||
assert res.conflict.kind is SaveConflictKind.REMOTE_METADATA_CHANGED
|
||||
|
||||
|
||||
def test_save_conflict_when_only_mtime_differs(tmp_path: Path) -> None:
|
||||
baseline = RemoteFileMetadata(mtime_ns=1, size_bytes=10)
|
||||
current = RemoteFileMetadata(mtime_ns=999, size_bytes=10)
|
||||
res = evaluate_save_file(
|
||||
_save_request(tmp_path, baseline=baseline, candidate=current)
|
||||
)
|
||||
assert res.conflict is not None
|
||||
assert res.conflict.kind is SaveConflictKind.REMOTE_METADATA_CHANGED
|
||||
assert (
|
||||
res.conflict.reload_choice_hint is ReloadChoice.KEEP_LOCAL_AND_OVERWRITE_REMOTE
|
||||
)
|
||||
|
||||
|
||||
def test_save_conflict_when_kind_changed_to_other(tmp_path: Path) -> None:
|
||||
baseline = RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=10, kind=RemoteFileKind.REGULAR_FILE
|
||||
)
|
||||
current = RemoteFileMetadata(mtime_ns=1, size_bytes=10, kind=RemoteFileKind.OTHER)
|
||||
res = evaluate_save_file(
|
||||
_save_request(tmp_path, baseline=baseline, candidate=current)
|
||||
)
|
||||
assert res.conflict is not None
|
||||
assert res.conflict.kind is SaveConflictKind.REMOTE_METADATA_CHANGED
|
||||
|
||||
|
||||
def test_save_conflict_when_path_became_symlink(tmp_path: Path) -> None:
|
||||
baseline = RemoteFileMetadata(mtime_ns=1, size_bytes=10)
|
||||
current = RemoteFileMetadata(mtime_ns=1, size_bytes=10, kind=RemoteFileKind.SYMLINK)
|
||||
res = evaluate_save_file(
|
||||
_save_request(tmp_path, baseline=baseline, candidate=current)
|
||||
)
|
||||
assert res.conflict is not None
|
||||
assert res.conflict.kind is SaveConflictKind.REMOTE_PATH_IS_SYMLINK
|
||||
assert res.conflict.reload_choice_hint is ReloadChoice.CANCEL
|
||||
|
||||
|
||||
def test_save_conflict_baseline_unknown_with_candidate_present(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
meta = RemoteFileMetadata(mtime_ns=1, size_bytes=1)
|
||||
res = evaluate_save_file(_save_request(tmp_path, baseline=None, candidate=meta))
|
||||
assert res.conflict is not None
|
||||
assert res.conflict.kind is SaveConflictKind.BASELINE_UNKNOWN
|
||||
assert res.conflict.reload_choice_hint is ReloadChoice.CANCEL
|
||||
|
||||
|
||||
def test_save_conflict_baseline_unknown_when_both_none(tmp_path: Path) -> None:
|
||||
"""No baseline takes precedence over remote-missing — see decision_code 1."""
|
||||
res = evaluate_save_file(_save_request(tmp_path, baseline=None, candidate=None))
|
||||
assert res.conflict is not None
|
||||
assert res.conflict.kind is SaveConflictKind.BASELINE_UNKNOWN
|
||||
|
||||
|
||||
def test_save_conflict_remote_missing_message_text(tmp_path: Path) -> None:
|
||||
"""Pin user-visible message string — Python single-source-of-truth (amend A1)."""
|
||||
baseline = RemoteFileMetadata(mtime_ns=1, size_bytes=10)
|
||||
res = evaluate_save_file(_save_request(tmp_path, baseline=baseline, candidate=None))
|
||||
assert res.conflict is not None
|
||||
assert "disappeared" in res.conflict.message
|
||||
assert res.conflict.kind is SaveConflictKind.REMOTE_FILE_MISSING
|
||||
|
||||
|
||||
def test_save_conflict_directory_message_text(tmp_path: Path) -> None:
|
||||
baseline = RemoteFileMetadata(mtime_ns=1, size_bytes=10)
|
||||
current = RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=4096, kind=RemoteFileKind.DIRECTORY
|
||||
)
|
||||
res = evaluate_save_file(
|
||||
_save_request(tmp_path, baseline=baseline, candidate=current)
|
||||
)
|
||||
assert res.conflict is not None
|
||||
assert "directory" in res.conflict.message.lower()
|
||||
|
||||
|
||||
def test_save_conflict_symlink_message_text(tmp_path: Path) -> None:
|
||||
baseline = RemoteFileMetadata(mtime_ns=1, size_bytes=10)
|
||||
current = RemoteFileMetadata(mtime_ns=1, size_bytes=10, kind=RemoteFileKind.SYMLINK)
|
||||
res = evaluate_save_file(
|
||||
_save_request(tmp_path, baseline=baseline, candidate=current)
|
||||
)
|
||||
assert res.conflict is not None
|
||||
assert "symlink" in res.conflict.message.lower()
|
||||
|
||||
|
||||
def test_save_conflict_metadata_changed_message_text(tmp_path: Path) -> None:
|
||||
baseline = RemoteFileMetadata(mtime_ns=1, size_bytes=10)
|
||||
current = RemoteFileMetadata(mtime_ns=2, size_bytes=10)
|
||||
res = evaluate_save_file(
|
||||
_save_request(tmp_path, baseline=baseline, candidate=current)
|
||||
)
|
||||
assert res.conflict is not None
|
||||
assert "changed" in res.conflict.message.lower()
|
||||
|
||||
|
||||
def test_save_conflict_baseline_unknown_message_text(tmp_path: Path) -> None:
|
||||
meta = RemoteFileMetadata(mtime_ns=1, size_bytes=1)
|
||||
res = evaluate_save_file(_save_request(tmp_path, baseline=None, candidate=meta))
|
||||
assert res.conflict is not None
|
||||
assert "metadata" in res.conflict.message.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kind_codes matrix — every (baseline_kind, candidate_kind) where same →OK,
|
||||
# differ →METADATA_CHANGED, kind=DIRECTORY/SYMLINK on candidate trigger
|
||||
# their own conflict variants regardless of size/mtime equality.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind",
|
||||
[
|
||||
RemoteFileKind.REGULAR_FILE,
|
||||
RemoteFileKind.OTHER,
|
||||
],
|
||||
)
|
||||
def test_save_ok_for_same_kind_same_metadata(
|
||||
tmp_path: Path, kind: RemoteFileKind
|
||||
) -> None:
|
||||
meta = RemoteFileMetadata(mtime_ns=7, size_bytes=42, kind=kind)
|
||||
res = evaluate_save_file(_save_request(tmp_path, baseline=meta, candidate=meta))
|
||||
assert res.outcome is SaveOutcome.OK
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"candidate_kind, expected_kind",
|
||||
[
|
||||
(RemoteFileKind.DIRECTORY, SaveConflictKind.REMOTE_PATH_IS_DIRECTORY),
|
||||
(RemoteFileKind.SYMLINK, SaveConflictKind.REMOTE_PATH_IS_SYMLINK),
|
||||
],
|
||||
)
|
||||
def test_save_kind_changed_to_blocked_kind_overrides_metadata_match(
|
||||
tmp_path: Path,
|
||||
candidate_kind: RemoteFileKind,
|
||||
expected_kind: SaveConflictKind,
|
||||
) -> None:
|
||||
"""Even with identical (mtime, size), changing kind to dir/symlink trips the
|
||||
kind-specific conflict — Rust ``save_decision_code`` checks kind *before*
|
||||
metadata equality."""
|
||||
baseline = RemoteFileMetadata(
|
||||
mtime_ns=1, size_bytes=10, kind=RemoteFileKind.REGULAR_FILE
|
||||
)
|
||||
candidate = RemoteFileMetadata(mtime_ns=1, size_bytes=10, kind=candidate_kind)
|
||||
res = evaluate_save_file(
|
||||
_save_request(tmp_path, baseline=baseline, candidate=candidate)
|
||||
)
|
||||
assert res.conflict is not None
|
||||
assert res.conflict.kind is expected_kind
|
||||
@@ -270,6 +270,139 @@ def test_apply_pending_keeps_marker_on_remote_timeout(tmp_path: Path) -> None:
|
||||
assert (dot_git / "SESSIONS_PENDING_CHECKOUT").exists()
|
||||
|
||||
|
||||
def test_apply_pending_creates_branch_when_remote_has_no_such_ref(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""User created the branch in Sublime Merge — remote doesn't know it
|
||||
yet. The proxy must retry with ``checkout -b <new> <prev>`` so the
|
||||
new ref exists on the remote *before* the next .git tarball pull,
|
||||
otherwise ``fetch_remote_dot_git`` would clobber the local-only
|
||||
ref and the user's freshly-created branch silently disappears.
|
||||
"""
|
||||
repo = _make_repo(tmp_path)
|
||||
dot_git = repo.local_root / ".git"
|
||||
_write_marker(
|
||||
dot_git,
|
||||
{
|
||||
"prev_head": "abc123",
|
||||
"new_head": "feature/new",
|
||||
"branch_flag": "1",
|
||||
"ts": "ts",
|
||||
},
|
||||
)
|
||||
|
||||
captured: List[List[str]] = []
|
||||
|
||||
def fake_exec(
|
||||
host_alias: str, argv, cwd: str, timeout_ms: int
|
||||
) -> RemoteExecOnceResult:
|
||||
captured.append(list(argv))
|
||||
# First attempt: stock ``git checkout`` fails because the remote
|
||||
# repo has no such ref — recreate the older error wording too
|
||||
# (some host stacks ship gits old enough for this phrasing).
|
||||
if argv[3] == "checkout" and argv[4] == "feature/new":
|
||||
return _ok_exec(
|
||||
exit_code=1,
|
||||
stderr=(
|
||||
"error: pathspec 'feature/new' did not match any file(s) "
|
||||
"known to git\n"
|
||||
),
|
||||
)
|
||||
return _ok_exec(stdout="Switched to a new branch 'feature/new'\n")
|
||||
|
||||
result = apply_pending_checkout("h", repo, exec_once=fake_exec)
|
||||
assert result.proxied
|
||||
assert result.ok, result.error_detail
|
||||
assert result.new_head == "feature/new"
|
||||
assert captured[0] == ["git", "-C", "/srv/ws", "checkout", "feature/new"]
|
||||
# Fallback must use ``-b`` against ``prev_head`` so the new ref
|
||||
# mirrors where the user branched from locally.
|
||||
assert captured[1] == [
|
||||
"git",
|
||||
"-C",
|
||||
"/srv/ws",
|
||||
"checkout",
|
||||
"-b",
|
||||
"feature/new",
|
||||
"abc123",
|
||||
]
|
||||
assert not (dot_git / "SESSIONS_PENDING_CHECKOUT").exists()
|
||||
|
||||
|
||||
def test_apply_pending_creates_branch_with_newer_git_wording(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Newer git phrases the unknown-ref refusal as
|
||||
``did not match any known refs`` instead of ``... any file(s)
|
||||
known to git``. The fallback must trigger on both wordings."""
|
||||
repo = _make_repo(tmp_path)
|
||||
dot_git = repo.local_root / ".git"
|
||||
_write_marker(
|
||||
dot_git,
|
||||
{
|
||||
"prev_head": "abc",
|
||||
"new_head": "topic/x",
|
||||
"branch_flag": "1",
|
||||
"ts": "ts",
|
||||
},
|
||||
)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_exec(*_a: Any, **_kw: Any) -> RemoteExecOnceResult:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return _ok_exec(
|
||||
exit_code=1,
|
||||
stderr="error: pathspec 'topic/x' did not match any known refs\n",
|
||||
)
|
||||
return _ok_exec(stdout="Switched to a new branch 'topic/x'\n")
|
||||
|
||||
result = apply_pending_checkout("h", repo, exec_once=fake_exec)
|
||||
assert result.proxied
|
||||
assert result.ok, result.error_detail
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_apply_pending_does_not_create_branch_for_dirty_refusal(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The ``-b`` fallback must only fire on unknown-ref errors. A
|
||||
dirty-tree refusal (G6 path) keeps the marker so the user can
|
||||
resolve and retry — re-creating the branch instead would lose the
|
||||
refusal context."""
|
||||
repo = _make_repo(tmp_path)
|
||||
dot_git = repo.local_root / ".git"
|
||||
_write_marker(
|
||||
dot_git,
|
||||
{
|
||||
"prev_head": "abc",
|
||||
"new_head": "feature/x",
|
||||
"branch_flag": "1",
|
||||
"ts": "ts",
|
||||
},
|
||||
)
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_exec(*_a: Any, **_kw: Any) -> RemoteExecOnceResult:
|
||||
calls["n"] += 1
|
||||
return _ok_exec(
|
||||
exit_code=1,
|
||||
stderr=(
|
||||
"error: Your local changes to the following files would "
|
||||
"be overwritten by checkout:\n"
|
||||
),
|
||||
)
|
||||
|
||||
result = apply_pending_checkout("h", repo, exec_once=fake_exec)
|
||||
assert result.proxied
|
||||
assert not result.ok
|
||||
# Only the initial checkout fired — no ``-b`` retry.
|
||||
assert calls["n"] == 1
|
||||
# Marker stays for retry.
|
||||
assert (dot_git / "SESSIONS_PENDING_CHECKOUT").exists()
|
||||
|
||||
|
||||
def test_apply_pending_clears_marker_on_empty_new_head(tmp_path: Path) -> None:
|
||||
"""A malformed marker with empty new_head can't be proxied; clear it
|
||||
so it doesn't stick around shadowing future legit checkouts."""
|
||||
|
||||
@@ -275,6 +275,43 @@ def test_replace_overcomes_readonly_loose_objects(tmp_path: Path) -> None:
|
||||
os.chmod(pack_idx, stat.S_IWRITE)
|
||||
|
||||
|
||||
def test_fetch_preserves_pending_checkout_marker_across_wipe(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A queued post-checkout marker must survive ``_replace_local_dot_git``.
|
||||
|
||||
Refresh order is checkout proxy → fetch → materialise. If the
|
||||
proxy fails (remote dirty refusal, network blip) it intentionally
|
||||
keeps the marker so the next refresh can retry. The fetch step
|
||||
runs immediately after and previously wiped ``.git/`` wholesale —
|
||||
deleting the marker along with everything else and silently
|
||||
losing the user's pending branch switch. This pins the
|
||||
preservation behaviour in place.
|
||||
"""
|
||||
repo = _make_repo(tmp_path)
|
||||
dot_git = repo.local_root / ".git"
|
||||
dot_git.mkdir(parents=True)
|
||||
marker_payload = (
|
||||
b'{"prev_head":"abc","new_head":"feature/x","branch_flag":"1","ts":"t"}'
|
||||
)
|
||||
(dot_git / "SESSIONS_PENDING_CHECKOUT").write_bytes(marker_payload)
|
||||
|
||||
fake_stdout = _build_tar_b64({"HEAD": b"ref: refs/heads/main\n"})
|
||||
|
||||
def fake_exec(*_a: Any, **_kw: Any) -> RemoteExecOnceResult:
|
||||
return RemoteExecOnceResult(
|
||||
exit_code=0, stdout=fake_stdout, stderr="", timed_out=False
|
||||
)
|
||||
|
||||
result = fetch_remote_dot_git("h", repo, exec_once=fake_exec)
|
||||
assert result.ok
|
||||
# Tarball content landed,
|
||||
assert (dot_git / "HEAD").read_bytes() == b"ref: refs/heads/main\n"
|
||||
# AND the marker survived the wipe so the next refresh's proxy
|
||||
# step can retry.
|
||||
assert (dot_git / "SESSIONS_PENDING_CHECKOUT").read_bytes() == marker_payload
|
||||
|
||||
|
||||
def test_force_remove_dot_git_handles_readonly_directory_tree(tmp_path: Path) -> None:
|
||||
"""``_force_remove_dot_git`` must clear the read-only bit and retry rather
|
||||
than letting ``shutil.rmtree`` raise — otherwise the next refresh
|
||||
|
||||
146
sublime/tests/test_git_local_head_baseline.py
Normal file
146
sublime/tests/test_git_local_head_baseline.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Tests for the hookless local-HEAD divergence detection helpers.
|
||||
|
||||
When Sublime Merge runs ``git checkout`` without firing the
|
||||
post-checkout hook, the Track G branch proxy never sees a marker file
|
||||
and the remote stays on the old branch. The v0.7.34 fix:
|
||||
|
||||
* Snapshot the local HEAD branch name after every successful Track G
|
||||
refresh (``_remember_local_head_branch``).
|
||||
* On the next refresh, before ``apply_pending_checkout``, compare
|
||||
current local HEAD against the cached baseline; if they differ and
|
||||
no real marker is queued, write a synthetic marker
|
||||
(``_synthesize_pending_checkout_if_local_head_diverged``).
|
||||
|
||||
These tests exercise the helpers in isolation — the round-trip into
|
||||
``apply_pending_checkout`` is exercised by the existing branch-proxy
|
||||
tests via the marker-file contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sessions import commands
|
||||
from sessions.git_repo_discovery import GitRepo
|
||||
|
||||
|
||||
def _make_repo(tmp_path: Path) -> GitRepo:
|
||||
local_root = tmp_path / "repo"
|
||||
(local_root / ".git").mkdir(parents=True)
|
||||
return GitRepo(local_root=local_root, remote_root="/srv/repo", kind="regular")
|
||||
|
||||
|
||||
def test_read_local_head_branch_returns_branch_name(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"ref: refs/heads/feature-foo\n", encoding="utf-8"
|
||||
)
|
||||
assert commands._read_local_head_branch(repo.local_root) == "feature-foo"
|
||||
|
||||
|
||||
def test_read_local_head_branch_returns_empty_for_detached(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"deadbeef00000000000000000000000000000000\n", encoding="utf-8"
|
||||
)
|
||||
assert commands._read_local_head_branch(repo.local_root) == ""
|
||||
|
||||
|
||||
def test_read_local_head_branch_returns_empty_when_missing(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
# No HEAD file written.
|
||||
assert commands._read_local_head_branch(repo.local_root) == ""
|
||||
|
||||
|
||||
def test_remember_then_synthesize_writes_marker_on_divergence(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Baseline = main, current HEAD = feature → marker is synthesized."""
|
||||
repo = _make_repo(tmp_path)
|
||||
head_path = repo.local_root / ".git" / "HEAD"
|
||||
head_path.write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||
# First refresh remembers the baseline.
|
||||
monkeypatch.setattr(commands, "_track_g_local_branch_baseline", {})
|
||||
commands._remember_local_head_branch(repo)
|
||||
# User switches branches in Merge — local HEAD changes.
|
||||
head_path.write_text("ref: refs/heads/feature-x\n", encoding="utf-8")
|
||||
marker_path = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
assert not marker_path.exists()
|
||||
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
|
||||
assert marker_path.is_file()
|
||||
payload = json.loads(marker_path.read_text(encoding="utf-8"))
|
||||
assert payload["prev_head"] == "main"
|
||||
assert payload["new_head"] == "feature-x"
|
||||
assert payload["branch_flag"] == "1"
|
||||
assert payload["ts"] == "synthetic-from-local-head"
|
||||
|
||||
|
||||
def test_synthesize_no_op_when_baseline_unset(tmp_path: Path, monkeypatch) -> None:
|
||||
"""First-ever refresh has no baseline; do not write a synthetic marker."""
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"ref: refs/heads/main\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(commands, "_track_g_local_branch_baseline", {})
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_synthesize_no_op_when_baseline_matches(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Same branch as baseline → no marker."""
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"ref: refs/heads/main\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(commands, "_track_g_local_branch_baseline", {})
|
||||
commands._remember_local_head_branch(repo)
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_synthesize_no_op_when_marker_already_present(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Real post-checkout hook fired → don't overwrite its marker."""
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"ref: refs/heads/feature\n", encoding="utf-8"
|
||||
)
|
||||
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
marker.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"prev_head": "main",
|
||||
"new_head": "feature",
|
||||
"branch_flag": "1",
|
||||
"ts": "real-hook",
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands, "_track_g_local_branch_baseline", {"local::/srv/repo": "main"}
|
||||
)
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
payload = json.loads(marker.read_text(encoding="utf-8"))
|
||||
assert payload["ts"] == "real-hook", "must not overwrite a real hook marker"
|
||||
|
||||
|
||||
def test_synthesize_no_op_for_detached_head(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Detached HEAD (no ``ref: refs/heads/<x>`` shape) → don't synthesize."""
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo.local_root / ".git" / "HEAD").write_text(
|
||||
"deadbeef00000000000000000000000000000000\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands, "_track_g_local_branch_baseline", {"local::/srv/repo": "main"}
|
||||
)
|
||||
commands._synthesize_pending_checkout_if_local_head_diverged(repo)
|
||||
marker = repo.local_root / ".git" / "SESSIONS_PENDING_CHECKOUT"
|
||||
assert not marker.exists()
|
||||
@@ -39,20 +39,6 @@ def test_catalog_project_keys_match_managed_client_snapshot() -> None:
|
||||
assert SESSIONS_LSP_RUST_ANALYZER_CLIENT_KEY in lsp_keys
|
||||
|
||||
|
||||
def test_catalog_contains_jupyter_extension_entry() -> None:
|
||||
entries = [
|
||||
e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "jupyter"
|
||||
]
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.install_catalog_id == "jupyterlab"
|
||||
# LSP-specific fields are cleared for non-LSP kinds.
|
||||
assert entry.project_client_key is None
|
||||
assert entry.bridge_server_id is None
|
||||
assert entry.remote_spawn_argv is None
|
||||
assert entry.sublime_selector is None
|
||||
|
||||
|
||||
def test_catalog_contains_debugger_extension_entry() -> None:
|
||||
entries = [
|
||||
e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "debugger"
|
||||
@@ -70,19 +56,3 @@ def test_catalog_contains_debugger_extension_entry() -> None:
|
||||
assert entry.bridge_server_id is None
|
||||
assert entry.remote_spawn_argv is None
|
||||
assert entry.sublime_selector is None
|
||||
|
||||
|
||||
def test_catalog_contains_agent_extension_entries() -> None:
|
||||
entries = [e for e in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG if e.kind == "agent"]
|
||||
assert [e.install_catalog_id for e in entries] == [
|
||||
"tmux",
|
||||
"claude-code",
|
||||
"codex-cli",
|
||||
]
|
||||
for entry in entries:
|
||||
# LSP-specific fields are cleared for non-LSP kinds.
|
||||
assert entry.project_client_key is None
|
||||
assert entry.bridge_server_id is None
|
||||
assert entry.remote_spawn_argv is None
|
||||
assert entry.sublime_selector is None
|
||||
assert entry.legacy_project_client_keys == ()
|
||||
|
||||
@@ -24,7 +24,7 @@ import json
|
||||
|
||||
import pytest
|
||||
from sessions import _rust_ffi
|
||||
from sessions._rust_ffi import SessionsNativeLibraryError
|
||||
from sessions._rust_ffi import SessionsNativeLibraryError, _loader
|
||||
|
||||
|
||||
class _FakeStringFunc:
|
||||
@@ -62,7 +62,7 @@ def _install(monkeypatch, **symbols) -> None:
|
||||
lib = _Lib()
|
||||
for name, func in symbols.items():
|
||||
setattr(lib, name, func)
|
||||
monkeypatch.setattr(_rust_ffi, "_native_lib", lambda: lib)
|
||||
monkeypatch.setattr(_loader, "_native_lib", lambda: lib)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
@@ -37,7 +37,7 @@ def _install(monkeypatch, **symbols) -> None:
|
||||
lib = _Lib()
|
||||
for name, func in symbols.items():
|
||||
setattr(lib, name, func)
|
||||
monkeypatch.setattr(_rust_ffi, "_native_lib", lambda: lib)
|
||||
monkeypatch.setattr(_rust_ffi._loader, "_native_lib", lambda: lib)
|
||||
|
||||
|
||||
_HAPPY_CASES = [
|
||||
|
||||
@@ -59,7 +59,7 @@ def _install(monkeypatch, **symbols) -> None:
|
||||
lib = _Lib()
|
||||
for name, func in symbols.items():
|
||||
setattr(lib, name, func)
|
||||
monkeypatch.setattr(_rust_ffi, "_native_lib", lambda: lib)
|
||||
monkeypatch.setattr(_rust_ffi._loader, "_native_lib", lambda: lib)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
151
sublime/tests/test_rust_local_watcher.py
Normal file
151
sublime/tests/test_rust_local_watcher.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tests for ``_rust_ffi.local_watcher`` wrapper contracts.
|
||||
|
||||
The Rust side of the watcher is exercised by
|
||||
``sessions_native::local_watcher::tests`` (6 tests covering the live
|
||||
``notify`` event loop, filtering, and stop idempotency). These
|
||||
Python-only tests pin the ctypes-wrapper layer contract:
|
||||
|
||||
* ``start`` returns the integer the Rust ABI returned (handle on
|
||||
success, 0 on failure).
|
||||
* ``drain`` decodes the ``\\x1F``-joined payload, retries on the
|
||||
buffer-too-small sentinel, returns ``()`` on negative rc or
|
||||
zero/negative handle.
|
||||
* ``stop`` returns ``True`` only when the Rust ABI returns ``1``.
|
||||
* All three raise ``SessionsNativeLibraryError`` when the symbol is
|
||||
missing from the cdylib.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
|
||||
import pytest
|
||||
from sessions import _rust_ffi
|
||||
from sessions._rust_ffi import SessionsNativeLibraryError
|
||||
|
||||
|
||||
def _install(monkeypatch, **symbols) -> None:
|
||||
class _Lib:
|
||||
pass
|
||||
|
||||
lib = _Lib()
|
||||
for name, func in symbols.items():
|
||||
setattr(lib, name, func)
|
||||
monkeypatch.setattr(_rust_ffi._loader, "_native_lib", lambda: lib)
|
||||
|
||||
|
||||
class _FakeIntFunc:
|
||||
def __init__(self, rc: int) -> None:
|
||||
self._rc = rc
|
||||
self.argtypes = None
|
||||
self.restype = None
|
||||
|
||||
def __call__(self, *args: object) -> int:
|
||||
return self._rc
|
||||
|
||||
|
||||
class _FakeDrainFunc:
|
||||
"""Mimics ``sessions_local_watcher_drain``.
|
||||
|
||||
Returns ``rc`` and, when ``rc == 0``, writes ``payload`` (UTF-8 +
|
||||
NUL terminator) into the caller's ``out_buf``. When ``rc > out_cap``
|
||||
we expect the wrapper to retry with a bigger buffer.
|
||||
"""
|
||||
|
||||
def __init__(self, *, rc: int = 0, payload: str = "") -> None:
|
||||
self._rc = rc
|
||||
self._payload = payload
|
||||
self.argtypes = None
|
||||
self.restype = None
|
||||
self.calls: list[int] = []
|
||||
|
||||
def __call__(self, _handle: object, out_buf: object, out_cap: int) -> int:
|
||||
self.calls.append(out_cap)
|
||||
if self._rc != 0:
|
||||
return self._rc
|
||||
encoded = self._payload.encode("utf-8") + b"\x00"
|
||||
if out_cap < len(encoded):
|
||||
return len(encoded)
|
||||
ctypes.memmove(out_buf, encoded, len(encoded))
|
||||
return 0
|
||||
|
||||
|
||||
def test_start_returns_handle_from_rust(monkeypatch, tmp_path) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_start=_FakeIntFunc(rc=42))
|
||||
assert _rust_ffi.local_watcher.start(str(tmp_path)) == 42
|
||||
|
||||
|
||||
def test_start_returns_zero_on_failure(monkeypatch, tmp_path) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_start=_FakeIntFunc(rc=0))
|
||||
assert _rust_ffi.local_watcher.start(str(tmp_path)) == 0
|
||||
|
||||
|
||||
def test_start_raises_when_symbol_missing(monkeypatch, tmp_path) -> None:
|
||||
_install(monkeypatch) # no symbol bound
|
||||
with pytest.raises(SessionsNativeLibraryError):
|
||||
_rust_ffi.local_watcher.start(str(tmp_path))
|
||||
|
||||
|
||||
def test_drain_with_zero_handle_short_circuits(monkeypatch) -> None:
|
||||
# Should not even reach the Rust ABI; install a func that would
|
||||
# explode if called.
|
||||
_install(monkeypatch, sessions_local_watcher_drain=_FakeIntFunc(rc=-1))
|
||||
assert _rust_ffi.local_watcher.drain(0) == ()
|
||||
assert _rust_ffi.local_watcher.drain(-5) == ()
|
||||
|
||||
|
||||
def test_drain_returns_empty_tuple_on_empty_payload(monkeypatch) -> None:
|
||||
func = _FakeDrainFunc(rc=0, payload="")
|
||||
_install(monkeypatch, sessions_local_watcher_drain=func)
|
||||
assert _rust_ffi.local_watcher.drain(7) == ()
|
||||
|
||||
|
||||
def test_drain_splits_unit_separator(monkeypatch) -> None:
|
||||
func = _FakeDrainFunc(rc=0, payload="/a/b\x1f/c/d\x1f/e")
|
||||
_install(monkeypatch, sessions_local_watcher_drain=func)
|
||||
assert _rust_ffi.local_watcher.drain(1) == ("/a/b", "/c/d", "/e")
|
||||
|
||||
|
||||
def test_drain_returns_empty_on_unknown_handle(monkeypatch) -> None:
|
||||
# Rust returns -1 when ``handle`` is unknown ("watcher gone").
|
||||
_install(monkeypatch, sessions_local_watcher_drain=_FakeIntFunc(rc=-1))
|
||||
assert _rust_ffi.local_watcher.drain(99) == ()
|
||||
|
||||
|
||||
def test_drain_grows_buffer_on_buffer_too_small(monkeypatch) -> None:
|
||||
# First call returns the required size; second succeeds.
|
||||
payload = "/long/path/" + "x" * 16_000
|
||||
func = _FakeDrainFunc(rc=0, payload=payload)
|
||||
_install(monkeypatch, sessions_local_watcher_drain=func)
|
||||
out = _rust_ffi.local_watcher.drain(1)
|
||||
assert out == (payload,)
|
||||
# Two attempts: 8192 (initial), then >= encoded length.
|
||||
assert len(func.calls) >= 2
|
||||
assert func.calls[0] == 8192
|
||||
assert func.calls[-1] >= len(payload.encode("utf-8")) + 1
|
||||
|
||||
|
||||
def test_drain_raises_when_symbol_missing(monkeypatch) -> None:
|
||||
_install(monkeypatch)
|
||||
with pytest.raises(SessionsNativeLibraryError):
|
||||
_rust_ffi.local_watcher.drain(1)
|
||||
|
||||
|
||||
def test_stop_returns_true_when_rust_returned_one(monkeypatch) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_stop=_FakeIntFunc(rc=1))
|
||||
assert _rust_ffi.local_watcher.stop(1) is True
|
||||
|
||||
|
||||
def test_stop_returns_false_when_rust_returned_zero(monkeypatch) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_stop=_FakeIntFunc(rc=0))
|
||||
assert _rust_ffi.local_watcher.stop(1) is False
|
||||
|
||||
|
||||
def test_stop_with_zero_handle_short_circuits(monkeypatch) -> None:
|
||||
_install(monkeypatch, sessions_local_watcher_stop=_FakeIntFunc(rc=99))
|
||||
assert _rust_ffi.local_watcher.stop(0) is False
|
||||
assert _rust_ffi.local_watcher.stop(-3) is False
|
||||
|
||||
|
||||
def test_stop_raises_when_symbol_missing(monkeypatch) -> None:
|
||||
_install(monkeypatch)
|
||||
with pytest.raises(SessionsNativeLibraryError):
|
||||
_rust_ffi.local_watcher.stop(7)
|
||||
@@ -69,7 +69,7 @@ class _FakeLib:
|
||||
|
||||
|
||||
def _install(monkeypatch, lib: _FakeLib) -> None:
|
||||
monkeypatch.setattr(_rust_ffi, "_native_lib", lambda: lib)
|
||||
monkeypatch.setattr(_rust_ffi._loader, "_native_lib", lambda: lib)
|
||||
|
||||
|
||||
# ---------------- open_session ---------------------------------------------
|
||||
|
||||
@@ -29,7 +29,7 @@ class _FakeLib:
|
||||
|
||||
def _install(monkeypatch, func) -> None:
|
||||
lib = _FakeLib(func)
|
||||
monkeypatch.setattr(_rust_ffi, "_native_lib", lambda: lib)
|
||||
monkeypatch.setattr(_rust_ffi._loader, "_native_lib", lambda: lib)
|
||||
|
||||
|
||||
def test_parse_ruff_diagnostics_returns_empty_on_empty_array(monkeypatch):
|
||||
|
||||
@@ -143,12 +143,14 @@ class _FakeLib:
|
||||
|
||||
|
||||
def test_workspace_cache_key_returns_native_value(monkeypatch) -> None:
|
||||
monkeypatch.setattr("sessions._rust_ffi._native_lib", lambda: _FakeLib())
|
||||
monkeypatch.setattr("sessions._rust_ffi._loader._native_lib", lambda: _FakeLib())
|
||||
got = workspace_cache_key("prod", "/srv/app", "python")
|
||||
assert got == "abc123"
|
||||
|
||||
|
||||
def test_workspace_cache_key_raises_on_negative_rc(monkeypatch) -> None:
|
||||
monkeypatch.setattr("sessions._rust_ffi._native_lib", lambda: _FakeLib(rc=-2))
|
||||
monkeypatch.setattr(
|
||||
"sessions._rust_ffi._loader._native_lib", lambda: _FakeLib(rc=-2)
|
||||
)
|
||||
with pytest.raises(SessionsNativeLibraryError):
|
||||
workspace_cache_key("prod", "/srv/app")
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
from sessions.settings_model import (
|
||||
DEFAULT_BUILTIN_REMOTE_EXTENSION_SPECS,
|
||||
SESSIONS_SYNC_MODE_DEFAULT,
|
||||
SESSIONS_SYNC_MODE_KEY,
|
||||
CodeServerSpec,
|
||||
RemoteExtensionSpec,
|
||||
SessionsSettings,
|
||||
@@ -14,6 +16,8 @@ from sessions.settings_model import (
|
||||
normalize_code_server_specs,
|
||||
normalize_remote_extension_specs,
|
||||
normalize_remote_python_tool_pipeline,
|
||||
resolve_sessions_sync_mode,
|
||||
sync_mode_bool,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,11 +162,7 @@ def test_merge_remote_extension_catalog_user_overrides_builtin_by_id() -> None:
|
||||
"pyright-langserver",
|
||||
"ruff",
|
||||
"rust-analyzer",
|
||||
"jupyterlab",
|
||||
"debugpy",
|
||||
"tmux",
|
||||
"claude-code",
|
||||
"codex-cli",
|
||||
]
|
||||
assert merged[0].label == "Custom Pyright"
|
||||
assert merged[0].probe_argv == ("pyright-langserver", "--help")
|
||||
@@ -184,11 +184,7 @@ def test_merge_remote_extension_catalog_appends_user_only_ids() -> None:
|
||||
"pyright-langserver",
|
||||
"ruff",
|
||||
"rust-analyzer",
|
||||
"jupyterlab",
|
||||
"debugpy",
|
||||
"tmux",
|
||||
"claude-code",
|
||||
"codex-cli",
|
||||
"my-lsp",
|
||||
]
|
||||
|
||||
@@ -346,11 +342,7 @@ def test_load_settings_from_sublime_with_full_mock(monkeypatch) -> None:
|
||||
"pyright-langserver",
|
||||
"ruff",
|
||||
"rust-analyzer",
|
||||
"jupyterlab",
|
||||
"debugpy",
|
||||
"tmux",
|
||||
"claude-code",
|
||||
"codex-cli",
|
||||
}
|
||||
|
||||
|
||||
@@ -447,3 +439,101 @@ def test_load_settings_bad_base_url_uses_default(monkeypatch) -> None:
|
||||
monkeypatch.setitem(sys.modules, "sublime", fake_sublime)
|
||||
settings = load_sessions_settings_from_sublime()
|
||||
assert settings.gitea_base_url == "https://git.teahaven.kr"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sessions sync-mode (safe / balanced / full) — see SECURITY.md § "Sync mode"
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DictGetter:
|
||||
"""Minimal stand-in for ``Sublime.Settings.get`` used by the sync-mode helpers."""
|
||||
|
||||
def __init__(self, store):
|
||||
self._store = dict(store)
|
||||
|
||||
def __call__(self, key, default=None):
|
||||
return self._store.get(key, default)
|
||||
|
||||
|
||||
def test_resolve_sessions_sync_mode_defaults_to_balanced() -> None:
|
||||
assert resolve_sessions_sync_mode(_DictGetter({})) == SESSIONS_SYNC_MODE_DEFAULT
|
||||
assert SESSIONS_SYNC_MODE_DEFAULT == "balanced"
|
||||
|
||||
|
||||
def test_resolve_sessions_sync_mode_falls_back_on_unknown_value() -> None:
|
||||
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: "ultra"})
|
||||
assert resolve_sessions_sync_mode(getter) == SESSIONS_SYNC_MODE_DEFAULT
|
||||
|
||||
|
||||
def test_resolve_sessions_sync_mode_accepts_known_values() -> None:
|
||||
for value in ("safe", "balanced", "full"):
|
||||
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: value})
|
||||
assert resolve_sessions_sync_mode(getter) == value
|
||||
|
||||
|
||||
def test_sync_mode_bool_safe_forces_off_three_keys() -> None:
|
||||
getter = _DictGetter(
|
||||
{
|
||||
SESSIONS_SYNC_MODE_KEY: "safe",
|
||||
"sessions_mirror_auto_refresh": True,
|
||||
"sessions_mirror_include_files": True,
|
||||
"sessions_connect_auto_open_remote_folder": True,
|
||||
}
|
||||
)
|
||||
for forced_key in (
|
||||
"sessions_mirror_auto_refresh",
|
||||
"sessions_mirror_include_files",
|
||||
"sessions_connect_auto_open_remote_folder",
|
||||
):
|
||||
assert sync_mode_bool(getter, forced_key, True) is False
|
||||
|
||||
|
||||
def test_sync_mode_bool_safe_does_not_touch_unrelated_keys() -> None:
|
||||
# User-set value for a key NOT in the safe-mode forced-off list passes
|
||||
# through unchanged.
|
||||
getter_user_set = _DictGetter(
|
||||
{
|
||||
SESSIONS_SYNC_MODE_KEY: "safe",
|
||||
"sessions_mirror_show_sidebar_after_sync": True,
|
||||
}
|
||||
)
|
||||
assert (
|
||||
sync_mode_bool(
|
||||
getter_user_set, "sessions_mirror_show_sidebar_after_sync", False
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
# When the key is not set, the caller's fallback is honoured.
|
||||
getter_no_set = _DictGetter({SESSIONS_SYNC_MODE_KEY: "safe"})
|
||||
assert (
|
||||
sync_mode_bool(getter_no_set, "sessions_mirror_show_sidebar_after_sync", True)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
sync_mode_bool(getter_no_set, "sessions_mirror_show_sidebar_after_sync", False)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_sync_mode_bool_balanced_passes_per_key_setting_through() -> None:
|
||||
getter = _DictGetter(
|
||||
{
|
||||
SESSIONS_SYNC_MODE_KEY: "balanced",
|
||||
"sessions_mirror_auto_refresh": False,
|
||||
}
|
||||
)
|
||||
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", True) is False
|
||||
|
||||
|
||||
def test_sync_mode_bool_full_passes_per_key_setting_through() -> None:
|
||||
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: "full"})
|
||||
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", True) is True
|
||||
assert sync_mode_bool(getter, "sessions_mirror_include_files", False) is False
|
||||
|
||||
|
||||
def test_sync_mode_bool_uses_fallback_when_key_missing() -> None:
|
||||
getter = _DictGetter({SESSIONS_SYNC_MODE_KEY: "balanced"})
|
||||
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", True) is True
|
||||
assert sync_mode_bool(getter, "sessions_mirror_auto_refresh", False) is False
|
||||
|
||||
@@ -89,7 +89,7 @@ class _FakeNativeLib:
|
||||
|
||||
def _install_fake_native(monkeypatch, parser=None) -> None:
|
||||
monkeypatch.setattr(
|
||||
"sessions._rust_ffi._native_lib",
|
||||
"sessions._rust_ffi._loader._native_lib",
|
||||
lambda: _FakeNativeLib(parser=parser),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for cache mirror and opening remote files into local cache."""
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import sessions.ssh_file_transport as ssh_ft
|
||||
@@ -10,7 +9,6 @@ from sessions.file_state import (
|
||||
OpenOutcome,
|
||||
UnsupportedOpenReason,
|
||||
)
|
||||
from sessions.remote import RemoteReadFileRequest
|
||||
from sessions.ssh_file_transport import (
|
||||
RemoteCacheMirrorOptions,
|
||||
execute_remote_cache_mirror,
|
||||
@@ -123,6 +121,25 @@ def test_execute_remote_cache_mirror_error_without_bridge(monkeypatch) -> None:
|
||||
assert "Rust bridge" in (result.error_detail or "")
|
||||
|
||||
|
||||
def _writing_transaction(body: bytes, metadata: dict) -> "object":
|
||||
"""Return a fake ``_rust_file_open_transaction`` that writes ``body``.
|
||||
|
||||
Mirrors the Rust transaction's atomic_write side-effect so OK-path tests
|
||||
can still assert ``target.read_bytes() == body``.
|
||||
"""
|
||||
|
||||
def fake(*, local_cache_path: Path, **_kwargs: object) -> dict:
|
||||
local_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_cache_path.write_bytes(body)
|
||||
return {
|
||||
"outcome": "OK",
|
||||
"bytes_written": len(body),
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def test_open_remote_cache_writes_ok(tmp_path: Path, monkeypatch) -> None:
|
||||
meta = {
|
||||
"mtime_ns": 1,
|
||||
@@ -133,14 +150,8 @@ def test_open_remote_cache_writes_ok(tmp_path: Path, monkeypatch) -> None:
|
||||
body = b"hey\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._execute_rust_bridge_request",
|
||||
lambda host_alias, method, params, **_kwargs: {
|
||||
"ok": True,
|
||||
"result": {
|
||||
"metadata": meta,
|
||||
"body_b64": base64.b64encode(body).decode("ascii"),
|
||||
},
|
||||
},
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
_writing_transaction(body, meta),
|
||||
)
|
||||
|
||||
target = tmp_path / "mirror" / "f.txt"
|
||||
@@ -155,20 +166,15 @@ def test_open_remote_cache_writes_ok(tmp_path: Path, monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_open_remote_cache_binary_block(tmp_path: Path, monkeypatch) -> None:
|
||||
body = b"\x00\x01\x02"
|
||||
meta = {
|
||||
"mtime_ns": 1,
|
||||
"size_bytes": 99,
|
||||
"kind": "regular_file",
|
||||
"unix_mode": 33188,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._execute_rust_bridge_request",
|
||||
lambda host_alias, method, params, **_kwargs: {
|
||||
"ok": True,
|
||||
"result": {
|
||||
"metadata": meta,
|
||||
"body_b64": base64.b64encode(body).decode("ascii"),
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
lambda **_kwargs: {
|
||||
"outcome": "BLOCKED_BINARY_HEURISTIC",
|
||||
"metadata": {
|
||||
"mtime_ns": 1,
|
||||
"size_bytes": 99,
|
||||
"kind": "regular_file",
|
||||
"unix_mode": 33188,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -185,15 +191,12 @@ def test_open_remote_cache_binary_block(tmp_path: Path, monkeypatch) -> None:
|
||||
def test_open_remote_cache_transport_error_on_read_failure(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
def read_raises(
|
||||
host_alias: str, request: RemoteReadFileRequest, **kwargs: object
|
||||
) -> None:
|
||||
_ = (host_alias, request, kwargs)
|
||||
raise SessionHelperStartError("Rust bridge read failed.")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport.execute_remote_read_file",
|
||||
read_raises,
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
lambda **_kwargs: {
|
||||
"outcome": "TRANSPORT_ERROR",
|
||||
"detail": "Rust bridge read failed.",
|
||||
},
|
||||
)
|
||||
target = tmp_path / "x"
|
||||
res = open_remote_file_into_local_cache(
|
||||
@@ -205,15 +208,13 @@ def test_open_remote_cache_transport_error_on_read_failure(
|
||||
|
||||
|
||||
def test_open_remote_cache_remote_missing(tmp_path: Path, monkeypatch) -> None:
|
||||
def boom(host_alias: str, request: RemoteReadFileRequest) -> None:
|
||||
_ = (host_alias, request)
|
||||
raise SessionHelperStartError(
|
||||
"Remote file read failed: [Errno 2] No such file or directory: '/srv/y'"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport.execute_remote_read_file",
|
||||
boom,
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
lambda **_kwargs: {
|
||||
"outcome": "REMOTE_NOT_FOUND",
|
||||
"error_code": "file_read_failed",
|
||||
"detail": "No such file or directory: /srv/y",
|
||||
},
|
||||
)
|
||||
target = tmp_path / "y"
|
||||
res = open_remote_file_into_local_cache(
|
||||
@@ -229,17 +230,15 @@ def test_open_remote_cache_blocks_directory_payload(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._execute_rust_bridge_request",
|
||||
lambda host_alias, method, params, **_kwargs: {
|
||||
"ok": True,
|
||||
"result": {
|
||||
"metadata": {
|
||||
"mtime_ns": 1,
|
||||
"size_bytes": 0,
|
||||
"kind": "directory",
|
||||
"unix_mode": 16877,
|
||||
},
|
||||
"body_b64": "",
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
lambda **_kwargs: {
|
||||
"outcome": "BLOCKED_BY_POLICY",
|
||||
"unsupported_reason": "unsupported_remote_kind",
|
||||
"metadata": {
|
||||
"mtime_ns": 1,
|
||||
"size_bytes": 0,
|
||||
"kind": "directory",
|
||||
"unix_mode": 16877,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -258,20 +257,16 @@ def test_open_remote_cache_blocks_large_declared_size(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Oversized files are blocked by metadata before binary heuristics."""
|
||||
small_text = b"tiny"
|
||||
meta = {
|
||||
"mtime_ns": 1,
|
||||
"size_bytes": FileOpenGuardrails().max_open_bytes + 1,
|
||||
"kind": "regular_file",
|
||||
"unix_mode": 33188,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._execute_rust_bridge_request",
|
||||
lambda host_alias, method, params, **_kwargs: {
|
||||
"ok": True,
|
||||
"result": {
|
||||
"metadata": meta,
|
||||
"body_b64": base64.b64encode(small_text).decode("ascii"),
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
lambda **_kwargs: {
|
||||
"outcome": "BLOCKED_BY_POLICY",
|
||||
"unsupported_reason": "file_too_large",
|
||||
"metadata": {
|
||||
"mtime_ns": 1,
|
||||
"size_bytes": FileOpenGuardrails().max_open_bytes + 1,
|
||||
"kind": "regular_file",
|
||||
"unix_mode": 33188,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -282,6 +277,7 @@ def test_open_remote_cache_blocks_large_declared_size(
|
||||
local_cache_path=target,
|
||||
)
|
||||
assert res.outcome is OpenOutcome.BLOCKED_BY_POLICY
|
||||
assert res.unsupported_reason is UnsupportedOpenReason.FILE_TOO_LARGE
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
@@ -444,18 +440,11 @@ def test_mirror_success(monkeypatch) -> None:
|
||||
|
||||
def test_open_remote_file_success(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._execute_rust_bridge_request",
|
||||
lambda host, method, params, **kw: {
|
||||
"ok": True,
|
||||
"result": {
|
||||
"metadata": {
|
||||
"kind": "regular_file",
|
||||
"mtime_ns": 1000,
|
||||
"size_bytes": 5,
|
||||
},
|
||||
"body_b64": base64.b64encode(b"hello").decode(),
|
||||
},
|
||||
},
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
_writing_transaction(
|
||||
b"hello",
|
||||
{"kind": "regular_file", "mtime_ns": 1000, "size_bytes": 5},
|
||||
),
|
||||
)
|
||||
cache_file = tmp_path / "file.txt"
|
||||
result = open_remote_file_into_local_cache(
|
||||
@@ -471,11 +460,9 @@ def test_open_remote_file_success(monkeypatch, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_open_remote_file_transport_error(monkeypatch, tmp_path) -> None:
|
||||
def raise_error(host, req, **kw):
|
||||
raise SessionHelperStartError("transport boom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport.execute_remote_read_file", raise_error
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
lambda **_kwargs: {"outcome": "TRANSPORT_ERROR", "detail": "transport boom"},
|
||||
)
|
||||
result = open_remote_file_into_local_cache(
|
||||
"host",
|
||||
@@ -489,11 +476,13 @@ def test_open_remote_file_transport_error(monkeypatch, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_open_remote_file_not_found(monkeypatch, tmp_path) -> None:
|
||||
def raise_error(host, req, **kw):
|
||||
raise SessionHelperStartError("No such file or directory: /remote/gone")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport.execute_remote_read_file", raise_error
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
lambda **_kwargs: {
|
||||
"outcome": "REMOTE_NOT_FOUND",
|
||||
"error_code": "file_read_failed",
|
||||
"detail": "No such file or directory: /remote/gone",
|
||||
},
|
||||
)
|
||||
result = open_remote_file_into_local_cache(
|
||||
"host",
|
||||
@@ -509,94 +498,49 @@ def test_open_remote_file_not_found(monkeypatch, tmp_path) -> None:
|
||||
def test_open_remote_cache_reports_local_write_failure(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Remote fetch succeeds but local write_bytes raises → TRANSPORT_ERROR or
|
||||
meaningful failure, not an unhandled exception."""
|
||||
body = b"hello\n"
|
||||
meta = {
|
||||
"mtime_ns": 1,
|
||||
"size_bytes": len(body),
|
||||
"kind": "regular_file",
|
||||
"unix_mode": 33188,
|
||||
}
|
||||
"""Local write failure inside the Rust transaction surfaces as TRANSPORT_ERROR.
|
||||
|
||||
The Rust transaction's atomic_write step now owns the local write; on
|
||||
failure it returns ``outcome=TRANSPORT_ERROR`` with a ``local cache
|
||||
write failed`` detail string.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._execute_rust_bridge_request",
|
||||
lambda host_alias, method, params, **_kwargs: {
|
||||
"ok": True,
|
||||
"result": {
|
||||
"metadata": meta,
|
||||
"body_b64": base64.b64encode(body).decode("ascii"),
|
||||
},
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
lambda **_kwargs: {
|
||||
"outcome": "TRANSPORT_ERROR",
|
||||
"detail": "local cache write failed: disk full",
|
||||
},
|
||||
)
|
||||
|
||||
target = tmp_path / "cache" / "file.txt"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_write_bytes = Path.write_bytes
|
||||
|
||||
def failing_write_bytes(self, data):
|
||||
if self == target:
|
||||
raise OSError("disk full")
|
||||
return original_write_bytes(self, data)
|
||||
|
||||
monkeypatch.setattr(Path, "write_bytes", failing_write_bytes)
|
||||
|
||||
try:
|
||||
res = open_remote_file_into_local_cache(
|
||||
"host",
|
||||
remote_absolute_path="/srv/ws/file.txt",
|
||||
local_cache_path=target,
|
||||
)
|
||||
assert res.outcome in (
|
||||
OpenOutcome.TRANSPORT_ERROR,
|
||||
OpenOutcome.OK,
|
||||
), f"unexpected outcome: {res.outcome}"
|
||||
except OSError as exc:
|
||||
assert "disk full" in str(exc)
|
||||
res = open_remote_file_into_local_cache(
|
||||
"host",
|
||||
remote_absolute_path="/srv/ws/file.txt",
|
||||
local_cache_path=target,
|
||||
)
|
||||
assert res.outcome is OpenOutcome.TRANSPORT_ERROR
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_open_remote_cache_write_failure_no_partial_sidecar(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""If local write fails, no sidecar metadata file should remain."""
|
||||
body = b"content\n"
|
||||
meta = {
|
||||
"mtime_ns": 1,
|
||||
"size_bytes": len(body),
|
||||
"kind": "regular_file",
|
||||
"unix_mode": 33188,
|
||||
}
|
||||
"""A local write failure leaves no partial cache file or sidecar."""
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._execute_rust_bridge_request",
|
||||
lambda host_alias, method, params, **_kwargs: {
|
||||
"ok": True,
|
||||
"result": {
|
||||
"metadata": meta,
|
||||
"body_b64": base64.b64encode(body).decode("ascii"),
|
||||
},
|
||||
"sessions.ssh_file_transport._rust_file_open_transaction",
|
||||
lambda **_kwargs: {
|
||||
"outcome": "TRANSPORT_ERROR",
|
||||
"detail": "local cache write failed: permission denied",
|
||||
},
|
||||
)
|
||||
|
||||
target = tmp_path / "cache" / "f2.txt"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_write_bytes = Path.write_bytes
|
||||
|
||||
def failing_write_bytes(self, data):
|
||||
if self == target:
|
||||
raise OSError("permission denied")
|
||||
return original_write_bytes(self, data)
|
||||
|
||||
monkeypatch.setattr(Path, "write_bytes", failing_write_bytes)
|
||||
|
||||
try:
|
||||
open_remote_file_into_local_cache(
|
||||
"host",
|
||||
remote_absolute_path="/srv/ws/f2.txt",
|
||||
local_cache_path=target,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
open_remote_file_into_local_cache(
|
||||
"host",
|
||||
remote_absolute_path="/srv/ws/f2.txt",
|
||||
local_cache_path=target,
|
||||
)
|
||||
|
||||
assert not target.exists()
|
||||
sidecar = target.with_suffix(target.suffix + ".sessions-metadata")
|
||||
assert not sidecar.exists(), "sidecar should not be written on write failure"
|
||||
|
||||
Reference in New Issue
Block a user