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,262 @@
|
|
|
1
|
+
"""Portable directory snapshots with content verification and isolated restore."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
|
|
12
|
+
from ..errors import RecoveryError
|
|
13
|
+
from .config import Mount, MountTable, dump_mounts, load_mounts
|
|
14
|
+
from .fsutil import atomic_write_text, fsync_dir
|
|
15
|
+
from .history import ChangeHistory, event
|
|
16
|
+
|
|
17
|
+
MANIFEST = 'backup.json'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def digest(path):
|
|
21
|
+
with Path(path).open('rb') as stream:
|
|
22
|
+
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def inventory(root, exclude=()):
|
|
26
|
+
"""Do not follow symlinks, including directory links and special files."""
|
|
27
|
+
root = Path(root)
|
|
28
|
+
if root.is_symlink() or not root.is_dir():
|
|
29
|
+
raise RecoveryError('snapshot source must be a real directory', path=str(root))
|
|
30
|
+
files, directories = {}, []
|
|
31
|
+
def unreadable(exc):
|
|
32
|
+
raise RecoveryError('cannot read a snapshot directory', path=str(exc.filename)) from exc
|
|
33
|
+
for directory, names, filenames in os.walk(root, followlinks=False, onerror=unreadable):
|
|
34
|
+
current = Path(directory)
|
|
35
|
+
for name in sorted(names + filenames):
|
|
36
|
+
path = current / name
|
|
37
|
+
rel = path.relative_to(root).as_posix()
|
|
38
|
+
if any(rel == e or rel.startswith(e + '/') for e in exclude):
|
|
39
|
+
if name in names:
|
|
40
|
+
names.remove(name)
|
|
41
|
+
continue
|
|
42
|
+
mode = path.lstat().st_mode
|
|
43
|
+
if stat.S_ISLNK(mode) or not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
|
|
44
|
+
raise RecoveryError('snapshot contains a symlink or special file; use ordinary files/directories', path=str(path))
|
|
45
|
+
if stat.S_ISDIR(mode):
|
|
46
|
+
directories.append(rel)
|
|
47
|
+
else:
|
|
48
|
+
files[rel] = {'sha256': digest(path), 'size': path.stat().st_size}
|
|
49
|
+
return {'files': dict(sorted(files.items())), 'directories': sorted(directories)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _copy(source, target, listing):
|
|
53
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
for name in listing['directories']:
|
|
55
|
+
(target / name).mkdir(parents=True, exist_ok=True)
|
|
56
|
+
for name, info in listing['files'].items():
|
|
57
|
+
src, dst = source / name, target / name
|
|
58
|
+
if src.is_symlink() or not stat.S_ISREG(src.stat().st_mode):
|
|
59
|
+
raise RecoveryError('source changed during snapshot', path=str(src))
|
|
60
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
with src.open('rb') as reader, dst.open('xb') as stream:
|
|
62
|
+
shutil.copyfileobj(reader, stream)
|
|
63
|
+
stream.flush()
|
|
64
|
+
shutil.copystat(src, dst)
|
|
65
|
+
os.fsync(stream.fileno())
|
|
66
|
+
if digest(dst) != info['sha256'] or dst.stat().st_size != info['size']:
|
|
67
|
+
raise RecoveryError('source changed during snapshot', path=str(src))
|
|
68
|
+
for name in reversed(listing['directories']):
|
|
69
|
+
fsync_dir(target / name)
|
|
70
|
+
fsync_dir(target)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _destination(path, sources):
|
|
74
|
+
target = Path(path).expanduser().absolute()
|
|
75
|
+
if target.exists() or target.is_symlink():
|
|
76
|
+
raise RecoveryError('destination already exists; choose a new directory', path=str(target))
|
|
77
|
+
for root in sources:
|
|
78
|
+
if target.resolve().is_relative_to(root.resolve()):
|
|
79
|
+
raise RecoveryError('destination must be outside the source directories', path=str(target), source=str(root))
|
|
80
|
+
return target
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _publish(temporary, target):
|
|
84
|
+
if target.exists() or target.is_symlink():
|
|
85
|
+
raise RecoveryError('destination appeared during operation; preserved', path=str(target))
|
|
86
|
+
os.rename(temporary, target)
|
|
87
|
+
fsync_dir(target.parent)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _safe_relative(value):
|
|
91
|
+
if not isinstance(value, str) or not value or '\\' in value or ':' in value or '\x00' in value:
|
|
92
|
+
raise RecoveryError('invalid path in backup manifest')
|
|
93
|
+
path = PurePosixPath(value)
|
|
94
|
+
if path.is_absolute() or any(p in ('', '.', '..') for p in value.split('/')):
|
|
95
|
+
raise RecoveryError('unsafe path in backup manifest', path=value)
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def backup(ws, output):
|
|
100
|
+
from ..operations.audit import history_path
|
|
101
|
+
|
|
102
|
+
with ws._writing() as table:
|
|
103
|
+
# Recovery has completed before recording any bytes. A partial orphan log tail is not a backupable event.
|
|
104
|
+
_, complete = ws.history._read()
|
|
105
|
+
if ws.history.path.exists() and ws.history.path.stat().st_size != complete:
|
|
106
|
+
raise RecoveryError('history has an incomplete final record; inspect it before backing up')
|
|
107
|
+
roots = [ws.root]
|
|
108
|
+
requested = [m.root for m in table.all()]
|
|
109
|
+
unavailable = []
|
|
110
|
+
records = ws.history.events()
|
|
111
|
+
for e in records:
|
|
112
|
+
if e['event'] in ('baked', 'rolled_back'):
|
|
113
|
+
for field in ('path', 'previous_path'):
|
|
114
|
+
if e.get(field):
|
|
115
|
+
path = history_path(ws, e[field], records)
|
|
116
|
+
if path.is_dir():
|
|
117
|
+
requested.append(path)
|
|
118
|
+
else:
|
|
119
|
+
unavailable.append(str(path))
|
|
120
|
+
for root in requested:
|
|
121
|
+
if not root.is_dir():
|
|
122
|
+
raise RecoveryError('a mounted source is missing; restore its path or unmount it first', path=str(root))
|
|
123
|
+
if root != ws.root and ws.root.is_relative_to(root):
|
|
124
|
+
raise RecoveryError('a mounted source contains the workspace; mount a narrower directory before backup', path=str(root))
|
|
125
|
+
external = sorted(set(p for p in requested if not p.is_relative_to(ws.root)), key=lambda p: (len(p.parts), str(p)))
|
|
126
|
+
for root in external:
|
|
127
|
+
if not any(root.is_relative_to(r) for r in roots):
|
|
128
|
+
roots.append(root)
|
|
129
|
+
target = _destination(output, roots)
|
|
130
|
+
locations = {root: ('workspace' if root == ws.root else f'external/{i}') for i, root in enumerate(roots)}
|
|
131
|
+
def location(path):
|
|
132
|
+
root = next(r for r in roots if path.is_relative_to(r))
|
|
133
|
+
return (PurePosixPath(locations[root]) / path.relative_to(root).as_posix()).as_posix()
|
|
134
|
+
snapshots = {root: inventory(root, ('.cache',) if root == ws.root else ()) for root in roots}
|
|
135
|
+
if any(p in snapshots[ws.root]['files'] for p in ('staging/.apply_journal.json', '.history/.pending.json')):
|
|
136
|
+
raise RecoveryError('unfinished transaction; recover it before backing up')
|
|
137
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
temporary = Path(tempfile.mkdtemp(prefix='.mindslice-backup-', dir=target.parent))
|
|
139
|
+
try:
|
|
140
|
+
for root in roots:
|
|
141
|
+
_copy(root, temporary / locations[root], snapshots[root])
|
|
142
|
+
for root in roots:
|
|
143
|
+
if snapshots[root] != inventory(root, ('.cache',) if root == ws.root else ()):
|
|
144
|
+
raise RecoveryError('source changed during backup; retry after edits finish', path=str(root))
|
|
145
|
+
listing = inventory(temporary)
|
|
146
|
+
for name in list(listing['files']) + listing['directories']:
|
|
147
|
+
_safe_relative(name)
|
|
148
|
+
metadata = {'format': 'mindslice-workspace-backup', 'version': 1,
|
|
149
|
+
'workspace': str(ws.root), 'roots': {str(p): locations[p] for p in roots},
|
|
150
|
+
'mounts': [dict(m.to_toml(), kind=m.kind, stored=location(m.root)) for m in table.all()],
|
|
151
|
+
'unavailable_versions': sorted(set(unavailable)), **listing}
|
|
152
|
+
atomic_write_text(temporary / MANIFEST, json.dumps(metadata, indent=2, ensure_ascii=False) + '\n')
|
|
153
|
+
_publish(temporary, target)
|
|
154
|
+
finally:
|
|
155
|
+
if temporary.exists():
|
|
156
|
+
shutil.rmtree(temporary)
|
|
157
|
+
return {'path': str(target), 'files': len(listing['files']), 'external_sources': len(roots) - 1,
|
|
158
|
+
'unavailable_versions': metadata['unavailable_versions'],
|
|
159
|
+
'note': 'Includes workspace files, staging, history, mounted external sources and available recorded versions; cache excluded.'}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _load_backup(source):
|
|
163
|
+
if source.is_symlink() or (source / MANIFEST).is_symlink():
|
|
164
|
+
raise RecoveryError('backup and its manifest must not be symlinks')
|
|
165
|
+
try:
|
|
166
|
+
metadata = json.loads((source / MANIFEST).read_text(encoding='utf-8'))
|
|
167
|
+
except (OSError, ValueError) as exc:
|
|
168
|
+
raise RecoveryError('cannot read backup manifest', path=str(source)) from exc
|
|
169
|
+
if not isinstance(metadata, dict) or metadata.get('format') != 'mindslice-workspace-backup' or metadata.get('version') != 1:
|
|
170
|
+
raise RecoveryError('unsupported backup format')
|
|
171
|
+
files, directories = metadata.get('files'), metadata.get('directories')
|
|
172
|
+
if not isinstance(files, dict) or not isinstance(directories, list):
|
|
173
|
+
raise RecoveryError('invalid backup inventory')
|
|
174
|
+
for name in list(files) + directories:
|
|
175
|
+
_safe_relative(name)
|
|
176
|
+
for info in files.values():
|
|
177
|
+
if not isinstance(info, dict) or not isinstance(info.get('sha256'), str) or not isinstance(info.get('size'), int):
|
|
178
|
+
raise RecoveryError('invalid file checksum in backup')
|
|
179
|
+
actual = inventory(source, (MANIFEST,))
|
|
180
|
+
if actual != {'files': files, 'directories': directories}:
|
|
181
|
+
raise RecoveryError('backup contents do not match the manifest; no workspace restored')
|
|
182
|
+
if 'workspace/mounts.toml' not in files:
|
|
183
|
+
raise RecoveryError('backup has no workspace mount table')
|
|
184
|
+
if any(p in files for p in ('workspace/staging/.apply_journal.json', 'workspace/.history/.pending.json')):
|
|
185
|
+
raise RecoveryError('backup contains an unfinished transaction; refusing to replay old absolute paths')
|
|
186
|
+
roots = metadata.get('roots')
|
|
187
|
+
if not isinstance(roots, dict) or not isinstance(metadata.get('mounts'), list):
|
|
188
|
+
raise RecoveryError('invalid backup source mapping')
|
|
189
|
+
stored_roots = list(roots.values())
|
|
190
|
+
if stored_roots.count('workspace') != 1 or len(set(stored_roots)) != len(stored_roots):
|
|
191
|
+
raise RecoveryError('backup must contain one workspace and unique external roots')
|
|
192
|
+
for original, stored in roots.items():
|
|
193
|
+
if not isinstance(original, str):
|
|
194
|
+
raise RecoveryError('invalid original source path')
|
|
195
|
+
_safe_relative(stored)
|
|
196
|
+
if stored != 'workspace' and (len(PurePosixPath(stored).parts) != 2 or not stored.startswith('external/')):
|
|
197
|
+
raise RecoveryError('invalid stored source path')
|
|
198
|
+
if stored not in directories:
|
|
199
|
+
raise RecoveryError('missing stored source directory', path=stored)
|
|
200
|
+
for mount in metadata['mounts']:
|
|
201
|
+
if not isinstance(mount, dict):
|
|
202
|
+
raise RecoveryError('invalid mount record')
|
|
203
|
+
stored = _safe_relative(mount.get('stored'))
|
|
204
|
+
if not any(stored == r or stored.startswith(r + '/') for r in stored_roots) or stored not in directories:
|
|
205
|
+
raise RecoveryError('mount points outside stored sources', path=stored)
|
|
206
|
+
return metadata
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def restore(source, destination):
|
|
210
|
+
source = Path(source).expanduser().absolute()
|
|
211
|
+
metadata = _load_backup(source)
|
|
212
|
+
target = _destination(destination, [source])
|
|
213
|
+
# Reserve a namespace for external sources without replacing any backed-up files.
|
|
214
|
+
external_dir = '.restored-mounts'
|
|
215
|
+
n = 1
|
|
216
|
+
while f'workspace/{external_dir}' in metadata['directories'] or f'workspace/{external_dir}' in metadata['files']:
|
|
217
|
+
n += 1
|
|
218
|
+
external_dir = f'.restored-mounts-{n}'
|
|
219
|
+
def relocated(stored):
|
|
220
|
+
if stored == 'workspace':
|
|
221
|
+
return '.'
|
|
222
|
+
if stored.startswith('workspace/'):
|
|
223
|
+
return stored[len('workspace/'):]
|
|
224
|
+
return external_dir + '/' + stored[len('external/'):]
|
|
225
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
temporary = Path(tempfile.mkdtemp(prefix='.mindslice-restore-', dir=target.parent))
|
|
227
|
+
try:
|
|
228
|
+
for stored in metadata['roots'].values():
|
|
229
|
+
prefix = stored + '/'
|
|
230
|
+
subset = {'files': {p[len(prefix):]: v for p, v in metadata['files'].items() if p.startswith(prefix)},
|
|
231
|
+
'directories': [p[len(prefix):] for p in metadata['directories'] if p.startswith(prefix)]}
|
|
232
|
+
_copy(source / stored, temporary / relocated(stored), subset)
|
|
233
|
+
mounts = []
|
|
234
|
+
for entry in metadata['mounts']:
|
|
235
|
+
rel = relocated(entry['stored'])
|
|
236
|
+
mounts.append(Mount(entry['alias'], './' + rel, entry['precedence'], entry['role'], entry['kind'], temporary / rel))
|
|
237
|
+
overlays = [m for m in mounts if m.kind == 'overlay']
|
|
238
|
+
if len(overlays) != 1 or any(m.kind not in ('overlay', 'slice') for m in mounts):
|
|
239
|
+
raise RecoveryError('backup needs exactly one overlay')
|
|
240
|
+
atomic_write_text(temporary / 'mounts.toml', dump_mounts(MountTable(overlays[0], [m for m in mounts if m.kind == 'slice'])))
|
|
241
|
+
# Parse only the rewritten local paths, never access original paths from the manifest.
|
|
242
|
+
load_mounts(temporary)
|
|
243
|
+
history = ChangeHistory(temporary / '.history')
|
|
244
|
+
_, complete = history._read()
|
|
245
|
+
if history.path.exists() and complete != history.path.stat().st_size:
|
|
246
|
+
raise RecoveryError('backup history has an incomplete record')
|
|
247
|
+
path_map = {original: relocated(stored) for original, stored in metadata['roots'].items()}
|
|
248
|
+
history.append([event('restored', backup=str(source), path=str(target), path_map=path_map)])
|
|
249
|
+
# A reviewed bake plan is bound to the original workspace and must be reviewed again.
|
|
250
|
+
(temporary / '.bake-plan.json').unlink(missing_ok=True)
|
|
251
|
+
if _load_backup(source) != metadata:
|
|
252
|
+
raise RecoveryError('backup changed during restore; retry with an unchanged backup')
|
|
253
|
+
_publish(temporary, target)
|
|
254
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
255
|
+
raise RecoveryError('invalid backup metadata; no workspace restored') from exc
|
|
256
|
+
finally:
|
|
257
|
+
if temporary.exists():
|
|
258
|
+
shutil.rmtree(temporary)
|
|
259
|
+
return {'workspace': str(target), 'backup': str(source), 'mounts': [dict(m.to_toml(), path=str(target / m.path)) for m in mounts],
|
|
260
|
+
'unavailable_versions': metadata.get('unavailable_versions', []),
|
|
261
|
+
'note': 'Restored into a new directory. Overlay and staging preserved; cache rebuilds on first use. '
|
|
262
|
+
'Review bake choices again and point the client at this workspace.'}
|
mindslice/workspace.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""The MindSlice workspace: mount table + overlay + staging + disposable index.
|
|
2
|
+
|
|
3
|
+
All nine primitives are methods on :class:`Workspace`:
|
|
4
|
+
|
|
5
|
+
* Group A (online, also exposed over MCP): ``manifest``, ``search``, ``read``, ``write``
|
|
6
|
+
(plus ``boot``: the always-loaded cards, which ``manifest`` also returns)
|
|
7
|
+
* Group B (slice management): ``mount`` / ``unmount``, ``validate``, ``bake`` (plus ``status``)
|
|
8
|
+
* Group C (transactional sandbox): ``diff``, ``apply`` / ``discard``
|
|
9
|
+
|
|
10
|
+
Every operation runs under ``.cache/workspace.lock`` (``LOCK_SH`` for reads,
|
|
11
|
+
``LOCK_EX`` for writes). Before serving any request the workspace finishes an
|
|
12
|
+
interrupted ``apply()`` (if ``staging/.apply_journal.json`` exists) and
|
|
13
|
+
re-indexes files whose ``(mtime_ns, size)`` changed, upgrading to ``LOCK_EX``
|
|
14
|
+
only when there is maintenance to do.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Iterator, Sequence
|
|
23
|
+
|
|
24
|
+
from .baking import bake as bake_mod, slice_transfer
|
|
25
|
+
from .operations import boot as boot_mod, staging as staging_mod, status as status_mod, validation, writer
|
|
26
|
+
from .retrieval import query
|
|
27
|
+
from .model.addressing import alias_from_name, normalize_alias
|
|
28
|
+
from .storage.config import (
|
|
29
|
+
MOUNTS_FILE,
|
|
30
|
+
Mount,
|
|
31
|
+
MountTable,
|
|
32
|
+
load_mounts,
|
|
33
|
+
load_slice_manifest,
|
|
34
|
+
portable_path,
|
|
35
|
+
save_mounts,
|
|
36
|
+
)
|
|
37
|
+
from .errors import MountError, NotFoundError, WorkspaceNotFoundError
|
|
38
|
+
from .storage.history import ChangeHistory
|
|
39
|
+
from .retrieval.index import FRONTMATTER_CODES, Index, SyncStats
|
|
40
|
+
from .model.lineage import FollowSpec
|
|
41
|
+
from .storage.locking import WorkspaceLock
|
|
42
|
+
from .operations.staging import StagingArea
|
|
43
|
+
|
|
44
|
+
CACHE_DIR = ".cache"
|
|
45
|
+
INDEX_FILE = "index.sqlite"
|
|
46
|
+
LOCK_FILE = "workspace.lock"
|
|
47
|
+
STAGING_DIR = "staging"
|
|
48
|
+
SLICES_DIR = "slices"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Workspace:
|
|
52
|
+
def __init__(self, root: str | os.PathLike[str], *, create: bool = False, lock_timeout: float = 30.0) -> None:
|
|
53
|
+
self.root = Path(os.path.abspath(Path(root).expanduser()))
|
|
54
|
+
if not (self.root / MOUNTS_FILE).is_file():
|
|
55
|
+
if not create:
|
|
56
|
+
raise WorkspaceNotFoundError(
|
|
57
|
+
f"{self.root} is not a MindSlice workspace (no {MOUNTS_FILE}); run `mindslice init`", next_steps=[
|
|
58
|
+
"If the path is wrong, pass the right workspace with `-w PATH`; a hook or MCP configuration "
|
|
59
|
+
"needs the absolute path.",
|
|
60
|
+
f"If there is no workspace yet, create one: `mindslice init {self.root}`."])
|
|
61
|
+
self._bootstrap()
|
|
62
|
+
self.cache_dir = self.root / CACHE_DIR
|
|
63
|
+
self.staging = StagingArea(self.root / STAGING_DIR)
|
|
64
|
+
self.history = ChangeHistory(self.root / ".history")
|
|
65
|
+
self.lock = WorkspaceLock(self.cache_dir / LOCK_FILE, timeout=lock_timeout)
|
|
66
|
+
self._index: Index | None = None
|
|
67
|
+
self._table: MountTable | None = None
|
|
68
|
+
self.last_sync: SyncStats | None = None
|
|
69
|
+
self.last_recovery: dict[str, Any] | None = None
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# construction helpers
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
@classmethod
|
|
75
|
+
def init(cls, root: str | os.PathLike[str], **kwargs: Any) -> "Workspace":
|
|
76
|
+
"""Create (or open) a workspace with an empty overlay and default mount table."""
|
|
77
|
+
return cls(root, create=True, **kwargs)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def discover(cls, start: str | os.PathLike[str] | None = None, **kwargs: Any) -> "Workspace":
|
|
81
|
+
"""Find the nearest enclosing workspace (directory containing ``mounts.toml``)."""
|
|
82
|
+
current = Path(os.path.abspath(Path(start or os.getcwd()).expanduser()))
|
|
83
|
+
for candidate in (current, *current.parents):
|
|
84
|
+
if (candidate / MOUNTS_FILE).is_file():
|
|
85
|
+
return cls(candidate, **kwargs)
|
|
86
|
+
raise WorkspaceNotFoundError(f"no {MOUNTS_FILE} found in {current} or any parent directory", next_steps=[
|
|
87
|
+
"Name the workspace: `mindslice -w PATH <command>`, or set `MINDSLICE_WORKSPACE=PATH`.",
|
|
88
|
+
"If there is no workspace yet, create one: `mindslice init PATH`."])
|
|
89
|
+
|
|
90
|
+
def _bootstrap(self) -> None:
|
|
91
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
for sub in ("overlay", STAGING_DIR, SLICES_DIR, CACHE_DIR):
|
|
93
|
+
(self.root / sub).mkdir(exist_ok=True)
|
|
94
|
+
gitignore = self.root / ".gitignore"
|
|
95
|
+
if not gitignore.exists():
|
|
96
|
+
gitignore.write_text(f"{CACHE_DIR}/\n", encoding="utf-8")
|
|
97
|
+
table = load_mounts(self.root) # defaults: ./overlay @overlay precedence=100 role=runtime
|
|
98
|
+
save_mounts(self.root, table)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# lifecycle
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
@property
|
|
104
|
+
def index(self) -> Index:
|
|
105
|
+
if self._index is None:
|
|
106
|
+
self._index = Index(self.cache_dir / INDEX_FILE)
|
|
107
|
+
return self._index
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
if self._index is not None:
|
|
111
|
+
self._index.close()
|
|
112
|
+
self._index = None
|
|
113
|
+
self.lock.close()
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> "Workspace":
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *exc: object) -> None:
|
|
119
|
+
self.close()
|
|
120
|
+
|
|
121
|
+
def __repr__(self) -> str:
|
|
122
|
+
return f"Workspace({str(self.root)!r})"
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# mount table / maintenance
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
def table(self) -> MountTable:
|
|
128
|
+
if self._table is None:
|
|
129
|
+
self._table = load_mounts(self.root)
|
|
130
|
+
return self._table
|
|
131
|
+
|
|
132
|
+
def _reload_table(self) -> MountTable:
|
|
133
|
+
self._table = load_mounts(self.root)
|
|
134
|
+
return self._table
|
|
135
|
+
|
|
136
|
+
def _exclude(self) -> list[Path]:
|
|
137
|
+
return [self.staging.root, self.cache_dir, self.history.root]
|
|
138
|
+
|
|
139
|
+
def _sync(self, table: MountTable | None = None, verify: bool = False) -> SyncStats:
|
|
140
|
+
stats = self.index.sync(table or self.table(), self._exclude(), verify=verify)
|
|
141
|
+
self.last_sync = stats
|
|
142
|
+
return stats
|
|
143
|
+
|
|
144
|
+
def _recover(self, table: MountTable) -> None:
|
|
145
|
+
if self.history.recover():
|
|
146
|
+
self.last_recovery = {"recovered": True, "history": True}
|
|
147
|
+
table = self._reload_table()
|
|
148
|
+
journal = self.staging.read_journal()
|
|
149
|
+
if journal is None:
|
|
150
|
+
return
|
|
151
|
+
if journal.get("corrupt"):
|
|
152
|
+
moved_to = self.staging.quarantine_journal()
|
|
153
|
+
self.last_recovery = {"recovered": False, "corrupt_journal": str(moved_to)}
|
|
154
|
+
return
|
|
155
|
+
report = staging_mod.replay(self, table, journal)
|
|
156
|
+
report["recovered"] = True
|
|
157
|
+
self.last_recovery = report
|
|
158
|
+
|
|
159
|
+
def _needs_maintenance(self, table: MountTable) -> bool:
|
|
160
|
+
return self.history.has_journal() or self.staging.has_journal() or self.index.is_stale(table, self._exclude())
|
|
161
|
+
|
|
162
|
+
@contextmanager
|
|
163
|
+
def _reading(self) -> Iterator[MountTable]:
|
|
164
|
+
"""Shared lock; upgrades to exclusive only if a journal or stale files need handling."""
|
|
165
|
+
with self.lock.shared():
|
|
166
|
+
table = self._reload_table()
|
|
167
|
+
if not self._needs_maintenance(table):
|
|
168
|
+
yield table
|
|
169
|
+
return
|
|
170
|
+
with self.lock.exclusive():
|
|
171
|
+
table = self._reload_table()
|
|
172
|
+
self._recover(table)
|
|
173
|
+
table = self._reload_table()
|
|
174
|
+
self._sync(table)
|
|
175
|
+
yield table
|
|
176
|
+
|
|
177
|
+
@contextmanager
|
|
178
|
+
def _writing(self, sync: bool = True) -> Iterator[MountTable]:
|
|
179
|
+
with self.lock.exclusive():
|
|
180
|
+
table = self._reload_table()
|
|
181
|
+
self._recover(table)
|
|
182
|
+
table = self._reload_table()
|
|
183
|
+
if sync:
|
|
184
|
+
self._sync(table)
|
|
185
|
+
yield table
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Group A: online read/write
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
def manifest(self, limit: int = 20, offset: int = 0, max_folders_per_mount: int = 10,
|
|
191
|
+
include_overview: bool = False, include_always: bool = True) -> dict[str, Any]:
|
|
192
|
+
return query.manifest(self, limit, offset, max_folders_per_mount, include_overview, include_always)
|
|
193
|
+
|
|
194
|
+
def boot(self) -> dict[str, Any]:
|
|
195
|
+
"""The always-loaded cards (``load = "always"``), resolved and composed, for a session's start."""
|
|
196
|
+
return boot_mod.boot(self)
|
|
197
|
+
|
|
198
|
+
def search(self, query_text: str, source: str | Sequence[str] | None = None,
|
|
199
|
+
tags: str | Sequence[str] | None = None, k: int = 10, offset: int = 0,
|
|
200
|
+
max_neighbors: int = 3, follow_supersedes: FollowSpec = True) -> dict[str, Any]:
|
|
201
|
+
return query.search(self, query_text, source, tags, k, offset, max_neighbors, follow_supersedes)
|
|
202
|
+
|
|
203
|
+
def read(self, address: str, follow_supersedes: FollowSpec = True, max_backlinks: int = 10,
|
|
204
|
+
backlinks_offset: int = 0, max_extensions: int = 5, extensions_offset: int = 0) -> dict[str, Any]:
|
|
205
|
+
return query.read(self, address, follow_supersedes, max_backlinks, backlinks_offset,
|
|
206
|
+
max_extensions, extensions_offset)
|
|
207
|
+
|
|
208
|
+
def write(self, id: str, op: str = "create", content: Any = None, summary: str | None = None,
|
|
209
|
+
title: str | None = None, tags: Sequence[str] | None = None, aliases: Sequence[str] | None = None,
|
|
210
|
+
extends: Sequence[str] | None = None, supersedes: Sequence[str] | None = None,
|
|
211
|
+
stage: bool | None = None, if_match: str | None = None, load: str | None = None, reason: str | None = None) -> dict[str, Any]:
|
|
212
|
+
"""``stage=None`` stages changes to the always-loaded context for approval (see ``boot``)."""
|
|
213
|
+
return writer.write(self, id, op, content, summary, title, tags, aliases, extends, supersedes, stage,
|
|
214
|
+
if_match, load, reason)
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
# Group B: slice management, linting & packaging
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
def status(self) -> dict[str, Any]:
|
|
220
|
+
"""Mounts, pending review and unbaked overlay changes, without card bodies."""
|
|
221
|
+
return status_mod.status(self)
|
|
222
|
+
|
|
223
|
+
def card_history(self, address: str | None = None, limit: int = 20, offset: int = 0) -> dict[str, Any]:
|
|
224
|
+
from .operations.audit import card_history
|
|
225
|
+
return card_history(self, address, limit, offset)
|
|
226
|
+
|
|
227
|
+
def explain(self, address: str, follow_supersedes=True, max_extensions: int = 5,
|
|
228
|
+
extensions_offset: int = 0) -> dict[str, Any]:
|
|
229
|
+
from .operations.audit import explain
|
|
230
|
+
return explain(self, address, follow_supersedes, max_extensions, extensions_offset)
|
|
231
|
+
|
|
232
|
+
def rollback(self, alias: str, path=None, *, dry_run: bool = False, revision: str | None = None) -> dict[str, Any]:
|
|
233
|
+
from .operations.recovery import rollback
|
|
234
|
+
return rollback(self, alias, path, dry_run=dry_run, revision=revision)
|
|
235
|
+
|
|
236
|
+
def backup(self, output) -> dict[str, Any]:
|
|
237
|
+
from .storage.snapshots import backup
|
|
238
|
+
return backup(self, output)
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def restore(cls, source, destination) -> dict[str, Any]:
|
|
242
|
+
from .storage.snapshots import restore
|
|
243
|
+
return restore(source, destination)
|
|
244
|
+
|
|
245
|
+
def mount(self, path: str | os.PathLike[str], alias: str | None = None, precedence: int = 50,
|
|
246
|
+
role: str = "baseline", retire_overlay: bool = True) -> dict[str, Any]:
|
|
247
|
+
"""Mount a read-only slice directory.
|
|
248
|
+
|
|
249
|
+
Relative paths are resolved against the current directory, falling back to the
|
|
250
|
+
workspace root. ``mounts.toml`` stores ``./relative`` paths for slices inside the workspace.
|
|
251
|
+
|
|
252
|
+
Any directory of Markdown works as is: notes without frontmatter, or with legacy ``---`` YAML,
|
|
253
|
+
are indexed without conversion, and without ``alias`` or a ``slice.toml`` alias the directory
|
|
254
|
+
name becomes one (``My Notes`` -> ``@My-Notes``).
|
|
255
|
+
|
|
256
|
+
``retire_overlay`` (default): overlay cards this slice holds unchanged after reference
|
|
257
|
+
rewriting are removed so they don't shadow it. References from the overlay cards that stay
|
|
258
|
+
are rewritten to the slice (``slice_transfer.retire_copies``).
|
|
259
|
+
"""
|
|
260
|
+
target = Path(path).expanduser()
|
|
261
|
+
if not target.is_absolute():
|
|
262
|
+
target = target.absolute() if target.is_dir() else self.root / target
|
|
263
|
+
target = Path(os.path.abspath(target))
|
|
264
|
+
if not target.is_dir():
|
|
265
|
+
raise MountError(f"{target} is not a directory", path=str(target))
|
|
266
|
+
if isinstance(precedence, bool) or not isinstance(precedence, int):
|
|
267
|
+
raise MountError("precedence must be an integer")
|
|
268
|
+
if not isinstance(role, str) or not role.strip():
|
|
269
|
+
raise MountError("role must be a non-empty string")
|
|
270
|
+
sm = load_slice_manifest(target)
|
|
271
|
+
if alias or sm.alias:
|
|
272
|
+
alias = normalize_alias(alias or sm.alias)
|
|
273
|
+
else: # a plain folder: its name, made a valid alias
|
|
274
|
+
alias = alias_from_name(target.name.removesuffix(".slice"))
|
|
275
|
+
with self._writing() as table:
|
|
276
|
+
if alias == table.overlay.alias or table.get(alias) is not None:
|
|
277
|
+
raise MountError(f"alias {alias} is already mounted", alias=alias, next_steps=[
|
|
278
|
+
f"Mount under another name with `--alias NAME`, or run `mindslice unmount {alias}` first to "
|
|
279
|
+
"replace that mount; `mindslice status` lists the mounts."])
|
|
280
|
+
for m in table.all():
|
|
281
|
+
if m.root == target:
|
|
282
|
+
raise MountError(f"{target} is already mounted as {m.alias}", alias=m.alias, next_steps=[
|
|
283
|
+
f"Nothing to do: its cards are available as {m.alias}. To change its alias or precedence, "
|
|
284
|
+
f"run `mindslice unmount {m.alias}` and mount it again."])
|
|
285
|
+
if target in (self.staging.root, self.cache_dir, self.history.root) or any(
|
|
286
|
+
root in target.parents for root in (self.cache_dir, self.history.root)):
|
|
287
|
+
raise MountError("cannot mount MindSlice internal directories")
|
|
288
|
+
new = Mount(alias, portable_path(self.root, target), precedence, role.strip(), "slice", target)
|
|
289
|
+
new_table = MountTable(table.overlay, [*table.mounts, new])
|
|
290
|
+
save_mounts(self.root, new_table)
|
|
291
|
+
table = self._reload_table()
|
|
292
|
+
stats = self._sync(table)
|
|
293
|
+
retired = slice_transfer.retire_copies(self, table, alias) if retire_overlay else {}
|
|
294
|
+
if retired:
|
|
295
|
+
self._sync(table)
|
|
296
|
+
mounted = {m.alias for m in new_table.all()}
|
|
297
|
+
missing = []
|
|
298
|
+
for dep in sm.dependencies:
|
|
299
|
+
try:
|
|
300
|
+
if normalize_alias(dep) not in mounted:
|
|
301
|
+
missing.append(dep)
|
|
302
|
+
except ValueError:
|
|
303
|
+
missing.append(dep)
|
|
304
|
+
rows = [r for r in self.index.all_cards(alias) if not r["is_overview"]]
|
|
305
|
+
result: dict[str, Any] = {
|
|
306
|
+
"alias": alias, "path": new.path, "precedence": precedence, "role": new.role,
|
|
307
|
+
"cards": len(rows), "sync": stats.to_dict(),
|
|
308
|
+
}
|
|
309
|
+
plain = sum(1 for r in rows if r["has_frontmatter"] == FRONTMATTER_CODES[None])
|
|
310
|
+
yaml = sum(1 for r in rows if r["has_frontmatter"] == FRONTMATTER_CODES["yaml"])
|
|
311
|
+
if plain or yaml: # indexed as is: title from the heading or file name, summary from the first paragraph
|
|
312
|
+
result["formats"] = {"toml": len(rows) - plain - yaml, "yaml": yaml, "plain": plain}
|
|
313
|
+
result.update(retired)
|
|
314
|
+
if missing:
|
|
315
|
+
result["missing_dependencies"] = missing
|
|
316
|
+
if sm.errors:
|
|
317
|
+
result["manifest_errors"] = sm.errors
|
|
318
|
+
always = [c for c in boot_mod.snapshot(self, self.table())["cards"]
|
|
319
|
+
if c["mount"] == alias or c.get("redirected_from", "").startswith(alias + "/")]
|
|
320
|
+
if always:
|
|
321
|
+
result["always_loaded"] = always
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
def unmount(self, alias: str) -> dict[str, Any]:
|
|
325
|
+
alias = normalize_alias(alias)
|
|
326
|
+
with self._writing() as table:
|
|
327
|
+
if alias == table.overlay.alias:
|
|
328
|
+
raise MountError(f"{alias} is the writable overlay and cannot be unmounted")
|
|
329
|
+
if table.get(alias) is None:
|
|
330
|
+
raise NotFoundError(f"{alias} is not mounted", alias=alias)
|
|
331
|
+
new_table = MountTable(table.overlay, [m for m in table.mounts if m.alias != alias])
|
|
332
|
+
save_mounts(self.root, new_table)
|
|
333
|
+
stats = self._sync(self._reload_table())
|
|
334
|
+
return {"alias": alias, "removed_cards": stats.removed, "sync": stats.to_dict()}
|
|
335
|
+
|
|
336
|
+
def validate(self, slice_path: str | os.PathLike[str] | None = None, rebuild_index: bool = False) -> dict[str, Any]:
|
|
337
|
+
return validation.validate(self, slice_path, rebuild_index)
|
|
338
|
+
|
|
339
|
+
def bake(self, alias: str | None = None, output_path: str | os.PathLike[str] | None = None, prefix: str | None = None,
|
|
340
|
+
tags: str | Sequence[str] | None = None, include_deps: bool = False, dry_run: bool = False,
|
|
341
|
+
*, name: str | None = None, description: str | None = None, version: str | None = None,
|
|
342
|
+
plan: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
343
|
+
"""Bake one alias, or a reviewed workspace plan when ``alias`` is omitted.
|
|
344
|
+
|
|
345
|
+
Named new slices require ``prefix``; batch plans explicitly assign individual cards.
|
|
346
|
+
``bake(dry_run=True)`` returns the batch plan; ``bake(plan=reviewed)`` verifies and executes it.
|
|
347
|
+
Previous directories remain untouched. Batch outputs use workspace ``slices/`` defaults.
|
|
348
|
+
"""
|
|
349
|
+
if alias is None:
|
|
350
|
+
from .baking import bake_batch
|
|
351
|
+
from .errors import InvalidArgumentsError
|
|
352
|
+
if any(x is not None for x in (output_path, prefix, tags, name, description, version)) or include_deps:
|
|
353
|
+
raise InvalidArgumentsError("single-slice options require an alias")
|
|
354
|
+
reviewed = self.plan_bake() if plan is None else plan
|
|
355
|
+
if dry_run:
|
|
356
|
+
return reviewed if plan is None else bake_batch.inspect_plan(self, reviewed)
|
|
357
|
+
return bake_batch.execute(self, reviewed)
|
|
358
|
+
if plan is not None:
|
|
359
|
+
from .errors import InvalidArgumentsError
|
|
360
|
+
raise InvalidArgumentsError("a workspace bake plan cannot be combined with an alias")
|
|
361
|
+
return bake_mod.bake(self, alias, output_path, prefix, tags, include_deps, dry_run,
|
|
362
|
+
name=name, description=description, version=version)
|
|
363
|
+
|
|
364
|
+
def plan_bake(self, assignments: dict[str, str | None] | None = None, *, resume: bool = True) -> dict[str, Any]:
|
|
365
|
+
"""Review all overlay cards; null destinations explicitly defer cards. No content writes."""
|
|
366
|
+
from .baking.bake_batch import plan_bake
|
|
367
|
+
return plan_bake(self, assignments, resume=resume)
|
|
368
|
+
|
|
369
|
+
# ------------------------------------------------------------------
|
|
370
|
+
# Group C: transactional sandbox
|
|
371
|
+
# ------------------------------------------------------------------
|
|
372
|
+
def diff(self, scope: str = "staging") -> dict[str, Any]:
|
|
373
|
+
return staging_mod.diff(self, scope)
|
|
374
|
+
|
|
375
|
+
def apply(self, ids: list[str] | None = None) -> dict[str, Any]:
|
|
376
|
+
"""Accept all pending changes, or only the specified staging card IDs."""
|
|
377
|
+
return staging_mod.apply(self, ids)
|
|
378
|
+
|
|
379
|
+
def discard(self, ids: list[str] | None = None) -> dict[str, Any]:
|
|
380
|
+
"""Reject all pending changes, or only the specified staging card IDs."""
|
|
381
|
+
return staging_mod.discard(self, ids)
|
|
382
|
+
|
|
383
|
+
def review(self, accept: list[str], reject: list[str], *, revision: str, dry_run: bool = False) -> dict[str, Any]:
|
|
384
|
+
"""Execute reviewed choices in one recoverable batch; other cards stay pending."""
|
|
385
|
+
return staging_mod.apply(self, accept, reject=reject, revision=revision, dry_run=dry_run)
|