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
+87
View File
@@ -0,0 +1,87 @@
"""Changes a person or an agent records by hand: a config edited on a host, a
disk swapped, a service moved. Steward can't see these happen, so whoever
made the change writes it down, from the Timeline page, a host's page, or
the log_change MCP tool.
Each is one entry of type change.recorded, sorted under the area its author
picked. Its author can edit or delete it, and so can an admin; the entry
keeps the author's user id in its detail for that. Writing, editing and
deleting are audited, but the audit rows stay off the timeline, which holds
the change itself (catalog.audit_type).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from .config import Config
from .record import record
from steward.models.timeline import TimelineEntry
TYPE = "change.recorded"
SUMMARY_MAX = 300
NOTE_MAX = 4000
SUBJECT_MAX = 255
# A change is recorded once it is made; a clock a little ahead is allowed for.
_AHEAD = timedelta(minutes=5)
async def fields(db, cfg: Config, *, summary: str, area: str, hosts=(), subject: str = "",
note: str = "", at: datetime | None = None, now: datetime | None = None) -> dict:
"""The entry's fields, checked: a summary, an area Settings → Timeline
lists, hosts Steward knows by name, and a time not in the future. Raises
ValueError saying what to fix."""
now = now or datetime.now(timezone.utc)
summary = " ".join((summary or "").split())
if not summary:
raise ValueError("Say what was done.")
if len(summary) > SUMMARY_MAX:
raise ValueError(f"Keep what was done to {SUMMARY_MAX} characters; put the rest in the detail.")
if area not in cfg.labels:
raise ValueError(f"Pick an area: {', '.join(cfg.labels.values())}.")
from steward.models.hosts import Host
wanted = sorted({h.strip() for h in hosts if h and h.strip()})
known = set((await db.execute(select(Host.name).where(Host.name.in_(wanted)))).scalars()) if wanted else set()
unknown = [h for h in wanted if h not in known]
if unknown:
raise ValueError(f"No host is named {', '.join(unknown)}.")
note = (note or "").strip()
if len(note) > NOTE_MAX:
raise ValueError(f"Keep the detail to {NOTE_MAX} characters.")
at = at or now
if at > now + _AHEAD:
raise ValueError("A change is recorded once it is made; pick a time that has passed.")
return {"summary": summary, "area": area, "hosts": wanted,
"subject": (subject or "").strip()[:SUBJECT_MAX] or None, "note": note or None, "at": at}
def log(db, f: dict, *, author_id: str | None, actor: str) -> TimelineEntry:
"""Add the entry `fields` checked, written by `actor` ("<user>" or
"<user> via MCP")."""
return record(db, type=TYPE, at=f["at"], summary=f["summary"], area=f["area"], hosts=f["hosts"],
subject=f["subject"], actor=actor, detail=_detail(f, author_id))
def edit(entry: TimelineEntry, f: dict) -> None:
author_id = (entry.detail or {}).get("author_id")
entry.at, entry.summary, entry.area = f["at"], f["summary"], f["area"]
entry.hosts, entry.subject = f["hosts"], f["subject"]
entry.detail = _detail(f, author_id)
def _detail(f: dict, author_id: str | None) -> dict:
return {"note": f["note"], "author_id": author_id}
def can_edit(entry: TimelineEntry, user_id: str | None, role: str | None) -> bool:
"""Whether this reader may edit or delete `entry`: a recorded change,
by its author or an admin."""
if entry.type != TYPE or not user_id:
return False
return role == "admin" or (entry.detail or {}).get("author_id") == user_id
async def get(db, entry_id: str) -> TimelineEntry | None:
entry = await db.get(TimelineEntry, entry_id)
return entry if entry is not None and entry.type == TYPE else None