Add automation inventory surface

This commit is contained in:
2026-07-02 02:15:39 +02:00
parent ffe10f098e
commit 2f55167215
6 changed files with 498 additions and 9 deletions

View File

@@ -2,7 +2,7 @@
export export
.PHONY: sync-event-types sync-activity-definitions sync-schedules test migrate sync-all \ .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 \ dev-up dev-down railiance-up railiance-down \
start-worker start-api start-event-router help 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 SINCE ?= today
FORMAT ?= human FORMAT ?= human
ENABLED ?= all
TRIGGER ?=
ACTIVITY_ID ?=
ACTIVITY_NAME ?=
automation-status: ## Report recent automation status from repo-owned evidence 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)" 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 automation-status-json: ## Report recent automation status as JSON
$(MAKE) automation-status FORMAT=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 ───────────────────────────────────────────────────────────── # ── Infrastructure ─────────────────────────────────────────────────────────────
dev-up: ## Start full dev stack (Temporal + PG + ES + NATS) dev-up: ## Start full dev stack (Temporal + PG + ES + NATS)

View File

@@ -136,6 +136,36 @@ The response reports:
- `schedules.deleted_orphans` - `schedules.deleted_orphans`
- bounded `errors[]` - 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 ## Automation status
Use the repo-native status command to answer operator questions such as "how did Use the repo-native status command to answer operator questions such as "how did

View File

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

View File

@@ -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 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] + "..." 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: async def async_main(argv: list[str] | None = None) -> int:
args = parse_args(argv) args = parse_args(argv)
try: try:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -182,3 +184,106 @@ def test_working_memory_frontmatter_evidence(tmp_path: Path) -> None:
assert source["status"] == "ok" assert source["status"] == "ok"
assert evidence[0]["run_id"] == "run-1" assert evidence[0]["run_id"] == "run-1"
assert evidence[0]["output_validated"] is False 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"

View File

@@ -4,11 +4,11 @@ type: workplan
title: "Automation schedule inventory Make targets" title: "Automation schedule inventory Make targets"
domain: infotech domain: infotech
repo: activity-core repo: activity-core
status: ready status: finished
owner: codex owner: codex
topic_slug: automation-inventory topic_slug: automation-inventory
created: "2026-06-29" created: "2026-06-29"
updated: "2026-06-29" updated: "2026-07-01"
state_hub_workstream_id: "21c73763-9adc-42f6-8fd2-1b8b33c2c770" state_hub_workstream_id: "21c73763-9adc-42f6-8fd2-1b8b33c2c770"
--- ---
@@ -45,7 +45,7 @@ coding assistant automation infrastructure.
```task ```task
id: ACTIVITY-WP-0019-T01 id: ACTIVITY-WP-0019-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "8de24590-f9ee-4d0e-8692-b7ada9f232ed" state_hub_task_id: "8de24590-f9ee-4d0e-8692-b7ada9f232ed"
``` ```
@@ -71,7 +71,7 @@ Acceptance:
```task ```task
id: ACTIVITY-WP-0019-T02 id: ACTIVITY-WP-0019-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "538cb9a5-48f3-470c-8518-29ee66c96678" state_hub_task_id: "538cb9a5-48f3-470c-8518-29ee66c96678"
``` ```
@@ -98,7 +98,7 @@ Acceptance:
```task ```task
id: ACTIVITY-WP-0019-T03 id: ACTIVITY-WP-0019-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "f2001721-07f3-42f5-a15e-0c7d1b0ed801" state_hub_task_id: "f2001721-07f3-42f5-a15e-0c7d1b0ed801"
``` ```
@@ -120,7 +120,7 @@ Acceptance:
```task ```task
id: ACTIVITY-WP-0019-T04 id: ACTIVITY-WP-0019-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "f687743b-3936-413e-ae50-d35484ae9a81" state_hub_task_id: "f687743b-3936-413e-ae50-d35484ae9a81"
``` ```
@@ -142,7 +142,7 @@ Acceptance:
```task ```task
id: ACTIVITY-WP-0019-T05 id: ACTIVITY-WP-0019-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "5317b532-5cef-4eff-b6d8-3e85bbca8e8a" state_hub_task_id: "5317b532-5cef-4eff-b6d8-3e85bbca8e8a"
``` ```
@@ -162,3 +162,43 @@ Acceptance:
than hanging. than hanging.
- Focused tests pass and the result is recorded in this workplan before the - Focused tests pass and the result is recorded in this workplan before the
workplan is moved to `finished`. 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`.