From 2f5516721588b78eb5550141da8213756a6a721b Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 2 Jul 2026 02:15:39 +0200 Subject: [PATCH] Add automation inventory surface --- Makefile | 12 +- docs/runbook.md | 30 ++ scripts/automation_inventory.py | 8 + src/activity_core/automation_status.py | 298 +++++++++++++++++- tests/test_automation_status.py | 105 ++++++ ...9-automation-schedule-inventory-targets.md | 54 +++- 6 files changed, 498 insertions(+), 9 deletions(-) create mode 100644 scripts/automation_inventory.py diff --git a/Makefile b/Makefile index 756ae72..b8ca422 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ export .PHONY: sync-event-types sync-activity-definitions sync-schedules test migrate sync-all \ - automation-status automation-status-json \ + automation-status automation-status-json automation-list automation-list-json \ dev-up dev-down railiance-up railiance-down \ start-worker start-api start-event-router help @@ -29,6 +29,10 @@ sync-all: sync-event-types sync-activity-definitions ## Sync event types and ac SINCE ?= today FORMAT ?= human +ENABLED ?= all +TRIGGER ?= +ACTIVITY_ID ?= +ACTIVITY_NAME ?= automation-status: ## Report recent automation status from repo-owned evidence uv run python scripts/automation_status.py --since "$(SINCE)" $(if $(UNTIL),--until "$(UNTIL)",) --format "$(FORMAT)" @@ -36,6 +40,12 @@ automation-status: ## Report recent automation status from repo-owned evidence automation-status-json: ## Report recent automation status as JSON $(MAKE) automation-status FORMAT=json +automation-list: ## List configured scheduled automations from repo-owned definitions + @uv run python scripts/automation_inventory.py --format "$(FORMAT)" --enabled "$(ENABLED)" $(if $(TRIGGER),--trigger-type "$(TRIGGER)",) $(if $(ACTIVITY_ID),--activity-id "$(ACTIVITY_ID)",) $(if $(ACTIVITY_NAME),--activity-name "$(ACTIVITY_NAME)",) + +automation-list-json: ## List configured scheduled automations as JSON + @$(MAKE) --no-print-directory automation-list FORMAT=json + # ── Infrastructure ───────────────────────────────────────────────────────────── dev-up: ## Start full dev stack (Temporal + PG + ES + NATS) diff --git a/docs/runbook.md b/docs/runbook.md index 7ffae30..5353a05 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -136,6 +136,36 @@ The response reports: - `schedules.deleted_orphans` - bounded `errors[]` +## Automation inventory + +Use the repo-native inventory command to answer "what automations are scheduled +at all?" before checking whether a recent window succeeded. The command is +read-only: it loads ActivityDefinition rows or files and, when `TEMPORAL_HOST` +is configured, describes Temporal schedules for visibility. It does not sync, +upsert, pause, delete, or enqueue schedules. + +```bash +# Human-readable configured automation inventory. +make automation-list + +# JSON for scripts or assistant summarization. +make automation-list-json + +# Common filters. +make automation-list ENABLED=true TRIGGER=cron +make automation-list ACTIVITY_ID=6fca51fa-387a-4fd0-bc4e-d62c29eb859a +``` + +Inventory answers what is configured; `make automation-status` answers what +happened in a time window. Missing optional live sources are warnings, not +silent omissions, so a degraded local run still lists repo definition files. + +Compact human output looks like: + +```text +- Daily State Hub WSJF Triage [enabled cron] schedule=activity-schedule-... trigger=20 7 * * * tz=Europe/Berlin source=files temporal=not_checked +``` + ## Automation status Use the repo-native status command to answer operator questions such as "how did diff --git a/scripts/automation_inventory.py b/scripts/automation_inventory.py new file mode 100644 index 0000000..40ce46c --- /dev/null +++ b/scripts/automation_inventory.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""CLI wrapper for the repo-native automation inventory report.""" + +from activity_core.automation_status import inventory_main + + +if __name__ == "__main__": + raise SystemExit(inventory_main()) diff --git a/src/activity_core/automation_status.py b/src/activity_core/automation_status.py index 8bcf05d..9462a31 100644 --- a/src/activity_core/automation_status.py +++ b/src/activity_core/automation_status.py @@ -1,4 +1,4 @@ -"""Repo-native automation status reporting without LLM calls.""" +"""Repo-native automation status and inventory reporting without LLM calls.""" from __future__ import annotations @@ -789,6 +789,302 @@ def shorten(value: str | None, limit: int = 240) -> str | None: return value if len(value) <= limit else value[: limit - 3] + "..." +def parse_inventory_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="List configured activity-core scheduled automations without LLMs." + ) + parser.add_argument("--activity-id", action="append", default=_env_list("ACTIVITY_ID")) + parser.add_argument("--activity-name", action="append", default=_env_list("ACTIVITY_NAME")) + parser.add_argument( + "--enabled", + default=os.environ.get("ENABLED", "all"), + help="Filter by enabled state: all, true/enabled, or false/disabled.", + ) + parser.add_argument( + "--trigger-type", + "--trigger", + dest="trigger_type", + action="append", + default=_env_list("TRIGGER"), + help="Filter by trigger type: cron or scheduled. Repeatable or comma-separated.", + ) + parser.add_argument("--db-url", default=os.environ.get("ACTCORE_DB_URL")) + parser.add_argument("--temporal-host", default=os.environ.get("TEMPORAL_HOST")) + parser.add_argument( + "--temporal-namespace", + default=os.environ.get("TEMPORAL_NAMESPACE", DEFAULT_TEMPORAL_NAMESPACE), + ) + parser.add_argument( + "--timeout-seconds", + type=float, + default=float(os.environ.get( + "AUTOMATION_INVENTORY_TIMEOUT_SECONDS", + os.environ.get("AUTOMATION_STATUS_TIMEOUT_SECONDS", "5"), + )), + ) + parser.add_argument("--format", choices=("human", "json"), default=os.environ.get("FORMAT", "human")) + return parser.parse_args(argv) + + +def _env_list(name: str) -> list[str]: + raw = os.environ.get(name, "") + return [item.strip() for item in raw.split(",") if item.strip()] + + +def parse_enabled_filter(value: str | None) -> bool | None: + if value is None or not str(value).strip() or str(value).strip().lower() == "all": + return None + lowered = str(value).strip().lower() + if lowered in {"true", "yes", "1", "enabled"}: + return True + if lowered in {"false", "no", "0", "disabled"}: + return False + raise ValueError("enabled filter must be all, true/enabled, or false/disabled") + + +def normalize_trigger_filters(values: list[str] | None) -> set[str]: + result: set[str] = set() + for value in values or []: + for item in str(value).split(","): + token = item.strip().lower() + if not token or token == "all": + continue + if token not in {"cron", "scheduled"}: + raise ValueError("trigger filter must be cron or scheduled") + result.add(token) + return result + + +def filter_inventory_definitions( + definitions: list[dict[str, Any]], + ids: list[str], + names: list[str], + enabled: bool | None, + trigger_types: set[str], +) -> list[dict[str, Any]]: + selected = filter_definitions(definitions, ids, names) + if enabled is not None: + selected = [item for item in selected if bool(item.get("enabled")) is enabled] + if trigger_types: + selected = [item for item in selected if str(item.get("trigger_type") or "").lower() in trigger_types] + return selected + + +def inventory_row(definition: dict[str, Any], temporal: dict[str, Any] | None) -> dict[str, Any]: + trigger = public_trigger_config(definition.get("trigger_config", {})) + row = { + "id": definition["id"], + "name": definition["name"], + "enabled": bool(definition.get("enabled")), + "trigger_type": definition["trigger_type"], + "schedule_id": automation_schedule_id(definition), + "definition_source": definition.get("source"), + "sources": inventory_sources(definition, temporal), + "trigger": trigger, + "cron_expression": trigger.get("cron_expression"), + "at": trigger.get("at"), + "timezone": trigger.get("timezone"), + "misfire_policy": trigger.get("misfire_policy"), + "catchup_window_seconds": trigger.get("catchup_window_seconds"), + "jitter_seconds": trigger.get("jitter_seconds"), + "temporal": inventory_temporal_state(definition, temporal), + "drift_hints": inventory_drift_hints(definition, temporal), + } + return row + + +def inventory_sources(definition: dict[str, Any], temporal: dict[str, Any] | None) -> list[str]: + sources = [str(definition.get("source") or "definition")] + if temporal is None: + sources.append("temporal:not_checked") + elif temporal.get("available"): + sources.append("temporal") + else: + sources.append("temporal:unavailable") + return sources + + +def inventory_temporal_state(definition: dict[str, Any], temporal: dict[str, Any] | None) -> dict[str, Any]: + expected_schedule_id = automation_schedule_id(definition) + if temporal is None: + return {"status": "not_checked", "schedule_id": expected_schedule_id} + if not temporal.get("available"): + return { + "status": "missing_or_unavailable", + "schedule_id": temporal.get("schedule_id") or expected_schedule_id, + "warning": temporal.get("warning"), + } + paused = bool(temporal.get("paused")) + return { + "status": "paused" if paused else "active", + "schedule_id": temporal.get("schedule_id") or expected_schedule_id, + "paused": paused, + "last_fired_at": temporal.get("last_fired_at"), + "missed_catchup_window": int(temporal.get("missed_catchup_window") or 0), + } + + +def inventory_drift_hints(definition: dict[str, Any], temporal: dict[str, Any] | None) -> list[str]: + if temporal is None: + return [] + hints: list[str] = [] + expected_schedule_id = automation_schedule_id(definition) + observed_schedule_id = temporal.get("schedule_id") + if observed_schedule_id and observed_schedule_id != expected_schedule_id: + hints.append("temporal_schedule_id_mismatch") + if not temporal.get("available"): + hints.append("temporal_schedule_missing_or_unavailable") + return hints + paused = bool(temporal.get("paused")) + enabled = bool(definition.get("enabled")) + if enabled and paused: + hints.append("temporal_paused_but_definition_enabled") + if not enabled and not paused: + hints.append("temporal_active_but_definition_disabled") + if int(temporal.get("missed_catchup_window") or 0) > 0: + hints.append("temporal_missed_catchup_window") + return hints + + +async def build_inventory_report(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + timeout = max(float(args.timeout_seconds), 0.1) + warnings: list[str] = [] + sources: dict[str, dict[str, Any]] = {} + + try: + definitions, sources["definitions"] = await asyncio.wait_for( + load_definitions(args, warnings), + timeout=timeout, + ) + except asyncio.TimeoutError: + warning = "definition DB timed out; using file definitions" + warnings.append(warning) + definitions = file_definitions() + sources["definitions"] = {"status": "degraded", "source": "files", "warning": warning} + + enabled = parse_enabled_filter(args.enabled) + trigger_types = normalize_trigger_filters(args.trigger_type) + definitions = filter_inventory_definitions( + definitions, + args.activity_id, + args.activity_name, + enabled, + trigger_types, + ) + + temporal_by_activity, sources["temporal"] = await load_temporal_visibility( + args.temporal_host, + args.temporal_namespace, + definitions, + timeout_seconds=timeout, + ) + for source in sources.values(): + if source.get("status") not in {"ok", "skipped"} and source.get("warning"): + warnings.append(str(source["warning"])) + + automations = [ + inventory_row(definition, temporal_by_activity.get(definition["id"])) + for definition in definitions + ] + report = { + "mode": "automation-inventory", + "generated_at": datetime.now(tz=timezone.utc).isoformat(), + "sources": sources, + "summary": summarize_inventory(automations), + "automations": automations, + "filters": { + "activity_id": args.activity_id, + "activity_name": args.activity_name, + "enabled": args.enabled, + "trigger_type": sorted(trigger_types), + }, + "warnings": sorted(set(warnings)), + } + return report, 0 + + +def summarize_inventory(automations: list[dict[str, Any]]) -> dict[str, Any]: + trigger_counts: dict[str, int] = {} + temporal_counts: dict[str, int] = {} + for item in automations: + trigger = str(item.get("trigger_type") or "unknown") + trigger_counts[trigger] = trigger_counts.get(trigger, 0) + 1 + temporal_status = str((item.get("temporal") or {}).get("status") or "unknown") + temporal_counts[temporal_status] = temporal_counts.get(temporal_status, 0) + 1 + return { + "automation_count": len(automations), + "enabled_count": sum(1 for item in automations if item.get("enabled")), + "disabled_count": sum(1 for item in automations if not item.get("enabled")), + "trigger_counts": trigger_counts, + "temporal_status_counts": temporal_counts, + "drift_count": sum(1 for item in automations if item.get("drift_hints")), + } + + +def render_inventory_human(report: dict[str, Any]) -> str: + lines = [ + f"Automation inventory generated_at={report['generated_at']}", + render_source_line(report["sources"]), + render_inventory_summary_line(report["summary"]), + "", + ] + for item in report["automations"]: + state = "enabled" if item["enabled"] else "disabled" + trigger_value = item.get("cron_expression") or item.get("at") or "-" + temporal = item.get("temporal") or {} + line = ( + f"- {item['name']} [{state} {item['trigger_type']}] " + f"schedule={item['schedule_id']} trigger={trigger_value} " + f"tz={item.get('timezone') or '-'} source={item.get('definition_source') or '-'} " + f"temporal={temporal.get('status') or 'unknown'}" + ) + lines.append(line) + if item.get("misfire_policy") or item.get("catchup_window_seconds") is not None: + lines.append( + " policy=" + f"{item.get('misfire_policy') or '-'} catchup={item.get('catchup_window_seconds') if item.get('catchup_window_seconds') is not None else '-'}" + ) + if item.get("drift_hints"): + lines.append(" drift: " + ", ".join(item["drift_hints"])) + if temporal.get("warning"): + lines.append(f" temporal warning: {shorten(str(temporal['warning']))}") + if report["warnings"]: + lines.extend(["", "Warnings:"]) + lines.extend(f"- {warning}" for warning in report["warnings"]) + return "\n".join(lines) + + +def render_inventory_summary_line(summary: dict[str, Any]) -> str: + trigger_counts = summary.get("trigger_counts") or {} + trigger_text = ", ".join(f"{key}={value}" for key, value in sorted(trigger_counts.items())) or "none" + return ( + "Summary: " + f"total={summary.get('automation_count', 0)} " + f"enabled={summary.get('enabled_count', 0)} " + f"disabled={summary.get('disabled_count', 0)} " + f"drift={summary.get('drift_count', 0)} " + f"triggers=({trigger_text})" + ) + + +async def async_inventory_main(argv: list[str] | None = None) -> int: + args = parse_inventory_args(argv) + try: + report, exit_code = await build_inventory_report(args) + except ValueError as exc: + print(f"automation_inventory: {exc}", file=sys.stderr) + return 2 + if args.format == "json": + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(render_inventory_human(report)) + return exit_code + + +def inventory_main(argv: list[str] | None = None) -> int: + return asyncio.run(async_inventory_main(argv)) + + async def async_main(argv: list[str] | None = None) -> int: args = parse_args(argv) try: diff --git a/tests/test_automation_status.py b/tests/test_automation_status.py index 47da300..7b8008d 100644 --- a/tests/test_automation_status.py +++ b/tests/test_automation_status.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import json from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo @@ -182,3 +184,106 @@ def test_working_memory_frontmatter_evidence(tmp_path: Path) -> None: assert source["status"] == "ok" assert evidence[0]["run_id"] == "run-1" assert evidence[0]["output_validated"] is False + + +def _scheduled_definition(enabled: bool = False): + return { + "id": "00000000-0000-0000-0000-000000000456", + "name": "One Shot", + "enabled": enabled, + "trigger_type": "scheduled", + "trigger_config": { + "trigger_type": "scheduled", + "at": "2026-06-26T09:00:00+02:00", + "timezone": "Europe/Berlin", + }, + "source": "db", + } + + +def test_inventory_report_uses_db_definition_rows(monkeypatch) -> None: + async def fake_load_definitions(args, warnings): + return [dict(_definition(), source="db"), _scheduled_definition()], {"status": "ok", "source": "db"} + + async def fake_temporal(host, namespace, definitions, *, timeout_seconds): + return { + ACTIVITY_ID: { + "schedule_id": f"activity-schedule-{ACTIVITY_ID}", + "available": True, + "paused": False, + "missed_catchup_window": 0, + "last_fired_at": None, + }, + }, {"status": "ok", "count": 1} + + monkeypatch.setattr(status, "load_definitions", fake_load_definitions) + monkeypatch.setattr(status, "load_temporal_visibility", fake_temporal) + args = status.parse_inventory_args(["--format", "json"]) + + report, exit_code = asyncio.run(status.build_inventory_report(args)) + + assert exit_code == 0 + assert report["sources"]["definitions"] == {"status": "ok", "source": "db"} + assert report["summary"]["automation_count"] == 2 + assert report["automations"][0]["definition_source"] == "db" + assert report["automations"][0]["temporal"]["status"] == "active" + assert report["automations"][1]["schedule_id"].endswith("-once") + + +def test_inventory_file_fallback_when_db_url_missing(monkeypatch) -> None: + monkeypatch.setattr(status, "file_definitions", lambda: [dict(_definition(), source="files")]) + args = status.parse_inventory_args(["--db-url", "", "--temporal-host", ""]) + + report, exit_code = asyncio.run(status.build_inventory_report(args)) + + assert exit_code == 0 + assert report["sources"]["definitions"]["status"] == "degraded" + assert report["automations"][0]["definition_source"] == "files" + assert "ACTCORE_DB_URL is not set" in report["warnings"][0] + + +def test_inventory_filters_disabled_definitions() -> None: + definitions = [_definition(enabled=True), _scheduled_definition(enabled=False)] + + filtered = status.filter_inventory_definitions( + definitions, + ids=[], + names=[], + enabled=False, + trigger_types=set(), + ) + + assert [item["name"] for item in filtered] == ["One Shot"] + + +def test_inventory_temporal_unavailable_is_warning_not_failure(monkeypatch) -> None: + async def fake_load_definitions(args, warnings): + return [_definition()], {"status": "ok", "source": "db"} + + async def fake_temporal(host, namespace, definitions, *, timeout_seconds): + return {}, {"status": "unavailable", "warning": "Temporal unavailable: nope"} + + monkeypatch.setattr(status, "load_definitions", fake_load_definitions) + monkeypatch.setattr(status, "load_temporal_visibility", fake_temporal) + args = status.parse_inventory_args([]) + + report, exit_code = asyncio.run(status.build_inventory_report(args)) + + assert exit_code == 0 + assert report["automations"][0]["temporal"]["status"] == "not_checked" + assert report["warnings"] == ["Temporal unavailable: nope"] + + +def test_inventory_cli_emits_json(monkeypatch, capsys) -> None: + monkeypatch.setattr(status, "file_definitions", lambda: [dict(_definition(), source="files")]) + + exit_code = asyncio.run(status.async_inventory_main([ + "--db-url", "", + "--temporal-host", "", + "--format", "json", + ])) + + payload = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert payload["mode"] == "automation-inventory" + assert payload["automations"][0]["name"] == "Daily Check" diff --git a/workplans/ACTIVITY-WP-0019-automation-schedule-inventory-targets.md b/workplans/ACTIVITY-WP-0019-automation-schedule-inventory-targets.md index a8db05e..0430c71 100644 --- a/workplans/ACTIVITY-WP-0019-automation-schedule-inventory-targets.md +++ b/workplans/ACTIVITY-WP-0019-automation-schedule-inventory-targets.md @@ -4,11 +4,11 @@ type: workplan title: "Automation schedule inventory Make targets" domain: infotech repo: activity-core -status: ready +status: finished owner: codex topic_slug: automation-inventory created: "2026-06-29" -updated: "2026-06-29" +updated: "2026-07-01" state_hub_workstream_id: "21c73763-9adc-42f6-8fd2-1b8b33c2c770" --- @@ -45,7 +45,7 @@ coding assistant automation infrastructure. ```task id: ACTIVITY-WP-0019-T01 -status: todo +status: done priority: high state_hub_task_id: "8de24590-f9ee-4d0e-8692-b7ada9f232ed" ``` @@ -71,7 +71,7 @@ Acceptance: ```task id: ACTIVITY-WP-0019-T02 -status: todo +status: done priority: high state_hub_task_id: "538cb9a5-48f3-470c-8518-29ee66c96678" ``` @@ -98,7 +98,7 @@ Acceptance: ```task id: ACTIVITY-WP-0019-T03 -status: todo +status: done priority: high state_hub_task_id: "f2001721-07f3-42f5-a15e-0c7d1b0ed801" ``` @@ -120,7 +120,7 @@ Acceptance: ```task id: ACTIVITY-WP-0019-T04 -status: todo +status: done priority: medium state_hub_task_id: "f687743b-3936-413e-ae50-d35484ae9a81" ``` @@ -142,7 +142,7 @@ Acceptance: ```task id: ACTIVITY-WP-0019-T05 -status: todo +status: done priority: medium state_hub_task_id: "5317b532-5cef-4eff-b6d8-3e85bbca8e8a" ``` @@ -162,3 +162,43 @@ Acceptance: than hanging. - Focused tests pass and the result is recorded in this workplan before the workplan is moved to `finished`. + + +## Implementation Result + +Completed 2026-07-01: implemented the read-only scheduled automation inventory +surface. + +Delivered: + +- `scripts/automation_inventory.py` exposes the inventory CLI backed by + `activity_core.automation_status` shared definition and Temporal helpers. +- `make automation-list` and `make automation-list-json` list configured + scheduled ActivityDefinitions with filters for `ENABLED`, `TRIGGER`, + `ACTIVITY_ID`, and `ACTIVITY_NAME`. +- JSON output is script-safe; the Make JSON target suppresses command echo and + recursive make directory chatter. +- `docs/runbook.md` now distinguishes inventory (what is configured) from status + (what happened in a time window). +- Tests cover DB-backed rows, file fallback, disabled filtering, Temporal + unavailable warnings, and JSON CLI output. + +Verification: + +```bash +/home/worsch/.local/bin/uv run pytest tests/test_automation_status.py tests/test_daily_triage_verifier.py -q +bash -lc 'export PATH="/home/worsch/.local/bin:$PATH"; make automation-list ACTCORE_DB_URL= TEMPORAL_HOST=' +bash -lc 'export PATH="/home/worsch/.local/bin:$PATH"; make automation-list-json ACTCORE_DB_URL= TEMPORAL_HOST= > /tmp/activity-core-inventory.json && python3 -m json.tool /tmp/activity-core-inventory.json >/tmp/activity-core-inventory.pretty' +bash -lc 'export PATH="/home/worsch/.local/bin:$PATH"; make automation-list ACTCORE_DB_URL= TEMPORAL_HOST= ENABLED=true TRIGGER=cron' +bash -lc 'export PATH="/home/worsch/.local/bin:$PATH"; make help' +``` + +Results: + +- focused tests: `16 passed`; +- degraded Make inventory run listed 9 file-backed scheduled automations, with + 5 enabled and 4 disabled; +- filtered Make run with `ENABLED=true TRIGGER=cron` listed 5 enabled cron + automations; +- `automation-list-json` emitted parseable JSON directly; +- `make help` lists `automation-list` and `automation-list-json`.