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
mindslice/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""MindSlice: a layered, pluggable, human-readable long-term memory engine for LLM agents.
|
|
2
|
+
|
|
3
|
+
Plain Markdown + TOML on disk is the single source of truth; ``.cache/index.sqlite``
|
|
4
|
+
is a disposable acceleration cache. Standard library only (Python 3.12+).
|
|
5
|
+
|
|
6
|
+
>>> from mindslice import Workspace
|
|
7
|
+
>>> ws = Workspace.init("workspace")
|
|
8
|
+
>>> ws.write("user-preferences/tooling", summary="The user prefers uv.", content="- Use `uv run` for scripts.")
|
|
9
|
+
>>> ws.search("uv scripts")["results"][0]["address"]
|
|
10
|
+
'@overlay/user-preferences/tooling'
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .errors import (
|
|
14
|
+
AlreadyExistsError,
|
|
15
|
+
AmbiguousAddressError,
|
|
16
|
+
ApplyConflictError,
|
|
17
|
+
BakeConflictError,
|
|
18
|
+
FrontmatterError,
|
|
19
|
+
HistoryError,
|
|
20
|
+
InvalidAddressError,
|
|
21
|
+
InvalidArgumentsError,
|
|
22
|
+
LineageCycleError,
|
|
23
|
+
LockTimeoutError,
|
|
24
|
+
MindSliceError,
|
|
25
|
+
MountError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
PreconditionFailedError,
|
|
28
|
+
ReadOnlyMountError,
|
|
29
|
+
RecoveryError,
|
|
30
|
+
UnresolvedExportDependencyError,
|
|
31
|
+
WorkspaceNotFoundError,
|
|
32
|
+
)
|
|
33
|
+
from .workspace import Workspace
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Workspace",
|
|
39
|
+
"MindSliceError",
|
|
40
|
+
"AlreadyExistsError",
|
|
41
|
+
"AmbiguousAddressError",
|
|
42
|
+
"ApplyConflictError",
|
|
43
|
+
"BakeConflictError",
|
|
44
|
+
"FrontmatterError",
|
|
45
|
+
"HistoryError",
|
|
46
|
+
"InvalidAddressError",
|
|
47
|
+
"InvalidArgumentsError",
|
|
48
|
+
"LineageCycleError",
|
|
49
|
+
"LockTimeoutError",
|
|
50
|
+
"MountError",
|
|
51
|
+
"NotFoundError",
|
|
52
|
+
"PreconditionFailedError",
|
|
53
|
+
"ReadOnlyMountError",
|
|
54
|
+
"RecoveryError",
|
|
55
|
+
"UnresolvedExportDependencyError",
|
|
56
|
+
"WorkspaceNotFoundError",
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
mindslice/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Slice preparation, reference transfer, batch planning and export."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Deterministic archives of a baked slice directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import tarfile
|
|
9
|
+
import tempfile
|
|
10
|
+
import zipfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..storage.fsutil import fsync_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def archive_format(output: Path) -> str:
|
|
17
|
+
name = output.name.lower()
|
|
18
|
+
if name.endswith(".zip"):
|
|
19
|
+
return "zip"
|
|
20
|
+
if name.endswith((".tar.gz", ".tgz")):
|
|
21
|
+
return "tar.gz"
|
|
22
|
+
return "directory"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_archive(tree: Path, output: Path, fmt: str) -> None:
|
|
26
|
+
"""Write all files, with fixed archive metadata, then atomically publish the archive."""
|
|
27
|
+
name = output.name
|
|
28
|
+
for suffix in (".tar.gz", ".tgz", ".zip"):
|
|
29
|
+
if name.lower().endswith(suffix):
|
|
30
|
+
name = name[:-len(suffix)]
|
|
31
|
+
break
|
|
32
|
+
root = name if name.endswith(".slice") else name + ".slice"
|
|
33
|
+
entries = [(root, None)]
|
|
34
|
+
entries += [(root + "/" + p.relative_to(tree).as_posix(), None if p.is_dir() else p.read_bytes())
|
|
35
|
+
for p in sorted(tree.rglob("*"))]
|
|
36
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{output.name}.", dir=output.parent)
|
|
38
|
+
os.close(fd)
|
|
39
|
+
try:
|
|
40
|
+
if fmt == "zip":
|
|
41
|
+
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
42
|
+
for path, data in entries:
|
|
43
|
+
info = zipfile.ZipInfo(path + "/" if data is None else path, date_time=(1980, 1, 1, 0, 0, 0))
|
|
44
|
+
info.external_attr = ((0o40755 << 16) | 0x10) if data is None else 0o100644 << 16
|
|
45
|
+
if data is not None:
|
|
46
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
47
|
+
archive.writestr(info, b"" if data is None else data)
|
|
48
|
+
else:
|
|
49
|
+
with open(temporary, "wb") as raw, gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz:
|
|
50
|
+
with tarfile.open(fileobj=gz, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
|
51
|
+
for path, data in entries:
|
|
52
|
+
info = tarfile.TarInfo(path)
|
|
53
|
+
info.mtime = 0
|
|
54
|
+
info.uid = info.gid = 0
|
|
55
|
+
info.uname = info.gname = ""
|
|
56
|
+
info.mode = 0o755 if data is None else 0o644
|
|
57
|
+
if data is None:
|
|
58
|
+
info.type = tarfile.DIRTYPE
|
|
59
|
+
archive.addfile(info)
|
|
60
|
+
else:
|
|
61
|
+
info.size = len(data)
|
|
62
|
+
archive.addfile(info, io.BytesIO(data))
|
|
63
|
+
os.replace(temporary, output)
|
|
64
|
+
fsync_dir(output.parent)
|
|
65
|
+
finally:
|
|
66
|
+
Path(temporary).unlink(missing_ok=True)
|
mindslice/baking/bake.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""``bake()``: create or update a slice and mount it under the requested alias.
|
|
2
|
+
|
|
3
|
+
A new alias requires an explicit ``prefix`` selecting overlay cards. ``bake(alias)`` without a
|
|
4
|
+
prefix builds the next version of a mounted slice: a copy of its folder (``.git`` excluded) plus the
|
|
5
|
+
overlay cards, at a new path. The new folder then takes over the mount (same alias, precedence and role, so every
|
|
6
|
+
``@alias/...`` reference keeps resolving), the overlay copies are retired, and the old folder is left
|
|
7
|
+
exactly as it was: no longer mounted, and a way back (``mount`` it again). It is a curation step for
|
|
8
|
+
the slice's owner (CLI and Python, never an agent tool).
|
|
9
|
+
|
|
10
|
+
Which cards: with ``prefix`` / ``tags``, the overlay cards they select; without, the overlay cards
|
|
11
|
+
that belong to the slice: those that extend or supersede one of its cards, or sit
|
|
12
|
+
at the same path as one. ``include_deps`` pulls in overlay cards the selection references.
|
|
13
|
+
|
|
14
|
+
How each card is written into the new slice:
|
|
15
|
+
|
|
16
|
+
* references to the slice itself (``@alias/x``) and to other baked cards become slice-relative
|
|
17
|
+
(``x``); references to other mounts stay qualified and are added to ``slice.toml`` dependencies;
|
|
18
|
+
* a card that supersedes the slice card at its own path, or shadows it, replaces that file (the
|
|
19
|
+
card's ``supersedes`` drops the reference to itself); a superseded card at another path stays,
|
|
20
|
+
as the route that forwards its hits;
|
|
21
|
+
* a card that *extends* the slice card at its own path can't replace it without losing the
|
|
22
|
+
baseline: bake refuses it (``BakeConflictError``) until it is consolidated into one card.
|
|
23
|
+
|
|
24
|
+
The new slice's ``slice.toml`` version is bumped (``1.2.3`` -> ``1.2.4``) when it has that form. Its
|
|
25
|
+
default path is ``<workspace>/slices/<name>.slice``, or ``<name>-2.slice``, ``-3`` ... when taken.
|
|
26
|
+
``dry_run`` reports the plan and writes nothing. The new folder is built in a temporary directory and
|
|
27
|
+
moved into place whole; the mount table is switched in one atomic write.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import tempfile
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import TYPE_CHECKING, Any, Sequence
|
|
38
|
+
|
|
39
|
+
from ..model.addressing import format_address, normalize_alias, resolve_ref
|
|
40
|
+
from .archive import archive_format, write_archive
|
|
41
|
+
from ..model.card import normalize_newlines, parse_card, truncate
|
|
42
|
+
from ..storage.config import (
|
|
43
|
+
DESCRIPTION_CHARS,
|
|
44
|
+
MOUNTS_FILE,
|
|
45
|
+
OVERVIEW_PATH,
|
|
46
|
+
SLICE_FILE,
|
|
47
|
+
Mount,
|
|
48
|
+
MountTable,
|
|
49
|
+
SliceManifest,
|
|
50
|
+
dump_slice_manifest,
|
|
51
|
+
load_slice_manifest,
|
|
52
|
+
portable_path,
|
|
53
|
+
dump_mounts,
|
|
54
|
+
)
|
|
55
|
+
from ..errors import AlreadyExistsError, BakeConflictError, InvalidAddressError, InvalidArgumentsError, MountError, NotFoundError
|
|
56
|
+
from ..storage.fsutil import fsync_dir
|
|
57
|
+
from ..storage.history import event, file_action, file_sha
|
|
58
|
+
from ..model.text import fold
|
|
59
|
+
from .slice_transfer import dependency_closure, export_text, overlay_cards, retire_overlay_paths, rewrite_refs
|
|
60
|
+
from ..operations.validation import validate_slice_dir
|
|
61
|
+
from ..model.card import reassemble
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
64
|
+
from ..workspace import Workspace
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bump_version(version: str) -> str:
|
|
71
|
+
m = _SEMVER_RE.match(version.strip())
|
|
72
|
+
return f"{m.group(1)}.{m.group(2)}.{int(m.group(3)) + 1}" if m else version
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def default_output(ws: "Workspace", alias: str, mount: Mount | None) -> Path:
|
|
76
|
+
"""``<workspace>/slices/<name>.slice``, or the first free ``<name>-N.slice``."""
|
|
77
|
+
stem = re.sub(r"-\d+$", "", mount.root.name.removesuffix(".slice")) if mount else alias.lstrip("@")
|
|
78
|
+
stem = stem or "slice"
|
|
79
|
+
slices = ws.root / "slices"
|
|
80
|
+
n = 1
|
|
81
|
+
while True:
|
|
82
|
+
candidate = slices / (f"{stem}.slice" if n == 1 else f"{stem}-{n}.slice")
|
|
83
|
+
if (mount is None or candidate != mount.root) and not candidate.exists():
|
|
84
|
+
return candidate
|
|
85
|
+
n += 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def bake(
|
|
89
|
+
ws: "Workspace",
|
|
90
|
+
alias: str,
|
|
91
|
+
output_path: str | os.PathLike[str] | None = None,
|
|
92
|
+
prefix: str | None = None,
|
|
93
|
+
tags: str | Sequence[str] | None = None,
|
|
94
|
+
include_deps: bool = False,
|
|
95
|
+
dry_run: bool = False,
|
|
96
|
+
*,
|
|
97
|
+
name: str | None = None,
|
|
98
|
+
description: str | None = None,
|
|
99
|
+
version: str | None = None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
alias = normalize_alias(alias)
|
|
102
|
+
tag_set = {fold(t) for t in ([tags] if isinstance(tags, str) else (tags or [])) if fold(t)}
|
|
103
|
+
if prefix is not None:
|
|
104
|
+
prefix = prefix.strip().lstrip("./").lstrip("/")
|
|
105
|
+
with ws._writing() as table:
|
|
106
|
+
mount = table.get(alias)
|
|
107
|
+
overlay = table.overlay
|
|
108
|
+
if mount is None and prefix is None:
|
|
109
|
+
raise InvalidArgumentsError(f"--prefix is required to create {alias}; use prefix='' to select all overlay cards",
|
|
110
|
+
alias=alias)
|
|
111
|
+
if alias == overlay.alias:
|
|
112
|
+
raise MountError(f"bake moves {overlay.alias} cards into a slice; name the slice, not {alias}")
|
|
113
|
+
if mount and not mount.root.is_dir():
|
|
114
|
+
raise MountError(f"{alias}: {mount.root} is not a directory", alias=alias)
|
|
115
|
+
idx = ws.index
|
|
116
|
+
cats = idx.catalogs()
|
|
117
|
+
cards = overlay_cards(ws, table)
|
|
118
|
+
slice_paths = {r["path"] for r in idx.all_cards(alias)}
|
|
119
|
+
|
|
120
|
+
def targets_slice(raw: str) -> str | None:
|
|
121
|
+
res = resolve_ref(raw, overlay.alias, cats)
|
|
122
|
+
return res.path if res.resolved and res.mount == alias else None
|
|
123
|
+
|
|
124
|
+
def selected(path: str, parsed) -> bool:
|
|
125
|
+
if path == OVERVIEW_PATH and (mount or prefix or tag_set):
|
|
126
|
+
return False
|
|
127
|
+
if prefix is not None or tag_set or mount is None:
|
|
128
|
+
return (not prefix or path.startswith(prefix)) and tag_set <= {fold(t) for t in parsed.tags}
|
|
129
|
+
return path in slice_paths or any(targets_slice(r) for r in parsed.extends + parsed.supersedes)
|
|
130
|
+
|
|
131
|
+
chosen = sorted(p for p, (_, parsed) in cards.items() if selected(p, parsed))
|
|
132
|
+
if not chosen:
|
|
133
|
+
how = f"prefix={prefix!r} tags={sorted(tag_set)}" if (prefix is not None or tag_set) else (
|
|
134
|
+
f"none extends, supersedes or shares a path with a card in {alias}; pass prefix or tags to choose")
|
|
135
|
+
raise NotFoundError(f"no {overlay.alias} cards to bake into {alias} ({how})", alias=alias)
|
|
136
|
+
included, pulled, external, warnings = dependency_closure(cards, chosen, overlay.alias, cats, include_deps)
|
|
137
|
+
external.discard(alias)
|
|
138
|
+
|
|
139
|
+
staged_paths = ws.staging.entries()
|
|
140
|
+
staged = sorted(p for p in included if p in staged_paths)
|
|
141
|
+
conflicts = [{"address": format_address(overlay.alias, p), "reason": "staged change pending (apply or discard it first)",
|
|
142
|
+
"fix": f"`mindslice apply {p}` to accept the staged change, or `mindslice discard {p}` to drop it"}
|
|
143
|
+
for p in staged]
|
|
144
|
+
for path in sorted(included):
|
|
145
|
+
parsed = cards[path][1]
|
|
146
|
+
if path in slice_paths and any(targets_slice(r) == path for r in parsed.extends):
|
|
147
|
+
conflicts.append({"address": format_address(overlay.alias, path),
|
|
148
|
+
"reason": f"extends {format_address(alias, path)} at the same path; merge the two "
|
|
149
|
+
"into one card first",
|
|
150
|
+
"fix": "rewrite the overlay card with the combined text and drop its `extends` link, "
|
|
151
|
+
"so one card replaces the slice's"})
|
|
152
|
+
if conflicts:
|
|
153
|
+
raise BakeConflictError(f"bake into {alias} stopped before writing: {len(conflicts)} card(s) can't move as they are",
|
|
154
|
+
conflicts=conflicts,
|
|
155
|
+
next_steps=[f"{c['address']}: {c['fix']}" for c in conflicts]
|
|
156
|
+
+ [f"Then run the same `mindslice bake {alias}` command again; "
|
|
157
|
+
"`--dry-run` previews it without writing."])
|
|
158
|
+
|
|
159
|
+
def mapper(raw: str) -> str:
|
|
160
|
+
res = resolve_ref(raw, overlay.alias, cats)
|
|
161
|
+
if res.resolved and res.path and (res.mount == alias or (res.mount == overlay.alias and res.path in included)):
|
|
162
|
+
return res.path
|
|
163
|
+
if res.resolved and res.mount and res.path:
|
|
164
|
+
return format_address(res.mount, res.path)
|
|
165
|
+
return raw
|
|
166
|
+
|
|
167
|
+
files: dict[str, str] = {}
|
|
168
|
+
created: list[str] = []
|
|
169
|
+
replaced: list[str] = []
|
|
170
|
+
for path in sorted(included):
|
|
171
|
+
text, parsed = cards[path]
|
|
172
|
+
new_text = rewrite_refs(text, parsed, mapper) if mount else export_text(text, parsed, overlay.alias, cats)
|
|
173
|
+
new_parsed = parse_card(new_text)
|
|
174
|
+
if path in new_parsed.supersedes: # it replaces the file it superseded; a self-reference would be a cycle
|
|
175
|
+
fields = dict(new_parsed.frontmatter)
|
|
176
|
+
fields["supersedes"] = [r for r in new_parsed.supersedes if r != path]
|
|
177
|
+
new_text = reassemble(new_parsed, new_text, fields, None)
|
|
178
|
+
files[path] = normalize_newlines(new_text)
|
|
179
|
+
(replaced if path in slice_paths else created).append(format_address(alias, path))
|
|
180
|
+
|
|
181
|
+
sm = load_slice_manifest(mount.root) if mount else SliceManifest(
|
|
182
|
+
name=alias.lstrip("@"), version="0.1.0", alias=alias,
|
|
183
|
+
description=f"Baked from {overlay.alias} (prefix={prefix})", present=True)
|
|
184
|
+
declared = set()
|
|
185
|
+
for dep in sm.dependencies:
|
|
186
|
+
try:
|
|
187
|
+
declared.add(normalize_alias(dep))
|
|
188
|
+
except InvalidAddressError:
|
|
189
|
+
pass
|
|
190
|
+
added_deps = sorted(external - declared)
|
|
191
|
+
requested = default_output(ws, alias, mount) if output_path is None else Path(output_path).expanduser().absolute()
|
|
192
|
+
requested = Path(os.path.abspath(requested))
|
|
193
|
+
fmt = archive_format(requested)
|
|
194
|
+
output = requested if fmt == "directory" else default_output(ws, alias, mount)
|
|
195
|
+
if fmt != "directory" and output in requested.parents:
|
|
196
|
+
raise MountError("the archive cannot go inside the slice directory being created", output=str(requested))
|
|
197
|
+
if fmt != "directory" and requested.exists():
|
|
198
|
+
raise AlreadyExistsError(f"{requested} already exists", path=str(requested))
|
|
199
|
+
if mount and (output == mount.root or mount.root in output.parents):
|
|
200
|
+
raise MountError(f"the new slice can't go inside {alias}'s own folder ({mount.root}); pass another output",
|
|
201
|
+
alias=alias, output=str(output))
|
|
202
|
+
if output.exists() and (not output.is_dir() or any(output.iterdir())):
|
|
203
|
+
raise AlreadyExistsError(f"{output} already exists and is not empty", path=str(output))
|
|
204
|
+
for destination in {output, requested}:
|
|
205
|
+
resolved = destination.resolve()
|
|
206
|
+
for root in (ws.staging.root, ws.cache_dir, ws.history.root):
|
|
207
|
+
if resolved == root.resolve() or root.resolve() in resolved.parents:
|
|
208
|
+
raise MountError("cannot bake into MindSlice internal directories", output=str(destination))
|
|
209
|
+
for existing in table.all():
|
|
210
|
+
if resolved == existing.root.resolve() or existing.root.resolve() in resolved.parents:
|
|
211
|
+
raise MountError(f"cannot bake inside mounted {existing.alias}'s folder", output=str(destination))
|
|
212
|
+
new_version = version if version is not None else (bump_version(sm.version) if mount else sm.version)
|
|
213
|
+
if name is not None:
|
|
214
|
+
sm.name = name
|
|
215
|
+
elif not sm.present:
|
|
216
|
+
sm.name = mount.root.name.removesuffix(".slice")
|
|
217
|
+
if description is not None or mount is None:
|
|
218
|
+
sm.description, _ = truncate(" ".join((description if description is not None else sm.description).split()),
|
|
219
|
+
DESCRIPTION_CHARS)
|
|
220
|
+
result: dict[str, Any] = {
|
|
221
|
+
"alias": alias,
|
|
222
|
+
"output": str(requested),
|
|
223
|
+
"path": str(output),
|
|
224
|
+
"previous_path": str(mount.root) if mount else None,
|
|
225
|
+
"format": fmt,
|
|
226
|
+
"name": sm.name,
|
|
227
|
+
"cards": sorted(included),
|
|
228
|
+
"dependencies": sorted(declared | external),
|
|
229
|
+
"dry_run": dry_run,
|
|
230
|
+
"created": created,
|
|
231
|
+
"replaced": replaced,
|
|
232
|
+
"included_dependencies": pulled,
|
|
233
|
+
"dependencies_added": added_deps,
|
|
234
|
+
"warnings": warnings,
|
|
235
|
+
}
|
|
236
|
+
if new_version:
|
|
237
|
+
result["version"] = new_version
|
|
238
|
+
if dry_run:
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
tmp_root = Path(tempfile.mkdtemp(prefix=f".{output.name}.", dir=output.parent))
|
|
243
|
+
try:
|
|
244
|
+
tree = tmp_root / output.name
|
|
245
|
+
if mount:
|
|
246
|
+
shutil.copytree(mount.root, tree, symlinks=False, ignore=shutil.ignore_patterns(".git", ".history", ".bake-plan.json"))
|
|
247
|
+
else:
|
|
248
|
+
tree.mkdir()
|
|
249
|
+
for path, text in files.items():
|
|
250
|
+
dest = tree / f"{path}.md"
|
|
251
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
dest.write_bytes(text.encode("utf-8"))
|
|
253
|
+
sm.alias = alias
|
|
254
|
+
sm.dependencies = list(sm.dependencies) + added_deps
|
|
255
|
+
sm.version = new_version
|
|
256
|
+
(tree / SLICE_FILE).write_bytes(dump_slice_manifest(sm).encode("utf-8"))
|
|
257
|
+
if fmt != "directory":
|
|
258
|
+
write_archive(tree, requested, fmt)
|
|
259
|
+
if output.exists():
|
|
260
|
+
output.rmdir() # an empty directory (checked above)
|
|
261
|
+
os.replace(tree, output)
|
|
262
|
+
fsync_dir(output.parent)
|
|
263
|
+
finally:
|
|
264
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
265
|
+
validation = validate_slice_dir(output, ws)
|
|
266
|
+
|
|
267
|
+
# The new folder takes over the mount: same alias, precedence and role; the old folder is untouched.
|
|
268
|
+
new_mount = Mount(alias, portable_path(ws.root, output), mount.precedence if mount else 50,
|
|
269
|
+
mount.role if mount else "baseline", "slice", output)
|
|
270
|
+
mounts = [new_mount if m.alias == alias else m for m in table.mounts] if mount else [*table.mounts, new_mount]
|
|
271
|
+
actions = [file_action(ws.root / MOUNTS_FILE, dump_mounts(MountTable(overlay, mounts)))]
|
|
272
|
+
events = []
|
|
273
|
+
retired = retire_overlay_paths(ws, table, alias, sorted(included), [], cats, actions=actions, events=events)
|
|
274
|
+
active = ws.history.applied()
|
|
275
|
+
baked_cards = []
|
|
276
|
+
for path in sorted(included):
|
|
277
|
+
source = format_address(overlay.alias, path)
|
|
278
|
+
before = file_sha(overlay.root / f"{path}.md")
|
|
279
|
+
tip, ids = active.get(source, (None, []))
|
|
280
|
+
baked_cards.append({"source": source, "target": format_address(alias, path),
|
|
281
|
+
"before_sha256": before, "after_sha256": file_sha(output / f"{path}.md"),
|
|
282
|
+
"change_ids": ids if tip == before else []})
|
|
283
|
+
baked = event("baked", alias=alias, path=str(output), output=str(requested),
|
|
284
|
+
previous_path=str(mount.root) if mount else None, version=new_version, cards=baked_cards,
|
|
285
|
+
change_ids=list(dict.fromkeys(id for card in baked_cards for id in card["change_ids"])),
|
|
286
|
+
retired_overlay_cards=retired.get("retired_overlay_cards", []))
|
|
287
|
+
events.append(baked)
|
|
288
|
+
guards = [{"path": str(p), "sha256": file_sha(p)} for p in sorted(output.rglob("*")) if p.is_file()]
|
|
289
|
+
ws.history.transact(actions, events, guards=guards)
|
|
290
|
+
table = ws._reload_table()
|
|
291
|
+
ws._sync(table)
|
|
292
|
+
result.update(retired)
|
|
293
|
+
result["history_event_id"] = baked["event_id"]
|
|
294
|
+
result["validation"] = {"ok": validation["ok"], "errors": validation["errors"],
|
|
295
|
+
"warnings": len(validation["warnings"])}
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
__all__ = ["bake"]
|