generated from coulomb/repo-seed
Add reuse-surface report gaps resolver
This commit is contained in:
@@ -149,6 +149,8 @@ async def resolve_context(
|
|||||||
query = source.get("query", "")
|
query = source.get("query", "")
|
||||||
params = source.get("params") or {}
|
params = source.get("params") or {}
|
||||||
required = bool(source.get("required") or params.get("required", False))
|
required = bool(source.get("required") or params.get("required", False))
|
||||||
|
resolver_params = dict(params)
|
||||||
|
resolver_params["required"] = required
|
||||||
raw_bind = source.get("bind_to") or source.get("name") or source_type
|
raw_bind = source.get("bind_to") or source.get("name") or source_type
|
||||||
# Strip the 'context.' namespace prefix so evaluator can find the key.
|
# 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
|
bind_key = raw_bind.removeprefix("context.") if raw_bind.startswith("context.") else raw_bind
|
||||||
@@ -172,7 +174,7 @@ async def resolve_context(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = resolver_cls().resolve(query, event_envelope, params)
|
resolved = resolver_cls().resolve(query, event_envelope, resolver_params)
|
||||||
snapshot[bind_key] = _bind_resolver_result(bind_key, resolved)
|
snapshot[bind_key] = _bind_resolver_result(bind_key, resolved)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if required:
|
if required:
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ from activity_core.context_resolvers import ( # noqa: F401
|
|||||||
ops_inventory,
|
ops_inventory,
|
||||||
repo_scoping,
|
repo_scoping,
|
||||||
state_hub,
|
state_hub,
|
||||||
|
reuse_surface,
|
||||||
)
|
)
|
||||||
|
|||||||
516
src/activity_core/context_resolvers/reuse_surface.py
Normal file
516
src/activity_core/context_resolvers/reuse_surface.py
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
"""Reuse-surface registry hygiene context adapter.
|
||||||
|
|
||||||
|
Registered as source type ``reuse-surface`` and as the ``shell`` resolver
|
||||||
|
dispatcher for the ``reuse_surface_report_gaps`` query. Other shell queries
|
||||||
|
continue to delegate to the kaizen resolver for backward compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver
|
||||||
|
from activity_core.context_resolvers.kaizen import KaizenContextResolver
|
||||||
|
from activity_core.context_resolvers.state_hub import StateHubContextResolver
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_STATE_HUB_URL = "http://127.0.0.1:8000"
|
||||||
|
_REPORT_TIMEOUT_SECONDS = 60
|
||||||
|
_STATE_HUB_TIMEOUT_SECONDS = 10.0
|
||||||
|
_KNOWN_SIGNALS = frozenset(
|
||||||
|
{
|
||||||
|
"registry_gap",
|
||||||
|
"empty_capability_scaffold",
|
||||||
|
"stale_scope",
|
||||||
|
"stale_sbom",
|
||||||
|
"publish_check_fail",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RosterEntry:
|
||||||
|
slug: str
|
||||||
|
domain: str | None = None
|
||||||
|
publish_check: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url() -> str:
|
||||||
|
return os.environ.get("STATE_HUB_URL", _DEFAULT_STATE_HUB_URL).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _runner_host(params: dict[str, Any]) -> str:
|
||||||
|
return str(
|
||||||
|
params.get("runner_host")
|
||||||
|
or os.environ.get("KAIZEN_RUNNER_HOST")
|
||||||
|
or socket.gethostname()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_required(params: dict[str, Any]) -> bool:
|
||||||
|
return bool(params.get("required", False))
|
||||||
|
|
||||||
|
|
||||||
|
def reuse_surface_report_gaps(params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Resolve registry-hygiene gaps for the next rollout batch.
|
||||||
|
|
||||||
|
Missing operational dependencies are visible failures for required sources
|
||||||
|
and graceful empty lists for optional sources so definitions can opt into
|
||||||
|
either behavior without changing rule logic.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _resolve_reuse_surface_report_gaps(params)
|
||||||
|
except Exception as exc:
|
||||||
|
if _as_required(params):
|
||||||
|
raise
|
||||||
|
logger.warning("reuse_surface_report_gaps unavailable: %s", exc)
|
||||||
|
return {"gaps": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_reuse_surface_report_gaps(params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
roster_path = _roster_path(params)
|
||||||
|
entries = _load_active_roster_entries(roster_path)
|
||||||
|
if not entries:
|
||||||
|
return {"gaps": []}
|
||||||
|
|
||||||
|
state_path = _round_robin_state_path(params, roster_path)
|
||||||
|
selected, next_cursor = _select_round_robin_batch(
|
||||||
|
entries,
|
||||||
|
_batch_size(params),
|
||||||
|
state_path,
|
||||||
|
)
|
||||||
|
if not selected:
|
||||||
|
return {"gaps": []}
|
||||||
|
|
||||||
|
signals = _enabled_signals(_signals_path(params, roster_path))
|
||||||
|
roots = _resolve_repo_roots(selected, _runner_host(params))
|
||||||
|
report = _reuse_surface_report(params, signals)
|
||||||
|
gaps = _gap_records(selected, roots, signals, report)
|
||||||
|
|
||||||
|
_write_round_robin_state(state_path, next_cursor, selected)
|
||||||
|
return {"gaps": gaps}
|
||||||
|
|
||||||
|
|
||||||
|
def _roster_path(params: dict[str, Any]) -> Path:
|
||||||
|
raw = params.get("roster")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("reuse_surface_report_gaps requires params.roster")
|
||||||
|
path = Path(str(raw)).expanduser()
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(f"reuse_surface_report_gaps roster not found: {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_size(params: dict[str, Any]) -> int:
|
||||||
|
try:
|
||||||
|
return max(1, int(params.get("batch_size", 3)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 3
|
||||||
|
|
||||||
|
|
||||||
|
def _round_robin_state_path(params: dict[str, Any], roster_path: Path) -> Path:
|
||||||
|
raw = params.get("round_robin_state")
|
||||||
|
if raw:
|
||||||
|
return Path(str(raw)).expanduser()
|
||||||
|
return roster_path.with_name("round-robin-state.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _signals_path(params: dict[str, Any], roster_path: Path) -> Path:
|
||||||
|
raw = params.get("signals")
|
||||||
|
if raw:
|
||||||
|
return Path(str(raw)).expanduser()
|
||||||
|
return roster_path.with_name("signals.yml")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_active_roster_entries(path: Path) -> list[RosterEntry]:
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f"reuse_surface rollout roster is not a mapping: {path}")
|
||||||
|
|
||||||
|
entries: dict[str, RosterEntry] = {}
|
||||||
|
for domain, block in _iter_domain_blocks(data):
|
||||||
|
if _domain_phase(block) != "active":
|
||||||
|
continue
|
||||||
|
for item in _repo_items(block):
|
||||||
|
entry = _entry_from_item(item, domain, block)
|
||||||
|
if entry and entry.slug not in entries:
|
||||||
|
entries[entry.slug] = entry
|
||||||
|
return list(entries.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_domain_blocks(data: dict[str, Any]) -> list[tuple[str | None, dict[str, Any]]]:
|
||||||
|
domains = data.get("domains")
|
||||||
|
if isinstance(domains, dict):
|
||||||
|
return [
|
||||||
|
(str(name), block)
|
||||||
|
for name, block in domains.items()
|
||||||
|
if isinstance(block, dict)
|
||||||
|
]
|
||||||
|
if isinstance(domains, list):
|
||||||
|
return [
|
||||||
|
(str(block.get("name") or block.get("domain") or ""), block)
|
||||||
|
for block in domains
|
||||||
|
if isinstance(block, dict)
|
||||||
|
]
|
||||||
|
if isinstance(data.get("active"), list):
|
||||||
|
return [(None, {"phase": "active", "repos": data["active"]})]
|
||||||
|
return [
|
||||||
|
(str(name), block)
|
||||||
|
for name, block in data.items()
|
||||||
|
if isinstance(block, dict) and ("phase" in block or "repos" in block)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_phase(block: dict[str, Any]) -> str:
|
||||||
|
return str(block.get("phase") or block.get("status") or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_items(block: dict[str, Any]) -> list[Any]:
|
||||||
|
repos = (
|
||||||
|
block.get("repos")
|
||||||
|
or block.get("repo_slugs")
|
||||||
|
or block.get("repositories")
|
||||||
|
or block.get("slugs")
|
||||||
|
or []
|
||||||
|
)
|
||||||
|
if isinstance(repos, dict):
|
||||||
|
items: list[Any] = []
|
||||||
|
for slug, config in repos.items():
|
||||||
|
if isinstance(config, dict):
|
||||||
|
item = dict(config)
|
||||||
|
item.setdefault("slug", slug)
|
||||||
|
items.append(item)
|
||||||
|
else:
|
||||||
|
items.append(str(slug))
|
||||||
|
return items
|
||||||
|
if isinstance(repos, list):
|
||||||
|
return repos
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_from_item(
|
||||||
|
item: Any,
|
||||||
|
domain: str | None,
|
||||||
|
block: dict[str, Any],
|
||||||
|
) -> RosterEntry | None:
|
||||||
|
publish_check = block.get("publish_check")
|
||||||
|
if isinstance(item, str):
|
||||||
|
slug = item
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
slug = item.get("slug") or item.get("repo") or item.get("name")
|
||||||
|
publish_check = item.get("publish_check", publish_check)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if not slug:
|
||||||
|
return None
|
||||||
|
return RosterEntry(
|
||||||
|
slug=str(slug),
|
||||||
|
domain=domain or None,
|
||||||
|
publish_check=str(publish_check).lower() if publish_check is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_round_robin_batch(
|
||||||
|
entries: list[RosterEntry],
|
||||||
|
batch_size: int,
|
||||||
|
state_path: Path,
|
||||||
|
) -> tuple[list[RosterEntry], int]:
|
||||||
|
if not entries:
|
||||||
|
return [], 0
|
||||||
|
cursor = _read_round_robin_cursor(state_path) % len(entries)
|
||||||
|
size = min(batch_size, len(entries))
|
||||||
|
selected = [entries[(cursor + offset) % len(entries)] for offset in range(size)]
|
||||||
|
next_cursor = (cursor + size) % len(entries)
|
||||||
|
return selected, next_cursor
|
||||||
|
|
||||||
|
|
||||||
|
def _read_round_robin_cursor(path: Path) -> int:
|
||||||
|
if not path.is_file():
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return 0
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(data.get("cursor", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _write_round_robin_state(
|
||||||
|
path: Path,
|
||||||
|
cursor: int,
|
||||||
|
selected: list[RosterEntry],
|
||||||
|
) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {
|
||||||
|
"cursor": cursor,
|
||||||
|
"last_batch": [entry.slug for entry in selected],
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_signals(path: Path) -> set[str]:
|
||||||
|
if not path.is_file():
|
||||||
|
return set(_KNOWN_SIGNALS)
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
node = data.get("signals") if isinstance(data, dict) else data
|
||||||
|
enabled: set[str] = set()
|
||||||
|
saw_known_signal = False
|
||||||
|
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for name, config in node.items():
|
||||||
|
if str(name) not in _KNOWN_SIGNALS:
|
||||||
|
continue
|
||||||
|
saw_known_signal = True
|
||||||
|
if isinstance(config, dict) and config.get("enabled") is False:
|
||||||
|
continue
|
||||||
|
if config is False:
|
||||||
|
continue
|
||||||
|
enabled.add(str(name))
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
if isinstance(item, str) and item in _KNOWN_SIGNALS:
|
||||||
|
saw_known_signal = True
|
||||||
|
enabled.add(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
name = item.get("id") or item.get("signal") or item.get("name")
|
||||||
|
if str(name) in _KNOWN_SIGNALS and item.get("enabled", True) is not False:
|
||||||
|
saw_known_signal = True
|
||||||
|
enabled.add(str(name))
|
||||||
|
|
||||||
|
return enabled if saw_known_signal else set(_KNOWN_SIGNALS)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_repo_roots(
|
||||||
|
entries: list[RosterEntry],
|
||||||
|
runner_host: str,
|
||||||
|
) -> dict[str, Path]:
|
||||||
|
requested = {entry.slug for entry in entries}
|
||||||
|
roots: dict[str, Path] = {}
|
||||||
|
for repo in _fetch_repos():
|
||||||
|
slug = str(repo.get("slug") or "")
|
||||||
|
if slug not in requested:
|
||||||
|
continue
|
||||||
|
raw = _repo_path_for_host(repo, runner_host)
|
||||||
|
if raw:
|
||||||
|
roots[slug] = Path(raw)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_repos() -> list[dict[str, Any]]:
|
||||||
|
url = f"{_base_url()}/repos/"
|
||||||
|
try:
|
||||||
|
resp = httpx.get(url, timeout=_STATE_HUB_TIMEOUT_SECONDS)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise RuntimeError(f"State Hub unreachable at {url}: {exc}") from exc
|
||||||
|
payload = resp.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise RuntimeError(f"State Hub /repos/ returned non-list: {type(payload)!r}")
|
||||||
|
return [repo for repo in payload if isinstance(repo, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_path_for_host(repo: dict[str, Any], runner_host: str) -> str | None:
|
||||||
|
host_paths = repo.get("host_paths") or {}
|
||||||
|
raw = None
|
||||||
|
if isinstance(host_paths, dict):
|
||||||
|
raw = host_paths.get(runner_host)
|
||||||
|
raw = raw or repo.get("local_path")
|
||||||
|
if not raw or raw == "(unknown)":
|
||||||
|
return None
|
||||||
|
return str(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _reuse_surface_report(params: dict[str, Any], signals: set[str]) -> dict[str, Any]:
|
||||||
|
if not (signals & {"registry_gap", "empty_capability_scaffold"}):
|
||||||
|
return {}
|
||||||
|
binary = str(params.get("reuse_surface_bin") or "reuse-surface")
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
[binary, "report", "gaps", "--format", "json"],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
timeout=_REPORT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise RuntimeError(f"reuse-surface CLI not found: {binary}") from exc
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise RuntimeError("reuse-surface report gaps timed out") from exc
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
detail = completed.stderr.strip() or completed.stdout.strip()
|
||||||
|
raise RuntimeError(f"reuse-surface report gaps failed: {detail}")
|
||||||
|
try:
|
||||||
|
payload = json.loads(completed.stdout or "{}")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError("reuse-surface report gaps returned invalid JSON") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RuntimeError("reuse-surface report gaps returned non-object JSON")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _gap_records(
|
||||||
|
entries: list[RosterEntry],
|
||||||
|
roots: dict[str, Path],
|
||||||
|
signals: set[str],
|
||||||
|
report: dict[str, Any],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
empty_scaffolds = _repo_set(report, {"empty_scaffolds", "empty_scaffold"})
|
||||||
|
publish_fail = _repo_set(
|
||||||
|
report,
|
||||||
|
{"publish_fail", "publish_fails", "publish_failures"},
|
||||||
|
)
|
||||||
|
gaps: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
root = roots.get(entry.slug)
|
||||||
|
if root is None:
|
||||||
|
logger.info("reuse_surface repo_unreachable slug=%s", entry.slug)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
signals & {"registry_gap", "empty_capability_scaffold"}
|
||||||
|
and entry.slug in empty_scaffolds
|
||||||
|
):
|
||||||
|
_append_gap(gaps, seen, entry.slug, root, "empty_capability_scaffold")
|
||||||
|
|
||||||
|
if "registry_gap" in signals and entry.slug in publish_fail:
|
||||||
|
_append_gap(gaps, seen, entry.slug, root, "registry_gap")
|
||||||
|
|
||||||
|
if "publish_check_fail" in signals and entry.publish_check == "fail":
|
||||||
|
_append_gap(gaps, seen, entry.slug, root, "publish_check_fail")
|
||||||
|
|
||||||
|
if "stale_scope" in signals and _scope_is_stale(root):
|
||||||
|
_append_gap(gaps, seen, entry.slug, root, "stale_scope")
|
||||||
|
|
||||||
|
if "stale_sbom" in signals and _sbom_is_stale(entry.slug):
|
||||||
|
_append_gap(gaps, seen, entry.slug, root, "stale_sbom")
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
|
||||||
|
|
||||||
|
def _append_gap(
|
||||||
|
gaps: list[dict[str, Any]],
|
||||||
|
seen: set[tuple[str, str]],
|
||||||
|
slug: str,
|
||||||
|
root: Path,
|
||||||
|
signal: str,
|
||||||
|
) -> None:
|
||||||
|
key = (slug, signal)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
gaps.append(
|
||||||
|
{
|
||||||
|
"repo": slug,
|
||||||
|
"root": str(root),
|
||||||
|
"signal": signal,
|
||||||
|
"hygiene_signal": signal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_is_stale(root: Path) -> bool:
|
||||||
|
scope = root / "SCOPE.md"
|
||||||
|
if not scope.is_file():
|
||||||
|
return True
|
||||||
|
age_seconds = datetime.now(timezone.utc).timestamp() - scope.stat().st_mtime
|
||||||
|
return age_seconds > 90 * 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def _sbom_is_stale(slug: str) -> bool:
|
||||||
|
payload = StateHubContextResolver().resolve(
|
||||||
|
"repo_sbom_status",
|
||||||
|
None,
|
||||||
|
{"repo_slug": slug},
|
||||||
|
)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(payload.get("sbom_age_days", 0)) > 30
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_set(report: dict[str, Any], keys: set[str]) -> set[str]:
|
||||||
|
slugs: set[str] = set()
|
||||||
|
for value in _values_for_keys(report, keys):
|
||||||
|
slugs.update(_slugs_from_value(value))
|
||||||
|
return slugs
|
||||||
|
|
||||||
|
|
||||||
|
def _values_for_keys(value: Any, keys: set[str]) -> list[Any]:
|
||||||
|
values: list[Any] = []
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, nested in value.items():
|
||||||
|
if key in keys:
|
||||||
|
values.append(nested)
|
||||||
|
values.extend(_values_for_keys(nested, keys))
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
values.extend(_values_for_keys(item, keys))
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _slugs_from_value(value: Any) -> set[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return {value}
|
||||||
|
if isinstance(value, list):
|
||||||
|
slugs: set[str] = set()
|
||||||
|
for item in value:
|
||||||
|
slugs.update(_slugs_from_value(item))
|
||||||
|
return slugs
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key in ("repo", "repo_slug", "slug", "name"):
|
||||||
|
if value.get(key):
|
||||||
|
return {str(value[key])}
|
||||||
|
slugs: set[str] = set()
|
||||||
|
for key, nested in value.items():
|
||||||
|
if nested is True or isinstance(nested, (dict, list)):
|
||||||
|
slugs.add(str(key))
|
||||||
|
slugs.update(_slugs_from_value(nested))
|
||||||
|
return slugs
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
class ReuseSurfaceContextResolver(ContextResolver):
|
||||||
|
"""Resolves reuse-surface registry hygiene gap reports."""
|
||||||
|
|
||||||
|
def resolve(self, query: str, event: Any, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if query == "reuse_surface_report_gaps":
|
||||||
|
return reuse_surface_report_gaps(params)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class ShellContextResolver(ContextResolver):
|
||||||
|
"""Dispatch shell-backed context queries without breaking kaizen aliases."""
|
||||||
|
|
||||||
|
def resolve(self, query: str, event: Any, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if query == "reuse_surface_report_gaps":
|
||||||
|
return reuse_surface_report_gaps(params)
|
||||||
|
return KaizenContextResolver().resolve(query, event, params)
|
||||||
|
|
||||||
|
|
||||||
|
CONTEXT_RESOLVER_REGISTRY["reuse-surface"] = ReuseSurfaceContextResolver
|
||||||
|
CONTEXT_RESOLVER_REGISTRY["shell"] = ShellContextResolver
|
||||||
@@ -88,6 +88,43 @@ def test_for_each_binds_each_list_item_before_condition_and_action_rendering() -
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_for_each_can_gate_registry_hygiene_gaps_on_signal() -> None:
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
"id": "flag-registry-hygiene-gap",
|
||||||
|
"for_each": "context.gaps",
|
||||||
|
"bind_as": "g",
|
||||||
|
"condition": 'context.g.hygiene_signal != ""',
|
||||||
|
"action": {
|
||||||
|
"task_template": "Close registry hygiene gap for {context.g.repo}",
|
||||||
|
"target_repo": "context.g.repo",
|
||||||
|
"priority": "medium",
|
||||||
|
"labels": ["registry-hygiene", "{context.g.hygiene_signal}"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
context = {
|
||||||
|
"gaps": [
|
||||||
|
{
|
||||||
|
"repo": "reuse-surface",
|
||||||
|
"hygiene_signal": "empty_capability_scaffold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"repo": "activity-core",
|
||||||
|
"hygiene_signal": "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
specs = expand_rule_actions(rules, _Event(), context)
|
||||||
|
|
||||||
|
assert [spec["target_repo"] for spec in specs] == ["reuse-surface"]
|
||||||
|
assert specs[0]["labels"] == [
|
||||||
|
"registry-hygiene",
|
||||||
|
"empty_capability_scaffold",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_for_each_rejects_non_path_expression() -> None:
|
def test_for_each_rejects_non_path_expression() -> None:
|
||||||
rules = [
|
rules = [
|
||||||
{
|
{
|
||||||
|
|||||||
167
tests/test_reuse_surface_context_resolver.py
Normal file
167
tests/test_reuse_surface_context_resolver.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from temporalio.exceptions import ApplicationError
|
||||||
|
|
||||||
|
from activity_core.activities import resolve_context
|
||||||
|
from activity_core.context_resolvers import reuse_surface
|
||||||
|
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
def __init__(self, payload: Any) -> None:
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self) -> Any:
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class _Completed:
|
||||||
|
returncode = 0
|
||||||
|
stderr = ""
|
||||||
|
|
||||||
|
def __init__(self, payload: dict[str, Any]) -> None:
|
||||||
|
self.stdout = json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_rollout(path: Path) -> None:
|
||||||
|
path.write_text(
|
||||||
|
"""
|
||||||
|
domains:
|
||||||
|
reuse:
|
||||||
|
phase: active
|
||||||
|
repos:
|
||||||
|
- reuse-surface
|
||||||
|
- activity-core
|
||||||
|
parked:
|
||||||
|
phase: backlog
|
||||||
|
repos:
|
||||||
|
- ignored-repo
|
||||||
|
""".lstrip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cli_only_signals(path: Path) -> None:
|
||||||
|
path.write_text(
|
||||||
|
"""
|
||||||
|
signals:
|
||||||
|
empty_capability_scaffold:
|
||||||
|
enabled: true
|
||||||
|
registry_gap:
|
||||||
|
enabled: false
|
||||||
|
stale_scope:
|
||||||
|
enabled: false
|
||||||
|
stale_sbom:
|
||||||
|
enabled: false
|
||||||
|
publish_check_fail:
|
||||||
|
enabled: false
|
||||||
|
""".lstrip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_resolver_emits_reuse_surface_gaps_and_advances_cursor(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
rollout = tmp_path / "rollout.yaml"
|
||||||
|
_write_rollout(rollout)
|
||||||
|
_write_cli_only_signals(tmp_path / "signals.yml")
|
||||||
|
reuse_root = tmp_path / "reuse-surface"
|
||||||
|
reuse_root.mkdir()
|
||||||
|
(reuse_root / "SCOPE.md").write_text("fresh\n", encoding="utf-8")
|
||||||
|
activity_root = tmp_path / "activity-core"
|
||||||
|
activity_root.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setenv("KAIZEN_RUNNER_HOST", "runner")
|
||||||
|
|
||||||
|
def fake_get(url: str, **kwargs: Any) -> _Response:
|
||||||
|
assert url.endswith("/repos/")
|
||||||
|
return _Response(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"slug": "reuse-surface",
|
||||||
|
"host_paths": {"runner": str(reuse_root)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "activity-core",
|
||||||
|
"host_paths": {"runner": str(activity_root)},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_run(cmd: list[str], **kwargs: Any) -> _Completed:
|
||||||
|
assert cmd == ["reuse-surface", "report", "gaps", "--format", "json"]
|
||||||
|
return _Completed({"empty_scaffolds": ["reuse-surface"]})
|
||||||
|
|
||||||
|
monkeypatch.setattr(reuse_surface.httpx, "get", fake_get)
|
||||||
|
monkeypatch.setattr(reuse_surface.subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
import activity_core.context_resolvers # noqa: F401
|
||||||
|
|
||||||
|
result = CONTEXT_RESOLVER_REGISTRY["shell"]().resolve(
|
||||||
|
"reuse_surface_report_gaps",
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"roster": str(rollout),
|
||||||
|
"batch_size": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"gaps": [
|
||||||
|
{
|
||||||
|
"repo": "reuse-surface",
|
||||||
|
"root": str(reuse_root),
|
||||||
|
"signal": "empty_capability_scaffold",
|
||||||
|
"hygiene_signal": "empty_capability_scaffold",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
state = json.loads((tmp_path / "round-robin-state.json").read_text(encoding="utf-8"))
|
||||||
|
assert state["cursor"] == 1
|
||||||
|
assert state["last_batch"] == ["reuse-surface"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_resolver_keeps_kaizen_fallback_for_existing_queries() -> None:
|
||||||
|
assert CONTEXT_RESOLVER_REGISTRY["shell"]().resolve("unknown_query", None, {}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_optional_reuse_surface_missing_roster_binds_empty_list(tmp_path) -> None:
|
||||||
|
snapshot = await resolve_context(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "shell",
|
||||||
|
"query": "reuse_surface_report_gaps",
|
||||||
|
"params": {"roster": str(tmp_path / "missing.yaml")},
|
||||||
|
"bind_to": "context.gaps",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot == {"gaps": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_required_reuse_surface_missing_roster_fails_visibly(tmp_path) -> None:
|
||||||
|
with pytest.raises(ApplicationError, match="Required context resolver"):
|
||||||
|
await resolve_context(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "shell",
|
||||||
|
"query": "reuse_surface_report_gaps",
|
||||||
|
"params": {"roster": str(tmp_path / "missing.yaml")},
|
||||||
|
"bind_to": "context.gaps",
|
||||||
|
"required": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
id: ACTIVITY-WP-0013
|
||||||
|
type: workplan
|
||||||
|
title: "Reuse Surface Report Gaps Resolver"
|
||||||
|
domain: custodian
|
||||||
|
repo: activity-core
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: activity-core
|
||||||
|
created: "2026-06-18"
|
||||||
|
updated: "2026-06-18"
|
||||||
|
state_hub_workstream_id: "01e68dfd-b146-4aef-a575-2d3b178ca5c2"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Reuse Surface Report Gaps Resolver
|
||||||
|
|
||||||
|
Implement the R2 handoff from kaizen-agentic (`bffa224c`) so the
|
||||||
|
`reuse_surface_report_gaps` shell context source populates
|
||||||
|
`context.gaps` for the Coulomb daily registry hygiene sweep.
|
||||||
|
|
||||||
|
## Register Shell Resolver Query
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ACTIVITY-WP-0013-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "a6e1fc5c-7b42-436d-914e-4d605cb6f329"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a dedicated reuse-surface context resolver module and register
|
||||||
|
`reuse_surface_report_gaps` on the `shell` resolver path while preserving
|
||||||
|
the existing kaizen shell query behavior.
|
||||||
|
|
||||||
|
## Implement Batch And Signal Semantics
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ACTIVITY-WP-0013-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "229cf285-8388-471d-95fd-08400db1553e"
|
||||||
|
```
|
||||||
|
|
||||||
|
Load the Coulomb rollout roster, select active repos with a persisted
|
||||||
|
round-robin cursor, resolve repo roots from State Hub host paths, run
|
||||||
|
`reuse-surface report gaps --format json`, and emit gap records for the
|
||||||
|
enabled registry hygiene signals.
|
||||||
|
|
||||||
|
## Cover Required And Optional Failure Modes
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ACTIVITY-WP-0013-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "85b5c7d4-40e1-4945-8ada-1dff2363c194"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure missing required dependencies fail visibly while optional resolver
|
||||||
|
sources bind an empty `context.gaps` list. Add unit coverage for fixture
|
||||||
|
rollout data, mocked CLI JSON, resolver binding, and `hygiene_signal`
|
||||||
|
rule gating.
|
||||||
|
|
||||||
|
## Smoke Real Coulomb Rollout
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: ACTIVITY-WP-0013-T04
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "6a5446ed-b4ec-4693-b508-65415571d834"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a live resolver smoke against
|
||||||
|
`/home/worsch/coulomb-loop/loops/registry-hygiene/rollout.yaml` using a
|
||||||
|
temporary round-robin cursor. The real active rollout produced five gaps,
|
||||||
|
including one for `reuse-surface` with `hygiene_signal: stale_sbom`.
|
||||||
|
The smoke supplied `reuse_surface_bin:
|
||||||
|
/home/worsch/reuse-surface/.venv/bin/reuse-surface` and
|
||||||
|
`runner_host: bnt-lap001`; the worker environment or definition params must
|
||||||
|
provide equivalent values before enabling the production sweep.
|
||||||
Reference in New Issue
Block a user