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

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