generated from coulomb/repo-seed
Add automation inventory surface
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user