Forward (/vault->/data) mirroring was gated to only AREA/CATEGORY/ID-shaped names, so arbitrary sub-structure Ryan creates inside an ID (e.g. dated trip folders under F.21.21) never propagated to /data. Removed the name-shape gate; the existing parent-must-exist check is sufficient safety, plus an explicit '0 System area' exclusion since that tree has no /data root at all.
527 lines
20 KiB
Python
527 lines
20 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):
|
|
- 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,
|
|
tagged "needs review", AND append a `#fix-me` line to the nearest `.01`
|
|
Inbox note in /vault (category-level first, then area/system, falling
|
|
back to the top-level System inbox).
|
|
- 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
|
|
|
|
_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:
|
|
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,
|
|
)
|
|
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
|
|
INBOX_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.01 ')
|
|
|
|
_lock = threading.Lock()
|
|
_recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events
|
|
_DEBOUNCE_SECONDS = 3.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
|
|
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):
|
|
return # nothing to mirror
|
|
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 _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):
|
|
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
|
|
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 _find_nearest_inbox_note(rel_path):
|
|
"""
|
|
Inbox destination is ALWAYS the immediate owning category of the new
|
|
/data folder (same rule as archives: create the missing '.01 Inbox'
|
|
right there if it doesn't exist yet, never borrow one from a higher
|
|
ancestor).
|
|
"""
|
|
parts = rel_path.split(os.sep)
|
|
if len(parts) < 2:
|
|
return None
|
|
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 None
|
|
sysletter, catnum = m.group(1), m.group(2)
|
|
for name in sorted(os.listdir(cat_vault)):
|
|
if INBOX_ID_RE.match(name):
|
|
note = os.path.join(cat_vault, name, name + ".md")
|
|
if os.path.exists(note):
|
|
return note
|
|
# missing — create it in THIS category, not a higher one
|
|
inbox_name = f"{sysletter}.{catnum}.01 Inbox for {_category_title(catname)}"
|
|
vpath = os.path.join(cat_vault, inbox_name)
|
|
note = os.path.join(vpath, inbox_name + ".md")
|
|
os.makedirs(vpath, exist_ok=True)
|
|
with open(note, "w", encoding="utf-8") as f:
|
|
f.write(f"# {inbox_name}\n\n"
|
|
f"Auto-created by vault_sync_daemon on "
|
|
f"{datetime.datetime.now().isoformat(timespec='seconds')} "
|
|
f"to flag an unrecognized /data folder in this category.\n")
|
|
log(f"CREATED category inbox slot: {os.path.relpath(vpath, VAULT)}")
|
|
dpath = os.path.join(cat_data, inbox_name)
|
|
os.makedirs(dpath, exist_ok=True)
|
|
return note
|
|
|
|
|
|
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)
|
|
ts = datetime.datetime.now().isoformat(timespec="seconds")
|
|
with open(note, "w", encoding="utf-8") as f:
|
|
f.write(f"# {base}\n\n"
|
|
f"> [!warning] Needs review\n"
|
|
f"> Auto-created by vault_sync_daemon on {ts} because a matching "
|
|
f"folder appeared in `/data` with no `/vault` note yet.\n")
|
|
log(f"STUBBED /vault note for new /data folder: {rel}")
|
|
commit_paths = [note]
|
|
inbox = _find_nearest_inbox_note(rel)
|
|
if inbox:
|
|
with open(inbox, "a", encoding="utf-8") as f:
|
|
f.write(f"\n- #fix-me New `/data` folder detected with no vault note: "
|
|
f"[[{base}]] (auto-stub created {ts})\n")
|
|
log(f"FLAGGED in inbox: {os.path.relpath(inbox, VAULT)}")
|
|
commit_paths.append(inbox)
|
|
_git_commit(f"vault_sync_daemon: stubbed {base}", commit_paths)
|
|
ntfy_post(
|
|
"Vault sync: needs review",
|
|
f"New /data folder with no vault note: {rel}\nStub created, flagged in nearest inbox.",
|
|
tags="package,warning",
|
|
priority="high",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# watchdog wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class VaultHandler(FileSystemEventHandler):
|
|
def on_created(self, event):
|
|
if not event.is_directory:
|
|
return
|
|
with _lock:
|
|
try:
|
|
time.sleep(0.5) # let Obsidian finish writing the folder-note
|
|
handle_vault_created(event.src_path)
|
|
except Exception as e:
|
|
log(f"ERROR handle_vault_created: {e}")
|
|
ntfy_post(
|
|
"Vault sync ERROR",
|
|
f"handle_vault_created failed:\n{event.src_path}\n{e}",
|
|
tags="rotating_light",
|
|
priority="urgent",
|
|
)
|
|
|
|
def on_moved(self, event):
|
|
if not event.is_directory:
|
|
return
|
|
with _lock:
|
|
try:
|
|
handle_vault_moved(event.src_path, event.dest_path)
|
|
except Exception as e:
|
|
log(f"ERROR handle_vault_moved: {e}")
|
|
ntfy_post(
|
|
"Vault sync ERROR",
|
|
f"handle_vault_moved failed:\n{event.src_path} -> {event.dest_path}\n{e}",
|
|
tags="rotating_light",
|
|
priority="urgent",
|
|
)
|
|
|
|
def on_deleted(self, event):
|
|
if not event.is_directory:
|
|
return
|
|
with _lock:
|
|
try:
|
|
handle_vault_deleted(event.src_path)
|
|
except Exception as e:
|
|
log(f"ERROR handle_vault_deleted: {e}")
|
|
ntfy_post(
|
|
"Vault sync ERROR",
|
|
f"handle_vault_deleted failed:\n{event.src_path}\n{e}",
|
|
tags="rotating_light",
|
|
priority="urgent",
|
|
)
|
|
|
|
|
|
class DataHandler(FileSystemEventHandler):
|
|
def on_created(self, event):
|
|
if not event.is_directory:
|
|
return
|
|
with _lock:
|
|
try:
|
|
# small settle delay so a batch mkdir/copy isn't caught half-written
|
|
time.sleep(0.5)
|
|
handle_data_created(event.src_path)
|
|
except Exception as e:
|
|
log(f"ERROR handle_data_created: {e}")
|
|
ntfy_post(
|
|
"Vault sync ERROR",
|
|
f"handle_data_created failed:\n{event.src_path}\n{e}",
|
|
tags="rotating_light",
|
|
priority="urgent",
|
|
)
|
|
|
|
|
|
def main():
|
|
log("vault_sync_daemon starting")
|
|
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")
|
|
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()
|