feat(timeline): record a change by hand from the Timeline, a host's page or MCP log_change (#6350)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m20s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped

A change Steward can't see happen (a config edited, a disk swapped) is
recorded by whoever made it: what was done, its area, when (in the reader's
own zone), the hosts, what it was done to and a detail. Its author or an
admin edits or deletes it; recording, editing and deleting are audited and
those audit rows stay off the timeline. log_change records one as
"<user> via MCP", editable by the token's user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:26:02 -04:00
co-authored by Claude Opus 5.5
parent 2b97c36fd4
commit 329aa15e3d
15 changed files with 703 additions and 38 deletions
+91 -21
View File
@@ -1,13 +1,39 @@
"""The timeline: everything that happened to the infrastructure, in one list."""
"""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, config as tl_config, service as tl
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
@@ -48,25 +74,7 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
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 = [
{
"at": iso(v["entry"].at),
"age_s": age_s(v["entry"].at),
"until": iso(v["entry"].until),
"type": v["entry"].type,
"kind": v["kind"],
"area": v["area"],
"summary": v["entry"].summary,
"note": v["note"],
"hosts": v["entry"].hosts,
"subject": v["entry"].subject,
"by": v["entry"].actor,
"url": v["url"],
"details_kept": not v["gone"],
"ref": {"type": v["entry"].ref_type, "id": v["entry"].ref_id} if v["entry"].ref_type else None,
}
for v in items
]
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"]:
@@ -76,5 +84,67 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
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)