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
.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)

View File

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

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

View File

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

View File

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