Activation reaches plugin discovery before the application dependencies exist. Give PM its own locked Python project and runtime so it can install or repair the application without importing that dependency tree. Keep PM outside the application workspace. A shared uv workspace resolves the application graph and cannot provide this isolation. Route mutations through an isolated worker and preserve transaction callbacks, cancellation, custom package registrations, and correlated receipts. Use the same runtime builder for source installs and packaged payloads. Keep offline wheelhouse support in that builder. Nix builds the independent PM lock as a separate derivation. Refuse lazy-disabled bootstrap before installing tools or dependencies. Move first-party YAML readers and writers to ruamel. Keep the application lock's transitive PyYAML requirements for third-party packages. Verification: - Focused canonical Python suite: 177 passed, 1 host-gated skip. - Electron backend probes: 12 passed. Electron typecheck passed. - Both uv locks, scoped lint, Bash syntax, and whitespace checks passed. - Cold activation, corrupt-app repair, offline staging, and relocation ran. - Built and exercised the Nix PM runtime and standalone YAML merge script. Six broader caller test files retain the same 24 failing test IDs as an archive of HEAD. The existing real-home guard blocks those tests before they can exercise the affected paths. No full-suite pass is claimed. Native Windows signing and full Bionic package execution remain unverified.
94 lines
3.7 KiB
Python
94 lines
3.7 KiB
Python
"""Timezone-aware clock for Hermes.
|
|
|
|
``now()`` returns a tz-aware datetime in the user's configured IANA timezone. Resolution order:
|
|
``HERMES_TIMEZONE`` env var, then ``timezone`` in ``~/.hermes/config.yaml``, else server-local
|
|
time. Invalid timezone values log a warning and fall back — never crash.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
from datetime import datetime
|
|
from typing import Dict, Optional, Tuple
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from hermes_constants import get_config_path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cache keyed by timezone *source* identity. This process can multiplex profiles by switching
|
|
# HERMES_HOME, so one unkeyed global would leak the first profile's timezone into later
|
|
# profile-scoped work (e.g. the desktop multiplex cron ticker persisting another profile's
|
|
# ``next_run_at``). Entries are published atomically under ``_cache_lock`` as one
|
|
# ``identity -> (name, ZoneInfo | None)`` value, so racing resolvers can never publish a mixed
|
|
# identity/value pair. Call reset_cache() after in-place config changes.
|
|
_cache_lock = threading.Lock()
|
|
_tz_cache: Dict[Tuple[str, str], Tuple[str, Optional[ZoneInfo]]] = {}
|
|
|
|
|
|
def _timezone_cache_identity() -> Tuple[str, str]:
|
|
tz_env = os.getenv("HERMES_TIMEZONE", "").strip()
|
|
return ("environment", tz_env) if tz_env else ("config", str(get_config_path()))
|
|
|
|
|
|
def _resolve_timezone_name() -> str:
|
|
"""Read the configured IANA timezone string (or ``""``). Does file I/O — callers cache."""
|
|
tz_env = os.getenv("HERMES_TIMEZONE", "").strip()
|
|
if tz_env:
|
|
return tz_env
|
|
try:
|
|
# Prefer the shared cached raw-config reader (mtime-keyed + libyaml): a direct safe_load of
|
|
# a large config.yaml costs ~100 ms and this ran inside the FIRST system prompt build.
|
|
try:
|
|
from hermes_cli.config import read_raw_config
|
|
cfg = read_raw_config() or {}
|
|
except Exception:
|
|
import hermes_yaml as yaml
|
|
config_path = get_config_path()
|
|
cfg = (yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}) if config_path.exists() else {}
|
|
if cfg:
|
|
# Managed scope: an administrator can pin ``timezone`` too (fail-open overlay).
|
|
try:
|
|
from hermes_cli import managed_scope
|
|
cfg = managed_scope.apply_managed_overlay(cfg)
|
|
except Exception:
|
|
pass
|
|
tz_cfg = cfg.get("timezone", "")
|
|
if isinstance(tz_cfg, str) and tz_cfg.strip():
|
|
return tz_cfg.strip()
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def get_timezone() -> Optional[ZoneInfo]:
|
|
"""Return the active profile's configured ZoneInfo, or None (server-local)."""
|
|
cache_identity = _timezone_cache_identity()
|
|
with _cache_lock:
|
|
entry = _tz_cache.get(cache_identity)
|
|
if entry is not None:
|
|
return entry[1]
|
|
# Resolve outside the lock (config file I/O); first writer wins so concurrent resolvers of the
|
|
# same identity converge on one ZoneInfo object.
|
|
name = _resolve_timezone_name()
|
|
tz = None
|
|
if name:
|
|
try:
|
|
tz = ZoneInfo(name)
|
|
except Exception as exc:
|
|
logger.warning("Invalid timezone '%s': %s. Falling back to server local time.", name, exc)
|
|
with _cache_lock:
|
|
return _tz_cache.setdefault(cache_identity, (name, tz))[1]
|
|
|
|
|
|
def reset_cache() -> None:
|
|
"""Clear the cached timezone so the next call re-resolves it (after config/env changes)."""
|
|
with _cache_lock:
|
|
_tz_cache.clear()
|
|
|
|
|
|
def now() -> datetime:
|
|
"""Current time as a tz-aware datetime: configured zone, else server-local."""
|
|
tz = get_timezone()
|
|
return datetime.now(tz) if tz is not None else datetime.now().astimezone()
|