whykit 0.3.1__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.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/io.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Small durability helpers for governed WhyKit writes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import errno
|
|
5
|
+
import contextvars
|
|
6
|
+
import asyncio
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
import functools
|
|
10
|
+
import shutil
|
|
11
|
+
import hashlib
|
|
12
|
+
import codecs
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import stat
|
|
18
|
+
import time
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from collections.abc import Iterator, Mapping
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TextIO
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _sync_directory(path: Path) -> None:
|
|
26
|
+
"""Persist a rename on systems that support syncing directories."""
|
|
27
|
+
if os.name == "nt":
|
|
28
|
+
return # Windows does not expose directory fsync through os.open.
|
|
29
|
+
fd = os.open(path, os.O_RDONLY)
|
|
30
|
+
try:
|
|
31
|
+
try:
|
|
32
|
+
os.fsync(fd)
|
|
33
|
+
except OSError as exc:
|
|
34
|
+
if exc.errno not in {errno.EINVAL, errno.ENOTSUP}:
|
|
35
|
+
raise
|
|
36
|
+
finally:
|
|
37
|
+
os.close(fd)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ensure_writable(path: Path) -> None:
|
|
41
|
+
"""Refuse to replace a file someone marked read-only.
|
|
42
|
+
|
|
43
|
+
``os.replace`` would silently override the mark on POSIX, while Windows
|
|
44
|
+
refuses the rename and then cannot delete the read-only temporary file
|
|
45
|
+
either. Checking first gives one answer on every platform, before any
|
|
46
|
+
byte of a multi-file change is written.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
mode = os.stat(path).st_mode
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
return
|
|
52
|
+
if stat.S_ISREG(mode) and (not mode & stat.S_IWUSR or not os.access(path, os.W_OK)):
|
|
53
|
+
raise PermissionError(errno.EACCES, "file is read-only; make it writable before WhyKit updates it", str(path))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def match_line_endings(path: Path, text: str) -> str:
|
|
57
|
+
"""Keep an existing CRLF file CRLF when WhyKit rewrites it.
|
|
58
|
+
|
|
59
|
+
Edits read text with universal newlines, so writing it back as-is would
|
|
60
|
+
turn every line of a Windows-authored file into a change. A new file, or
|
|
61
|
+
one whose first line ends in LF, is written with LF.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
with open(path, "rb") as handle:
|
|
65
|
+
head = handle.read(65536)
|
|
66
|
+
except OSError:
|
|
67
|
+
return text
|
|
68
|
+
first = head.find(b"\n")
|
|
69
|
+
if first <= 0 or head[first - 1:first] != b"\r":
|
|
70
|
+
return text
|
|
71
|
+
return text.replace("\r\n", "\n").replace("\n", "\r\n")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@contextmanager
|
|
75
|
+
def _atomic_text_writer(path: Path, *, encoding: str = "utf-8", newline: str = "") -> Iterator[TextIO]:
|
|
76
|
+
"""Flush a complete replacement before the atomic rename, including on cancellation."""
|
|
77
|
+
path = Path(path)
|
|
78
|
+
ensure_writable(path)
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
tmp = path.with_name(f".{path.name}.whykit-tmp-{secrets.token_hex(6)}")
|
|
81
|
+
previous_mode = None
|
|
82
|
+
try:
|
|
83
|
+
try:
|
|
84
|
+
previous_mode = path.stat().st_mode & 0o7777
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
pass
|
|
87
|
+
# surrogateescape: under a C/ASCII locale, command-line text (a title,
|
|
88
|
+
# an owner) arrives with surrogates for its UTF-8 bytes; write those
|
|
89
|
+
# bytes back instead of refusing the record.
|
|
90
|
+
errors = "surrogateescape" if codecs.lookup(encoding).name == "utf-8" else "strict"
|
|
91
|
+
with tmp.open("x", encoding=encoding, errors=errors, newline=newline) as handle:
|
|
92
|
+
yield handle
|
|
93
|
+
handle.flush()
|
|
94
|
+
os.fsync(handle.fileno())
|
|
95
|
+
except BaseException:
|
|
96
|
+
try:
|
|
97
|
+
tmp.unlink()
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
pass
|
|
100
|
+
raise
|
|
101
|
+
try:
|
|
102
|
+
if previous_mode is not None:
|
|
103
|
+
os.chmod(tmp, previous_mode)
|
|
104
|
+
os.replace(tmp, path)
|
|
105
|
+
_sync_directory(path.parent)
|
|
106
|
+
finally:
|
|
107
|
+
try:
|
|
108
|
+
tmp.unlink()
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
|
|
114
|
+
"""Atomically replace text, preserving permissions and existing line endings."""
|
|
115
|
+
with _atomic_text_writer(path, encoding=encoding) as handle:
|
|
116
|
+
handle.write(match_line_endings(path, text))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def atomic_write_json(path: Path, value: object) -> None:
|
|
120
|
+
"""Stream pretty JSON through the same durable replacement as text writes."""
|
|
121
|
+
newline = match_line_endings(path, "\n")
|
|
122
|
+
with _atomic_text_writer(path, newline=newline) as handle:
|
|
123
|
+
json.dump(value, handle, ensure_ascii=False, indent=2)
|
|
124
|
+
handle.write("\n")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
128
|
+
"""Binary counterpart of :func:`atomic_write_text`."""
|
|
129
|
+
path = Path(path)
|
|
130
|
+
ensure_writable(path)
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
tmp = path.with_name(f".{path.name}.whykit-tmp-{secrets.token_hex(6)}")
|
|
133
|
+
previous_mode = None
|
|
134
|
+
try:
|
|
135
|
+
try:
|
|
136
|
+
previous_mode = path.stat().st_mode & 0o7777
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
pass
|
|
139
|
+
with tmp.open("xb") as handle:
|
|
140
|
+
handle.write(data)
|
|
141
|
+
handle.flush()
|
|
142
|
+
os.fsync(handle.fileno())
|
|
143
|
+
except BaseException:
|
|
144
|
+
try:
|
|
145
|
+
tmp.unlink()
|
|
146
|
+
except FileNotFoundError:
|
|
147
|
+
pass
|
|
148
|
+
raise
|
|
149
|
+
try:
|
|
150
|
+
if previous_mode is not None:
|
|
151
|
+
os.chmod(tmp, previous_mode)
|
|
152
|
+
os.replace(tmp, path)
|
|
153
|
+
_sync_directory(path.parent)
|
|
154
|
+
finally:
|
|
155
|
+
try:
|
|
156
|
+
tmp.unlink()
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def safe_vault_dir(root: Path, relative: str | Path) -> Path:
|
|
162
|
+
"""Return a vault-relative directory, creating missing parents without following symlinks."""
|
|
163
|
+
root = Path(root).resolve(strict=True)
|
|
164
|
+
relative = Path(relative)
|
|
165
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
166
|
+
raise ValueError(f"unsafe vault path: {relative}")
|
|
167
|
+
current = root
|
|
168
|
+
for part in relative.parts:
|
|
169
|
+
current = current / part
|
|
170
|
+
if current.is_symlink():
|
|
171
|
+
raise RuntimeError(f"symlinked vault directory refused: {current}")
|
|
172
|
+
if not current.exists():
|
|
173
|
+
try:
|
|
174
|
+
current.mkdir(mode=0o755)
|
|
175
|
+
_sync_directory(current.parent)
|
|
176
|
+
except FileExistsError:
|
|
177
|
+
# Another writer may have created this directory after our
|
|
178
|
+
# existence check. Revalidate below; in particular, do not
|
|
179
|
+
# accept a symlink created in the same race.
|
|
180
|
+
pass
|
|
181
|
+
if current.is_symlink():
|
|
182
|
+
raise RuntimeError(f"symlinked vault directory refused: {current}")
|
|
183
|
+
if not current.is_dir():
|
|
184
|
+
raise RuntimeError(f"expected directory: {current}")
|
|
185
|
+
resolved = current.resolve(strict=True)
|
|
186
|
+
try:
|
|
187
|
+
resolved.relative_to(root)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
raise RuntimeError(f"path escapes vault: {relative}") from exc
|
|
190
|
+
return current
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def safe_vault_target(root: Path, relative: str | Path, *, create_parents: bool = True) -> Path:
|
|
194
|
+
"""Resolve a vault-relative write path without following parent symlinks."""
|
|
195
|
+
root = Path(root).resolve(strict=True)
|
|
196
|
+
relative = Path(relative)
|
|
197
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
198
|
+
raise ValueError(f"unsafe vault path: {relative}")
|
|
199
|
+
|
|
200
|
+
parts = relative.parts
|
|
201
|
+
if not parts:
|
|
202
|
+
raise ValueError("unsafe vault path: empty")
|
|
203
|
+
|
|
204
|
+
if len(parts) > 1:
|
|
205
|
+
if create_parents:
|
|
206
|
+
safe_vault_dir(root, Path(*parts[:-1]))
|
|
207
|
+
else:
|
|
208
|
+
parent = root.joinpath(*parts[:-1])
|
|
209
|
+
if parent.is_symlink():
|
|
210
|
+
raise RuntimeError(f"symlinked vault directory refused: {parent}")
|
|
211
|
+
parent.resolve(strict=True).relative_to(root)
|
|
212
|
+
|
|
213
|
+
target = root / relative
|
|
214
|
+
if target.is_symlink():
|
|
215
|
+
raise RuntimeError(f"refusing to mutate symlink: {target}")
|
|
216
|
+
parent = target.parent.resolve(strict=True)
|
|
217
|
+
try:
|
|
218
|
+
parent.relative_to(root)
|
|
219
|
+
except ValueError as exc:
|
|
220
|
+
raise RuntimeError(f"path escapes vault: {relative}") from exc
|
|
221
|
+
return target
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def safe_export_target(root: Path, relative: str | Path) -> Path:
|
|
225
|
+
"""Exports must not replace Git state or the inode that coordinates readers."""
|
|
226
|
+
parts = tuple(part.split(":", 1)[0].rstrip(" .").casefold() for part in Path(relative).parts)
|
|
227
|
+
if parts and (parts[0] == ".git" or parts[:2] in {
|
|
228
|
+
(".whykit", "mutation.lock"), (".whykit", "transactions"), (".whykit", "cache"),
|
|
229
|
+
}):
|
|
230
|
+
raise ValueError("export cannot replace Git or WhyKit coordination state")
|
|
231
|
+
return safe_vault_target(root, relative)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _acquire_exclusive(handle, *, non_blocking: bool) -> None:
|
|
235
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows runners
|
|
236
|
+
import msvcrt
|
|
237
|
+
|
|
238
|
+
handle.seek(0)
|
|
239
|
+
mode = msvcrt.LK_NBLCK if non_blocking else msvcrt.LK_LOCK
|
|
240
|
+
msvcrt.locking(handle.fileno(), mode, 1)
|
|
241
|
+
return
|
|
242
|
+
import fcntl
|
|
243
|
+
|
|
244
|
+
flags = fcntl.LOCK_EX
|
|
245
|
+
if non_blocking:
|
|
246
|
+
flags |= fcntl.LOCK_NB
|
|
247
|
+
fcntl.flock(handle.fileno(), flags)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _release_exclusive(handle) -> None:
|
|
251
|
+
if os.name == "nt": # pragma: no cover
|
|
252
|
+
import msvcrt
|
|
253
|
+
|
|
254
|
+
handle.seek(0)
|
|
255
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
256
|
+
return
|
|
257
|
+
import fcntl
|
|
258
|
+
|
|
259
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class _HeldLock:
|
|
264
|
+
mode: str
|
|
265
|
+
owner: tuple[int, int, int | None]
|
|
266
|
+
active: bool = True
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _lock_owner() -> tuple[int, int, int | None]:
|
|
270
|
+
try:
|
|
271
|
+
task = asyncio.current_task()
|
|
272
|
+
except RuntimeError:
|
|
273
|
+
task = None
|
|
274
|
+
return os.getpid(), threading.get_ident(), id(task) if task is not None else None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
_HELD_LOCKS: contextvars.ContextVar[dict[Path, _HeldLock] | None] = contextvars.ContextVar("whykit_vault_locks", default=None)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _owned_lock(held: dict[Path, _HeldLock], root: Path) -> _HeldLock | None:
|
|
281
|
+
lock = held.get(root)
|
|
282
|
+
return lock if lock is not None and lock.active and lock.owner == _lock_owner() else None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _acquire_shared(handle) -> None:
|
|
286
|
+
if os.name == "nt": # pragma: no cover - Windows CI
|
|
287
|
+
import msvcrt
|
|
288
|
+
handle.seek(0)
|
|
289
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBRLCK, 1)
|
|
290
|
+
else:
|
|
291
|
+
import fcntl
|
|
292
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _pending_transactions(root: Path) -> bool:
|
|
296
|
+
base = root / ".whykit" / "transactions"
|
|
297
|
+
if base.is_symlink():
|
|
298
|
+
raise OSError(errno.EBUSY, "symlinked transaction state refused")
|
|
299
|
+
if not base.exists():
|
|
300
|
+
return False
|
|
301
|
+
for tx in base.iterdir():
|
|
302
|
+
if tx.is_symlink():
|
|
303
|
+
raise OSError(errno.EBUSY, "symlinked transaction state refused")
|
|
304
|
+
if (tx / "READY").exists() and not (tx / "COMMITTED").exists():
|
|
305
|
+
return True
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@contextmanager
|
|
310
|
+
def vault_read_lock(root: Path, *, timeout: float = 15.0) -> Iterator[None]:
|
|
311
|
+
"""Hold a shared OS lock without writing to the vault; pending recovery fails closed.
|
|
312
|
+
|
|
313
|
+
A pristine vault has no lock file. Detect its creation before returning a
|
|
314
|
+
result, so its first writer cannot race the reader. External editors that
|
|
315
|
+
ignore the advisory lock are outside this transaction guarantee.
|
|
316
|
+
"""
|
|
317
|
+
root = Path(root).resolve(strict=True)
|
|
318
|
+
held = _HELD_LOCKS.get() or {}
|
|
319
|
+
if _owned_lock(held, root) is not None:
|
|
320
|
+
yield
|
|
321
|
+
return
|
|
322
|
+
state = root / ".whykit"
|
|
323
|
+
lock_path = state / "mutation.lock"
|
|
324
|
+
if state.is_symlink() or lock_path.is_symlink():
|
|
325
|
+
raise OSError(errno.EBUSY, "symlinked mutation state refused")
|
|
326
|
+
try:
|
|
327
|
+
handle = lock_path.open("rb")
|
|
328
|
+
except FileNotFoundError:
|
|
329
|
+
handle = None
|
|
330
|
+
token = None
|
|
331
|
+
acquired = False
|
|
332
|
+
ownership = _HeldLock("read", _lock_owner())
|
|
333
|
+
try:
|
|
334
|
+
if handle is not None:
|
|
335
|
+
deadline = time.monotonic() + timeout
|
|
336
|
+
while True:
|
|
337
|
+
try:
|
|
338
|
+
_acquire_shared(handle)
|
|
339
|
+
acquired = True
|
|
340
|
+
break
|
|
341
|
+
except OSError as exc:
|
|
342
|
+
if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
|
|
343
|
+
raise
|
|
344
|
+
if time.monotonic() >= deadline:
|
|
345
|
+
raise OSError(errno.EBUSY, "vault write in progress; retry the read") from None
|
|
346
|
+
time.sleep(0.01)
|
|
347
|
+
if _pending_transactions(root):
|
|
348
|
+
raise OSError(errno.EBUSY, "vault recovery required; run whykit recover before reading")
|
|
349
|
+
token = _HELD_LOCKS.set({**held, root: ownership})
|
|
350
|
+
yield
|
|
351
|
+
if handle is None and (lock_path.exists() or _pending_transactions(root)):
|
|
352
|
+
raise OSError(errno.EBUSY, "vault changed during its first governed write; retry the read")
|
|
353
|
+
finally:
|
|
354
|
+
ownership.active = False
|
|
355
|
+
if token is not None:
|
|
356
|
+
_HELD_LOCKS.reset(token)
|
|
357
|
+
if handle is not None:
|
|
358
|
+
try:
|
|
359
|
+
if acquired:
|
|
360
|
+
_release_exclusive(handle)
|
|
361
|
+
finally:
|
|
362
|
+
handle.close()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def consistent_read(handler):
|
|
366
|
+
"""Protect a complete root-first report, including its nested config/source reads."""
|
|
367
|
+
@functools.wraps(handler)
|
|
368
|
+
def guarded(root, *args, **kwargs):
|
|
369
|
+
with vault_read_lock(root):
|
|
370
|
+
return handler(root, *args, **kwargs)
|
|
371
|
+
return guarded
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@contextmanager
|
|
375
|
+
def vault_mutation_lock(root: Path, *, timeout: float = 15.0) -> Iterator[None]:
|
|
376
|
+
"""Serialize ID allocation and multi-file ledger mutations for one vault.
|
|
377
|
+
|
|
378
|
+
Uses an OS advisory exclusive lock on ``.whykit/mutation.lock``. The kernel
|
|
379
|
+
releases the lock when the process dies, so crash/SIGKILL cannot leave a
|
|
380
|
+
permanent directory lock that blocks all future mutations.
|
|
381
|
+
"""
|
|
382
|
+
root = Path(root).resolve(strict=True)
|
|
383
|
+
held = _HELD_LOCKS.get() or {}
|
|
384
|
+
current = _owned_lock(held, root)
|
|
385
|
+
if current is not None:
|
|
386
|
+
if current.mode != "write":
|
|
387
|
+
raise RuntimeError("cannot upgrade a vault read lock to a write lock")
|
|
388
|
+
yield
|
|
389
|
+
return
|
|
390
|
+
lock_path = safe_vault_target(root, ".whykit/mutation.lock")
|
|
391
|
+
handle = lock_path.open("a+b")
|
|
392
|
+
deadline = time.monotonic() + timeout
|
|
393
|
+
token = None
|
|
394
|
+
ownership = _HeldLock("write", _lock_owner())
|
|
395
|
+
try:
|
|
396
|
+
while True:
|
|
397
|
+
try:
|
|
398
|
+
_acquire_exclusive(handle, non_blocking=True)
|
|
399
|
+
break
|
|
400
|
+
except (BlockingIOError, OSError):
|
|
401
|
+
if time.monotonic() >= deadline:
|
|
402
|
+
raise TimeoutError(f"vault is already being modified: {root}") from None
|
|
403
|
+
time.sleep(0.05)
|
|
404
|
+
token = _HELD_LOCKS.set({**held, root: ownership})
|
|
405
|
+
# Crash recovery must run under the lock so two agents never finish the
|
|
406
|
+
# same READY journal concurrently.
|
|
407
|
+
recover_pending_transactions(root)
|
|
408
|
+
handle.seek(0)
|
|
409
|
+
handle.truncate()
|
|
410
|
+
handle.write(
|
|
411
|
+
json.dumps({"pid": os.getpid(), "started_at": time.time()}, ensure_ascii=False).encode("utf-8")
|
|
412
|
+
)
|
|
413
|
+
handle.flush()
|
|
414
|
+
yield
|
|
415
|
+
finally:
|
|
416
|
+
ownership.active = False
|
|
417
|
+
if token is not None:
|
|
418
|
+
_HELD_LOCKS.reset(token)
|
|
419
|
+
try:
|
|
420
|
+
_release_exclusive(handle)
|
|
421
|
+
except OSError:
|
|
422
|
+
pass
|
|
423
|
+
handle.close()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _is_transaction_state(relative: str | Path) -> bool:
|
|
427
|
+
# Reserve filesystem aliases on every host so moved journals stay safe.
|
|
428
|
+
parts = Path(relative).parts
|
|
429
|
+
return bool(parts) and parts[0].split(":", 1)[0].rstrip(" .").casefold() == ".whykit"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def stage_transaction(root: Path, updates: Mapping[Path, str]) -> Path:
|
|
433
|
+
"""Stage a multi-file write batch under ``.whykit/transactions/<id>/``."""
|
|
434
|
+
root = Path(root).resolve(strict=True)
|
|
435
|
+
if not updates:
|
|
436
|
+
raise ValueError("transaction requires at least one update")
|
|
437
|
+
for target in updates:
|
|
438
|
+
ensure_writable(Path(target))
|
|
439
|
+
txid = secrets.token_hex(12)
|
|
440
|
+
tx = safe_vault_dir(root, Path(".whykit") / "transactions" / txid)
|
|
441
|
+
manifest: list[dict[str, str]] = []
|
|
442
|
+
for index, (target, content) in enumerate(updates.items()):
|
|
443
|
+
if Path(target).is_symlink():
|
|
444
|
+
raise RuntimeError(f"refusing to mutate symlink: {target}")
|
|
445
|
+
resolved_target = Path(target).resolve()
|
|
446
|
+
try:
|
|
447
|
+
relative = resolved_target.relative_to(root).as_posix()
|
|
448
|
+
except ValueError as exc:
|
|
449
|
+
raise RuntimeError(f"transaction target escapes vault: {target}") from exc
|
|
450
|
+
if _is_transaction_state(relative):
|
|
451
|
+
raise ValueError(f"transaction cannot replace its own state: {target}")
|
|
452
|
+
staged_name = f"{index}.new"
|
|
453
|
+
staged = tx / staged_name
|
|
454
|
+
content = match_line_endings(resolved_target, content)
|
|
455
|
+
atomic_write_text(staged, content)
|
|
456
|
+
manifest.append({
|
|
457
|
+
"target": relative,
|
|
458
|
+
"staged": staged_name,
|
|
459
|
+
"sha256": hashlib.sha256(content.encode("utf-8", "surrogateescape")).hexdigest(),
|
|
460
|
+
})
|
|
461
|
+
# ASCII JSON: paths decoded under a C locale carry surrogates, which UTF-8
|
|
462
|
+
# cannot encode; json escapes round-trip them exactly.
|
|
463
|
+
atomic_write_text(tx / "manifest.json", json.dumps(manifest, sort_keys=True, ensure_ascii=True))
|
|
464
|
+
atomic_write_text(tx / "READY", "1\n")
|
|
465
|
+
return tx
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def commit_transaction(root: Path, tx: Path) -> None:
|
|
469
|
+
"""Replay a READY transaction; discard staged bytes only after COMMITTED."""
|
|
470
|
+
root = Path(root).resolve(strict=True)
|
|
471
|
+
tx = Path(tx)
|
|
472
|
+
base = safe_vault_dir(root, ".whykit/transactions")
|
|
473
|
+
if tx.parent.resolve() != base or tx.is_symlink():
|
|
474
|
+
raise RuntimeError(f"unsafe transaction directory: {tx}")
|
|
475
|
+
ready = tx / "READY"
|
|
476
|
+
committed = tx / "COMMITTED"
|
|
477
|
+
if not ready.exists():
|
|
478
|
+
raise RuntimeError(f"transaction is not READY: {tx}")
|
|
479
|
+
if any(path.is_symlink() for path in (ready, committed, tx / "manifest.json")):
|
|
480
|
+
raise RuntimeError(f"symlinked transaction metadata refused: {tx}")
|
|
481
|
+
manifest = json.loads((tx / "manifest.json").read_text(encoding="utf-8"))
|
|
482
|
+
if not isinstance(manifest, list) or not manifest:
|
|
483
|
+
raise RuntimeError(f"malformed transaction manifest: {tx}")
|
|
484
|
+
entries: list[tuple[Path, Path, str]] = []
|
|
485
|
+
sources: set[str] = set()
|
|
486
|
+
targets: set[Path] = set()
|
|
487
|
+
for item in manifest:
|
|
488
|
+
if (
|
|
489
|
+
not isinstance(item, dict)
|
|
490
|
+
or not isinstance(item.get("target"), str)
|
|
491
|
+
or not isinstance(item.get("staged"), str)
|
|
492
|
+
or not re.fullmatch(r"[0-9]+\.new", item["staged"])
|
|
493
|
+
or not isinstance(item.get("sha256"), str)
|
|
494
|
+
or not re.fullmatch(r"[0-9a-f]{64}", item["sha256"])
|
|
495
|
+
):
|
|
496
|
+
raise RuntimeError(f"malformed transaction entry: {tx}")
|
|
497
|
+
source = tx / item["staged"]
|
|
498
|
+
if _is_transaction_state(item["target"]):
|
|
499
|
+
raise RuntimeError(f"transaction cannot replace its own state: {item['target']}")
|
|
500
|
+
target = safe_vault_target(root, item["target"])
|
|
501
|
+
if source.is_symlink() or item["staged"] in sources or target in targets:
|
|
502
|
+
raise RuntimeError(f"unsafe or duplicate transaction entry: {tx}")
|
|
503
|
+
sources.add(item["staged"])
|
|
504
|
+
targets.add(target)
|
|
505
|
+
entries.append((source, target, item["sha256"]))
|
|
506
|
+
if not committed.exists():
|
|
507
|
+
for source, target, expected in entries:
|
|
508
|
+
try:
|
|
509
|
+
data = source.read_bytes()
|
|
510
|
+
except FileNotFoundError:
|
|
511
|
+
# Older journals deleted each source immediately after writing.
|
|
512
|
+
if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() == expected:
|
|
513
|
+
continue
|
|
514
|
+
raise RuntimeError(f"cannot recover missing staged content: {source}; restore it from a backup") from None
|
|
515
|
+
if hashlib.sha256(data).hexdigest() != expected:
|
|
516
|
+
raise RuntimeError(f"staged content digest mismatch: {source}")
|
|
517
|
+
atomic_write_bytes(target, data)
|
|
518
|
+
atomic_write_text(committed, "1\n")
|
|
519
|
+
_discard_transaction(tx)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _discard_transaction(tx: Path) -> None:
|
|
523
|
+
# Rename before unlinking: a crash during deletion must never replay targets.
|
|
524
|
+
garbage = tx.with_name("gc-" + tx.name)
|
|
525
|
+
os.replace(tx, garbage)
|
|
526
|
+
_sync_directory(garbage.parent)
|
|
527
|
+
shutil.rmtree(garbage)
|
|
528
|
+
_sync_directory(garbage.parent)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def recover_pending_transactions(root: Path) -> list[Path]:
|
|
532
|
+
"""Finish READY transactions that never reached COMMITTED (crash recovery)."""
|
|
533
|
+
root = Path(root).resolve(strict=True)
|
|
534
|
+
base = root / ".whykit" / "transactions"
|
|
535
|
+
if base.is_symlink():
|
|
536
|
+
raise RuntimeError(f"symlinked transaction directory refused: {base}")
|
|
537
|
+
if not base.is_dir():
|
|
538
|
+
return []
|
|
539
|
+
base = safe_vault_dir(root, ".whykit/transactions")
|
|
540
|
+
recovered: list[Path] = []
|
|
541
|
+
for tx in sorted(base.iterdir()):
|
|
542
|
+
if tx.is_symlink():
|
|
543
|
+
raise RuntimeError(f"symlinked transaction directory refused: {tx}")
|
|
544
|
+
if not tx.is_dir():
|
|
545
|
+
continue
|
|
546
|
+
if re.fullmatch(r"gc-[0-9a-f]{24}", tx.name):
|
|
547
|
+
shutil.rmtree(tx)
|
|
548
|
+
_sync_directory(base)
|
|
549
|
+
elif (tx / "READY").exists():
|
|
550
|
+
pending = not (tx / "COMMITTED").exists()
|
|
551
|
+
commit_transaction(root, tx)
|
|
552
|
+
if pending:
|
|
553
|
+
recovered.append(tx)
|
|
554
|
+
elif re.fullmatch(r"[0-9a-f]{24}", tx.name) and time.time() - tx.stat().st_mtime >= 86400:
|
|
555
|
+
# Pre-READY journals never touched targets; retain young ones for inspection.
|
|
556
|
+
_discard_transaction(tx)
|
|
557
|
+
return recovered
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def apply_transaction(root: Path, updates: Mapping[Path, str]) -> None:
|
|
561
|
+
"""Stage then commit a multi-file update under the caller's mutation lock."""
|
|
562
|
+
tx = stage_transaction(root, updates)
|
|
563
|
+
commit_transaction(root, tx)
|