Dependency publication now recovers interrupted config/facts changes before activation and leases live generations during collection. Receipts retain update correlation and failed steps across nested command boundaries. Doctor and desktop surfaces report those failures through shared owners. Move checkout updates out of the desktop facade. Stage a detached Windows relaunch waiter before shutdown, with bounded handshake and process-birth checks. Keep packaged lifecycle tests isolated from the installed app. Native verification exposed two production races: cron maintenance imported the interactive CLI and rewrote TERMINAL_CWD, and install-ID reads collided with first publication. Use the existing owners and locks. Plugin checks now run at startup and each due-gated housekeeping tick, not after 60 ticks. Share updater-test mutation boundaries and remove collection-root fixtures. Separate cold MCP startup from command latency and give the real HTTP drip test enough time to reach body handling. Root npm check passed, including packaging. The fixed-tree Windows Python run reported 44557 passed, one failed, and 1404 skipped, plus one retry-only HTTP test. Those final failures now pass in a 35-test bounded batch. A real isolated gateway wrote startup and periodic plugin-check receipts. Full final-tree CI, bundled Sandbox deployment, and actual App Installer relaunch remain unverified. docs/pm-audit-status.md records these limits.
251 lines
8.6 KiB
Python
251 lines
8.6 KiB
Python
"""The plugin update-check cadence: read-only, receipt-surfaced.
|
|
|
|
Runs at gateway start + the periodic tick when
|
|
``plugins.auto_update_check_hours`` (default 24, 0 disables) says it's
|
|
due, writes a plugin-check receipt (pm.receipt, kind 'plugin-check'),
|
|
and logs ONE actionable line when updates exist. Applying updates
|
|
stays explicit — ``plugins.auto_apply: true`` (default false) opts into
|
|
unattended apply for git-row plugins ONLY, scan-gated like cmd_update.
|
|
|
|
Pure, injectable clock/check/update seams for hermetic tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
_MARKERS_DIR = "plugin-update-checks"
|
|
_DEFAULT_INTERVAL_HOURS = 24
|
|
|
|
|
|
def _marker_path() -> Path:
|
|
"""The last-run marker, resolved per call (tests monkeypatch
|
|
get_hermes_home). Derivation only — NO mkdir: a due-check or any
|
|
read path must not create user state."""
|
|
from hermes_constants import get_hermes_home
|
|
|
|
return get_hermes_home() / _MARKERS_DIR / "last-run"
|
|
|
|
|
|
def _markers_dir() -> Path:
|
|
"""The markers dir, created on demand — WRITE paths only."""
|
|
d = _marker_path().parent
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def check_interval_hours(config_get: Callable = None) -> float:
|
|
"""plugins.auto_update_check_hours; 0 disables; default 24."""
|
|
if config_get is None:
|
|
config_get = _default_config_get
|
|
try:
|
|
value = config_get("plugins", "auto_update_check_hours")
|
|
except Exception:
|
|
return _DEFAULT_INTERVAL_HOURS
|
|
if value is None:
|
|
return _DEFAULT_INTERVAL_HOURS
|
|
try:
|
|
hours = float(value)
|
|
except (TypeError, ValueError):
|
|
return _DEFAULT_INTERVAL_HOURS
|
|
return max(0.0, hours)
|
|
|
|
|
|
def auto_apply_enabled(config_get: Callable = None) -> bool:
|
|
if config_get is None:
|
|
config_get = _default_config_get
|
|
try:
|
|
return bool(config_get("plugins", "auto_apply"))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _default_config_get(section: str, key: str):
|
|
try:
|
|
from hermes_cli.config import cfg_get, load_config_readonly
|
|
|
|
return cfg_get(load_config_readonly(), section, key, default=None)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def check_due(now: Optional[float] = None, interval_hours: Optional[float] = None,
|
|
config_get: Callable = None) -> bool:
|
|
"""The clock gate: last-run marker vs the interval."""
|
|
if interval_hours is None:
|
|
interval_hours = check_interval_hours(config_get)
|
|
if interval_hours <= 0:
|
|
return False
|
|
now = time.time() if now is None else now
|
|
marker = _marker_path()
|
|
try:
|
|
last = marker.stat().st_mtime
|
|
except OSError:
|
|
return True
|
|
return (now - last) >= interval_hours * 3600
|
|
|
|
|
|
#: Minimal singleflight for one home: two gateway ticks overlapping in
|
|
#: this process (boot pass + periodic tick) must not double-fetch or
|
|
#: double-write receipts. A plain non-blocking lock — not a manager.
|
|
_tick_lock = threading.Lock()
|
|
|
|
|
|
def run_scheduled_check(
|
|
*,
|
|
run_checks_fn: Callable[..., list],
|
|
plugins_dir: Path,
|
|
apply_updates_fn: Optional[Callable[[str], None]] = None,
|
|
log=None,
|
|
config_get: Callable = None,
|
|
now: Optional[float] = None,
|
|
) -> Optional[list]:
|
|
"""One cadence tick: gate → check → receipt → (opt-in) apply.
|
|
|
|
Returns the check results, or None when not due / disabled / another
|
|
tick is already in flight. NEVER raises — a cadence failure is
|
|
logged, never fatal.
|
|
"""
|
|
import logging
|
|
|
|
if log is None:
|
|
log = logging.getLogger(__name__)
|
|
if not check_due(now=now, config_get=config_get):
|
|
return None
|
|
if not _tick_lock.acquire(blocking=False):
|
|
log.debug("plugin update check already in flight — skipping tick")
|
|
return None
|
|
try:
|
|
return _run_check_locked(
|
|
run_checks_fn=run_checks_fn,
|
|
plugins_dir=plugins_dir,
|
|
apply_updates_fn=apply_updates_fn,
|
|
log=log,
|
|
config_get=config_get,
|
|
)
|
|
finally:
|
|
_tick_lock.release()
|
|
|
|
|
|
def _run_check_locked(
|
|
*,
|
|
run_checks_fn: Callable[..., list],
|
|
plugins_dir: Path,
|
|
apply_updates_fn: Optional[Callable[[str], None]],
|
|
log,
|
|
config_get: Callable = None,
|
|
) -> list:
|
|
check_ok = True
|
|
warning = ""
|
|
try:
|
|
results = run_checks_fn(plugins_dir)
|
|
except Exception:
|
|
warning = "plugin update check failed"
|
|
log.warning(warning, exc_info=True)
|
|
results = []
|
|
check_ok = False
|
|
|
|
try:
|
|
from pm import receipt
|
|
token = receipt.begin("plugin-check")
|
|
receipt.record_plugin_checks(results)
|
|
if warning:
|
|
receipt.record_warning(warning)
|
|
updates = [r for r in results if getattr(r, "update_available", None) is True]
|
|
receipt.finalize("failed" if not check_ok else "updates-available" if updates else "ok",
|
|
exit_code=0 if check_ok else 1, token=token)
|
|
except Exception:
|
|
log.debug("plugin-check receipt write failed", exc_info=True)
|
|
|
|
# ONE actionable line (a log, not a system-prompt mutation — cache safe)
|
|
updates = [r for r in results if getattr(r, "update_available", None) is True]
|
|
needs_fixing = [r for r in results if getattr(r, "needs_fixing", None)]
|
|
if updates:
|
|
names = ", ".join(r.name for r in updates)
|
|
log.info(
|
|
"plugin updates available: %s — run `hermes plugins check-updates` "
|
|
"and `hermes plugins update <name>`",
|
|
names,
|
|
)
|
|
if needs_fixing:
|
|
names = ", ".join(r.name for r in needs_fixing)
|
|
log.warning(
|
|
"plugin update_url mismatches need attention: %s — run "
|
|
"`hermes plugins trust-update-url <name>` after review",
|
|
names,
|
|
)
|
|
|
|
# Opt-in unattended apply: git rows ONLY, scan-gated by the update
|
|
# path itself. Pinned/manual/drift/pip are never auto-applied
|
|
# (their update_available is False/None or their class excludes it).
|
|
if apply_updates_fn and updates and auto_apply_enabled(config_get):
|
|
for r in updates:
|
|
if getattr(r, "klass", "") == "git":
|
|
try:
|
|
apply_updates_fn(r.name)
|
|
except (Exception, SystemExit):
|
|
log.warning("auto-apply of %s failed", r.name, exc_info=True)
|
|
|
|
# stamp the marker AFTER a completed run (even a failed one — a
|
|
# failing check retrying every tick would hammer the network)
|
|
try:
|
|
marker = _markers_dir() / "last-run"
|
|
marker.write_text(str(int(time.time())), encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the production caller: real seams for the gateway boot/housekeeping tick
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def maybe_run_gateway_check(
|
|
*,
|
|
run_checks_fn: Optional[Callable[..., list]] = None,
|
|
apply_updates_fn: Optional[Callable[[str], None]] = None,
|
|
plugins_dir: Optional[Path] = None,
|
|
log=None,
|
|
now: Optional[float] = None,
|
|
) -> Optional[list]:
|
|
"""The tick every gateway boot / housekeeping pass calls.
|
|
|
|
Fills in the REAL seams ``run_scheduled_check`` leaves injectable:
|
|
the same read-only ``plugins_updates.run_checks`` the manual
|
|
``hermes plugins check-updates`` uses (urllib feed fetch, git
|
|
ls-remote, PyPI latest), the real plugins dir, and the manual
|
|
``hermes plugins update <name>`` flow as the opt-in apply path —
|
|
auto-apply rides the identical security/consent/scan pipeline.
|
|
|
|
Returns the check results, or None when not due / disabled. Due-gated
|
|
by ``plugins.auto_update_check_hours`` (0 disables); a network error
|
|
costs one warning and a stamped marker — never an apply.
|
|
"""
|
|
if plugins_dir is None:
|
|
from hermes_cli import plugins_cmd
|
|
|
|
plugins_dir = plugins_cmd._plugins_dir()
|
|
if run_checks_fn is None:
|
|
# ONE shared network-default implementation — plugins_updates
|
|
# owns default_fetch / default_ls_remote / _default_pypi_latest;
|
|
# the manual `hermes plugins check-updates` passes the same
|
|
# defaults. No boilerplate re-derivation here.
|
|
from hermes_cli.plugins_updates import run_checks
|
|
|
|
run_checks_fn = run_checks
|
|
if apply_updates_fn is None:
|
|
from hermes_cli import plugins_cmd
|
|
|
|
from functools import partial
|
|
apply_updates_fn = partial(plugins_cmd.cmd_update, interactive=False)
|
|
return run_scheduled_check(
|
|
run_checks_fn=run_checks_fn,
|
|
plugins_dir=plugins_dir,
|
|
apply_updates_fn=apply_updates_fn,
|
|
log=log,
|
|
now=now,
|
|
)
|