#!/usr/bin/env python3 """ vault_sync_daemon.py — long-running inotify (watchdog) daemon that keeps /vault (JDex notes, git-backed) and /data (blob storage, Duplicati-backed) structurally mirrored, in BOTH directions, for changes made by ANY tool (Obsidian, SMB client, manual mv, not just the `vault` CLI). Rules (confirmed with Ryan 2026-09-15): FORWARD (/vault -> /data): - rename/move of an ID folder in /vault -> mirror the rename in /data. - delete of an ID folder in /vault -> do NOT delete /data's copy. Instead: move the /data folder into the NEAREST existing `.09` archive folder up the tree (category-level first, then area/system; create a category-level `.09 Archive` in /data if none exists at any level yet), and append a note to the nearest `.09` archive's /vault folder-note markdown recording what was archived, when, and where. REVERSE (/data -> /vault): - a new folder appears in /data whose basename matches the `SYS.AC.ID ` Johnny Decimal pattern, with NO /vault counterpart yet (e.g. dropped in over SMB) -> auto-create a minimal /vault stub folder + folder-note with just `aliases: []` frontmatter (no "needs review" banner, no inbox #fix-me flagging — confirmed with Ryan 2026-09-22: keep it quiet, just the empty note + alias). - Folders inside any `.09`-pattern archive folder are never auto-stubbed (archives are meant to hold retired stuff, not grow new tracked IDs) — this also breaks the feedback loop from the forward-delete archive move. - Generic non-ID subfolders (e.g. "Documents/" inside an existing ID folder) are ignored entirely — normal blob organization, not JD structure. Idempotent by construction: every action checks target state first, so a duplicate/replayed event from the daemon's own writes is a safe no-op. Run: python3 vault_sync_daemon.py (foreground; systemd unit wraps this) Logs: /home/hermes/.hermes/logs/vault-sync-daemon.log """ import os import re import sys import time import shutil import subprocess import datetime import threading import queue _SELF_DIR = os.path.dirname(os.path.realpath(__file__)) sys.path.insert(0, _SELF_DIR) import vault_lib as vl from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler, DirCreatedEvent, DirMovedEvent, DirDeletedEvent LOG_PATH = "/home/hermes/.hermes/logs/vault-sync-daemon.log" os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) NTFY_TOKEN_PATH = "/home/hermes/.hermes/secrets/ntfy-vault-token" NTFY_URL = "https://ntfy.pandu.ski/vault" def ntfy_post(title, message, tags="package", priority="default"): """Fire-and-forget ntfy notification. Never raises — a notify failure must never block or crash the daemon's actual sync work.""" try: token = open(NTFY_TOKEN_PATH, encoding="utf-8").read().strip() except Exception as e: log(f"ntfy_post: could not read token: {e}") return try: result = subprocess.run( ["curl", "-fsS", "--max-time", "5", "-X", "POST", "-H", f"Authorization: Bearer {token}", "-H", f"Title: {title}", "-H", f"Tags: {tags}", "-H", f"Priority: {priority}", "-d", message, NTFY_URL], check=False, capture_output=True, timeout=8, text=True, ) if result.returncode != 0: log(f"ntfy_post: curl exited {result.returncode}: {result.stderr.strip()}") except Exception as e: log(f"ntfy_post failed: {e}") VAULT = vl.VAULT DATA = vl.DATA SKIP_DIRS = vl.SKIP_DIRS DATA_EXEMPT = vl.DATA_EXEMPT ID_RE = vl.ID_RE CAT_RE = vl.CAT_RE ARCHIVE_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.09 ') # e.g. "F.25.09 Archive" ARCHIVE_CAT_RE = re.compile(r'^([RJFI])\.(\d\d) ') # category folder, to find/create .09 inside # Obsidian's "New folder" action does `mkdir Untitled` (or "Untitled 1", "2", # etc. if that name's taken) the INSTANT you click it, before you've typed a # real name -- it is never a real OS rename() afterward, just a second # mkdir-with-real-name once you hit enter (confirmed with Ryan 2026-09-22). # The "Untitled" placeholder is always transient and must never be mirrored # to /data: doing so left permanent orphan junk there (the real delete event # for "Untitled" gets silently dropped by handle_vault_deleted, which only # tracks ID-shaped names) and produced two separate "create" ntfy pings # instead of the one the user actually cares about. UNTITLED_RE = re.compile(r'^Untitled(\s\d+)?$') # Journal entries: /vault/R Ryan/.../R.16.11 My journal & notes by day/YYYY/MM/YYYY-MM-DD.md # gets an auto-injected `title: MMM D, YYYY` frontmatter property (matches the # format Ryan has used by hand on every entry so far -- full month name, NO # zero-padded day, e.g. "title: September 3, 2026" not "September 03, 2026"). JOURNAL_DIR = os.path.join( VAULT, "R Ryan", "R.1 Life admin", "R.16 Journal & personal writing", "R.16.11 My journal & notes by day", ) JOURNAL_FILENAME_RE = re.compile(r'^(\d{4})-(\d{2})-(\d{2})\.md$') _MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] _lock = threading.Lock() _recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events _DEBOUNCE_SECONDS = 3.0 # Watchdog's inotify cookie-pairing (MOVED_FROM + MOVED_TO -> one on_moved) # is best-effort and confirmed to fail even on a single, unloaded, plain # folder rename (see handle_vault_deleted docstring) -- not just under # burst load like the earlier "Untitled" bug. To recover the rename intent # from an unpaired delete+create pair, a vault-side ID deletion is held # here (keyed by JD ID code, e.g. "F.13.13") for RENAME_GRACE_SECONDS # before it's actually archived, giving a same-ID create a chance to # claim it as a rename instead. _pending_id_deletes = {} _pending_deletes_lock = threading.Lock() RENAME_GRACE_SECONDS = 15.0 def _debounced(kind, path): """ Return True (and record) if this (kind, path) event was NOT seen in the last _DEBOUNCE_SECONDS — inotify/watchdog can emit duplicate CREATE events for a single mkdir, so every handler must be idempotent AND debounced to avoid double-processing (e.g. flagging the same new /data folder in two different inbox notes). """ now = time.time() key = (kind, path) last = _recent_events.get(key) _recent_events[key] = now # opportunistic cleanup so this dict never grows unbounded if len(_recent_events) > 2000: cutoff = now - _DEBOUNCE_SECONDS for k, v in list(_recent_events.items()): if v < cutoff: del _recent_events[k] return last is None or (now - last) > _DEBOUNCE_SECONDS def log(msg): line = f"{datetime.datetime.now().isoformat(timespec='seconds')} {msg}" print(line, flush=True) try: with open(LOG_PATH, "a", encoding="utf-8") as f: f.write(line + "\n") except Exception: pass def _git_commit(msg, paths=None): """ Commit only the specific paths the daemon itself touched — never a blanket `git add -A`, so a human's concurrent in-progress edits elsewhere in the vault are never swept into an unrelated auto-commit. """ if not paths: return subprocess.run(["git", "-C", VAULT, "add", "--"] + list(paths), check=False) diff = subprocess.run(["git", "-C", VAULT, "diff", "--cached", "--quiet"], check=False) if diff.returncode == 0: return # nothing staged subprocess.run(["git", "-C", VAULT, "commit", "-m", msg], check=False) def _in_skip(path, root): rel = os.path.relpath(path, root) parts = rel.split(os.sep) return any(p in SKIP_DIRS for p in parts) def _under_archive(rel_path): """True if any path component matches the '<SYS.AC>.09 ' archive pattern.""" for part in rel_path.split(os.sep): if ARCHIVE_ID_RE.match(part): return True return False def _under_data_exempt(rel_path): top = rel_path.split(os.sep)[0] return top in DATA_EXEMPT # --------------------------------------------------------------------------- # FORWARD: /vault -> /data # --------------------------------------------------------------------------- def handle_vault_created(path): """ A new folder appeared in /vault (e.g. created via Obsidian, not the `vault` CLI). Mirror it: create the matching empty folder in /data. This is the forward-create leg that was missing — only move/delete were wired up originally, so a brand-new folder (like "R.6 Test") never propagated. Full 1:1 structural mirror, NOT limited to JD-shaped (AREA/CATEGORY/ID) names (confirmed with Ryan 2026-09-19): once inside an ID's territory Ryan organizes arbitrary sub-structure (e.g. dated trip folders under an ID like "F.21.21 All short trips"), and expects /data to match that structure too, not just the JD skeleton. The only safety gate is that the /data PARENT must already exist — this naturally still excludes "0 System area" (which has no /data root at all, by design) and any other genuinely orphaned branch, without needing a name-shape check. Reverse (/data -> /vault) intentionally keeps its stricter JD-shape-only gate (see handle_data_created) — /data is the hand-sorted blob side with far more incidental subfolders, so stubbing a /vault note for every one of those would be noise; there's no such asymmetry forward. """ if not os.path.isdir(path): return if _in_skip(path, VAULT): return if not _debounced("vault_created", path): return rel = os.path.relpath(path, VAULT) if rel.split(os.sep)[0] == vl.SYSTEM_META: return # "0 System area" never mirrors to /data, by design if UNTITLED_RE.match(os.path.basename(path)): # Obsidian's transient "New folder" placeholder -- never mirror it. # The real create (with the name Ryan actually typed) fires as its # own separate event moments later and mirrors normally, so skipping # this one is enough: no orphan junk in /data, no duplicate ntfy. return base = os.path.basename(path) id_code = vl.id_code(base) if id_code: with _pending_deletes_lock: pending = _pending_id_deletes.pop(id_code, None) if pending is not None: # A vault-side delete of the SAME JD ID landed within the # grace window -- this create is the other half of a rename # that watchdog failed to pair into one on_moved (confirmed # 2026-09-24, see handle_vault_deleted). Recover the rename # instead of doing a normal create-mirror (which would have # produced a fresh empty stub, orphaning whatever was really # in the old-named /data folder). _resolve_as_rename(pending, path, rel) return data_path = os.path.join(DATA, rel) if os.path.isdir(data_path): return # already mirrored (duplicate event or pre-existing) parent = os.path.dirname(data_path) if not os.path.isdir(parent): log(f"SKIP create mirror (parent missing in /data): {rel}") return os.makedirs(data_path, exist_ok=True) log(f"MIRRORED create /data: {rel}") ntfy_post( "Vault sync: new folder", f"Created in /vault, mirrored to /data: {rel}", tags="sparkles", ) def handle_vault_moved(src_path, dest_path): if _in_skip(src_path, VAULT) or _in_skip(dest_path, VAULT): return rel_src = os.path.relpath(src_path, VAULT) rel_dest = os.path.relpath(dest_path, VAULT) data_src = os.path.join(DATA, rel_src) data_dest = os.path.join(DATA, rel_dest) if not os.path.isdir(data_src): # No /data counterpart to rename -- most commonly because the # source was an "Untitled" placeholder we deliberately never # mirrored (see UNTITLED_RE). Don't just drop this silently: the # folder (now under its real name) still needs to exist in /data, # so fall through to a normal create-mirror at the destination. if os.path.exists(data_dest): return # already mirrored somehow, nothing to do parent = os.path.dirname(data_dest) if not os.path.isdir(parent): log(f"SKIP move-as-create mirror (parent missing in /data): {rel_dest}") return os.makedirs(data_dest, exist_ok=True) log(f"MIRRORED create /data (from untitled-rename): {rel_dest}") ntfy_post( "Vault sync: new folder", f"Created in /vault, mirrored to /data: {rel_dest}", tags="sparkles", ) return if os.path.exists(data_dest): log(f"SKIP move mirror (dest exists): {rel_src} -> {rel_dest}") return os.makedirs(os.path.dirname(data_dest), exist_ok=True) os.rename(data_src, data_dest) log(f"MIRRORED rename /data: {rel_src} -> {rel_dest}") ntfy_post( "Vault sync: renamed", f"/data: {rel_src} -> {rel_dest}", tags="package", ) def handle_journal_file_created(path): """ A new dated journal entry (YYYY-MM-DD.md) appeared under R.16.11 -- auto-inject `title: MMM D, YYYY` frontmatter (no zero-padded day), matching the format Ryan has applied by hand to every entry so far. Idempotent: no-op if the file already has a `title:` line in its frontmatter block, or isn't actually under the journal dir / doesn't match the dated-entry filename shape. """ if not os.path.isfile(path): return if not path.endswith(".md"): return try: rel_to_journal = os.path.relpath(path, JOURNAL_DIR) except ValueError: return if rel_to_journal.startswith(".."): return # not actually under the journal dir base = os.path.basename(path) m = JOURNAL_FILENAME_RE.match(base) if not m: return # not a dated entry (e.g. some other note dropped in here) if not _debounced("journal_title", path): return year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3)) month_name = _MONTH_NAMES[month - 1] title_value = f"{month_name} {day}, {year}" try: with open(path, encoding="utf-8") as f: text = f.read() except Exception as e: log(f"ERROR journal title read failed: {path}: {e}") return if text.startswith("---\n"): end = text.find("\n---", 4) if end == -1: log(f"SKIP journal title (malformed frontmatter, no closing ---): {rel_to_journal}") return frontmatter = text[4:end] rest = text[end:] # starts with "\n---" if re.search(r'^title:\s*', frontmatter, re.MULTILINE): return # already has a title, never overwrite a human-set value new_frontmatter = f"title: {title_value}\n" + frontmatter new_text = "---\n" + new_frontmatter + rest else: # no frontmatter block at all yet -- create one new_text = f"---\ntitle: {title_value}\n---\n" + text try: with open(path, "w", encoding="utf-8") as f: f.write(new_text) except Exception as e: log(f"ERROR journal title write failed: {path}: {e}") return log(f"TITLED journal entry: {rel_to_journal} -> \"{title_value}\"") _git_commit(f"vault_sync_daemon: add title to journal entry {base}", [path]) def _category_title(catname): """'F.22 Fun lists' -> 'Fun lists'""" m = CAT_RE.match(catname) return catname[m.end():].strip() if m else catname def _find_nearest_archive_dir(rel_path): """ Archive destination is ALWAYS the immediate owning category of the deleted ID (one level up from the ID folder) — never a higher-level ancestor's archive, even if one already exists there. If that category has no '<SYS.AC>.09 ' archive folder yet, create it right there (materializing the missing JD archive slot), matching Ryan's rule: "create missing .01s and .09s when they're missing", not borrow one further up the tree. Returns (archive_data_path, vault_note_path_or_None). vault_note_path is set only if this call just created a NEW archive slot's folder-note (so the caller knows to git-add it). """ parts = rel_path.split(os.sep) if len(parts) < 2: return (DATA, None) # ID sits at top level (shouldn't happen) cat_rel = os.sep.join(parts[:-1]) cat_vault = os.path.join(VAULT, cat_rel) cat_data = os.path.join(DATA, cat_rel) catname = os.path.basename(cat_vault) m = CAT_RE.match(catname) if not m or not os.path.isdir(cat_vault): return (cat_data if os.path.isdir(cat_data) else DATA, None) sysletter, catnum = m.group(1), m.group(2) # already has an archive slot in this category? if os.path.isdir(cat_vault): for name in sorted(os.listdir(cat_vault)): if ARCHIVE_ID_RE.match(name): dpath = os.path.join(cat_data, name) os.makedirs(dpath, exist_ok=True) return (dpath, None) # missing — create it in THIS category, not a higher one archive_name = f"{sysletter}.{catnum}.09 Archive for {_category_title(catname)}" vpath = os.path.join(cat_vault, archive_name) vnote = os.path.join(vpath, archive_name + ".md") created_note = None if not os.path.exists(vnote): os.makedirs(vpath, exist_ok=True) with open(vnote, "w", encoding="utf-8") as f: f.write(f"# {archive_name}\n\n" f"Auto-created by vault_sync_daemon on " f"{datetime.datetime.now().isoformat(timespec='seconds')} " f"as the archive destination for a deleted ID in this category.\n") log(f"CREATED category archive slot: {os.path.relpath(vpath, VAULT)}") created_note = vnote archive_path = os.path.join(cat_data, archive_name) os.makedirs(archive_path, exist_ok=True) return (archive_path, created_note) def _nearest_archive_note(archive_data_path): """Given an archive dir in /data, return the matching /vault folder-note path, if any.""" rel = os.path.relpath(archive_data_path, DATA) vault_dir = os.path.join(VAULT, rel) if not os.path.isdir(vault_dir): return None note = os.path.join(vault_dir, os.path.basename(vault_dir) + ".md") return note if os.path.exists(note) else None def handle_vault_deleted(path): """ An ID-shaped folder disappeared from /vault. Per design this normally means archive the /data copy (see module docstring) -- but a bare on_deleted this is NOT always a real deletion. Confirmed 2026-09-24: a plain, unloaded, single-folder Obsidian rename (no burst, no stress test -- just "Budget & cash-flow tracking" -> "...tracker") produced an unpaired on_deleted + on_created ~12s apart instead of one on_moved. This is the SAME watchdog cookie-pairing gap as the earlier Untitled-placeholder bug, but this time with no load to blame at all -- so it can happen on any rename, not just bursts. Fix: don't archive immediately. Hold the delete in `_pending_id_deletes` keyed by the JD ID code (e.g. "F.13.13") for RENAME_GRACE_SECONDS. If a create for a folder with the SAME id code (different title text is fine -- that's exactly what a rename is) shows up within the window, handle_vault_created finds it in the pending dict and treats the pair as the rename it actually was instead of archiving-then-restubbing. Only after the grace window passes with no matching create does this actually archive. """ if _in_skip(path, VAULT): return if not _debounced("vault_deleted", path): return rel = os.path.relpath(path, VAULT) base = os.path.basename(path) if not ID_RE.match(base): return # only care about ID-folder deletions data_path = os.path.join(DATA, rel) if not os.path.isdir(data_path): return # already handled (duplicate event) or no /data counterpart ever existed id_code = vl.id_code(base) if id_code: with _pending_deletes_lock: _pending_id_deletes[id_code] = {"path": path, "rel": rel, "base": base} log(f"PENDING delete (grace window for possible rename pairing): {rel}") timer = threading.Timer(RENAME_GRACE_SECONDS, _finalize_pending_delete, args=(id_code,)) timer.daemon = True timer.start() return _archive_deleted_id(path, rel, base) def _finalize_pending_delete(id_code): """Timer callback: if nothing claimed this id_code as a rename target within the grace window, it really was just a deletion -- archive now.""" with _pending_deletes_lock: entry = _pending_id_deletes.pop(id_code, None) if entry is None: return # a matching create already resolved this as a rename with _lock: _archive_deleted_id(entry["path"], entry["rel"], entry["base"]) def _resolve_as_rename(pending, new_vault_path, new_rel): """A delete+create pair shared the same JD ID within the grace window -- treat it as the rename it actually is: move /data's old-named folder to the new name (and its folder-note filename to match), instead of archiving. Falls back to a normal create-mirror if /data has no old-named counterpart to move.""" old_rel = pending["rel"] data_src = os.path.join(DATA, old_rel) data_dest = os.path.join(DATA, new_rel) if not os.path.isdir(data_src): handle_vault_created(new_vault_path) return if os.path.exists(data_dest): log(f"SKIP rename-recovery mirror (dest exists): {old_rel} -> {new_rel}") return os.makedirs(os.path.dirname(data_dest), exist_ok=True) os.rename(data_src, data_dest) old_base = pending["base"] new_base = os.path.basename(new_vault_path) old_note = os.path.join(data_dest, old_base + ".md") new_note = os.path.join(data_dest, new_base + ".md") if old_base != new_base and os.path.isfile(old_note) and not os.path.exists(new_note): os.rename(old_note, new_note) log(f"RECOVERED rename (delete+create pair, same ID {vl.id_code(new_base)}): {old_rel} -> {new_rel}") ntfy_post( "Vault sync: renamed", f"/data: {old_rel} -> {new_rel}", tags="package", ) def _archive_deleted_id(path, rel, base): data_path = os.path.join(DATA, rel) if not os.path.isdir(data_path): return # nothing to archive -- claimed by a rename, or already gone archive_dir, created_note = _find_nearest_archive_dir(rel) dest = os.path.join(archive_dir, base) if os.path.exists(dest): dest = os.path.join(archive_dir, f"{base} (archived {int(time.time())})") shutil.move(data_path, dest) log(f"ARCHIVED /data: {rel} -> {os.path.relpath(dest, DATA)}") note = _nearest_archive_note(archive_dir) ts = datetime.datetime.now().isoformat(timespec="seconds") entry = (f"\n- **Archived {ts}:** `{base}` (deleted from /vault) — " f"data preserved at `/data/{os.path.relpath(dest, DATA)}`\n") commit_paths = [] if note: with open(note, "a", encoding="utf-8") as f: f.write(entry) log(f"NOTED archive in: {os.path.relpath(note, VAULT)}") commit_paths.append(note) if created_note: commit_paths.append(created_note) _git_commit(f"vault_sync_daemon: archived {base}", commit_paths) ntfy_post( "Vault sync: archived", f"Deleted from /vault: {base}\nData preserved at /data/{os.path.relpath(dest, DATA)}", tags="package,wastebasket", ) # --------------------------------------------------------------------------- # REVERSE: /data -> /vault # --------------------------------------------------------------------------- def _id_title(idname): """'R.32.20 Recreational drugs' -> 'Recreational drugs' (ID stripped).""" m = ID_RE.match(idname) return idname[m.end():].strip() if m else idname def handle_data_created(path): if not os.path.isdir(path): return if _in_skip(path, DATA): return if not _debounced("data_created", path): return # duplicate inotify event for the same mkdir, ignore rel = os.path.relpath(path, DATA) if _under_data_exempt(rel): return if _under_archive(rel): return # never auto-stub inside archive folders base = os.path.basename(path) if not ID_RE.match(base): return # only ID-shaped folders trigger reverse-sync vault_path = os.path.join(VAULT, rel) if os.path.isdir(vault_path): return # already has a /vault counterpart catcode_root = os.path.dirname(vault_path) if not os.path.isdir(catcode_root): log(f"SKIP stub (parent category folder missing in /vault): {rel}") return note = os.path.join(vault_path, base + ".md") os.makedirs(vault_path, exist_ok=True) alias = _id_title(base) with open(note, "w", encoding="utf-8") as f: f.write(f"---\naliases:\n - {alias}\n---\n") log(f"STUBBED /vault note for new /data folder: {rel}") _git_commit(f"vault_sync_daemon: stubbed {base}", [note]) ntfy_post( "Vault sync: new note", f"New /data folder with no vault note: {rel}\nAuto-created stub with alias \"{alias}\".", tags="package,sparkles", ) def handle_data_moved(src_path, dest_path): """ A folder in /data was renamed/moved (e.g. via an SMB client, GUI file manager, or any tool that stages-then-renames rather than mkdir'ing directly in place). DataHandler previously had NO on_moved at all, so this fired zero log line and zero ntfy -- confirmed root cause of Ryan's 2026-09-22 report ("created a folder in /data, nothing happened on ntfy or in /vault": the folder actually arrived via a rename, not a bare create, and there was no handler to catch it). Mirror rule: if /vault already has a note at the OLD relative path, rename it to match (same as the forward vault->data rename mirror). If there's no /vault counterpart yet at all, treat this exactly like a fresh data_created at the NEW path (covers the same "staged then renamed into place" arrival pattern as a plain create). """ if _in_skip(src_path, DATA) or _in_skip(dest_path, DATA): return rel_src = os.path.relpath(src_path, DATA) rel_dest = os.path.relpath(dest_path, DATA) if _under_data_exempt(rel_src) or _under_data_exempt(rel_dest): return vault_src = os.path.join(VAULT, rel_src) vault_dest = os.path.join(VAULT, rel_dest) if not os.path.isdir(vault_src): # No /vault counterpart to rename -- treat the arrival at the new # name like a normal reverse-sync create. handle_data_created(dest_path) return if os.path.exists(vault_dest): log(f"SKIP data-move mirror (vault dest exists): {rel_src} -> {rel_dest}") return os.makedirs(os.path.dirname(vault_dest), exist_ok=True) os.rename(vault_src, vault_dest) # keep the folder-note's own filename in sync with its containing # folder's new name (matches the "<ID name>/<ID name>.md" convention # used throughout the vault) old_base = os.path.basename(vault_src) new_base = os.path.basename(vault_dest) old_note = os.path.join(vault_dest, old_base + ".md") new_note = os.path.join(vault_dest, new_base + ".md") if old_base != new_base and os.path.isfile(old_note) and not os.path.exists(new_note): os.rename(old_note, new_note) log(f"MIRRORED rename /vault: {rel_src} -> {rel_dest}") commit_paths = [vault_dest] if os.path.isdir(vault_dest) else [] _git_commit(f"vault_sync_daemon: renamed {old_base} -> {new_base}", commit_paths) ntfy_post( "Vault sync: renamed", f"/vault: {rel_src} -> {rel_dest}", tags="package", ) # --------------------------------------------------------------------------- # watchdog wiring # # CRITICAL: watchdog's internal reader thread must stay fast. On Linux, # watchdog polls the raw inotify fd and is responsible for pairing a # rename's IN_MOVED_FROM + IN_MOVED_TO events into a single `on_moved` # call (it does this by matching cookies within its own read loop). If our # FileSystemEventHandler callbacks run slow, synchronous work directly on # that thread (git commits, network ntfy calls, time.sleep debounce # delays), a burst of inotify events (e.g. renaming a folder with many # children -> hundreds of individual FS events) can back up the kernel's # inotify event queue faster than watchdog's reader drains it. Once # events start dropping, the MOVED_FROM/MOVED_TO cookie pairing breaks and # watchdog falls back to firing a bare `on_deleted` + a later bare # `on_created` instead of one `on_moved` -- exactly the delete+create # corruption bug Ryan reported (confirmed by reproduction: renaming a # ~300-child folder produced a rename that silently vanished from the log # entirely, while the same rename on a small/empty folder worked fine). # # Fix: handlers do ZERO real work. They just stuff the event onto a # queue and return immediately, so the watchdog reader thread is never # blocked and can drain the kernel's rename event pairs promptly. A # single dedicated worker thread pulls off the queue and does the actual # (slow) sync work, serialized, in FS-event order. # --------------------------------------------------------------------------- _event_queue = queue.Queue() class VaultHandler(FileSystemEventHandler): def on_created(self, event): if event.is_directory: _event_queue.put(("vault_created", event.src_path)) else: _event_queue.put(("vault_file_created", event.src_path)) def on_moved(self, event): if not event.is_directory: return _event_queue.put(("vault_moved", event.src_path, event.dest_path)) def on_deleted(self, event): if not event.is_directory: return _event_queue.put(("vault_deleted", event.src_path)) class DataHandler(FileSystemEventHandler): def on_created(self, event): if not event.is_directory: return _event_queue.put(("data_created", event.src_path)) def on_moved(self, event): if not event.is_directory: return _event_queue.put(("data_moved", event.src_path, event.dest_path)) def _worker_loop(): """Single consumer thread: drains _event_queue and does the actual (slow) sync work, one event at a time, in the order events arrived. Keeping this off the watchdog reader thread is what lets inotify's MOVED_FROM/MOVED_TO cookie pairing succeed under load (see module docstring above the queue wiring).""" while True: item = _event_queue.get() kind = item[0] try: with _lock: if kind == "vault_created": _, path = item time.sleep(0.5) # let Obsidian finish writing the folder-note handle_vault_created(path) elif kind == "vault_file_created": _, path = item time.sleep(0.5) # let the writer (Obsidian/editor) finish writing content handle_journal_file_created(path) elif kind == "vault_moved": _, src_path, dest_path = item handle_vault_moved(src_path, dest_path) elif kind == "vault_deleted": _, path = item handle_vault_deleted(path) elif kind == "data_created": _, path = item time.sleep(0.5) # small settle delay so a batch mkdir/copy isn't caught half-written handle_data_created(path) elif kind == "data_moved": _, src_path, dest_path = item handle_data_moved(src_path, dest_path) except Exception as e: log(f"ERROR handling {item}: {e}") ntfy_post( "Vault sync ERROR", f"Handling {kind} failed:\n{item}\n{e}", tags="rotating_light", priority="urgent", ) finally: _event_queue.task_done() RECONCILE_INTERVAL_SECONDS = 180 def reconcile(): """ Full-tree self-healing sweep, independent of inotify. inotify is best-effort: watch descriptors can be dropped, events can be coalesced or lost under load, and — per Ryan 2026-09-24 — a plain `mkdir` under R.12.22 produced zero log line and zero ntfy with no reproducible cause found (kernel-level inotifywait on the same exact directory caught the same mkdir instantly, so it wasn't a mount/fs issue — watchdog's own bookkeeping simply missed it). Rather than chase every possible way an event can go missing, the daemon must be resilient no matter what: periodically diff the whole tree in both directions and self-heal anything inotify failed to deliver. This walks every directory in /data and /vault and calls the exact same idempotent handlers used for live events (handle_data_created / handle_vault_created). Both are "check target state first, act only if something's actually missing" by construction (see module docstring), so calling them on every already-mirrored directory is a cheap no-op — only genuinely missed folders get created, stubbed, and ntfy'd here. Deliberately does NOT attempt rename detection: a rename that happened while the daemon was blind looks identical to a delete+create pair, which is genuinely ambiguous to reconstruct after the fact. Live on_moved handling remains the only path for renames; reconcile's job is just to guarantee creates are never permanently lost. """ t0 = time.time() data_count = 0 for dirpath, dirnames, _ in os.walk(DATA): dirnames.sort() if dirpath == DATA: continue if _in_skip(dirpath, DATA): dirnames[:] = [] continue with _lock: handle_data_created(dirpath) data_count += 1 vault_count = 0 for dirpath, dirnames, _ in os.walk(VAULT): dirnames.sort() if dirpath == VAULT: continue if _in_skip(dirpath, VAULT): dirnames[:] = [] continue with _lock: handle_vault_created(dirpath) vault_count += 1 log(f"reconcile: swept {data_count} /data dirs, {vault_count} /vault dirs " f"in {time.time() - t0:.1f}s") def _reconcile_loop(): while True: time.sleep(RECONCILE_INTERVAL_SECONDS) try: reconcile() except Exception as e: log(f"ERROR in reconcile sweep: {e}") ntfy_post( "Vault sync ERROR", f"Periodic reconcile sweep failed:\n{e}", tags="rotating_light", priority="urgent", ) def main(): log("vault_sync_daemon starting") worker = threading.Thread(target=_worker_loop, daemon=True) worker.start() observer = Observer() observer.schedule(VaultHandler(), VAULT, recursive=True) observer.schedule(DataHandler(), DATA, recursive=True) observer.start() log(f"watching {VAULT} and {DATA}") ntfy_post("Vault sync daemon started", f"Watching {VAULT} and {DATA}", tags="rocket", priority="low") # Run one reconcile sweep at startup (catches anything that happened # while the daemon was down, e.g. a reboot) then keep sweeping on a # timer as a standing backstop under live inotify events. try: reconcile() except Exception as e: log(f"ERROR in startup reconcile sweep: {e}") threading.Thread(target=_reconcile_loop, daemon=True).start() try: while True: time.sleep(5) except KeyboardInterrupt: pass finally: observer.stop() observer.join() log("vault_sync_daemon stopped") # NOTE: no ntfy_post here — a hard kill/OOM/crash skips this `finally` # entirely, so relying on it for "stopped" alerts is unreliable. The # systemd unit's ExecStopPost= hook fires unconditionally on every # exit (clean or not) and is the actual source of stop notifications. if __name__ == "__main__": main()