generated from coulomb/repo-seed
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:
@@ -89,34 +89,52 @@ async def load_activity_definition(activity_id: str) -> dict:
|
||||
|
||||
|
||||
@activity.defn
|
||||
async def resolve_context(context_sources: list[dict]) -> dict:
|
||||
async def resolve_context(
|
||||
context_sources: list[dict],
|
||||
event_envelope_json: str | None = None,
|
||||
) -> dict:
|
||||
"""Resolve each context source and merge into a snapshot dict.
|
||||
|
||||
Returns: {source.name: resolved_value, ...}
|
||||
Returns: {bind_key: resolved_value, ...}
|
||||
|
||||
Supported source types:
|
||||
static — returns config["value"] directly
|
||||
http_get — not yet implemented
|
||||
db_query — not yet implemented
|
||||
Source types are dispatched via CONTEXT_RESOLVER_REGISTRY.
|
||||
A resolver that raises logs a warning and binds {} — it does not abort the run.
|
||||
The 'static' type is handled inline without a registry entry.
|
||||
"""
|
||||
import activity_core.context_resolvers # noqa: F401 — registers all adapters
|
||||
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY
|
||||
|
||||
snapshot: dict = {}
|
||||
for source in context_sources:
|
||||
name = source["name"]
|
||||
source_type = source["type"]
|
||||
config = source.get("config", {})
|
||||
source_type = source.get("type", "")
|
||||
query = source.get("query", "")
|
||||
params = source.get("params") or {}
|
||||
raw_bind = source.get("bind_to") or source.get("name") or source_type
|
||||
# Strip the 'context.' namespace prefix so evaluator can find the key.
|
||||
bind_key = raw_bind.removeprefix("context.") if raw_bind.startswith("context.") else raw_bind
|
||||
|
||||
if source_type == "static":
|
||||
snapshot[name] = config.get("value")
|
||||
elif source_type in ("http_get", "db_query"):
|
||||
raise ApplicationError(
|
||||
f"Context source type {source_type!r} is not yet implemented",
|
||||
non_retryable=True,
|
||||
snapshot[bind_key] = source.get("config", {}).get("value")
|
||||
continue
|
||||
|
||||
resolver_cls = CONTEXT_RESOLVER_REGISTRY.get(source_type)
|
||||
if resolver_cls is None:
|
||||
activity.logger.warning(
|
||||
"Unknown context source type %r — binding {}",
|
||||
source_type,
|
||||
)
|
||||
else:
|
||||
raise ApplicationError(
|
||||
f"Unknown context source type {source_type!r}",
|
||||
non_retryable=True,
|
||||
snapshot[bind_key] = {}
|
||||
continue
|
||||
|
||||
try:
|
||||
snapshot[bind_key] = resolver_cls().resolve(query, None, params)
|
||||
except Exception as exc:
|
||||
activity.logger.warning(
|
||||
"Context resolver %r failed — %s; binding {}",
|
||||
source_type,
|
||||
exc,
|
||||
)
|
||||
snapshot[bind_key] = {}
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
1
src/activity_core/context_resolvers/__init__.py
Normal file
1
src/activity_core/context_resolvers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from activity_core.context_resolvers import repo_scoping, state_hub # noqa: F401
|
||||
25
src/activity_core/context_resolvers/base.py
Normal file
25
src/activity_core/context_resolvers/base.py
Normal 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]: ...
|
||||
48
src/activity_core/context_resolvers/repo_scoping.py
Normal file
48
src/activity_core/context_resolvers/repo_scoping.py
Normal 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
|
||||
43
src/activity_core/context_resolvers/state_hub.py
Normal file
43
src/activity_core/context_resolvers/state_hub.py
Normal 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
|
||||
@@ -95,7 +95,7 @@ class RunActivityWorkflow:
|
||||
# ── 2. Resolve context ────────────────────────────────────────────────
|
||||
context_snapshot: dict = await workflow.execute_activity(
|
||||
resolve_context,
|
||||
defn["context_sources"],
|
||||
args=[defn["context_sources"], event_envelope_json],
|
||||
start_to_close_timeout=_ACTIVITY_TIMEOUT,
|
||||
retry_policy=_RETRY_POLICY,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user