feat(ledger): divergence readout — button B where button A is canon, shape history, and judged-shape recheck (#2793, milestone 294 step 7)
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Every judgment now goes through one helper that remembers the fingerprint judged (classified_sha) and writes a code_shape_events row; the sync writes vanished / reappeared / drifted events and flags recheck_at when a body moves under an instance/variant. The refresh flags diverges_from on shapes new since the previous computation that sit where one canon dominates the judged siblings of their directory+kind and were not proposed as that canon (a first seed flags nothing); the write-path hint asks the same question in-band for the shapes the hook names. list_shapes(flag=divergence|recheck), shape_history(project_id, path, symbol) (read-only), coverage line/payload/ card carry divergent + recheck. Backup v8 carries the history. Plugin 0.1.36. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ from scribe.models.note_supersession import NoteSupersession
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.code_shape import CodeShape
|
||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.repo_binding import RepoBinding
|
||||
from scribe.models.rulebook import (
|
||||
@@ -40,8 +40,10 @@ logger = logging.getLogger(__name__)
|
||||
# v7 (2026-08) added code_shapes — the shape ledger (#2787). Classifications
|
||||
# are judgment data worth carrying; a restore keeps a judgment only when its
|
||||
# snippet target survives the id re-mapping, else the row rejoins the todo.
|
||||
# v8 (2026-08) added code_shape_events — the ledger's history (#2793): what
|
||||
# was used where, when, and why is not recomputable, so it travels.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 7
|
||||
BACKUP_VERSION = 8
|
||||
|
||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||
# below, these two lists must together account for the entire schema — which is
|
||||
@@ -59,8 +61,8 @@ _BACKED_UP = [
|
||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||
"systems", "record_systems", "design_systems", "design_tokens",
|
||||
"note_usage_events", "repo_bindings", "note_supersessions",
|
||||
# v7 (2026-08): the shape ledger (#2787).
|
||||
"code_shapes",
|
||||
# v7 (2026-08): the shape ledger (#2787); v8: its history (#2793).
|
||||
"code_shapes", "code_shape_events",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
@@ -176,6 +178,10 @@ def _code_shape_rows(rows) -> list[dict]:
|
||||
return [r.to_dict() for r in rows]
|
||||
|
||||
|
||||
def _code_shape_event_rows(rows) -> list[dict]:
|
||||
return [r.to_dict() for r in rows]
|
||||
|
||||
|
||||
def _repo_binding_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
|
||||
@@ -216,6 +222,9 @@ async def export_full_backup() -> dict:
|
||||
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
|
||||
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
|
||||
code_shapes = (await session.execute(select(CodeShape))).scalars().all()
|
||||
code_shape_events = (await session.execute(
|
||||
select(CodeShapeEvent).order_by(CodeShapeEvent.at, CodeShapeEvent.id)
|
||||
)).scalars().all()
|
||||
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
|
||||
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
||||
rules = (await session.execute(select(Rule))).scalars().all()
|
||||
@@ -391,6 +400,7 @@ async def export_full_backup() -> dict:
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
"code_shapes": _code_shape_rows(code_shapes),
|
||||
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
||||
}
|
||||
|
||||
|
||||
@@ -463,6 +473,10 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
code_shapes = (await session.execute(
|
||||
select(CodeShape).where(CodeShape.project_id.in_(project_ids))
|
||||
)).scalars().all() if project_ids else []
|
||||
code_shape_events = (await session.execute(
|
||||
select(CodeShapeEvent).where(CodeShapeEvent.project_id.in_(project_ids))
|
||||
.order_by(CodeShapeEvent.at, CodeShapeEvent.id)
|
||||
)).scalars().all() if project_ids else []
|
||||
rulebooks = (await session.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user_id)
|
||||
)).scalars().all()
|
||||
@@ -652,6 +666,7 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
"note_supersessions": _note_supersession_rows(supersessions),
|
||||
"code_shapes": _code_shape_rows(code_shapes),
|
||||
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
||||
}
|
||||
|
||||
|
||||
@@ -755,7 +770,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"topic_suppressions": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||
"note_supersessions": 0, "code_shapes": 0,
|
||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -1137,6 +1152,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
# survive the re-mapping (canonical/instance/variant with a gone
|
||||
# snippet) is downgraded to unclassified so it rejoins the todo
|
||||
# honestly instead of dangling; exempt needs no target and keeps.
|
||||
shape_id_map: dict[int, int] = {}
|
||||
for cs_data in data.get("code_shapes", []):
|
||||
mapped_pid = project_id_map.get(cs_data.get("project_id", 0))
|
||||
if mapped_pid is None:
|
||||
@@ -1149,7 +1165,7 @@ async def _restore_v2(data: dict) -> dict:
|
||||
status = "unclassified"
|
||||
classified_by = None
|
||||
classified_at = None
|
||||
session.add(CodeShape(
|
||||
shape = CodeShape(
|
||||
project_id=mapped_pid,
|
||||
repo_key=cs_data.get("repo_key", ""),
|
||||
path=cs_data.get("path", ""),
|
||||
@@ -1168,11 +1184,41 @@ async def _restore_v2(data: dict) -> dict:
|
||||
# against the restored snippet ids.
|
||||
signature=cs_data.get("signature", ""),
|
||||
body_sha=cs_data.get("body_sha", ""),
|
||||
classified_sha=cs_data.get("classified_sha", ""),
|
||||
created_at=_dt(cs_data.get("created_at")),
|
||||
updated_at=_dt(cs_data.get("updated_at")),
|
||||
))
|
||||
)
|
||||
session.add(shape)
|
||||
await session.flush()
|
||||
if cs_data.get("id"):
|
||||
shape_id_map[int(cs_data["id"])] = shape.id
|
||||
stats["code_shapes"] += 1
|
||||
|
||||
# v8: the ledger's history rides its shapes. snippet_id is kept as
|
||||
# the history's own claim (FK-free by design) but re-mapped when the
|
||||
# snippet survived, so a restored timeline points at restored records.
|
||||
for ev in data.get("code_shape_events", []):
|
||||
new_shape_id = shape_id_map.get(ev.get("shape_id") or 0)
|
||||
mapped_pid = project_id_map.get(ev.get("project_id", 0))
|
||||
if new_shape_id is None or mapped_pid is None:
|
||||
continue
|
||||
old_sid = ev.get("snippet_id")
|
||||
session.add(CodeShapeEvent(
|
||||
shape_id=new_shape_id,
|
||||
project_id=mapped_pid,
|
||||
path=ev.get("path", ""),
|
||||
symbol=ev.get("symbol", ""),
|
||||
kind=ev.get("kind", "sym"),
|
||||
event=ev.get("event", "classified"),
|
||||
status=ev.get("status"),
|
||||
snippet_id=note_id_map.get(old_sid, old_sid) if old_sid else None,
|
||||
classified_by=ev.get("classified_by"),
|
||||
reason=ev.get("reason"),
|
||||
commit=ev.get("commit", ""),
|
||||
at=_dt(ev.get("at")),
|
||||
))
|
||||
stats["code_shape_events"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2/v3 backup: %s", stats)
|
||||
|
||||
Reference in New Issue
Block a user