Skip to content

feat: implement SettingSync protocol for config directories (.obsidian, .agents, etc.)#13

Merged
Go1c merged 1 commit intoGo1c:mainfrom
LLQWQ:feat/setting-sync
Apr 15, 2026
Merged

feat: implement SettingSync protocol for config directories (.obsidian, .agents, etc.)#13
Go1c merged 1 commit intoGo1c:mainfrom
LLQWQ:feat/setting-sync

Conversation

@LLQWQ
Copy link
Copy Markdown

@LLQWQ LLQWQ commented Apr 15, 2026

Closes #12

Summary

This PR adds full SettingSync (config directory) support to FastNodeSync-CLI, enabling synchronization of dot-prefixed directories such as .obsidian and .agents/skills.

What changed

  • protocol.py — Added SettingSync action constants (SettingSync, SettingModify, SettingDelete, SettingSyncModify, SettingSyncDelete, SettingSyncRename, SettingSyncMtime, SettingSyncEnd).
  • state.py — Added last_setting_sync_time field and persisted it to .fns_state.json.
  • setting_sync.pyNew module implementing the complete SettingSync protocol:
    • Incremental and full sync requests
    • Server-to-client handlers for modify / delete / rename / mtime / end
    • Echo-hash cache to prevent push-back loops
  • sync_engine.py — Wired setting_sync into run, pull, push, sync_once, and local file watcher callbacks.
  • file_sync.py — Broadened config exclusion from .obsidian/ only to all dot-prefixed directories, preventing FileSync and SettingSync from fighting over the same files.

Verification

  • Restarted fns-cli.service; logs show SettingSyncModify messages for .agents/skills/... and .obsidian/plugins/....
  • Local vault now contains the previously missing .agents/skills/ directory with all expected skill files.
  • Hermes skill count increased from 79 to 93 after the sync, confirming external skills are now loadable.

Notes

  • config.yaml was not included in this PR because it is local configuration.
  • The new setting_sync.py follows the existing patterns established by note_sync.py and file_sync.py for consistency.

Summary by Sourcery

Add support for synchronizing configuration files in dot-prefixed directories via a dedicated SettingSync protocol alongside existing note, file, and folder sync.

New Features:

  • Introduce a SettingSync protocol and client implementation to sync config files in dot-prefixed directories (e.g., .obsidian, .agents).

Enhancements:

  • Integrate SettingSync into the main sync engine for incremental, full, push, and initial sync flows, including watcher-based local change handling.
  • Broaden config detection from a hardcoded .obsidian directory to all dot-prefixed directories and adjust file syncing to respect the new config/file split.
  • Persist the last settings sync time in the sync state to enable incremental configuration synchronization.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Apr 15, 2026

Reviewer's Guide

Implements a new SettingSync protocol for synchronizing all dot-prefixed configuration directories (e.g., .obsidian, .agents) alongside existing note/file sync flows, including protocol constants, engine wiring, local state tracking, and an echo-hash mechanism to avoid sync loops.

Sequence diagram for incremental SettingSync and echo-hash handling

sequenceDiagram
    actor User
    participant SyncEngine
    participant SettingSync
    participant WSClient
    participant Server
    participant FS as FileSystem

    User->>SyncEngine: sync_once()
    activate SyncEngine
    SyncEngine->>SettingSync: request_sync()
    activate SettingSync
    SettingSync->>SettingSync: _collect_local_settings()
    SettingSync->>WSClient: send(WSMessage SettingSync)
    deactivate SettingSync
    WSClient-->>Server: SettingSync request

    Server-->>WSClient: SettingSyncModify(path, content, mtime)
    WSClient->>SettingSync: _on_sync_modify(msg)
    activate SettingSync
    SettingSync->>FS: write_text(path, content)
    SettingSync->>FS: os.utime(path, mtime)
    SettingSync->>SettingSync: _echo_hashes[path] = file_content_hash_binary(path)
    SettingSync->>SettingSync: _received_modify++
    SettingSync->>SettingSync: _check_all_received()
    deactivate SettingSync

    Server-->>WSClient: SettingSyncEnd(lastTime, needModifyCount, needDeleteCount)
    WSClient->>SettingSync: _on_sync_end(msg)
    activate SettingSync
    SettingSync->>SettingSync: _expected_modify = needModifyCount
    SettingSync->>SettingSync: _expected_delete = needDeleteCount
    SettingSync->>SettingSync: _pending_last_time = lastTime
    SettingSync->>SettingSync: _got_end = True
    SettingSync->>SettingSync: _check_all_received()
    SettingSync->>SyncEngine: state.last_setting_sync_time = lastTime
    SettingSync->>SyncEngine: state.save()
    SettingSync->>SyncEngine: is_sync_complete = True
    deactivate SettingSync

    SyncEngine-->>User: sync_once completed
    deactivate SyncEngine

    User->>SyncEngine: on_local_change(rel_path in dot directory)
    activate SyncEngine
    SyncEngine->>SettingSync: push_modify(rel_path)
    activate SettingSync
    SettingSync->>FS: read_text(rel_path)
    SettingSync->>SettingSync: hash = file_content_hash_binary(rel_path)
    SettingSync->>SettingSync: if _echo_hashes[rel_path] == hash then return
    SettingSync->>WSClient: send(WSMessage SettingModify)
    SettingSync->>SettingSync: _echo_hashes[rel_path] = hash
    deactivate SettingSync
    WSClient-->>Server: SettingModify
    deactivate SyncEngine
Loading

Class diagram for SyncEngine and new SettingSync integration

classDiagram
    class SyncEngine {
        - AppConfig config
        - Path vault_path
        - NoteSync note_sync
        - FileSync file_sync
        - FolderSync folder_sync
        - SettingSync setting_sync
        - set~str~ _ignored_files
        - bool _watch_enabled
        + __init__(config: AppConfig)
        + run() async
        + sync_once() async
        + push() async
        + on_local_change(rel_path: str) async
        + on_local_delete(rel_path: str) async
        + on_local_rename(new_rel: str, old_rel: str) async
        + _register_handlers()
        + _is_note(rel_path: str) bool
        + _is_config(rel_path: str) bool
        + _should_sync_file(rel_path: str) bool
        + _initial_sync() async
        + _wait_note_sync(timeout: float) async
        + _wait_file_sync(timeout: float) async
        + _wait_setting_sync(timeout: float) async
        + _push_all_files() async
        + _push_all_settings() async
    }

    class SettingSync {
        - SyncEngine engine
        - AppConfig config
        - Path vault_path
        - bool _sync_complete
        - int _expected_modify
        - int _expected_delete
        - int _received_modify
        - int _received_delete
        - bool _got_end
        - int _pending_last_time
        - dict~str, str~ _echo_hashes
        + __init__(engine: SyncEngine)
        + is_sync_complete bool
        + register_handlers()
        + request_sync() async
        + request_full_sync() async
        + push_modify(rel_path: str, force: bool) async
        + push_delete(rel_path: str) async
        + push_rename(new_rel: str, old_rel: str) async
        + _on_sync_modify(msg: WSMessage) async
        + _on_sync_delete(msg: WSMessage) async
        + _on_sync_rename(msg: WSMessage) async
        + _on_sync_mtime(msg: WSMessage) async
        + _on_sync_end(msg: WSMessage) async
        + _reset_counters()
        + _check_all_received()
        + _commit_last_time()
        + _try_remove_empty_parent(file_path: Path)
        + _collect_local_settings() list~dict~
    }

    class SyncState {
        + int last_note_sync_time
        + int last_file_sync_time
        + int last_setting_sync_time
        - str _path
        + load(vault_dir: Path) SyncState
        + save()
    }

    class FileSync {
        + _collect_local_files() list~dict~
    }

    class NoteSync
    class FolderSync
    class WSClient {
        + on(action: str, handler)
        + send(msg: WSMessage) async
    }

    class WSMessage {
        + str action
        + dict data
        + __init__(action: str, data: dict)
    }

    SyncEngine *-- NoteSync
    SyncEngine *-- FileSync
    SyncEngine *-- FolderSync
    SyncEngine *-- SettingSync
    SyncEngine *-- SyncState
    SyncEngine *-- WSClient

    SettingSync --> SyncEngine
    SettingSync --> WSClient
    SettingSync --> WSMessage
    FileSync --> SyncEngine
    SyncState ..> Path
Loading

Flow diagram for file classification between notes, settings, and regular files

flowchart TD
    A[Start]
Loading

File-Level Changes

Change Details Files
Introduce SettingSync protocol actions and client-side sync engine for dot-prefixed config directories.
  • Add SettingSync-related action constants for client→server and server→client messages to the protocol definition.
  • Create a new SettingSync class that manages incremental/full sync requests, processes modify/delete/rename/mtime/end server events, and tracks sync completion.
  • Implement collection of local config files under dot-prefixed directories and maintain last_setting_sync_time in the persisted sync state.
fns_cli/protocol.py
fns_cli/setting_sync.py
fns_cli/state.py
Wire SettingSync into the main SyncEngine lifecycle and watcher flows so config files are synced independently of regular files.
  • Instantiate SettingSync in SyncEngine and register its websocket handlers.
  • Classify any path whose first segment starts with a dot as config, and route local modify/delete/rename events for such paths to SettingSync instead of FileSync.
  • Integrate setting sync into run/sync_once/push/initial_sync via request_sync, request_full_sync, _wait_setting_sync, and _push_all_settings helpers.
fns_cli/sync_engine.py
Adjust FileSync collection rules so that config directories are treated generically as dot-prefixed folders rather than only .obsidian, and honor sync_config vs sync_files flags accordingly.
  • Update FileSync’s local file collection to compute the first path segment and treat any dot-prefixed segment as a config directory.
  • Gate inclusion of config vs non-config files based on sync_config and sync_files booleans to avoid overlap between FileSync and SettingSync responsibilities.
fns_cli/file_sync.py

Assessment against linked issues

Issue Objective Addressed Explanation
#12 Implement full SettingSync protocol support, including defining SettingSync action constants, tracking last_setting_sync_time, and adding a new SettingSync module that can perform incremental/full sync, handle server-side modify/delete/rename/mtime/end events, and use an echo-hash cache to avoid push-back loops.
#12 Integrate SettingSync into the sync engine so that config files in dot-prefixed directories are synchronized via SettingSync for run/sync_once/pull/push flows and local file watcher callbacks.
#12 Adjust FileSync so that all dot-prefixed directories are excluded from FileSync and handled exclusively by SettingSync instead of only excluding .obsidian/.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • The logic for identifying config paths (leading dot segment) is duplicated in sync_engine._is_config, file_sync._collect_local_files, and _is_config_path in setting_sync; consider centralizing this into a single helper to avoid drift and keep behavior consistent.
  • The _echo_hashes cache in SettingSync is unbounded and keyed by path; if a vault has many churned config files this could grow indefinitely—consider a simple eviction strategy (e.g., LRU with a max size) or periodically pruning entries for paths that no longer exist.
  • Both _push_all_files and _push_all_settings perform separate rglob('*') passes over the vault; if vaults are large this may be expensive—consider consolidating into a single walk that routes each path to the appropriate sync channel.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The logic for identifying config paths (leading dot segment) is duplicated in `sync_engine._is_config`, `file_sync._collect_local_files`, and `_is_config_path` in `setting_sync`; consider centralizing this into a single helper to avoid drift and keep behavior consistent.
- The `_echo_hashes` cache in `SettingSync` is unbounded and keyed by path; if a vault has many churned config files this could grow indefinitely—consider a simple eviction strategy (e.g., LRU with a max size) or periodically pruning entries for paths that no longer exist.
- Both `_push_all_files` and `_push_all_settings` perform separate `rglob('*')` passes over the vault; if vaults are large this may be expensive—consider consolidating into a single walk that routes each path to the appropriate sync channel.

## Individual Comments

### Comment 1
<location path="fns_cli/file_sync.py" line_range="503-506" />
<code_context>
             if self.engine.is_excluded(rel) or rel.endswith(".md"):
                 continue
-            if rel.startswith(".obsidian/") and not self.config.sync.sync_config:
+            first = rel.split("/")[0]
+            if first.startswith(".") and not self.config.sync.sync_config:
                 continue
-            if not rel.startswith(".obsidian/") and not self.config.sync.sync_files:
+            if not first.startswith(".") and not self.config.sync.sync_files:
                 continue
             try:
</code_context>
<issue_to_address>
**issue (bug_risk):** Config files are still included in FileSync when `sync_config` is enabled, causing overlap with SettingSync.

With these conditions, any path whose first segment starts with `.` is always included in `FileSync` when `sync_config` is true, even though the same paths are now also processed by `SettingSync` (`_is_config` / `_collect_local_settings`). That means config files are synced by both mechanisms.

If config/settings are meant to be owned by `SettingSync`, `FileSync._collect_local_files` should instead always exclude dot-prefixed directories (or at least when setting sync is enabled), rather than tying them to `sync_config`, to prevent duplicate and inconsistent syncing.
</issue_to_address>

### Comment 2
<location path="fns_cli/sync_engine.py" line_range="286-288" />
<code_context>
+                break
+            await asyncio.sleep(0.5)
+
     async def _push_all_files(self) -> None:
         """Upload every non-note, non-excluded file in the vault."""
         for fp in self.vault_path.rglob("*"):
</code_context>
<issue_to_address>
**issue (bug_risk):** Full push still sends config files through FileSync in addition to SettingSync.

Because `_push_all_files` only filters with `is_excluded` and `_is_note`, config files in dot-prefixed directories still go through `file_sync.push_upload`. With `push()` now calling both `_push_all_files()` (`sync_files`) and `_push_all_settings()` (`sync_config`), those config files will be sent twice when both options are enabled. If config should be handled only by the settings protocol, `_push_all_files` should also skip `_is_config(rel)` to avoid double processing.
</issue_to_address>

### Comment 3
<location path="fns_cli/setting_sync.py" line_range="41-49" />
<code_context>
+    return msg_data if isinstance(msg_data, dict) else {}
+
+
+def _is_config_path(rel: str) -> bool:
+    """Check whether a relative path belongs to config/settings scope.
+
+    This matches the Obsidian plugin behaviour: anything inside a dot-prefixed
+    directory (e.g. .obsidian, .agents) is treated as a setting file.
+    Standard exclusions (.git, .trash) are handled by is_excluded() upstream.
+    """
+    first = rel.split("/")[0]
+    return first.startswith(".")
+
+
</code_context>
<issue_to_address>
**suggestion:** Config path detection logic is duplicated and may drift from `SyncEngine._is_config`.

There are now two separate implementations of this rule: `SyncEngine._is_config` and `_is_config_path`. If one is updated (e.g., changing which dot-prefixed directories count as config) and the other isn’t, they may diverge and cause inconsistencies between what the engine sends to `SettingSync` and what `_collect_local_settings` sees. Please centralize this logic (e.g., via a shared helper or by delegating to the engine) so config path classification is defined in a single place.

Suggested implementation:

```python
def _extract_inner(msg_data: dict) -> dict:
    """Server wraps payloads as {code, status, message, data: {actual fields}}."""
    if isinstance(msg_data, dict) and "data" in msg_data:
        inner = msg_data["data"]
        if isinstance(inner, dict):
            return inner
    return msg_data if isinstance(msg_data, dict) else {}


def _is_config_path(rel: str) -> bool:
    """Check whether a relative path belongs to config/settings scope.

    Delegates to SyncEngine._is_config to keep config path classification in one place.
    This ensures that what the engine considers "config" matches what
    _collect_local_settings() sees.
    """
    # Local import to avoid potential circular imports at module load time.
    from .sync_engine import SyncEngine

    return SyncEngine._is_config(rel)

```

To fully centralize config path detection and avoid drift:
1. Update `SyncEngine._is_config` (in `fns_cli/sync_engine.py`) to be a `@staticmethod` or `@classmethod` that accepts `(rel: str)` so it can be called as `SyncEngine._is_config(rel)` without an instance.
2. Ensure the logic for classifying config paths lives only in `SyncEngine._is_config` and remove any duplicated rules from there if they were previously inlined in multiple places.
3. If `SyncEngine` already imports `setting_sync`, you may need to move the shared helper into a third module (e.g., `fns_cli/config_paths.py`) to avoid circular imports, and then have both `SyncEngine._is_config` and `_is_config_path` delegate to that shared function instead of importing `SyncEngine` here.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread fns_cli/file_sync.py
Comment on lines +503 to +506
first = rel.split("/")[0]
if first.startswith(".") and not self.config.sync.sync_config:
continue
if not rel.startswith(".obsidian/") and not self.config.sync.sync_files:
if not first.startswith(".") and not self.config.sync.sync_files:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Config files are still included in FileSync when sync_config is enabled, causing overlap with SettingSync.

With these conditions, any path whose first segment starts with . is always included in FileSync when sync_config is true, even though the same paths are now also processed by SettingSync (_is_config / _collect_local_settings). That means config files are synced by both mechanisms.

If config/settings are meant to be owned by SettingSync, FileSync._collect_local_files should instead always exclude dot-prefixed directories (or at least when setting sync is enabled), rather than tying them to sync_config, to prevent duplicate and inconsistent syncing.

Comment thread fns_cli/sync_engine.py
Comment on lines 286 to 288
async def _push_all_files(self) -> None:
"""Upload every non-note, non-excluded file in the vault."""
for fp in self.vault_path.rglob("*"):
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Full push still sends config files through FileSync in addition to SettingSync.

Because _push_all_files only filters with is_excluded and _is_note, config files in dot-prefixed directories still go through file_sync.push_upload. With push() now calling both _push_all_files() (sync_files) and _push_all_settings() (sync_config), those config files will be sent twice when both options are enabled. If config should be handled only by the settings protocol, _push_all_files should also skip _is_config(rel) to avoid double processing.

Comment thread fns_cli/setting_sync.py
Comment on lines +41 to +49
def _is_config_path(rel: str) -> bool:
"""Check whether a relative path belongs to config/settings scope.

This matches the Obsidian plugin behaviour: anything inside a dot-prefixed
directory (e.g. .obsidian, .agents) is treated as a setting file.
Standard exclusions (.git, .trash) are handled by is_excluded() upstream.
"""
first = rel.split("/")[0]
return first.startswith(".")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Config path detection logic is duplicated and may drift from SyncEngine._is_config.

There are now two separate implementations of this rule: SyncEngine._is_config and _is_config_path. If one is updated (e.g., changing which dot-prefixed directories count as config) and the other isn’t, they may diverge and cause inconsistencies between what the engine sends to SettingSync and what _collect_local_settings sees. Please centralize this logic (e.g., via a shared helper or by delegating to the engine) so config path classification is defined in a single place.

Suggested implementation:

def _extract_inner(msg_data: dict) -> dict:
    """Server wraps payloads as {code, status, message, data: {actual fields}}."""
    if isinstance(msg_data, dict) and "data" in msg_data:
        inner = msg_data["data"]
        if isinstance(inner, dict):
            return inner
    return msg_data if isinstance(msg_data, dict) else {}


def _is_config_path(rel: str) -> bool:
    """Check whether a relative path belongs to config/settings scope.

    Delegates to SyncEngine._is_config to keep config path classification in one place.
    This ensures that what the engine considers "config" matches what
    _collect_local_settings() sees.
    """
    # Local import to avoid potential circular imports at module load time.
    from .sync_engine import SyncEngine

    return SyncEngine._is_config(rel)

To fully centralize config path detection and avoid drift:

  1. Update SyncEngine._is_config (in fns_cli/sync_engine.py) to be a @staticmethod or @classmethod that accepts (rel: str) so it can be called as SyncEngine._is_config(rel) without an instance.
  2. Ensure the logic for classifying config paths lives only in SyncEngine._is_config and remove any duplicated rules from there if they were previously inlined in multiple places.
  3. If SyncEngine already imports setting_sync, you may need to move the shared helper into a third module (e.g., fns_cli/config_paths.py) to avoid circular imports, and then have both SyncEngine._is_config and _is_config_path delegate to that shared function instead of importing SyncEngine here.

…n, .agents, etc.)

Adds full support for synchronizing Obsidian config directories
(e.g. .obsidian, .agents/skills) via the SettingSync WebSocket protocol.

- protocol.py: add SettingSync action constants
- state.py: persist last_setting_sync_time
- setting_sync.py: new module handling SettingSyncModify/Delete/Rename/Mtime/End
- sync_engine.py: wire setting_sync into pull/push/run and watcher callbacks
- file_sync.py: exclude all dot-prefixed dirs from FileSync to avoid conflicts

Resolves the issue where config files uploaded by the Obsidian plugin
never reached the CLI because the CLI only implemented NoteSync/FileSync.
@LLQWQ LLQWQ force-pushed the feat/setting-sync branch from c0d7537 to 193d3d4 Compare April 15, 2026 12:20
@Go1c Go1c merged commit 31ede4a into Go1c:main Apr 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Missing SettingSync support causes config directories (.obsidian, .agents/skills, etc.) to never sync to CLI

2 participants