"""The timeline: everything that happened to the infrastructure, in one list, and changes recorded on it by hand.""" from __future__ import annotations from datetime import datetime, timezone from steward.mcp._context import current_caller from steward.models.timeline import TimelineEntry from steward.models.users import UserRole from steward.timeline import catalog, changes, config as tl_config, service as tl from ._audit import audit_mcp, mcp_username from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback def _entry(v: dict) -> dict: e = v["entry"] return { "entry_id": e.id, "at": iso(e.at), "age_s": age_s(e.at), "until": iso(e.until), "type": e.type, "kind": v["kind"], "area": v["area"], "summary": e.summary, "note": v["note"], "hosts": e.hosts, "subject": e.subject, "by": e.actor, "url": v["url"], "details_kept": not v["gone"], "ref": {"type": e.ref_type, "id": e.ref_id} if e.ref_type else None, } async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str = "", range: str = "7d", include_hidden: bool = False, limit: int = 50) -> dict: """What happened to the infrastructure, newest first, from every source in one list: alerts firing, resolving and going silent; playbook runs and what they changed; maintenance runs, reboots and lost contact; deploys and crash-loops; anomalies once reviewed as real; changes to Steward's own settings, users and backups. Each entry outlives the record it came from, so this reaches further back than get_alert_history, list_ansible_runs or list_audit_events. Reach for it first for "what changed on this host lately?" or "what else was going on when this broke?". Follow an entry's `url` or ids into the tool for that record while it is still kept (`details_kept`). host: a host's name, exactly; empty for every host. area: what part of the infrastructure an entry is about, as Settings → Timeline names its areas (`areas` in the reply lists them); empty for all. kind: how it came about: "change", "automation", "alert", "anomaly", "ticket", "observed" (seen happening: a reboot, a crash-loop) or "steward"; empty for all. type: one entry type (`types` in the reply); it is returned even if hidden. range: "1h", "6h", "24h", "7d" (default) or "30d". include_hidden: also the types Settings hides from the timeline (dry runs, sign-ins, playbook runs that changed nothing, single container restarts, anomalies not reviewed as real). limit: default 50, max 500. """ if kind and kind not in catalog.KINDS: raise ValueError(f"kind {kind!r} is not one of {', '.join(catalog.KINDS)}.") since, key = lookback(range) limit = clamp_limit(limit, 50, 500) async with db_session() as db: cfg = await tl_config.load(db) if area and area not in cfg.labels and area != catalog.OTHER[0]: raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)}.") stmt = tl.query(cfg, host=host.strip(), area=area, kind=kind, type_=type, since=since, include_hidden=include_hidden) rows = (await db.execute(stmt.order_by(TimelineEntry.at.desc(), TimelineEntry.id.desc()) .limit(limit + 1))).scalars().all() items = await tl.views(db, cfg, rows) entries = [_entry(v) for v in items] out = {"range": key, **bounded(entries, limit, key="entries"), "areas": dict(cfg.labels), "types": sorted(catalog.TYPES)} if out["truncated"]: out["total"] = None out["more"] = (f"More than {limit} entries in range. Pass a larger `limit` (max 500), a " "shorter `range`, or filter by `host`, `area`, `kind` or `type`.") return out def _area(cfg, area: str) -> str: """An area by its key or its label, as Settings → Timeline names it.""" want = (area or "").strip().lower() for key, label in cfg.areas: if want in (key, label.lower()): return key raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)} (get_timeline lists them).") async def log_change(summary: str, area: str, hosts: list[str] | str | None = None, subject: str = "", detail: str = "", at: str = "") -> dict: """Record on the timeline a change Steward can't see happen: a config edited by hand, a package installed, a disk swapped, a service moved, a setting changed in another system. Record one whenever you, or the person you work with, change the infrastructure outside Steward's own tools, so "what changed on this host lately?" has the answer later. Playbook runs, maintenance, deploys and Steward's own settings are recorded already. summary: what was done, one line (up to 300 characters), e.g. "Moved Postgres data to the new SSD". area: what part of the infrastructure it is about, by key or label: service, host_os, hardware, storage, network, security, monitoring, as Settings → Timeline names them (get_timeline's `areas`). hosts: the host(s) it was done on, by exact name (list_hosts); none for a change not on any one host. subject: what it was done to, when that is not a host: a container, a service, a file ("postgres", "/etc/fstab"). detail: why and how, and anything worth knowing if it goes wrong (up to 4000 characters). at: when it was done, ISO-8601 (a time with no zone is UTC); default now. Shown as by " via MCP"; the person whose token this is can edit or delete it from the Timeline page. Recorded in the audit log. """ if isinstance(hosts, str): hosts = hosts.split(",") when = None if (at or "").strip(): try: when = datetime.fromisoformat(at.strip().replace("Z", "+00:00")) except ValueError: raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None when = when if when.tzinfo else when.replace(tzinfo=timezone.utc) caller = current_caller() async with db_session() as db: async with db.begin(): cfg = await tl_config.load(db) try: f = await changes.fields(db, cfg, summary=summary, area=_area(cfg, area), hosts=hosts or [], subject=subject, note=detail, at=when) except ValueError as exc: if "No host is named" in str(exc): raise ValueError(f"{exc} list_hosts gives the names.") from None raise entry = changes.log(db, f, author_id=caller.user_id, actor=mcp_username(caller.username)) [v] = await tl.views(db, cfg, [entry]) await audit_mcp("timeline.change_recorded", "timeline_entry", entry.id, {"name": entry.summary, "hosts": entry.hosts, "area": entry.area}) return _entry(v) def register(register_tool) -> None: register_tool(get_timeline, role=UserRole.viewer, read_only=True) register_tool(log_change, role=UserRole.operator)