"""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` ("" or " 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