Files
FabledSteward/steward/mcp/tools/timeline.py
T
bvandeusenandClaude Opus 5.5 3899e0e299
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m49s
CI / integration (push) Failing after 2m6s
CI / publish (push) Skipped
fix(timeline): register log_change with read_only=False, which register_tool requires (#6350)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:31:37 -04:00

151 lines
7.1 KiB
Python

"""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 "<you> 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, read_only=False)