memdebug 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,569 @@
1
+ """The viewer's pages. Every page is built with el(), so untrusted text is escaped by construction.
2
+
3
+ Nothing here reads request input directly: the server validates it first, and the values that reach
4
+ a page are ids matched against strict patterns, small integers, and members of fixed lists.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from urllib.parse import urlencode
12
+
13
+ from ..backends import HISTORYLESS, is_opaque_id, short_id
14
+ from ..describe import describe_event, rollback_details
15
+ from ..diff import Diff, diff_snapshots, line_diff
16
+ from ..errors import SnapshotError
17
+ from ..hints import new_hints
18
+ from ..ledger import Ledger, VerifyResult
19
+ from ..models import LedgerEntry, MemoryEvent, SnapshotInfo, Trust
20
+ from .html import Markup, block, el, inline, raw_doctype
21
+ from .redline import change_snippet, redline
22
+
23
+ PAGE_SIZE = 40
24
+ SNAPSHOT_PAGE_SIZE = 100
25
+ MAX_DIFF_CHANGES = 200
26
+
27
+ OP_CLASS = {
28
+ "ADD": "op-add", "UPDATE": "op-update", "DELETE": "op-delete", "EXTERNAL": "op-external",
29
+ "SNAPSHOT": "op-snapshot", "SNAP_DEL": "op-snap-del", "ROLLBACK": "op-rollback",
30
+ }
31
+ NODE_CLASS = { # the shape of an entry's node on the chain
32
+ "ADD": "n-add", "UPDATE": "n-update", "DELETE": "n-delete", "EXTERNAL": "n-external",
33
+ "SNAPSHOT": "n-snapshot", "SNAP_DEL": "n-snap-del", "ROLLBACK": "n-rollback",
34
+ }
35
+ OP_LABEL = {"EXTERNAL": "OUTSIDE HISTORY", "SNAP_DEL": "SNAPSHOT DELETED"}
36
+ FILTERS = [ # (label, op, trust)
37
+ ("All", None, None), ("Added", "ADD", None), ("Changed", "UPDATE", None), ("Deleted", "DELETE", None),
38
+ ("Outside the history", "EXTERNAL", None), ("Snapshots", "SNAPSHOT", None), ("Rollbacks", "ROLLBACK", None),
39
+ ("Untrusted source", None, "untrusted"),
40
+ ]
41
+ NAV = [("overview", "Overview", "/"), ("timeline", "Timeline", "/timeline"), ("snapshots", "Snapshots", "/snapshots"),
42
+ ("compare", "Compare", "/diff"), ("integrity", "Integrity", "/integrity")]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Page:
47
+ status: int
48
+ html: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Context:
53
+ ledger_name: str
54
+ theme: str = "auto" # "auto" follows the computer's setting; "light" and "dark" are the person's choice
55
+ here: str = "/" # the page being shown, so the theme switch can return to it
56
+
57
+
58
+ THEMES = (("auto", "Auto"), ("light", "Light"), ("dark", "Dark"))
59
+
60
+
61
+ def url(path: str, **params) -> str:
62
+ query = urlencode([(k, v) for k, v in sorted(params.items()) if v is not None])
63
+ return f"{path}?{query}" if query else path
64
+
65
+
66
+ def when(moment: datetime) -> str:
67
+ return moment.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + " UTC"
68
+
69
+
70
+ def stamp(moment: datetime) -> str:
71
+ """A short, quiet time for lists; the header says all times are UTC."""
72
+ utc = moment.astimezone(timezone.utc)
73
+ return f"{utc.day} {utc:%b %Y}, {utc:%H:%M}"
74
+
75
+
76
+ def plural(count: int, one: str, many: str) -> str:
77
+ return f"{count} {one if count == 1 else many}"
78
+
79
+
80
+ def short(digest: str) -> str:
81
+ return digest[:12]
82
+
83
+
84
+ def scope_text(scope: dict) -> str:
85
+ return ", ".join(f"{k}={v}" for k, v in sorted(scope.items())) or "-"
86
+
87
+
88
+ def document(ctx: Context, title: str, active: str, content: Markup) -> str:
89
+ tabs = el("nav", *[
90
+ el("a", label, href=path, aria_current="page" if key == active else None) for key, label, path in NAV
91
+ ], class_="tabs", aria_label="Sections")
92
+ head = el(
93
+ "head",
94
+ el("meta", charset="utf-8"),
95
+ el("meta", name="viewport", content="width=device-width, initial-scale=1"),
96
+ el("meta", name="referrer", content="no-referrer"),
97
+ el("title", f"{title} - memdebug"),
98
+ el("link", rel="stylesheet", href="/style.css"),
99
+ )
100
+ switch = el("div", *[
101
+ el("a", label, href=url("/theme", mode=key, next=ctx.here), aria_current="true" if key == ctx.theme else None)
102
+ for key, label in THEMES
103
+ ], class_="theme", role="group", aria_label="Colour theme")
104
+ top = el("header", el("span", "memdebug", class_="brand"), el("span", inline(ctx.ledger_name, 60), class_="where"),
105
+ el("span", "Read-only, times in UTC", class_="ro"), tabs, switch, class_="top")
106
+ foot = el("footer", "This page only reads the ledger. It cannot change a memory store or the ledger.")
107
+ body = el("body", top, el("main", content), foot)
108
+ theme_class = f"theme-{ctx.theme}" if ctx.theme in ("light", "dark") else None
109
+ return str(raw_doctype()) + str(el("html", head, body, lang="en", class_=theme_class))
110
+
111
+
112
+ def badge(event: MemoryEvent) -> Markup:
113
+ return el("span", OP_LABEL.get(event.op.value, event.op.value), class_=f"badge {OP_CLASS[event.op.value]}")
114
+
115
+
116
+ def notice(text: str, kind: str = "") -> Markup:
117
+ return el("div", text, class_=f"notice {kind}".strip(), role="status")
118
+
119
+
120
+ def error_page(ctx: Context, status: int, title: str, message: str) -> Page:
121
+ content = el("h1", title), notice(message, "bad")
122
+ return Page(status, document(ctx, title, "", Markup("".join(content))))
123
+
124
+
125
+ def diff_lines(lines: list[str]) -> Markup:
126
+ spans: list[Markup] = []
127
+ for line in lines:
128
+ if line.startswith("+++") or line.startswith("---"):
129
+ continue
130
+ kind = "add" if line.startswith("+") else "del" if line.startswith("-") else "hunk"
131
+ spans.append(el("span", block(line, 2000), class_=kind))
132
+ return el("pre", *spans)
133
+
134
+
135
+ # -- the chain: one entry per recorded event ----------------------------------------------------------------------
136
+
137
+ _BREAKS = re.compile(r"[ \t\r\n]+")
138
+
139
+
140
+ def _excerpt(event: MemoryEvent, limit: int = 260) -> Markup:
141
+ """The start of a memory on one line. Only spaces, tabs and line breaks are folded together; any other
142
+ control character stays visible, as everywhere else in the viewer."""
143
+ return inline(_BREAKS.sub(" ", describe_event(event)).strip(), limit)
144
+
145
+
146
+ def event_row(entry: LedgerEntry, href: str, selected: bool, snaps: dict[str, SnapshotInfo] | None = None,
147
+ show_store: bool = False) -> Markup:
148
+ """One entry on the chain. The list item carries the dot's colour and icon; the link carries the content."""
149
+ event = entry.event
150
+ op = event.op.value
151
+ seq = el("span", inline(entry.id, 12), class_="seq")
152
+ moment = el("time", ("noticed " if event.ts_observed else "") + stamp(event.ts), class_="when")
153
+ if op == "SNAPSHOT":
154
+ sid = event.memory_id.removeprefix("snapshot:")
155
+ info = (snaps or {}).get(sid)
156
+ top = [badge(event), el("strong", sid, class_="what")]
157
+ if info is not None and info.label:
158
+ top.append(el("span", inline(info.label, 100), class_="label"))
159
+ top.append(moment)
160
+ detail: list = []
161
+ if info is not None:
162
+ detail.append(el("span", plural(info.count, "memory", "memories")))
163
+ if not info.complete:
164
+ detail.append(el("span", "may be incomplete", class_="flag"))
165
+ else:
166
+ detail.append(el("span", _excerpt(event)))
167
+ body = [el("div", *top, class_="top"), el("div", *detail, class_="meta")]
168
+ elif op == "ROLLBACK":
169
+ details = rollback_details(event)
170
+ top = [badge(event), el("strong", inline(event.memory_id.removeprefix("rollback:"), 40), class_="what")]
171
+ if details is not None:
172
+ top.append(el("span", f"back to {details['target']}", class_="label"))
173
+ top.append(moment)
174
+ summary = plural(details["file_count"], "file", "files") + " changed" if details is not None else "unreadable record"
175
+ body = [el("div", *top, class_="top"), el("div", el("span", summary), class_="meta")]
176
+ elif op == "SNAP_DEL":
177
+ body = [el("div", badge(event), el("strong", inline(event.memory_id.removeprefix("snapshot:"), 40), class_="what"),
178
+ moment, class_="top"), el("div", el("span", _excerpt(event)), class_="meta")]
179
+ else:
180
+ meta: list = []
181
+ if show_store:
182
+ meta.append(el("span", f"{event.backend} {scope_text(event.scope)}".strip()))
183
+ if event.trust == Trust.UNTRUSTED:
184
+ meta.append(el("span", "came from an untrusted source", class_="flag"))
185
+ if op in ("ADD", "UPDATE", "EXTERNAL") and any(h.severity == "warning" for h in new_hints(event.before, event.after, budget=0.02, max_chars=5000)):
186
+ meta.append(el("span", "worth a second look", class_="flag hint"))
187
+ preview = _excerpt(event)
188
+ if op in ("UPDATE", "EXTERNAL") and event.before is not None and event.after is not None:
189
+ found = change_snippet(event.before, event.after) # show what changed, not the start of the file
190
+ if found is not None:
191
+ preview = found[0]
192
+ if found[1]:
193
+ meta.insert(0, el("span", found[1]))
194
+ body = [el("div", badge(event), el("strong", inline(short_id(event.memory_id), 60), class_="what"), moment, class_="top"),
195
+ el("p", preview, class_="mem")]
196
+ if meta:
197
+ body.append(el("div", *meta, class_="meta"))
198
+ link = el("a", *body, class_="row", href=href, aria_current="true" if selected else None)
199
+ return el("li", seq, link, class_=NODE_CLASS[op])
200
+
201
+
202
+ def chain(rows: list[Markup], goes_on: bool = False) -> Markup:
203
+ return el("ol", *rows, class_="chain goes-on" if goes_on else "chain")
204
+
205
+
206
+ def _stores(entries: list[LedgerEntry]) -> bool:
207
+ return len({(e.event.backend, tuple(sorted(e.event.scope.items()))) for e in entries}) > 1
208
+
209
+
210
+ # -- overview ---------------------------------------------------------------------------------------------------
211
+
212
+ def overview(ledger: Ledger, ctx: Context) -> Page:
213
+ counts = ledger.counts()
214
+ external = counts["by_op"].get("EXTERNAL", 0)
215
+ untrusted = counts["untrusted"]
216
+ snaps = {i.id: i for i in ledger.list_snapshots()}
217
+
218
+ if counts["events"] == 0:
219
+ headline = "Nothing is recorded yet."
220
+ elif external:
221
+ headline = f"{plural(external, 'change', 'changes')} happened outside the history."
222
+ elif untrusted:
223
+ headline = f"{plural(untrusted, 'memory', 'memories')} came from an untrusted source."
224
+ else:
225
+ headline = "Nothing needs a look."
226
+ summary = f"{plural(counts['events'], 'event', 'events')} and {plural(counts['snapshots'], 'snapshot', 'snapshots')} are on record."
227
+ if external and untrusted:
228
+ summary += f" {plural(untrusted, 'memory', 'memories')} also came from an untrusted source."
229
+ elif not external and not untrusted and counts["events"]:
230
+ present = ledger.backends()
231
+ quiet = sorted(present & HISTORYLESS)
232
+ if quiet and present == set(quiet):
233
+ summary += (" These stores keep no history of their own, so memdebug records a change when it notices one; it cannot say who "
234
+ "made it or whether it was expected.")
235
+ elif quiet:
236
+ summary += (f" Changes in the stores that have a history went through it. {', '.join(quiet)} keep no history of their own, so "
237
+ "there memdebug only records changes when it notices them.")
238
+ else:
239
+ summary += " Every change went through the store's own history."
240
+ parts: list[Markup] = [el("h1", headline, class_="headline"), el("p", summary, class_="sub")]
241
+
242
+ flagged: dict[int, LedgerEntry] = {}
243
+ for op, trust in (("EXTERNAL", None), (None, "untrusted")):
244
+ if (op == "EXTERNAL" and external) or (trust and untrusted):
245
+ for e in ledger.events_page(limit=6, op=op, trust=trust):
246
+ flagged[e.seq] = e
247
+ if flagged:
248
+ shown = sorted(flagged.values(), key=lambda e: -e.seq)[:6]
249
+ parts.append(el(
250
+ "section",
251
+ el("h2", "Needs a look"),
252
+ el("p", "These did not come through the store's own history, or came from an untrusted source. "
253
+ "Open one to see the exact words that changed.", class_="sub"),
254
+ chain([event_row(e, url("/timeline", event=e.id), False, snaps, _stores(shown)) for e in shown]),
255
+ class_="attention"))
256
+
257
+ latest = ledger.events_page(limit=8)
258
+ parts.append(el("h2", "Recent entries"))
259
+ parts.append(chain([event_row(e, url("/timeline", event=e.id), False, snaps, _stores(latest)) for e in latest])
260
+ if latest else el("p", "Nothing recorded yet. Run 'memdebug sync ...' first."))
261
+ if latest:
262
+ parts.append(el("p", el("a", "See the whole timeline", href="/timeline")))
263
+ recent_snaps = ledger.list_snapshots()[-3:][::-1]
264
+ parts.append(el("h2", "Snapshots"))
265
+ parts.append(snapshot_table(recent_snaps) if recent_snaps else el("p", "No snapshots yet."))
266
+ parts += [
267
+ el("h2", "Ledger head"),
268
+ el("code", counts["head"], class_="fingerprint"),
269
+ el("small", "A fingerprint of everything recorded so far. Keep a copy somewhere else: it lets you notice if the newest entries "
270
+ "are ever removed or the ledger is rewritten. 'memdebug witness --file <a file on another drive>' keeps one for you."),
271
+ ]
272
+ return Page(200, document(ctx, "Overview", "overview", Markup("".join(str(p) for p in parts))))
273
+
274
+
275
+ # -- timeline ------------------------------------------------------------------------------------------------------
276
+
277
+ def rollback_section(event: MemoryEvent) -> list[Markup]:
278
+ details = rollback_details(event)
279
+ if details is None:
280
+ return [el("p", "This record could not be read. Run the integrity check.", class_="why")]
281
+ def link(sid, text):
282
+ return el("a", text, href=f"/snapshot/{sid}")
283
+
284
+ parts: list[Markup] = [
285
+ el("p", "The memory store was put back to ", link(details["target"], f"snapshot {details['target']}"),
286
+ ". Nothing was rewritten: any change to the files is a new commit, and anything git did not already hold was "
287
+ "saved first.", class_="why"),
288
+ el("h3", "What was done"),
289
+ ]
290
+ shown = details["files"]
291
+ parts.append(el("ul", *[el("li", el("span", inline(f["action"], 20), class_="what-did"), " ", inline(f["path"], 120),
292
+ el("small", {"git": " from git", "snapshot": " from snapshot text"}.get(f["source"], "")))
293
+ for f in shown], class_="files"))
294
+ if details["file_count"] > len(shown):
295
+ parts.append(el("p", f"... and {details['file_count'] - len(shown)} more.", class_="why"))
296
+ facts: list[tuple[str, Markup | str]] = []
297
+ if details["before_snapshot"]:
298
+ facts.append(("To undo", el("span", "restore ", link(details["before_snapshot"], f"snapshot {details['before_snapshot']}"),
299
+ ", taken just before")))
300
+ if details["after_snapshot"]:
301
+ facts.append(("Afterwards", el("span", link(details["after_snapshot"], f"snapshot {details['after_snapshot']}"),
302
+ ", taken just after")))
303
+ if details["commit"]:
304
+ facts.append(("New commit", details["commit"][:12]))
305
+ if details["previous_head"]:
306
+ facts.append(("Branch was at", details["previous_head"][:12]))
307
+ if details["backup"]:
308
+ facts.append(("Saved first in", details["backup"]))
309
+ parts.append(el("dl", *[Markup(str(el("dt", k)) + str(el("dd", v if isinstance(v, Markup) else inline(v, 120))))
310
+ for k, v in facts], class_="facts"))
311
+ return parts
312
+
313
+
314
+ def inspector(entry: LedgerEntry) -> Markup:
315
+ event = entry.event
316
+ op = event.op.value
317
+ source = event.source
318
+ described = source is not None and bool(source.actor_id or source.role or source.note)
319
+ trust_note = {
320
+ Trust.UNTRUSTED: "Came from a tool result (such as an email or web page the agent read), not from the user.",
321
+ Trust.TRUSTED: "Came from something the user wrote.",
322
+ Trust.UNKNOWN: ("Not proven either way. What the app itself records and how close a chat was are shown under Source: a label and a "
323
+ "timing comparison, not proof." if described else
324
+ "Where this came from is not known yet (session logs are not connected)."),
325
+ }[event.trust]
326
+ source_text = ""
327
+ if source is not None:
328
+ bits = [f"{k}={v}" for k, v in (("session", source.session_id), ("turn", source.turn), ("kind", source.kind.value)) if v not in (None, "unknown")]
329
+ bits += [x for x in (source.actor_id, source.role) if x]
330
+ source_text = ", ".join(bits) + (f". {source.note}" if source.note else "")
331
+ facts = [
332
+ ("When", when(event.ts) + (" (when it was noticed)" if event.ts_observed else "")),
333
+ ("Backend", event.backend), ("Store", scope_text(event.scope)),
334
+ ("Trust", "" if op in ("SNAPSHOT", "SNAP_DEL", "ROLLBACK") else f"{event.trust.value}. {trust_note}"), ("Source", source_text),
335
+ ("Memory id", event.memory_id if op not in ("SNAPSHOT", "SNAP_DEL", "ROLLBACK") and is_opaque_id(event.memory_id) else ""),
336
+ ("Backend row", event.backend_ref or ""),
337
+ ("Ledger", f"entry {entry.seq}, hash {short(entry.hash)}, previous {short(entry.prev_hash)}"),
338
+ ]
339
+ title = (f"Snapshot {event.memory_id.removeprefix('snapshot:')}" if op in ("SNAPSHOT", "SNAP_DEL")
340
+ else f"Rollback {event.memory_id.removeprefix('rollback:')}" if op == "ROLLBACK" else short_id(event.memory_id))
341
+ main: list[Markup] = [
342
+ el("h2", inline(title, 80)),
343
+ el("p", f"Event {entry.id}, ledger entry {entry.seq}", class_="where2"),
344
+ el("p", badge(event), class_="verdict"),
345
+ ]
346
+ if op == "EXTERNAL":
347
+ main.append(el("p", "Nothing in the store's own history explains this change. If you did not make it, "
348
+ "treat the new text as untrusted.", class_="why"))
349
+ elif event.trust == Trust.UNTRUSTED:
350
+ main.append(el("p", trust_note, class_="why"))
351
+
352
+ if op == "SNAPSHOT":
353
+ sid = event.memory_id.removeprefix("snapshot:")
354
+ main.append(el("p", el("a", f"Open snapshot {sid}", href=f"/snapshot/{sid}")))
355
+ elif op == "SNAP_DEL":
356
+ main.append(el("p", "This snapshot was deleted. Its texts were removed; this record stays.", class_="why"))
357
+ elif op == "ROLLBACK":
358
+ main += rollback_section(event)
359
+ else:
360
+ main.append(el("h3", "What changed"))
361
+ if event.before is not None and event.after is not None:
362
+ marked = redline(event.before, event.after)
363
+ if marked is not None:
364
+ main += [el("p", el("span", "removed", class_="rm"), " ", el("span", "added", class_="ins"), class_="change-key"),
365
+ marked]
366
+ else:
367
+ main.append(diff_lines(line_diff(event.before, event.after)))
368
+ elif event.after is not None:
369
+ main += [el("p", "Added", class_="change-key"), el("div", block(event.after), class_="quote")]
370
+ elif event.before is not None:
371
+ main += [el("p", "Removed", class_="change-key"), el("div", block(event.before), class_="quote gone")]
372
+ for label, text in (("Full text before", event.before), ("Full text after", event.after)):
373
+ if text is not None:
374
+ main.append(el("details", el("summary", label), el("pre", block(text))))
375
+ if op in ("ADD", "UPDATE", "EXTERNAL"):
376
+ found = new_hints(event.before, event.after)
377
+ if found:
378
+ main.append(el("h3", "Worth a second look"))
379
+ main.append(el("p", "Guesses from the wording of what was added. They miss things and can be wrong: no hint is not a clean bill of health.", class_="why"))
380
+ main.append(el("ul", *[el("li", el("span", inline(h.message, 160)), " ", el("code", inline(h.evidence, 120))) for h in found], class_="hints"))
381
+ visible = [(k, v) for k, v in facts if v]
382
+ side = el("aside", el("h3", "Details"),
383
+ el("dl", *[Markup(str(el("dt", k)) + str(el("dd", inline(v, 300)))) for k, v in visible], class_="facts"),
384
+ class_="sheet-side")
385
+ return el("div", el("div", *main, class_="sheet-main"), side, class_="sheet")
386
+
387
+
388
+ def timeline(ledger: Ledger, ctx: Context, *, op: str | None, trust: str | None, before: int | None,
389
+ event_id: str | None) -> Page:
390
+ selected = ledger.get_entry(event_id) if event_id else None
391
+ page_before = before
392
+ if before is None and selected is not None:
393
+ before = selected.seq + 6 # start the list near the selected event
394
+ entries = ledger.events_page(before_seq=before, limit=PAGE_SIZE, op=op, trust=trust)
395
+ snaps = {i.id: i for i in ledger.list_snapshots()}
396
+
397
+ filters = el("nav", *[
398
+ el("a", label, href=url("/timeline", op=f_op, trust=f_trust),
399
+ aria_current="page" if (f_op, f_trust) == (op, trust) else None)
400
+ for label, f_op, f_trust in FILTERS
401
+ ], class_="filters", aria_label="Filter entries")
402
+ many_stores = _stores(entries)
403
+ rows = [event_row(e, url("/timeline", op=op, trust=trust, before=page_before, event=e.id),
404
+ selected is not None and e.seq == selected.seq, snaps, many_stores) for e in entries]
405
+ goes_on = len(entries) == PAGE_SIZE
406
+ listing = chain(rows, goes_on) if rows else el("p", "No entries match.")
407
+ pager: list[Markup] = []
408
+ if page_before is not None:
409
+ pager.append(el("a", "Newest", href=url("/timeline", op=op, trust=trust)))
410
+ if goes_on:
411
+ pager.append(el("a", "Older", href=url("/timeline", op=op, trust=trust, before=entries[-1].seq)))
412
+ side = inspector(selected) if selected else el(
413
+ "div", el("p", "Select an entry to see what changed, where it came from and its place in the ledger."),
414
+ class_="sheet")
415
+ if event_id and selected is None:
416
+ side = notice("That event does not exist.", "bad")
417
+ content = Markup("".join([
418
+ str(el("h1", "Timeline")),
419
+ str(el("p", "Every recorded change, newest entry first. A change found later is stamped with the time it was "
420
+ "found, so dates can look out of order.", class_="sub")),
421
+ str(filters),
422
+ str(el("div", Markup(str(el("div", listing, el("div", *pager, class_="pager")))), side,
423
+ class_="layout has-sheet" if selected else "layout")),
424
+ ]))
425
+ return Page(200, document(ctx, "Timeline", "timeline", content))
426
+
427
+
428
+ # -- snapshots -----------------------------------------------------------------------------------------------------------
429
+
430
+ def snapshot_table(infos: list[SnapshotInfo]) -> Markup:
431
+ header = el("tr", *[el("th", h, scope="col") for h in ("Snapshot", "Taken", "Store", "Memories", "Anchor", "Label", "")])
432
+ rows = []
433
+ previous: dict[tuple, str] = {}
434
+ for info in sorted(infos, key=lambda i: int(i.id[1:])):
435
+ key = (info.backend, tuple(sorted(info.scope.items())))
436
+ compare = el("a", f"Compare with {previous[key]}", href=url("/diff", **{"from": previous[key], "to": info.id})) \
437
+ if key in previous else Markup("")
438
+ previous[key] = info.id
439
+ rows.append(el(
440
+ "tr",
441
+ el("td", el("a", info.id, href=f"/snapshot/{info.id}")), el("td", el("span", stamp(info.taken_at), class_="mono")),
442
+ el("td", f"{info.backend} ({scope_text(info.scope)})"),
443
+ el("td", info.count, el("br") if not info.complete else None,
444
+ el("span", "may be incomplete", class_="flag") if not info.complete else None),
445
+ el("td", f"after entry {info.ledger_seq}"),
446
+ el("td", el("span", inline(info.label or "", 100), class_="label")), el("td", compare)))
447
+ return el("div", el("table", el("thead", header), el("tbody", *reversed(rows))), class_="table-wrap")
448
+
449
+
450
+ def snapshots(ledger: Ledger, ctx: Context) -> Page:
451
+ infos = ledger.list_snapshots()
452
+ content = Markup("".join([
453
+ str(el("h1", "Snapshots")),
454
+ str(el("p", "Saved copies of what a memory store held at one moment. Their fingerprints are chained into the ledger.", class_="sub")),
455
+ str(snapshot_table(infos) if infos else el("p", "No snapshots yet. Run 'memdebug snapshot ...'.")),
456
+ ]))
457
+ return Page(200, document(ctx, "Snapshots", "snapshots", content))
458
+
459
+
460
+ def snapshot_detail(ledger: Ledger, ctx: Context, snapshot_id: str, page: int) -> Page:
461
+ snap = ledger.load_snapshot(snapshot_id)
462
+ info = snap.info
463
+ start = (page - 1) * SNAPSHOT_PAGE_SIZE
464
+ shown = snap.memories[start:start + SNAPSHOT_PAGE_SIZE]
465
+ rows = [el("tr", el("td", inline(m.id, 80)), el("td", el("span", inline(m.text, 120), class_="mem"),
466
+ el("details", el("summary", "full text"), el("pre", block(m.text))))) for m in shown]
467
+ pager: list[Markup] = []
468
+ if page > 1:
469
+ pager.append(el("a", "Previous", href=url(f"/snapshot/{snapshot_id}", page=page - 1)))
470
+ if start + SNAPSHOT_PAGE_SIZE < len(snap.memories):
471
+ pager.append(el("a", "Next", href=url(f"/snapshot/{snapshot_id}", page=page + 1)))
472
+ facts = [("Taken", when(info.taken_at)), ("Store", f"{info.backend} ({scope_text(info.scope)})"),
473
+ ("Memories", str(info.count)), ("Label", info.label or "-"),
474
+ ("Anchored", f"after ledger entry {info.ledger_seq} ({short(info.ledger_head)})")]
475
+ content = Markup("".join([
476
+ str(el("h1", f"Snapshot {info.id}")),
477
+ str(el("p", inline(info.label or "", 100), class_="sub") if info.label else Markup("")),
478
+ str(notice("This snapshot may be incomplete: the listing could have been cut short.") if not info.complete else Markup("")),
479
+ str(el("dl", *[Markup(str(el("dt", k)) + str(el("dd", inline(v, 200)))) for k, v in facts], class_="facts")),
480
+ str(el("h2", "Memories")),
481
+ str(el("div", el("table", el("thead", el("tr", el("th", "Id", scope="col"), el("th", "Text", scope="col"))),
482
+ el("tbody", *rows) if rows else el("tbody", el("tr", el("td", "Empty.", colspan="2")))),
483
+ class_="table-wrap")),
484
+ str(el("div", *pager, class_="pager")),
485
+ ]))
486
+ return Page(200, document(ctx, f"Snapshot {info.id}", "snapshots", content))
487
+
488
+
489
+ # -- compare -----------------------------------------------------------------------------------------------------------------
490
+
491
+ def diff_page(ledger: Ledger, ctx: Context, old_id: str | None, new_id: str | None, full: bool) -> Page:
492
+ infos = ledger.list_snapshots()
493
+ def options(chosen):
494
+ return [el("option", f"{i.id}: {stamp(i.taken_at)} ({i.count} memories)", value=i.id, selected=(i.id == chosen))
495
+ for i in infos]
496
+
497
+ form = el(
498
+ "form",
499
+ el("label", "From (earlier)", el("select", *options(old_id), name="from")),
500
+ el("label", "To (later)", el("select", *options(new_id), name="to")),
501
+ el("label", el("span", el("input", type="checkbox", name="full", value="1", checked=full), "show full texts")),
502
+ el("button", "Compare", type="submit"), class_="pick", method="get", action="/diff",
503
+ )
504
+ parts: list[Markup] = [el("h1", "Compare"), el("p", "What changed between two snapshots.", class_="sub")]
505
+ if len(infos) < 2:
506
+ parts.append(notice("You need at least two snapshots to compare. Run 'memdebug snapshot ...' twice."))
507
+ else:
508
+ parts.append(form)
509
+ if old_id and new_id:
510
+ try:
511
+ result = diff_snapshots(ledger.load_snapshot(old_id), ledger.load_snapshot(new_id))
512
+ except SnapshotError as exc:
513
+ parts.append(notice(str(exc), "bad"))
514
+ else:
515
+ parts += diff_body(result, full)
516
+ return Page(200, document(ctx, "Compare", "compare", Markup("".join(str(p) for p in parts))))
517
+
518
+
519
+ def diff_body(result: Diff, full: bool) -> list[Markup]:
520
+ parts: list[Markup] = [el("h2", f"{result.old.id} to {result.new.id}"), el(
521
+ "p", f"{result.count('changed')} changed, {result.count('added')} added, {result.count('removed')} removed "
522
+ f"({result.old.backend}, {scope_text(result.old.scope)})")]
523
+ parts += [notice(w) for w in result.warnings]
524
+ kinds = {"changed": ("UPDATE", "Changed"), "added": ("ADD", "Added"), "removed": ("DELETE", "Removed")}
525
+ items: list[Markup] = []
526
+ for change in result.changes[:MAX_DIFF_CHANGES]:
527
+ label, word = kinds[change.kind]
528
+ who = el("div", el("span", word.upper(), class_=f"badge {OP_CLASS[label]}"), el("strong", inline(short_id(change.memory_id), 80), class_="what"),
529
+ class_="who")
530
+ if change.kind == "changed":
531
+ marked = redline(change.before or "", change.after or "")
532
+ if marked is not None:
533
+ detail = marked
534
+ elif full:
535
+ detail = diff_lines(line_diff(change.before, change.after))
536
+ else:
537
+ detail = el("p", el("span", inline(change.before, 80), class_="mem"), " became ",
538
+ el("span", inline(change.after, 80), class_="mem"))
539
+ else:
540
+ text = change.after if change.kind == "added" else change.before
541
+ detail = el("div", block(text, 4000), class_="quote" if change.kind == "added" else "quote gone") if full \
542
+ else el("p", inline(text, 120), class_="mem")
543
+ items.append(el("div", who, el("div", detail), class_="change"))
544
+ parts.append(el("div", *items, class_="changes"))
545
+ if len(result.changes) > MAX_DIFF_CHANGES:
546
+ parts.append(notice(f"Showing the first {MAX_DIFF_CHANGES} of {len(result.changes)} changes."))
547
+ return parts
548
+
549
+
550
+ # -- integrity ---------------------------------------------------------------------------------------------------------------------
551
+
552
+ def integrity(ctx: Context, counts: dict, result: VerifyResult, checked_at: datetime) -> Page:
553
+ if result.ok:
554
+ verdict = notice(f"Intact. {counts['events']} events and {counts['snapshots']} snapshots match the chain.", "ok")
555
+ else:
556
+ verdict = el("div", notice("Problems were found. Do not trust this ledger until they are understood.", "bad"),
557
+ el("ul", *[el("li", p) for p in result.problems[:100]], class_="problems"))
558
+ content = Markup("".join([
559
+ str(el("h1", "Integrity")),
560
+ str(el("p", f"Checked {when(checked_at)}.", class_="sub")),
561
+ str(verdict),
562
+ str(el("h2", "What this does and does not prove")),
563
+ str(el("p", "It proves that no recorded event or snapshot was edited or removed from the middle, and that "
564
+ "each snapshot still matches its record in the chain.")),
565
+ str(el("p", "It cannot see: someone who rewrites the whole file and rebuilds the chain, or the removal of the "
566
+ "newest entries unless you compare against a head hash you saved earlier.")),
567
+ str(el("p", "Current head: ", el("code", counts["head"]))),
568
+ ]))
569
+ return Page(200, document(ctx, "Integrity", "integrity", content))