feat(#101): live hot-reload of external sidecar/config changes
CI / Lint (ruff) (pull_request) Successful in 13s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 25s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 13s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 8s

Detection (#91/#93) previously ran only at profile load / server
selection, so a config.toml created or chmod-ed while BCC was running
stayed invisible until a restart. Make the view react on its own.

Core (pure, unit-tested — CI has no PySide6):
- sidecar_watch_paths(): the external paths worth watching for a server
  — the resolved sidecar file, its directory (so create/delete and
  atomic-rename replaces register), and the wrong-path/doc file — order-
  stable and de-duplicated.
- sidecar_state_fingerprint(): a hashable snapshot folding the #91
  sidecar status and #93 permission status, so the GUI can tell whether
  the *observable* state actually changed and skip a redundant refresh.
- sidecar_state_changed(): explicit, named equality for that decision.

GUI (thin wiring, smoke-tested headlessly):
- QFileSystemWatcher over the selection's sidecar path(s) + BCC's own
  loaded config; debounced (300 ms) so a burst of writes doesn't thrash.
- Re-arm on every event: an atomic-rename replace drops the inode from
  the watcher, so wanted paths are re-added before the next check.
- Focus-in fallback via changeEvent(ActivationChange) — always works
  where watchers miss (atomic replaces, not-yet-created files).
- recheck_advisories() only recomputes warning labels from the form +
  filesystem; it never touches field values, so a live reload cannot
  clobber unsaved edits.
- An external edit to BCC's own config surfaces a non-destructive
  Reload banner (never a silent overwrite); confirm-on-dirty reuses the
  existing load path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cowork Supervisor
2026-08-13 00:12:26 -04:00
co-authored by Claude Opus 4.8
parent cdda60da1b
commit ded1eef2dd
3 changed files with 352 additions and 1 deletions
+92
View File
@@ -3100,6 +3100,98 @@ def sidecar_permission_fix_target(
return sidecar_path(spec, platform=platform, environ=environ, home=home)
# --------------------------------------------------------------------------- #
# Hot-reload — live external-change detection (issue #101, epic #94)
#
# #91/#93 compute the sidecar and permission advisories only at profile load /
# server selection, so a config.toml created (or chmod-ed) WHILE BCC runs stays
# invisible until a restart. The OS file-watcher, the focus-in re-check and the
# debounce are GUI wiring (bcc.py). The testable core is two pure functions:
# * which external paths to WATCH for a given server, and
# * a comparable SNAPSHOT of the externally-observable state, so the GUI can
# cheaply decide "did anything the user can see actually change?" and skip a
# redundant (flicker-y) refresh on an unrelated write in the watched dir.
# --------------------------------------------------------------------------- #
def sidecar_watch_paths(
data: dict,
*,
platform: str | None = None,
environ: dict | None = None,
home: str | os.PathLike | None = None,
) -> list[Path]:
"""External filesystem paths whose changes affect a server's advisories.
Returns the resolved sidecar path, its containing directory (so the file
appearing or being deleted — which a file-only watch on a not-yet-existent
path would miss — is still observed), and, when distinct, the README/doc
path (the #91 wrong-path trap: a file a user placed where the docs say but
the server never reads). Empty when the server has no sidecar. Order is
stable and de-duplicated so the GUI can hand it straight to a watcher.
"""
spec = resolve_server_spec(data)
if spec is None:
return []
out: list[Path] = []
p = sidecar_path(spec, platform=platform, environ=environ, home=home)
if p is not None:
out.append(p)
out.append(p.parent)
doc = sidecar_doc_path(spec, home=home)
if doc is not None and doc != p:
out.append(doc)
out.append(doc.parent)
# De-dupe while preserving order (the sidecar file's own dir may equal the
# doc dir, or on Linux the doc path coincides with the real one).
seen: set[Path] = set()
uniq: list[Path] = []
for path in out:
if path not in seen:
seen.add(path)
uniq.append(path)
return uniq
def sidecar_state_fingerprint(
data: dict,
*,
platform: str | None = None,
environ: dict | None = None,
home: str | os.PathLike | None = None,
exists=None,
stat_mode=None,
) -> tuple | None:
"""A comparable snapshot of a server's externally-observable sidecar state.
Folds the #91 sidecar status (existence, precedence, wrong-path) and the #93
permission status (mode + ok) into one hashable tuple. The GUI takes a
fingerprint before and after a filesystem event and refreshes the advisories
only when it changed — so an unrelated write in the watched directory doesn't
thrash the UI. Returns None when the server has no sidecar (nothing to
watch). All inputs are injectable so tests drive a virtual filesystem.
"""
status = sidecar_status(data, platform=platform, environ=environ, home=home, exists=exists)
if status is None:
return None
perm = permission_status(status["path"], platform=platform, stat_mode=stat_mode)
return (
status["exists"],
status["doc_exists"],
status["args_inert"],
status["wrong_path"],
None if perm is None else perm["ok"],
None if perm is None else perm["mode"],
)
def sidecar_state_changed(before: tuple | None, after: tuple | None) -> bool:
"""True when two ``sidecar_state_fingerprint`` snapshots differ.
A thin, explicitly-named equality so the GUI's watcher/focus-in handlers read
intentionally and the "should I refresh?" decision stays covered by tests.
"""
return before != after
# --------------------------------------------------------------------------- #
# Validation
# --------------------------------------------------------------------------- #