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.
- memdebug/__init__.py +3 -0
- memdebug/__main__.py +3 -0
- memdebug/adapters/__init__.py +0 -0
- memdebug/adapters/base.py +37 -0
- memdebug/adapters/common.py +69 -0
- memdebug/adapters/folder.py +96 -0
- memdebug/adapters/markdown_git.py +731 -0
- memdebug/adapters/mem0.py +324 -0
- memdebug/adapters/openwebui.py +209 -0
- memdebug/adapters/restore.py +705 -0
- memdebug/agents.py +128 -0
- memdebug/backends.py +29 -0
- memdebug/cli.py +857 -0
- memdebug/demo.py +190 -0
- memdebug/describe.py +34 -0
- memdebug/diff.py +91 -0
- memdebug/docker_source.py +164 -0
- memdebug/errors.py +29 -0
- memdebug/hints.py +145 -0
- memdebug/ledger.py +700 -0
- memdebug/models.py +157 -0
- memdebug/monitor.py +246 -0
- memdebug/paths.py +19 -0
- memdebug/reconcile.py +83 -0
- memdebug/report.py +279 -0
- memdebug/rollback_flow.py +69 -0
- memdebug/selftest.py +482 -0
- memdebug/stores.py +276 -0
- memdebug/sync.py +177 -0
- memdebug/textsafe.py +73 -0
- memdebug/viewer/__init__.py +1 -0
- memdebug/viewer/html.py +136 -0
- memdebug/viewer/pages.py +569 -0
- memdebug/viewer/redline.py +242 -0
- memdebug/viewer/server.py +432 -0
- memdebug/viewer/style.py +218 -0
- memdebug/witness.py +199 -0
- memdebug-0.2.0.dist-info/METADATA +206 -0
- memdebug-0.2.0.dist-info/RECORD +43 -0
- memdebug-0.2.0.dist-info/WHEEL +4 -0
- memdebug-0.2.0.dist-info/entry_points.txt +2 -0
- memdebug-0.2.0.dist-info/licenses/LICENSE +202 -0
- memdebug-0.2.0.dist-info/licenses/NOTICE +4 -0
memdebug/report.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Reports on a ledger: for a person (Markdown), for a program (JSON) and for CI and code-scanning dashboards (SARIF).
|
|
2
|
+
|
|
3
|
+
Everything that came from a memory store is untrusted text. In Markdown it only ever appears inside a code fence that
|
|
4
|
+
is longer than any run of backticks in the text, so it cannot turn into headings, links or HTML. Control and
|
|
5
|
+
bidirectional characters are made visible. In JSON and SARIF it is a plain string value, bounded in length.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import stat
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .describe import rollback_details
|
|
20
|
+
from .errors import MemdebugError
|
|
21
|
+
from .hints import new_hints
|
|
22
|
+
from .ledger import Ledger
|
|
23
|
+
from .models import Op, Trust
|
|
24
|
+
from .textsafe import safe_text
|
|
25
|
+
from .witness import WitnessCheck
|
|
26
|
+
|
|
27
|
+
MAX_FINDINGS = 500
|
|
28
|
+
MAX_TEXT = 1500
|
|
29
|
+
|
|
30
|
+
RULES = {
|
|
31
|
+
"outside-history": ("A memory changed outside its store's own history", "warning",
|
|
32
|
+
"A memory was added, changed or removed without going through the store's own history (an uncommitted file "
|
|
33
|
+
"edit, a direct database change). Check whether you made the change."),
|
|
34
|
+
"untrusted-source": ("A memory came from an untrusted source", "warning",
|
|
35
|
+
"The recorded source of this memory is an untrusted one (for example a tool result the agent read)."),
|
|
36
|
+
"integrity": ("The ledger failed its integrity check", "error",
|
|
37
|
+
"The ledger's hash chain or a snapshot does not match what was recorded. Do not trust it until this is understood."),
|
|
38
|
+
"hint": ("Worth a second look", "note",
|
|
39
|
+
"The wording of what was added looks like a known poisoning pattern (an instruction to send data to an address, to stop "
|
|
40
|
+
"asking for confirmation, to weaken a safeguard, hidden characters, or a secret). A heuristic: it misses things and can be wrong."),
|
|
41
|
+
"witness": ("The ledger disagrees with its witness", "error",
|
|
42
|
+
"The ledger was rewritten or cut short compared with the witness file."),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Finding:
|
|
48
|
+
rule: str
|
|
49
|
+
level: str
|
|
50
|
+
message: str
|
|
51
|
+
memory_id: str | None = None
|
|
52
|
+
event_id: str | None = None
|
|
53
|
+
when: str | None = None
|
|
54
|
+
before: str | None = None
|
|
55
|
+
after: str | None = None
|
|
56
|
+
evidence: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Report:
|
|
61
|
+
generated_at: str
|
|
62
|
+
tool_version: str
|
|
63
|
+
ledger_name: str
|
|
64
|
+
integrity_ok: bool
|
|
65
|
+
integrity_problems: list[str]
|
|
66
|
+
witness: WitnessCheck | None
|
|
67
|
+
counts: dict
|
|
68
|
+
findings: list[Finding] = field(default_factory=list)
|
|
69
|
+
rollbacks: list[dict] = field(default_factory=list)
|
|
70
|
+
snapshots: list[dict] = field(default_factory=list)
|
|
71
|
+
truncated: bool = False
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def hinted(self) -> bool:
|
|
75
|
+
return any(f.rule == "hint" for f in self.findings)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def attention(self) -> bool:
|
|
79
|
+
return (not self.integrity_ok or (self.witness is not None and not self.witness.ok)
|
|
80
|
+
or any(f.level in ("warning", "error") for f in self.findings))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _text(value: str | None) -> str | None:
|
|
84
|
+
"""Memory text, bounded, with control and bidi characters made visible; line breaks are kept."""
|
|
85
|
+
if value is None:
|
|
86
|
+
return None
|
|
87
|
+
lines = value.replace("\r\n", "\n").split("\n")
|
|
88
|
+
out = "\n".join(safe_text(line, None) for line in lines)
|
|
89
|
+
return out if len(out) <= MAX_TEXT else out[:MAX_TEXT] + f"\n... [{len(out) - MAX_TEXT} more characters]"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_report(ledger: Ledger, *, ledger_name: str = "ledger", witness: WitnessCheck | None = None,
|
|
93
|
+
now: datetime | None = None) -> Report:
|
|
94
|
+
verdict = ledger.verify()
|
|
95
|
+
counts = ledger.counts()
|
|
96
|
+
report = Report(
|
|
97
|
+
generated_at=(now or datetime.now(timezone.utc)).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
98
|
+
tool_version=__version__, ledger_name=safe_text(ledger_name, 80), integrity_ok=verdict.ok,
|
|
99
|
+
integrity_problems=[safe_text(p, 300) for p in verdict.problems[:50]], witness=witness,
|
|
100
|
+
counts={"events": counts["events"], "snapshots": counts["snapshots"], "outside_history": counts["by_op"].get("EXTERNAL", 0),
|
|
101
|
+
"untrusted": counts["untrusted"], "rollbacks": counts["by_op"].get("ROLLBACK", 0), "hints": 0},
|
|
102
|
+
)
|
|
103
|
+
for entry in ledger.entries():
|
|
104
|
+
event = entry.event
|
|
105
|
+
when = event.ts.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
106
|
+
if event.op == Op.EXTERNAL and len(report.findings) < MAX_FINDINGS:
|
|
107
|
+
report.findings.append(Finding("outside-history", "warning",
|
|
108
|
+
f"{safe_text(event.memory_id, 80)} changed outside the store's own history",
|
|
109
|
+
safe_text(event.memory_id, 120), entry.id, when, _text(event.before), _text(event.after)))
|
|
110
|
+
elif event.trust == Trust.UNTRUSTED and event.op not in (Op.SNAPSHOT, Op.SNAPSHOT_DELETED, Op.ROLLBACK) and len(report.findings) < MAX_FINDINGS:
|
|
111
|
+
report.findings.append(Finding("untrusted-source", "warning",
|
|
112
|
+
f"{safe_text(event.memory_id, 80)} came from an untrusted source",
|
|
113
|
+
safe_text(event.memory_id, 120), entry.id, when, _text(event.before), _text(event.after)))
|
|
114
|
+
if event.op in (Op.ADD, Op.UPDATE, Op.EXTERNAL):
|
|
115
|
+
for hint in [h for h in new_hints(event.before, event.after, budget=0.1, max_chars=20_000) if h.severity == "warning"][:3]:
|
|
116
|
+
if len(report.findings) < MAX_FINDINGS:
|
|
117
|
+
report.findings.append(Finding("hint", "note", hint.message, safe_text(event.memory_id, 120), entry.id, when, evidence=hint.evidence))
|
|
118
|
+
if event.op == Op.ROLLBACK:
|
|
119
|
+
details = rollback_details(event)
|
|
120
|
+
if details is not None:
|
|
121
|
+
report.rollbacks.append({"id": entry.id, "rollback": event.memory_id, "when": when, "target": details["target"],
|
|
122
|
+
"files": details["file_count"], "undo_point": details["before_snapshot"],
|
|
123
|
+
"commit": details["commit"], "backup": details["backup"]})
|
|
124
|
+
report.truncated = len(report.findings) >= MAX_FINDINGS
|
|
125
|
+
report.counts["hints"] = sum(1 for f in report.findings if f.rule == "hint")
|
|
126
|
+
for info in ledger.list_snapshots():
|
|
127
|
+
report.snapshots.append({"id": info.id, "taken": info.taken_at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
128
|
+
"memories": info.count, "label": safe_text(info.label or "", 100), "complete": info.complete})
|
|
129
|
+
if not verdict.ok:
|
|
130
|
+
report.findings.insert(0, Finding("integrity", "error", "the ledger failed its integrity check: " + (report.integrity_problems[0] if report.integrity_problems else "")))
|
|
131
|
+
if witness is not None and not witness.ok:
|
|
132
|
+
report.findings.insert(0, Finding("witness", "error", "the ledger disagrees with its witness: " + safe_text(witness.problems[0] if witness.problems else "", 300)))
|
|
133
|
+
return report
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# -- Markdown -------------------------------------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _fence(text: str) -> str:
|
|
139
|
+
longest = max((len(m.group(0)) for m in re.finditer(r"`+", text)), default=0)
|
|
140
|
+
return "`" * max(3, longest + 1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _code_block(text: str | None) -> str:
|
|
144
|
+
if text is None:
|
|
145
|
+
return "_(none)_\n"
|
|
146
|
+
fence = _fence(text)
|
|
147
|
+
return f"{fence}text\n{text}\n{fence}\n"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _code_span(text: str) -> str:
|
|
151
|
+
longest = max((len(m.group(0)) for m in re.finditer(r"`+", text)), default=0)
|
|
152
|
+
ticks = "`" * (longest + 1)
|
|
153
|
+
pad = " " if text.startswith("`") or text.endswith("`") else ""
|
|
154
|
+
return f"{ticks}{pad}{text}{pad}{ticks}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def to_markdown(report: Report) -> str:
|
|
158
|
+
out = ["# memdebug report", "",
|
|
159
|
+
f"Generated {report.generated_at} by memdebug {report.tool_version}. Ledger: {_code_span(report.ledger_name)}.", ""]
|
|
160
|
+
verdict = "INTACT" if report.integrity_ok else "PROBLEMS FOUND"
|
|
161
|
+
out += ["## Summary", "",
|
|
162
|
+
f"- Ledger integrity: **{verdict}**",
|
|
163
|
+
f"- Entries: {report.counts['events']}, snapshots: {report.counts['snapshots']}, rollbacks: {report.counts['rollbacks']}",
|
|
164
|
+
f"- Changes outside the store's own history: **{report.counts['outside_history']}**",
|
|
165
|
+
f"- Memories from an untrusted source: {report.counts['untrusted']}",
|
|
166
|
+
f"- Wording worth a second look (heuristic hints): {report.counts['hints']}"]
|
|
167
|
+
if report.witness is not None:
|
|
168
|
+
w = report.witness
|
|
169
|
+
out.append(f"- Witness: **{'agrees' if w.ok else 'DISAGREES'}** ({w.lines} witnessed head(s), {w.unwitnessed} newer entries not yet witnessed)")
|
|
170
|
+
if not report.integrity_ok:
|
|
171
|
+
out += ["", "### Integrity problems", ""] + [f"- {_code_span(p)}" for p in report.integrity_problems]
|
|
172
|
+
out += ["", "## Findings", ""]
|
|
173
|
+
if not report.findings:
|
|
174
|
+
out.append("Nothing needs a look.")
|
|
175
|
+
for number, f in enumerate(report.findings, 1):
|
|
176
|
+
out += [f"### {number}. {f.rule} ({f.level})", "", f"{_code_span(f.message)}", ""]
|
|
177
|
+
if f.memory_id:
|
|
178
|
+
out.append(f"- Memory: {_code_span(f.memory_id)}")
|
|
179
|
+
if f.event_id:
|
|
180
|
+
out.append(f"- Ledger entry: {_code_span(f.event_id)}, noticed {f.when}")
|
|
181
|
+
if f.evidence:
|
|
182
|
+
out.append(f"- Evidence: {_code_span(f.evidence)}")
|
|
183
|
+
if f.before is not None or f.after is not None:
|
|
184
|
+
out += ["", "Before:", "", _code_block(f.before), "After:", "", _code_block(f.after)]
|
|
185
|
+
out.append("")
|
|
186
|
+
if report.truncated:
|
|
187
|
+
out.append(f"_Only the first {MAX_FINDINGS} findings are shown._\n")
|
|
188
|
+
if report.rollbacks:
|
|
189
|
+
out += ["## Rollbacks", ""]
|
|
190
|
+
for r in report.rollbacks:
|
|
191
|
+
out.append(f"- {_code_span(r['rollback'])} at {r['when']}: back to {_code_span(r['target'])}, {r['files']} file(s); "
|
|
192
|
+
f"undo point {_code_span(r['undo_point'] or '-')}")
|
|
193
|
+
out.append("")
|
|
194
|
+
if report.snapshots:
|
|
195
|
+
out += ["## Snapshots", ""]
|
|
196
|
+
for s in report.snapshots:
|
|
197
|
+
out.append(f"- {_code_span(s['id'])} {s['taken']}: {s['memories']} memories" + (f", {_code_span(s['label'])}" if s["label"] else ""))
|
|
198
|
+
out.append("")
|
|
199
|
+
out += ["---", "memdebug records what a memory store holds and what changed. It does not say who or what caused a change, and "
|
|
200
|
+
"it is not a prompt-injection detector. See docs/threat-model.md.", ""]
|
|
201
|
+
return "\n".join(out)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# -- JSON -----------------------------------------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def to_dict(report: Report) -> dict:
|
|
207
|
+
return {
|
|
208
|
+
"tool": {"name": "memdebug", "version": report.tool_version}, "generated_at": report.generated_at, "ledger": report.ledger_name,
|
|
209
|
+
"integrity": {"ok": report.integrity_ok, "problems": report.integrity_problems},
|
|
210
|
+
"witness": None if report.witness is None else {"ok": report.witness.ok, "problems": [safe_text(p, 300) for p in report.witness.problems],
|
|
211
|
+
"lines": report.witness.lines, "unwitnessed": report.witness.unwitnessed},
|
|
212
|
+
"counts": report.counts, "attention": report.attention, "truncated": report.truncated,
|
|
213
|
+
"findings": [{"rule": f.rule, "level": f.level, "message": f.message, "memory_id": f.memory_id, "ledger_entry": f.event_id,
|
|
214
|
+
"noticed": f.when, "before": f.before, "after": f.after, "evidence": f.evidence} for f in report.findings],
|
|
215
|
+
"rollbacks": report.rollbacks, "snapshots": report.snapshots,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def to_json(report: Report) -> str:
|
|
220
|
+
return json.dumps(to_dict(report), indent=2, ensure_ascii=True) + "\n"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# -- SARIF ----------------------------------------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
_PATHLIKE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-/ ]{0,200}\.[A-Za-z0-9]{1,8}\Z") # a file name with an extension, not just any id
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def to_sarif(report: Report) -> str:
|
|
229
|
+
rules = [{"id": rid, "name": rid, "shortDescription": {"text": short}, "fullDescription": {"text": full},
|
|
230
|
+
"defaultConfiguration": {"level": level}} for rid, (short, level, full) in RULES.items()]
|
|
231
|
+
results = []
|
|
232
|
+
for f in report.findings:
|
|
233
|
+
item: dict = {"ruleId": f.rule, "level": f.level, "message": {"text": f.message},
|
|
234
|
+
"properties": {"ledger_entry": f.event_id, "noticed": f.when, "evidence": f.evidence}}
|
|
235
|
+
if f.memory_id:
|
|
236
|
+
location: dict = {"logicalLocations": [{"name": f.memory_id, "kind": "memory"}]}
|
|
237
|
+
if _PATHLIKE.match(f.memory_id) and ".." not in f.memory_id.split("/"):
|
|
238
|
+
location["physicalLocation"] = {"artifactLocation": {"uri": quote(f.memory_id, safe="/")}}
|
|
239
|
+
item["locations"] = [location]
|
|
240
|
+
results.append(item)
|
|
241
|
+
document = {"$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0",
|
|
242
|
+
"runs": [{"tool": {"driver": {"name": "memdebug", "version": report.tool_version, "rules": rules}}, "results": results}]}
|
|
243
|
+
return json.dumps(document, indent=2, ensure_ascii=True) + "\n"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
RENDERERS = {"markdown": to_markdown, "json": to_json, "sarif": to_sarif}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def write_report(path, text: str, *, force: bool = False) -> None:
|
|
250
|
+
"""Write a report file. It contains memory text, so it is created private; an existing file is only replaced
|
|
251
|
+
with `force`, and links and folders are refused."""
|
|
252
|
+
from pathlib import Path
|
|
253
|
+
|
|
254
|
+
target = Path(path)
|
|
255
|
+
try:
|
|
256
|
+
info = os.lstat(target)
|
|
257
|
+
except FileNotFoundError:
|
|
258
|
+
info = None
|
|
259
|
+
if info is not None:
|
|
260
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
261
|
+
raise MemdebugError("the report path must be a plain file, not a link or a folder")
|
|
262
|
+
if not force:
|
|
263
|
+
raise MemdebugError("that file already exists; use --force to replace it")
|
|
264
|
+
if not target.parent.is_dir():
|
|
265
|
+
raise MemdebugError("the folder for the report does not exist")
|
|
266
|
+
temporary = target.parent / f".memdebug-report-{secrets.token_hex(6)}.tmp"
|
|
267
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
268
|
+
try:
|
|
269
|
+
with os.fdopen(fd, "wb") as handle:
|
|
270
|
+
handle.write(text.encode("utf-8"))
|
|
271
|
+
handle.flush()
|
|
272
|
+
os.fsync(handle.fileno())
|
|
273
|
+
os.replace(temporary, target)
|
|
274
|
+
except BaseException:
|
|
275
|
+
try:
|
|
276
|
+
os.unlink(temporary)
|
|
277
|
+
except OSError:
|
|
278
|
+
pass
|
|
279
|
+
raise
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The steps around a markdown rollback, shared by the command line and the demo.
|
|
2
|
+
|
|
3
|
+
Restorer (adapters/restore.py) changes the files. This module wraps it so that a rollback is always recorded the same
|
|
4
|
+
way: the state before is snapshotted (that snapshot is the undo point), the files are restored, the state after is
|
|
5
|
+
snapshotted, and a ROLLBACK entry is chained into the ledger. The ledger record is checked BEFORE any file is touched,
|
|
6
|
+
so recording can never fail after the damage is done.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from .adapters.restore import Outcome, Plan, Restorer
|
|
15
|
+
from .errors import MemdebugError
|
|
16
|
+
from .ledger import MAX_ROLLBACK_FILES, Ledger, validate_rollback_details
|
|
17
|
+
from .models import LedgerEntry, Snapshot, SnapshotInfo
|
|
18
|
+
from .sync import sync
|
|
19
|
+
from .textsafe import safe_text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RollbackResult:
|
|
24
|
+
outcome: Outcome
|
|
25
|
+
before: SnapshotInfo # the undo point
|
|
26
|
+
after: SnapshotInfo | None = None
|
|
27
|
+
entry: LedgerEntry | None = None # None when recording failed (see record_error)
|
|
28
|
+
record_error: str | None = None # the files WERE restored, but the ledger could not record it
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rollback_details(snapshot: Snapshot, plan: Plan, before_id: str | None, after_id: str | None,
|
|
32
|
+
outcome: Outcome | None = None) -> dict:
|
|
33
|
+
items = outcome.items if outcome else plan.items
|
|
34
|
+
return {"target": snapshot.info.id, "before_snapshot": before_id, "after_snapshot": after_id,
|
|
35
|
+
"commit": outcome.commit if outcome else None, "previous_head": plan.head,
|
|
36
|
+
"backup": outcome.backup_ref if outcome else None, "file_count": len(items),
|
|
37
|
+
"files": [{"path": i.path, "action": i.action, "source": i.source} for i in items[:MAX_ROLLBACK_FILES]]}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_rollback(adapter, ledger: Ledger, scope: dict[str, str], restorer: Restorer, snapshot: Snapshot, plan: Plan, *,
|
|
41
|
+
only: list[str] | None = None, remove_added: bool = False, settle: float = 1.0,
|
|
42
|
+
warn: Callable[[str], None] = lambda text: None) -> RollbackResult:
|
|
43
|
+
"""Carry out a confirmed plan. Raises MemdebugError if it could not be started or the files could not be restored."""
|
|
44
|
+
|
|
45
|
+
def record_state(note: str, written: dict | None = None) -> SnapshotInfo:
|
|
46
|
+
report = sync(adapter, ledger, scope, settle_seconds=settle, acknowledge=written)
|
|
47
|
+
for warning in report.warnings:
|
|
48
|
+
warn(safe_text(warning, 300))
|
|
49
|
+
if report.live is None:
|
|
50
|
+
raise MemdebugError("the sync did not produce a listing to snapshot")
|
|
51
|
+
return ledger.save_snapshot(adapter.name, scope, report.live.memories, complete=report.live.complete,
|
|
52
|
+
taken_at=datetime.now(timezone.utc), label=note)
|
|
53
|
+
|
|
54
|
+
try: # the record is checked now, so recording can never fail after the files have been changed
|
|
55
|
+
validate_rollback_details(rollback_details(snapshot, plan, None, None))
|
|
56
|
+
except ValueError as exc:
|
|
57
|
+
raise MemdebugError(f"this rollback could not be recorded ({safe_text(exc, 100)}), so it was not started") from exc
|
|
58
|
+
|
|
59
|
+
before = record_state(f"before rollback to {snapshot.info.id}") # this is what undoes the rollback
|
|
60
|
+
outcome = restorer.apply(snapshot, expected_plan_id=plan.plan_id, only=list(only or []), remove_added=remove_added)
|
|
61
|
+
result = RollbackResult(outcome=outcome, before=before)
|
|
62
|
+
try:
|
|
63
|
+
result.after = record_state(f"after rollback to {snapshot.info.id}",
|
|
64
|
+
{i.path: (None if i.action == "remove" else i.target_text) for i in outcome.items})
|
|
65
|
+
result.entry = ledger.record_rollback(adapter.name, scope, rollback_details(snapshot, plan, before.id, result.after.id, outcome),
|
|
66
|
+
ts=datetime.now(timezone.utc))
|
|
67
|
+
except MemdebugError as exc:
|
|
68
|
+
result.record_error = safe_text(exc, 300)
|
|
69
|
+
return result
|