Skip to content

feat: multiple path pairs (#122)#149

Merged
nitrobass24 merged 5 commits intofeat/v0.13.0from
feat/multiple-path-pairs
Mar 5, 2026
Merged

feat: multiple path pairs (#122)#149
nitrobass24 merged 5 commits intofeat/v0.13.0from
feat/multiple-path-pairs

Conversation

@nitrobass24
Copy link
Owner

@nitrobass24 nitrobass24 commented Mar 5, 2026

Summary

  • PathPairsConfig: New path_pairs.json config with CRUD operations and migration from legacy single remote_path/local_path
  • Backend plumbing: pair_id on ModelFile, composite keys in Model, pair-aware ModelBuilder, pair_id on Controller.Command, REST CRUD at /server/pathpairs, SSE serialization includes pair_id
  • Angular frontend: PathPair model, PathPairsService (HTTP CRUD with BehaviorSubject), composite keys in ModelFileService and ViewFileService, pair_id flows through to ViewFile
  • Tests: All 140 Angular tests pass including new pair_id parsing tests

What's included

Layer Changes
Config PathPairsConfig class with JSON persistence, PathPair dataclass, legacy migration
Context path_pairs_config wired into Context and seedsync.py startup/persist
Model pair_id field on ModelFile, composite key (pair_id:name) support in Model
ModelBuilder Accepts pair_id parameter, stamps it on created ModelFiles
Controller pair_id on Command (passed via ?pair_id= query param from frontend)
REST API GET/POST/PUT/DELETE /server/pathpairs with PathPairsHandler
Web POST/PUT/DELETE route methods on WebApp
SSE pair_id included in model serialization
Angular PathPair interface, PathPairsService, composite keys throughout file services

Not yet included (follow-up PRs)

  • Per-pair LFTP instances and scanners in Controller (currently uses single set from config)
  • Settings UI page for path pair management
  • File list pair label column
  • Auto-queue per-pair filtering

Test plan

  • All 140 Angular Vitest tests pass
  • Manual: Docker build and container startup
  • Manual: Verify legacy migration creates path_pairs.json from existing settings.cfg
  • Manual: CRUD operations on /server/pathpairs endpoints

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Manage "path pairs" via REST endpoints with persistent storage and UI-accessible list; full CRUD for remote↔local mappings.
    • Files and sync commands can be associated with an optional pair identifier.
  • Improvements

    • File tracking uses composite pair+name keys for more reliable multi-sync behavior.
    • Path-pairs auto-refresh on connection changes; request logging now includes timing.
    • Added unit tests for pair-id handling.
  • Refactor

    • Switched to a lighter WSGI-based web server setup.

Closes #122
Closes #23

nitrobass24 and others added 2 commits March 4, 2026 23:12
Replace paste.httpserver with Python's wsgiref.simple_server and
paste.translogger with a lightweight _RequestLoggingMiddleware.
Remove paste from runtime dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add PathPairsConfig for managing multiple remote-to-local directory
mappings with migration from legacy single-path config. Wire through
the full stack: Python backend (Model, ModelBuilder, Controller,
REST API, SSE serialization) and Angular frontend (ModelFile,
ViewFile, PathPairsService with CRUD operations).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds multi-path-pair support: new PathPair model and services in Angular, PathPairsConfig and HTTP handlers in Python, composite-key (pair_id:name) propagation across models, persistence/migration of path_pairs.json, and updates to command routing and server wiring.

Changes

Cohort / File(s) Summary
Angular Models
src/angular/src/app/models/index.ts, src/angular/src/app/models/path-pair.ts, src/angular/src/app/models/model-file.ts, src/angular/src/app/models/view-file.ts, src/angular/src/app/models/model-file.spec.ts
Add PathPair export; add pair_id to ModelFile and pairId to ViewFile; tests updated for parsing/defaults.
Angular File Services
src/angular/src/app/services/files/model-file.service.ts, src/angular/src/app/services/files/view-file.service.ts, src/angular/src/app/services/files/view-file.service.spec.ts, src/angular/src/app/pages/files/file.component.spec.ts
Switch identification from name→composite key (pair_id:name); centralize command URL construction; propagate pairId through view/model creation; update maps, selection logic and tests/fixtures.
Angular PathPairs Service & Index
src/angular/src/app/services/settings/path-pairs.service.ts, src/angular/src/app/services/index.ts
New PathPairsService with reactive pairs$ and CRUD against /server/pathpairs; exported from services index.
Python Common / Config
src/python/common/path_pairs_config.py, src/python/common/__init__.py, src/python/common/context.py
New PathPair and PathPairsConfig with CRUD, thread-safety, (de)serialization, versioning, and legacy migration; wired into Context.
Python Model & Diff
src/python/model/file.py, src/python/model/model.py, src/python/model/diff.py
ModelFile gains optional pair_id; Model stores files by composite key and exposes key helpers; diff logic updated to use key-based maps.
Python Controller & Builder
src/python/controller/controller.py, src/python/controller/model_builder.py
Command and ModelBuilder accept/propagate optional pair_id so created ModelFile instances carry pair association.
Python Web / Handlers / Routing
src/python/web/handler/path_pairs.py, src/python/web/handler/controller.py, src/python/web/serialize/serialize_model.py, src/python/web/web_app.py, src/python/web/web_app_builder.py, src/python/web/web_app_job.py
New PathPairsHandler exposing CRUD endpoints; controller handlers extract pair_id from requests; serialized model includes pair_id; web app gained POST/PUT/DELETE helpers; server switched to wsgiref with request-logging middleware.
Python App Persistence
src/python/seedsync.py
Load/migrate/save path_pairs.json; pass PathPairsConfig into Context; persist on shutdown; migration from legacy single-pair config supported.
Packaging / Requirements
src/python/pyproject.toml, src/python/requirements.txt, package.json
Removed paste dependency from Python manifests; minor package manifest edits.
sequenceDiagram
participant UI as Angular UI
participant AS as PathPairsService
participant S as Server (/server/pathpairs)
participant CFG as PathPairsConfig
participant DISK as Disk

UI->>AS: createPair(payload)
AS->>S: POST /server/pathpairs (payload)
S->>CFG: add_pair(new Pair)
CFG->>DISK: persist path_pairs.json
CFG-->>S: return created Pair (with id)
S-->>AS: 201 Created (Pair JSON)
AS->>AS: update BehaviorSubject pairs$
AS-->>UI: observable emits new pair
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through code to link each pair,

IDs stitched to names with tidy care.
Endpoints hum, the pairs persist,
Composite keys keep every list.
A carrot for migrations — hop, skip, and cheer!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: multiple path pairs (#122)' clearly and concisely describes the main feature addition: support for multiple path pairs, directly addressing the primary objective.
Linked Issues check ✅ Passed The PR implementation addresses core requirements from #122: path_pairs.json persistence, REST CRUD endpoints, PathPair model with required fields, pair_id tracking in ModelFile, composite key support, backend/frontend integration, and legacy migration.
Out of Scope Changes check ✅ Passed All changes align with PR objectives: backend models/services, REST handlers, frontend models/services, and supporting infrastructure (WebApp enhancements, web_app_job WSGI refactor, dependency cleanup).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/multiple-path-pairs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nitrobass24
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/python/web/web_app_job.py (1)

105-107: ⚠️ Potential issue | 🟠 Major

Call shutdown() before server_close() to properly exit the server loop.

The serve_forever() call on line 103 blocks indefinitely until shutdown() is explicitly called. Currently, stop() only calls server_close(), which closes the listening socket but does not signal the server loop to exit. This leaves the server thread blocked on serve_forever(), causing the join() on line 49 to hang indefinitely during cleanup.

🔧 Suggested fix
     def stop(self):
         self.logger.debug("Stopping web server")
-        self.server.server_close()
+        if self.server is not None:
+            self.server.shutdown()
+            self.server.server_close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/web/web_app_job.py` around lines 105 - 107, The stop() method
currently only calls server.server_close() which won't break the serve_forever()
loop; modify stop() to first call server.shutdown() to signal the server loop to
exit (so the thread can finish and join() won't hang), then call
server.server_close() and keep the existing logger.debug call; adjust references
to self.server.shutdown() and self.server.server_close() inside the stop()
method where serve_forever() is run.
src/python/controller/model_builder.py (1)

238-238: ⚠️ Potential issue | 🟠 Major

Propagate pair_id when creating child ModelFile nodes.

Children are created without pair_id, so nested files lose pair affinity and can break pair-scoped identity/commands in tree operations.

🔧 Proposed fix
-                    _child_model_file = ModelFile(_child_name, _is_dir)
+                    _child_model_file = ModelFile(_child_name, _is_dir, pair_id=self.__pair_id)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/controller/model_builder.py` at line 238, When instantiating child
ModelFile nodes you must propagate the parent's pair_id so children retain pair
affinity; change the creation of _child_model_file = ModelFile(_child_name,
_is_dir) to include the parent's pair_id (e.g., pass pair_id from the
current/parent ModelFile variable such as _model_file.pair_id or self.pair_id)
so nested files preserve pair-scoped identity and commands.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/angular/src/app/services/files/view-file.service.spec.ts`:
- Line 14: The spec adds pair_id but the test helper still emits map entries
keyed only by name, so it never verifies same-name files across different pairs;
update the test to include a case with identical name but different pair_id and
adjust the helper that emits map entries (the helper in
view-file.service.spec.ts that currently keys by name) to include pair_id in the
key (e.g., use a composite key like `${pair_id}:${name}` or make the map nested
by pair_id then name) so the assertion at the map check validates pair-aware
behavior.

In `@src/angular/src/app/services/files/view-file.service.ts`:
- Line 214: The forEach callback calling this.indices.set(viewFileKey(value),
index) returns the Map (triggering a linter warning); replace the forEach with a
simple for...of loop to avoid returning a value. Locate the two occurrences
where newViewFiles.forEach(...) is used (the one that sets this.indices using
viewFileKey and index, and the similar pattern around line 286) and rewrite them
as: iterate newViewFiles with for (const [index, value] of
newViewFiles.entries()) { this.indices.set(viewFileKey(value), index); } so no
implicit return value is produced.

In `@src/python/common/path_pairs_config.py`:
- Around line 103-113: from_str currently accepts arbitrary JSON shapes, can
raise raw KeyError/TypeError, and appends PathPair objects bypassing uniqueness
checks; update PathPairsConfig.from_str to validate that the top-level
"path_pairs" is a list and each item is a dict, wrap PathPair.from_dict calls in
try/except and convert KeyError/TypeError/ValueError into PersistError with a
clear message, and instead of appending to _pairs directly call
self.add_pair(...) (or perform an explicit duplicate check on pair_id returned
by PathPair.from_dict) so duplicate pair_id values are rejected consistently;
reference PathPairsConfig.from_str, PathPair.from_dict, add_pair, PersistError
in your changes.

In `@src/python/controller/controller.py`:
- Around line 60-63: Controller.__init__ saves pair_id but the command execution
path still resolves targets using only filename, causing cross-pair ambiguity;
update the file/target lookup at the command execution site (the place that
currently calls the file-resolution with only filename) to pass self.pair_id as
an additional parameter, and update the called lookup function(s) (e.g., any
get_target_by_filename / resolve_target_by_filename) to accept and use pair_id
when querying so lookups are scoped to the correct pair; ensure any call sites
that now require the extra argument are updated accordingly.

In `@src/python/controller/model_builder.py`:
- Around line 24-26: ModelBuilder's __init__ sets self.__pair_id but callers
instantiate ModelBuilder() without forwarding the current pair id, leaving
__pair_id None; update every call site that constructs ModelBuilder (e.g., the
Controller code that calls ModelBuilder()) to pass the active pair id (e.g.,
pass self.pair_id or the local pair_id/context variable) so
ModelBuilder(pair_id=...) is used, and ensure any factory/helper that creates
ModelBuilder also forwards the pair id.

In `@src/python/seedsync.py`:
- Around line 385-387: Replace the hardcoded dummy string with the class
sentinel: use Seedsync.__CONFIG_DUMMY_VALUE instead of "<replace me>" when
building remote_path and local_path (currently assigned to local variable
dummy); change the expressions that set remote_path = config.lftp.remote_path if
config.lftp.remote_path != dummy else "" and local_path = config.lftp.local_path
if config.lftp.local_path != dummy else "" to compare against
Seedsync.__CONFIG_DUMMY_VALUE so the code references the single source of truth
for the sentinel.

In `@src/python/web/handler/path_pairs.py`:
- Around line 30-41: The handler currently assumes json.loads(request.body...)
returns a mapping and calls data.get(...), which will raise AttributeError for
non-object JSON (list/string/number); update the parsing blocks around the
PathPair creation (the first block that constructs PathPair with
name/remote_path/local_path/enabled/auto_queue and the similar block later that
also builds a PathPair) to validate that the parsed value is a dict/mapping,
returning HTTP 400 "Invalid JSON" if not, before accessing .get(); keep existing
JSONDecodeError/UnicodeDecodeError handling intact.

In `@src/python/web/web_app_job.py`:
- Around line 99-103: Replace the single-threaded WSGI server created by
make_server with a threaded server to prevent the SSE long-lived loop in
__web_stream from blocking other requests: define a ThreadingWSGIServer
(subclassing socketserver.ThreadingMixIn and wsgiref.simple_server.WSGIServer)
and pass it as the server_class argument to make_server where
_RequestLoggingMiddleware is wrapped and self.server is assigned (currently
calling make_server(self.host, self.port, handler,
handler_class=_QuietHandler)); this ensures each request (including SSE
connections) runs in its own thread and avoids head-of-line blocking.

---

Outside diff comments:
In `@src/python/controller/model_builder.py`:
- Line 238: When instantiating child ModelFile nodes you must propagate the
parent's pair_id so children retain pair affinity; change the creation of
_child_model_file = ModelFile(_child_name, _is_dir) to include the parent's
pair_id (e.g., pass pair_id from the current/parent ModelFile variable such as
_model_file.pair_id or self.pair_id) so nested files preserve pair-scoped
identity and commands.

In `@src/python/web/web_app_job.py`:
- Around line 105-107: The stop() method currently only calls
server.server_close() which won't break the serve_forever() loop; modify stop()
to first call server.shutdown() to signal the server loop to exit (so the thread
can finish and join() won't hang), then call server.server_close() and keep the
existing logger.debug call; adjust references to self.server.shutdown() and
self.server.server_close() inside the stop() method where serve_forever() is
run.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 966d5dc8-5c52-4a65-8ec9-d6a0213179c2

📥 Commits

Reviewing files that changed from the base of the PR and between bb5f602 and 139d97c.

📒 Files selected for processing (28)
  • src/angular/src/app/models/index.ts
  • src/angular/src/app/models/model-file.spec.ts
  • src/angular/src/app/models/model-file.ts
  • src/angular/src/app/models/path-pair.ts
  • src/angular/src/app/models/view-file.ts
  • src/angular/src/app/pages/files/file.component.spec.ts
  • src/angular/src/app/services/files/model-file.service.ts
  • src/angular/src/app/services/files/view-file.service.spec.ts
  • src/angular/src/app/services/files/view-file.service.ts
  • src/angular/src/app/services/index.ts
  • src/angular/src/app/services/settings/path-pairs.service.ts
  • src/python/common/__init__.py
  • src/python/common/context.py
  • src/python/common/path_pairs_config.py
  • src/python/controller/controller.py
  • src/python/controller/model_builder.py
  • src/python/model/diff.py
  • src/python/model/file.py
  • src/python/model/model.py
  • src/python/pyproject.toml
  • src/python/requirements.txt
  • src/python/seedsync.py
  • src/python/web/handler/controller.py
  • src/python/web/handler/path_pairs.py
  • src/python/web/serialize/serialize_model.py
  • src/python/web/web_app.py
  • src/python/web/web_app_builder.py
  • src/python/web/web_app_job.py
💤 Files with no reviewable changes (2)
  • src/python/requirements.txt
  • src/python/pyproject.toml

overrides: Partial<ModelFile> & { name: string },
): ModelFile {
return {
pair_id: null,
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Add a duplicate-name cross-pair regression test.

Line 14 adds pair_id, but the helper still emits map entries keyed only by name (Line 87), so this spec never validates same-name files across different pairs. Add one case with identical name + different pair_id to confirm pair-aware behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/angular/src/app/services/files/view-file.service.spec.ts` at line 14, The
spec adds pair_id but the test helper still emits map entries keyed only by
name, so it never verifies same-name files across different pairs; update the
test to include a case with identical name but different pair_id and adjust the
helper that emits map entries (the helper in view-file.service.spec.ts that
currently keys by name) to include pair_id in the key (e.g., use a composite key
like `${pair_id}:${name}` or make the map nested by pair_id then name) so the
assertion at the map check validates pair-aware behavior.

this.files = newViewFiles;
this.indices.clear();
newViewFiles.forEach((value, index) => this.indices.set(value.name, index));
newViewFiles.forEach((value, index) => this.indices.set(viewFileKey(value), index));
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Use for...of instead of forEach to avoid implicit return value.

Map.set() returns the Map, causing the callback to return a value. While functionally harmless, this triggers a linter warning and for...of is clearer.

This same pattern appears on line 286.

♻️ Suggested refactor
-    newViewFiles.forEach((value, index) => this.indices.set(viewFileKey(value), index));
+    for (let index = 0; index < newViewFiles.length; index++) {
+      this.indices.set(viewFileKey(newViewFiles[index]), index);
+    }

And similarly for line 286:

-      newViewFiles.forEach((value, index) => this.indices.set(viewFileKey(value), index));
+      for (let index = 0; index < newViewFiles.length; index++) {
+        this.indices.set(viewFileKey(newViewFiles[index]), index);
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
newViewFiles.forEach((value, index) => this.indices.set(viewFileKey(value), index));
for (let index = 0; index < newViewFiles.length; index++) {
this.indices.set(viewFileKey(newViewFiles[index]), index);
}
🧰 Tools
🪛 Biome (2.4.4)

[error] 214-214: This callback passed to forEach() iterable method should not return a value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/angular/src/app/services/files/view-file.service.ts` at line 214, The
forEach callback calling this.indices.set(viewFileKey(value), index) returns the
Map (triggering a linter warning); replace the forEach with a simple for...of
loop to avoid returning a value. Locate the two occurrences where
newViewFiles.forEach(...) is used (the one that sets this.indices using
viewFileKey and index, and the similar pattern around line 286) and rewrite them
as: iterate newViewFiles with for (const [index, value] of
newViewFiles.entries()) { this.indices.set(viewFileKey(value), index); } so no
implicit return value is produced.

Comment on lines +60 to +63
def __init__(self, action: Action, filename: str, pair_id: str = None):
self.action = action
self.filename = filename
self.pair_id = pair_id
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Controller command lookup ==="
rg -n -C2 'command\.pair_id|get_file\(command\.filename\)|Model\.make_key' src/python/controller/controller.py

echo "=== Model key/get API ==="
rg -n -C3 'def make_key|def file_key|def get_file' src/python/model/model.py

echo "=== Command construction sites ==="
rg -n -C2 'Controller\.Command\(' src/python/web/handler/controller.py

Repository: nitrobass24/seedsync

Length of output: 3079


pair_id is captured but not used for command target resolution.

Line 60–63 stores pair_id, but command execution at Line 604 resolves files by filename only. In multi-pair scenarios, same-name files can be acted on incorrectly.

Fix the lookup to pass pair_id:

🔧 Proposed fix
-                file = self.__model.get_file(command.filename)
+                file = self.__model.get_file(command.filename, command.pair_id)
🧰 Tools
🪛 Ruff (0.15.2)

[warning] 60-60: Missing return type annotation for special method __init__

Add return type annotation: None

(ANN204)


[warning] 60-60: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/controller/controller.py` around lines 60 - 63,
Controller.__init__ saves pair_id but the command execution path still resolves
targets using only filename, causing cross-pair ambiguity; update the
file/target lookup at the command execution site (the place that currently calls
the file-resolution with only filename) to pass self.pair_id as an additional
parameter, and update the called lookup function(s) (e.g., any
get_target_by_filename / resolve_target_by_filename) to accept and use pair_id
when querying so lookups are scoped to the correct pair; ensure any call sites
that now require the extra argument are updated accordingly.

Comment on lines +24 to +26
def __init__(self, pair_id: str = None):
self.logger = logging.getLogger("ModelBuilder")
self.__pair_id = pair_id
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

pair_id constructor path is not fully wired, so propagation can remain inactive.

ModelBuilder now supports pair_id, but current instantiation still uses ModelBuilder() without passing it (see src/python/controller/controller.py Lines 101-104). That leaves self.__pair_id as None, defeating pair-aware model identity.

🧰 Tools
🪛 Ruff (0.15.2)

[warning] 24-24: Missing return type annotation for special method __init__

Add return type annotation: None

(ANN204)


[warning] 24-24: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/controller/model_builder.py` around lines 24 - 26, ModelBuilder's
__init__ sets self.__pair_id but callers instantiate ModelBuilder() without
forwarding the current pair id, leaving __pair_id None; update every call site
that constructs ModelBuilder (e.g., the Controller code that calls
ModelBuilder()) to pass the active pair id (e.g., pass self.pair_id or the local
pair_id/context variable) so ModelBuilder(pair_id=...) is used, and ensure any
factory/helper that creates ModelBuilder also forwards the pair id.

@nitrobass24 nitrobass24 added the feature New feature or capability label Mar 5, 2026
- Use __CONFIG_DUMMY_VALUE sentinel instead of hardcoded string in migration
- Add ThreadingWSGIServer so SSE connections don't block other requests
- Call shutdown() before server_close() for clean server stop
- Validate JSON body is dict in path pairs REST handlers
- Harden PathPairsConfig.from_str with type checks and add_pair() uniqueness
- Propagate pair_id to child ModelFiles in ModelBuilder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@nitrobass24
Copy link
Owner Author

nitrobass24 commented Mar 5, 2026

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (2)
src/python/controller/model_builder.py (1)

24-26: 🧹 Nitpick | 🔵 Trivial

Make pair_id explicitly optional and annotate __init__ return type.

Current annotation uses str = None; switch to explicit optional typing and add -> None for lint/type consistency.

Proposed patch
-    def __init__(self, pair_id: str = None):
+    def __init__(self, pair_id: Optional[str] = None) -> None:
         self.logger = logging.getLogger("ModelBuilder")
         self.__pair_id = pair_id
#!/bin/bash
# Verify the constructor signature still uses implicit Optional / missing return type
rg -nP 'def __init__\(self,\s*pair_id:\s*str\s*=\s*None\)' src/python/controller/model_builder.py
rg -nP 'def __init__\(self,\s*pair_id:\s*Optional\[str\]\s*=\s*None\)\s*->\s*None:' src/python/controller/model_builder.py

Based on learnings: "Maintain consistency by using typing.Dict, typing.List, and typing.Set across Python code. Do not suggest converting to built-in generic types (dict, list, set) on a per-file basis."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/controller/model_builder.py` around lines 24 - 26, Update the
ModelBuilder constructor signature to use explicit optional typing and an
explicit return annotation: change def __init__(self, pair_id: str = None): to
def __init__(self, pair_id: Optional[str] = None) -> None: (import Optional from
typing if not already) and keep assigning self.__pair_id = pair_id; ensure
references to ModelBuilder.__init__, pair_id, and self.__pair_id remain
unchanged.
src/python/common/path_pairs_config.py (1)

115-120: ⚠️ Potential issue | 🟠 Major

Fail fast on malformed entries during from_str() instead of silently dropping them.

Lines 115-120 currently skip malformed/duplicate entries with a warning. That can silently erase mappings on the next save cycle. Raise PersistError so load/backup recovery paths handle corruption explicitly.

🧩 Proposed fix
-        for pair_dict in pairs_list:
-            try:
-                config.add_pair(PathPair.from_dict(pair_dict))
-            except (KeyError, TypeError, ValueError) as e:
-                _logger.warning("Skipping malformed path pair: %s", e)
+        for i, pair_dict in enumerate(pairs_list):
+            if not isinstance(pair_dict, dict):
+                raise PersistError("Invalid path pair at index {}: expected object".format(i))
+            try:
+                config.add_pair(PathPair.from_dict(pair_dict))
+            except (KeyError, TypeError, ValueError) as e:
+                raise PersistError("Invalid path pair at index {}: {}".format(i, str(e))) from e
         return config
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/common/path_pairs_config.py` around lines 115 - 120, from_str()
currently swallows malformed PathPair entries by catching
KeyError/TypeError/ValueError in the loop over pairs_list and logging via
_logger.warning; instead, stop silently dropping them and raise PersistError so
callers can handle corruption explicitly. Change the except block around
PathPair.from_dict(...) / config.add_pair(...) to raise a PersistError (include
the original exception as context or in the message) rather than calling
_logger.warning, preserving the error details and ensuring duplicate/malformed
entries surface to load/backup recovery logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/python/common/path_pairs_config.py`:
- Around line 70-100: PathPairsConfig currently accesses and mutates shared list
self._pairs without synchronization; introduce a threading.RLock (e.g.,
self._lock) and wrap all accesses/mutations to _pairs in the lock (use with
self._lock:) in the pairs getter/setter and in get_pair, add_pair, update_pair,
remove_pair, and any to_str/serialization methods referenced around lines
122-127 so reads and writes cannot interleave; ensure add_pair/update_pair still
raise the same ValueError conditions but performed while holding the lock to
avoid race conditions.

In `@src/python/web/handler/path_pairs.py`:
- Around line 37-43: The code constructs a PathPair from unvalidated JSON fields
(data.get("name"/"remote_path"/"local_path"/"enabled"/"auto_queue")), which
allows non-string or non-boolean values to be persisted; validate each field
before creating PathPair: ensure name, remote_path, and local_path are strings
(or coerce via str() only if safe) and ensure enabled and auto_queue are
booleans (reject with a 400/BadRequest or normalize using explicit isinstance
checks and a clear conversion path); update the handler that builds PathPair to
perform these checks and return an error response on invalid types instead of
passing raw values into PathPair.
- Around line 51-72: In __handle_update, a race can cause
self.__config.update_pair(updated) to raise ValueError if the pair was deleted
after existing = self.__config.get_pair(pair_id); catch ValueError around the
update_pair call and return HTTPResponse(body="Path pair not found", status=404)
instead of letting it propagate, while leaving other exceptions unchanged so
they still surface as errors.

In `@src/python/web/web_app_job.py`:
- Around line 112-115: The stop() method currently dereferences self.server
unconditionally; change it to first check that self.server exists and is not
None (e.g., use getattr(self, "server", None) or hasattr(self, "server") and a
truthy check) before calling shutdown() and server_close(), and log a
debug/warning if the server was never initialized (this prevents an
AttributeError when run() failed during server creation and preserves the
original exception flow).

---

Duplicate comments:
In `@src/python/common/path_pairs_config.py`:
- Around line 115-120: from_str() currently swallows malformed PathPair entries
by catching KeyError/TypeError/ValueError in the loop over pairs_list and
logging via _logger.warning; instead, stop silently dropping them and raise
PersistError so callers can handle corruption explicitly. Change the except
block around PathPair.from_dict(...) / config.add_pair(...) to raise a
PersistError (include the original exception as context or in the message)
rather than calling _logger.warning, preserving the error details and ensuring
duplicate/malformed entries surface to load/backup recovery logic.

In `@src/python/controller/model_builder.py`:
- Around line 24-26: Update the ModelBuilder constructor signature to use
explicit optional typing and an explicit return annotation: change def
__init__(self, pair_id: str = None): to def __init__(self, pair_id:
Optional[str] = None) -> None: (import Optional from typing if not already) and
keep assigning self.__pair_id = pair_id; ensure references to
ModelBuilder.__init__, pair_id, and self.__pair_id remain unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 051e21dc-2c64-4744-bb6a-a793b4c2a790

📥 Commits

Reviewing files that changed from the base of the PR and between 139d97c and 85f2894.

📒 Files selected for processing (5)
  • src/python/common/path_pairs_config.py
  • src/python/controller/model_builder.py
  • src/python/seedsync.py
  • src/python/web/handler/path_pairs.py
  • src/python/web/web_app_job.py

Comment on lines 112 to 115
def stop(self):
self.logger.debug("Stopping web server")
self.server.shutdown()
self.server.server_close()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard stop() when server initialization fails.

Line 114 dereferences self.server unconditionally. If server creation/bind fails in run(), cleanup can raise AttributeError and hide the original failure.

🔧 Proposed fix
 def stop(self):
     self.logger.debug("Stopping web server")
+    if self.server is None:
+        return
     self.server.shutdown()
     self.server.server_close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/web/web_app_job.py` around lines 112 - 115, The stop() method
currently dereferences self.server unconditionally; change it to first check
that self.server exists and is not None (e.g., use getattr(self, "server", None)
or hasattr(self, "server") and a truthy check) before calling shutdown() and
server_close(), and log a debug/warning if the server was never initialized
(this prevents an AttributeError when run() failed during server creation and
preserves the original exception flow).

- Add threading.RLock to PathPairsConfig for thread-safe access
- Raise PersistError on malformed pairs in from_str() instead of swallowing
- Validate field types (str/bool) in path pairs REST handlers
- Catch ValueError race in __handle_update when pair deleted concurrently
- Guard MyWSGIRefServer.stop() against None server
- Fix ModelBuilder.__init__ typing: pair_id: Optional[str] = None

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/python/controller/model_builder.py (1)

24-26: ⚠️ Potential issue | 🟠 Major

Make pair_id non-optional to prevent silent deactivation.

Line 24 allows pair_id=None, and src/python/controller/controller.py Line 101 still constructs ModelBuilder() without passing it. That makes Line 201 and Line 238 propagate pair_id=None, so pair-aware identity stays inactive.

🔧 Proposed fix
-    def __init__(self, pair_id: Optional[str] = None) -> None:
+    def __init__(self, pair_id: str) -> None:
         self.logger = logging.getLogger("ModelBuilder")
         self.__pair_id = pair_id
# src/python/controller/controller.py
self.__model_builder = ModelBuilder(pair_id=<active_pair_id>)

As per coding guidelines, follow existing patterns for controller state management (persist → model_builder → model).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/controller/model_builder.py` around lines 24 - 26, The
ModelBuilder constructor currently accepts pair_id: Optional[str] which allows
silent deactivation; change ModelBuilder.__init__ to require pair_id: str (make
it non-optional) and propagate that change by updating the instantiation in
controller (replace self.__model_builder = ModelBuilder() with passing the
active pair id, e.g. the controller's active_pair_id/get_active_pair method) so
that pair-aware identity remains active; update any call sites of ModelBuilder()
to supply the pair id and follow the controller state flow (persist →
model_builder → model) when wiring the value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/python/common/path_pairs_config.py`:
- Around line 106-108: The remove_pair method currently silently does nothing if
the given pair_id is absent; change remove_pair (in class containing _pairs and
_lock) to detect whether any element was removed and raise ValueError("Path pair
not found") when none were removed so callers can distinguish
concurrent-delete/missing cases; then update the caller in path_pairs handler
(the code that calls remove_pair) to catch ValueError and return a 404 HTTP
response (e.g., HTTPResponse with "Path pair not found") instead of treating the
call as success.
- Around line 43-51: PathPair.from_dict currently trusts input dict types; add
explicit type checks for keys used there (pair_id/id, name, remote_path,
local_path, enabled, auto_queue) inside PathPair.from_dict: ensure id, name,
remote_path, local_path are str (use d["id"] / d.get("name", "") etc.) and that
enabled and auto_queue are bool (use d.get(..., True)), and if any value is the
wrong type raise a clear ValueError/TypeError mentioning the field and actual
value; keep behavior of using defaults for missing optional fields but validate
their types when present so invalid configs are rejected before constructing the
PathPair.

In `@src/python/web/web_app_job.py`:
- Around line 113-115: stop() can return early if called before run() sets
self.server, allowing the server thread to still call serve_forever() and
causing join to hang; add a shutdown coordination flag/condition: introduce a
boolean like self._shutdown_requested (or an Event) that cleanup()/stop() sets
unconditionally, then modify stop() to not only check self.server but also set
the flag and, if self.server is None, wait/signall that run() should shutdown
once it creates/assigns self.server; in run() after assigning self.server call
server.shutdown() immediately if self._shutdown_requested is set (or notify the
condition) so the server will not enter a permanent serve_forever(); ensure the
join() call then reliably completes.
- Around line 71-87: The __call__ middleware logs duration in a finally that
runs before the WSGI iterable is consumed (so streaming responses are logged
prematurely); fix by detecting when self.app(environ, _start_response) returns
an iterable and wrapping it in a generator wrapper that yields from the original
iterable and only executes the logging (using self.logger.log and self.level)
after iteration completes or on error, and also delegates .close() if present
and propagates exceptions; keep the existing _start_response behavior and ensure
you do not fully materialize the response body when wrapping.

---

Duplicate comments:
In `@src/python/controller/model_builder.py`:
- Around line 24-26: The ModelBuilder constructor currently accepts pair_id:
Optional[str] which allows silent deactivation; change ModelBuilder.__init__ to
require pair_id: str (make it non-optional) and propagate that change by
updating the instantiation in controller (replace self.__model_builder =
ModelBuilder() with passing the active pair id, e.g. the controller's
active_pair_id/get_active_pair method) so that pair-aware identity remains
active; update any call sites of ModelBuilder() to supply the pair id and follow
the controller state flow (persist → model_builder → model) when wiring the
value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2433607b-4519-4554-8110-5550c4b7d4f9

📥 Commits

Reviewing files that changed from the base of the PR and between 85f2894 and 61db57e.

📒 Files selected for processing (4)
  • src/python/common/path_pairs_config.py
  • src/python/controller/model_builder.py
  • src/python/web/handler/path_pairs.py
  • src/python/web/web_app_job.py

Comment on lines +106 to +108
def remove_pair(self, pair_id: str):
with self._lock:
self._pairs = [p for p in self._pairs if p.id != pair_id]
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

remove_pair should signal not-found to avoid masked concurrent-delete misses.

Line 106–108 silently succeeds when nothing is removed. Downstream, src/python/web/handler/path_pairs.py Line 100–105 can return success even if another request already deleted the pair.

🔧 Proposed fix
     def remove_pair(self, pair_id: str):
         with self._lock:
-            self._pairs = [p for p in self._pairs if p.id != pair_id]
+            original_len = len(self._pairs)
+            self._pairs = [p for p in self._pairs if p.id != pair_id]
+            if len(self._pairs) == original_len:
+                raise ValueError("PathPair with id '{}' not found".format(pair_id))
# src/python/web/handler/path_pairs.py
try:
    self.__config.remove_pair(pair_id)
except ValueError:
    return HTTPResponse(body="Path pair not found", status=404)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/common/path_pairs_config.py` around lines 106 - 108, The
remove_pair method currently silently does nothing if the given pair_id is
absent; change remove_pair (in class containing _pairs and _lock) to detect
whether any element was removed and raise ValueError("Path pair not found") when
none were removed so callers can distinguish concurrent-delete/missing cases;
then update the caller in path_pairs handler (the code that calls remove_pair)
to catch ValueError and return a 404 HTTP response (e.g., HTTPResponse with
"Path pair not found") instead of treating the call as success.

Comment on lines +71 to +87
def __call__(self, environ, start_response):
method = environ.get("REQUEST_METHOD", "")
path = environ.get("PATH_INFO", "")
start = time.monotonic()
status_code = None

def _start_response(status, headers, *args):
nonlocal status_code
status_code = status.split(" ", 1)[0]
return start_response(status, headers, *args)

try:
return self.app(environ, _start_response)
finally:
duration_ms = (time.monotonic() - start) * 1000
self.logger.log(self.level, "%s %s %s %.1fms",
method, path, status_code or "-", duration_ms)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the exact current state of the __call__ method
rg -n -A25 'def __call__' src/python/web/web_app_job.py

Repository: nitrobass24/seedsync

Length of output: 1060


🏁 Script executed:

#!/bin/bash
# Check the full _RequestLoggingMiddleware class
rg -n -B5 -A40 'class _RequestLoggingMiddleware' src/python/web/web_app_job.py

Repository: nitrobass24/seedsync

Length of output: 1764


🏁 Script executed:

#!/bin/bash
# Check if there are any streaming/SSE responses in the codebase
rg -n 'text/event-stream|StreamingResponse|yield' src/python/web/ --type py

Repository: nitrobass24/seedsync

Length of output: 226


🏁 Script executed:

#!/bin/bash
# Verify Bottle framework usage and how app iterables are handled
rg -n 'from bottle|import bottle' src/python/web/ --type py

Repository: nitrobass24/seedsync

Length of output: 759


Streaming responses log duration before transmission completes.

The middleware returns the iterable directly in the try block, so the finally block executes immediately after creating the response object, not after the WSGI server consumes it. This causes streamed responses (like the SSE endpoint at web_app.py:142-156) to log incomplete timing and potentially incomplete status information.

🔧 Proposed fix
 class _RequestLoggingMiddleware:
     def __call__(self, environ, start_response):
         method = environ.get("REQUEST_METHOD", "")
         path = environ.get("PATH_INFO", "")
         start = time.monotonic()
         status_code = None
 
         def _start_response(status, headers, *args):
             nonlocal status_code
             status_code = status.split(" ", 1)[0]
             return start_response(status, headers, *args)
 
-        try:
-            return self.app(environ, _start_response)
-        finally:
-            duration_ms = (time.monotonic() - start) * 1000
-            self.logger.log(self.level, "%s %s %s %.1fms",
-                            method, path, status_code or "-", duration_ms)
+        app_iter = self.app(environ, _start_response)
+
+        def _iter():
+            try:
+                for chunk in app_iter:
+                    yield chunk
+            finally:
+                close = getattr(app_iter, "close", None)
+                if callable(close):
+                    close()
+                duration_ms = (time.monotonic() - start) * 1000
+                self.logger.log(self.level, "%s %s %s %.1fms",
+                                method, path, status_code or "-", duration_ms)
+
+        return _iter()
🧰 Tools
🪛 Ruff (0.15.2)

[warning] 71-71: Missing return type annotation for special method __call__

(ANN204)


[warning] 77-77: Missing return type annotation for private function _start_response

(ANN202)


[warning] 77-77: Missing type annotation for *args

(ANN002)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/web/web_app_job.py` around lines 71 - 87, The __call__ middleware
logs duration in a finally that runs before the WSGI iterable is consumed (so
streaming responses are logged prematurely); fix by detecting when
self.app(environ, _start_response) returns an iterable and wrapping it in a
generator wrapper that yields from the original iterable and only executes the
logging (using self.logger.log and self.level) after iteration completes or on
error, and also delegates .close() if present and propagates exceptions; keep
the existing _start_response behavior and ensure you do not fully materialize
the response body when wrapping.

Comment on lines +113 to +115
if self.server is None:
self.logger.warning("Web server was never initialized; skipping shutdown")
return
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

stop() can miss shutdown during startup race and hang join.

If cleanup() calls stop() before run() assigns self.server, this returns early. The server thread can still enter serve_forever(), and Line 50 (join) may block indefinitely.

🔧 Proposed fix
-from threading import Thread
+from threading import Thread, Event
@@
 class MyWSGIRefServer(bottle.ServerAdapter):
@@
     def __init__(self, logger: logging.Logger, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.logger = logger
         self.server = None
+        self._shutdown_requested = Event()
@@
     def run(self, handler):
         self.logger.debug("Starting web server")
         handler = _RequestLoggingMiddleware(handler, logger=self.logger,
                                             level=logging.DEBUG)
         self.server = make_server(self.host, self.port, handler,
                                   server_class=_ThreadingWSGIServer,
                                   handler_class=_QuietHandler)
+        if self._shutdown_requested.is_set():
+            self.server.server_close()
+            return
         self.server.serve_forever()
@@
     def stop(self):
+        self._shutdown_requested.set()
         if self.server is None:
             self.logger.warning("Web server was never initialized; skipping shutdown")
             return
         self.logger.debug("Stopping web server")
         self.server.shutdown()
         self.server.server_close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/web/web_app_job.py` around lines 113 - 115, stop() can return
early if called before run() sets self.server, allowing the server thread to
still call serve_forever() and causing join to hang; add a shutdown coordination
flag/condition: introduce a boolean like self._shutdown_requested (or an Event)
that cleanup()/stop() sets unconditionally, then modify stop() to not only check
self.server but also set the flag and, if self.server is None, wait/signall that
run() should shutdown once it creates/assigns self.server; in run() after
assigning self.server call server.shutdown() immediately if
self._shutdown_requested is set (or notify the condition) so the server will not
enter a permanent serve_forever(); ensure the join() call then reliably
completes.

- remove_pair now raises ValueError when pair_id not found
- Delete handler catches ValueError instead of pre-checking with get_pair
- PathPair.from_dict validates types of all fields (str/bool checks)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant