fix(filestation): probe recycle-bin status per share#73
Conversation
Closes #37. Replaces the always-empty `recycle_status: dict[str, bool] = {}` at modules/filestation/__init__.py with a lazy per-share probe that hits `SYNO.FileStation.List` on `/{share}/#recycle?limit=0` on first observation: success -> True, DSM 408 -> False, 105/other -> True + WARNING (preserves prior optimistic default; surfaces unknown states in logs for the operator). Wired into the two correctness-sensitive paths: delete_files (per share touched) and list_recycle_bin. Adds self-correct-on-observation so the cache flips when DSM behavior contradicts the cached value mid-session (e.g. cached True but actual list returns 408). Adds clear-on-reauth via a new AuthManager.add_on_reauth_callback API + a SharedClientManager.subscribe_on_reauth proxy that queues callbacks before the lazily-created AuthManager exists and flushes them on first get_client. list_shares left alone (renders whatever's cached, kept cheap). Persistence across restarts (binding the closure dict to ServerState.recycle_bin_status) is intentionally out of scope — load_state/save_state aren't currently wired up at all; treating that as a separate follow-up. vdsm integration test is also a follow-up since `synoshare --setopt` recycle-toggle reliability on DSM 7.2.x is unproven (PR #23 reverted a similar setopt for share creation). Eighteen new tests (9 helper + 3 auth + 3 server + 1 delete-files lazy-probe + 2 list_recycle_bin self-correct); 546 passing total at 96.10% coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
cmeans
left a comment
There was a problem hiding this comment.
QA Round 1 — Three blockers, two observations
The design is thoughtful (lazy probe + observation-based self-correct + invalidate-on-reauth) and the 18 new tests are well-shaped — happy paths, error codes, memoization, dispatch-exception isolation, pre-AuthManager queue, all covered. Code reads cleanly and threading of recycle_status through __init__.py (3 sites) → helpers.py → operations.py/listing.py is correct per git grep. Lock + callback dispatch in _re_authenticate is structurally sound.
That said, three blockers stop signoff — one CI, one issue-scope, one regression I caught while re-reading list_shares.
Verification
| Check | Result |
|---|---|
uv run pytest |
546 passed (+18 from #72), 94 deselected, 96.10% coverage |
New test classes (TestEnsureRecycleStatus, TestCorrectRecycleStatusFromObservation, TestOnReauthCallbacks, TestSharedClientManagerSubscribeOnReauth) |
15/15 pass directly |
uv run ruff check src/ tests/ scripts/ |
clean |
uv run ruff format --check src/ tests/ scripts/ |
72 files already formatted |
uv run mypy src/ scripts/ |
clean (30 files, strict-mode) |
git grep -n 'recycle_status\b|recycle_bin_status\b' src/ |
matches PR body — 3 callsites in __init__.py thread the closure dict to delete_files/list_recycle_bin/list_shares, helper signatures consistent |
git grep -n 'subscribe_on_reauth|on_reauth_callbacks' src/ |
AuthManager API + dispatch loop + SharedClientManager proxy + filestation register() subscription all present and wired |
Required CI on 1f19bc54 |
vdsm FAILED (see F1); other 12 green |
PR-body manual tests 1–7 all verified and checked.
Findings
| ID | Finding |
|---|---|
| F1 (blocker — CI gate) | vdsm integration tests failed on tests/vdsm/test_vdsm_integration.py::TestSearch::test_search_keyword_finds_directory. Search for Bambu in /testshare returned 0 results across all 6 retry attempts (~75s). The synoindex fixture from PR #67 IS firing per the captured log (synoindex registered /volume1/testshare/Documents/Bambu Studio with DSM search index), but DSM Universal Search hadn't returned the result within the budget. PR #73 does not touch any vdsm test, search code, or the auth-on-init flow (the new on-reauth callback list is only consulted on re-auth, not initial login), so this is almost certainly a re-surfacing of the same flake #67 was meant to root-cause. Per the project's CI gate rule any failed required check is automatic QA Failed; first action is just gh workflow run vdsm.yml --ref fix/recycle-status-probe (or push an empty commit) to confirm transient. If it persists across two consecutive runs, that's a real signal the #67 mitigation isn't sufficient on this branch and needs a follow-up — possibly extending the retry budget past 75s, or adding a synchronous "search service ready" probe before the test fires. Not blaming this PR; just can't sign off on a red required check. |
| F2 (substantive) | Issue-scope drift. PR body says "Closes #37" but two of the issue's six ACs are deferred to follow-ups — the vdsm integration test (AC#5) and the persistence wiring (ServerState.recycle_bin_status, mentioned as "out of scope" in the design section). I searched gh issue list --search recycle and gh issue list --search vdsm recycle: the only open issue is #37 itself. No follow-up tickets exist for either deferral, and I couldn't locate a "signoff" record in awareness for the scope reduction. Two paths: (a) file follow-up issues now and link them in the PR body so "Closes #37" becomes honest (everything tracked somewhere), or (b) downgrade to "Refs #37" in the PR body and the merge commit so the issue stays open until the deferred work lands. (a) is the project's existing pattern — see the cycle that filed #70 from the F2 of PR #69. |
| F3 (substantive — UX regression) | list_shares "Recycle Bin" column shows misleading partial truth post-fix. At listing.py:83-95: the column is added when recycle_bin_status is truthy, and unknown shares default to False via recycle_bin_status.get(name, False). Pre-fix the dict was always empty so if recycle_bin_status: was always False and the column never appeared — never misleading. Post-fix the dict fills lazily; once any one share has been probed (e.g. via delete_files /video/x), the cache is non-empty so list_shares adds the column, and every other share — none of which have been probed — renders as "disabled" with no indication that the value isn't observed. Concrete user flow: delete_files /video/foo → list_shares → shows /video correctly + /music, /home, /photo, etc. all falsely as "disabled". The PR body's "list_shares left alone — renders whatever's cached at the moment; not a correctness path" is the design rationale, but the result is worse than the pre-fix behavior because partial correctness has the appearance of full correctness. Cleanest fix is at listing.py:95: render the state when the share is in the cache, otherwise an explicit unknown indicator (e.g. "?" or "unknown"). One-line change: row.append("enabled" if recycle_bin_status.get(name, False) else "disabled" if name in recycle_bin_status else "?"). Add one test in tests/modules/filestation/test_listing.py::TestListShares covering "share not in cache → ? rendered". |
Observations (each blocks signoff per the project's observations-block rule)
| ID | Observation |
|---|---|
| O1 | helpers.py:191-192 file-header comment claims DSM 105 (permission denied) -> unknown; default True + WARN as a specific case, but the implementation at helpers.py:217-228 doesn't branch on 105 at all — both 105 and any other DSM error code fall through the same generic else arm. The behavior IS correct (105 lands in the right bucket); only the comment over-promises specificity. Either drop the 105 line from the file-header inventory, or add elif e.code == 105 for cosmetic alignment with the documented contract. |
| O2 | The on-reauth callback dispatch loop in core/auth.py:240-249 runs INSIDE self._lock. The add_on_reauth_callback docstring says callbacks must be cheap and synchronous — but only convention enforces this. Worth a one-line inline comment in the dispatch loop noting that callbacks block the auth lock so future contributors don't add an expensive sync hook here. The current recycle_status.clear is O(n_shares) and trivial; this is forward-looking maintenance hygiene, not a present-day correctness issue. |
Verdict
QA Failed Round 1. F1 is the simplest fix path (re-run, see whether it sticks) and the most ambiguous on what counts as "Dev's responsibility" since the test isn't in this PR's diff. F2 is procedural (file two follow-up issues, optionally relink in PR body). F3 is a single-line correctness fix plus one regression test — the meaningful blocker on the actual diff. O1+O2 are small.
|
Applying QA Failed as the final act of round 1. F1 (vdsm CI red — likely flake re-surfacing), F2 (issue scope: 2 deferred ACs need follow-up issues filed before "Closes #37" is honest), F3 ( |
F3 — list_shares: render "unknown" for shares not yet probed. listing.py:95 previously rendered "disabled" for any share missing from the cache, which became misleading post-#37: once the cache fills lazily (e.g. via delete_files /video/x), the column appears on list_shares and every other share — none of which have been probed — falsely renders as "disabled". Now distinguishes "enabled" / "disabled" / "unknown". One-line code change + new TestListShares::test_list_shares_renders_unknown_for_unprobed_shares regression covering /video probed-True, /scratch probed-False, /music never probed → all three values present in output. O1 — helpers.py: add explicit elif for DSM 105 (permission denied). The file-header comment promised a 105-specific branch but the implementation collapsed it into the generic else. Added an elif arm with a permission-specific WARNING log that points the operator at the actionable fix (grant the MCP service account read on /share/#recycle), aligning code with the documented contract. Updated TestEnsureRecycleStatus:: test_probe_permission_denied_falls_back_to_true_with_warning to assert the new specific message. O2 — auth.py: inline note that on-reauth callbacks dispatch inside self._lock. Future contributors might be tempted to add expensive sync hooks; the comment makes the cheapness contract visible at the call site. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 2 — F2/F3/O1/O2 addressed; F1 awaiting CI re-runAll four code-side findings closed in commit F1 — vdsm
|
| Check | Result |
|---|---|
uv run pytest |
547 passed (+1 from F3 regression test), 94 deselected, 96.11% coverage |
uv run ruff check src/ tests/ scripts/ |
clean |
uv run ruff format --check src/ tests/ scripts/ |
72 files already formatted |
uv run mypy src/ scripts/ |
clean (30 files, strict-mode) |
Ready for round 2 — pending the F1 re-run verdict.
cmeans
left a comment
There was a problem hiding this comment.
QA Round 2 — PASS
All five round-1 items closed.
F1 — vdsm CI
Re-ran on 616ea3c and the vdsm integration tests job is green. test_search_keyword_finds_directory was indeed a transient flake unrelated to this PR — the diff doesn't touch any vdsm/search/auth-init path so the round-1 failure was the same #67-targeted flake re-surfacing once. If it recurs on a future PR, that's a follow-up for #67's mitigation, not this branch.
F2 — issue scope
Both deferred ACs now have follow-up issues filed by Dev with explicit cross-reference to #73's F2:
- #75 — Wire
ServerState load_state/save_statelifecycle inSharedClientManager(the persistence wiring out-of-scope item). - #76 — vdsm integration test for recycle-bin messaging in
delete_files(the AC#5 deferral).
Closes #37 is now honest because the deferred work is tracked somewhere durable.
F3 — list_shares partial-truth regression
Fixed at listing.py:94-104 with the recommended approach: name in recycle_bin_status → render enabled/disabled, otherwise unknown. Three-state output instead of two-state-with-misleading-default. The new TestListShares::test_list_shares_renders_unknown_for_unprobed_shares regression seeds a cache with {video: True, scratch: False} against three rendered shares (video, music, scratch) and asserts all three values (enabled, disabled, unknown) appear in the output — covers the exact scenario from the round-1 finding. Passes locally.
O1 — DSM 105 specificity
Resolved with an explicit elif e.code == 105 branch at helpers.py:221-232. The new operator-actionable WARNING is significantly better than the generic round-1 message — it names the resolution path: "Grant the MCP service account read access on //#recycle for accurate delete-files messaging." The updated test asserts both the DSM-105 marker and the actionable-fix substring. Code now matches the file-header inventory.
O2 — auth-lock dispatch contract
Five-line inline comment added at core/auth.py:246-250 making the cheap-and-synchronous contract visible at the call site. Names today's only subscriber (recycle_status.clear, O(n_shares)) and points future contributors at "deferred task" if they need to do heavy work. Cheapest possible mitigation; sufficient.
Verification (re-run on 616ea3c)
| Check | Result |
|---|---|
uv run pytest |
547 passed (+1 from R1: the new test_list_shares_renders_unknown_for_unprobed_shares), 94 deselected, 96.11% coverage |
uv run ruff check src/ tests/ scripts/ |
clean |
uv run ruff format --check src/ tests/ scripts/ |
72 files already formatted |
uv run mypy src/ scripts/ |
clean (30 files, strict-mode) |
| Targeted F3 + O1 tests | both pass |
Required CI on 616ea3c |
13/13 green (vdsm completed SUCCESS) |
Verdict
Ready for QA Signoff. F1 confirmed flake, F2 tracked in #75 + #76, F3/O1/O2 all closed in code with regression coverage. Final maintainer call.
|
Applying Ready for QA Signoff as the final act of round 2. All five round-1 items closed: F1 transient flake (vdsm green on |
) ## Summary **Closes #68 (both halves).** DSM 7.x's `SYNO.FileStation.List getinfo` and `SYNO.FileStation.Delete start` do not honor the documented comma-joined multi-path format — even on v2. A request with `path=/a,/b` is treated as a single literal path. For `getinfo` this surfaces as one synthetic record whose `path` field IS the literal comma-joined string (the handler's `len(files) == 1` branch then renders it as a single info card). For `delete_files` it surfaces as a successful task that no-ops on every input, returning `[+] Deleted N item(s)` with all paths listed but none removed. Fix: **per-path serial calls**. Both tools iterate `paths` and issue one DSM request per input path; results aggregate into the same response shape callers already expect (single info card for one path, table for multiple; per-share recycle-bin messaging unchanged for delete). Matches the user's documented workaround on #68. Trade-off: N round-trips for N paths, fine for typical small-N usage. ## Round-1 → round-2 history Round 1 hypothesized v3-vs-v2 misinterpretation and shipped a one-line v2 pin on `getinfo`. The newly-strengthened vdsm assertion (`test_get_file_info_multiple_paths` against DSM 7.2.2) **failed** in CI on the round-1 head: ``` AssertionError: v2 pin regression: response contains the comma-joined path string, indicating DSM treated the input as a single literal path. Got: 'File Info: /testshare,/writable\n========\n Name: \n Path: /testshare,/writable\n Type: File\n Size: 0 B' ``` That output is exactly the bug shape #68 reports — and it reproduced even on v2. So the real bug isn't a version-pin miss; DSM 7.x just doesn't accept comma-joined multi-path on these methods. Round 2 (commit `ddfac45`) pivoted to per-path serial in both `get_file_info` and `delete_files`. Round-2 follow-up commit `4a3e695` added defensive empty-paths guards (Codecov was flagging the new branches); commit `dd7c30e` updated this PR's CHANGELOG test counts after that. ## Diff at a glance \`\`\`python # Before — get_file_info normalized = [normalize_path(p) for p in paths] path_param = escape_multi_path(normalized) data = await client.request( "SYNO.FileStation.List", "getinfo", params={"path": path_param, ...}, ) files = data.get("files", []) # After — get_file_info normalized = [normalize_path(p) for p in paths] files: list[dict[str, Any]] = [] for p in normalized: data = await client.request( "SYNO.FileStation.List", "getinfo", version=getinfo_version, params={"path": p, ...}, ) files.extend(data.get("files", [])) \`\`\` `delete_files` got the same treatment via an extracted `_delete_one_path` helper that owns the start → poll → stop async-task pattern; `delete_files` becomes a thin loop over normalized paths plus the existing recycle-bin messaging. ## What stays from round 1 - The v2 pin on `getinfo` itself remains (now defensive parallelism with Delete/CopyMove/Search rather than the bug fix it claimed to be in round 1). - `tests/conftest.py` `SYNO.FileStation.List max_version=3` bump stays (matches DSM 7.x reality; protects future tests from being fooled by a max-resolves-to-2 default). - `TestMultiPathDelete` integration test (now re-exported on vdsm too). ## Acceptance criteria - [x] `get_file_info` returns one record per input path on real DSM 7.x. - [x] `delete_files` actually deletes every path in a multi-path call on real DSM 7.x. - [x] Per-share recycle-bin messaging on `delete_files` unchanged from #73. - [x] Defensive empty-paths guard returns a precise `not_found` error on `paths=[]`. - [x] CHANGELOG `### Fixed` entry. ## QA ### Manual tests 1. - [x] Run `uv run pytest` — 550 passed at 96.13% coverage. 2. - [x] Run `uv run pytest tests/modules/filestation/test_metadata.py::TestGetFileInfo::test_multipath_uses_per_path_serial_calls -v` — passes; asserts N requests for N paths, each carrying a single path with no commas, all pinned to v2, results aggregate correctly. 3. - [x] Run `uv run pytest tests/modules/filestation/test_metadata.py::TestGetFileInfo::test_empty_paths_list_returns_not_found tests/modules/filestation/test_operations.py::TestDeleteFiles::test_delete_empty_paths_list_returns_not_found -v` — both pass; defensive empty-paths guard fires with the expected error envelope. 4. - [x] Run `uv run ruff check src/ tests/ scripts/` — clean. 5. - [x] Run `uv run ruff format --check src/ tests/ scripts/` — clean. 6. - [x] Run `uv run mypy src/ scripts/` — strict-mode clean. 7. - [x] Spot-check `git grep -n 'escape_multi_path' src/` — `metadata.py` no longer imports it (per-path serial doesn't need multi-path escaping); only `operations.py` (CopyMove + CreateFolder) and `helpers.py` (the helper itself) reference it. 8. - [x] Verify regression-detect on the new wire test: temporarily change `metadata.py` back to a single multi-path call (`path=escape_multi_path(normalized)`), run `test_multipath_uses_per_path_serial_calls`. It should fail with `expected two DSM calls (one per path), got 1`. Restore. 9. - [x] Optionally: run the `TestMultiPathDelete` integration test against a real NAS to confirm multi-path delete actually deletes (requires `tests/integration_config.yaml` with a `writable_folder`). vdsm has it covered automatically via the re-export. ### Verification I already ran | Check | Result | |---|---| | `uv run pytest` | **550 passed**, 100 deselected, 96.13% coverage | | `uv run ruff check src/ tests/ scripts/` | clean | | `uv run ruff format --check src/ tests/ scripts/` | 72 files already formatted | | `uv run mypy src/ scripts/` | clean (30 files, strict-mode) | | Required CI on `4a3e695` (the head before this round-2-doc-fix push) | 13/13 green; vdsm completed `SUCCESS` | | Regression-detect on the new wire test | confirmed in CI: removing per-path serial caused the round-1 head to fail with the comma-joined-as-single-path symptom | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary Patch release rolling up the post-0.5.0 fixes. Five PRs land in this release: - **#69** — Atomic state-file writes (closes #36). New `core/fs.py::atomic_write_text` helper; `save_state` and `_save_global_state` route through it. Eliminates torn-write windows on `state.yaml` / `global.yaml`. - **#71** — Atomic write for `cli/setup.py` interactive config (closes #70). Last user-visible non-atomic write site cascaded onto the same helper. - **#72** — README per-installer and per-OS download-breakdown badges. Mirrors mcp-clipboard's layout; all 9 new badges link to `cmeans/pypi-winnow-downloads` (the dogfooded service). - **#73** — Recycle-bin status probed per share (closes #37). Lazy `ensure_recycle_status` helper; self-correct on observation; clear-on-reauth via new `AuthManager.add_on_reauth_callback` API. Fixes incorrect "Recycle bin is enabled" messaging on shares with `#recycle` disabled. - **#77** — Per-path serial for multi-path `getinfo` and `delete` (closes #68). **Critical: fixes a v0.5.0 silent-no-op regression on `delete_files` with multi-path arrays** — caller would see `[+] Deleted N item(s)` listing every path, but none were actually deleted on real DSM 7.x. Same root cause on `get_file_info` (single synthetic record with comma-joined `path`). Both tools now issue one DSM call per input path. ## Files touched (per CLAUDE.md release procedure) - `pyproject.toml`: 0.5.0 → 0.5.1 - `server.json`: synced via `scripts/sync-server-json.py` (top-level + `packages[0].version`) - `uv.lock`: refreshed via `uv lock` - `CHANGELOG.md`: `## Unreleased` renamed to `## 0.5.1 (2026-05-01)`; fresh empty `## Unreleased` added above for the next cycle ## QA ### Manual tests 1. - [x] `python scripts/sync-server-json.py --check` — reports "in sync (0.5.1)". 2. - [x] `uv run pytest` — 550 passed, 96.13% coverage. 3. - [x] `uv run ruff check src/ tests/ scripts/` — clean. 4. - [x] `uv run ruff format --check src/ tests/ scripts/` — clean. 5. - [x] `uv run mypy src/ scripts/` — clean. 6. - [x] CHANGELOG diff is exactly: `## Unreleased` → `## 0.5.1 (2026-05-01)`, plus a fresh empty `## Unreleased` heading inserted above. No content was moved or rewritten — every entry under `## 0.5.1` already lived under `## Unreleased` on `main`. 7. - [x] After merge: tag-push triggers `publish.yml`. The `awk` extractor on `## <version>( |\()` matches `## 0.5.1 (2026-05-01)` correctly and the new empty `## Unreleased` is harmlessly walked past. ### Verification I already ran | Check | Result | |---|---| | `python scripts/sync-server-json.py --check` | in sync (0.5.1) | | `uv run pytest` | 550 passed, 100 deselected, 96.13% coverage | | `uv run ruff check src/ tests/ scripts/` | clean | | `uv run ruff format --check src/ tests/ scripts/` | 72 files already formatted | | `uv run mypy src/ scripts/` | clean (30 files, strict-mode) | | `git diff --stat HEAD~1` | 4 files: CHANGELOG.md, pyproject.toml, server.json, uv.lock | 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes #37. Replaces the always-empty
recycle_status: dict[str, bool] = {}atmodules/filestation/__init__.py:238with a lazy per-share probe that populates the cache on first observation. Pre-fix,delete_filesalways reported"Recycle bin is enabled on /{share}"regardless of actual DSM state — confusing UX for users with#recycledisabled on a share, since their data was actually permanently gone.Design
Lazy probe —
ensure_recycle_status(client, share, recycle_status)inmodules/filestation/helpers.py:SYNO.FileStation.Liston/{share}/#recyclewithlimit=0(cheap, just probes existence)True, DSM 408 (path not found) →FalseTrue+ WARNING log (preserves prior optimistic-default behavior; surfaces unknown states in logs for operator visibility)Self-correct on observation —
correct_recycle_status_from_observationupdates the cache whenlist_recycle_binsees DSM behavior contradicting the cached value (cachedTruebut the actual list returns 408 → flip toFalse; or cache saysFalsethen the live call succeeds → flip toTrue). Logs at INFO. Means subsequentdelete_filescalls in the same session see corrected state without waiting for re-auth.Invalidate on re-auth — Two-layer hook:
AuthManager.add_on_reauth_callback(cb)registers callbacks, fired after_re_authenticatesucceeds. Exceptions in callbacks are logged (WARNING) and don't block other callbacks.SharedClientManager.subscribe_on_reauth(cb)proxies subscriptions made before the AuthManager is lazily created (syncregister()time) and flushes them on firstget_client.register()subscribesrecycle_status.clearso admin-side#recycletoggles between sessions are picked up after the next session-error-driven re-auth.list_sharesleft alone — renders whatever's cached at the moment; not a correctness path. Kept cheap.Out of scope (intentional)
ServerState.recycle_bin_statusexists on the model butload_state/save_statearen't currently wired up at all inSharedClientManager. Wiring persistence touches a much larger surface than recycle_status passed to delete_files is always empty — incorrect recycle-bin messaging #37 alone needs. Per signoff, treating as a separate follow-up.synoshare --setoptrecycle-toggle reliability on DSM 7.2.x is unproven (PR Fix all 5 remaining vdsm test failures (42/47 → 47/47) #23 reverted a similar setopt for share creation). Per signoff, unit tests carry the regression load until vdsm behavior is verified — vdsm follow-up will land separately.Acceptance criteria
#recycleexistence and caches the result. (Probes lazily on first observation per share, not at init.)delete_files()so the correct with-recycle vs NOT-enabled message fires per-share. (Plus the same onlist_recycle_bin.)#recycle: Falseproduces the permanent-delete message; withTrueproduces the recoverable message. (Existing tests + new lazy-probe test inTestDeleteFiles.)### Fixedentry.QA
Manual tests
uv run pytest— 546 pass, 96.10% coverage (gate: 95%).uv run ruff check src/ tests/ scripts/— clean.uv run ruff format --check src/ tests/ scripts/— clean.uv run mypy src/ scripts/— strict-mode clean.git grep -n 'recycle_status\b\|recycle_bin_status\b' src/shows the closure dict reachesdelete_files,list_recycle_bin, andlist_shares(the last unchanged), with no remaining# Assume recycle bin by defaultfall-through branch on the populated path.git grep -n 'subscribe_on_reauth\|on_reauth_callbacks' src/shows the AuthManager API + SharedClientManager proxy + filestationregister()subscription are all wired together.uv run pytest tests/modules/filestation/test_helpers.py::TestEnsureRecycleStatus tests/modules/filestation/test_helpers.py::TestCorrectRecycleStatusFromObservation -v— 9 pass; covers cache hit, probe-success, probe-408, probe-105+WARN, probe-other+WARN, memoization, and the three self-correct cases.New test classes
TestEnsureRecycleStatustests/modules/filestation/test_helpers.pyTestCorrectRecycleStatusFromObservationtests/modules/filestation/test_helpers.pyTestOnReauthCallbackstests/core/test_auth.pyTestSharedClientManagerSubscribeOnReauthtests/core/test_server.pyTestDeleteFilestests/modules/filestation/test_operations.pyTestListRecycleBintests/modules/filestation/test_listing.pyVerification I already ran
uv run pytestuv run ruff check src/ tests/ scripts/uv run ruff format --check src/ tests/ scripts/uv run mypy src/ scripts/🤖 Generated with Claude Code