generated from coulomb/repo-seed
Add kaizen context resolver for scheduled agent fleet discovery.
Implement discover_kaizen_scheduled_repos and discover_kaizen_projects per kaizen-agentic ADR-005 contract: State Hub roster, roster.yaml filter, schedule validation, and prepare_command emission. Register kaizen/resolver/shell source types with unit tests and runbook dry-run instructions.
This commit is contained in:
@@ -1 +1 @@
|
||||
from activity_core.context_resolvers import ops_inventory, repo_scoping, state_hub # noqa: F401
|
||||
from activity_core.context_resolvers import kaizen, ops_inventory, repo_scoping, state_hub # noqa: F401
|
||||
|
||||
305
src/activity_core/context_resolvers/kaizen.py
Normal file
305
src/activity_core/context_resolvers/kaizen.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Kaizen-agentic fleet context adapter.
|
||||
|
||||
Registered as source types ``kaizen`` and ``resolver`` (alias for ADR-005 drafts).
|
||||
|
||||
Supported queries:
|
||||
- discover_kaizen_scheduled_repos: hub roster ∩ valid ``.kaizen/schedule.yml``
|
||||
- discover_kaizen_projects: repos with ``.kaizen/metrics`` marker (+ optional roster)
|
||||
|
||||
Contract: kaizen-agentic ``docs/integrations/discover-kaizen-scheduled-repos.md``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_STATE_HUB_URL = "http://127.0.0.1:8000"
|
||||
_TIMEOUT_SECONDS = 10.0
|
||||
_SCHEDULE_VERSION = "1"
|
||||
_VALID_CADENCES = frozenset({"daily", "weekly", "monthly"})
|
||||
_PREPARE_BIN = os.environ.get("KAIZEN_AGENTIC_BIN", "kaizen-agentic")
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return os.environ.get("STATE_HUB_URL", _DEFAULT_STATE_HUB_URL).rstrip("/")
|
||||
|
||||
|
||||
def _runner_host() -> str:
|
||||
return os.environ.get("KAIZEN_RUNNER_HOST", socket.gethostname())
|
||||
|
||||
|
||||
def _fetch_repos(domain: str | None) -> list[dict[str, Any]]:
|
||||
url = f"{_base_url()}/repos/"
|
||||
try:
|
||||
resp = httpx.get(url, timeout=_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}")
|
||||
if domain:
|
||||
payload = [r for r in payload if r.get("domain_slug") == domain]
|
||||
return payload
|
||||
|
||||
|
||||
def _repo_root(repo: dict[str, Any]) -> Path | None:
|
||||
host_paths = repo.get("host_paths") or {}
|
||||
host = _runner_host()
|
||||
raw = host_paths.get(host) or repo.get("local_path")
|
||||
if not raw or raw == "(unknown)":
|
||||
return None
|
||||
path = Path(raw)
|
||||
return path if path.is_dir() else None
|
||||
|
||||
|
||||
def _load_roster(params: dict[str, Any]) -> dict[str, dict[str, Any]] | None:
|
||||
"""Return slug -> roster entry for active repos, or None if no roster param."""
|
||||
roster_path = params.get("roster")
|
||||
if not roster_path:
|
||||
return None
|
||||
path = Path(roster_path)
|
||||
if not path.is_file():
|
||||
logger.warning("kaizen roster file not found: %s", path)
|
||||
return {}
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("kaizen roster invalid (not a mapping): %s", path)
|
||||
return {}
|
||||
entries: dict[str, dict[str, Any]] = {}
|
||||
for item in data.get("active") or []:
|
||||
if isinstance(item, dict) and item.get("slug"):
|
||||
slug = str(item["slug"])
|
||||
if item.get("status", "active") == "saturated":
|
||||
continue
|
||||
entries[slug] = item
|
||||
return entries
|
||||
|
||||
|
||||
def _validate_schedule_file(path: Path) -> list[str]:
|
||||
"""Structural validation aligned with kaizen-agentic schedule validate."""
|
||||
errors: list[str] = []
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as exc:
|
||||
return [f"invalid YAML: {exc}"]
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return ["schedule.yml must be a YAML mapping at the top level"]
|
||||
|
||||
version = raw.get("version")
|
||||
if version is None:
|
||||
errors.append("missing required key: version")
|
||||
elif str(version) != _SCHEDULE_VERSION:
|
||||
errors.append(f"unsupported version '{version}' (expected '{_SCHEDULE_VERSION}')")
|
||||
|
||||
agents = raw.get("agents", {})
|
||||
if not isinstance(agents, dict):
|
||||
errors.append("agents must be a mapping")
|
||||
return errors
|
||||
if not agents:
|
||||
errors.append("no agents declared under 'agents:'")
|
||||
|
||||
seen: set[str] = set()
|
||||
for name, settings in agents.items():
|
||||
if settings is None:
|
||||
settings = {}
|
||||
if not isinstance(settings, dict):
|
||||
errors.append(f"agent '{name}' settings must be a mapping")
|
||||
continue
|
||||
if name in seen:
|
||||
errors.append(f"duplicate agent entry: {name}")
|
||||
seen.add(name)
|
||||
cadence = str(settings.get("cadence", ""))
|
||||
if cadence not in _VALID_CADENCES:
|
||||
errors.append(
|
||||
f"agent '{name}': invalid cadence '{cadence}' "
|
||||
f"(expected one of {', '.join(sorted(_VALID_CADENCES))})"
|
||||
)
|
||||
cron = settings.get("cron")
|
||||
if cron is not None and not isinstance(cron, str):
|
||||
errors.append(f"agent '{name}' cron must be a string")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _parse_schedule(path: Path) -> dict[str, Any] | None:
|
||||
errors = _validate_schedule_file(path)
|
||||
if errors:
|
||||
return None
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
return raw if isinstance(raw, dict) else None
|
||||
|
||||
|
||||
def _prepare_command(agent: str, root: Path) -> str:
|
||||
return f"{_PREPARE_BIN} schedule prepare {agent} --target {root}"
|
||||
|
||||
|
||||
def discover_kaizen_scheduled_repos(params: dict[str, Any]) -> dict[str, Any]:
|
||||
domain = params.get("domain")
|
||||
cadence_filter = params.get("cadence")
|
||||
roster = _load_roster(params)
|
||||
runs: list[dict[str, Any]] = []
|
||||
|
||||
for repo in _fetch_repos(domain):
|
||||
slug = repo.get("slug", "")
|
||||
if not slug:
|
||||
continue
|
||||
if roster is not None and slug not in roster:
|
||||
continue
|
||||
|
||||
root = _repo_root(repo)
|
||||
if root is None:
|
||||
logger.info("kaizen repo_unreachable slug=%s host=%s", slug, _runner_host())
|
||||
continue
|
||||
|
||||
schedule_path = root / ".kaizen" / "schedule.yml"
|
||||
if not schedule_path.is_file():
|
||||
continue
|
||||
|
||||
errors = _validate_schedule_file(schedule_path)
|
||||
if errors:
|
||||
logger.warning(
|
||||
"kaizen schedule_invalid slug=%s path=%s errors=%s",
|
||||
slug,
|
||||
schedule_path,
|
||||
"; ".join(errors),
|
||||
)
|
||||
continue
|
||||
|
||||
schedule = _parse_schedule(schedule_path)
|
||||
if schedule is None:
|
||||
continue
|
||||
|
||||
timezone = schedule.get("timezone") or "Europe/Berlin"
|
||||
roster_agents = roster.get(slug, {}).get("agents") if roster else None
|
||||
agents = schedule.get("agents") or {}
|
||||
|
||||
for agent_name, settings in agents.items():
|
||||
if not isinstance(settings, dict):
|
||||
continue
|
||||
if not bool(settings.get("enabled", True)):
|
||||
continue
|
||||
cadence = str(settings.get("cadence", ""))
|
||||
if cadence_filter and cadence != cadence_filter:
|
||||
continue
|
||||
if roster_agents and agent_name not in roster_agents:
|
||||
continue
|
||||
cron = settings.get("cron")
|
||||
runs.append(
|
||||
{
|
||||
"repo": slug,
|
||||
"root": str(root),
|
||||
"agent": agent_name,
|
||||
"cadence": cadence,
|
||||
"cron": cron,
|
||||
"timezone": timezone,
|
||||
"enabled": True,
|
||||
"prepare_command": _prepare_command(agent_name, root),
|
||||
}
|
||||
)
|
||||
|
||||
return {"scheduled_runs": runs}
|
||||
|
||||
|
||||
def _read_metrics_summary(metrics_dir: Path) -> dict[str, Any]:
|
||||
summary_path = metrics_dir / "summary.json"
|
||||
if not summary_path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def discover_kaizen_projects(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Discover repos with ``.kaizen/metrics`` (optional per-agent summaries)."""
|
||||
domain = params.get("domain")
|
||||
marker = params.get("marker", ".kaizen/metrics")
|
||||
roster = _load_roster(params)
|
||||
in_roster_key = "in_pilot_roster"
|
||||
projects: list[dict[str, Any]] = []
|
||||
|
||||
for repo in _fetch_repos(domain):
|
||||
slug = repo.get("slug", "")
|
||||
if not slug:
|
||||
continue
|
||||
in_pilot = roster is None or slug in roster
|
||||
if roster is not None and slug not in roster:
|
||||
continue
|
||||
|
||||
root = _repo_root(repo)
|
||||
if root is None:
|
||||
continue
|
||||
|
||||
metrics_root = root / Path(marker)
|
||||
if not metrics_root.is_dir():
|
||||
continue
|
||||
|
||||
has_metrics = any(metrics_root.iterdir()) if metrics_root.is_dir() else False
|
||||
if not has_metrics:
|
||||
continue
|
||||
|
||||
roster_entry = roster.get(slug, {}) if roster else {}
|
||||
agent_filter = roster_entry.get("agents")
|
||||
|
||||
for agent_dir in sorted(metrics_root.iterdir()):
|
||||
if not agent_dir.is_dir() or agent_dir.name == "optimizer":
|
||||
continue
|
||||
agent = agent_dir.name
|
||||
if agent_filter and agent not in agent_filter:
|
||||
continue
|
||||
summary = _read_metrics_summary(agent_dir)
|
||||
projects.append(
|
||||
{
|
||||
"repo": slug,
|
||||
"root": str(root),
|
||||
"agent": agent,
|
||||
"has_metrics": True,
|
||||
in_roster_key: in_pilot,
|
||||
"summary": summary,
|
||||
}
|
||||
)
|
||||
|
||||
if not any(p["repo"] == slug for p in projects):
|
||||
projects.append(
|
||||
{
|
||||
"repo": slug,
|
||||
"root": str(root),
|
||||
"agent": None,
|
||||
"has_metrics": has_metrics,
|
||||
in_roster_key: in_pilot,
|
||||
"summary": {},
|
||||
}
|
||||
)
|
||||
|
||||
return {"projects": projects}
|
||||
|
||||
|
||||
class KaizenContextResolver(ContextResolver):
|
||||
"""Resolves kaizen fleet scheduling and project metrics discovery."""
|
||||
|
||||
def resolve(self, query: str, event: Any, params: dict[str, Any]) -> dict[str, Any]:
|
||||
if query == "discover_kaizen_scheduled_repos":
|
||||
return discover_kaizen_scheduled_repos(params)
|
||||
if query == "discover_kaizen_projects":
|
||||
return discover_kaizen_projects(params)
|
||||
return {}
|
||||
|
||||
|
||||
CONTEXT_RESOLVER_REGISTRY["kaizen"] = KaizenContextResolver
|
||||
CONTEXT_RESOLVER_REGISTRY["resolver"] = KaizenContextResolver
|
||||
CONTEXT_RESOLVER_REGISTRY["shell"] = KaizenContextResolver
|
||||
Reference in New Issue
Block a user