feat(timeline): record a change by hand from the Timeline, a host's page or MCP log_change (#6350)
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:
@@ -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
|
||||
Reference in New Issue
Block a user