refactor: rename package fabledassistant -> scribe (code-only)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s

Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.

Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.

ruff check src/ clean locally; CI (typecheck + pytest) is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:48:35 -04:00
co-authored by Claude Opus 4.8
parent 1d4c206563
commit b255a0f90e
167 changed files with 1183 additions and 2368 deletions
+243
View File
@@ -0,0 +1,243 @@
"""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()