superlocalmemory 4.1.0 → 4.1.3
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.
- package/.claude-plugin/marketplace.json +29 -1
- package/CHANGELOG.md +107 -0
- package/README.md +37 -72
- package/ide/configs/codex-mcp.toml +3 -1
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -3
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +100 -31
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +94 -0
- package/src/superlocalmemory/server/recall_health.py +87 -10
- package/src/superlocalmemory/server/unified_daemon.py +55 -2
- package/src/superlocalmemory/storage/_migration_internals.py +23 -2
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
- package/src/superlocalmemory/storage/schema.py +23 -0
|
@@ -137,4 +137,4 @@ When the SLM MCP server is unavailable, use these CLI equivalents:
|
|
|
137
137
|
- **slm-optimize-advisor** — context compression and KV cache
|
|
138
138
|
- **slm-governance-advisor** — scope/role compliance, retention policies, GDPR
|
|
139
139
|
|
|
140
|
-
SuperLocalMemory v4.1.
|
|
140
|
+
SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later
|
package/pyproject.toml
CHANGED
|
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
|
|
|
32
32
|
os.environ["OMP_NUM_THREADS"] = "2"
|
|
33
33
|
# ---------------------------------------------------------------------------
|
|
34
34
|
|
|
35
|
-
__version__ = "4.1.
|
|
35
|
+
__version__ = "4.1.3"
|
|
36
36
|
|
|
37
37
|
_REQUIRED_VERSIONS = {
|
|
38
38
|
"sentence_transformers": "5.3.0",
|
|
@@ -2577,6 +2577,56 @@ def _migration_error_logs() -> list:
|
|
|
2577
2577
|
return []
|
|
2578
2578
|
|
|
2579
2579
|
|
|
2580
|
+
def _slm_version() -> str:
|
|
2581
|
+
"""The installed package version, or "unknown"."""
|
|
2582
|
+
try:
|
|
2583
|
+
from importlib.metadata import version
|
|
2584
|
+
return version("superlocalmemory")
|
|
2585
|
+
except Exception: # noqa: BLE001
|
|
2586
|
+
return "unknown"
|
|
2587
|
+
|
|
2588
|
+
|
|
2589
|
+
def _installed_plugin_versions() -> dict:
|
|
2590
|
+
"""Version of each editor plugin found on this machine, by install name.
|
|
2591
|
+
|
|
2592
|
+
The skills, agents and commands live in the editor's plugin channel rather
|
|
2593
|
+
than in the Python package, so upgrading with pip leaves them exactly where
|
|
2594
|
+
they were. This looks for them where each editor puts them, and returns an
|
|
2595
|
+
empty mapping when none is installed -- which is itself the answer worth
|
|
2596
|
+
reporting, because it means pip is the only thing being upgraded.
|
|
2597
|
+
|
|
2598
|
+
Best effort by design: an editor this does not know about should produce
|
|
2599
|
+
"not detected", never an error.
|
|
2600
|
+
"""
|
|
2601
|
+
import json
|
|
2602
|
+
from pathlib import Path
|
|
2603
|
+
|
|
2604
|
+
found: dict[str, str] = {}
|
|
2605
|
+
roots = (
|
|
2606
|
+
# Claude Code: marketplace installs and directly-added plugins.
|
|
2607
|
+
Path.home() / ".claude" / "plugins",
|
|
2608
|
+
# Codex and VS Code copies, when placed by hand.
|
|
2609
|
+
Path.home() / ".codex" / "plugins",
|
|
2610
|
+
Path.home() / ".vscode" / "extensions",
|
|
2611
|
+
)
|
|
2612
|
+
for root in roots:
|
|
2613
|
+
if not root.is_dir():
|
|
2614
|
+
continue
|
|
2615
|
+
for manifest in list(root.glob("*/.claude-plugin/plugin.json")) + \
|
|
2616
|
+
list(root.glob("*/plugin.json")) + \
|
|
2617
|
+
list(root.glob("*/*/.claude-plugin/plugin.json")):
|
|
2618
|
+
try:
|
|
2619
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
2620
|
+
except Exception: # noqa: BLE001 — a sibling plugin's bad json is not ours
|
|
2621
|
+
continue
|
|
2622
|
+
if str(data.get("name", "")) != "superlocalmemory":
|
|
2623
|
+
continue
|
|
2624
|
+
found[str(manifest.parent.parent.name)] = str(
|
|
2625
|
+
data.get("version", "unknown")
|
|
2626
|
+
)
|
|
2627
|
+
return found
|
|
2628
|
+
|
|
2629
|
+
|
|
2580
2630
|
def _detect_all_installs() -> list:
|
|
2581
2631
|
"""Thin shim so cmd_doctor can be tested without importing install_detector."""
|
|
2582
2632
|
try:
|
|
@@ -3093,6 +3143,50 @@ def cmd_doctor(args: Namespace) -> None:
|
|
|
3093
3143
|
except Exception as _inst_exc: # noqa: BLE001 — never break doctor
|
|
3094
3144
|
_check("install_versions", "WARN", f"could not probe installs: {_inst_exc}")
|
|
3095
3145
|
|
|
3146
|
+
# 14. The skills, agents and commands are NOT in the Python package.
|
|
3147
|
+
# They ship through the editor's own plugin channel -- `plugin/` in the
|
|
3148
|
+
# repository -- so `pip install --upgrade` cannot move them, and until now
|
|
3149
|
+
# nothing said so. 4.1 changed 76 files across those trees; a user who
|
|
3150
|
+
# upgraded the package and read a clean `slm doctor` had every reason to
|
|
3151
|
+
# believe they had all of it, and no way to find out otherwise.
|
|
3152
|
+
try:
|
|
3153
|
+
_pkg_version = _slm_version()
|
|
3154
|
+
_pl = _installed_plugin_versions()
|
|
3155
|
+
if not _pl:
|
|
3156
|
+
_check(
|
|
3157
|
+
"plugin_skills",
|
|
3158
|
+
"WARN",
|
|
3159
|
+
f"package is {_pkg_version}; no editor plugin detected, so the "
|
|
3160
|
+
f"skills, agents and commands are not installed or updated by "
|
|
3161
|
+
f"pip",
|
|
3162
|
+
fix="Claude Code: claude plugin marketplace add "
|
|
3163
|
+
"qualixar/superlocalmemory && claude plugin install "
|
|
3164
|
+
"superlocalmemory@qualixar "
|
|
3165
|
+
"Codex / VS Code: copy codex-plugin/ or copilot-plugin/ "
|
|
3166
|
+
"from the tag you are on",
|
|
3167
|
+
)
|
|
3168
|
+
else:
|
|
3169
|
+
_stale = {n: v for n, v in _pl.items() if v != _pkg_version}
|
|
3170
|
+
if _stale:
|
|
3171
|
+
_check(
|
|
3172
|
+
"plugin_skills",
|
|
3173
|
+
"WARN",
|
|
3174
|
+
"package is %s; plugin content still at %s" % (
|
|
3175
|
+
_pkg_version,
|
|
3176
|
+
", ".join(f"{n}={v}" for n, v in sorted(_stale.items())),
|
|
3177
|
+
),
|
|
3178
|
+
fix="claude plugin marketplace update qualixar && "
|
|
3179
|
+
"claude plugin update superlocalmemory@qualixar",
|
|
3180
|
+
)
|
|
3181
|
+
else:
|
|
3182
|
+
_check(
|
|
3183
|
+
"plugin_skills",
|
|
3184
|
+
"PASS",
|
|
3185
|
+
f"plugin content matches the package ({_pkg_version})",
|
|
3186
|
+
)
|
|
3187
|
+
except Exception as _pl_exc: # noqa: BLE001 — never break doctor
|
|
3188
|
+
_check("plugin_skills", "WARN", f"could not probe plugins: {_pl_exc}")
|
|
3189
|
+
|
|
3096
3190
|
# 14. Migration error logs — surface any unresolved failure from a previous
|
|
3097
3191
|
# upgrade attempt. The daemon writes these and they persist until the
|
|
3098
3192
|
# user takes action; doctor is the right place to surface them.
|
|
@@ -39,6 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
|
|
40
40
|
import logging
|
|
41
41
|
import threading
|
|
42
|
+
import time
|
|
42
43
|
from contextlib import nullcontext
|
|
43
44
|
from dataclasses import dataclass
|
|
44
45
|
|
|
@@ -67,6 +68,17 @@ class RecallHealth:
|
|
|
67
68
|
checks: int = 0
|
|
68
69
|
last_semantic_score: float = 0.0
|
|
69
70
|
last_error: str = ""
|
|
71
|
+
#: When the last tick finished, as a unix timestamp. A tick that finds
|
|
72
|
+
#: nothing wrong logs nothing, which is correct -- a monitor that narrates
|
|
73
|
+
#: every success is a monitor whose real warnings get skimmed past. But it
|
|
74
|
+
#: left no way to tell a monitor that is ticking quietly from a thread that
|
|
75
|
+
#: died or never started, and that ambiguity cost someone an hour of looking
|
|
76
|
+
#: for log lines that were never going to appear. So the fact of the tick is
|
|
77
|
+
#: recorded here and surfaced on /health, where it can be checked instead of
|
|
78
|
+
#: inferred.
|
|
79
|
+
last_tick_at: float = 0.0
|
|
80
|
+
#: Whether the embedder could produce a vector at the last tick.
|
|
81
|
+
embedder_alive: bool = True
|
|
70
82
|
|
|
71
83
|
|
|
72
84
|
def _max_semantic(results) -> float:
|
|
@@ -91,6 +103,38 @@ def _get_embedder(engine):
|
|
|
91
103
|
return emb
|
|
92
104
|
|
|
93
105
|
|
|
106
|
+
def _embedder_is_dead(engine) -> bool:
|
|
107
|
+
"""Can the embedder produce a vector right now?
|
|
108
|
+
|
|
109
|
+
Asked directly, because it cannot be inferred from a recall. The monitor
|
|
110
|
+
used to decide the embedder was fine whenever the probe recall came back
|
|
111
|
+
with no results at all -- and a dead embedder is one of the reasons a recall
|
|
112
|
+
comes back with no results, so the one symptom that should have triggered a
|
|
113
|
+
heal was read as proof that none was needed.
|
|
114
|
+
|
|
115
|
+
That is not hypothetical. An idle-timeout kill leaves no worker; the next
|
|
116
|
+
probe finds nothing by meaning, finds nothing by keyword either because the
|
|
117
|
+
probe phrase appears in nobody's memories, and returns zero results. The
|
|
118
|
+
monitor then recorded "healthy", logged nothing, and never respawned the
|
|
119
|
+
worker -- so ``readiness.embedding`` stayed false and the daemon sat in
|
|
120
|
+
``warming`` until someone restarted it by hand, with not one line in the log
|
|
121
|
+
to say why.
|
|
122
|
+
|
|
123
|
+
Fails safe in the opposite direction from before: an embedder this cannot
|
|
124
|
+
reach is reported dead, so the worst case is one unnecessary re-warm rather
|
|
125
|
+
than a silent outage.
|
|
126
|
+
"""
|
|
127
|
+
emb = _get_embedder(engine)
|
|
128
|
+
if emb is None:
|
|
129
|
+
return False # BM25-only by configuration; nothing to heal.
|
|
130
|
+
warm = getattr(emb, "is_warm", None)
|
|
131
|
+
if warm is not None and not warm:
|
|
132
|
+
return True
|
|
133
|
+
if getattr(emb, "_available", True) is False:
|
|
134
|
+
return True
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
94
138
|
def _heal_embedder(engine, *, log) -> bool:
|
|
95
139
|
"""Tier 3: reset the cached availability flag and re-exercise the embedder.
|
|
96
140
|
|
|
@@ -169,10 +213,27 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
|
|
|
169
213
|
results = list(getattr(resp, "results", []) or [])
|
|
170
214
|
sem = _max_semantic(results)
|
|
171
215
|
state.last_semantic_score = sem
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
#
|
|
175
|
-
|
|
216
|
+
state.last_tick_at = time.time()
|
|
217
|
+
|
|
218
|
+
# Tier 2: readiness. Two independent signatures, and the second one is why
|
|
219
|
+
# this monitor exists.
|
|
220
|
+
#
|
|
221
|
+
# * rows present but semantic never fired -> warm-but-broken
|
|
222
|
+
# * the embedder cannot produce a vector -> dead, whatever the recall said
|
|
223
|
+
#
|
|
224
|
+
# The second used to be missing, and its absence was load-bearing: zero
|
|
225
|
+
# results was treated as "not this signature", so the case where the embedder
|
|
226
|
+
# is dead AND the probe matches nothing by keyword -- which is the normal
|
|
227
|
+
# shape of an idle-timeout kill -- came out as healthy, silently.
|
|
228
|
+
dead = _embedder_is_dead(engine)
|
|
229
|
+
state.embedder_alive = not dead
|
|
230
|
+
broken = dead or (bool(results) and sem <= 0.0)
|
|
231
|
+
if dead:
|
|
232
|
+
log.critical(
|
|
233
|
+
"recall-health: embedder cannot produce a vector (%d probe results) "
|
|
234
|
+
"— attempting self-heal",
|
|
235
|
+
len(results),
|
|
236
|
+
)
|
|
176
237
|
if not broken:
|
|
177
238
|
if not state.healthy:
|
|
178
239
|
log.warning(
|
|
@@ -184,12 +245,16 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
|
|
|
184
245
|
state.last_error = ""
|
|
185
246
|
return state
|
|
186
247
|
|
|
187
|
-
# Tier 3: self-heal.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
248
|
+
# Tier 3: self-heal. The dead-embedder case already said so above; saying
|
|
249
|
+
# "semantic channel DEAD (max semantic=0.0)" as well would be a second,
|
|
250
|
+
# differently-worded CRITICAL about the same tick, and one of the two would
|
|
251
|
+
# be describing a symptom the reader does not have.
|
|
252
|
+
if not dead:
|
|
253
|
+
log.critical(
|
|
254
|
+
"recall-health: semantic channel DEAD (%d results, max semantic=0.0) "
|
|
255
|
+
"— embedder returning None; attempting self-heal",
|
|
256
|
+
len(results),
|
|
257
|
+
)
|
|
193
258
|
if _heal_embedder(engine, log=log):
|
|
194
259
|
state.total_heals += 1
|
|
195
260
|
state.healthy = True
|
|
@@ -256,6 +321,7 @@ def start_recall_health_monitor(engine, *, interval_s: int = DEFAULT_INTERVAL_S,
|
|
|
256
321
|
def get_recall_health() -> dict:
|
|
257
322
|
"""Snapshot for /health surfacing (visibility — never silent degradation)."""
|
|
258
323
|
s = _GLOBAL_STATE
|
|
324
|
+
now = time.time()
|
|
259
325
|
return {
|
|
260
326
|
"recall_healthy": s.healthy,
|
|
261
327
|
"consecutive_failures": s.consecutive_failures,
|
|
@@ -263,4 +329,15 @@ def get_recall_health() -> dict:
|
|
|
263
329
|
"checks": s.checks,
|
|
264
330
|
"last_semantic_score": round(s.last_semantic_score, 4),
|
|
265
331
|
"last_error": s.last_error,
|
|
332
|
+
# Proof of life. A tick that finds nothing wrong logs nothing, so there
|
|
333
|
+
# was no way to tell this monitor apart from a thread that never started
|
|
334
|
+
# -- someone spent an hour reading logs for lines that were never going
|
|
335
|
+
# to be written. These two answer that without needing the log at all:
|
|
336
|
+
# if seconds_since_last_tick keeps climbing past the interval, the thread
|
|
337
|
+
# is gone.
|
|
338
|
+
"last_tick_at": round(s.last_tick_at, 3) if s.last_tick_at else None,
|
|
339
|
+
"seconds_since_last_tick": (
|
|
340
|
+
round(now - s.last_tick_at, 1) if s.last_tick_at else None
|
|
341
|
+
),
|
|
342
|
+
"embedder_alive": s.embedder_alive,
|
|
266
343
|
}
|
|
@@ -511,6 +511,49 @@ _MIGRATION_EXEMPT_PATH_PREFIXES: tuple[str, ...] = (
|
|
|
511
511
|
)
|
|
512
512
|
|
|
513
513
|
|
|
514
|
+
def _serving_blocked_by(migration_result: dict) -> list[str]:
|
|
515
|
+
"""Failed migrations that should stop this daemon serving. Fail-closed.
|
|
516
|
+
|
|
517
|
+
A failed migration used to 503 every route without asking what had failed.
|
|
518
|
+
For a missing table that is right. For a data invariant that ordinary use can
|
|
519
|
+
re-violate it is not: one drifted row made the whole store unreachable until
|
|
520
|
+
somebody restarted it by hand, and the restart fixed nothing that a
|
|
521
|
+
background repair would not have fixed on its own.
|
|
522
|
+
|
|
523
|
+
A migration may answer for itself by exposing ``blocks_serving(conn)``.
|
|
524
|
+
Anything that does not is treated as blocking, so this cannot quietly open a
|
|
525
|
+
door for a migration nobody has thought about.
|
|
526
|
+
"""
|
|
527
|
+
failed = list(migration_result.get("failed") or [])
|
|
528
|
+
if not failed:
|
|
529
|
+
return []
|
|
530
|
+
try:
|
|
531
|
+
import sqlite3
|
|
532
|
+
|
|
533
|
+
from superlocalmemory.infra.data_root import state_path
|
|
534
|
+
from superlocalmemory.storage._migration_internals import _MODULES
|
|
535
|
+
except Exception: # noqa: BLE001 — never let this decide by crashing
|
|
536
|
+
return failed
|
|
537
|
+
|
|
538
|
+
blocking: list[str] = []
|
|
539
|
+
for name in failed:
|
|
540
|
+
decide = getattr(_MODULES.get(name), "blocks_serving", None)
|
|
541
|
+
if not callable(decide):
|
|
542
|
+
blocking.append(name)
|
|
543
|
+
continue
|
|
544
|
+
try:
|
|
545
|
+
db = state_path("memory.db")
|
|
546
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
547
|
+
try:
|
|
548
|
+
if decide(conn):
|
|
549
|
+
blocking.append(name)
|
|
550
|
+
finally:
|
|
551
|
+
conn.close()
|
|
552
|
+
except Exception: # noqa: BLE001 — unknown means blocking
|
|
553
|
+
blocking.append(name)
|
|
554
|
+
return blocking
|
|
555
|
+
|
|
556
|
+
|
|
514
557
|
def _is_migration_exempt_path(path: str) -> bool:
|
|
515
558
|
"""Return True for health, status, and repair paths that must stay reachable
|
|
516
559
|
even when the daemon reports a schema migration failure.
|
|
@@ -4009,7 +4052,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
4009
4052
|
@application.middleware("http")
|
|
4010
4053
|
async def _migration_readiness_gate(request, call_next):
|
|
4011
4054
|
migration_result = getattr(application.state, "migration_result", None)
|
|
4012
|
-
if migration_result and migration_result
|
|
4055
|
+
if migration_result and _serving_blocked_by(migration_result):
|
|
4013
4056
|
if not _is_migration_exempt_path(request.url.path):
|
|
4014
4057
|
from fastapi.responses import JSONResponse
|
|
4015
4058
|
return JSONResponse(
|
|
@@ -4278,7 +4321,14 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4278
4321
|
(migration_result or {}).get("failed", []) or []
|
|
4279
4322
|
)
|
|
4280
4323
|
migration_details = (migration_result or {}).get("details", {}) or {}
|
|
4281
|
-
|
|
4324
|
+
# Ready means "can serve", so it keys off the failures that actually
|
|
4325
|
+
# stop this daemon serving -- not off every failure. A data invariant
|
|
4326
|
+
# that ordinary use re-violated leaves every route working; reporting
|
|
4327
|
+
# not-ready for it told operators to restart, which fixed nothing a
|
|
4328
|
+
# background repair would not have fixed. Everything still shows up in
|
|
4329
|
+
# migration_failures and migration_failure_reasons below, named.
|
|
4330
|
+
migration_blocking = _serving_blocked_by(migration_result or {})
|
|
4331
|
+
migrations_ready = bool(migration_result) and not migration_blocking
|
|
4282
4332
|
if migration_details.get("_crash"):
|
|
4283
4333
|
migrations_ready = False
|
|
4284
4334
|
writer_runtime = getattr(
|
|
@@ -4296,6 +4346,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4296
4346
|
"embedding": embedding_ready,
|
|
4297
4347
|
"recall_health": recall_health.get("recall_healthy") is True,
|
|
4298
4348
|
"migration_failures": migration_failures,
|
|
4349
|
+
# Which of those are the reason this daemon will not serve, as
|
|
4350
|
+
# opposed to the ones it is reporting while serving normally.
|
|
4351
|
+
"migration_blocking": migration_blocking,
|
|
4299
4352
|
# WHY each one failed, not just which. The runner already produces
|
|
4300
4353
|
# a precise sentence per migration -- "safe repair did not restore
|
|
4301
4354
|
# M043_...", "schema verification failed ... : <sqlite error>" --
|
|
@@ -332,6 +332,22 @@ def _delete_log(conn: sqlite3.Connection, name: str) -> None:
|
|
|
332
332
|
conn.execute("DELETE FROM migration_log WHERE name = ?", (name,))
|
|
333
333
|
|
|
334
334
|
|
|
335
|
+
def _why_unmet(mod, conn) -> str:
|
|
336
|
+
"""The migration's own account of which check does not hold.
|
|
337
|
+
|
|
338
|
+
A migration may expose ``unmet(conn)`` returning a sentence. Most do not,
|
|
339
|
+
and for those the caller keeps its generic wording. Never raises: this runs
|
|
340
|
+
while reporting a failure and must not become a second one.
|
|
341
|
+
"""
|
|
342
|
+
fn = getattr(mod, "unmet", None)
|
|
343
|
+
if not callable(fn):
|
|
344
|
+
return ""
|
|
345
|
+
try:
|
|
346
|
+
return str(fn(conn) or "")
|
|
347
|
+
except Exception: # noqa: BLE001 - a detail string is not worth a crash
|
|
348
|
+
return ""
|
|
349
|
+
|
|
350
|
+
|
|
335
351
|
def _apply_single(
|
|
336
352
|
conn: sqlite3.Connection,
|
|
337
353
|
migration: Migration,
|
|
@@ -414,9 +430,12 @@ def _apply_single(
|
|
|
414
430
|
try:
|
|
415
431
|
repair_fn(conn)
|
|
416
432
|
if not bool(verify_fn(conn)):
|
|
433
|
+
_why = _why_unmet(mod, conn)
|
|
417
434
|
return (
|
|
418
435
|
"failed",
|
|
419
|
-
f"safe repair did not restore
|
|
436
|
+
f"safe repair did not restore "
|
|
437
|
+
f"{migration.name}"
|
|
438
|
+
+ (f": {_why}" if _why else ""),
|
|
420
439
|
)
|
|
421
440
|
_upsert_log(conn, migration.name, ddl_hash, "complete")
|
|
422
441
|
return (
|
|
@@ -482,9 +501,11 @@ def _apply_single(
|
|
|
482
501
|
)
|
|
483
502
|
try:
|
|
484
503
|
if not bool(verify_fn(conn)):
|
|
504
|
+
_why = _why_unmet(mod, conn)
|
|
485
505
|
return (
|
|
486
506
|
"failed",
|
|
487
|
-
f"safe repair did not restore {migration.name}"
|
|
507
|
+
f"safe repair did not restore {migration.name}"
|
|
508
|
+
+ (f": {_why}" if _why else ""),
|
|
488
509
|
)
|
|
489
510
|
except sqlite3.Error as exc:
|
|
490
511
|
return (
|