"""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_key(cfg, area: str) -> str: """An area by its key or its label, as Settings → Timeline names it (log_change, and the ticket tools).""" 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).") def _when(at: str): """An ISO-8601 time (no zone is UTC), or None for an empty one.""" if not (at or "").strip(): return None 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 return when if when.tzinfo else when.replace(tzinfo=timezone.utc) def host_hint(exc: ValueError) -> ValueError: """A refusal naming a host that isn't one, pointed at the tool that lists them.""" return ValueError(f"{exc} list_hosts gives the names.") if "No host is named" in str(exc) else exc 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". It can be corrected with update_change or taken back with delete_change, by the person whose token this is or an admin. Recorded in the audit log. """ if isinstance(hosts, str): hosts = hosts.split(",") when = _when(at) 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_key(cfg, area), hosts=hosts or [], subject=subject, note=detail, at=when) except ValueError as exc: raise host_hint(exc) from None 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) async def _own(db, entry_id: str): """A recorded change the caller may edit: one their token's user wrote, or any for an admin.""" caller = current_caller() entry = await changes.get(db, entry_id) if entry is None: raise ValueError(f"No recorded change has the id {entry_id!r} (get_timeline's `entry_id`, " "on an entry of type change.recorded).") if not changes.can_edit(entry, caller.user_id, getattr(caller.role, "value", caller.role)): raise ValueError(f"Change {entry_id} was recorded by {entry.actor}; only they or an admin can change it.") return entry async def update_change(entry_id: str, summary: str | None = None, area: str | None = None, hosts: list[str] | str | None = None, subject: str | None = None, detail: str | None = None, at: str | None = None) -> dict: """Correct a change recorded with log_change (or on the Timeline page): only what you pass changes; an empty string clears the subject or detail, an empty list the hosts. Only its author or an admin can. entry_id: the change's `entry_id` (log_change's reply, or get_timeline). The rest as log_change. Recorded in the audit log. """ async with db_session() as db: async with db.begin(): entry = await _own(db, entry_id) cfg = await tl_config.load(db) if isinstance(hosts, str): hosts = hosts.split(",") try: f = await changes.fields( db, cfg, summary=entry.summary if summary is None else summary, area=entry.area if area is None else area_key(cfg, area), hosts=entry.hosts if hosts is None else hosts, subject=(entry.subject or "") if subject is None else subject, note=((entry.detail or {}).get("note") or "") if detail is None else detail, at=entry.at if at is None else _when(at)) except ValueError as exc: raise host_hint(exc) from None changes.edit(entry, f) [v] = await tl.views(db, cfg, [entry]) await audit_mcp("timeline.change_edited", "timeline_entry", entry.id, {"name": entry.summary}) return _entry(v) async def delete_change(entry_id: str) -> dict: """Take a recorded change off the timeline: one logged by mistake, or twice. Only its author or an admin can; the audit log keeps that it was. entry_id: the change's `entry_id`. """ async with db_session() as db: async with db.begin(): entry = await _own(db, entry_id) summary = entry.summary await db.delete(entry) await audit_mcp("timeline.change_deleted", "timeline_entry", entry_id, {"name": summary}) return {"deleted": entry_id, "summary": summary} def register(register_tool) -> None: register_tool(get_timeline, role=UserRole.viewer, read_only=True) for fn in (log_change, update_change, delete_change): register_tool(fn, role=UserRole.operator, read_only=False)