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
+100
View File
@@ -3645,6 +3645,106 @@ def test_sidecar_permission_fix_target():
assert c.sidecar_permission_fix_target({"command": "npx", "args": ["other"]}) is None
# --------------------------------------------------------------------------- #
# Hot-reload — live external-change detection (issue #101)
# --------------------------------------------------------------------------- #
def test_sidecar_watch_paths_covers_file_dir_and_doc():
data = dict(_SSH)
paths = c.sidecar_watch_paths(data, platform="darwin", environ={}, home=_HOME)
posix = [p.as_posix() for p in paths]
real = c.sidecar_path(c.SERVER_SPECS["ssh-mcp"], platform="darwin", environ={}, home=_HOME)
# The resolved sidecar file AND its directory (so create/delete registers).
assert real.as_posix() in posix
assert real.parent.as_posix() in posix
# The README/doc path is distinct on macOS -> also watched.
doc = c.sidecar_doc_path(c.SERVER_SPECS["ssh-mcp"], home=_HOME)
assert doc.as_posix() in posix
# No duplicates.
assert len(posix) == len(set(posix))
def test_sidecar_watch_paths_dedupes_on_linux():
# On Linux the doc path coincides with the real path, so the file + dir are
# each listed once, not twice.
data = dict(_SSH)
paths = c.sidecar_watch_paths(
data, platform="linux", environ={"XDG_CONFIG_HOME": "/cfg"}, home=_HOME
)
posix = [p.as_posix() for p in paths]
assert posix == list(dict.fromkeys(posix)) # order-preserving de-dup is a no-op
assert "/cfg/ssh-mcp/config.toml" in posix
assert "/cfg/ssh-mcp" in posix
def test_sidecar_watch_paths_empty_for_non_sidecar():
assert c.sidecar_watch_paths({"command": "npx", "args": ["other"]}) == []
def test_sidecar_state_fingerprint_reflects_appearance_and_perms():
data = dict(_SSH)
real = c.sidecar_path(c.SERVER_SPECS["ssh-mcp"], platform="darwin", environ={}, home=_HOME)
# File absent -> a fingerprint that encodes "not there, no perms".
absent = c.sidecar_state_fingerprint(
data, platform="darwin", environ={}, home=_HOME, exists=lambda p: False
)
# File present but world-readable.
bad = c.sidecar_state_fingerprint(
data,
platform="darwin",
environ={},
home=_HOME,
exists=lambda p: Path(p) == real,
stat_mode=lambda p: 0o644 if Path(p) == real else 0o700,
)
# File present and tight.
good = c.sidecar_state_fingerprint(
data,
platform="darwin",
environ={},
home=_HOME,
exists=lambda p: Path(p) == real,
stat_mode=lambda p: 0o600 if Path(p) == real else 0o700,
)
assert absent is not None
# Each observable transition changes the fingerprint.
assert c.sidecar_state_changed(absent, bad)
assert c.sidecar_state_changed(bad, good)
assert c.sidecar_state_changed(absent, good)
# Stable when nothing changed.
assert not c.sidecar_state_changed(good, good)
def test_sidecar_state_fingerprint_none_for_non_sidecar():
assert c.sidecar_state_fingerprint({"command": "npx", "args": ["other"]}) is None
def test_sidecar_state_fingerprint_tracks_args_inert_flip():
# args_inert only becomes true once the sidecar file exists AND managed args
# are present; the fingerprint must capture that precedence flip.
data = dict(_SSH) # carries --host/--user (managed args)
real = c.sidecar_path(
c.SERVER_SPECS["ssh-mcp"], platform="linux", environ={"XDG_CONFIG_HOME": "/cfg"}, home=_HOME
)
before = c.sidecar_state_fingerprint(
data,
platform="linux",
environ={"XDG_CONFIG_HOME": "/cfg"},
home=_HOME,
exists=lambda p: False,
stat_mode=lambda p: None,
)
after = c.sidecar_state_fingerprint(
data,
platform="linux",
environ={"XDG_CONFIG_HOME": "/cfg"},
home=_HOME,
exists=lambda p: Path(p) == real,
stat_mode=lambda p: 0o600,
)
assert c.sidecar_state_changed(before, after)
# --------------------------------------------------------------------------- #
# Move to environment variable (issue #83)
# --------------------------------------------------------------------------- #