feat(WP-0003c): context adapters, first ActivityDefinition, full test suite

T51: ContextResolver ABC + CONTEXT_RESOLVER_REGISTRY; resolve_context activity
updated to dispatch via registry (warns + binds {} on failure, never aborts run).
T52: RepoScopingContextResolver with 5-min in-process cache.
T53: StateHubContextResolver (no cache) for domain_summary and repo_sbom_status.
T54: activity-definitions/weekly-sbom-staleness.md (Monday 09:00 Berlin, cron
trigger, flag-stale-sbom rule at >30 days) + tasks/sbom-rescan.md template.
T55: 51 parametrized evaluator tests — all whitelisted operators, unsafe
expression rejection, empty condition, missing attribute, nested context access.
T56: 15 executor safety tests — UntrustedFieldError, object-type rejection,
injection fixture, LLM retry on bad JSON, review_required field.
T57: 6 integration tests — parses real definition, evaluates rule per-repo
(stale/fresh boundary), emits via NullSink, verifies spawn log entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 23:24:48 +02:00
parent fd8d0827d7
commit 827ef9c1a0
12 changed files with 839 additions and 27 deletions

View File

@@ -0,0 +1 @@
from activity_core.context_resolvers import repo_scoping, state_hub # noqa: F401

View File

@@ -0,0 +1,25 @@
"""Context resolver adapter interface (T51).
Each adapter handles one source type (e.g. 'repo-scoping', 'state-hub').
Adapters self-register by assigning to CONTEXT_RESOLVER_REGISTRY at import time.
Import activity_core.context_resolvers (the package) to trigger all registrations.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
CONTEXT_RESOLVER_REGISTRY: dict[str, type["ContextResolver"]] = {}
class ContextResolver(ABC):
"""Base class for context source adapters."""
@abstractmethod
def resolve(
self,
query: str,
event: Any,
params: dict[str, Any],
) -> dict[str, Any]: ...

View File

@@ -0,0 +1,48 @@
"""Repo-scoping context adapter (T52).
Registered as source type 'repo-scoping'.
Supported queries:
- repo_profile: GET {REPO_SCOPING_URL}/repos/{repo_slug}/scope
5-minute in-process cache keyed by (query, repo_slug). Cache is per-worker-
process; not shared across Temporal workers.
Config: REPO_SCOPING_URL env var (default: http://127.0.0.1:8020).
"""
from __future__ import annotations
import os
import time
from typing import Any
import httpx
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver
_REPO_SCOPING_URL = os.environ.get("REPO_SCOPING_URL", "http://127.0.0.1:8020")
_CACHE: dict[tuple, tuple[float, dict]] = {}
_CACHE_TTL = 300.0 # 5 minutes
class RepoScopingContextResolver(ContextResolver):
"""Fetches repository scope profiles from the repo-scoping service."""
def resolve(self, query: str, event: Any, params: dict[str, Any]) -> dict[str, Any]:
if query == "repo_profile":
repo_slug = params.get("repo_slug", "")
cache_key = (query, repo_slug)
now = time.monotonic()
if cache_key in _CACHE:
ts, val = _CACHE[cache_key]
if now - ts < _CACHE_TTL:
return val
url = f"{_REPO_SCOPING_URL.rstrip('/')}/repos/{repo_slug}/scope"
resp = httpx.get(url, timeout=10.0)
resp.raise_for_status()
result: dict[str, Any] = resp.json()
_CACHE[cache_key] = (now, result)
return result
return {}
CONTEXT_RESOLVER_REGISTRY["repo-scoping"] = RepoScopingContextResolver

View File

@@ -0,0 +1,43 @@
"""State-hub context adapter (T53).
Registered as source type 'state-hub'.
Supported queries:
- domain_summary: GET {STATE_HUB_URL}/state/domain/{domain}
- repo_sbom_status: GET {STATE_HUB_URL}/sbom/status?repo={repo_slug}
No caching — state hub data is live operational state and must not be stale
within a single workflow run.
Config: STATE_HUB_URL env var (default: http://127.0.0.1:8000).
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver
_STATE_HUB_URL = os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000")
class StateHubContextResolver(ContextResolver):
"""Fetches live data from the Custodian State Hub."""
def resolve(self, query: str, event: Any, params: dict[str, Any]) -> dict[str, Any]:
base = _STATE_HUB_URL.rstrip("/")
if query == "domain_summary":
domain = params.get("domain", "")
resp = httpx.get(f"{base}/state/domain/{domain}", timeout=10.0)
resp.raise_for_status()
return resp.json()
if query == "repo_sbom_status":
repo_slug = params.get("repo_slug", "")
resp = httpx.get(f"{base}/sbom/status", params={"repo": repo_slug}, timeout=10.0)
resp.raise_for_status()
return resp.json()
return {}
CONTEXT_RESOLVER_REGISTRY["state-hub"] = StateHubContextResolver