refactor(plugins/hermes-achievements,dashboard_auth): unify snapshot/scan_meta/result builders, drop dead evaluate_boolean + import shims, compact module docstrings, AST-neutral layout
This commit is contained in:
@@ -109,12 +109,10 @@ def pkce_login_start(authorize_url: str, *, client_id: str, scope: str, redirect
|
||||
params = {
|
||||
"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "scope": scope, "state": state,
|
||||
"code_challenge": b64url_no_pad(hashlib.sha256(code_verifier.encode("ascii")).digest()),
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
"code_challenge_method": "S256"}
|
||||
return LoginStart(
|
||||
redirect_url=f"{authorize_url}?{urllib.parse.urlencode(params)}",
|
||||
cookie_payload={"hermes_session_pkce": f"state={state};verifier={code_verifier}"},
|
||||
)
|
||||
cookie_payload={"hermes_session_pkce": f"state={state};verifier={code_verifier}"})
|
||||
|
||||
|
||||
def parse_json_body(response: httpx.Response) -> Dict[str, Any]:
|
||||
@@ -130,8 +128,7 @@ def parse_json_body(response: httpx.Response) -> Dict[str, Any]:
|
||||
|
||||
def exchange_token(
|
||||
url: str, data: Dict[str, str], *, headers: Optional[Dict[str, str]] = None, bad_request_exc: type[Exception],
|
||||
idp: str, endpoint: str, token_key: str, missing_msg: str,
|
||||
) -> tuple[str, Dict[str, Any]]:
|
||||
idp: str, endpoint: str, token_key: str, missing_msg: str) -> tuple[str, Dict[str, Any]]:
|
||||
"""POST a token grant and return ``(token, payload)``.
|
||||
|
||||
A 400 (OAuth-shaped error envelope) raises ``bad_request_exc`` — ``InvalidCodeError``
|
||||
@@ -168,16 +165,14 @@ def refresh_token_from(payload: Dict[str, Any], fallback: str = "") -> str:
|
||||
|
||||
def session_from_claims(
|
||||
provider: str, claims: Dict[str, Any], *, access_token: str, refresh_token: str,
|
||||
label: str = "token", email: str = "", display_name: str = "", org_id: str = "",
|
||||
) -> Session:
|
||||
label: str = "token", email: str = "", display_name: str = "", org_id: str = "") -> Session:
|
||||
"""Map verified JWT claims onto a Session; ``sub`` is mandatory."""
|
||||
user_id = str(claims.get("sub", ""))
|
||||
if not user_id:
|
||||
raise ProviderError(f"{label} missing 'sub' (user_id) claim")
|
||||
return Session(
|
||||
user_id=user_id, email=email, display_name=display_name, org_id=org_id, provider=provider,
|
||||
expires_at=int(claims["exp"]), access_token=access_token, refresh_token=refresh_token,
|
||||
)
|
||||
expires_at=int(claims["exp"]), access_token=access_token, refresh_token=refresh_token)
|
||||
|
||||
|
||||
# ---- JWT verification ----
|
||||
@@ -189,13 +184,11 @@ def make_jwks_client(jwks_url: str) -> Any:
|
||||
|
||||
return PyJWKClient(
|
||||
jwks_url, cache_keys=True, lifespan=JWKS_CACHE_SECONDS,
|
||||
headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0"},
|
||||
)
|
||||
headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0"})
|
||||
|
||||
|
||||
def verify_jwt(
|
||||
token: str, jwks_client: Any, *, algorithms: list[str], audience: str, issuer: str, label: str,
|
||||
) -> Dict[str, Any]:
|
||||
token: str, jwks_client: Any, *, algorithms: list[str], audience: str, issuer: str, label: str) -> Dict[str, Any]:
|
||||
"""Verify ``token`` against ``jwks_client`` with pinned ``aud``/``iss``.
|
||||
|
||||
Unreachable JWKS → ``ProviderError`` (503); a bearer that is not one of our JWTs
|
||||
@@ -213,8 +206,7 @@ def verify_jwt(
|
||||
try:
|
||||
return jwt.decode(
|
||||
token, signing_key.key, algorithms=algorithms, audience=audience, issuer=issuer,
|
||||
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
|
||||
)
|
||||
options={"require": ["exp", "iat", "aud", "iss", "sub"]})
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise InvalidCodeError(f"{label} expired: {exc}") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
@@ -225,8 +217,7 @@ def verify_jwt(
|
||||
unverified = jwt.decode(token, options={"verify_signature": False, "verify_exp": False})
|
||||
details = (
|
||||
f" [token iss={unverified.get('iss')!r} aud={unverified.get('aud')!r}; "
|
||||
f"expected iss={issuer!r} aud={audience!r}]"
|
||||
)
|
||||
f"expected iss={issuer!r} aud={audience!r}]")
|
||||
except Exception:
|
||||
pass
|
||||
raise ProviderError(f"{label} verification failed: {exc}{details}") from exc
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
"""BasicAuthProvider — username/password dashboard auth (no OAuth IDP).
|
||||
|
||||
Same ``DashboardAuthProvider`` framework as the OAuth providers, but login is a credential
|
||||
form (``supports_password = True`` + ``complete_password_login``); cookies, verify, refresh,
|
||||
ws-tickets and logout are shared. No IDP and no database: sessions are stateless
|
||||
HMAC-signed tokens this provider mints and verifies. Passwords use stdlib scrypt;
|
||||
``complete_password_login`` always performs a hash even for an unknown username, so the
|
||||
endpoint is not a username-enumeration timing oracle. ``LAST_SKIP_REASON`` is read by
|
||||
the gate's fail-closed branch when the plugin loads but declines to register.
|
||||
|
||||
Configuration (env wins over config.yaml when set non-empty)::
|
||||
|
||||
dashboard:
|
||||
basic_auth:
|
||||
username: admin # required
|
||||
password_hash: "scrypt$..." # preferred — see hash_password()
|
||||
password: "s3cret" # OR plaintext, hashed in-memory at load
|
||||
secret: "<32+ random bytes, base64 or hex>" # optional signing key
|
||||
session_ttl_seconds: 43200 # optional access-token lifetime (12h)
|
||||
|
||||
HERMES_DASHBOARD_BASIC_AUTH_USERNAME / _PASSWORD_HASH (preferred) / _PASSWORD (plaintext)
|
||||
HERMES_DASHBOARD_BASIC_AUTH_SECRET / _TTL_SECONDS
|
||||
|
||||
Without ``secret`` a random per-process key is generated: sessions then don't
|
||||
survive a restart or span multiple worker processes.
|
||||
Login is a credential form (``supports_password`` + ``complete_password_login``); cookies,
|
||||
verify, refresh, ws-tickets and logout are the shared framework. Sessions are stateless
|
||||
HMAC-signed tokens (no IDP, no database); passwords use stdlib scrypt and login always hashes
|
||||
even for an unknown username (no username-enumeration timing oracle). Config: ``dashboard.
|
||||
basic_auth.{username,password_hash|password,secret,session_ttl_seconds}`` or the
|
||||
``HERMES_DASHBOARD_BASIC_AUTH_*`` env vars (env wins when non-empty; see ``_settings``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -37,7 +20,8 @@ import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.dashboard_auth import DashboardAuthProvider, InvalidCredentialsError, LoginStart, RefreshExpiredError, Session
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider, InvalidCredentialsError, LoginStart, RefreshExpiredError, Session)
|
||||
from plugins.dashboard_auth._shared import SkipRegistration, load_config_section, register_provider, resolve_env_or_cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -69,8 +53,10 @@ def hash_password(password: str) -> str:
|
||||
precompute ``password_hash`` for config.yaml (the plaintext then never sits at rest):
|
||||
``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('pw'))"``."""
|
||||
salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
|
||||
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_DKLEN, maxmem=0)
|
||||
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
|
||||
dk = hashlib.scrypt(
|
||||
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_DKLEN, maxmem=0)
|
||||
salt_b64, dk_b64 = base64.b64encode(salt).decode(), base64.b64encode(dk).decode()
|
||||
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt_b64}${dk_b64}"
|
||||
|
||||
|
||||
def _verify_password(password: str, encoded: str) -> bool:
|
||||
@@ -149,8 +135,7 @@ class BasicAuthProvider(DashboardAuthProvider):
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
raise NotImplementedError(
|
||||
"BasicAuthProvider is password-only; there is no OAuth redirect flow. "
|
||||
"The login page POSTs to /auth/password-login instead."
|
||||
)
|
||||
"The login page POSTs to /auth/password-login instead.")
|
||||
|
||||
def complete_login(self, *, code: str, state: str, code_verifier: str, redirect_uri: str) -> Session:
|
||||
raise NotImplementedError("BasicAuthProvider is password-only; use complete_password_login.")
|
||||
@@ -195,14 +180,12 @@ class BasicAuthProvider(DashboardAuthProvider):
|
||||
return self._session(
|
||||
user_id, exp,
|
||||
_sign({"sub": user_id, "kind": "access", "exp": exp}, self._secret),
|
||||
_sign({"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS}, self._secret),
|
||||
)
|
||||
_sign({"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS}, self._secret))
|
||||
|
||||
def _session(self, user_id: str, exp: int, access_token: str, refresh_token: str) -> Session:
|
||||
return Session(
|
||||
user_id=user_id, email="", display_name=user_id, org_id="", provider=self.name,
|
||||
expires_at=exp, access_token=access_token, refresh_token=refresh_token,
|
||||
)
|
||||
expires_at=exp, access_token=access_token, refresh_token=refresh_token)
|
||||
|
||||
|
||||
# ---- Plugin entry point ----
|
||||
@@ -221,8 +204,7 @@ def _resolve_secret(cfg_section: dict) -> bytes:
|
||||
"dashboard-auth-basic: no 'secret' configured; generating a random "
|
||||
"per-process signing key. Sessions will not survive a restart or span "
|
||||
"multiple workers. Set dashboard.basic_auth.secret (or "
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable sessions."
|
||||
)
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable sessions.")
|
||||
return secrets.token_bytes(32)
|
||||
for decoder in (base64.b64decode, bytes.fromhex):
|
||||
try:
|
||||
@@ -250,15 +232,13 @@ def _settings() -> dict:
|
||||
"dashboard.basic_auth.username is not set (and HERMES_DASHBOARD_BASIC_AUTH_USERNAME "
|
||||
"is empty). Set a username and a password (or password_hash) under "
|
||||
"dashboard.basic_auth in config.yaml to enable username/password dashboard "
|
||||
"login, or use the OAuth provider, or pass --insecure to skip the auth gate."
|
||||
)
|
||||
"login, or use the OAuth provider, or pass --insecure to skip the auth gate.")
|
||||
if not password_hash and not plaintext:
|
||||
raise SkipRegistration(
|
||||
"dashboard.basic_auth.username is set but neither password_hash nor password "
|
||||
"is configured. Provide one of them (password_hash is preferred — compute it "
|
||||
"with plugins.dashboard_auth.basic.hash_password).",
|
||||
level="warning",
|
||||
)
|
||||
level="warning")
|
||||
# Precedence: env password (hashed in-memory) overrides any config password_hash so
|
||||
# operators can rotate without editing config; a config password_hash wins over a
|
||||
# config-only plaintext password (preferred at-rest form).
|
||||
@@ -271,8 +251,7 @@ def _settings() -> dict:
|
||||
logger.info(
|
||||
"dashboard-auth-basic: hashed plaintext password in-memory. "
|
||||
"For production, precompute dashboard.basic_auth.password_hash "
|
||||
"and remove the plaintext password from config."
|
||||
)
|
||||
"and remove the plaintext password from config.")
|
||||
try:
|
||||
ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS
|
||||
except ValueError:
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
"""DrainSecretProvider — shared-bearer-secret auth for the drain-control endpoint.
|
||||
|
||||
Uses the non-interactive token capability of the ``DashboardAuthProvider`` ABC
|
||||
(``supports_token`` / ``verify_token`` + the route-agnostic ``token_auth`` middleware
|
||||
seam). ``nous-account-service`` (NAS) provisions a **per-agent unique** shared secret into
|
||||
each agent's environment; this provider verifies an inbound bearer against it with a
|
||||
constant-time compare and vouches for the caller as the ``drain-control`` principal. No
|
||||
login/cookie/session/refresh — the interactive ABC methods raise ``NotImplementedError``.
|
||||
Fail-CLOSED entropy gate at registration: a weak/short/low-entropy secret is never
|
||||
silently accepted (>= 43 url-safe-base64 chars ~= 256 bits, enough distinct characters,
|
||||
Shannon entropy floor). The secret is a CREDENTIAL, so it is env-only
|
||||
(``HERMES_DASHBOARD_DRAIN_SECRET``); knobs ``scope`` / ``min_secret_chars`` live under
|
||||
``dashboard.drain_auth`` in config.yaml. Unset env var → no-op (records a skip reason).
|
||||
Non-interactive token capability of the ``DashboardAuthProvider`` ABC (``verify_token`` +
|
||||
the ``token_auth`` middleware seam): ``nous-account-service`` provisions a per-agent unique
|
||||
secret (``HERMES_DASHBOARD_DRAIN_SECRET``, env-only — it is a credential); an inbound bearer
|
||||
is compared constant-time and vouched for as the ``drain-control`` principal. Fail-CLOSED
|
||||
entropy gate at registration (length, distinct chars, Shannon bits); interactive ABC methods
|
||||
raise. Knobs ``scope`` / ``min_secret_chars`` live under ``dashboard.drain_auth``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -60,8 +55,7 @@ def assess_secret_strength(secret: str, *, min_chars: int = _DEFAULT_MIN_SECRET_
|
||||
return (
|
||||
f"secret too short: {len(secret)} chars (need >= {min_chars}; "
|
||||
"use a >=256-bit value, e.g. `python -c \"import secrets; "
|
||||
"print(secrets.token_urlsafe(32))\"`)"
|
||||
)
|
||||
"print(secrets.token_urlsafe(32))\"`)")
|
||||
distinct = len(set(secret))
|
||||
if distinct < _MIN_DISTINCT_CHARS:
|
||||
return f"secret has only {distinct} distinct characters (need >= {_MIN_DISTINCT_CHARS}); looks structured/low-entropy"
|
||||
@@ -130,8 +124,7 @@ def _settings() -> dict:
|
||||
raise SkipRegistration(
|
||||
"HERMES_DASHBOARD_DRAIN_SECRET is not set. Set a per-agent >=256-bit secret "
|
||||
"(e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`) to enable "
|
||||
"NAS-driven drain coordination; leave it unset to disable the drain endpoint."
|
||||
)
|
||||
"NAS-driven drain coordination; leave it unset to disable the drain endpoint.")
|
||||
section = _load_config_drain_auth_section()
|
||||
scope = str(section.get("scope", "drain") or "drain").strip() or "drain"
|
||||
try:
|
||||
@@ -142,8 +135,7 @@ def _settings() -> dict:
|
||||
if reason is not None:
|
||||
raise SkipRegistration(
|
||||
f"HERMES_DASHBOARD_DRAIN_SECRET rejected — {reason}. The drain endpoint stays disabled (fail-closed).",
|
||||
level="warning",
|
||||
)
|
||||
level="warning")
|
||||
return {"secret": secret, "scope": scope}
|
||||
|
||||
|
||||
@@ -166,5 +158,4 @@ def register(ctx) -> None:
|
||||
logger.warning("dashboard-auth-drain: could not register token route %s: %s", DRAIN_ROUTE_PATH, exc)
|
||||
logger.info(
|
||||
"dashboard-auth-drain: registered drain service-credential provider (scope=%s, route=%s)",
|
||||
kwargs["scope"], DRAIN_ROUTE_PATH,
|
||||
)
|
||||
kwargs["scope"], DRAIN_ROUTE_PATH)
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
"""NousDashboardAuthProvider — Nous Portal OAuth (authorization-code + PKCE).
|
||||
|
||||
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``. Bundled and
|
||||
auto-loaded, but only registers when a client_id is configured (``dashboard.oauth.client_id``
|
||||
or ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``, shape ``agent:{agent_instance_id}``; optional
|
||||
``portal_url`` / ``HERMES_DASHBOARD_PORTAL_URL``). Env wins over config when set non-empty.
|
||||
|
||||
Contract points: the client_id suffix is cross-checked against the token's
|
||||
``agent_instance_id`` claim; scope is ``agent_dashboard:access`` only, audience is the bare
|
||||
client_id; access tokens are RS256 JWTs verified against ``/.well-known/jwks.json`` (cached
|
||||
5 min). Portal issues a 24h *rotating* refresh token with reuse detection: the middleware
|
||||
MUST persist ``Session.refresh_token`` back to the cookie on every refresh, or the next
|
||||
refresh replays a rotated token and (outside a 60s grace) revokes the whole session; a 400
|
||||
on refresh → ``RefreshExpiredError`` → re-login. ``oauth_contract_version``: missing →
|
||||
warn + proceed; ``!= 1`` → refuse. ``LAST_SKIP_REASON`` is read by the gate's fail-closed branch.
|
||||
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``; registers only
|
||||
when a client_id (``dashboard.oauth.client_id`` / ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``, shape
|
||||
``agent:{instance_id}``) is configured. Access tokens are RS256 JWTs verified against the
|
||||
Portal JWKS with ``aud`` = bare client_id. Portal issues a 24h *rotating* refresh token with
|
||||
reuse detection: the middleware MUST persist ``Session.refresh_token`` back to the cookie on
|
||||
every refresh or the next refresh replays a rotated token and revokes the whole session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,7 +13,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_cli.dashboard_auth import DashboardAuthProvider, InvalidCodeError, LoginStart, ProviderError, RefreshExpiredError, Session
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider, InvalidCodeError, LoginStart, ProviderError, RefreshExpiredError, Session)
|
||||
from plugins.dashboard_auth._shared import (
|
||||
SkipRegistration,
|
||||
exchange_token,
|
||||
@@ -32,8 +26,7 @@ from plugins.dashboard_auth._shared import (
|
||||
resolve_env_or_cfg,
|
||||
session_from_claims,
|
||||
validate_redirect_uri,
|
||||
verify_jwt,
|
||||
)
|
||||
verify_jwt)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_TAG = "dashboard-auth-nous"
|
||||
@@ -74,10 +67,8 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
return self._token_grant(
|
||||
{
|
||||
"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
|
||||
"client_id": self._client_id, "code_verifier": code_verifier,
|
||||
},
|
||||
bad_request_exc=InvalidCodeError,
|
||||
)
|
||||
"client_id": self._client_id, "code_verifier": code_verifier},
|
||||
bad_request_exc=InvalidCodeError)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
if not refresh_token:
|
||||
@@ -88,8 +79,7 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
return self._token_grant(
|
||||
{"grant_type": "refresh_token", "client_id": self._client_id, "refresh_token": refresh_token},
|
||||
headers={"x-nous-refresh-token": refresh_token},
|
||||
bad_request_exc=RefreshExpiredError,
|
||||
)
|
||||
bad_request_exc=RefreshExpiredError)
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# None on expiry/invalidity (middleware then tries refresh); a ProviderError
|
||||
@@ -113,8 +103,7 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
access_token, payload = exchange_token(
|
||||
self._token_url, data, headers=headers, bad_request_exc=bad_request_exc,
|
||||
idp="Portal", endpoint="Portal token endpoint", token_key="access_token",
|
||||
missing_msg="Portal token response missing access_token",
|
||||
)
|
||||
missing_msg="Portal token response missing access_token")
|
||||
# Rotating RT the caller MUST persist back to the cookie.
|
||||
return self._session(access_token, refresh_token_from(payload), self._verify_jwt(access_token))
|
||||
|
||||
@@ -127,25 +116,21 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
claims = verify_jwt(
|
||||
access_token, self._get_jwks_client(), algorithms=["RS256"],
|
||||
audience=self._client_id, # contract C2: bare client_id
|
||||
issuer=self._portal_url, label="access token",
|
||||
)
|
||||
issuer=self._portal_url, label="access token")
|
||||
# Contract C9: agent_instance_id is "should" not "must" — tolerated when absent
|
||||
# (the aud check already binds the token to this instance).
|
||||
token_instance_id = claims.get("agent_instance_id")
|
||||
if token_instance_id is not None and token_instance_id != self._agent_instance_id:
|
||||
raise ProviderError(
|
||||
f"agent_instance_id mismatch: token={token_instance_id!r} vs configured={self._agent_instance_id!r}"
|
||||
)
|
||||
f"agent_instance_id mismatch: token={token_instance_id!r} vs configured={self._agent_instance_id!r}")
|
||||
contract_version = claims.get("oauth_contract_version")
|
||||
if contract_version is None:
|
||||
logger.warning(
|
||||
"Nous Portal token missing oauth_contract_version claim (contract says it should be %d); proceeding anyway.",
|
||||
_EXPECTED_CONTRACT_VERSION,
|
||||
)
|
||||
_EXPECTED_CONTRACT_VERSION)
|
||||
elif contract_version != _EXPECTED_CONTRACT_VERSION:
|
||||
raise ProviderError(
|
||||
f"unsupported oauth_contract_version={contract_version!r}, expected {_EXPECTED_CONTRACT_VERSION}"
|
||||
)
|
||||
f"unsupported oauth_contract_version={contract_version!r}, expected {_EXPECTED_CONTRACT_VERSION}")
|
||||
return claims
|
||||
|
||||
def _session(self, access_token: str, refresh_token: str, claims: Dict[str, Any]) -> Session:
|
||||
@@ -173,15 +158,13 @@ def _settings() -> dict:
|
||||
"'agent:{instance_id}') when it deploys a Hermes Agent instance — set it to "
|
||||
"your provisioned client id (either as an env var or under "
|
||||
"dashboard.oauth.client_id in config.yaml), or pass --insecure to skip the "
|
||||
"OAuth gate entirely."
|
||||
)
|
||||
"OAuth gate entirely.")
|
||||
if not client_id.startswith("agent:"):
|
||||
raise SkipRegistration(
|
||||
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id!r} doesn't match the contract "
|
||||
f"shape 'agent:{{instance_id}}'. The Nous Portal provisions this value at deploy "
|
||||
f"time; check your Fly app's secrets or override with the value from the Portal admin UI.",
|
||||
level="warning",
|
||||
)
|
||||
level="warning")
|
||||
return {"client_id": client_id, "portal_url": portal_url}
|
||||
|
||||
|
||||
@@ -191,4 +174,5 @@ def register(ctx) -> None:
|
||||
LAST_SKIP_REASON = ""
|
||||
kwargs, LAST_SKIP_REASON = register_provider(ctx, logger, _TAG, NousDashboardAuthProvider, _settings)
|
||||
if kwargs is not None:
|
||||
logger.info("dashboard-auth-nous: registered provider (client_id=%s, portal=%s)", kwargs["client_id"], kwargs["portal_url"])
|
||||
logger.info(
|
||||
"dashboard-auth-nous: registered provider (client_id=%s, portal=%s)", kwargs["client_id"], kwargs["portal_url"])
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
"""SelfHostedOIDCProvider — generic self-hosted OpenID Connect dashboard auth.
|
||||
|
||||
A standards-compliant OIDC Relying Party for the ``hermes dashboard`` gate (Authentik,
|
||||
Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). The HTTP round trip, cookies, CSRF
|
||||
``state`` check and ``redirect_uri`` reconstruction are owned by
|
||||
``hermes_cli/dashboard_auth/routes.py``; this provider discovers endpoints from
|
||||
``{issuer}/.well-known/openid-configuration``, builds the PKCE (S256) ``/authorize`` URL,
|
||||
exchanges the code at the discovered ``token_endpoint``, and verifies the **ID token**
|
||||
against the discovered ``jwks_uri`` with ``iss``/``aud`` pinned (the access token's format
|
||||
is opaque per spec; the ``nous`` provider verifies its access token only because Portal
|
||||
mints a custom JWT). Public (PKCE-only) and confidential (PKCE + ``client_secret`` via
|
||||
``client_secret_basic`` / ``client_secret_post``) clients are both supported — the secret is
|
||||
layered on top of PKCE, never a replacement (OAuth 2.1 / RFC 9700).
|
||||
|
||||
Configuration (env wins over config.yaml when set non-empty)::
|
||||
|
||||
dashboard:
|
||||
oauth:
|
||||
provider: self-hosted
|
||||
self_hosted:
|
||||
issuer: https://auth.example.com/application/o/hermes/ # required
|
||||
client_id: hermes-dashboard # required
|
||||
scopes: "openid profile email" # optional
|
||||
# client_secret: confidential clients only — prefer the env var.
|
||||
|
||||
HERMES_DASHBOARD_OIDC_ISSUER / _CLIENT_ID / _SCOPES (optional)
|
||||
HERMES_DASHBOARD_OIDC_CLIENT_SECRET # optional; .env is its canonical home
|
||||
|
||||
On skip (missing issuer / client_id) ``LAST_SKIP_REASON`` carries the reason for the gate.
|
||||
A plain OIDC Relying Party (Authentik, Keycloak, Zitadel, Authelia, Auth0, Okta, …): discovers
|
||||
endpoints from ``{issuer}/.well-known/openid-configuration``, builds the PKCE (S256) authorize
|
||||
URL, exchanges the code, and verifies the **ID token** (the access token is opaque per spec)
|
||||
against the discovered ``jwks_uri`` with ``iss``/``aud`` pinned. Public and confidential
|
||||
(``client_secret`` layered on top of PKCE, never replacing it) clients both work. Config:
|
||||
``dashboard.oauth.self_hosted.{issuer,client_id,scopes,client_secret}`` or ``HERMES_DASHBOARD_OIDC_*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,7 +19,8 @@ from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from hermes_cli.dashboard_auth import DashboardAuthProvider, InvalidCodeError, LoginStart, ProviderError, RefreshExpiredError, Session
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider, InvalidCodeError, LoginStart, ProviderError, RefreshExpiredError, Session)
|
||||
from plugins.dashboard_auth._shared import (
|
||||
JSON_HEADERS,
|
||||
TOKEN_ENDPOINT_TIMEOUT_SEC as _TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
@@ -55,8 +35,7 @@ from plugins.dashboard_auth._shared import (
|
||||
resolve_env_or_cfg,
|
||||
session_from_claims,
|
||||
validate_redirect_uri,
|
||||
verify_jwt,
|
||||
)
|
||||
verify_jwt)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_TAG = "dashboard-auth-self-hosted"
|
||||
@@ -120,18 +99,15 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
validate_redirect_uri(redirect_uri)
|
||||
disco = self._get_discovery()
|
||||
return pkce_login_start(
|
||||
disco["authorization_endpoint"], client_id=self._client_id, scope=self._scopes, redirect_uri=redirect_uri,
|
||||
)
|
||||
disco["authorization_endpoint"], client_id=self._client_id, scope=self._scopes, redirect_uri=redirect_uri)
|
||||
|
||||
def complete_login(self, *, code: str, state: str, code_verifier: str, redirect_uri: str) -> Session:
|
||||
# ``state`` is verified by the auth-route layer before this call.
|
||||
return self._exchange(
|
||||
{
|
||||
"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
|
||||
"client_id": self._client_id, "code_verifier": code_verifier,
|
||||
},
|
||||
bad_request_exc=InvalidCodeError,
|
||||
)
|
||||
"client_id": self._client_id, "code_verifier": code_verifier},
|
||||
bad_request_exc=InvalidCodeError)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
if not refresh_token:
|
||||
@@ -141,11 +117,9 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
"grant_type": "refresh_token", "client_id": self._client_id, "refresh_token": refresh_token,
|
||||
# Re-request the same scopes so the rotated ID token keeps its identity
|
||||
# claims (some IDPs narrow scope on refresh otherwise).
|
||||
"scope": self._scopes,
|
||||
},
|
||||
"scope": self._scopes},
|
||||
bad_request_exc=RefreshExpiredError,
|
||||
previous_refresh_token=refresh_token,
|
||||
)
|
||||
previous_refresh_token=refresh_token)
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# The session cookie carries the ID token in the access-token slot (see _session)
|
||||
@@ -207,9 +181,7 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
missing_msg=(
|
||||
"OIDC token response missing id_token — ensure the 'openid' "
|
||||
"scope is configured and the client is allowed to receive an "
|
||||
"ID token."
|
||||
),
|
||||
)
|
||||
"ID token."))
|
||||
claims = self._verify_id_token(id_token)
|
||||
# Prefer a freshly-issued RT, else keep the previous (some IDPs don't rotate).
|
||||
return self._session(id_token, refresh_token_from(payload, previous_refresh_token), claims)
|
||||
@@ -261,7 +233,8 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
advertised_issuer = field("issuer")
|
||||
if advertised_issuer and advertised_issuer.rstrip("/") != self._issuer:
|
||||
raise ProviderError(
|
||||
f"OIDC discovery issuer mismatch: document advertises {advertised_issuer!r} but configured issuer is {self._issuer!r}"
|
||||
f"OIDC discovery issuer mismatch: document advertises {advertised_issuer!r} "
|
||||
f"but configured issuer is {self._issuer!r}"
|
||||
)
|
||||
for key, url in endpoints.items():
|
||||
_require_https_or_loopback(url, field=key)
|
||||
@@ -272,9 +245,7 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
**endpoints,
|
||||
"revocation_endpoint": field("revocation_endpoint"),
|
||||
"token_endpoint_auth_methods_supported": (
|
||||
[str(m) for m in auth_methods_raw] if isinstance(auth_methods_raw, list) else []
|
||||
),
|
||||
}
|
||||
[str(m) for m in auth_methods_raw] if isinstance(auth_methods_raw, list) else [])}
|
||||
|
||||
# ---- internals: JWT verification + mapping ----------------------------
|
||||
|
||||
@@ -287,8 +258,7 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
issuer = self._get_discovery()["issuer"]
|
||||
return verify_jwt(
|
||||
id_token, self._get_jwks_client(), algorithms=list(_ALLOWED_ID_TOKEN_ALGS),
|
||||
audience=self._client_id, issuer=issuer, label="ID token",
|
||||
)
|
||||
audience=self._client_id, issuer=issuer, label="ID token")
|
||||
|
||||
def _session(self, id_token: str, refresh_token: str, claims: Dict[str, Any]) -> Session:
|
||||
"""Map verified OIDC claims onto a Session. The verified ID token is stored in
|
||||
@@ -304,8 +274,7 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
return session_from_claims(
|
||||
self.name, claims, access_token=id_token, refresh_token=refresh_token, label="ID token", email=email,
|
||||
display_name=str(claims.get("name") or claims.get("preferred_username") or claims.get("nickname") or email or ""),
|
||||
org_id=str(org_id or ""),
|
||||
)
|
||||
org_id=str(org_id or ""))
|
||||
|
||||
|
||||
# ---- Plugin entry point ----
|
||||
@@ -330,14 +299,12 @@ def _settings() -> dict:
|
||||
"HERMES_DASHBOARD_OIDC_CLIENT_ID) or under "
|
||||
"dashboard.oauth.self_hosted.{issuer,client_id} in config.yaml — or pass "
|
||||
"--insecure to skip the OAuth gate entirely. (issuer set: %s; client_id set: %s)"
|
||||
% (bool(issuer), bool(client_id))
|
||||
)
|
||||
% (bool(issuer), bool(client_id)))
|
||||
return {
|
||||
"issuer": issuer, "client_id": client_id,
|
||||
"scopes": setting("HERMES_DASHBOARD_OIDC_SCOPES", "scopes") or _DEFAULT_SCOPES,
|
||||
# Credential: canonical home is the env var / ~/.hermes/.env. Empty ⇒ public client.
|
||||
"client_secret": setting("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "client_secret"),
|
||||
}
|
||||
"client_secret": setting("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "client_secret")}
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Hermes Achievements dashboard plugin backend.
|
||||
"""Hermes Achievements dashboard plugin backend, mounted at /api/plugins/hermes-achievements/.
|
||||
|
||||
Mounted at /api/plugins/hermes-achievements/ by Hermes dashboard.
|
||||
Scans the session history into per-session stats (checkpointed by fingerprint so warm
|
||||
scans are cheap), aggregates them, and evaluates the tiered / multi-condition catalog.
|
||||
Cold scans run on a background thread; ``/achievements`` serves the last snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,22 +14,9 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
except ImportError:
|
||||
import os as _os
|
||||
def get_hermes_home() -> Path: # type: ignore[misc]
|
||||
val = (_os.environ.get("HERMES_HOME") or "").strip()
|
||||
return Path(val) if val else Path.home() / ".hermes"
|
||||
from fastapi import APIRouter
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception: # Allows local unit tests without dashboard dependencies.
|
||||
class APIRouter: # type: ignore
|
||||
def get(self, *_args, **_kwargs):
|
||||
return lambda fn: fn
|
||||
def post(self, *_args, **_kwargs):
|
||||
return lambda fn: fn
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -46,18 +35,13 @@ FILE_RE = re.compile(r"(?:/home/|~/?|\./|/mnt/)[\w./-]+\.(?:py|js|ts|tsx|jsx|css
|
||||
|
||||
TIER_NAMES = ["Copper", "Silver", "Gold", "Diamond", "Olympian"]
|
||||
|
||||
|
||||
def _ach(
|
||||
id: str, name: str, description: str, category: str, icon: str, *,
|
||||
metric: Optional[str] = None, tiers: Optional[List[int]] = None,
|
||||
requires: Optional[List[tuple]] = None, secret: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build one catalog entry.
|
||||
|
||||
``kind`` is derived: ``requires`` -> multi_condition; a ``max_*`` metric is a
|
||||
per-session best (best_session); any other metric accumulates over the whole
|
||||
history (lifetime).
|
||||
"""
|
||||
requires: Optional[List[tuple]] = None, secret: bool = False) -> Dict[str, Any]:
|
||||
"""Build one catalog entry. ``kind`` is derived: ``requires`` -> multi_condition; a
|
||||
``max_*`` metric is a per-session best (best_session); anything else accumulates over
|
||||
the whole history (lifetime)."""
|
||||
kind = "multi_condition" if requires is not None else ("best_session" if metric.startswith("max_") else "lifetime")
|
||||
item: Dict[str, Any] = {"id": id, "name": name, "description": description, "category": category, "kind": kind, "icon": icon}
|
||||
if secret:
|
||||
@@ -151,14 +135,16 @@ ACHIEVEMENTS: List[Dict[str, Any]] = [
|
||||
|
||||
]
|
||||
|
||||
# ---- Durable state files ----
|
||||
|
||||
SNAPSHOT_FILE = "scan_snapshot.json"
|
||||
CHECKPOINT_FILE = "scan_checkpoint.json"
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
"""Durable data root (``<hermes home>/plugin-data/hermes-achievements/``).
|
||||
|
||||
Was the install tree (``plugins/hermes-achievements/``) before the plugin-data
|
||||
convention existed — state parked there died on ``hermes plugins remove``/
|
||||
``update``. Legacy files migrate on first read (see ``_data_file``).
|
||||
"""
|
||||
"""Durable data root (``<hermes home>/plugin-data/hermes-achievements/``). State used to
|
||||
live in the install tree and died on ``hermes plugins remove``/``update``; legacy files
|
||||
migrate on first read (see ``_data_file``)."""
|
||||
try:
|
||||
from plugins.plugin_storage import plugin_data_dir
|
||||
return plugin_data_dir("hermes-achievements")
|
||||
@@ -181,10 +167,6 @@ def _data_file(name: str) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
SNAPSHOT_FILE = "scan_snapshot.json"
|
||||
CHECKPOINT_FILE = "scan_checkpoint.json"
|
||||
|
||||
|
||||
def _read_json(name: str) -> Any:
|
||||
"""Parsed data file, or ``None`` when missing/unreadable."""
|
||||
path = _data_file(name)
|
||||
@@ -254,15 +236,15 @@ def _scan_status_payload(now: Optional[int] = None) -> Dict[str, Any]:
|
||||
"ttl_seconds": SNAPSHOT_TTL_SECONDS,
|
||||
"snapshot_generated_at": generated_at or None,
|
||||
"snapshot_age_seconds": (current - generated_at) if generated_at else None,
|
||||
"snapshot_stale": _is_snapshot_stale(snap, current),
|
||||
}
|
||||
"snapshot_stale": _is_snapshot_stale(snap, current)}
|
||||
|
||||
|
||||
# ---- Per-session analysis ----
|
||||
|
||||
def _tool_name_from_call(call: Any) -> Optional[str]:
|
||||
if not isinstance(call, dict):
|
||||
return None
|
||||
fn = call.get("function") or {}
|
||||
return call.get("name") or fn.get("name")
|
||||
return call.get("name") or (call.get("function") or {}).get("name")
|
||||
|
||||
|
||||
def _content(msg: Dict[str, Any]) -> str:
|
||||
@@ -282,13 +264,17 @@ def _count_tool(tool_names: List[str], *needles: str) -> int:
|
||||
return sum(1 for name in lowered if any(needle in name for needle in needles))
|
||||
|
||||
|
||||
_PROVIDER_MARKERS = ["openai", "anthropic", "google", "gemini", "mistral", "meta", "qwen", "deepseek", "xai", "nous", "ollama", "groq", "openrouter", "codex"]
|
||||
_LOCAL_MARKERS = ["ollama", "llama.cpp", "localhost", "127.0.0.1", "local/", "local:", "gguf", "vllm-local"]
|
||||
|
||||
|
||||
def model_provider(model_name: str) -> Optional[str]:
|
||||
name = (model_name or "").strip().lower()
|
||||
if not name or name == "none":
|
||||
return None
|
||||
if "/" in name:
|
||||
return name.split("/", 1)[0]
|
||||
for provider in ["openai", "anthropic", "google", "gemini", "mistral", "meta", "qwen", "deepseek", "xai", "nous", "ollama", "groq", "openrouter", "codex"]:
|
||||
for provider in _PROVIDER_MARKERS:
|
||||
if provider in name:
|
||||
return "google" if provider == "gemini" else provider
|
||||
return name.split(":", 1)[0].split("-", 1)[0]
|
||||
@@ -296,10 +282,7 @@ def model_provider(model_name: str) -> Optional[str]:
|
||||
|
||||
def is_local_model_name(model_name: str) -> bool:
|
||||
name = (model_name or "").strip().lower()
|
||||
if not name or name == "none":
|
||||
return False
|
||||
local_markers = ["ollama", "llama.cpp", "localhost", "127.0.0.1", "local/", "local:", "gguf", "vllm-local"]
|
||||
return any(marker in name for marker in local_markers)
|
||||
return bool(name) and name != "none" and any(marker in name for marker in _LOCAL_MARKERS)
|
||||
|
||||
|
||||
def analyze_messages(session_id: str, title: str, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
@@ -308,15 +291,14 @@ def analyze_messages(session_id: str, title: str, messages: List[Dict[str, Any]]
|
||||
files_touched: Set[str] = set()
|
||||
full_text_parts: List[str] = []
|
||||
error_count = 0
|
||||
|
||||
for msg in messages:
|
||||
text = _content(msg)
|
||||
full_text_parts.append(text)
|
||||
if msg.get("tool_name"):
|
||||
name = str(msg["tool_name"])
|
||||
tool_names.add(name)
|
||||
# Tool result rows name the tool that already appeared in the assistant tool_calls.
|
||||
# Keep it for distinct-tool detection, but do not double-count it as a new call.
|
||||
# Tool result rows name the tool that already appeared in the assistant tool_calls:
|
||||
# keep it for distinct-tool detection but don't double-count it as a new call.
|
||||
if msg.get("role") != "tool":
|
||||
tool_sequence.append(name)
|
||||
for call in msg.get("tool_calls") or []:
|
||||
@@ -398,32 +380,43 @@ def analyze_messages(session_id: str, title: str, messages: List[Dict[str, Any]]
|
||||
"screenshot_events": hits(r"screenshot|playwright|vision_analyze|browser_vision|\.png|image data"),
|
||||
"release_events": hits(r"\bgit\s+tag|release|version bump|changelog|publish|pushed? tag"),
|
||||
"cache_events": hits(r"cache hit|prompt caching|cache_read"),
|
||||
"model_names": set(),
|
||||
}
|
||||
"model_names": set()}
|
||||
|
||||
|
||||
# ---- Evaluation ----
|
||||
|
||||
def _result(*, unlocked: bool, discovered: bool, state: str, tier, progress: int, next_tier, next_threshold: int, progress_pct: int) -> Dict[str, Any]:
|
||||
"""Uniform evaluation result (key order is part of the wire shape)."""
|
||||
return {"unlocked": unlocked, "discovered": discovered, "state": state, "tier": tier, "progress": progress, "next_tier": next_tier, "next_threshold": next_threshold, "progress_pct": progress_pct}
|
||||
|
||||
|
||||
def _state(definition: Dict[str, Any], unlocked: bool, any_progress: bool) -> tuple[str, bool]:
|
||||
"""``(state, discovered)``: secret badges stay hidden until the first matching signal."""
|
||||
secret = bool(definition.get("secret"))
|
||||
state = "unlocked" if unlocked else ("secret" if secret and not any_progress else "discovered")
|
||||
return state, any_progress or not secret
|
||||
|
||||
|
||||
def evaluate_tiered(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> Dict[str, Any]:
|
||||
metric = definition["threshold_metric"]
|
||||
progress = int(aggregate.get(metric, 0) or 0)
|
||||
progress = int(aggregate.get(definition["threshold_metric"], 0) or 0)
|
||||
tiers_list = sorted(definition.get("tiers", []), key=lambda t: t["threshold"])
|
||||
achieved = [t for t in tiers_list if progress >= t["threshold"]]
|
||||
next_tiers = [t for t in tiers_list if progress < t["threshold"]]
|
||||
tier = achieved[-1]["name"] if achieved else None
|
||||
next_tier = next_tiers[0]["name"] if next_tiers else None
|
||||
next_threshold = next_tiers[0]["threshold"] if next_tiers else (tiers_list[-1]["threshold"] if tiers_list else 1)
|
||||
current_threshold = achieved[-1]["threshold"] if achieved else 0
|
||||
denom = max(1, next_threshold - current_threshold)
|
||||
pct = 100 if not next_tiers and achieved else max(0, min(99, math.floor(((progress - current_threshold) / denom) * 100)))
|
||||
unlocked = bool(achieved)
|
||||
discovered = bool(progress > 0)
|
||||
state = "unlocked" if unlocked else ("secret" if definition.get("secret") and not discovered else "discovered")
|
||||
return {"unlocked": unlocked, "discovered": discovered or not definition.get("secret"), "state": state, "tier": tier, "progress": progress, "next_tier": next_tier, "next_threshold": next_threshold, "progress_pct": pct}
|
||||
state, discovered = _state(definition, bool(achieved), progress > 0)
|
||||
return _result(
|
||||
unlocked=bool(achieved), discovered=discovered, state=state, tier=achieved[-1]["name"] if achieved else None,
|
||||
progress=progress, next_tier=next_tiers[0]["name"] if next_tiers else None, next_threshold=next_threshold, progress_pct=pct)
|
||||
|
||||
|
||||
def evaluate_requirements(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> Dict[str, Any]:
|
||||
requirements = definition.get("requirements", [])
|
||||
if not requirements:
|
||||
return {"unlocked": False, "discovered": not definition.get("secret"), "state": "secret" if definition.get("secret") else "discovered", "tier": None, "progress": 0, "next_tier": None, "next_threshold": 1, "progress_pct": 0}
|
||||
state, discovered = _state(definition, False, False)
|
||||
return _result(unlocked=False, discovered=discovered, state=state, tier=None, progress=0, next_tier=None, next_threshold=1, progress_pct=0)
|
||||
parts = []
|
||||
any_progress = False
|
||||
complete = True
|
||||
@@ -434,14 +427,16 @@ def evaluate_requirements(definition: Dict[str, Any], aggregate: Dict[str, Any])
|
||||
complete = complete and value >= threshold
|
||||
parts.append(min(1.0, value / max(1, threshold)))
|
||||
pct = math.floor((sum(parts) / len(parts)) * 100)
|
||||
state = "unlocked" if complete else ("secret" if definition.get("secret") and not any_progress else "discovered")
|
||||
return {"unlocked": complete, "discovered": any_progress or not definition.get("secret"), "state": state, "tier": None, "progress": pct, "next_tier": None, "next_threshold": 100, "progress_pct": 100 if complete else min(99, pct)}
|
||||
state, discovered = _state(definition, complete, any_progress)
|
||||
return _result(
|
||||
unlocked=complete, discovered=discovered, state=state, tier=None, progress=pct, next_tier=None,
|
||||
next_threshold=100, progress_pct=100 if complete else min(99, pct))
|
||||
|
||||
|
||||
def evaluate_boolean(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Backward-compatible helper for old tests/definitions. New catalog avoids simple booleans.
|
||||
unlocked = bool(aggregate.get(definition["metric"]))
|
||||
return {"unlocked": unlocked, "discovered": True, "state": "unlocked" if unlocked else "discovered", "tier": None, "progress": 1 if unlocked else 0, "next_tier": None, "next_threshold": 1, "progress_pct": 100 if unlocked else 0}
|
||||
def evaluate_definition(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if "threshold_metric" in definition:
|
||||
return evaluate_tiered(definition, aggregate)
|
||||
return evaluate_requirements(definition, aggregate)
|
||||
|
||||
|
||||
METRIC_LABELS = {
|
||||
@@ -506,8 +501,7 @@ METRIC_LABELS = {
|
||||
"release_events": "release, version, publish, or git tag events",
|
||||
"session_count": "Hermes sessions",
|
||||
"weekend_sessions": "sessions started on weekends",
|
||||
"night_sessions": "sessions started late night or before dawn",
|
||||
}
|
||||
"night_sessions": "sessions started late night or before dawn"}
|
||||
|
||||
|
||||
def metric_label(metric: str) -> str:
|
||||
@@ -521,13 +515,11 @@ def criteria_for(definition: Dict[str, Any]) -> str:
|
||||
tiers_list = sorted(definition.get("tiers", []), key=lambda t: t["threshold"])
|
||||
if not tiers_list:
|
||||
return "Requirement: use Hermes in the matching workflow."
|
||||
metric = metric_label(definition["threshold_metric"])
|
||||
ladder = ", ".join(f"{t['name']} {t['threshold']}" for t in tiers_list)
|
||||
return f"Requirement: {metric}. Tier ladder: {ladder}."
|
||||
return f"Requirement: {metric_label(definition['threshold_metric'])}. Tier ladder: {ladder}."
|
||||
requirements = definition.get("requirements") or []
|
||||
if requirements:
|
||||
parts = [f"{metric_label(r['metric'])} ≥ {int(r.get('gte', 1))}" for r in requirements]
|
||||
return "Requirement: " + "; ".join(parts) + "."
|
||||
return "Requirement: " + "; ".join(f"{metric_label(r['metric'])} ≥ {int(r.get('gte', 1))}" for r in requirements) + "."
|
||||
return "Requirement: complete the matching Hermes behavior."
|
||||
|
||||
|
||||
@@ -539,36 +531,34 @@ def display_achievement(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return clean
|
||||
|
||||
|
||||
def scan_sessions(
|
||||
limit: Optional[int] = None,
|
||||
progress_callback: Optional[Any] = None,
|
||||
progress_every: int = 250,
|
||||
) -> Dict[str, Any]:
|
||||
# ---- Scanning + aggregation ----
|
||||
|
||||
def _scan_meta(mode: str, total: int, *, rescanned: int = 0, reused: int = 0, scanned_so_far: Optional[int] = None, expected_total: Optional[int] = None) -> Dict[str, Any]:
|
||||
meta = {"mode": mode, "sessions_total": total, "sessions_rescanned": rescanned, "sessions_reused": reused}
|
||||
if scanned_so_far is not None:
|
||||
meta.update(sessions_scanned_so_far=scanned_so_far, sessions_expected_total=expected_total)
|
||||
return meta
|
||||
|
||||
|
||||
def scan_sessions(limit: Optional[int] = None, progress_callback: Optional[Any] = None, progress_every: int = 250) -> Dict[str, Any]:
|
||||
"""Scan Hermes sessions and build per-session achievement stats.
|
||||
|
||||
``limit=None`` (the default) scans the ENTIRE history: SQLite's ``LIMIT -1``
|
||||
means unlimited, so ``None``/non-positive map to ``-1``. A former cap of 200
|
||||
silently shrank lifetime totals to ~2% on long-running installs.
|
||||
|
||||
Warm scans stay cheap: the checkpoint stores per-session stats keyed by
|
||||
``(started_at, last_active)`` fingerprint and only re-analyzes changed
|
||||
sessions. Cold scans over thousands of sessions take tens of seconds to
|
||||
minutes, so ``evaluate_all`` runs them on a background thread.
|
||||
|
||||
``progress_callback(partial_sessions, scanned_so_far, total)`` fires every
|
||||
``progress_every`` sessions so background scans can publish intermediate
|
||||
snapshots and surface badges incrementally instead of all at the end.
|
||||
``limit=None`` (default) scans the ENTIRE history (SQLite ``LIMIT -1``); a former cap
|
||||
of 200 silently shrank lifetime totals on long-running installs. The checkpoint stores
|
||||
per-session stats keyed by ``(started_at, last_active)`` fingerprint so warm scans only
|
||||
re-analyze changed sessions. ``progress_callback(partial_sessions, scanned_so_far,
|
||||
total)`` fires every ``progress_every`` sessions so background scans can publish
|
||||
intermediate snapshots.
|
||||
"""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
except Exception as exc:
|
||||
return {"sessions": [], "aggregate": {}, "error": f"Could not import SessionDB: {exc}", "scan_meta": {"mode": "failed", "sessions_total": 0, "sessions_rescanned": 0, "sessions_reused": 0}}
|
||||
return {"sessions": [], "aggregate": {}, "error": f"Could not import SessionDB: {exc}", "scan_meta": _scan_meta("failed", 0)}
|
||||
|
||||
checkpoint = load_checkpoint()
|
||||
previous_sessions = checkpoint.get("sessions") if isinstance(checkpoint.get("sessions"), dict) else {}
|
||||
reused = rescanned = 0
|
||||
db_limit = -1 if (limit is None or limit <= 0) else int(limit)
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sessions_meta = db.list_sessions_rich(limit=db_limit, include_children=True, project_compression_tips=False)
|
||||
@@ -582,45 +572,41 @@ def scan_sessions(
|
||||
fp = session_fingerprint(meta)
|
||||
cached = previous_sessions.get(sid)
|
||||
cached = cached if isinstance(cached, dict) else {}
|
||||
title = meta.get("title") or meta.get("preview")
|
||||
if isinstance(cached.get("stats"), dict) and cached.get("fingerprint") == fp:
|
||||
stats = dict(cached["stats"])
|
||||
reused += 1
|
||||
else:
|
||||
messages = db.get_messages(sid)
|
||||
stats = analyze_messages(sid, meta.get("title") or meta.get("preview") or "Untitled", messages)
|
||||
stats = analyze_messages(sid, title or "Untitled", db.get_messages(sid))
|
||||
rescanned += 1
|
||||
|
||||
stats.update(session_id=sid, title=meta.get("title") or meta.get("preview") or stats.get("title") or "Untitled", started_at=meta.get("started_at"), last_active=meta.get("last_active"), source=meta.get("source"))
|
||||
stats.update(session_id=sid, title=title or stats.get("title") or "Untitled", started_at=meta.get("started_at"), last_active=meta.get("last_active"), source=meta.get("source"))
|
||||
if meta.get("model"):
|
||||
# Checkpoint round-trips turn the set into a list; handle both.
|
||||
model = str(meta.get("model"))
|
||||
names = stats.setdefault("model_names", set())
|
||||
if isinstance(names, set):
|
||||
names.add(model)
|
||||
elif isinstance(names, list) and model not in names:
|
||||
names.append(model)
|
||||
elif not isinstance(names, list):
|
||||
elif isinstance(names, list):
|
||||
if model not in names:
|
||||
names.append(model)
|
||||
else:
|
||||
stats["model_names"] = {model}
|
||||
|
||||
sessions.append(stats)
|
||||
checkpoint_sessions[sid] = {"fingerprint": fp, "stats": _json_safe(stats)}
|
||||
|
||||
if progress_callback is not None and progress_every > 0 and (idx % progress_every == 0) and idx < total_sessions:
|
||||
try:
|
||||
progress_callback(list(sessions), idx, total_sessions)
|
||||
except Exception:
|
||||
pass # Advisory — a broken publisher must never abort the scan.
|
||||
|
||||
_write_json(CHECKPOINT_FILE, {"schema_version": 1, "generated_at": int(time.time()), "sessions": checkpoint_sessions})
|
||||
finally:
|
||||
close = getattr(db, "close", None)
|
||||
if close:
|
||||
close()
|
||||
db.close()
|
||||
return {
|
||||
"sessions": sessions,
|
||||
"aggregate": aggregate_stats(sessions),
|
||||
"scan_meta": {"mode": "incremental" if reused > 0 else "full", "sessions_total": len(sessions), "sessions_rescanned": rescanned, "sessions_reused": reused, "sessions_scanned_so_far": len(sessions), "sessions_expected_total": total_sessions},
|
||||
}
|
||||
"scan_meta": _scan_meta(
|
||||
"incremental" if reused > 0 else "full", len(sessions), rescanned=rescanned, reused=reused,
|
||||
scanned_so_far=len(sessions), expected_total=total_sessions)}
|
||||
|
||||
|
||||
# Per-session bests: aggregate metric -> session stat key (also drives evidence_for).
|
||||
@@ -632,8 +618,7 @@ _SESSION_MAX_METRICS = {
|
||||
"max_file_tool_calls_in_session": "file_tool_calls",
|
||||
"max_web_calls_in_session": "web_calls",
|
||||
"max_web_browser_calls_in_session": "web_browser_calls",
|
||||
"max_files_touched_in_session": "files_touched_count",
|
||||
}
|
||||
"max_files_touched_in_session": "files_touched_count"}
|
||||
# Lifetime sums: aggregate metric -> session stat key.
|
||||
_SESSION_SUM_METRICS = {
|
||||
"total_errors": "error_count",
|
||||
@@ -648,8 +633,7 @@ _SESSION_SUM_METRICS = {
|
||||
"total_cron_calls": "cron_calls",
|
||||
"browser_calls": "browser_calls",
|
||||
"image_vision_calls": "image_vision_calls",
|
||||
"tts_calls": "tts_calls",
|
||||
}
|
||||
"tts_calls": "tts_calls"}
|
||||
# ``*_events`` counters summed under their own name.
|
||||
_SESSION_EVENT_KEYS = [
|
||||
"traceback_events", "log_read_events", "port_conflict_events", "permission_denied_events", "install_error_events", "install_success_events", "restart_after_error_events", "env_var_error_events", "yaml_error_events", "docker_conflict_events", "frontend_activity_events", "css_activity_events", "git_events", "tiny_patch_after_errors_events", "skill_events", "skill_manage_events", "memory_events", "memory_write_events", "context_events", "gateway_events", "plugin_events", "rollback_events", "docs_activity_events", "model_events", "openrouter_events", "codex_events", "claude_events", "gemini_events", "local_model_events", "toolset_events", "config_events", "git_history_events", "test_events", "screenshot_events", "release_events", "cache_events",
|
||||
@@ -661,7 +645,6 @@ def aggregate_stats(sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
agg: Dict[str, Any] = {"session_count": len(sessions)}
|
||||
for key in (*_SESSION_MAX_METRICS, *_SESSION_SUM_METRICS, "distinct_model_count", "distinct_provider_count", "local_model_chat_sessions", "weekend_sessions", "night_sessions", *_SESSION_EVENT_KEYS):
|
||||
agg[key] = 0
|
||||
|
||||
model_names: Set[str] = set()
|
||||
provider_names: Set[str] = set()
|
||||
for s in sessions:
|
||||
@@ -673,11 +656,8 @@ def aggregate_stats(sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
agg[key] += s.get(key, 0)
|
||||
session_models = s.get("model_names") or set()
|
||||
model_names.update(session_models)
|
||||
for model_name in session_models:
|
||||
provider = model_provider(str(model_name))
|
||||
if provider:
|
||||
provider_names.add(provider)
|
||||
if any(is_local_model_name(str(model_name)) for model_name in session_models):
|
||||
provider_names.update(filter(None, (model_provider(str(m)) for m in session_models)))
|
||||
if any(is_local_model_name(str(m)) for m in session_models):
|
||||
agg["local_model_chat_sessions"] += 1
|
||||
if s.get("started_at"):
|
||||
try:
|
||||
@@ -693,14 +673,6 @@ def aggregate_stats(sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
return agg
|
||||
|
||||
|
||||
def evaluate_definition(definition: Dict[str, Any], aggregate: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if "threshold_metric" in definition:
|
||||
return evaluate_tiered(definition, aggregate)
|
||||
if "requirements" in definition:
|
||||
return evaluate_requirements(definition, aggregate)
|
||||
return evaluate_boolean(definition, aggregate)
|
||||
|
||||
|
||||
def evidence_for(definition: Dict[str, Any], sessions: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
key = _SESSION_MAX_METRICS.get(definition.get("threshold_metric"))
|
||||
if not sessions or key is None:
|
||||
@@ -709,14 +681,28 @@ def evidence_for(definition: Dict[str, Any], sessions: List[Dict[str, Any]]) ->
|
||||
return {"session_id": s.get("session_id"), "title": s.get("title"), "value": s.get(key, 0)}
|
||||
|
||||
|
||||
def _compute_from_scan(scan: Dict[str, Any], *, is_partial: bool = False) -> Dict[str, Any]:
|
||||
"""Evaluate every achievement definition against a scan result.
|
||||
# ---- Snapshot assembly ----
|
||||
|
||||
Used by ``compute_all`` for finished scans AND by the background progress
|
||||
callback for in-flight snapshots. ``is_partial=True`` skips persisting
|
||||
``state.json`` unlocks — an "unlock time" from half a scan could be
|
||||
invalidated by a later session.
|
||||
"""
|
||||
def _snapshot(evaluated: List[Dict[str, Any]], scan: Dict[str, Any], now: int) -> Dict[str, Any]:
|
||||
"""Wire payload shared by finished, partial and pending snapshots."""
|
||||
return {
|
||||
"achievements": evaluated,
|
||||
"sessions": scan.get("sessions", []),
|
||||
"aggregate": scan.get("aggregate", {}),
|
||||
"scan_meta": scan.get("scan_meta", {}),
|
||||
"error": scan.get("error"),
|
||||
"unlocked_count": sum(1 for a in evaluated if a["unlocked"]),
|
||||
"discovered_count": sum(1 for a in evaluated if a.get("state") == "discovered"),
|
||||
"secret_count": sum(1 for a in evaluated if a.get("state") == "secret"),
|
||||
"total_count": len(evaluated),
|
||||
"generated_at": now}
|
||||
|
||||
|
||||
def _compute_from_scan(scan: Dict[str, Any], *, is_partial: bool = False) -> Dict[str, Any]:
|
||||
"""Evaluate every achievement definition against a scan result. Used by ``compute_all``
|
||||
for finished scans AND by the background progress callback for in-flight snapshots;
|
||||
``is_partial=True`` skips persisting ``state.json`` unlocks — an "unlock time" from
|
||||
half a scan could be invalidated by a later session."""
|
||||
aggregate = scan.get("aggregate", {})
|
||||
state = load_state() if not is_partial else {"unlocks": {}}
|
||||
unlocks = state.setdefault("unlocks", {})
|
||||
@@ -734,18 +720,7 @@ def _compute_from_scan(scan: Dict[str, Any], *, is_partial: bool = False) -> Dic
|
||||
evaluated.append(display_achievement(item))
|
||||
if not is_partial:
|
||||
save_state(state)
|
||||
return {
|
||||
"achievements": evaluated,
|
||||
"sessions": scan.get("sessions", []),
|
||||
"aggregate": aggregate,
|
||||
"scan_meta": scan.get("scan_meta", {}),
|
||||
"error": scan.get("error"),
|
||||
"unlocked_count": sum(1 for a in evaluated if a["unlocked"]),
|
||||
"discovered_count": sum(1 for a in evaluated if a.get("state") == "discovered"),
|
||||
"secret_count": sum(1 for a in evaluated if a.get("state") == "secret"),
|
||||
"total_count": len(evaluated),
|
||||
"generated_at": now,
|
||||
}
|
||||
return _snapshot(evaluated, scan, now)
|
||||
|
||||
|
||||
def compute_all(progress_callback: Optional[Any] = None, progress_every: int = 250) -> Dict[str, Any]:
|
||||
@@ -758,77 +733,59 @@ _BACKGROUND_SCAN_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _build_pending_snapshot(now: int) -> Dict[str, Any]:
|
||||
"""Structurally-complete placeholder served while the first-ever scan runs,
|
||||
so the UI renders an empty list + spinner without special-casing "no data"."""
|
||||
evaluated = [display_achievement({**d, **{"unlocked": False, "discovered": False, "state": "secret" if d.get("secret") else "discovered", "progress": 0, "progress_pct": 0, "next_tier": (d.get("tiers") or [{}])[0].get("name"), "next_threshold": (d.get("tiers") or [{}])[0].get("threshold", 1), "tier": None}}) for d in ACHIEVEMENTS]
|
||||
return {
|
||||
"achievements": evaluated,
|
||||
"sessions": [],
|
||||
"aggregate": {},
|
||||
"scan_meta": {"mode": "pending", "sessions_total": 0, "sessions_rescanned": 0, "sessions_reused": 0},
|
||||
"error": None,
|
||||
"unlocked_count": 0,
|
||||
"discovered_count": sum(1 for a in evaluated if a.get("state") == "discovered"),
|
||||
"secret_count": sum(1 for a in evaluated if a.get("state") == "secret"),
|
||||
"total_count": len(evaluated),
|
||||
"generated_at": now,
|
||||
}
|
||||
"""Structurally-complete placeholder served while the first-ever scan runs, so the UI
|
||||
renders an empty list + spinner without special-casing "no data"."""
|
||||
evaluated = [
|
||||
display_achievement({
|
||||
**d, "unlocked": False, "discovered": False, "state": "secret" if d.get("secret") else "discovered", "progress": 0,
|
||||
"progress_pct": 0, "next_tier": (d.get("tiers") or [{}])[0].get("name"),
|
||||
"next_threshold": (d.get("tiers") or [{}])[0].get("threshold", 1), "tier": None})
|
||||
for d in ACHIEVEMENTS]
|
||||
return _snapshot(evaluated, {"scan_meta": _scan_meta("pending", 0), "error": None}, now)
|
||||
|
||||
|
||||
def _set_cache(snapshot: Dict[str, Any], at: int) -> None:
|
||||
global _SNAPSHOT_CACHE, _SNAPSHOT_CACHE_AT
|
||||
_SNAPSHOT_CACHE = _json_safe(snapshot)
|
||||
_SNAPSHOT_CACHE_AT = at
|
||||
|
||||
|
||||
def _run_scan_and_update_cache(publish_partial_snapshots: bool = True) -> None:
|
||||
"""Execute a scan + snapshot update. Called synchronously or from a thread.
|
||||
|
||||
With ``publish_partial_snapshots`` (background scans) the scanner periodically
|
||||
publishes in-progress snapshots to ``_SNAPSHOT_CACHE`` so a long cold scan
|
||||
unlocks badges incrementally instead of jumping from zero to final.
|
||||
Synchronous /rescan callers pass ``False`` since they block on the result.
|
||||
"""
|
||||
global _SNAPSHOT_CACHE, _SNAPSHOT_CACHE_AT
|
||||
"""Execute a scan + snapshot update (synchronously or from a thread). With
|
||||
``publish_partial_snapshots`` (background scans) the scanner periodically publishes
|
||||
in-progress snapshots to ``_SNAPSHOT_CACHE`` so a long cold scan unlocks badges
|
||||
incrementally; synchronous /rescan callers pass ``False`` since they block on the result."""
|
||||
with _SCAN_LOCK:
|
||||
started = int(time.time())
|
||||
_SCAN_STATUS["state"] = "running"
|
||||
_SCAN_STATUS["started_at"] = started
|
||||
_SCAN_STATUS["last_error"] = None
|
||||
_SCAN_STATUS.update(state="running", started_at=started, last_error=None)
|
||||
|
||||
def _publish_partial(partial_sessions, scanned_so_far, total):
|
||||
global _SNAPSHOT_CACHE, _SNAPSHOT_CACHE_AT
|
||||
try:
|
||||
partial_scan = {
|
||||
"sessions": partial_sessions,
|
||||
"aggregate": aggregate_stats(partial_sessions),
|
||||
"scan_meta": {"mode": "in_progress", "sessions_total": scanned_so_far, "sessions_rescanned": 0, "sessions_reused": 0, "sessions_scanned_so_far": scanned_so_far, "sessions_expected_total": total},
|
||||
"scan_meta": _scan_meta("in_progress", scanned_so_far, scanned_so_far=scanned_so_far, expected_total=total),
|
||||
}
|
||||
partial = _compute_from_scan(partial_scan, is_partial=True)
|
||||
# _SNAPSHOT_CACHE_AT stays 0 so partials remain in the 'stale'
|
||||
# regime: the UI keeps polling /scan-status and never mistakes
|
||||
# an in-flight result for a finished one.
|
||||
_SNAPSHOT_CACHE = _json_safe(partial)
|
||||
_SNAPSHOT_CACHE_AT = 0
|
||||
# _SNAPSHOT_CACHE_AT stays 0 so partials remain in the 'stale' regime: the UI
|
||||
# keeps polling /scan-status and never mistakes an in-flight result for a finished one.
|
||||
_set_cache(_compute_from_scan(partial_scan, is_partial=True), 0)
|
||||
except Exception:
|
||||
pass # Intermediate publication is best-effort; don't kill the scan.
|
||||
|
||||
callback = _publish_partial if publish_partial_snapshots else None
|
||||
try:
|
||||
computed = compute_all(progress_callback=callback)
|
||||
_SNAPSHOT_CACHE = _json_safe(computed)
|
||||
_SNAPSHOT_CACHE_AT = int(_SNAPSHOT_CACHE.get("generated_at") or int(time.time()))
|
||||
computed = _json_safe(compute_all(progress_callback=_publish_partial if publish_partial_snapshots else None))
|
||||
_set_cache(computed, int(computed.get("generated_at") or int(time.time())))
|
||||
_write_json(SNAPSHOT_FILE, _SNAPSHOT_CACHE)
|
||||
_SCAN_STATUS["state"] = "idle"
|
||||
except Exception as exc:
|
||||
_SCAN_STATUS["state"] = "failed"
|
||||
_SCAN_STATUS["last_error"] = str(exc)
|
||||
_SCAN_STATUS.update(state="failed", last_error=str(exc))
|
||||
finally:
|
||||
_SCAN_STATUS["finished_at"] = int(time.time())
|
||||
_SCAN_STATUS["last_duration_ms"] = int((_SCAN_STATUS["finished_at"] - started) * 1000)
|
||||
_SCAN_STATUS["run_count"] = int(_SCAN_STATUS.get("run_count", 0)) + 1
|
||||
finished = int(time.time())
|
||||
_SCAN_STATUS.update(finished_at=finished, last_duration_ms=int((finished - started) * 1000), run_count=int(_SCAN_STATUS.get("run_count", 0)) + 1)
|
||||
|
||||
|
||||
def _start_background_scan() -> None:
|
||||
"""Kick off a daemon-thread scan unless one is already running (idempotent).
|
||||
|
||||
The thread updates ``_SNAPSHOT_CACHE`` on completion and publishes partial
|
||||
snapshots every ~250 sessions while running.
|
||||
"""
|
||||
"""Kick off a daemon-thread scan unless one is already running (idempotent)."""
|
||||
global _BACKGROUND_SCAN_THREAD
|
||||
with _BACKGROUND_SCAN_LOCK:
|
||||
existing = _BACKGROUND_SCAN_THREAD
|
||||
@@ -840,45 +797,33 @@ def _start_background_scan() -> None:
|
||||
|
||||
|
||||
def evaluate_all(force: bool = False) -> Dict[str, Any]:
|
||||
"""Return the current achievements payload.
|
||||
|
||||
* Fresh in-memory cache -> return it instantly.
|
||||
* Stale on-disk snapshot -> load it, kick a background rescan, return the
|
||||
stale data (UI decorates it with ``is_stale=True``).
|
||||
* No snapshot yet (first-ever run) -> kick a background scan, return an
|
||||
empty-but-valid "pending" payload so the UI can render a spinner.
|
||||
* ``force=True`` (manual /rescan) -> run synchronously, replace the cache.
|
||||
|
||||
Cold scans on 8000+ session databases take minutes; the background thread
|
||||
keeps them off the dashboard request path.
|
||||
"""
|
||||
"""Return the current achievements payload: a fresh in-memory cache is returned as is;
|
||||
a stale on-disk snapshot is served while a background rescan runs (UI decorates it with
|
||||
``is_stale=True``); with no snapshot yet an empty-but-valid "pending" payload is served
|
||||
while the first scan runs; ``force=True`` (manual /rescan) scans synchronously. Cold
|
||||
scans on 8000+ session databases take minutes, hence the background thread."""
|
||||
global _SNAPSHOT_CACHE, _SNAPSHOT_CACHE_AT
|
||||
now = int(time.time())
|
||||
|
||||
if not force and _cache_is_fresh(now):
|
||||
return _SNAPSHOT_CACHE or {}
|
||||
|
||||
# Lazy-load the persisted snapshot so fresh process starts serve cached data.
|
||||
if _SNAPSHOT_CACHE is None:
|
||||
persisted = _read_json(SNAPSHOT_FILE)
|
||||
if isinstance(persisted, dict):
|
||||
_SNAPSHOT_CACHE = persisted
|
||||
_SNAPSHOT_CACHE_AT = int(persisted.get("generated_at") or 0) or now
|
||||
|
||||
if force:
|
||||
# No partial publishing: the caller is blocking on the final result.
|
||||
_run_scan_and_update_cache(publish_partial_snapshots=False)
|
||||
if _SNAPSHOT_CACHE is not None:
|
||||
return _SNAPSHOT_CACHE
|
||||
return _build_pending_snapshot(now) # Scan failed with no prior cache.
|
||||
|
||||
# Serve what we have (stale is fine) and refresh in the background; on a
|
||||
# first-ever run the UI polls /scan-status and re-fetches when the scan completes.
|
||||
if not _cache_is_fresh(now):
|
||||
elif not _cache_is_fresh(now):
|
||||
# Serve what we have (stale is fine) and refresh in the background; on a first-ever
|
||||
# run the UI polls /scan-status and re-fetches when the scan completes.
|
||||
_start_background_scan()
|
||||
return _SNAPSHOT_CACHE if _SNAPSHOT_CACHE is not None else _build_pending_snapshot(now)
|
||||
|
||||
|
||||
# ---- Routes ----
|
||||
|
||||
@router.get("/achievements")
|
||||
async def achievements():
|
||||
data = evaluate_all()
|
||||
|
||||
@@ -23,7 +23,8 @@ from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator, Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status
|
||||
from fastapi import (
|
||||
APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status)
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -1264,7 +1265,8 @@ def model_options():
|
||||
load_picker_context(), explicit_only=True, canonical_order=True, probe_custom_providers=False)
|
||||
return {
|
||||
"providers": [
|
||||
{"slug": row.get("slug", ""), "label": row.get("label") or row.get("slug", ""), "models": list(row.get("models") or [])}
|
||||
{"slug": row.get("slug", ""), "label": row.get("label") or row.get("slug", ""),
|
||||
"models": list(row.get("models") or [])}
|
||||
for row in payload.get("providers", [])
|
||||
if row.get("models")]}
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user