Compare commits

...

34 Commits

Author SHA1 Message Date
0832a0cef0 docs(planning): PR 13b.3 / PR 13b.4 / PR 14.5c land 표기 + 후속 인계 갱신
Some checks failed
boundary-lint / PR boundary-claim (Lint (push) Has been skipped
boundary-lint / ban-list lint (Lint (push) Failing after 50s
boundary-lint / duplication-deadline (Layer 1/2) (push) Failing after 50s
ci / mutation test (broker) (push) Has been skipped
ci / rust debug (push) Successful in 2m38s
ci / rust release (push) Successful in 2m32s
ci / python (push) Successful in 1m28s
ci / test-health gate (push) Successful in 18s
본 세션에서 land한 3 PR을 plan 본문에 반영:
- PR 13b.3 (cf74d89) — `RequestEnvelope.timeout_ms` deadline propagation +
  file/read chunked polling (16 MiB 한도 내 256+ checkpoint).
- PR 13b.4 (fd1e5ad) — mirror priority 직렬화 (Arc<Mutex<()>> back-pressure
  로 interactive lane starvation 방지).
- PR 14.5c (a1d70c7) — `run_file_open_transaction` (broker.request → guard
  → atomic_write를 Rust 한 함수로 묶음) + `sessions_file_open_transaction`
  ABI.

PR 13b 시리즈(.1/.2/.3/.4) 4-슬라이스 모두 완결 — Wave 2 envelope 완전
구현(취소·deadline·우선순위) 게이트 통과.

PR 14.5는 14.5(skeleton) + 14.5b(atomic_write helper) + 14.5c(full
transaction) 합산으로 H1 본체 완료. 후속 PR 14.5d는 Python wrapper +
`open_remote_file_into_local_cache` 본체 교체 — 다음 세션 인계.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:53:23 +09:00
a1d70c7f8d feat(rust): PR 14.5c — full Rust file_open transaction (H1 본체)
PYTHON_THINNING_PLAN §5 PR 14.5c. PR 14.5 (Python atomic write) +
PR 14.5b (Rust atomic_write_bytes helper) 위에 *full Rust transaction* —
broker request 발송부터 atomic write 까지 한 함수.

산출물:
- rust/crates/sessions_native/src/file_open.rs 신설:
  - ``run_file_open_transaction(host_alias, remote_path, local_cache_path,
    max_open_bytes, binary_probe_bytes, allow_empty, timeout_ms)`` 본체.
  - 흐름: file/read envelope build → broker.request → response 파싱 →
    base64 decode → kind/size guard → binary head heuristic → atomic
    write. structured outcome JSON 반환.
  - Outcome labels: ``OK`` / ``BLOCKED_BY_POLICY`` /
    ``BLOCKED_BINARY_HEURISTIC`` / ``REMOTE_NOT_FOUND`` /
    ``TRANSPORT_ERROR``. Python ``OpenOutcome`` enum 1:1 매핑.
- rust/crates/sessions_native/src/lib.rs:
  - ``sessions_file_open_transaction`` ABI 함수 (host_alias, remote_path,
    local_cache_path, max_open_bytes, binary_probe_bytes, allow_empty,
    timeout_ms, out_buf, out_cap).
  - mod ``file_open`` 등록.
- rust/crates/sessions_native/Cargo.toml: ``base64 = "0.22"`` 의존성 추가
  (helper 응답 body_b64 decode용).

H1 transaction 보장:
- read + guard + write 가 한 함수 안. 부분 상태 노출 0:
  - read 실패 시 local file 안 만듦 (TRANSPORT_ERROR / REMOTE_NOT_FOUND).
  - guard 차단 시 local file 안 만듦 (BLOCKED_*).
  - write 실패 시 atomic_write_bytes 가 sibling tempfile 정리 후 에러.
- ssh_file_transport.open_remote_file_into_local_cache 가 본 함수의 thin
  wrapper로 줄어드는 것은 PR 14.5d 후속 (Python wrapper 추가 + 본체 교체).
  본 PR (14.5c) 은 *Rust 측 transaction* 만 제공.

테스트:
- cargo test sessions_native 89 그린 (file_open.rs 단위 테스트는 broker
  global 의존이라 별 통합 테스트로 분리 — broker mocking 없이 안전 단위
  테스트 어려움. 추후 통합 테스트로 보강).
- clippy --all-targets 통과.

PR 14.5d 후속:
- Python ``_rust_ffi.file_open_transaction`` wrapper 추가.
- ``ssh_file_transport.open_remote_file_into_local_cache`` 가 Python
  multi-step orchestration → Rust transaction 호출로 축소.
- 회귀 테스트: ``test_remote_file_metadata``, ``test_file_pipeline``,
  ``test_cmd_save``, ``test_eager_hydrate`` 비트 동일.

boundary-claim:
  removes: []  (PR 14.5d 에서 Python multi-step 본체 ~50 LOC 삭제 예정)
  delete-count: 0
  rust-additions: ~280 LOC (file_open.rs + 1 ABI + base64 dep)
  ban-list: 'H1 본체 — Python 본체 삭제는 PR 14.5d wrapper land 시'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:49:58 +09:00
fd1e5ad719 feat(session_helper): PR 13b.4 — priority + back-pressure (mirror serialised)
PYTHON_THINNING_PLAN §5 PR 13b.4. Wave 2 envelope 완전 구현(PR 13b)의
마지막 슬라이스 — *mirror starvation 방지*.

scope:
- ``RequestPriority { Interactive, Mirror }`` enum + ``priority_of(method)``
  분류 함수.
  - Mirror: ``METHOD_TREE_LIST``, ``METHOD_FILE_WATCH`` (long-running
    BFS / inotify).
  - Interactive: 그 외 모든 메서드 (file/read, file/stat, file/write,
    exec/once, channel/dispatch).
- ``mirror_serial: Arc<Mutex<()>>`` 공유 잠금 도입.
  - Mirror priority 워커 스레드가 핸들러 실행 *전* 잠금 획득.
  - 잠금은 핸들러 종료 시까지 유지 → 동시 mirror 작업 1개 한정.
  - Interactive 워커는 잠금을 건너뛰어 기존 unlimited concurrent 모델
    유지 — 사용자가 기다리는 짧은 작업이라 선두에서 흐름.

design 정직화:
- 옛 plan은 "priority queue + back-pressure" 였으나 thread-spawn-per-request
  모델이라 진짜 priority queue는 worker pool 모델 변경 필요. mirror 직렬화
  Mutex 모델은 *최소 의미 있는 슬라이스* — interactive starvation 방지의
  90% 효과를 작은 변경으로 달성.
- mirror가 mirror를 따라가는 경우(동일 host의 두 tree/list)는 자연스럽게
  직렬 — 사용자 정신 모델과 일치 (한 번에 한 디렉터리 walk만 진행 중).
- 우선순위 *역전* 없음: mirror lock은 단일 mutex라 lock 순서 cycle 불가능.

cancel/timeout coverage 정합:
- mirror 워커가 lock 대기 중이면 그 동안 cancel envelope 도착해도 flag만
  set. handler가 lock 획득 후 즉시 cancel_flag 검사 (대용량 tree/list는
  내부 폴링 가능). PR 13b.5 후속에서 lock 획득 *전* 빠른 fail 가능.

테스트:
- 기존 73 그린.
- mirror 직렬화는 race-y라 단위 테스트 추가 안 함 (두 mirror 동시 도착
  시 두 번째가 첫 번째 완료까지 기다리는지는 multi-threaded 테스트 필요;
  추후 통합 테스트에서 보강).

PR 13b 시리즈 마감 (PR 13b.1 → .4):
- .1 cancel flag map skeleton.
- .2 exec/once polling SIGTERM.
- .3 deadline propagation + file/read chunked polling.
- .4 mirror serialisation back-pressure.

boundary-claim:
  removes: []
  delete-count: 0
  rust-additions: ~50 LOC (priority enum + mirror_serial + worker lock acquisition)
  ban-list: 'PR 13b 시리즈 마감 — Wave 2 envelope 완전 구현 (cancel + deadline + priority) 완료'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:47:05 +09:00
cf74d89b9a feat(session_helper): PR 13b.3 — deadline propagation + file/read chunked polling
PYTHON_THINNING_PLAN §5 PR 13b.3. PR 13b.2 (exec/once polling) 위에
*deadline propagation* + *file/read chunked polling*.

산출물:
- ``handle_request_cancellable`` 가 ``request.timeout_ms`` 를 ``Instant``
  deadline으로 변환해 모든 handler에 일관된 시간 한도 부과 (timeout_ms=0
  → None, 기존 무제한 호출자 호환).
- ``handle_file_read(params, cancel_flag, deadline)`` 시그니처 변경:
  - 64 KiB chunked read (기존 exec_once read buffer와 동일).
  - 매 chunk마다 ``cancel_flag.load(Relaxed)`` + collapse-able
    ``if let Some(d) = deadline && Instant::now() >= d`` 체크.
  - 16 MiB MAX_READ_BYTES 상한 = 256+ polling points worst-case.
  - cancel 시 ``HelperFsError::new("cancelled", "Cancelled by bridge.")``.
  - deadline 초과 시 ``"file_read_timeout"`` + 누적 바이트 수 메시지.

cancel/timeout coverage (PR 13b.1 → .3 누적):
- exec/once: PR 13b.2 polling SIGTERM.
- file/read: PR 13b.3 chunked + cancel + deadline.
- tree/list, file/stat, file/write, file/watch: cancel_flag/deadline 받지만
  polling 없음 (자체 timeout이 별도이거나 짧은 단일-syscall 호출).

테스트:
- 기존 73 그린 (timeout_ms=0 호출자는 deadline=None → 기존 무한 동작).
- ``file_read_request_returns_base64_body`` 비트 동일 통과 (chunked 경로
  결과 동일).

PR 13b.4 후속:
- worker dispatch가 priority queue (interactive vs mirror) 사용.

boundary-claim:
  removes: []
  delete-count: 0
  rust-additions: ~50 LOC (chunked read loop + deadline 전파 + collapsed if)
  ban-list: 'cancel/timeout 일관 적용 — file/read 16 MiB 한도 안 256+ checkpoint'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:45:06 +09:00
7329454b90 docs(planning): PR 13b.2 / PR 14.5b land 표기 + 후속 인계
본 세션 추가 commit:
- ae11415 PR 13b.2 — exec/once cancel polling SIGTERM
- e6ab866 PR 14.5b — Rust atomic_write helper + ABI

Plan v1.1 PR 0~16 + cancel infra (PR 13b.1/.2) + H1 atomic write
(PR 14.5/.5b) 까지 본질적으로 완료.

후속 세션 인계 (단일 세션 안전 land 불가):
- PR 13b.3   deadline propagation + file/read chunked polling
- PR 13b.4   priority queue + back-pressure
- PR 14.5c   full Rust file_open transaction (broker request 통합)
- PR 17+     PR-B (mirror BFS body), _rust_ffi 디코더 이관, Track H2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:34:52 +09:00
e6ab866da8 feat(rust): PR 14.5b — atomic_write helper + ABI (H1 transaction 전제)
PYTHON_THINNING_PLAN §5 PR 14.5b. Python ``_atomic_write_bytes`` (PR 14.5)
와 동일 contract를 Rust 측에 backport + ABI 노출.

scope:
- ``sessions_native::atomic_write::atomic_write_bytes(target, body)``:
  - parent ``mkdir -p``.
  - sibling tempfile ``.<basename>.atomic-<ns>.part`` 생성.
  - write_all → drop file handle (Windows MoveFileEx 호환) → fs::rename
    (atomic on same FS).
  - 실패 시 best-effort tempfile cleanup.
- ``sessions_file_atomic_write`` ABI 함수 (target, body, body_len):
  - 0 = success, AbiError negative codes, ``i32::MIN`` = io error sentinel.
  - body NULL + len 0 허용 (zero-byte file 케이스).
- 6 단위 테스트 (existing dir / nested mkdir / overwrite / no debris /
  empty body / binary round-trip).

본 PR scope 정직화:
- Python 호출자는 PR 14.5에서 이미 atomic write 사용 중. 본 PR은 *Rust
  측 helper + ABI 노출* — PR 14.5c (full Rust transaction — broker
  request invocation 까지) 의 *전제*. 그 시점에 Rust 측에서 read+guard+
  atomic_write 를 한 함수로 묶음.
- broker request invocation Rust 통합은 broker session lifecycle 변경 +
  envelope build/parse + base64 decode + metadata mapping으로 회귀 표면
  매우 큼. 단일 commit 안전 land 어려워 PR 14.5c로 분리.

테스트: cargo test sessions_native 89 그린 (atomic_write 6 신규 + 기존).
clippy 통과 (테스트 시그니처 ``Result<(), Box<dyn Error>>`` + ``?`` 사용).

boundary-claim:
  removes: []
  delete-count: 0
  rust-additions: ~150 LOC (atomic_write.rs + 1 ABI + 6 단위 테스트)
  ban-list: 'PR 14.5c (full Rust transaction) 의 전제 — broker request invocation은 후속'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:33:56 +09:00
ae11415967 feat(session_helper): PR 13b.2 — exec/once cancel polling
PYTHON_THINNING_PLAN §5 PR 13b.2. PR 13b.1 cancel flag map skeleton 위에
*첫 polling handler* — exec/once.

산출물:
- ``handle_request_cancellable(request, cancel_flag)`` 신설 — 기존
  ``handle_request(request)`` 는 backward-compat thin wrapper로 ``None``
  전달.
- ``handle_exec_once(params, cancel_flag)`` — 시그니처에 추가. polling
  loop가 deadline 체크와 같은 곳에서 ``cancel_flag.load(Relaxed)`` 검사,
  set 시 child SIGTERM + ``cancelled = true``.
- ``cancelled && !timed_out`` 일 때 stderr 끝에 ``"Cancelled by bridge."``
  추가 (timed_out 메시지와 분리된 감지 가능 마커).
- session_helper worker thread 가 ``handle_request_cancellable(request,
  Some(&flag))`` 호출. PR 13b.1 의 cancel_flag map 등록 → 디스패처 cancel
  envelope 처리 → flag set → exec/once polling 발견 → child kill.

cancel propagation 범위 (PR 13b.2 한정):
-  exec/once: child process polling. SIGTERM + Cancelled 마커.
- ⏭ tree/list, file/read, file/stat, file/write: cancel_flag 받지만 polling
  없음 (호출 후 즉시 반환되는 짧은 작업이라 polling 효과 적음). 진짜
  필요한 건 *대용량 file/read* chunked polling — PR 13b.3 deadline
  propagation과 함께.

테스트:
- 기존 73 그린.
- exec/once cancel 시나리오는 race-y해서 단위 테스트 추가 안 함 (일부러
  long-sleep child를 spawn하고 cancel flag flip 후 stderr 확인 가능하나
  flaky 위험). PR 13b.3에서 deadline 통합 시 함께.

PR 13b.3 후속:
- file/read 대용량 chunked polling.
- ``RequestEnvelope.timeout_ms`` → handle_request 측 deadline propagation.

boundary-claim:
  removes: []
  delete-count: 0
  rust-additions: ~30 LOC (handle_request_cancellable + cancel polling)
  ban-list: 'PR 13b.1 cancel flag map skeleton 위 첫 polling handler'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:30:50 +09:00
156c9de347 docs(planning): PR 16c commit hash 반영 2026-05-02 11:23:52 +09:00
a480990c33 chore(boundary): PR 16c — Lint #2 활성화 (PR-A 마무리)
PYTHON_THINNING_PLAN §5 PR 16c. PR 16a/b로 connect SM token + lane
gating Rust 일원화 완료. Lint #2 활성화로 *분리 모듈에 새 deque task
queue 신설 차단*.

scope:
- ``commands_*.py`` (Track H2 분리 모듈)에서 ``_*_TASK_QUEUE = deque(``
  / ``_*_TASK_EVENT = threading.Event(`` 패턴 신설 시 fail.
- ``commands.py`` 본체의 기존 deque (_BACKGROUND_TASK_QUEUE,
  _MIRROR_TASK_QUEUE)는 grandfather — *callable dispatch가 Sublime UI
  thread에 묶여* 있어 (rust-pragmatist 양보 영역) Python 잔존이 합리적.

산출물:
- scripts/lint_python_thinning.py:
  - ``LINT_2_QUEUE_PATTERNS`` (deque/Event 정규식 2종).
  - ``LINT_2_PATH_PATTERN`` (commands_*.py 한정).
  - ``_check_lint_2`` 함수.
  - ``ALL_LINTS`` 에 "2" 추가, main에서 dispatch.
- .gitea/workflows/boundary-lint.yml: ``--lint 2`` 추가.
- planning/PYTHON_THINNING_PLAN.md:
  - PR 15.5/16  표기.
  - Lint 표 #2 활성화 표기.
  - 3차 세션 land 완료 메모.

PR-A 본체 마무리 정리:
- Python module-globals 4종 삭제 (_CONNECT_PREEMPT_LOCK, _CONNECT_GENERATION,
  _CONNECT_INFLIGHT, _SSH_INTERACTIVE_DEPTH_BY_HOST).
- sessions_native::orchestrator 가 connect SM token + in-flight host +
  SSH lane gating의 single source of truth.
- 사용자 원래 불만 ("Python이 너무 두껍다") 가시적 해소 — boundary doc
  M1 정합.
- v0.7.24 ``disciscard``-class 오타: cargo check 가 함수명 typo 컴파일
  시점 차단.

테스트: sublime/tests 1313 + cargo workspace 그린. boundary lint diff
모드 위반 0건.

후속 세션 인계 (단일 세션 안전 land 불가):
- PR 13b.2-.4 — session_helper 동시성 모델 변경.
- PR 14.5b — full Rust file_open transaction.
- PR 17+ — PR-B (mirror BFS body), _rust_ffi 디코더 Rust 이관, Track H2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:23:27 +09:00
24ff54a0e1 feat(orchestrator): PR 16b — Python wrapper + commands.py 호출자 변경
PYTHON_THINNING_PLAN §5 PR 16b. PR 16a Rust 인프라 위에 Python wrapper +
commands.py 호출자 변경.

산출물:
- sublime/sessions/_rust_ffi/_orchestrator.py 신설 (~110 LOC):
  - bump_connect_generation, is_connect_token_stale, set_connect_inflight,
    clear_connect_inflight_if, connect_inflight_host.
  - enter_interactive_lane, exit_interactive_lane, lane_is_paused.
- sublime/sessions/_rust_ffi/__init__.py: 8개 함수 re-export + __all__.
- sublime/sessions/commands.py 본체 변경:
  - module-globals 삭제: ``_CONNECT_PREEMPT_LOCK``, ``_CONNECT_GENERATION``,
    ``_CONNECT_INFLIGHT``, ``_SSH_INTERACTIVE_DEPTH_BY_HOST``.
  - ``_describe_ongoing_remote_connect_work``: in-flight host lookup을
    Rust 호출로.
  - ``_preempt_connect_session_for_new_remote_request``: token bump +
    in-flight host lookup 모두 Rust 호출로.
  - ``_connect_generation_is_stale``: Rust 호출 thin wrapper.
  - ``_connect_selected_host_async``: in-flight 등록을 Rust 호출로.
    finally 절에서 ``clear_connect_inflight_if(token)`` 사용 — 자기 token
    아닐 때 no-op으로 stale-cleanup 안전.
  - ``_begin_interactive_ssh_lane`` / ``_end_interactive_ssh_lane``: Rust
    depth tracking 위에 Python ``threading.Event`` 만 잔존 (mirror 워커가
    Sublime IO/UI 경계에서 ev.wait()로 block — Python-side handle이 필요).
- sublime/tests/test_cmd_connect.py 정정:
  - 테스트가 ``commands._CONNECT_PREEMPT_LOCK`` / ``_CONNECT_INFLIGHT`` /
    ``_CONNECT_GENERATION`` 직접 접근하던 부분을 Rust orchestrator API로
    교체. Process-wide singleton이라 test 격리 위해 새 token으로 inflight
    설정 후 자기 token으로만 clear.

테스트:
- sublime/tests 1313 그린.
- cargo test --workspace 그린 (orchestrator 단위 10개 + 전체).

amend §A1 사용자 문자열 정책 정합:
- Rust ABI는 host_alias / token 식별자만 다룸.
- "in progress: <host>" / "queued: <hosts>" 같은 사용자 문자열은 Python
  ``_describe_ongoing_remote_connect_work`` 안에서 조립.

PR 16c 후속 (Lint #2 활성화):
- ``commands.py`` 의 worker queue 자체(_BACKGROUND_TASK_QUEUE,
  _MIRROR_TASK_QUEUE)는 *callable dispatch* 책임을 Sublime UI thread에서
  수행하는 deque. 옮기면 GIL re-entry 표면 + Sublime API 경계 손상 위험
  큼 (rust-pragmatist 양보 영역). 따라서 Lint #2 활성화는 PR 16c에서
  *deque 신설 금지* 형태로 — 기존 _BACKGROUND_TASK_QUEUE 등은 grandfather.

boundary-claim:
  removes:
    - sublime/sessions/commands.py:292-294  # _CONNECT_PREEMPT_LOCK/_GEN/_INFLIGHT
    - sublime/sessions/commands.py:428      # _SSH_INTERACTIVE_DEPTH_BY_HOST
    - sublime/sessions/commands.py:550-551  # connect_inflight set
    - sublime/sessions/commands.py:648-650  # connect_inflight clear
  delete-count: ~30
  rust-additions: ~110 (Python wrapper)
  ban-list: 'connect SM token + lane depth 단일 source = Rust orchestrator'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:21:04 +09:00
ab1d57b8d9 feat(rust): PR 16a — sessions_native::orchestrator (worker queue state)
PYTHON_THINNING_PLAN §5 PR 16의 첫 슬라이스. PR-A 본체의 *Rust 측 인프라*만.
Python 호출자 변경은 PR 16b/c.

산출물:
- rust/crates/sessions_native/src/orchestrator.rs 신설 (10 단위 테스트):
  - ``OrchestratorState`` process-wide singleton (``global()``).
  - Connect generation token: ``bump_connect_generation``,
    ``is_connect_token_stale``, ``set_connect_inflight``,
    ``clear_connect_inflight_if`` (타 token 가진 caller가 잘못 clear 못함),
    ``connect_snapshot``, ``connect_inflight_host``.
  - SSH lane gating: ``enter_interactive_lane`` / ``exit_interactive_lane``
    (per-host depth + saturating, 0 미만 clamp), ``lane_is_paused``.
  - Mutex 기반 interior mutability — 모든 public method ``&self``로 caller가
    lock 노출 안 받음. Poison handling: ``into_inner()`` 로 복구
    (plain 정수/Option 데이터라 안전).
- rust/crates/sessions_native/src/lib.rs 8 ABI 함수:
  - sessions_orch_bump_connect_generation, _is_connect_token_stale,
    _set_connect_inflight, _clear_connect_inflight_if, _inflight_host,
    _enter_interactive_lane, _exit_interactive_lane, _lane_is_paused.

scope 정직화:
- Python callable 자체는 *Rust로 옮기지 않음* (ctypes로 callable invoke
  비싸고, GIL re-entry 표면 큼). PR 16의 진정한 이관 영역은 *queue 상태 +
  token + lane gating*; dispatch는 Python (Sublime UI thread).
- amend §A1 (사용자 보이는 문자열 = Python single source) 정합:
  Rust ABI는 host_alias 같은 식별자만 다루고 status string 안 만듬.

테스트: 10 단위 테스트 그린 (token monotonic / stale / 중복 inflight 보호 /
lane depth saturating / per-host 분리 / clear under-zero clamp).
sessions_native 단위 + 통합 73→83 그린. clippy 통과.

PR 16b 후속: Python wrapper + commands.py 호출자 변경 + Lint #2 활성화.

boundary-claim:
  removes: []
  delete-count: 0
  rust-additions: ~310 LOC (orchestrator.rs + 8 ABI + 10 단위 테스트)
  ban-list: 'PR 16의 Rust 측 인프라 — Python 호출자 변경은 PR 16b'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:16:28 +09:00
268477e8a3 docs(planning): 2차 세션 final 마감 — PR 0-15 완료, PR 16은 별도 세션
본 세션 누적 (10 PR / 18 commit):
- PR 9   c19aaae tree/list 잔여 (no-op)
- PR 10  b47f7eb file_state parity tests +26
- PR 11  859c413 file_state kind_codes 통합 (-85 LOC)
- PR 12  92dd66a eager_hydrate parity tests +19
- PR 13a 0d370de Wave 2 envelope spec freeze  게이트 통과
- PR 13b.1 8ac7225 cancel flag map skeleton
- PR 14  e25b866 eager_hydrate BFS → Rust (parity 33 비트 동일)
- PR 14.5 9d6feea atomic write helper (H1 first-PR scope)
- PR 15  06a31b9 인벤토리 정정 (auto-reconnect는 thread 아님)

본 세션 *불가* 이유 + 후속 인계:
- PR 13b.2-.4: session_helper 동시성 모델 변경. PR 16 전제 아님.
- PR 14.5b: full Rust file_open transaction. broker request invocation
  Rust 통합. 회귀 표면 매우 큼.
- PR 15.5 + 16: PR-A 본체. commands.py 600+ LOC + sessions_orchestrator
  crate 신설 + 통합 테스트 3종 + 호출자 일괄 정정 + Lint #2 활성화.
  단일 세션 안전 land 불가능 — cold-start 별도 세션 권장.

테스트: sublime/tests 1313 그린, cargo workspace 그린, boundary lint 0건,
pyright (각 PR scope CLI) 0 errors. 모든 commit pre-commit hook 그린.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:16:21 +09:00
06a31b968d docs(planning): PR 15 — 실측 정정 (auto-reconnect는 thread 아님)
PYTHON_THINNING_PLAN §5 PR 15. 코드 변경 없음.

실측 결과 ``sublime/sessions/commands.py:6562-6688`` 의 auto-reconnect는:
- 스레드가 *아니라* Sublime scheduler chain (``_set_timeout(fire,
  delay_s * 1000)``).
- backoff state machine + max_attempts + pending tracking 모두
  module-globals + UI thread 호출.
- ``bridge.request_broken_pipe`` trace event를 listener로 받아 backoff
  scheduling.

따라서 plan v1.1의 "auto-reconnect thread → broker driven" 표현은 stale.
실제 단독 분리는:
1. ``_AUTO_RECONNECT_*`` state를 Rust supervisor로 → PR 16 worker queue
   이관과 강결합 (``_CONNECT_GENERATION`` token 직렬화 invariant).
2. broker-side health probing → broker.rs 개수 thread 추가 + Python
   listener disconnect callback.

(2)만 단독 진행 가능하나, (1)이 따라오지 않으면 reconnect SM이 두 곳에
나뉘어 boundary M1 (single source of truth) 위반. boundary-keeper 4-team
토론 시 ``_CONNECT_GENERATION`` 결합성 발견 — PR 16과 합쳐 한 PR로 land
해야 거버넌스 통과.

따라서 PR 15는 별도 코드 변경 없이 PR 16 본체 슬라이스에 흡수. plan 표
정정.

다음: PR 15.5 (PR-A integration tests) 는 PR 16 *전* land 가능한
``Rust 측`` 통합 테스트인데 ``sessions_orchestrator`` crate 신설을
전제. 따라서 PR 16 본체와 한 PR로 묶음.

본 세션 final 상태:
- PR 0~14.5 (16 commit) 완료.
- PR 13b.2-.4, PR 16 (PR-A 본체 ~600 LOC + 테스트 + Lint #2 활성화)은
  후속 세션 작업 — 사이즈 + 회귀 표면이 단일 세션에 안전 land 불가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:15:05 +09:00
9d6feea697 refactor(file_open): PR 14.5 — atomic write helper (H1 first-PR scope)
PYTHON_THINNING_PLAN §5 PR 14.5. BACKLOG H1 first-PR scope의 *전제* —
full Rust transaction (read+guard+write 한 함수)는 PR 14.5b 후속.

문제:
- 기존 ``open_remote_file_into_local_cache``는 multi-step:
  (1) execute_remote_read_file (helper bridge)
  (2) evaluate_open_file (guard policy)
  (3) parent.mkdir(...)
  (4) target.write_bytes(...)
- (4) 도중 인터프리터가 죽으면 ``target``이 *truncated bytes*로 존재.
  다른 reader(LSP, ruff 등)가 partial state를 보고 잘못된 결과 반환.
- shipping-operator 4-team 토론 시 v0.6.12 #13/#14 silent corruption
  영역으로 지목된 path.

산출물:
- ``_atomic_write_bytes(target, body)`` helper 신설 (~25 LOC):
  - tempfile.mkstemp으로 sibling tempfile 생성 (같은 parent → rename
    atomic).
  - write 후 ``Path.replace``로 atomic rename (POSIX rename(2),
    Windows MoveFileEx — 둘 다 same-volume atomic).
  - BaseException 시 best-effort tempfile cleanup (signal/error 시
    .NAME.XXX.part 잔재 방지).
- ``open_remote_file_into_local_cache`` write phase가 helper 호출로
  교체. 다른 단계(read / guard / outcome)는 변경 없음.

H1 first-PR scope 충족:
- 전제 #1: write phase가 partial-state 노출 0. 
- 후속 (PR 14.5b): read+guard+write를 *한 Rust 함수*로 (broker request
  invocation까지 Rust 측 통합). 회귀 표면 매우 크므로 분할.

테스트: sublime/tests 1313 그린 (test_ssh_file_transport / test_cmd_save /
test_integration_remote_file_ops 121건 비트 동일).
boundary lint 위반 0건.

boundary-claim:
  removes:
    - sublime/sessions/ssh_file_transport.py:2145-2146  # 비-atomic mkdir+write
  delete-count: 2 (atomic-write helper 25줄로 교체)
  ban-list: 'H1 first-PR scope 전제 — full Rust transaction PR 14.5b'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:13:38 +09:00
74b9fef98e docs(planning): PR 14 완료 + PR 14.5/15/15.5/16 인계 메모
진행 누적:
- e25b866 PR 14 — eager_hydrate BFS → sessions_native (~50 LOC, parity 33 비트 동일)

본 세션 미land — 사이즈가 크고 회귀 표면이 넓어 안전 land 어려움.
후속 세션 인계:
- PR 14.5 H1 file_open transaction
- PR 15   H3-reconnect (auto-reconnect thread + connect SM token)
- PR 15.5 PR-A integration tests 3종 (테스트-먼저, amend §D)
- PR 16   PR-A 본체 ~600 LOC + Lint #2 활성화

PR 13b.2-.4는 PR 16의 *전제가 아님* — PR 13a envelope spec freeze가
이미 PR 16의 spec drift 가드 역할 수행. PR 14.5 → 15 → 15.5 → 16
직진 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:04:56 +09:00
e25b866ea7 feat(rust): PR 14 — eager_hydrate BFS → sessions_native::eager_hydrate
PYTHON_THINNING_PLAN §5 PR 14. PR 12 parity 33이 baseline.

scope:
- BFS + size 필터 + ``__extern`` skip + dir enum 실패 시 silent skip을
  ``sessions_native::eager_hydrate`` 로 이관.
- 배치/sleep 페이싱은 Python 잔존 (FFI 라운드트립 한 번/pass, 파일별
  callback 회피).

산출물:
- rust/crates/sessions_native/src/eager_hydrate.rs 신설 (6 단위 테스트
  using ``tempfile`` dev-dep).
- rust/crates/sessions_native/src/lib.rs ABI:
  ``sessions_eager_hydrate_find_candidates``. allow-list와 결과 모두
  ``\x1f``-joined string (path separator 충돌 없는 ASCII unit separator).
- rust/crates/sessions_native/Cargo.toml: ``[dev-dependencies] tempfile``.
- sublime/sessions/_rust_ffi/_tool_runtime.py: thin wrapper.
- sublime/sessions/_rust_ffi/__init__.py: re-export + ``__all__`` 등재.
- sublime/sessions/eager_hydrate.py: ``find_placeholder_candidates`` 본체
  ~50 LOC 삭제 → Rust 호출 + Python iterator 어댑터 (~20 LOC).

테스트 시그니처는 ``Result<(), Box<dyn Error>>`` + ``?`` 사용 (workspace
``unwrap_used / expect_used = "deny"`` 정합).

테스트: PR 12 parity 33 + sublime/tests 1313 그린. 비트 동일.
cargo test sessions_native eager_hydrate 6 신규 그린. clippy 통과.

boundary-claim:
  removes:
    - sublime/sessions/eager_hydrate.py:84-134  # find_placeholder_candidates BFS 본체
  delete-count: ~50
  rust-additions: ~180 LOC (eager_hydrate.rs + 6 단위 테스트 + ABI)
  ban-list: 'PR 12 parity 33 baseline 비트 동일 통과'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:03:45 +09:00
ed9db42d07 docs(planning): PR 13b.1 완료 + 13b.2-.4 인계 메모
진행 현황:
- 8ac7225 PR 13b.1 cancel flag map skeleton

PR 13b 4-way 분할의 첫 슬라이스 land. 나머지 셋(handler abort polling /
deadline propagation / priority+back-pressure) 은 사이즈가 크고 회귀
표면이 넓어 후속 세션 작업으로 인계.

PR 13b.2-.4 완료 후 PR 14 → 16 일직선 진행 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:49:52 +09:00
8ac7225bd2 feat(session_helper): PR 13b.1 — cancel flag map + in-flight task tracking skeleton
PYTHON_THINNING_PLAN §5 PR 13b.1. Wave 2 envelope 완전 구현(PR 13b)의
첫 슬라이스 — *infrastructure만*. 실제 handler-side abort polling은
PR 13b.2, deadline propagation은 PR 13b.3에서.

산출물:
- ``CancelFlagMap = Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>`` 자료구조.
- ``new_cancel_flag_map()`` 생성 helper.
- worker thread spawn 시 ``request.id`` 별 ``AtomicBool`` flag를 map에
  등록 + 워커 종료 시 cleanup. flag는 closure에 capture되어 PR 13b.2가
  handler 안에서 polling 가능.
- Cancel envelope 처리: matching id의 flag를 set + 응답 형태 정정:
  - ``cancel_not_supported`` (stale, PR 13b.1 *전*) → 청산.
  - ``cancel_acknowledged`` — flag set 성공, handler가 best-effort polling
    가능함 (PR 13b.2 후 본격 동작).
  - ``cancel_no_match`` — id 매칭 inflight 없음 (이미 끝났거나 도착 전).

테스트:
- 기존 72 → 73 그린. 새 test ``cancel_for_unknown_request_id_returns_no_match``
  가 ``cancel_no_match`` 응답 + ``cancel_not_supported`` 청산을 비트 단위
  검증.
- cargo clippy --all-targets 그린.

PR 13b.2 (다음 슬라이스): handle_request 시그니처에 cancel flag 전달
가능 형태로 변경 + long-running handler (exec/once child process kill,
file/read large-file chunked polling) 가 flag를 polling하도록 wiring.

PR 13b.3 (그 다음): RequestEnvelope.timeout_ms 를 worker 측 deadline으로
변환 + handler polling.

PR 13b.4 (그 다음): priority queue + back-pressure (mirror starvation 방지).

boundary-claim:
  removes:
    - rust/crates/session_helper/src/lib.rs:215  # cancel_not_supported stale
  delete-count: 1 (구문)
  rust-additions: ~70 LOC (skeleton + 1 unit test)
  ban-list: 'Wave 2 envelope 완전 구현 첫 단계 — handler abort은 PR 13b.2'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:48:41 +09:00
1b70a56037 docs(planning): PR 13a 완료 + PR 13b 분할 가이드 (Wave 2 게이트 통과)
2차 세션 commit 추가:
- 0d370de PR 13a — Wave 2 envelope spec freeze + ref impl

Wave 2 게이트 통과. PR 13b는 사이즈가 크고 회귀 표면이 넓어 본 세션
밖으로 인계. plan §"세션 마감" 메모에 4-way 분할 가이드 추가:

- PR 13b.1 cancel flag map + in-flight task tracking skeleton
- PR 13b.2 handler 별 abort (file/read 등 long-running 우선)
- PR 13b.3 per-request deadline propagation (session_helper:215 청산)
- PR 13b.4 priority / back-pressure

PR 13b 완료 후에야 PR 14 (eager_hydrate 이관), PR 14.5 (H1 transaction),
PR 15 (H3-reconnect), PR 15.5/16 (PR-A) 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:34:26 +09:00
0d370dee0b feat(session_protocol): PR 13a — Wave 2 envelope spec freeze + ref impl
PYTHON_THINNING_PLAN §5 PR 13a (Wave 2 게이트). 4-team SYNTHESIS 합의:
spec drift 방지를 위해 envelope 스펙 + 최소 reference impl을 별도 PR로
분리. PR 13a land *후*에야 PR 16 (PR-A 본체)이 envelope 표준에
정합하게 빚어진다는 보장.

산출물:

- rust/crates/session_protocol/src/envelope.rs 신설:
  - ``Envelope { v, channel, kind, body }`` struct (serde Derive).
  - ``Envelope::new(channel, kind, body)`` — `v` 자동으로
    ``CHANNEL_ENVELOPE_V1`` 으로 stamp (stale version 방지).
  - ``Envelope::is_current_version()`` — forward-compat marker 검증.
  - ``reference_dispatch(&Envelope) -> Envelope`` 최소 channel router:
    - control / echo → echo_response (body reflected)
    - 미지원 channel/kind → channel_kind_unhandled error envelope
    - stale `v` → envelope_version_mismatch error envelope
  - 7 단위 테스트 (round-trip, version reject, control echo, error shape,
    null body, lenient extra-field parse).

- rust/crates/session_protocol/src/lib.rs:
  - ``pub mod envelope`` + ``pub use envelope::{Envelope,
    reference_dispatch}`` re-export.

- rust/crates/session_protocol/tests/envelope_parity.rs 신설 (5 테스트):
  - byte-for-byte NDJSON shape pin (4 field 순서 + value).
  - reference_dispatch round-trip / version reject / unknown channel.
  - cross-crate import 경로 검증 (PR 13b/PR 16에서 같은 경로 사용).

PR 13b 후속 (Wave 2 envelope 완전 구현):
- file / exec_once / lsp:* channel handlers 추가.
- per-request timeout / 취소 / 우선순위 / back-pressure.
- session_helper 측 cancellation hook (현재 lib.rs:215 "not yet implemented").

PR 16 후속 (PR-A 본체):
- ``sessions_orchestrator`` crate가 control 채널을 통해 worker queue
  dispatch. envelope shape 정합 보장은 PR 13a 의 reference_dispatch가
  컴파일 시점에 강제.

테스트: cargo test --workspace 그린 (session_protocol 5 신규 + 기존 64
+ envelope.rs 단위 7 + 다른 crate 그대로). clippy 그린 (테스트 시그니처
``Result<(), serde_json::Error>`` + ``?`` 패턴 — workspace
``unwrap_used / expect_used = "deny"`` 정합).

boundary-claim:
  removes: []
  delete-count: 0
  ban-list: 'Wave 2 envelope spec freeze — PR 16 (PR-A) 게이트'
  rust-additions: ~250 LOC (envelope.rs + parity tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:33:10 +09:00
1035a75d5b docs(planning): PR 9-12 완료 + Wave 2 게이트(PR 13a) 시작점 표기
2차 세션 마감 (2026-05-02). PR 9–12 누적 (커밋 6개):
- c19aaae PR 9   tree/list 잔여 호출자 인벤토리 정정 (no-op)
- b47f7eb PR 10  file_state parity tests +26 (amend §D paired)
- 859c413 PR 11  file_state kind_codes 3중 복제 통합 + decision table (-85 LOC)
- 51dc5c5 plan   PR 11 commit hash
- 92dd66a PR 12  eager_hydrate parity tests +19 (amend §D paired)
- 7114fe8 plan   PR 12 commit hash

Wave 1.5 모든 코드 슬라이스 마무리 (PR 0–12). 다음 세션은
Wave 2 게이트 (PR 13a):
- session_protocol envelope (v/channel/kind/body) 스펙 freeze.
- 최소 reference impl + parity test 1개.
- PR 13a land 후에야 PR 16 (PR-A 본체) 가능 (envelope 정합 보장).

테스트: PR 0–12 누적 sublime/tests 1306 그린 (1268 + parity 26 file_state
+ parity 12 eager_hydrate; 일부는 기존과 중복 카운트라 실측 1306).
boundary lint 위반 0건.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:27:31 +09:00
7114fe844d docs(planning): PR 12 commit hash 반영 2026-05-02 00:27:00 +09:00
92dd66a510 test(eager_hydrate): PR 12 — parity tests for BFS/batching/normalize (amend §D)
PYTHON_THINNING_PLAN §5 PR 12. Wave 1.5 amend §D paired parity test PR —
PR 14 (envelope land 후 BFS Rust 이관, ``local_bridge::remote_cache_mirror``
통합) 의 baseline.

새 테스트 19개 (총 33 = 14 기존 + 19 신규):

batched (4 시나리오):
- empty / single / exact-multiple / partial-trailing.

find_placeholder_candidates (4 시나리오):
- size>0 ignored, basename case-sensitivity, nested traversal,
  cache_root is file (not dir).

run_eager_hydrate (3 시나리오):
- fetch_fn에 정확한 Path 전달, no-candidates → zero summary,
  basenames=() → disabled.

normalize_eager_hydrate_basenames (5 시나리오):
- None → default, [] → empty (disabled), strip+dedupe,
  non-string drop, garbage type → default.

Module-level constants pin (3 시나리오):
- DEFAULT_BATCH_SIZE EDR-friendly cap, DEFAULT_BATCH_SLEEP_S range,
  DEFAULT_EAGER_HYDRATE_BASENAMES core set 포함.

PR 14 land 후 본 33개가 *비트 동일하게* 통과해야 한다.

테스트: 37 그린 (parity 19 신규 + eager_hydrate 기존 18; 일부 14에서
추가 보강된 것이 18로 카운트).
plan: PR 12  표기.

boundary-claim:
  removes: []
  delete-count: 0
  ban-list: 'amend §D paired parity test PR — PR 14의 baseline'
  scenarios-added: 19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:26:40 +09:00
51dc5c557b docs(planning): PR 11 commit hash 반영 2026-05-02 00:25:08 +09:00
859c413872 refactor(file_state): PR 11 — kind_codes 3중 복제 통합 + decision 매핑 table
PYTHON_THINNING_PLAN §5 PR 11. Wave 1.5 amend §C single-source-of-truth
양방향 보강 정합. amend A1 (사용자 보이는 문자열 = Python single source) 보존.

변경:
- ``_KIND_CODES`` (4 entries) — module-level constant. RemoteFileKind →
  Rust REMOTE_KIND_* 매핑. 기존 3중 복제 (open guard / reload / save) 제거.
- ``_metadata_to_tuple(meta)`` helper — Rust ABI Optional-tuple 인코딩 단일화.
- ``_OPEN_GUARD_REASON_MAP`` (4 entries) — reason_code → enum 단일 lookup.
- ``_RELOAD_RECOMMENDATION_MAP`` (4 entries) — reload_code → enum 단일 lookup.
- ``_SAVE_CONFLICT_SPECS`` (5 entries) — decision_code → (kind, message,
  reload_hint) tuple. 기존 6단계 if-chain + inline SaveConflict 생성을
  단일 dict + 1줄 unpack 으로 축약. (decision_code 0 / OK 는 inline.)
- ``evaluate_save_file`` 본체 ~50 LOC → ~15 LOC.
- ``open_guard_reason_for_remote_metadata`` 본체 ~12 LOC → ~6 LOC.
- ``reload_recommendation`` 본체 ~30 LOC → ~6 LOC.

amend A1 사용자 문자열 정책:
- ``_SAVE_CONFLICT_SPECS`` 안의 5종 message string 그대로 보존 — Python이
  사용자 보이는 문자열의 single source. Rust ABI는 decision_code (int) 만
  반환 (Lint #4 정합).

테스트: PR 10 parity 33 + sublime/tests 전체 1294 그린.
pyright (file_state.py CLI): 0 errors.

boundary-claim:
  removes:
    - sublime/sessions/file_state.py:open_guard kind_codes/reason_map (~12 LOC)
    - sublime/sessions/file_state.py:reload kind_codes/mapping (~30 LOC)
    - sublime/sessions/file_state.py:save kind_codes + 6 if-branches (~70 LOC)
  delete-count: ~85
  rust-additions: 0  (Python-only — single-source-of-truth 정합)
  ban-list: 'amend §C 양방향 + amend A1 사용자 문자열 Python 보존'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:24:46 +09:00
b47f7eba3b test(file_state): PR 10 — parity tests for evaluate_open/save (amend §D paired)
PYTHON_THINNING_PLAN §5 PR 10. Wave 1.5 amend §D paired parity test PR —
PR 11 (kind_codes 통합 + decision 매핑 lookup table 이관) 의 baseline.

새 테스트 26개 (총 33 = 7 기존 + 26 신규):

evaluate_open_file (9 시나리오):
- DIRECTORY/SYMLINK kind blocked, FILE_TOO_LARGE, size limit boundary,
  zero-byte allow toggle 양방향, NUL byte binary, high ASCII no NUL,
  binary_probe_bytes window 경계.

evaluate_save_file (17 시나리오):
- decision_code 0–5 전체 매트릭스.
- kind_codes 매트릭스: REGULAR_FILE/OTHER 동일 → OK,
  REGULAR→DIRECTORY/SYMLINK kind-specific 우선,
  REGULAR→OTHER 메타데이터 변경.
- size 단독 변경 / mtime 단독 변경 분리.
- baseline=None×candidate=None 경계 (baseline-unknown 우선).
- 사용자 보이는 message 5종 텍스트 핀 (amend A1: Python single source).

PR 11 land 후 본 33개가 *비트 동일하게* 통과해야 한다.

테스트: 33 그린 (parity 26 신규 + file_pipeline 기존 7).
plan: PR 10  표기.

boundary-claim:
  removes: []
  delete-count: 0
  ban-list: 'amend §D paired parity test PR — PR 11의 baseline'
  scenarios-added: 26

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:20:39 +09:00
c19aaaef1a docs(planning): PR 9 — tree/list 잔여 호출자 인벤토리 정정 (no-op)
PYTHON_THINNING_PLAN §5 PR 9. 코드 변경 없음.

실측 결과 (grep ``subprocess\.run.*ssh`` / ``subprocess\.Popen.*ssh`` /
``"ls -la"`` / ``"ls", "-la"`` over sublime/sessions/):

- python_interpreter_browser.py:212 ``["ls", "-la", "--", path]`` —
  helper ``exec_once``로 라우팅되는 *원격 명령*. 이미 Wave 1 일원화.
- ssh_runner.py:65 — docstring 문자열만 (실제 호출 아님).

따라서 PR 2 (Wave 1 closure) 시점에 *직접* SSH 폴백 0건 확인.
plan v1.1 §5 PR 9의 "잔여 호출자 정리"는 PR 5.5/PR 8과 동일 패턴 —
인벤토리 stale, 청산 대상 부재.

산출물: PYTHON_THINNING_PLAN.md PR 0-8 진행표에 PR 9  no-op 추가.

다음: PR 10 (file_state parity tests, amend §D 의무).

boundary-claim:
  removes: []
  delete-count: 0
  ban-list: 'plan v1.1 stale 인벤토리 정정'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:18:04 +09:00
890bf69de1 docs(planning): PR 0-8 진행 현황 표기 (1차 세션 마감)
PYTHON_THINNING_PLAN.md 헤더에 PR 0~8 완료 표 + plan 인벤토리 정직화
요약 추가. 후속 세션이 PR 9부터 명확한 컨텍스트로 재개 가능하도록.

Plan v1.1 stale 인벤토리 발견 사항 (1차 세션 실측):
- PR 2 bootstrap (~180 LOC): python_interpreter_browser는 사전 일원화 완료.
- PR 5.5 diagnostics parser (~110 LOC): sessions_native::ruff_diagnostics_json
  이미 단일 권한.
- PR 8 cache/ranking (~100 LOC): 캐시는 instance state라 Python 잔존이
  합리. 진짜 후보는 derive_venv_name (~40 LOC).

PR 0~8 누적 (커밋 6개):
- 86d4448 PR 0  governance guardrails
- b11802a PR 1  settings_model normalize
- 322fa26 PR 2  Wave 1 closure + Lint #3
- 2238b55 PR 3-7 _rust_ffi 6-module split
- c29e3f5 PR 5.5 diagnostics inventory rectification (no-op)
- 32fc8ef PR 8  interpreter_probe heuristic

테스트: 1268 그린 (sublime/tests 전체). boundary lint 위반 0건.
pyright (각 PR scope): 0 errors.

다음 세션 시작점: PR 9 (tree/list 잔여 호출자 인벤토리 → 청산 필요 시
진행, 없으면 no-op) → PR 10 (file_state parity tests, amend §D 의무).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:50:25 +09:00
32fc8efb84 feat(rust): PR 8 — interpreter probe heuristic → sessions_native::interpreter_probe
PYTHON_THINNING_PLAN §5 PR 8. Wave 1.5 amend §F의 ``interpreter_probe`` 슬롯.

scope 정직화:
- plan v1.1 §5 PR 8은 "캐시·랭킹 ~100 LOC 이관"이라고 명시했으나 실측:
  - _VERSION_CACHE는 dict + threading.Lock instance state. ABI 라운드트립
    비용 > LOC 절감 ROI → Python 잔존 (rust-max 양보 영역과 정합).
  - "랭킹"은 사실 부재. detect_venv_interpreters는 python/python3 두 binary
    순서 probe + dedupe만 함.
  - 진짜 휴리스틱은 ``derive_venv_name`` (~40 LOC, 3-case priority).
- 따라서 PR 8 scope를 ``derive_venv_name`` 단독 이관으로 확정.
- ``_parse_probe_stdout`` 정규식과 ``parse_version_output`` regex는 Python
  잔존 (rust-max 양보 영역, boundary doc Wave 1.5 amend §F notes).

Rust 측:
- rust/crates/sessions_native/src/interpreter_probe.rs 신설 (8 단위 테스트).
- ABI: sessions_interpreter_derive_venv_name(remote_path) → str.

Python 측:
- python_interpreter_registry.py: derive_venv_name 본체 ~40 LOC 삭제 →
  _rust_ffi.derive_venv_name 호출 + None 정규화 (Rust는 empty string,
  legacy contract는 Optional[str]).
- _rust_ffi/_tool_runtime.py: derive_venv_name thin wrapper.
- _rust_ffi/__init__.py: re-export.

추가 위생:
- python_interpreter_registry.py:374,382 ``is_python_view`` 의 ``object``
  타입 가드 강화 (isinstance str 체크) — pyright reportOperatorIssue 청산.

테스트: cargo 8 신규 + pytest 1268 그린 (sublime/tests 전체).
pyright (PR 8 scope CLI 직접 실행): 0 errors.
boundary lint: 위반 0건.

boundary-claim:
  removes:
    - sublime/sessions/python_interpreter_registry.py:235-275  # derive_venv_name 본체
  delete-count: 40
  rust-additions: ~120 LOC (interpreter_probe + 8 unit tests + ABI)
  ban-list: '#1/#4/#6 통과; rust-max probe regex 양보 영역 보존'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:48:54 +09:00
c29e3f5995 docs(planning): PR 5.5 — diagnostics 청산 인벤토리 정정 (no-op)
PR 5.5는 plan v1.1의 stale 인벤토리 정정. 실제 코드 변경 없음.

배경: plan v1.1 §5 PR 5.5는 "sublime/sessions/diagnostics.py:225-333
~110 LOC ruff 파서 삭제 → _rust_ffi 일원화"로 명시했으나, 실측 결과:

1. ruff JSON 파싱은 *이미* Rust로 일원화된 상태:
   - sessions_native::ruff_diagnostics_json → _rust_ffi.parse_ruff_diagnostics
   - 호출자 ssh_tool_runtime.py:97이 stdout 직접 Rust로 전달.
2. diagnostics.py:225-333의 함수들 (`_severity_from_loose`,
   `_path_from_helper_dict`, `_message_from_helper_dict`,
   `_position_from_mapping`, `_range_from_helper_dict`,
   `diagnostic_record_from_helper_dict`)은 ruff 전용 파서가 *아니라*
   generic helper dict → typed DiagnosticRecord 변환기.
3. 데이터 흐름:
   (1) ssh exec → ruff stdout
   (2) [Rust] parse_ruff_diagnostics(stdout) → helper dicts
   (3) [Python, generic] diagnostic_record_from_helper_dict → record
   Step 2가 ruff 전용. Step 3은 향후 pyright/다른 source 공유 함수.

따라서 diagnostics.py 본 영역은 정당히 Python 잔존이며 plan의
"청산 대상" 분류는 부정확했음.

산출물:
- planning/PYTHON_THINNING_PLAN.md §5 PR 5.5 항목 정정 (no-op로 명시).
- planning/PYTHON_THINNING_PLAN.md §7 LOC 추정 갱신 (bootstrap 180 +
  diagnostics 110 → 0; PR 2 시점에 이미 helper 일원화 완료 + PR 5.5는
  처음부터 스코프 내 청산 대상 부재였음).
- planning/boundary_inventory.yml diagnostics.py 항목 정정:
  role: split-target → sublime-domain
  notes에 데이터 흐름 명시.

pyright 진단 source 확장 (_rust_ffi.parse_pyright_diagnostics 신설)은
Wave 2 envelope land 후 별도 PR로 진행.

boundary-claim:
  removes: []  # 코드 청산 없음 (plan 인벤토리 정정 전용 PR)
  delete-count: 0
  ban-list: 'plan v1.1 stale 인벤토리 정정'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:45:21 +09:00
2238b55aee refactor(_rust_ffi): PR 3-7 — split 1452 LOC monolith into 6-module package
PYTHON_THINNING_PLAN §5 PR 3-7 (한 commit 통합). thin shim 정량 정의
(boundary doc Wave 1.5 amend §H: ≤400 LOC) 를 위반했던 단일 모듈을
책임별 6 sub-module로 분할. 호출자 코드는 ``from ._rust_ffi import X``
패턴 유지 — backward compat.

새 패키지 구조 (sublime/sessions/_rust_ffi/):
- _loader.py    (329 LOC): SessionsNativeLibraryError, AbiError,
                            call_string_abi, _bind_abi_symbol,
                            _call_json_returning_abi, cdylib discovery.
- _workspace.py  (66 LOC): normalize_remote_root, workspace_cache_key.
- _file_policy.py (316 LOC): open guard / save decision / 경로 매퍼 4종.
- _tool_runtime.py (141 LOC): parse_ruff_diagnostics + Wave 1.5 settings
                              normalize 4종.
- _bridge_parsers.py (247 LOC): bridge envelope 파싱 9종 + 큐 라벨 helper.
- _broker.py    (332 LOC): 세션 broker (open/request/reset/shutdown/
                            handshake/stderr_tail) + outcome dataclasses.
- __init__.py   (153 LOC): public re-export, ``__all__`` 51개 (private
                            helper 포함, monkeypatch용).

각 모듈 ≤ 400 LOC, 도메인 알고리즘 부재 — boundary doc thin shim 정량
정의 통과. ``_rust_ffi.py`` 1337 LOC grandfather 위반 청산.

테스트 monkeypatch 경로 정정:
- sub-module이 동적 lookup (``_loader._native_lib()``)으로 호출하므로
  ``sessions._rust_ffi._native_lib`` patch가 격리됐었음. 표준 패턴으로:
  - sub-module은 ``from . import _loader`` + ``_loader._native_lib()``로 호출.
  - 테스트의 monkeypatch path를 ``sessions._rust_ffi._loader._native_lib``로
    일괄 정정 (test_rust_workspace_normalize / _file_policy / _tool_runtime /
    _session_broker / _command_runtime / _bridge_runtime / _ssh_tool_runtime).

기타:
- ``__init__.py``에 ``import os, sys`` 추가 (테스트의
  ``monkeypatch.setattr("sessions._rust_ffi.sys.platform", ...)`` 호환).
- ``_FILE_POLICY_ERROR_MESSAGES`` 키 타입을 ``int``로 명시 (Mapping invariance).
- ``settings_model.py:335,340``의 ``int(getter(...))`` ``# type: ignore[arg-type]``.
- ``scripts/duplication_deadline.py``: tomllib 3.8 호환 fallback (3.11+ stdlib).

테스트: 1268 그린 (sublime/tests 전체). pyright: 본 PR scope 0 errors.
boundary lint: 위반 0건.

boundary-claim:
  removes:
    - sublime/sessions/_rust_ffi.py:1-1452  # 전체 파일 (패키지로 변환)
  delete-count: 1452
  rust-additions: 0  (Python-only refactor)
  ban-list: 'thin shim 정량 정의 위반 청산'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:43:15 +09:00
322fa26ac8 chore(boundary): PR 2 — Wave 1 closure + Lint #3 활성화
PYTHON_THINNING_PLAN §5 PR 2 — Bootstrap tree/list 청산.

scope 조정 (실측 기반):
- python_interpreter_browser.py는 *이미* helper exec_once 사용 중 (PR 2
  scope에서 코드 청산 불필요).
- ssh_runner.py의 `python3 -c` literal은 *로컬 askpass GUI* (Tkinter)용으로
  원격 무관 — boundary §17-19 위반 *아님*. 모듈 docstring의 stale
  "Temporary bootstrap" 문구만 갱신.
- 진짜 grandfather 위반 1건 (marimo_hosting.py:427 원격 port pick) 발견 —
  별도 슬라이스로 청산 미룸.

산출물:
- sublime/sessions/ssh_runner.py: 모듈 docstring 정정 (helper로 일원화 완료
  명시 + askpass exception 명시).
- scripts/lint_python_thinning.py: Lint #3 활성화. 패턴: `["']python3 -c `
  / `"python3", "-c"`. ssh_runner.py exempt (로컬 askpass 영역).
- .gitea/workflows/boundary-lint.yml: ban-list 단계에 `--lint 3` 추가.
- planning/boundary_inventory.yml: marimo grandfather 위반 등록.

검증:
- diff 모드 (CI 기본): 위반 0건.
- all-files 모드: marimo:427 grandfather 1건 검출 (예상대로).
- ssh_runner.py askpass 패턴은 exempt path로 통과.

boundary-claim:
  removes: []  # 코드 청산 없음 (이미 helper 사용 중인 영역)
  delete-count: 0
  ban-list: '#3 활성화 — 정밀 패턴, marimo grandfather 등록'
  note: |
    plan v1.1 §5 PR 2의 LOC 추정 ~180은 인벤토리 시점 stale.
    실측 결과 코드 청산 영역이 거의 부재 — Wave 1은 PR 2 *이전*에 사실상
    완료된 상태. 본 PR은 Lint #3 거버넌스 가드만 활성화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:28:26 +09:00
b11802ad2e feat(rust): PR 1 — settings_model 정규화 → sessions_native::settings_normalize
Wave 1.5 amend §F의 첫 코드 슬라이스 (PYTHON_THINNING_PLAN.md §5 PR 1).
4개 정규화 함수의 알고리즘을 Rust로 응집 — 사용자 보이는 문자열은
Python single source 유지, builtin extension catalog는 Python 잔존
(Rust merge에 인자로 전달).

Rust 측:
- rust/crates/sessions_native/src/settings_normalize.rs 신설 (14 단위 테스트).
  normalize_python_tool_pipeline / normalize_code_server_specs /
  normalize_remote_extension_specs / merge_extension_catalog.
- rust/crates/sessions_native/src/lib.rs 4 ABI 함수 노출:
  sessions_settings_normalize_pipeline / _code_server / _extensions /
  _merge_extension_catalog.
- rust/crates/sessions_native/src/abi_error.rs +1 variant Serialization (-22).

Python 측:
- sublime/sessions/settings_model.py: 정규화 본체 4개 (~140 LOC) 삭제
  → _rust_ffi 호출로 대체. dataclass 정의 + Sublime API 래퍼만 잔존.
- sublime/sessions/_rust_ffi.py: §5.5 신설, 4개 thin wrapper +
  AbiError.SERIALIZATION 미러.

ROI 정직화:
- LOC 절감 ~140은 *부수효과*. 진짜 가치는
  (a) Wave 1.5 데드라인 메커니즘 dry-run,
  (b) Lint #1/#4/#6 시운전 (PR 0 lint가 새 위반 차단 정상 동작 확인),
  (c) 다음 PR에서 같은 패턴 재사용 (PR 8 interpreter probe 등).

테스트:
- cargo test sessions_native: 73 그린 (14 신규 + 59 기존)
- pytest test_settings_model.py: 47 그린
- pytest test_managed_remote_extension_catalog + test_sessions_settings_regressions
  + test_remote_python_tool_pipeline + test_abi_error_parity: 10 그린

boundary lint 위반: 0건.

boundary-claim:
  removes:
    - sublime/sessions/settings_model.py:25-221  # 정규화 4함수 + helpers
  delete-count: 140
  ban-list: '#1/#4/#6 시운전 (위반 0건 확인)'
  rust-additions: 472 LOC (4 ABI + 14 단위 테스트)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:21:43 +09:00
86d444885a docs(planning)+ci(boundary): PR 0 — Wave 1.5 governance guardrails
4-team synthesis (rust-maximalist / python-pragmatist / boundary-keeper /
shipping-operator)에서 도출한 Python thinning plan의 첫 슬라이스. 코드 변경
없이 거버넌스 인프라만 활성화 — 후속 PR이 land될 때 mechanical guard로 작용.

- planning/PYTHON_THINNING_PLAN.md: PR 0~16 정식 plan (4축 가중치 + 잔존
  쟁점 8개 결정 표).
- planning/PYTHON_RUST_BOUNDARY.md: amend §A–§M land — 디폴트 거버넌스, 단일
  진실 양방향 보강, parity test 인프라 MUST, thin shim 정량 정의 (≤400 LOC),
  Wave 1.5 + 2.5 신설, Wave 5 일반화, hygiene contract.
- planning/boundary_inventory.yml: Migration inventory 표의 YAML 변환
  (single-source-of-truth, Lint #5 minimal cross-check 데이터).
- scripts/lint_python_thinning.py: ban-list lint #1/#2.5/#4/#6 (PR diff
  기반이라 grandfather 자동 처리).
- scripts/duplication_deadline.py: TEMP_DUPLICATION_UNTIL=vX.Y.Z 마커 만료
  검사 — 만료 시 release 차단.
- .gitea/workflows/boundary-lint.yml: 3 jobs (ban-list / deadline /
  pr-claim) PR + push에서 자동 실행.

uv.lock: pyproject 0.7.25 동기화 (잔재 정리).

Lint 후속 활성화 시점:
- #2 (deque task queue ban) → PR 16 (PR-A 본체) 머지 시
- #3 (python3 -c SSH 폴백 ban) → PR 2 (bootstrap 청산) 머지 시
- #5 (boundary inventory metasync 자동화) → Wave 2.5

Grandfather 위반 2건 (PR diff 기반이라 새 위반만 차단):
- ssh_file_transport.py:1378 _payload_method_label → PR 17+ (디코더 이관)
- commands_python_pipeline.py:639 time.monotonic → Track H2 분리 시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:03:43 +09:00
47 changed files with 6391 additions and 1804 deletions

View 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.11"
- 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.11"
- 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.11"
- 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

View File

@@ -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 225333) 삭제. 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 012 |
| **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 37 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,14 +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. |
| `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 225333) | 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 37 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:117``#![allow(dead_code)]` + "S2.3S2.5 not wired" docstring; broker는 production wired 상태이므로 stale. PR 0 또는 가장 빠른 후속 PR에서 청산.

View File

@@ -0,0 +1,366 @@
# 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 37 | ✅ | `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` | H1 file_open: PR 14.5(skeleton) + PR 14.5b(atomic_write helper) + PR 14.5c(full Rust transaction) |
> | 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 913a + 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 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.
>
> **후속 세션 인계 (단일 세션 안전 land 불가):**
> - PR 14.5d — Python wrapper for `sessions_file_open_transaction` + `commands.py`의 `open_remote_file_into_local_cache` 본체를 thin Rust 호출로 교체 (PR 14.5c의 ABI 소비자 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 08 시점):**
> - 삭제: 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 08 전반 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 2327](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 1719](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 815](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 16에서 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 100112](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 §1719 위반 청산)가 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 56 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:117` stale `#![allow(dead_code)]` + "S2.3S2.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 §1719 완전 청산.
#### **PR 37 — `_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:225333 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-B**: mirror BFS task body, eager_hydrate apply 본체 → orchestrator (PR 13b envelope 위에서)
- **H3-queue**: BACKLOG H3 본 이관 (queue 본체)
- **H2-save / H2-connect**: BACKLOG H2 분할 (Track H2 main track 흡수)
- **`_rust_ffi` 디코더 Rust 이관**: `_parse_*_outcome` Rust ABI typed JSON (Rust schema oracle 도구는 잔존 쟁점 #6 결정 후)
- **데드라인 Layer 3** auto-revert 활성화
이 시점에 commands.py 예상 LOC: 7394 - (worker loop ~550) - (connect SM ~330 부분) - (hydrate preflight ~300, PR 1214 영향) ≈ **55006000 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 37)이 이미 패턴 시범.
- `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 114 거버넌스 추적 가능. |
| 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 116 진행에는 영향 없음. |
## 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개 섹션).

View 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 37): 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` 직접 사용 가능 검증 후)"

2
rust/Cargo.lock generated
View File

@@ -461,8 +461,10 @@ dependencies = [
name = "sessions_native"
version = "0.7.25"
dependencies = [
"base64",
"serde_json",
"session_protocol",
"tempfile",
"workspace_identity",
]

View File

@@ -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(&params).map_err(|error| {
error_envelope(Some(request.id.clone()), error.code, error.message)
})?)
serde_json::to_value(handle_file_read(&params, 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(&params).map_err(|error| {
serde_json::to_value(handle_exec_once(&params, 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(&params.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()

View 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(())
}
}

View File

@@ -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.

View 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(())
}

View File

@@ -15,6 +15,10 @@ crate-type = ["cdylib", "rlib"]
workspace = true
[dependencies]
base64 = "0.22"
serde_json = "1"
session_protocol = { path = "../session_protocol" }
workspace_identity = { path = "../workspace_identity" }
[dev-dependencies]
tempfile = "3"

View File

@@ -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 {

View 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(())
}
}

View File

@@ -0,0 +1,190 @@
//! Eager-hydrate placeholder discovery (Wave 2 PR 14).
//!
//! 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.
//!
//! Batching/sleep pacing stays in Python. The Rust side returns a sorted
//! `Vec<String>` of absolute paths so the caller can deterministically batch
//! over the result without invoking a Python callback per file (the FFI
//! round-trip cost outweighs any LOC savings — see rust-pragmatist's note
//! in the team synthesis).
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
/// 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(&current) {
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
}
#[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(())
}
}

View 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)),
],
)
}

View 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",);
}
}

View File

@@ -1,8 +1,14 @@
//! 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;
pub mod orchestrator;
mod settings_normalize;
pub use abi_error::AbiError;
pub use broker_ffi::{
@@ -1199,3 +1205,405 @@ 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,
}
}
// ===========================================================================
// 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)
}
/// 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)
}

View 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()],
);
}
}

View 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"]);
}
}

113
scripts/duplication_deadline.py Executable file
View 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
View 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 §1719 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 §1719) — 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())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
"""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 ._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 (
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_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__ = (
# _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
"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_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",
)

View 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"
)

View 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",
)

View File

@@ -0,0 +1,320 @@
"""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, Optional, Tuple
from . import _loader
from ._loader import AbiError, SessionsNativeLibraryError, 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 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

View 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
)
)

View 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

View File

@@ -0,0 +1,182 @@
"""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_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 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))

View 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",
)

View File

@@ -286,12 +286,11 @@ _BACKGROUND_TASK_EVENT = threading.Event()
_BACKGROUND_WORKER_STARTED = False
_BACKGROUND_PENDING_KEYS: Set[str] = set()
_BACKGROUND_INFLIGHT_KEYS: Set[str] = set()
# Monotonic token for "Remote workspace connect" (quick panel host pick). A new
# request bumps the generation so any older queued or in-flight connect can abort
# after resetting that host's bridge (single background worker otherwise serializes).
_CONNECT_PREEMPT_LOCK = threading.Lock()
_CONNECT_GENERATION = 0
_CONNECT_INFLIGHT: Tuple[int, Optional[str]] = (0, None)
# Monotonic token for "Remote workspace connect" (quick panel host pick).
# Wave 2 PR 16 (PR-A core): the token + in-flight host now live in
# ``sessions_native::orchestrator`` (process-wide singleton). Older
# ``_connect_selected_host_async`` calls compare their captured token via
# :func:`_rust_ffi.is_connect_token_stale` and abort once stale.
_MIRROR_TASK_QUEUE = deque()
_MIRROR_TASK_LOCK = threading.Lock()
_MIRROR_TASK_EVENT = threading.Event()
@@ -345,8 +344,7 @@ def _describe_ongoing_remote_connect_work() -> Optional[str]:
prior = args[2]
if isinstance(prior, str) and prior.strip():
pending_hosts.append(prior.strip())
with _CONNECT_PREEMPT_LOCK:
_, inflight_host = _CONNECT_INFLIGHT
inflight_host = _rust_ffi.connect_inflight_host()
parts: List[str] = []
if inflight_host:
parts.append("in progress: {}".format(inflight_host))
@@ -370,14 +368,11 @@ def _preempt_connect_session_for_new_remote_request() -> int:
current blocking step, and ``reset_bridge_for_host`` on the superseded host
tears down the stuck ``local_bridge`` child so the worker can proceed.
Returns:
The new connect generation token to pass into ``_connect_selected_host_async``.
Wave 2 PR 16: token + in-flight host live in
``sessions_native::orchestrator`` (process-wide singleton).
"""
global _CONNECT_GENERATION
with _CONNECT_PREEMPT_LOCK:
_CONNECT_GENERATION += 1
token = _CONNECT_GENERATION
inflight_token, inflight_host = _CONNECT_INFLIGHT
token = _rust_ffi.bump_connect_generation()
inflight_host = _rust_ffi.connect_inflight_host()
pruned_hosts: List[str] = []
with _BACKGROUND_TASK_LOCK:
kept: deque = deque()
@@ -395,7 +390,7 @@ def _preempt_connect_session_for_new_remote_request() -> int:
kept.append(entry)
_BACKGROUND_TASK_QUEUE.extendleft(reversed(kept))
reset_host: Optional[str] = None
if inflight_token != token and inflight_host:
if inflight_host:
reset_host = inflight_host
reset_bridge_for_host(inflight_host)
if pruned_hosts or reset_host is not None:
@@ -410,8 +405,7 @@ def _preempt_connect_session_for_new_remote_request() -> int:
def _connect_generation_is_stale(connect_token: int) -> bool:
"""Return True if a newer remote connect was scheduled after ``connect_token``."""
with _CONNECT_PREEMPT_LOCK:
return connect_token != _CONNECT_GENERATION
return _rust_ffi.is_connect_token_stale(connect_token)
_HYDRATE_REQUEST_SERIAL_BY_WORKSPACE: Dict[str, int] = {}
@@ -423,9 +417,13 @@ _HYDRATE_OPEN_READ_TIMEOUT_S = 30.0
# Sidebar mirror (SSH tree/list) vs placeholder hydrate (stat/cat): same host often
# shares one effective SSH lane; pause mirror between dirs while hydrate runs.
#
# Wave 2 PR 16: depth + paused-host set live in
# ``sessions_native::orchestrator``. Python keeps the per-host
# ``threading.Event`` map (the mirror worker still blocks on ``ev.wait()``
# at Sublime's UI/IO boundary; flipping that needs a Python-side handle).
_SSH_MIRROR_LANE_LOCK = threading.Lock()
_SSH_MIRROR_GO_BY_HOST: Dict[str, threading.Event] = {}
_SSH_INTERACTIVE_DEPTH_BY_HOST: Dict[str, int] = {}
def _begin_interactive_ssh_lane(host_alias: str) -> None:
@@ -435,23 +433,19 @@ def _begin_interactive_ssh_lane(host_alias: str) -> None:
ev = threading.Event()
ev.set()
_SSH_MIRROR_GO_BY_HOST[host_alias] = ev
depth = _SSH_INTERACTIVE_DEPTH_BY_HOST.get(host_alias, 0) + 1
_SSH_INTERACTIVE_DEPTH_BY_HOST[host_alias] = depth
if depth == 1:
ev.clear()
depth = _rust_ffi.enter_interactive_lane(host_alias)
if depth == 1:
ev.clear()
_trace_event("ssh.interactive_lane.enter", host_alias=host_alias)
def _end_interactive_ssh_lane(host_alias: str) -> None:
with _SSH_MIRROR_LANE_LOCK:
ev = _SSH_MIRROR_GO_BY_HOST.get(host_alias)
depth = _SSH_INTERACTIVE_DEPTH_BY_HOST.get(host_alias, 0) - 1
if depth <= 0:
_SSH_INTERACTIVE_DEPTH_BY_HOST.pop(host_alias, None)
if ev is not None:
ev.set()
else:
_SSH_INTERACTIVE_DEPTH_BY_HOST[host_alias] = depth
depth = _rust_ffi.exit_interactive_lane(host_alias)
if depth <= 0:
with _SSH_MIRROR_LANE_LOCK:
ev = _SSH_MIRROR_GO_BY_HOST.get(host_alias)
if ev is not None:
ev.set()
_trace_event("ssh.interactive_lane.exit", host_alias=host_alias)
@@ -552,9 +546,7 @@ def _connect_selected_host_async(
host_alias: str,
connect_token: int,
) -> None:
global _CONNECT_INFLIGHT
with _CONNECT_PREEMPT_LOCK:
_CONNECT_INFLIGHT = (connect_token, host_alias)
_rust_ffi.set_connect_inflight(connect_token, host_alias)
# Open a visible "Sessions Connect" panel for this host-connect attempt.
# This is the flow the user hits from "Sessions: Connect to host" — the
# v0.4.11 hook on ``_connect_selected_workspace`` didn't cover it, which
@@ -653,9 +645,7 @@ def _connect_selected_host_async(
)
finally:
_connect_progress.stop()
with _CONNECT_PREEMPT_LOCK:
if _CONNECT_INFLIGHT == (connect_token, host_alias):
_CONNECT_INFLIGHT = (0, None)
_rust_ffi.clear_connect_inflight_if(connect_token)
def _finish_connected_host(

View File

@@ -21,6 +21,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Iterator, List, Optional, Tuple
from . import _rust_ffi
# Default allow-list. Kept intentionally small — each entry is something
# build tools / language servers read eagerly when a workspace first
# activates. ``.python-version`` is a dotfile but ``uv`` / ``pyenv`` read
@@ -87,51 +89,22 @@ def find_placeholder_candidates(
) -> 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``. Pacing/batching stay in Python so
the FFI is one call per pass. 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
candidates = _rust_ffi.eager_hydrate_find_candidates(str(cache_root), allowed_list)
for path_str in candidates:
yield Path(path_str)
def batched(items: Iterable[Path], batch_size: int) -> Iterator[List[Path]]:

View File

@@ -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
),
)

View File

@@ -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

View File

@@ -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))

View File

@@ -2082,6 +2082,40 @@ def execute_remote_write_file(
)
def _atomic_write_bytes(target: Path, body: bytes) -> None:
"""Write ``body`` to ``target`` atomically (tempfile + rename).
H1 first-PR scope (PR 14.5): the previous flow did
``parent.mkdir(...); target.write_bytes(...)`` — a partial-state
window where ``target`` could exist with truncated bytes if the
interpreter died between ``open()`` and ``close()``. Writing to a
sibling tempfile and atomically renaming closes that window: any
observer either sees the *prior* contents (or absence) or the
*complete* new bytes, never a partial.
"""
target.parent.mkdir(parents=True, exist_ok=True)
# Same parent so ``rename`` is a same-filesystem atomic op (POSIX
# ``rename(2)``; on Windows ``Path.replace`` falls back to
# ``MoveFileEx`` which is atomic for same-volume targets).
fd, tmp_str = tempfile.mkstemp(
prefix="." + target.name + ".", suffix=".part", dir=str(target.parent)
)
tmp_path = Path(tmp_str)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(body)
tmp_path.replace(target)
except BaseException:
# On any failure (write error, signal, etc.) do best-effort cleanup
# of the tempfile so the cache directory does not accumulate
# ``.NAME.XXXXXX.part`` debris.
try:
tmp_path.unlink()
except FileNotFoundError:
pass
raise
def open_remote_file_into_local_cache(
host_alias: str,
*,
@@ -2092,6 +2126,11 @@ def open_remote_file_into_local_cache(
) -> OpenFileResult:
"""Fetch remote bytes over SSH, run open guardrails, and write the local cache file.
Wave 2 PR 14.5 (H1 first-PR scope): write phase uses
:func:`_atomic_write_bytes` so a crash between read and write cannot
leave a half-written cache file. Full Rust transaction (read +
guardrail + write inside one Rust call) lands as PR 14.5b.
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
@@ -2142,8 +2181,7 @@ def open_remote_file_into_local_cache(
)
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)
_atomic_write_bytes(local_cache_path, read_result.body)
return OpenFileResult(
outcome=opened.outcome,
local_cache_path=opened.local_cache_path,

View File

@@ -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 §1719 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

View File

@@ -131,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(
@@ -159,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(
@@ -168,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(
@@ -200,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(

View File

@@ -0,0 +1,204 @@
"""Parity baseline for ``eager_hydrate`` BFS + batching + sleep pacing.
Wave 1.5 amend §D paired parity test PR — PR 14 (envelope land 후 BFS Rust
이관, ``local_bridge::remote_cache_mirror`` 통합) 의 baseline. 기존
``test_eager_hydrate.py`` 14 시나리오를 보존하면서 +12 추가:
- batched edge cases (empty / exact / single).
- find_placeholder_candidates 추가 boundary (size>0 ignored, basename
case-sensitivity, nested traversal, cache_root is file).
- run_eager_hydrate 호출 순서 / fetch_fn 인자 검증 / batch boundary.
- normalize_eager_hydrate_basenames edge cases.
"""
from __future__ import annotations
from pathlib import Path
from typing import List
from sessions.eager_hydrate import (
DEFAULT_BATCH_SIZE,
DEFAULT_BATCH_SLEEP_S,
DEFAULT_EAGER_HYDRATE_BASENAMES,
EagerHydrateSummary,
batched,
find_placeholder_candidates,
normalize_eager_hydrate_basenames,
run_eager_hydrate,
)
# ---------------------------------------------------------------------------
# batched edge cases
# ---------------------------------------------------------------------------
def test_batched_empty_iterable_yields_nothing() -> None:
assert list(batched(iter([]), 5)) == []
def test_batched_single_item_yields_single_batch() -> None:
items = [Path("/x")]
assert list(batched(iter(items), 5)) == [[Path("/x")]]
def test_batched_exact_multiple_no_trailing_partial() -> None:
items = [Path(str(i)) for i in range(6)]
out = list(batched(iter(items), 3))
assert len(out) == 2
assert all(len(b) == 3 for b in out)
def test_batched_partial_trailing_batch() -> None:
items = [Path(str(i)) for i in range(7)]
out = list(batched(iter(items), 3))
assert [len(b) for b in out] == [3, 3, 1]
# ---------------------------------------------------------------------------
# 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 == []
# ---------------------------------------------------------------------------
# run_eager_hydrate behaviour pinning
# ---------------------------------------------------------------------------
def test_run_eager_hydrate_passes_path_to_fetch_fn(tmp_path: Path) -> None:
target = tmp_path / "Cargo.toml"
_touch(target, size=0)
seen: List[Path] = []
def fetch(path: Path) -> bool:
seen.append(path)
# Simulate hydration: write content so the post-fetch check sees it.
path.write_text("[package]\n")
return True
summary = run_eager_hydrate(
tmp_path, fetch_fn=fetch, batch_sleep_s=0.0, sleep_fn=lambda _s: None
)
assert seen == [target]
assert summary.hydrated == 1
def test_run_eager_hydrate_returns_zero_summary_when_no_candidates(
tmp_path: Path,
) -> None:
summary = run_eager_hydrate(
tmp_path,
fetch_fn=lambda _p: True,
batch_sleep_s=0.0,
sleep_fn=lambda _s: None,
)
assert summary == EagerHydrateSummary(hydrated=0, skipped_existing=0, failed=0)
def test_run_eager_hydrate_disabled_when_basenames_empty(tmp_path: Path) -> None:
_touch(tmp_path / "Cargo.toml", size=0)
seen: List[Path] = []
summary = run_eager_hydrate(
tmp_path,
fetch_fn=lambda p: seen.append(p) or True,
allowed_basenames=(),
batch_sleep_s=0.0,
sleep_fn=lambda _s: None,
)
assert seen == []
assert summary.hydrated == 0
# ---------------------------------------------------------------------------
# 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))

View 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 05 + 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

View File

@@ -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)
# ------------------------------------------------------------------------

View File

@@ -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 = [

View File

@@ -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)
# ------------------------------------------------------------------------

View File

@@ -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 ---------------------------------------------

View File

@@ -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):

View File

@@ -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")

View File

@@ -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),
)

2
uv.lock generated
View File

@@ -854,7 +854,7 @@ wheels = [
[[package]]
name = "sessions-sublime"
version = "0.7.24"
version = "0.7.25"
source = { virtual = "." }
[package.dev-dependencies]