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,537 @@
|
|
|
1
|
+
"""Workspace bake planning and execution. Review never holds a lock while waiting for a user.
|
|
2
|
+
|
|
3
|
+
A plan fingerprints its inputs; execution rebuilds and validates it under LOCK_EX. All outputs
|
|
4
|
+
are prepared before a single recoverable mount/overlay/history transaction is started.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from ..model.addressing import MountCatalog, format_address, normalize_alias, resolve_ref
|
|
18
|
+
from .bake import bump_version, default_output
|
|
19
|
+
from ..model.card import normalize_newlines, parse_card, sha256_bytes
|
|
20
|
+
from ..storage.config import MOUNTS_FILE, SLICE_FILE, Mount, MountTable, SliceManifest, dump_mounts, dump_slice_manifest, load_slice_manifest, portable_path
|
|
21
|
+
from ..errors import BakeConflictError, InvalidArgumentsError
|
|
22
|
+
from ..storage.fsutil import atomic_write_text, fsync_dir, fsync_file, iter_markdown
|
|
23
|
+
from ..storage.history import change, event, file_action, file_sha
|
|
24
|
+
from ..model.lineage import strongly_connected_cycles
|
|
25
|
+
from .slice_transfer import overlay_cards, rewrite_refs
|
|
26
|
+
from ..operations.staging import _diff_overlay, _diff_staging
|
|
27
|
+
from ..storage.card_io import read_card_file
|
|
28
|
+
from ..model.card import reassemble
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ..workspace import Workspace
|
|
32
|
+
|
|
33
|
+
PLAN_FILE = ".bake-plan.json"
|
|
34
|
+
MERGE_FIX = ("merge the two into one card: rewrite the overlay card with the combined text and drop its "
|
|
35
|
+
"`extends` link")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _stale(message: str, **details) -> BakeConflictError:
|
|
39
|
+
"""A plan no longer matches the workspace: nothing to fix, only to plan again."""
|
|
40
|
+
return BakeConflictError(message, code="stale_bake_plan", next_steps=[
|
|
41
|
+
"Run `mindslice bake` again: the plan is rebuilt from the current workspace, and destination "
|
|
42
|
+
"choices saved for unchanged cards are kept."], **details)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _selectors(plan: dict, ids: list[str]) -> list[str]:
|
|
46
|
+
"""Shortest ``--assign``/``--defer`` selectors for ``ids``: a whole top folder when every card in it is listed."""
|
|
47
|
+
wanted, out = set(ids), []
|
|
48
|
+
for path in ids:
|
|
49
|
+
folder = path.partition("/")[0] + "/" if "/" in path else None
|
|
50
|
+
inside = [c["id"] for c in plan["cards"] if folder and c["id"].startswith(folder)]
|
|
51
|
+
selector = folder if folder and set(inside) <= wanted else path
|
|
52
|
+
if selector not in out:
|
|
53
|
+
out.append(selector)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def blocked_message(plan: dict) -> str:
|
|
58
|
+
parts = []
|
|
59
|
+
if plan["unassigned"]:
|
|
60
|
+
parts.append(f"{len(plan['unassigned'])} overlay card(s) need a destination")
|
|
61
|
+
if plan["blockers"]:
|
|
62
|
+
parts.append(f"{len(plan['blockers'])} blocker(s) must be resolved")
|
|
63
|
+
return "bake stopped before writing: " + " and ".join(parts or ["the plan is not ready"])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def next_steps(plan: dict) -> list[str]:
|
|
67
|
+
"""What the user has to do before this plan can bake; empty when it is ready."""
|
|
68
|
+
steps = []
|
|
69
|
+
if plan["unassigned"]:
|
|
70
|
+
selectors = _selectors(plan, plan["unassigned"])
|
|
71
|
+
shown = ", ".join(selectors[:5]) + (f" (+{len(selectors) - 5} more)" if len(selectors) > 5 else "")
|
|
72
|
+
assign = " ".join(f"--assign '{sel}=@ALIAS'" for sel in selectors[:3])
|
|
73
|
+
steps.append(f"Choose a destination for {shown}: `mindslice bake {assign}` sends cards to a new or mounted "
|
|
74
|
+
f"slice; use `--defer '{selectors[0]}'` instead to leave cards in overlay; "
|
|
75
|
+
"or run `mindslice bake` in a terminal to decide card by card.")
|
|
76
|
+
seen = set()
|
|
77
|
+
for item in plan["blockers"]:
|
|
78
|
+
if item.get("derived"): # disappears with the blocker it follows from
|
|
79
|
+
continue
|
|
80
|
+
where = item.get("address") or item.get("alias") or "workspace"
|
|
81
|
+
key = (where, item.get("ref")) if item.get("ref") else (where, item["reason"])
|
|
82
|
+
if key in seen: # one broken reference can surface as two blockers; its first fix covers both
|
|
83
|
+
continue
|
|
84
|
+
seen.add(key)
|
|
85
|
+
steps.append(f"{where}: {item.get('fix') or item['reason']}")
|
|
86
|
+
return steps
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _tree_files(root: Path):
|
|
90
|
+
"""Match the baseline tree copied to a batch output, excluding workspace machinery."""
|
|
91
|
+
if not root.is_dir():
|
|
92
|
+
return
|
|
93
|
+
for current, dirs, files in os.walk(root, followlinks=True):
|
|
94
|
+
# Symlink loops are rejected rather than making a review hang.
|
|
95
|
+
for name in list(dirs):
|
|
96
|
+
p = Path(current) / name
|
|
97
|
+
if name in (".git", ".history", ".cache"):
|
|
98
|
+
dirs.remove(name)
|
|
99
|
+
elif p.is_symlink() and p.resolve() in {Path(current).resolve(), *(a.resolve() for a in Path(current).parents)}:
|
|
100
|
+
raise InvalidArgumentsError("directory symlink cycle in bake source", path=str(p))
|
|
101
|
+
for name in sorted(files):
|
|
102
|
+
if name == PLAN_FILE:
|
|
103
|
+
continue
|
|
104
|
+
yield Path(current) / name
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _revision(ws, table):
|
|
108
|
+
rows = [(str(ws.root / MOUNTS_FILE), file_sha(ws.root / MOUNTS_FILE))]
|
|
109
|
+
for mount in table.all():
|
|
110
|
+
rows.append((str(mount.root), mount.root.is_dir()))
|
|
111
|
+
for p in _tree_files(mount.root):
|
|
112
|
+
rows.append((str(p), file_sha(p)))
|
|
113
|
+
for p in _tree_files(ws.staging.root):
|
|
114
|
+
rows.append((str(p), file_sha(p)))
|
|
115
|
+
return sha256_bytes(json.dumps(sorted(rows), ensure_ascii=False).encode())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _refs(parsed):
|
|
119
|
+
return [(k, r) for k in ("extends", "supersedes") for r in parsed.list_field(k)] + [
|
|
120
|
+
("wikilink", r) for r in parsed.wikilink_targets()]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_plan(path: Path) -> dict:
|
|
124
|
+
try:
|
|
125
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
126
|
+
except (ValueError, OSError) as exc:
|
|
127
|
+
raise InvalidArgumentsError(f"cannot read bake plan: {path}: {exc}") from exc
|
|
128
|
+
valid = isinstance(value, dict) and value.get("schema_version") == 1
|
|
129
|
+
valid = valid and isinstance(value.get("assignments"), dict) and isinstance(value.get("cards"), list)
|
|
130
|
+
valid = valid and isinstance(value.get("slices"), list) and isinstance(value.get("workspace"), str)
|
|
131
|
+
valid = valid and isinstance(value.get("revision"), str)
|
|
132
|
+
if valid:
|
|
133
|
+
valid = all(isinstance(c, dict) and isinstance(c.get("id"), str) and isinstance(c.get("sha256"), str)
|
|
134
|
+
for c in value["cards"])
|
|
135
|
+
valid = valid and all(isinstance(p, str) and (a is None or isinstance(a, str))
|
|
136
|
+
for p, a in value["assignments"].items())
|
|
137
|
+
if not valid:
|
|
138
|
+
raise InvalidArgumentsError("invalid bake plan; regenerate with bake --dry-run --json", path=str(path))
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def save_plan(ws, plan):
|
|
143
|
+
with ws._writing() as table:
|
|
144
|
+
if plan.get("revision") != _revision(ws, table):
|
|
145
|
+
raise _stale("workspace changed during review; run bake again")
|
|
146
|
+
atomic_write_text(ws.root / PLAN_FILE, json.dumps(plan, ensure_ascii=False, indent=2) + "\n")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def plan_bake(ws: "Workspace", assignments: dict[str, str | None] | None = None, *, resume: bool = True) -> dict:
|
|
150
|
+
with ws._writing() as table:
|
|
151
|
+
ws._sync(table, verify=True)
|
|
152
|
+
choices = {}
|
|
153
|
+
saved = ws.root / PLAN_FILE
|
|
154
|
+
if resume and saved.exists():
|
|
155
|
+
old = load_plan(saved)
|
|
156
|
+
if old.get("workspace") != str(ws.root):
|
|
157
|
+
raise InvalidArgumentsError("saved bake plan belongs to another workspace")
|
|
158
|
+
old_hashes = {c["id"]: c["sha256"] for c in old.get("cards", [])}
|
|
159
|
+
live_paths = overlay_cards(ws, table)
|
|
160
|
+
for path, dest in old["assignments"].items():
|
|
161
|
+
# Choices survive baseline fixes, but changed/recreated cards need a new review.
|
|
162
|
+
if path in live_paths and path in old_hashes and old_hashes[path] == file_sha(table.overlay.root / f"{path}.md"):
|
|
163
|
+
choices[path] = dest
|
|
164
|
+
choices.update(assignments or {})
|
|
165
|
+
return _prepare(ws, table, choices)[0]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _prepare(ws, table, assignments):
|
|
169
|
+
revision = _revision(ws, table)
|
|
170
|
+
cards = overlay_cards(ws, table)
|
|
171
|
+
if not isinstance(assignments, dict) or any(not isinstance(p, str) or p not in cards for p in assignments):
|
|
172
|
+
raise InvalidArgumentsError("bake assignments must use existing overlay card IDs")
|
|
173
|
+
choices = {}
|
|
174
|
+
for path, alias in assignments.items():
|
|
175
|
+
if alias is not None and not isinstance(alias, str):
|
|
176
|
+
raise InvalidArgumentsError("a bake destination must be an alias or null (defer)", id=path)
|
|
177
|
+
dest = normalize_alias(alias) if alias is not None else None
|
|
178
|
+
if dest == table.overlay.alias:
|
|
179
|
+
raise InvalidArgumentsError("overlay is not a bake destination", id=path)
|
|
180
|
+
choices[path] = dest
|
|
181
|
+
cats = ws.index.catalogs()
|
|
182
|
+
pending = _diff_staging(ws, table)["items"]
|
|
183
|
+
staged = {i["id"] for i in pending}
|
|
184
|
+
diff = {i["id"]: i for i in _diff_overlay(ws, table)["items"]}
|
|
185
|
+
items, blockers = [], []
|
|
186
|
+
for path, (text, parsed) in sorted(cards.items()):
|
|
187
|
+
candidates = set()
|
|
188
|
+
for m in table.mounts:
|
|
189
|
+
if path in cats[m.alias].paths:
|
|
190
|
+
candidates.add(m.alias)
|
|
191
|
+
for raw in parsed.extends + parsed.supersedes:
|
|
192
|
+
ref = resolve_ref(raw, table.overlay.alias, cats)
|
|
193
|
+
if ref.resolved and ref.mount != table.overlay.alias:
|
|
194
|
+
candidates.add(ref.mount)
|
|
195
|
+
if path not in choices:
|
|
196
|
+
if path in staged:
|
|
197
|
+
choices[path] = None
|
|
198
|
+
elif len(candidates) == 1:
|
|
199
|
+
choices[path] = next(iter(candidates))
|
|
200
|
+
item = dict(diff.get(path, {}), id=path, address=format_address(table.overlay.alias, path),
|
|
201
|
+
sha256=file_sha(table.overlay.root / f"{path}.md"), summary=parsed.index_parts()[0],
|
|
202
|
+
candidates=sorted(candidates), destination=choices.get(path),
|
|
203
|
+
state="unassigned" if path not in choices else ("assigned" if choices[path] else "deferred"))
|
|
204
|
+
items.append(item)
|
|
205
|
+
if choices.get(path) and path in staged:
|
|
206
|
+
blockers.append({"address": item["address"], "reason": "staged change pending; apply, discard or defer this card",
|
|
207
|
+
"fix": f"`mindslice apply {path}` to accept the staged change or `mindslice discard {path}` "
|
|
208
|
+
f"to drop it, or keep the card in overlay with `--defer {path}`"})
|
|
209
|
+
destinations = {p: a for p, a in choices.items() if a}
|
|
210
|
+
groups = defaultdict(list)
|
|
211
|
+
for path, alias in sorted(destinations.items()):
|
|
212
|
+
groups[alias].append(path)
|
|
213
|
+
|
|
214
|
+
# Original resolution for overlay references; future catalogs also permit explicit references
|
|
215
|
+
# to a new slice whose cards are assigned by this very plan.
|
|
216
|
+
future = copy.deepcopy(cats)
|
|
217
|
+
for path, alias in destinations.items():
|
|
218
|
+
future.setdefault(alias, MountCatalog()).add(path, cards[path][1].aliases)
|
|
219
|
+
|
|
220
|
+
def resolve(raw, source):
|
|
221
|
+
result = resolve_ref(raw, source, cats)
|
|
222
|
+
return result if result.resolved else resolve_ref(raw, source, future)
|
|
223
|
+
|
|
224
|
+
def rewrite(text, parsed, source, target, path, *, portable=True):
|
|
225
|
+
def mapper(raw):
|
|
226
|
+
ref = resolve(raw, source)
|
|
227
|
+
if not ref.resolved:
|
|
228
|
+
if portable:
|
|
229
|
+
blockers.append({"address": format_address(target, path), "ref": raw, "reason": "unresolved reference",
|
|
230
|
+
"fix": f"the card references {raw!r}, which no mounted card matches: correct or "
|
|
231
|
+
"remove that reference"
|
|
232
|
+
+ (f", or keep the card in overlay with `--defer {path}`"
|
|
233
|
+
if source == table.overlay.alias else "")})
|
|
234
|
+
return raw
|
|
235
|
+
alias, dest_path = ref.mount, ref.path
|
|
236
|
+
if alias == table.overlay.alias:
|
|
237
|
+
alias = destinations.get(dest_path)
|
|
238
|
+
if alias is None:
|
|
239
|
+
if portable:
|
|
240
|
+
needed = format_address(table.overlay.alias, dest_path)
|
|
241
|
+
blockers.append({"address": format_address(target, path), "ref": raw,
|
|
242
|
+
"reason": "referenced overlay card needs a destination",
|
|
243
|
+
"fix": f"it references {needed}, which stays in overlay: bake that card too "
|
|
244
|
+
f"with `--assign '{dest_path}={target}'`"
|
|
245
|
+
+ (f", or keep this card in overlay with `--defer {path}`"
|
|
246
|
+
if source == table.overlay.alias else "")})
|
|
247
|
+
return raw
|
|
248
|
+
if not portable and ref.mount != table.overlay.alias:
|
|
249
|
+
return raw
|
|
250
|
+
return dest_path if alias == target else format_address(alias, dest_path)
|
|
251
|
+
result = rewrite_refs(text, parsed, mapper)
|
|
252
|
+
return normalize_newlines(result)
|
|
253
|
+
|
|
254
|
+
# Keep overlay copies required by pending work or unchanged read-only mounts. Otherwise all
|
|
255
|
+
# retiring targets are mapped together, so a deferred referrer is rewritten only once.
|
|
256
|
+
retained = {}
|
|
257
|
+
def protect(parsed, source, cause):
|
|
258
|
+
for _, raw in _refs(parsed):
|
|
259
|
+
ref = resolve(raw, source)
|
|
260
|
+
if ref.resolved and ref.mount == table.overlay.alias and ref.path in destinations:
|
|
261
|
+
retained.setdefault(ref.path, cause)
|
|
262
|
+
for item in pending:
|
|
263
|
+
path = item["id"]
|
|
264
|
+
if path in cards:
|
|
265
|
+
protect(cards[path][1], table.overlay.alias, "referenced by pending overlay card")
|
|
266
|
+
text, _ = read_card_file(ws.staging.file(path))
|
|
267
|
+
if text is not None:
|
|
268
|
+
protect(parse_card(text), table.overlay.alias, "referenced by staged content")
|
|
269
|
+
for m in table.mounts:
|
|
270
|
+
if m.alias not in groups:
|
|
271
|
+
for path, _ in iter_markdown(m.root, exclude=tuple(ws._exclude())):
|
|
272
|
+
text, _ = read_card_file(m.root / f"{path}.md")
|
|
273
|
+
if text is not None:
|
|
274
|
+
protect(parse_card(text), m.alias, f"referenced by read-only {m.alias}")
|
|
275
|
+
last = -1
|
|
276
|
+
while last != len(retained):
|
|
277
|
+
last = len(retained)
|
|
278
|
+
for path in list(retained):
|
|
279
|
+
protect(cards[path][1], table.overlay.alias, "required by retained overlay copy")
|
|
280
|
+
|
|
281
|
+
prepared, slices, used_outputs = {}, [], set()
|
|
282
|
+
projected = {}
|
|
283
|
+
same_path = {} # baked address -> the overlay card that extends it at its own path
|
|
284
|
+
for alias, paths in sorted(groups.items()):
|
|
285
|
+
mount = table.get(alias)
|
|
286
|
+
files = {}
|
|
287
|
+
baseline_rebased = []
|
|
288
|
+
if mount:
|
|
289
|
+
if not mount.root.is_dir():
|
|
290
|
+
blockers.append({"alias": alias, "reason": "mounted directory is missing",
|
|
291
|
+
"fix": f"restore {mount.root}, or `mindslice unmount {alias}` and assign its cards "
|
|
292
|
+
"elsewhere (`mindslice doctor` lists missing directories)"})
|
|
293
|
+
for path, _ in iter_markdown(mount.root, exclude=tuple(ws._exclude())):
|
|
294
|
+
text, _ = read_card_file(mount.root / f"{path}.md")
|
|
295
|
+
if text is not None and path not in paths:
|
|
296
|
+
files[path] = rewrite(text, parse_card(text), alias, alias, path)
|
|
297
|
+
if files[path] != normalize_newlines(text):
|
|
298
|
+
baseline_rebased.append(format_address(alias, path))
|
|
299
|
+
for path in paths:
|
|
300
|
+
text, parsed = cards[path]
|
|
301
|
+
for raw in parsed.extends:
|
|
302
|
+
ref = resolve(raw, table.overlay.alias)
|
|
303
|
+
if ref.resolved and ref.mount == alias and ref.path == path:
|
|
304
|
+
same_path[format_address(alias, path)] = format_address(table.overlay.alias, path)
|
|
305
|
+
blockers.append({"address": format_address(table.overlay.alias, path),
|
|
306
|
+
"reason": "extends destination at the same path; consolidate content first",
|
|
307
|
+
"fix": f"{MERGE_FIX}, or keep the card in overlay with `--defer {path}`"})
|
|
308
|
+
value = rewrite(text, parsed, table.overlay.alias, alias, path)
|
|
309
|
+
parsed_new = parse_card(value)
|
|
310
|
+
if path in parsed_new.supersedes:
|
|
311
|
+
fields = dict(parsed_new.frontmatter, supersedes=[r for r in parsed_new.supersedes if r != path])
|
|
312
|
+
value = reassemble(parsed_new, value, fields, None)
|
|
313
|
+
files[path] = value
|
|
314
|
+
sm = load_slice_manifest(mount.root) if mount else SliceManifest(
|
|
315
|
+
name=alias[1:], version="0.1.0", alias=alias, description=f"Baked from {table.overlay.alias}", present=True)
|
|
316
|
+
if sm.errors:
|
|
317
|
+
blockers.append({"alias": alias, "reason": "invalid slice manifest", "errors": sm.errors,
|
|
318
|
+
"fix": f"correct {mount.root / SLICE_FILE} (`mindslice validate {mount.root}` lists the problems)"})
|
|
319
|
+
sm.alias = alias
|
|
320
|
+
sm.name = sm.name or (mount.root.name.removesuffix(".slice") if mount else alias[1:])
|
|
321
|
+
sm.version = bump_version(sm.version) if mount and sm.version else (sm.version or "0.1.0")
|
|
322
|
+
declared = set()
|
|
323
|
+
for dep in sm.dependencies:
|
|
324
|
+
try:
|
|
325
|
+
declared.add(normalize_alias(dep))
|
|
326
|
+
except ValueError:
|
|
327
|
+
blockers.append({"alias": alias, "dependency": dep, "reason": "invalid dependency alias",
|
|
328
|
+
"fix": f"correct the dependency {dep!r} in {mount.root / SLICE_FILE if mount else SLICE_FILE}"})
|
|
329
|
+
dependencies = set(declared)
|
|
330
|
+
for path, value in files.items():
|
|
331
|
+
parsed = parse_card(value)
|
|
332
|
+
projected[(alias, path)] = parsed
|
|
333
|
+
if parsed.errors:
|
|
334
|
+
blockers.append({"address": format_address(alias, path), "reason": "invalid card", "errors": parsed.errors,
|
|
335
|
+
"fix": "correct the card's frontmatter so it parses"
|
|
336
|
+
+ (f", or keep it in overlay with `--defer {path}`" if path in paths else "")})
|
|
337
|
+
for _, raw in _refs(parsed):
|
|
338
|
+
ref = resolve_ref(raw, alias, future)
|
|
339
|
+
if ref.mount and ref.mount != alias:
|
|
340
|
+
dependencies.add(ref.mount)
|
|
341
|
+
dependencies.discard(alias)
|
|
342
|
+
sm.dependencies = sorted(dependencies)
|
|
343
|
+
for dep in sm.dependencies:
|
|
344
|
+
if dep not in future or dep == table.overlay.alias:
|
|
345
|
+
blockers.append({"alias": alias, "dependency": dep, "reason": "missing or nonportable dependency",
|
|
346
|
+
"fix": (f"cards bound for {alias} reference overlay cards that stay behind: give those "
|
|
347
|
+
"cards a destination too" if dep == table.overlay.alias else
|
|
348
|
+
f"mount the slice it depends on (`mindslice mount PATH --alias {dep}`), or "
|
|
349
|
+
f"drop {dep} from the slice's declared dependencies and card references")})
|
|
350
|
+
output = default_output(ws, alias, mount)
|
|
351
|
+
# Two mounts can have the same directory basename; reserve outputs across the full batch.
|
|
352
|
+
while str(output) in used_outputs:
|
|
353
|
+
output = output.with_name(output.stem + "-2.slice")
|
|
354
|
+
used_outputs.add(str(output))
|
|
355
|
+
for root in [ws.staging.root, ws.cache_dir, ws.history.root] + [m.root for m in table.all()]:
|
|
356
|
+
if output.resolve() == root.resolve() or root.resolve() in output.resolve().parents:
|
|
357
|
+
blockers.append({"alias": alias, "reason": "output falls inside a mounted/internal directory", "path": str(output),
|
|
358
|
+
"fix": f"bake this slice on its own to another location: `mindslice bake {alias} --output DIR`"
|
|
359
|
+
+ ("" if mount else " (with `--prefix` to select its cards)")})
|
|
360
|
+
slices.append({"alias": alias, "path": str(output), "output": str(output),
|
|
361
|
+
"previous_path": str(mount.root) if mount else None, "version": sm.version,
|
|
362
|
+
"action": "update" if mount else "create", "cards": paths,
|
|
363
|
+
"rebased_baseline_cards": sorted(baseline_rebased),
|
|
364
|
+
"dependencies": sm.dependencies, "dependencies_added": sorted(dependencies - declared)})
|
|
365
|
+
prepared[alias] = (files, sm, output, mount)
|
|
366
|
+
|
|
367
|
+
# Validate the full future lineage, including unchanged mounts. Detect cycles introduced by
|
|
368
|
+
# assigning different overlay cards to the same baseline namespace.
|
|
369
|
+
graph = defaultdict(list)
|
|
370
|
+
for row in ws.index.all_cards():
|
|
371
|
+
key = (row["mount"], row["path"])
|
|
372
|
+
if row["mount"] in groups or (row["mount"] == table.overlay.alias and row["path"] in destinations and row["path"] not in retained):
|
|
373
|
+
continue
|
|
374
|
+
mount = table.get(row["mount"])
|
|
375
|
+
text, _ = read_card_file(mount.root / f"{row['path']}.md")
|
|
376
|
+
if text is not None:
|
|
377
|
+
projected[key] = parse_card(text)
|
|
378
|
+
for (alias, path), parsed in projected.items():
|
|
379
|
+
for kind, raw in _refs(parsed):
|
|
380
|
+
ref = resolve_ref(raw, alias, future)
|
|
381
|
+
if alias in groups and not ref.resolved:
|
|
382
|
+
blockers.append({"address": format_address(alias, path), "ref": raw, "reason": "unresolved output reference",
|
|
383
|
+
"fix": f"the baked card would reference {raw!r}, which nothing in the planned workspace "
|
|
384
|
+
"matches: correct or remove that reference, or bake its target in the same run"})
|
|
385
|
+
if kind != "wikilink" and ref.resolved:
|
|
386
|
+
graph[format_address(alias, path)].append(ref.address)
|
|
387
|
+
nodes = set(graph) | {n for values in graph.values() for n in values}
|
|
388
|
+
for cycle in strongly_connected_cycles(sorted(nodes), lambda n: graph.get(n, ())):
|
|
389
|
+
cause = same_path.get(cycle[0]) if len(cycle) == 1 else None
|
|
390
|
+
blockers.append({"reason": "lineage cycle in planned workspace", "cards": cycle} | (
|
|
391
|
+
# Baked as it is, a card extending its own path would extend itself: one problem, not two.
|
|
392
|
+
{"fix": f"follows from {cause} extending this path; resolving that card removes the cycle", "derived": True}
|
|
393
|
+
if cause else
|
|
394
|
+
{"fix": "remove one `extends`/`supersedes` link among " + ", ".join(cycle) + " to break the cycle"}))
|
|
395
|
+
|
|
396
|
+
# Retained and staged cards stay byte-identical. All other remaining overlay refs are rebased
|
|
397
|
+
# in one pass, using only targets that really retire.
|
|
398
|
+
retired = set(destinations) - set(retained)
|
|
399
|
+
rebased = {}
|
|
400
|
+
for path, (text, parsed) in cards.items():
|
|
401
|
+
if path in destinations or path in staged:
|
|
402
|
+
continue
|
|
403
|
+
def mapper(raw):
|
|
404
|
+
ref = resolve(raw, table.overlay.alias)
|
|
405
|
+
if ref.resolved and ref.mount == table.overlay.alias and ref.path in retired:
|
|
406
|
+
return format_address(destinations[ref.path], ref.path)
|
|
407
|
+
return raw
|
|
408
|
+
value = rewrite_refs(text, parsed, mapper)
|
|
409
|
+
if value != text:
|
|
410
|
+
rebased[path] = value
|
|
411
|
+
plan = {"schema_version": 1, "workspace": str(ws.root), "revision": revision,
|
|
412
|
+
"assignments": choices, "cards": items, "slices": slices,
|
|
413
|
+
"unassigned": [i["id"] for i in items if i["state"] == "unassigned"],
|
|
414
|
+
"deferred": [p for p, a in choices.items() if a is None],
|
|
415
|
+
"pending_review": pending, "retained_overlay_cards": [
|
|
416
|
+
{"address": format_address(table.overlay.alias, p), "reason": retained[p]} for p in sorted(retained)],
|
|
417
|
+
"rebased_references": sorted(rebased), "blockers": blockers,
|
|
418
|
+
"remaining_overlay_cards": sorted(set(cards) - retired)}
|
|
419
|
+
plan["ready"] = not (plan["unassigned"] or blockers)
|
|
420
|
+
if revision != _revision(ws, table):
|
|
421
|
+
raise _stale("files changed during planning; retry review")
|
|
422
|
+
return plan, prepared, retired, rebased
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _reviewed(ws, table, reviewed):
|
|
426
|
+
if not isinstance(reviewed, dict) or reviewed.get("schema_version") != 1 or reviewed.get("workspace") != str(ws.root):
|
|
427
|
+
raise InvalidArgumentsError("invalid bake plan or wrong workspace")
|
|
428
|
+
if reviewed.get("revision") != _revision(ws, table):
|
|
429
|
+
raise _stale("workspace changed since review; run bake again")
|
|
430
|
+
ws._sync(table, verify=True)
|
|
431
|
+
prepared = _prepare(ws, table, reviewed.get("assignments"))
|
|
432
|
+
if prepared[0]["slices"] != reviewed.get("slices"):
|
|
433
|
+
raise _stale("bake outputs changed since review; run bake again")
|
|
434
|
+
return prepared
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def inspect_plan(ws: "Workspace", reviewed: dict) -> dict:
|
|
438
|
+
with ws._writing() as table:
|
|
439
|
+
return _reviewed(ws, table, reviewed)[0]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def execute(ws: "Workspace", reviewed: dict) -> dict:
|
|
443
|
+
with ws._writing() as table:
|
|
444
|
+
plan, prepared, retired, rebased = _reviewed(ws, table, reviewed)
|
|
445
|
+
if not plan["ready"]:
|
|
446
|
+
raise BakeConflictError(blocked_message(plan), plan=plan, next_steps=next_steps(plan) + [
|
|
447
|
+
"Then plan and bake again."])
|
|
448
|
+
if not prepared:
|
|
449
|
+
return {"baked": [], "remaining_overlay_cards": plan["remaining_overlay_cards"],
|
|
450
|
+
"retained_overlay_cards": [], "pending_review": len(plan["pending_review"]),
|
|
451
|
+
"rebased_references": [], "dry_run": False}
|
|
452
|
+
ws.history.events() # fail before producing outputs if the log is corrupt
|
|
453
|
+
temporary = Path(tempfile.mkdtemp(prefix="mindslice-batch-"))
|
|
454
|
+
published = []
|
|
455
|
+
transaction_started = False
|
|
456
|
+
try:
|
|
457
|
+
# Preparation is isolated. No mount, overlay or final output changes until all trees exist.
|
|
458
|
+
for alias, (files, sm, output, mount) in prepared.items():
|
|
459
|
+
tree = temporary / alias[1:]
|
|
460
|
+
tree.mkdir()
|
|
461
|
+
if mount:
|
|
462
|
+
for source in _tree_files(mount.root):
|
|
463
|
+
dest = tree / source.relative_to(mount.root)
|
|
464
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
465
|
+
shutil.copyfile(source, dest)
|
|
466
|
+
for path, text in files.items():
|
|
467
|
+
atomic_write_text(tree / f"{path}.md", text)
|
|
468
|
+
atomic_write_text(tree / SLICE_FILE, dump_slice_manifest(sm))
|
|
469
|
+
if plan["revision"] != _revision(ws, table):
|
|
470
|
+
raise _stale("files changed while preparing bake; review again")
|
|
471
|
+
# Publish via a sibling temp directory so the final rename works across devices.
|
|
472
|
+
for alias, (_, _, output, _) in prepared.items():
|
|
473
|
+
if output.exists():
|
|
474
|
+
raise BakeConflictError("output appeared during bake", path=str(output), next_steps=[
|
|
475
|
+
"Run `mindslice bake` again: planning picks an output directory that is still free."])
|
|
476
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
477
|
+
sibling = Path(tempfile.mkdtemp(prefix=".bake-", dir=output.parent))
|
|
478
|
+
try:
|
|
479
|
+
shutil.copytree(temporary / alias[1:], sibling / "tree")
|
|
480
|
+
for p in _tree_files(sibling / "tree"):
|
|
481
|
+
fsync_file(p)
|
|
482
|
+
for directory, _, _ in os.walk(sibling / "tree", topdown=False):
|
|
483
|
+
fsync_dir(Path(directory))
|
|
484
|
+
os.rename(sibling / "tree", output)
|
|
485
|
+
published.append(output)
|
|
486
|
+
fsync_dir(output.parent)
|
|
487
|
+
finally:
|
|
488
|
+
shutil.rmtree(sibling, ignore_errors=True)
|
|
489
|
+
new_mounts = {a: Mount(a, portable_path(ws.root, output), m.precedence if m else 50,
|
|
490
|
+
m.role if m else "baseline", "slice", output)
|
|
491
|
+
for a, (_, _, output, m) in prepared.items()}
|
|
492
|
+
mounts = [new_mounts.pop(m.alias, m) for m in table.mounts] + list(new_mounts.values())
|
|
493
|
+
actions = [file_action(ws.root / MOUNTS_FILE, dump_mounts(MountTable(table.overlay, mounts)))]
|
|
494
|
+
events = []
|
|
495
|
+
for path, text in rebased.items():
|
|
496
|
+
action = file_action(table.overlay.root / f"{path}.md", text)
|
|
497
|
+
actions.append(action)
|
|
498
|
+
events.append(change("applied", address=format_address(table.overlay.alias, path), op="rebase", origin="engine",
|
|
499
|
+
reason="Rebase references after workspace bake", before_sha256=action["before_sha256"],
|
|
500
|
+
after_sha256=action["after_sha256"]))
|
|
501
|
+
actions += [file_action(table.overlay.root / f"{p}.md", None, table.overlay.root) for p in sorted(retired)]
|
|
502
|
+
if (ws.root / PLAN_FILE).exists():
|
|
503
|
+
actions.append(file_action(ws.root / PLAN_FILE, None))
|
|
504
|
+
active = ws.history.applied()
|
|
505
|
+
receipt = []
|
|
506
|
+
for result in plan["slices"]:
|
|
507
|
+
baked_cards = []
|
|
508
|
+
for path in result["cards"]:
|
|
509
|
+
address = format_address(table.overlay.alias, path)
|
|
510
|
+
before = file_sha(table.overlay.root / f"{path}.md")
|
|
511
|
+
tip, ids = active.get(address, (None, []))
|
|
512
|
+
baked_cards.append({"source": address, "target": format_address(result["alias"], path),
|
|
513
|
+
"before_sha256": before, "after_sha256": file_sha(Path(result["path"]) / f"{path}.md"),
|
|
514
|
+
"change_ids": ids if tip == before else []})
|
|
515
|
+
record = event("baked", alias=result["alias"], path=result["path"], output=result["output"],
|
|
516
|
+
previous_path=result["previous_path"], version=result["version"], cards=baked_cards,
|
|
517
|
+
change_ids=list(dict.fromkeys(id for c in baked_cards for id in c["change_ids"])),
|
|
518
|
+
retired_overlay_cards=[format_address(table.overlay.alias, p) for p in result["cards"] if p in retired])
|
|
519
|
+
events.append(record)
|
|
520
|
+
receipt.append(dict(result, history_event_id=record["event_id"]))
|
|
521
|
+
guards = [{"path": str(p), "sha256": file_sha(p)} for output in published for p in _tree_files(output)]
|
|
522
|
+
if plan["revision"] != _revision(ws, table):
|
|
523
|
+
raise _stale("files changed before commit; review again")
|
|
524
|
+
transaction_started = True
|
|
525
|
+
ws.history.transact(actions, events, guards=guards)
|
|
526
|
+
ws._sync(ws._reload_table())
|
|
527
|
+
return {"baked": receipt, "remaining_overlay_cards": plan["remaining_overlay_cards"],
|
|
528
|
+
"retained_overlay_cards": plan["retained_overlay_cards"], "pending_review": len(plan["pending_review"]),
|
|
529
|
+
"rebased_references": plan["rebased_references"], "dry_run": False}
|
|
530
|
+
except BaseException:
|
|
531
|
+
# Once journaled, recovery owns the outputs. Never remove files a pending commit needs.
|
|
532
|
+
if not transaction_started:
|
|
533
|
+
for output in published:
|
|
534
|
+
shutil.rmtree(output)
|
|
535
|
+
raise
|
|
536
|
+
finally:
|
|
537
|
+
shutil.rmtree(temporary, ignore_errors=True)
|