"""Pin/unpin and auto-pin operations on NoteVersion. The autosave-rolling system in `services/note_versions.py` continues to own the existing rolling-cap behavior; this module adds the manual-pin API and the daily auto-pin scan. Tiers (pin_kind values): None → rolling autosave; capped at MAX_VERSIONS, FIFO. "auto" → system-declared via the stability scan; capped at MAX_AUTO_PINS, FIFO. "manual" → user-declared; unlimited, never pruned. Design: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md """ from __future__ import annotations import datetime import logging from datetime import timezone from sqlalchemy import select, text from scribe.models import async_session from scribe.models.note_version import NoteVersion logger = logging.getLogger(__name__) MAX_AUTO_PINS = 25 AUTO_PIN_STABILITY_DAYS = 2 PIN_LABEL_MAX_LEN = 500 async def pin_version( user_id: int, note_id: int, version_id: int, *, label: str | None, ) -> NoteVersion | None: """Mark a version as manually pinned. Returns the updated row or None if not found (or wrong user/note scope). Acceptable on already-pinned rows (manual or auto) — promotes to manual and updates the label. Labels are capped at PIN_LABEL_MAX_LEN chars; longer values raise ValueError. """ if label is not None and len(label) > PIN_LABEL_MAX_LEN: raise ValueError( f"pin_label too long ({len(label)} > {PIN_LABEL_MAX_LEN} chars)" ) async with async_session() as session: version = ( await session.execute( select(NoteVersion).where( NoteVersion.id == version_id, NoteVersion.note_id == note_id, NoteVersion.user_id == user_id, ) ) ).scalars().first() if version is None: return None version.pin_kind = "manual" version.pin_label = label await session.commit() await session.refresh(version) return version async def unpin_version( user_id: int, note_id: int, version_id: int, ) -> NoteVersion | None: """Clear pin_kind and pin_label, downgrading the row to rolling. Does NOT delete the row. If the row is older than the rolling cap depth, the next create_version call will prune it via the rolling FIFO. Returns the updated row or None if not found. """ async with async_session() as session: version = ( await session.execute( select(NoteVersion).where( NoteVersion.id == version_id, NoteVersion.note_id == note_id, NoteVersion.user_id == user_id, ) ) ).scalars().first() if version is None: return None version.pin_kind = None version.pin_label = None await session.commit() await session.refresh(version) return version def _format_auto_pin_label( start: datetime.datetime, end: datetime.datetime | None, ) -> str: """Auto-generated label describing the stability window. end=None → version is the latest with no successor; render as "stable since {start_iso}". Otherwise render as "stable {start_iso} → {end_iso}". """ s = start.date().isoformat() if end is None: return f"stable since {s}" return f"stable {s} → {end.date().isoformat()}" def _promote_stable_versions_for_note(versions_chrono: list) -> list: """Mutate the input list: set pin_kind='auto' + auto-label on any unpinned version whose gap to its successor (or to now, for the latest) is >= AUTO_PIN_STABILITY_DAYS. Returns the list of versions that were newly pinned (caller uses this for logging / counts). """ now = datetime.datetime.now(timezone.utc) newly_pinned: list = [] for i, v in enumerate(versions_chrono): if v.pin_kind is not None: continue if i + 1 < len(versions_chrono): next_ts = versions_chrono[i + 1].created_at is_latest = False else: next_ts = now is_latest = True v_ts = v.created_at if v_ts.tzinfo is None: v_ts = v_ts.replace(tzinfo=timezone.utc) if next_ts.tzinfo is None: next_ts = next_ts.replace(tzinfo=timezone.utc) gap_days = (next_ts - v_ts).total_seconds() / 86400 if gap_days >= AUTO_PIN_STABILITY_DAYS: v.pin_kind = "auto" v.pin_label = _format_auto_pin_label( v_ts, None if is_latest else next_ts, ) newly_pinned.append(v) return newly_pinned async def _list_user_note_ids_with_versions(user_id: int) -> list[int]: """Return note_ids belonging to user_id that have at least one row in note_versions. Notes with no version history are ignored.""" async with async_session() as session: rows = ( await session.execute( text( """ SELECT DISTINCT note_id FROM note_versions WHERE user_id = :user_id """ ).bindparams(user_id=user_id) ) ).all() return [r[0] for r in rows] async def _scan_one_note(user_id: int, note_id: int) -> int: """Run the promote+prune flow for one note. Returns count of newly- pinned versions.""" async with async_session() as session: versions = ( await session.execute( select(NoteVersion) .where( NoteVersion.note_id == note_id, NoteVersion.user_id == user_id, ) .order_by(NoteVersion.created_at.asc()) ) ).scalars().all() if not versions: return 0 newly_pinned = _promote_stable_versions_for_note(list(versions)) if newly_pinned: await session.commit() if newly_pinned: await prune_auto_pins(user_id=user_id, note_id=note_id) return len(newly_pinned) async def scan_user_for_auto_pins(user_id: int) -> int: """Run the scan across every versioned note for one user. Returns total newly-pinned-this-pass.""" total = 0 note_ids = await _list_user_note_ids_with_versions(user_id) for note_id in note_ids: try: total += await _scan_one_note(user_id, note_id) except Exception: logger.exception( "auto-pin scan failed for user=%d note=%d", user_id, note_id, ) return total async def scan_all_users_for_auto_pins() -> dict[int, int]: """Top-level scan entrypoint. Iterates over all users and runs the per-user scan. Returns {user_id: newly_pinned_count}. Per-user errors are caught and logged so one user's failure doesn't stop the scan.""" from scribe.models import User async with async_session() as session: users = (await session.execute(select(User.id))).scalars().all() out: dict[int, int] = {} for uid in users: try: out[uid] = await scan_user_for_auto_pins(uid) except Exception: logger.exception("auto-pin scan failed for user=%d", uid) out[uid] = 0 return out async def prune_auto_pins(user_id: int, note_id: int) -> None: """FIFO-prune the auto-pinned bucket for one note past MAX_AUTO_PINS. Manual pins and rolling rows are untouched. Called by the scan job after each note's auto-pin promotions finish. """ async with async_session() as session: await session.execute( text( """ DELETE FROM note_versions WHERE id IN ( SELECT id FROM note_versions WHERE note_id = :note_id AND user_id = :user_id AND pin_kind = 'auto' ORDER BY created_at DESC OFFSET :max_auto_pins ) """ ).bindparams( note_id=note_id, user_id=user_id, max_auto_pins=MAX_AUTO_PINS, ) ) await session.commit()