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,228 @@
|
|
|
1
|
+
"""Lineage semantics: trust policies, supersession forwarding and cycle detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Callable, Hashable, Iterable, Sequence, TypeVar
|
|
8
|
+
|
|
9
|
+
from .addressing import format_address, normalize_alias
|
|
10
|
+
from ..errors import InvalidAddressError
|
|
11
|
+
|
|
12
|
+
N = TypeVar("N", bound=Hashable)
|
|
13
|
+
FollowSpec = bool | Sequence[str] | None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TrustPolicy:
|
|
17
|
+
"""Which ``supersedes`` edges may be followed automatically.
|
|
18
|
+
|
|
19
|
+
* ``True`` - every active replacement is trusted (default)
|
|
20
|
+
* ``False`` - never forward; replacements are only reported
|
|
21
|
+
* ``["curated", "@team"]`` - trust replacements whose mount ``role`` or ``@alias`` is listed
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, follow: FollowSpec = True) -> None:
|
|
25
|
+
if follow is None:
|
|
26
|
+
follow = True
|
|
27
|
+
if isinstance(follow, str):
|
|
28
|
+
follow = [follow]
|
|
29
|
+
self.all = follow is True
|
|
30
|
+
self.none = follow is False
|
|
31
|
+
self.allow: set[str] = set()
|
|
32
|
+
if not isinstance(follow, bool):
|
|
33
|
+
for item in follow:
|
|
34
|
+
item = str(item).strip()
|
|
35
|
+
if not item:
|
|
36
|
+
continue
|
|
37
|
+
if item.startswith("@"):
|
|
38
|
+
try:
|
|
39
|
+
item = normalize_alias(item)
|
|
40
|
+
except InvalidAddressError:
|
|
41
|
+
pass
|
|
42
|
+
self.allow.add(item)
|
|
43
|
+
|
|
44
|
+
def trusts(self, mount: str, role: str) -> bool:
|
|
45
|
+
if self.all:
|
|
46
|
+
return True
|
|
47
|
+
if self.none:
|
|
48
|
+
return False
|
|
49
|
+
return mount in self.allow or role in self.allow
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Forward:
|
|
54
|
+
"""Result of following trusted ``supersedes`` edges from a starting node.
|
|
55
|
+
|
|
56
|
+
``node`` is where a read lands: the unique terminal replacement, or - when
|
|
57
|
+
replacements compete - the fork point reached by following the unambiguous
|
|
58
|
+
part of the chain (``start`` itself if it is directly contested or on a cycle).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
start: str
|
|
62
|
+
node: str
|
|
63
|
+
terminals: list[str] = field(default_factory=list)
|
|
64
|
+
path: list[str] = field(default_factory=list) # start -> ... -> node
|
|
65
|
+
superseded: set[str] = field(default_factory=set) # nodes passed through before ``node``
|
|
66
|
+
competing: bool = False
|
|
67
|
+
cycle: bool = False
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def redirected(self) -> bool:
|
|
71
|
+
return self.node != self.start
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def terminal(self) -> str | None:
|
|
75
|
+
return None if (self.competing or self.cycle) else self.node
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LineageView:
|
|
79
|
+
"""Per-request lineage queries over an index (memoized)."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, index, policy: TrustPolicy) -> None:
|
|
82
|
+
self.index = index
|
|
83
|
+
self.policy = policy
|
|
84
|
+
self._superseders: dict[str, list[tuple[str, bool, int, int]]] = {}
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def split(address: str) -> tuple[str, str]:
|
|
88
|
+
mount, _, path = address.partition("/")
|
|
89
|
+
return mount, path
|
|
90
|
+
|
|
91
|
+
def superseders(self, address: str) -> list[tuple[str, bool, int, int]]:
|
|
92
|
+
"""Direct active inbound ``supersedes`` sources: ``(address, trusted, precedence, mtime_ns)``."""
|
|
93
|
+
if address not in self._superseders:
|
|
94
|
+
mount, path = self.split(address)
|
|
95
|
+
rows = self.index.inbound("supersedes", mount, path)
|
|
96
|
+
self._superseders[address] = [
|
|
97
|
+
(
|
|
98
|
+
format_address(r["src_mount"], r["src_path"]),
|
|
99
|
+
self.policy.trusts(r["src_mount"], r["role"]),
|
|
100
|
+
r["precedence"],
|
|
101
|
+
r["mtime_ns"],
|
|
102
|
+
)
|
|
103
|
+
for r in rows
|
|
104
|
+
]
|
|
105
|
+
return self._superseders[address]
|
|
106
|
+
|
|
107
|
+
def trusted_superseders(self, address: str) -> list[str]:
|
|
108
|
+
return [a for a, trusted, _, _ in self.superseders(address) if trusted]
|
|
109
|
+
|
|
110
|
+
def untrusted_superseders(self, address: str) -> list[str]:
|
|
111
|
+
return [a for a, trusted, _, _ in self.superseders(address) if not trusted]
|
|
112
|
+
|
|
113
|
+
def all_superseders(self, address: str) -> list[str]:
|
|
114
|
+
return [a for a, _, _, _ in self.superseders(address)]
|
|
115
|
+
|
|
116
|
+
def forward(self, start: str) -> Forward:
|
|
117
|
+
parent: dict[str, str] = {}
|
|
118
|
+
seen = {start}
|
|
119
|
+
queue = deque([start])
|
|
120
|
+
order: dict[str, tuple[int, int]] = {}
|
|
121
|
+
while queue:
|
|
122
|
+
node = queue.popleft()
|
|
123
|
+
for addr, trusted, precedence, mtime in self.superseders(node):
|
|
124
|
+
if not trusted or addr in seen:
|
|
125
|
+
continue
|
|
126
|
+
seen.add(addr)
|
|
127
|
+
parent[addr] = node
|
|
128
|
+
order[addr] = (precedence, mtime)
|
|
129
|
+
queue.append(addr)
|
|
130
|
+
reached = seen - {start}
|
|
131
|
+
if not reached:
|
|
132
|
+
return Forward(start, start, [start], [start], set())
|
|
133
|
+
terminals = [n for n in reached if not self.trusted_superseders(n)]
|
|
134
|
+
terminals.sort(key=lambda a: (-order[a][0], a))
|
|
135
|
+
if not terminals:
|
|
136
|
+
return Forward(start, start, [], [start], set(), cycle=True)
|
|
137
|
+
if len(terminals) > 1:
|
|
138
|
+
# Follow the chain while it is unambiguous; stop at the fork.
|
|
139
|
+
path = [start]
|
|
140
|
+
while True:
|
|
141
|
+
nxt = self.trusted_superseders(path[-1])
|
|
142
|
+
if len(nxt) != 1 or nxt[0] in path:
|
|
143
|
+
break
|
|
144
|
+
path.append(nxt[0])
|
|
145
|
+
return Forward(start, path[-1], terminals, path, set(path[:-1]), competing=True)
|
|
146
|
+
terminal = terminals[0]
|
|
147
|
+
path = [terminal]
|
|
148
|
+
while path[-1] != start:
|
|
149
|
+
path.append(parent[path[-1]])
|
|
150
|
+
path.reverse()
|
|
151
|
+
return Forward(start, terminal, [terminal], path, seen - {terminal})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Graph algorithms
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cycle_through(start: N, successors: Callable[[N], Iterable[N]]) -> list[N] | None:
|
|
160
|
+
"""Return a cycle ``[start, ..., start]`` if ``start`` can reach itself, else None."""
|
|
161
|
+
parent: dict[N, N] = {}
|
|
162
|
+
queue: deque[N] = deque()
|
|
163
|
+
for nxt in successors(start):
|
|
164
|
+
if nxt not in parent:
|
|
165
|
+
parent[nxt] = start
|
|
166
|
+
queue.append(nxt)
|
|
167
|
+
while queue:
|
|
168
|
+
node = queue.popleft()
|
|
169
|
+
if node == start:
|
|
170
|
+
chain: list[N] = []
|
|
171
|
+
cur = parent[start]
|
|
172
|
+
while cur != start:
|
|
173
|
+
chain.append(cur)
|
|
174
|
+
cur = parent[cur]
|
|
175
|
+
return [start, *reversed(chain), start]
|
|
176
|
+
for nxt in successors(node):
|
|
177
|
+
if nxt not in parent:
|
|
178
|
+
parent[nxt] = node
|
|
179
|
+
queue.append(nxt)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def strongly_connected_cycles(nodes: Iterable[N], successors: Callable[[N], Iterable[N]]) -> list[list[N]]:
|
|
184
|
+
"""Tarjan's algorithm (iterative); returns SCCs that contain a cycle."""
|
|
185
|
+
index_of: dict[N, int] = {}
|
|
186
|
+
low: dict[N, int] = {}
|
|
187
|
+
on_stack: set[N] = set()
|
|
188
|
+
stack: list[N] = []
|
|
189
|
+
result: list[list[N]] = []
|
|
190
|
+
counter = 0
|
|
191
|
+
for root in nodes:
|
|
192
|
+
if root in index_of:
|
|
193
|
+
continue
|
|
194
|
+
work: list[tuple[N, Iterable[N]]] = [(root, iter(list(successors(root))))]
|
|
195
|
+
index_of[root] = low[root] = counter
|
|
196
|
+
counter += 1
|
|
197
|
+
stack.append(root)
|
|
198
|
+
on_stack.add(root)
|
|
199
|
+
while work:
|
|
200
|
+
node, it = work[-1]
|
|
201
|
+
advanced = False
|
|
202
|
+
for nxt in it:
|
|
203
|
+
if nxt not in index_of:
|
|
204
|
+
index_of[nxt] = low[nxt] = counter
|
|
205
|
+
counter += 1
|
|
206
|
+
stack.append(nxt)
|
|
207
|
+
on_stack.add(nxt)
|
|
208
|
+
work.append((nxt, iter(list(successors(nxt)))))
|
|
209
|
+
advanced = True
|
|
210
|
+
break
|
|
211
|
+
if nxt in on_stack:
|
|
212
|
+
low[node] = min(low[node], index_of[nxt])
|
|
213
|
+
if advanced:
|
|
214
|
+
continue
|
|
215
|
+
work.pop()
|
|
216
|
+
if work:
|
|
217
|
+
low[work[-1][0]] = min(low[work[-1][0]], low[node])
|
|
218
|
+
if low[node] == index_of[node]:
|
|
219
|
+
comp: list[N] = []
|
|
220
|
+
while True:
|
|
221
|
+
member = stack.pop()
|
|
222
|
+
on_stack.discard(member)
|
|
223
|
+
comp.append(member)
|
|
224
|
+
if member == node:
|
|
225
|
+
break
|
|
226
|
+
if len(comp) > 1 or node in set(successors(node)):
|
|
227
|
+
result.append(sorted(comp, key=str))
|
|
228
|
+
return result
|
mindslice/model/text.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared Unicode normalization for card text and exact-match keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
|
|
8
|
+
# Default-ignorable characters that must not change what text matches.
|
|
9
|
+
_IGNORABLE = re.compile("[-︀-️\U000e0100-\U000e01ef]")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize(text: str) -> str:
|
|
13
|
+
"""Search-side normalization: drop ignorable characters, then NFKC."""
|
|
14
|
+
return unicodedata.normalize("NFKC", _IGNORABLE.sub("", text or ""))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fold(text: str) -> str:
|
|
18
|
+
"""Normalization for exact-match keys (aliases, tags): NFKC + case folding + whitespace."""
|
|
19
|
+
return " ".join(normalize(text).casefold().split())
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Minimal, verified TOML v1.0 serializer for MindSlice's flat schemas.
|
|
2
|
+
|
|
3
|
+
``tomllib`` only parses, so MindSlice ships its own writer for the two shapes
|
|
4
|
+
it emits: card frontmatter (``str`` / ``list[str]`` values) and manifests such
|
|
5
|
+
as ``mounts.toml`` / ``slice.toml`` (which additionally use ``int`` / ``bool``
|
|
6
|
+
values and tables).
|
|
7
|
+
|
|
8
|
+
Strings are emitted as UTF-8 directly (never as UTF-16 surrogate escapes the
|
|
9
|
+
way ``json.dumps`` would), with the TOML short escapes for ``\\ \" \\b \\t \\n
|
|
10
|
+
\\f \\r`` and 4-digit ``\\uXXXX`` scalars for the remaining control characters
|
|
11
|
+
(U+0000-U+001F, U+007F). Every document produced here is re-parsed with
|
|
12
|
+
``tomllib.loads()`` and compared against its input before it is returned.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import tomllib
|
|
19
|
+
from typing import Any, Mapping
|
|
20
|
+
|
|
21
|
+
from ..errors import FrontmatterError
|
|
22
|
+
|
|
23
|
+
_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
24
|
+
_SHORT_ESCAPES = {
|
|
25
|
+
"\\": "\\\\",
|
|
26
|
+
'"': '\\"',
|
|
27
|
+
"\b": "\\b",
|
|
28
|
+
"\t": "\\t",
|
|
29
|
+
"\n": "\\n",
|
|
30
|
+
"\f": "\\f",
|
|
31
|
+
"\r": "\\r",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def escape_string(value: str) -> str:
|
|
36
|
+
"""Return ``value`` as a TOML basic string literal (including quotes)."""
|
|
37
|
+
out = ['"']
|
|
38
|
+
for ch in value:
|
|
39
|
+
code = ord(ch)
|
|
40
|
+
if ch in _SHORT_ESCAPES:
|
|
41
|
+
out.append(_SHORT_ESCAPES[ch])
|
|
42
|
+
elif code < 0x20 or code == 0x7F:
|
|
43
|
+
out.append(f"\\u{code:04X}")
|
|
44
|
+
elif 0xD800 <= code <= 0xDFFF:
|
|
45
|
+
raise FrontmatterError(f"lone UTF-16 surrogate U+{code:04X} cannot be encoded in TOML")
|
|
46
|
+
else:
|
|
47
|
+
out.append(ch)
|
|
48
|
+
out.append('"')
|
|
49
|
+
return "".join(out)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def format_key(key: str) -> str:
|
|
53
|
+
if not isinstance(key, str) or not key:
|
|
54
|
+
raise FrontmatterError(f"invalid TOML key: {key!r}")
|
|
55
|
+
return key if _BARE_KEY.match(key) else escape_string(key)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_value(value: Any) -> str:
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
return "true" if value else "false"
|
|
61
|
+
if isinstance(value, int):
|
|
62
|
+
return str(value)
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
return escape_string(value)
|
|
65
|
+
if isinstance(value, (list, tuple)):
|
|
66
|
+
return "[" + ", ".join(format_value(v) for v in value) + "]"
|
|
67
|
+
raise FrontmatterError(f"unsupported TOML value type: {type(value).__name__}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _verify(text: str, expected: Mapping[str, Any]) -> str:
|
|
71
|
+
try:
|
|
72
|
+
parsed = tomllib.loads(text)
|
|
73
|
+
except tomllib.TOMLDecodeError as exc: # pragma: no cover - defensive
|
|
74
|
+
raise FrontmatterError(f"serializer produced invalid TOML: {exc}") from exc
|
|
75
|
+
if parsed != _normalize(expected):
|
|
76
|
+
raise FrontmatterError("TOML round-trip verification failed")
|
|
77
|
+
return text
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize(obj: Any) -> Any:
|
|
81
|
+
if isinstance(obj, Mapping):
|
|
82
|
+
return {k: _normalize(v) for k, v in obj.items()}
|
|
83
|
+
if isinstance(obj, tuple):
|
|
84
|
+
return [_normalize(v) for v in obj]
|
|
85
|
+
if isinstance(obj, list):
|
|
86
|
+
return [_normalize(v) for v in obj]
|
|
87
|
+
return obj
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def dumps_flat(data: Mapping[str, Any]) -> str:
|
|
91
|
+
"""Serialize a flat mapping of scalars / lists of scalars (card frontmatter)."""
|
|
92
|
+
lines = []
|
|
93
|
+
for key, value in data.items():
|
|
94
|
+
if isinstance(value, Mapping):
|
|
95
|
+
raise FrontmatterError(f"frontmatter key {key!r} must be flat (got a table)")
|
|
96
|
+
lines.append(f"{format_key(key)} = {format_value(value)}")
|
|
97
|
+
text = "\n".join(lines) + ("\n" if lines else "")
|
|
98
|
+
return _verify(text, data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def dumps_document(
|
|
102
|
+
top: Mapping[str, Any] | None = None,
|
|
103
|
+
tables: Mapping[str, Mapping[str, Any]] | None = None,
|
|
104
|
+
array_tables: Mapping[str, list[Mapping[str, Any]]] | None = None,
|
|
105
|
+
header: str | None = None,
|
|
106
|
+
) -> str:
|
|
107
|
+
"""Serialize a document made of top-level keys, ``[tables]`` and ``[[array tables]]``."""
|
|
108
|
+
top = dict(top or {})
|
|
109
|
+
tables = dict(tables or {})
|
|
110
|
+
array_tables = dict(array_tables or {})
|
|
111
|
+
chunks: list[str] = []
|
|
112
|
+
if header:
|
|
113
|
+
chunks.append("".join(f"# {line}\n" if line else "#\n" for line in header.splitlines()))
|
|
114
|
+
if top:
|
|
115
|
+
chunks.append("".join(f"{format_key(k)} = {format_value(v)}\n" for k, v in top.items()))
|
|
116
|
+
for name, body in tables.items():
|
|
117
|
+
lines = [f"[{format_key(name)}]"]
|
|
118
|
+
lines += [f"{format_key(k)} = {format_value(v)}" for k, v in body.items()]
|
|
119
|
+
chunks.append("\n".join(lines) + "\n")
|
|
120
|
+
for name, items in array_tables.items():
|
|
121
|
+
for body in items:
|
|
122
|
+
lines = [f"[[{format_key(name)}]]"]
|
|
123
|
+
lines += [f"{format_key(k)} = {format_value(v)}" for k, v in body.items()]
|
|
124
|
+
chunks.append("\n".join(lines) + "\n")
|
|
125
|
+
text = "\n".join(chunks)
|
|
126
|
+
expected: dict[str, Any] = dict(top)
|
|
127
|
+
expected.update({k: dict(v) for k, v in tables.items()})
|
|
128
|
+
expected.update({k: [dict(i) for i in v] for k, v in array_tables.items() if v})
|
|
129
|
+
return _verify(text, expected)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Workspace memory operations, staging, startup context and validation."""
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Human-facing history and read explanations, without guessing unrecorded reasons."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..errors import AmbiguousAddressError, NotFoundError
|
|
7
|
+
from ..model.addressing import format_address, parse_address
|
|
8
|
+
from ..retrieval import query
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def history_path(ws, path, records=None):
|
|
12
|
+
"""Translate old absolute version pointers after a portable workspace restore."""
|
|
13
|
+
raw = str(path).replace('\\', '/').rstrip('/')
|
|
14
|
+
for record in reversed(ws.history.events() if records is None else records):
|
|
15
|
+
if record['event'] != 'restored':
|
|
16
|
+
continue
|
|
17
|
+
for source, relative in sorted(record.get('path_map', {}).items(), key=lambda x: -len(x[0])):
|
|
18
|
+
source = source.replace('\\', '/').rstrip('/')
|
|
19
|
+
if raw == source or raw.startswith(source + '/'):
|
|
20
|
+
return ws.root / relative / raw[len(source):].lstrip('/')
|
|
21
|
+
return Path(path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _baked_revision(ws, root, path, sha, records):
|
|
25
|
+
"""Follow a bake's parent for unchanged cards; a recorded mismatch ends attribution."""
|
|
26
|
+
versions = {str(history_path(ws, e['path'], records)): e for e in records if e['event'] == 'baked'}
|
|
27
|
+
seen = set()
|
|
28
|
+
current = str(root)
|
|
29
|
+
while current not in seen and current in versions:
|
|
30
|
+
seen.add(current)
|
|
31
|
+
e = versions[current]
|
|
32
|
+
card = next((c for c in e.get('cards', []) if c['target'].partition('/')[2] == path), None)
|
|
33
|
+
if card:
|
|
34
|
+
return (True, card['change_ids']) if card['after_sha256'] == sha else (False, [])
|
|
35
|
+
if not e.get('previous_path'):
|
|
36
|
+
break
|
|
37
|
+
current = str(history_path(ws, e['previous_path'], records))
|
|
38
|
+
return False, []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _event_addresses(record):
|
|
42
|
+
addresses = {record['address']} if record.get('address') else set()
|
|
43
|
+
addresses.update(record.get('retired_overlay_cards', []))
|
|
44
|
+
for card in record.get('cards', []):
|
|
45
|
+
addresses.update(card[k] for k in ('source', 'target') if card.get(k))
|
|
46
|
+
return addresses
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def card_history(ws, address=None, limit=20, offset=0):
|
|
50
|
+
query._nonneg(limit=limit, offset=offset)
|
|
51
|
+
with ws._reading() as table:
|
|
52
|
+
records = ws.history.events()
|
|
53
|
+
canonical = None
|
|
54
|
+
physical_versions = set()
|
|
55
|
+
if address is not None:
|
|
56
|
+
alias, path = parse_address(address)
|
|
57
|
+
try:
|
|
58
|
+
canonical, _ = query._select(query._Ctx(ws, table, False), address)
|
|
59
|
+
selected_alias, _ = parse_address(canonical)
|
|
60
|
+
selected_mount = table.get(selected_alias)
|
|
61
|
+
if selected_mount.kind == 'slice':
|
|
62
|
+
version = str(selected_mount.root)
|
|
63
|
+
versions = {str(history_path(ws, e['path'], records)): e for e in records if e['event'] == 'baked'}
|
|
64
|
+
while version not in physical_versions:
|
|
65
|
+
physical_versions.add(version)
|
|
66
|
+
previous = versions.get(version, {}).get('previous_path')
|
|
67
|
+
if not previous:
|
|
68
|
+
break
|
|
69
|
+
version = str(history_path(ws, previous, records))
|
|
70
|
+
except NotFoundError:
|
|
71
|
+
if alias:
|
|
72
|
+
canonical = format_address(alias, path)
|
|
73
|
+
else:
|
|
74
|
+
matches = sorted({a for e in records for a in _event_addresses(e) if a.partition('/')[2] == path})
|
|
75
|
+
if len(matches) > 1:
|
|
76
|
+
raise AmbiguousAddressError('historical address is ambiguous; use @alias/path', candidates=matches)
|
|
77
|
+
canonical = matches[0] if matches else format_address(table.overlay.alias, path)
|
|
78
|
+
if canonical:
|
|
79
|
+
def matches(card, record):
|
|
80
|
+
return canonical in (card.get('source'), card.get('target')) or (
|
|
81
|
+
record.get('path') and str(history_path(ws, record['path'], records)) in physical_versions
|
|
82
|
+
and card.get('target', '').partition('/')[2] == canonical.partition('/')[2])
|
|
83
|
+
ids = {e['change_id'] for e in records if e.get('address') == canonical and e.get('change_id')}
|
|
84
|
+
for e in records:
|
|
85
|
+
for card in e.get('cards', []):
|
|
86
|
+
if matches(card, e):
|
|
87
|
+
ids.update(card.get('change_ids', []))
|
|
88
|
+
filtered = []
|
|
89
|
+
for e in records:
|
|
90
|
+
cards = [c for c in e.get('cards', []) if matches(c, e)
|
|
91
|
+
or ids.intersection(c.get('change_ids', []))]
|
|
92
|
+
pointer_change = e['event'] == 'rolled_back' and e.get('alias') == canonical.partition('/')[0]
|
|
93
|
+
retired = canonical in e.get('retired_overlay_cards', [])
|
|
94
|
+
if e.get('address') == canonical or e.get('change_id') in ids or cards or pointer_change or retired:
|
|
95
|
+
entry = dict(e)
|
|
96
|
+
if 'retired_overlay_cards' in entry:
|
|
97
|
+
entry['retired_overlay_cards'] = [a for a in e['retired_overlay_cards'] if a == canonical
|
|
98
|
+
or any(c.get('source') == a for c in cards)]
|
|
99
|
+
if 'cards' in e:
|
|
100
|
+
entry['cards'] = cards
|
|
101
|
+
entry['change_ids'] = list(dict.fromkeys(i for c in cards for i in c.get('change_ids', [])))
|
|
102
|
+
filtered.append(entry)
|
|
103
|
+
records = filtered
|
|
104
|
+
details = ws.history.details()
|
|
105
|
+
records = list(reversed(records))
|
|
106
|
+
page = []
|
|
107
|
+
for e in records[offset:offset + limit]:
|
|
108
|
+
entry = dict(e)
|
|
109
|
+
if 'reason' not in entry and entry.get('change_id') in details:
|
|
110
|
+
entry['reason'] = details[entry['change_id']].get('reason')
|
|
111
|
+
if entry.get('change_ids'):
|
|
112
|
+
entry['changes'] = ws.history.descriptions(entry['change_ids'], records=details)
|
|
113
|
+
page.append(entry)
|
|
114
|
+
return {'address': canonical, 'events': page, 'total': len(records), 'offset': offset,
|
|
115
|
+
'next_offset': query._page(len(records), offset, limit),
|
|
116
|
+
'content_snapshots': False,
|
|
117
|
+
'note': 'Metadata history only. External editor changes are not automatically logged; old text requires a slice version or backup.'}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def explain(ws, address, follow_supersedes=True, max_extensions=5, extensions_offset=0):
|
|
121
|
+
query._nonneg(max_extensions=max_extensions, extensions_offset=extensions_offset)
|
|
122
|
+
with ws._reading() as table:
|
|
123
|
+
ctx = query._Ctx(ws, table, follow_supersedes)
|
|
124
|
+
selected, shadows = query._select(ctx, address)
|
|
125
|
+
result = query.resolve(ctx, address, max_backlinks=0, max_extensions=max_extensions,
|
|
126
|
+
extensions_offset=extensions_offset, bump_telemetry=False)
|
|
127
|
+
records = ws.history.events()
|
|
128
|
+
sources = []
|
|
129
|
+
parts = [('selected', ctx.card_payload(selected)), ('resolved', result)]
|
|
130
|
+
parts += [('baseline', c) for c in result.get('baselines', [])]
|
|
131
|
+
parts += [('extension', c) for c in result.get('extensions', [])]
|
|
132
|
+
for role, card in parts:
|
|
133
|
+
alias, path = parse_address(card['address'])
|
|
134
|
+
mount = table.get(alias)
|
|
135
|
+
changes = []
|
|
136
|
+
matched = False
|
|
137
|
+
if mount.kind == 'overlay':
|
|
138
|
+
changes = ws.history.changes_for(card['address'], card['sha256'])
|
|
139
|
+
matched = bool(changes)
|
|
140
|
+
else:
|
|
141
|
+
matched, ids = _baked_revision(ws, mount.root, path, card['sha256'], records)
|
|
142
|
+
changes = ws.history.descriptions(ids)
|
|
143
|
+
sources.append({'role': role, 'address': card['address'], 'path': str(mount.root / f'{path}.md'),
|
|
144
|
+
'sha256': card['sha256'], 'mount_role': mount.role, 'precedence': mount.precedence,
|
|
145
|
+
'revision_recorded': matched, 'changes': changes})
|
|
146
|
+
notices = {k: result[k] for k in ('status', 'competing_replacements', 'superseded_by_untrusted',
|
|
147
|
+
'bypassed_replacements', 'unrebased_extensions', 'baseline_superseded_by', 'missing_baselines') if k in result}
|
|
148
|
+
return {'requested': address, 'selected': selected, 'resolved': result['address'],
|
|
149
|
+
'follow_supersedes': follow_supersedes, 'lineage_path': result.get('lineage_path', [selected]),
|
|
150
|
+
'shadows': shadows, 'sources': sources, 'notices': notices,
|
|
151
|
+
'total_extensions': result.get('total_extensions', 0),
|
|
152
|
+
'extensions_next_offset': result.get('extensions_next_offset'),
|
|
153
|
+
'pending_review': [i['address'] for i in ws.diff()['items'] if i['address'] in {c['address'] for _, c in parts}],
|
|
154
|
+
'note': 'Selection uses mount precedence, then explicit lineage under the supplied trust policy. '
|
|
155
|
+
'Unrecorded revisions have no verified reason; pending edits do not affect this result.'}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render_history(result):
|
|
159
|
+
lines = [f"History: {result['address'] or 'workspace'} ({result['total']} events; newest first)"]
|
|
160
|
+
for e in result['events']:
|
|
161
|
+
lines.append(f"{e['timestamp']} {e['event']} {e.get('address', e.get('alias', ''))}")
|
|
162
|
+
if e.get('reason'):
|
|
163
|
+
lines.append(' Reason: ' + e['reason'])
|
|
164
|
+
for c in e.get('changes', []):
|
|
165
|
+
if c.get('reason'):
|
|
166
|
+
lines.append(' Reason: ' + c['reason'])
|
|
167
|
+
for key in ('previous_path', 'path'):
|
|
168
|
+
if e.get(key):
|
|
169
|
+
lines.append(f" {key}: {e[key]}")
|
|
170
|
+
if result['next_offset'] is not None:
|
|
171
|
+
lines.append(f"More events: --offset {result['next_offset']}")
|
|
172
|
+
return '\n'.join(lines + [result['note']]) + '\n'
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def render_explain(result):
|
|
176
|
+
lines = [f"Selected: {result['selected']}", f"Resolved: {result['resolved']}",
|
|
177
|
+
'Lineage: ' + ' -> '.join(result['lineage_path'])]
|
|
178
|
+
if result['shadows']:
|
|
179
|
+
lines.append('Shadows: ' + ', '.join(result['shadows']))
|
|
180
|
+
for source in result['sources']:
|
|
181
|
+
if source['role'] == 'selected' and result['selected'] == result['resolved']:
|
|
182
|
+
continue
|
|
183
|
+
lines.append(f"{source['role']}: {source['address']} ({source['path']}; precedence={source['precedence']}, role={source['mount_role']})")
|
|
184
|
+
for change in source['changes']:
|
|
185
|
+
if change.get('reason'):
|
|
186
|
+
lines.append(' Reason: ' + change['reason'])
|
|
187
|
+
if not source['revision_recorded']:
|
|
188
|
+
lines.append(' No history record matching these current bytes.')
|
|
189
|
+
if result['pending_review']:
|
|
190
|
+
lines.append('Pending, not active: ' + ', '.join(result['pending_review']))
|
|
191
|
+
for name, value in result['notices'].items():
|
|
192
|
+
lines.append(f'{name}: {value}')
|
|
193
|
+
if result['extensions_next_offset'] is not None:
|
|
194
|
+
lines.append(f"More extensions: --extensions-offset {result['extensions_next_offset']}")
|
|
195
|
+
return '\n'.join(lines + [result['note']]) + '\n'
|