946 lines
39 KiB
Python
946 lines
39 KiB
Python
#!/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):
|
|
- any non-`.md` file created or moved INTO /vault (a binary dropped or
|
|
saved there by mistake -- /vault is notes-only by design) -> relocate
|
|
it to the matching path under /data instead of leaving it in /vault.
|
|
- 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: [<title with ID stripped>]` 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_vault_binary_created(path):
|
|
"""
|
|
/vault is notes-only by design (see vault-librarian skill). Any
|
|
non-`.md` file that lands there -- Ryan saving a photo/PDF by hand,
|
|
an agent writing to the wrong tree, a sync client dropping a stray
|
|
file -- gets relocated to the matching path under /data, never left
|
|
in /vault. Idempotent: no-op if the file is already gone (raced with
|
|
another mover) or the /data destination already exists (name clash --
|
|
log and leave the vault copy in place rather than silently overwriting
|
|
or deleting real content).
|
|
"""
|
|
if not os.path.isfile(path):
|
|
return
|
|
if path.endswith(".md"):
|
|
return
|
|
if _in_skip(path, VAULT):
|
|
return
|
|
if not _debounced("vault_binary_created", path):
|
|
return
|
|
rel = os.path.relpath(path, VAULT)
|
|
if rel.split(os.sep)[0] == vl.SYSTEM_META:
|
|
return # "0 System area" has no /data counterpart at all
|
|
data_path = os.path.join(DATA, rel)
|
|
if os.path.exists(data_path):
|
|
log(f"SKIP relocate (already exists in /data, leaving vault copy): {rel}")
|
|
ntfy_post(
|
|
"Vault sync: binary needs manual move",
|
|
f"Non-.md file in /vault couldn't relocate -- /data already "
|
|
f"has {rel}. Left in place, resolve by hand.",
|
|
tags="warning",
|
|
priority="high",
|
|
)
|
|
return
|
|
os.makedirs(os.path.dirname(data_path), exist_ok=True)
|
|
shutil.move(path, data_path)
|
|
log(f"RELOCATED non-md file /vault -> /data: {rel}")
|
|
ntfy_post(
|
|
"Vault sync: moved binary out of /vault",
|
|
f"{rel} isn't a note -- moved to /data.",
|
|
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 _prune_deleted_plain_folder(vault_path, rel, data_path):
|
|
"""
|
|
A non-ID folder vanished from /vault (typically a sync client applying
|
|
a rename as delete+create, so the old name's /data mirror is orphaned).
|
|
Remove the /data mirror only if its whole tree holds no files; if it
|
|
holds any, leave it and ping instead.
|
|
"""
|
|
if rel.split(os.sep)[0] == vl.SYSTEM_META or _under_archive(rel):
|
|
return
|
|
if os.path.exists(vault_path) or not os.path.isdir(data_path):
|
|
return
|
|
if any(files for _, _, files in os.walk(data_path)):
|
|
log(f"KEPT /data mirror of deleted vault folder (has files): {rel}")
|
|
ntfy_post(
|
|
"Vault sync: deleted folder has blobs",
|
|
f"Folder removed from /vault but /data/{rel} contains files. Left in place.",
|
|
tags="warning",
|
|
)
|
|
return
|
|
shutil.rmtree(data_path)
|
|
log(f"PRUNED empty /data mirror of deleted vault folder: {rel}")
|
|
|
|
|
|
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)
|
|
data_path = os.path.join(DATA, rel)
|
|
if not ID_RE.match(base):
|
|
_prune_deleted_plain_folder(path, rel, data_path)
|
|
return
|
|
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:
|
|
_event_queue.put(("vault_file_moved", event.src_path, event.dest_path))
|
|
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_vault_binary_created(path)
|
|
handle_journal_file_created(path)
|
|
elif kind == "vault_file_moved":
|
|
_, src_path, dest_path = item
|
|
handle_vault_binary_created(dest_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
|
|
vault_file_count = 0
|
|
for dirpath, dirnames, filenames 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
|
|
for fname in filenames:
|
|
if fname.endswith(".md"):
|
|
continue
|
|
with _lock:
|
|
handle_vault_binary_created(os.path.join(dirpath, fname))
|
|
vault_file_count += 1
|
|
log(f"reconcile: swept {data_count} /data dirs, {vault_count} /vault dirs, "
|
|
f"{vault_file_count} /vault non-md files 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()
|