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:
2026-06-18 07:46:46 +02:00
parent 29bf87a44c
commit 517bf9c133
6 changed files with 560 additions and 5 deletions

View File

@@ -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

View 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