mindslice 0.1.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.
- mindslice/__init__.py +58 -0
- mindslice/__main__.py +5 -0
- mindslice/baking/__init__.py +1 -0
- mindslice/baking/archive.py +66 -0
- mindslice/baking/bake.py +299 -0
- mindslice/baking/bake_batch.py +537 -0
- mindslice/baking/slice_transfer.py +265 -0
- mindslice/errors.py +104 -0
- mindslice/interfaces/__init__.py +1 -0
- mindslice/interfaces/bake_review.py +230 -0
- mindslice/interfaces/cli.py +392 -0
- mindslice/interfaces/cli_addressing.py +52 -0
- mindslice/interfaces/doctor.py +184 -0
- mindslice/interfaces/mcp_server.py +536 -0
- mindslice/interfaces/render.py +346 -0
- mindslice/interfaces/staging_review.py +92 -0
- mindslice/model/__init__.py +1 -0
- mindslice/model/addressing.py +251 -0
- mindslice/model/card.py +641 -0
- mindslice/model/lineage.py +228 -0
- mindslice/model/text.py +19 -0
- mindslice/model/tomlfmt.py +129 -0
- mindslice/operations/__init__.py +1 -0
- mindslice/operations/audit.py +195 -0
- mindslice/operations/boot.py +183 -0
- mindslice/operations/impact.py +98 -0
- mindslice/operations/recovery.py +113 -0
- mindslice/operations/staging.py +641 -0
- mindslice/operations/status.py +137 -0
- mindslice/operations/validation.py +250 -0
- mindslice/operations/writer.py +481 -0
- mindslice/retrieval/__init__.py +1 -0
- mindslice/retrieval/index.py +634 -0
- mindslice/retrieval/lexical.py +215 -0
- mindslice/retrieval/query.py +593 -0
- mindslice/storage/__init__.py +1 -0
- mindslice/storage/card_io.py +14 -0
- mindslice/storage/config.py +192 -0
- mindslice/storage/fsutil.py +141 -0
- mindslice/storage/history.py +182 -0
- mindslice/storage/locking.py +125 -0
- mindslice/storage/snapshots.py +262 -0
- mindslice/workspace.py +385 -0
- mindslice-0.1.0.dist-info/METADATA +98 -0
- mindslice-0.1.0.dist-info/RECORD +50 -0
- mindslice-0.1.0.dist-info/WHEEL +5 -0
- mindslice-0.1.0.dist-info/entry_points.txt +2 -0
- mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
- mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
- mindslice-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Always-loaded cards (``load = "always"``): the boot set, its rendering, and its write protection.
|
|
2
|
+
|
|
3
|
+
A card whose frontmatter says ``load = "always"`` holds session-level context: an agent persona, the
|
|
4
|
+
user's language, standing instructions. Every session reads it at start (MCP ``initialize``
|
|
5
|
+
instructions, ``manifest()``, ``mindslice boot``), whether or not a query would have found it.
|
|
6
|
+
Otherwise it is an ordinary card: searchable, readable, and subject to lineage like any other.
|
|
7
|
+
|
|
8
|
+
The boot set is each always-loaded card resolved as ``read()`` resolves it: a superseded card
|
|
9
|
+
forwards to its replacement, and baselines and extensions are composed in. Its *footprint* is
|
|
10
|
+
every card whose content (or lineage) decides what the set contains. Because those cards shape
|
|
11
|
+
every session, a write that touches the footprint is staged for the user's approval by default
|
|
12
|
+
(``write(stage=None)``), and ``apply()`` is that approval.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Iterable
|
|
20
|
+
|
|
21
|
+
from ..retrieval import query
|
|
22
|
+
from ..model.addressing import format_address, resolve_ref
|
|
23
|
+
from ..model.card import LOAD_ALWAYS, parse_card
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
26
|
+
from ..storage.config import MountTable
|
|
27
|
+
from ..workspace import Workspace
|
|
28
|
+
|
|
29
|
+
# Extensions composed into one boot card (read() pages them; the boot set shows them all up to this).
|
|
30
|
+
MAX_EXTENSIONS = 50
|
|
31
|
+
|
|
32
|
+
HEADER = (
|
|
33
|
+
"# Always-loaded memory\n\n"
|
|
34
|
+
"These cards come from the user's MindSlice memory and apply to the whole session (persona, language, "
|
|
35
|
+
"standing instructions). Follow them unless the user says otherwise. Changes to them are staged for the "
|
|
36
|
+
"user's approval."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _card(c: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
return {"address": c["address"], "title": c["title"], "summary": c.get("summary", ""), "body": c.get("body", "")}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def collect(ctx: "query._Ctx") -> tuple[list[dict[str, Any]], set[str]]:
|
|
45
|
+
"""``(entries, footprint)``: the rendered boot cards and every address that shapes them."""
|
|
46
|
+
entries: list[dict[str, Any]] = []
|
|
47
|
+
footprint: set[str] = set()
|
|
48
|
+
shown: set[str] = set()
|
|
49
|
+
for row in ctx.idx.always_cards():
|
|
50
|
+
address = format_address(row["mount"], row["path"])
|
|
51
|
+
r = query.resolve(ctx, address, max_backlinks=0, max_extensions=MAX_EXTENSIONS, bump_telemetry=False)
|
|
52
|
+
baselines = r.get("baselines", [])
|
|
53
|
+
extensions = r.get("extensions", [])
|
|
54
|
+
parts = [r["address"], *(b["address"] for b in baselines), *(e["address"] for e in extensions)]
|
|
55
|
+
footprint.update([address, *r.get("lineage_path", []), *parts])
|
|
56
|
+
if all(a in shown for a in parts):
|
|
57
|
+
continue # everything it would show is already in the set (e.g. an always-loaded extension)
|
|
58
|
+
shown.update(parts)
|
|
59
|
+
entry = _card(r) | {"mount": r["mount"], "role": r["role"], "sha256": r["sha256"]}
|
|
60
|
+
if r.get("redirected_from"):
|
|
61
|
+
entry["redirected_from"] = r["redirected_from"]
|
|
62
|
+
if r.get("status") != "active":
|
|
63
|
+
entry["status"] = r["status"]
|
|
64
|
+
if baselines:
|
|
65
|
+
entry["baselines"] = [_card(b) for b in baselines]
|
|
66
|
+
if extensions:
|
|
67
|
+
entry["extensions"] = [_card(e) for e in extensions]
|
|
68
|
+
if r.get("total_extensions", 0) > len(extensions):
|
|
69
|
+
entry["total_extensions"] = r["total_extensions"]
|
|
70
|
+
entries.append(entry)
|
|
71
|
+
return entries, footprint
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pending_approval(ws: "Workspace") -> list[dict[str, Any]]:
|
|
75
|
+
out = []
|
|
76
|
+
for path, entry in ws.staging.entries().items():
|
|
77
|
+
reason = entry.get("requires_approval")
|
|
78
|
+
if reason:
|
|
79
|
+
out.append({"id": path, "op": entry.get("op", "write"), "reason": reason})
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_empty_workspace(ws: "Workspace", table: "MountTable") -> bool:
|
|
84
|
+
"""True when only ``@overlay`` is mounted, no cards are indexed, and nothing is staged."""
|
|
85
|
+
if len(table.all()) > 1 or ws.staging.entries():
|
|
86
|
+
return False
|
|
87
|
+
return ws.index.conn.execute("SELECT 1 FROM cards LIMIT 1").fetchone() is None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def snapshot(ws: "Workspace", table: "MountTable") -> dict[str, Any]:
|
|
91
|
+
"""The boot set for a caller that already holds the workspace lock."""
|
|
92
|
+
entries, _ = collect(query._Ctx(ws, table, True))
|
|
93
|
+
out: dict[str, Any] = {"cards": entries, "total": len(entries)}
|
|
94
|
+
pending = pending_approval(ws)
|
|
95
|
+
if pending:
|
|
96
|
+
out["pending_approval"] = pending
|
|
97
|
+
if not entries and is_empty_workspace(ws, table):
|
|
98
|
+
out["empty_workspace"] = True
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def boot(ws: "Workspace") -> dict[str, Any]:
|
|
103
|
+
with ws._reading() as table:
|
|
104
|
+
return snapshot(ws, table)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fingerprint(entry: dict[str, Any]) -> str:
|
|
108
|
+
return hashlib.sha256(json.dumps(entry, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_markdown(cards: Iterable[dict[str, Any]], header: str = HEADER) -> str:
|
|
112
|
+
"""The boot cards as one Markdown block, ready for a system prompt. Empty when there are none."""
|
|
113
|
+
out: list[str] = []
|
|
114
|
+
|
|
115
|
+
def block(c: dict[str, Any], heading: str) -> None:
|
|
116
|
+
out.append(f"{heading} {c['title']} ({c['address']})")
|
|
117
|
+
for part in (c.get("summary", ""), c.get("body", "")):
|
|
118
|
+
if part.strip():
|
|
119
|
+
out.append("")
|
|
120
|
+
out.append(part.strip())
|
|
121
|
+
out.append("")
|
|
122
|
+
|
|
123
|
+
for c in cards:
|
|
124
|
+
for base in c.get("baselines", []):
|
|
125
|
+
block(base, "## Baseline:")
|
|
126
|
+
block(c, "##")
|
|
127
|
+
for ext in c.get("extensions", []):
|
|
128
|
+
block(ext, "### Extension:")
|
|
129
|
+
if not out:
|
|
130
|
+
return ""
|
|
131
|
+
prefix = header.rstrip() + "\n\n" if header.strip() else ""
|
|
132
|
+
return prefix + "\n".join(out).rstrip() + "\n"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def protection_reason(
|
|
136
|
+
ws: "Workspace",
|
|
137
|
+
table: "MountTable",
|
|
138
|
+
path: str,
|
|
139
|
+
texts: Iterable[str | None],
|
|
140
|
+
load: str | None,
|
|
141
|
+
lineage: Iterable[tuple[str, str]],
|
|
142
|
+
) -> str | None:
|
|
143
|
+
"""Why a write to ``@overlay/<path>`` changes the always-loaded context (None if it does not).
|
|
144
|
+
|
|
145
|
+
``texts`` are the card's current versions (overlay and staged), ``load`` and ``lineage`` the
|
|
146
|
+
write's own ``load`` argument and ``extends`` / ``supersedes`` references (named by kind in
|
|
147
|
+
``lineage``: ``[("extends", ref), ...]``).
|
|
148
|
+
"""
|
|
149
|
+
overlay = table.overlay
|
|
150
|
+
address = format_address(overlay.alias, path)
|
|
151
|
+
if load == LOAD_ALWAYS:
|
|
152
|
+
return f"it makes {address} always-loaded"
|
|
153
|
+
parsed = [parse_card(t) for t in texts if t is not None]
|
|
154
|
+
if any(p.load == LOAD_ALWAYS for p in parsed):
|
|
155
|
+
return f"{address} is always-loaded"
|
|
156
|
+
ctx = query._Ctx(ws, table, True)
|
|
157
|
+
_, footprint = collect(ctx)
|
|
158
|
+
if not footprint:
|
|
159
|
+
return None
|
|
160
|
+
if address in footprint:
|
|
161
|
+
return f"{address} is part of the always-loaded context"
|
|
162
|
+
cats = ws.index.catalogs()
|
|
163
|
+
refs = list(lineage)
|
|
164
|
+
for p in parsed:
|
|
165
|
+
refs += [("extends", raw) for raw in p.extends] + [("supersedes", raw) for raw in p.supersedes]
|
|
166
|
+
for verb, raw in refs:
|
|
167
|
+
res = resolve_ref(raw, overlay.alias, cats)
|
|
168
|
+
if res.resolved and res.address in footprint:
|
|
169
|
+
return f"it {verb} {res.address}, which is part of the always-loaded context"
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = [
|
|
174
|
+
"boot",
|
|
175
|
+
"snapshot",
|
|
176
|
+
"collect",
|
|
177
|
+
"is_empty_workspace",
|
|
178
|
+
"render_markdown",
|
|
179
|
+
"protection_reason",
|
|
180
|
+
"pending_approval",
|
|
181
|
+
"fingerprint",
|
|
182
|
+
"HEADER",
|
|
183
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Inbound impact of retiring a card: who still links to it or extends it.
|
|
2
|
+
|
|
3
|
+
When ``write()`` supersedes or deletes a card, the cards that ``[[link]]`` to it or ``extends`` it keep
|
|
4
|
+
pointing at the old card: a link whose target names an old value keeps stating that value, and an
|
|
5
|
+
extension keeps resting on a premise that changed. This module lists them, straight from the index
|
|
6
|
+
(plus staged overrides for ``stage=True``), so the caller can update or rebase them. It is pure
|
|
7
|
+
mechanism: no ranking, no judgment about which ones matter. Results are ordered by mount precedence,
|
|
8
|
+
then address, then target, and bounded by ``MAX_AFFECTED`` per list (totals are always reported).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Iterable
|
|
14
|
+
|
|
15
|
+
from ..model.addressing import MountCatalog, format_address, resolve_ref
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
18
|
+
from ..model.card import ParsedCard
|
|
19
|
+
from ..retrieval.index import Index
|
|
20
|
+
|
|
21
|
+
MAX_AFFECTED = 25
|
|
22
|
+
_KINDS = (("wikilink", "affected_backlinks", "links_to"), ("extends", "affected_extensions", "extends"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _split(address: str) -> tuple[str, str]:
|
|
26
|
+
mount, _, path = address.partition("/")
|
|
27
|
+
return mount, path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def supersede_targets(parsed: "ParsedCard", src_mount: str, cats: dict[str, MountCatalog]) -> list[str]:
|
|
31
|
+
"""Resolved addresses of the cards ``parsed`` supersedes, in order, without duplicates."""
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
for raw in parsed.supersedes:
|
|
34
|
+
res = resolve_ref(raw, src_mount, cats)
|
|
35
|
+
if res.resolved and res.address and res.address not in out:
|
|
36
|
+
out.append(res.address)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def inbound_impact(
|
|
41
|
+
index: "Index",
|
|
42
|
+
overlay_alias: str,
|
|
43
|
+
overlay_precedence: int,
|
|
44
|
+
targets: Iterable[str],
|
|
45
|
+
*,
|
|
46
|
+
overrides: dict[str, "ParsedCard | None"] | None = None,
|
|
47
|
+
cats: dict[str, MountCatalog] | None = None,
|
|
48
|
+
exclude: Iterable[str] = (),
|
|
49
|
+
dangling: bool = False,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""``affected_backlinks`` / ``affected_extensions`` (+ totals) for cards that reference ``targets``.
|
|
52
|
+
|
|
53
|
+
``overrides`` are staged overlay cards (path -> parsed card, None for a staged delete): indexed
|
|
54
|
+
links from those paths are replaced by the staged versions' own references, resolved with
|
|
55
|
+
``cats``. ``exclude`` drops sources (the card being written). ``dangling=True`` matches references
|
|
56
|
+
that name a target by path but no longer resolve (the target was already deleted).
|
|
57
|
+
"""
|
|
58
|
+
targets = list(dict.fromkeys(targets))
|
|
59
|
+
overrides = overrides or {}
|
|
60
|
+
skip = set(exclude) | set(targets)
|
|
61
|
+
found: dict[str, dict[tuple[str, str], int]] = {kind: {} for kind, _, _ in _KINDS}
|
|
62
|
+
for target in targets:
|
|
63
|
+
mount, path = _split(target)
|
|
64
|
+
for kind, _, _ in _KINDS:
|
|
65
|
+
rows = index.dangling_inbound(kind, mount, path) if dangling else index.inbound(kind, mount, path)
|
|
66
|
+
for r in rows:
|
|
67
|
+
if r["src_mount"] == overlay_alias and r["src_path"] in overrides:
|
|
68
|
+
continue # superseded by its staged version, handled below
|
|
69
|
+
src = format_address(r["src_mount"], r["src_path"])
|
|
70
|
+
if src not in skip:
|
|
71
|
+
found[kind][(src, target)] = r["precedence"]
|
|
72
|
+
if overrides and cats is not None:
|
|
73
|
+
wanted = set(targets)
|
|
74
|
+
for path, parsed in overrides.items():
|
|
75
|
+
src = format_address(overlay_alias, path)
|
|
76
|
+
if parsed is None or src in skip:
|
|
77
|
+
continue
|
|
78
|
+
refs = {"wikilink": parsed.wikilink_targets(), "extends": parsed.extends}
|
|
79
|
+
for kind, raws in refs.items():
|
|
80
|
+
for raw in raws:
|
|
81
|
+
res = resolve_ref(raw, overlay_alias, cats)
|
|
82
|
+
if res.resolved and res.address in wanted:
|
|
83
|
+
found[kind][(src, res.address)] = overlay_precedence
|
|
84
|
+
out: dict[str, Any] = {}
|
|
85
|
+
for kind, key, field in _KINDS:
|
|
86
|
+
ordered = sorted(found[kind].items(), key=lambda kv: (-kv[1], kv[0][0], kv[0][1]))
|
|
87
|
+
items = []
|
|
88
|
+
for (src, target), _ in ordered[:MAX_AFFECTED]:
|
|
89
|
+
item: dict[str, Any] = {"address": src, field: target}
|
|
90
|
+
if _split(src)[0] != overlay_alias:
|
|
91
|
+
item["read_only"] = True
|
|
92
|
+
items.append(item)
|
|
93
|
+
out[key] = items
|
|
94
|
+
out["total_" + key] = len(ordered)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["MAX_AFFECTED", "inbound_impact", "supersede_targets"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Preview and journal a slice-pointer rollback without reverting overlay or staging."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..errors import RecoveryError
|
|
9
|
+
from ..model.addressing import normalize_alias
|
|
10
|
+
from ..storage.config import Mount, MountTable, dump_mounts, load_slice_manifest, portable_path
|
|
11
|
+
from ..storage.history import event, file_action, file_sha
|
|
12
|
+
from ..storage.snapshots import inventory
|
|
13
|
+
from .audit import history_path
|
|
14
|
+
from .validation import Report, SliceSource, lint
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _issues(ws, table):
|
|
18
|
+
report = Report(str(ws.root))
|
|
19
|
+
sources = []
|
|
20
|
+
for m in table.all():
|
|
21
|
+
if not m.root.is_dir():
|
|
22
|
+
report.add('error', 'missing_mount', f'mount {m.alias} has no directory', address=m.alias)
|
|
23
|
+
continue
|
|
24
|
+
nested = tuple(o.root for o in table.all() if o.alias != m.alias and m.root in o.root.parents)
|
|
25
|
+
sources.append(SliceSource(m.alias, m.root, load_slice_manifest(m.root), m.kind, tuple(ws._exclude()) + nested))
|
|
26
|
+
lint(sources, report)
|
|
27
|
+
return report.errors + report.warnings
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def rollback(ws, alias, path=None, *, dry_run=False, revision=None):
|
|
31
|
+
alias = normalize_alias(alias)
|
|
32
|
+
with ws._writing() as table:
|
|
33
|
+
current = table.get(alias)
|
|
34
|
+
if current is None or current.kind == 'overlay':
|
|
35
|
+
raise RecoveryError('rollback requires a mounted read-only slice', alias=alias)
|
|
36
|
+
if path is None:
|
|
37
|
+
previous = next((e.get('previous_path') for e in reversed(ws.history.events())
|
|
38
|
+
if e['event'] == 'baked' and e.get('alias') == alias
|
|
39
|
+
and history_path(ws, e['path']) == current.root), None)
|
|
40
|
+
if not previous:
|
|
41
|
+
raise RecoveryError('no recorded predecessor for this slice; specify --to /path/to/version', alias=alias)
|
|
42
|
+
target = history_path(ws, previous)
|
|
43
|
+
else:
|
|
44
|
+
target = Path(path).expanduser().absolute()
|
|
45
|
+
if target == current.root:
|
|
46
|
+
raise RecoveryError('target is already mounted', path=str(target))
|
|
47
|
+
if any(target.resolve() == m.root.resolve() for m in table.all()):
|
|
48
|
+
raise RecoveryError('target is already mounted under another alias', path=str(target))
|
|
49
|
+
if any(target.resolve() == p.resolve() or target.resolve().is_relative_to(p.resolve())
|
|
50
|
+
for p in (table.overlay.root, ws.staging.root, ws.cache_dir, ws.history.root)):
|
|
51
|
+
raise RecoveryError('rollback target must be a slice directory, not live or internal workspace storage')
|
|
52
|
+
target_files = inventory(target)
|
|
53
|
+
replacement = Mount(alias, portable_path(ws.root, target), current.precedence, current.role, 'slice', target)
|
|
54
|
+
projected = MountTable(table.overlay, [replacement if m.alias == alias else m for m in table.mounts])
|
|
55
|
+
before = _issues(ws, table)
|
|
56
|
+
after = _issues(ws, projected)
|
|
57
|
+
def identity(issue):
|
|
58
|
+
return (issue['code'], issue.get('address'), issue['message'])
|
|
59
|
+
existing = {identity(i) for i in before}
|
|
60
|
+
blockers = [i for i in after if identity(i) not in existing and
|
|
61
|
+
(i['severity'] == 'error' or i['code'] in ('broken_wikilink', 'unmounted_dependency', 'missing_dependency'))]
|
|
62
|
+
# A malformed destination is never accepted just because the current version has the same defect.
|
|
63
|
+
blockers += [i for i in after if i['severity'] == 'error' and
|
|
64
|
+
(i.get('address', '').startswith(alias + '/') or
|
|
65
|
+
(i.get('file') and Path(i['file']).is_relative_to(target))) and i not in blockers]
|
|
66
|
+
state = ws.status()
|
|
67
|
+
overlay = [
|
|
68
|
+
f"{table.overlay.alias}/{c['path']}" for c in ws.index.all_cards(table.overlay.alias)]
|
|
69
|
+
plan = {'alias': alias, 'previous_path': str(current.root), 'path': str(target),
|
|
70
|
+
'version': load_slice_manifest(target).version,
|
|
71
|
+
'precedence': current.precedence, 'role': current.role,
|
|
72
|
+
'overlay_preserved': overlay,
|
|
73
|
+
'overlay_same_paths': [f"{table.overlay.alias}/{r['path']}" for r in ws.index.all_cards(table.overlay.alias)
|
|
74
|
+
if (target / (r['path'] + '.md')).is_file()],
|
|
75
|
+
'pending_preserved': [i['address'] for i in state['pending_review']['files']],
|
|
76
|
+
'blockers': blockers, 'warnings': [i for i in after if i not in blockers], 'ready': not blockers,
|
|
77
|
+
'note': 'Only the slice pointer changes. Existing overlay overrides and pending proposals remain; this does not undo the whole workspace.'}
|
|
78
|
+
# Bind confirmation to the mount table, destination bytes, all live card bytes, and pending proposals.
|
|
79
|
+
live_files = {f"{r['mount']}/{r['path']}": file_sha(table.get(r['mount']).root / (r['path'] + '.md'))
|
|
80
|
+
for r in ws.index.all_cards()}
|
|
81
|
+
fingerprint = {'plan': plan, 'target': target_files, 'live': live_files,
|
|
82
|
+
'mounts': (ws.root / 'mounts.toml').read_text(encoding='utf-8'),
|
|
83
|
+
'staging': inventory(ws.staging.root)}
|
|
84
|
+
plan['revision'] = hashlib.sha256(json.dumps(fingerprint, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
85
|
+
if dry_run:
|
|
86
|
+
return plan
|
|
87
|
+
if revision is not None and revision != plan['revision']:
|
|
88
|
+
raise RecoveryError('rollback preview is stale; review the target and remaining overlay again')
|
|
89
|
+
if blockers:
|
|
90
|
+
raise RecoveryError('rollback would introduce invalid references or cards; resolve the blockers first', plan=plan)
|
|
91
|
+
record = event('rolled_back', alias=alias, previous_path=str(current.root), path=str(target), version=plan['version'])
|
|
92
|
+
guards = [{'path': str(target / name), 'sha256': info['sha256']} for name, info in target_files['files'].items()]
|
|
93
|
+
ws.history.transact([file_action(ws.root / 'mounts.toml', dump_mounts(projected))], [record], guards=guards)
|
|
94
|
+
ws._sync(ws._reload_table())
|
|
95
|
+
return dict(plan, history_event_id=record['event_id'], rolled_back=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_rollback(plan):
|
|
99
|
+
lines = [f"{plan['alias']}: {plan['previous_path']} -> {plan['path']} (v{plan['version'] or 'unspecified'})",
|
|
100
|
+
plan['note'], f"Overlay cards retained: {len(plan['overlay_preserved'])}",
|
|
101
|
+
f"Pending proposals retained: {len(plan['pending_preserved'])}"]
|
|
102
|
+
for address in plan['overlay_preserved']:
|
|
103
|
+
lines.append(' Overlay: ' + address)
|
|
104
|
+
if plan['overlay_same_paths']:
|
|
105
|
+
lines.append('Overlay cards sharing target paths: ' + ', '.join(plan['overlay_same_paths']))
|
|
106
|
+
lines.append('Use explain CARD to check which version an unqualified read selects.')
|
|
107
|
+
for address in plan['pending_preserved']:
|
|
108
|
+
lines.append(' Pending: ' + address)
|
|
109
|
+
for issue in plan['blockers']:
|
|
110
|
+
lines.append('Blocked: ' + issue['message'])
|
|
111
|
+
for issue in plan['warnings']:
|
|
112
|
+
lines.append('Warning: ' + issue['message'])
|
|
113
|
+
return '\n'.join(lines) + '\n'
|