@r1z4x/tezgah 1.3.0 → 1.4.0
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.
- package/CHANGELOG.md +33 -1
- package/MANIFEST +3 -0
- package/bin/tezgah-doctor +75 -18
- package/bin/tezgah-setup +6 -17
- package/bin/tezgah-taste +30 -29
- package/docs/architecture.md +4 -4
- package/docs/clarity-baseline.json +3 -3
- package/docs/contract.md +1 -1
- package/docs/environment.md +1 -0
- package/docs/evidence.md +67 -31
- package/docs/gate.md +21 -21
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/operations.md +48 -20
- package/docs/skills.md +1 -1
- package/docs/status-line.md +7 -7
- package/hooks/tezgah_attest.py +15 -22
- package/hooks/tezgah_context.py +44 -118
- package/hooks/tezgah_gate.py +26 -23
- package/hooks/tezgah_guard.py +9 -11
- package/hooks/tezgah_integrity.py +77 -178
- package/hooks/tezgah_judge.py +25 -43
- package/hooks/tezgah_lessons.py +18 -63
- package/hooks/tezgah_paths.py +18 -28
- package/hooks/tezgah_replay.py +31 -18
- package/hooks/tezgah_shapes.py +4 -4
- package/hooks/tezgah_skill_pick.py +6 -35
- package/hooks/tezgah_snapshot.py +25 -46
- package/hooks/tezgah_store.py +1268 -0
- package/hooks/tezgah_taste.py +20 -17
- package/hooks/tezgah_taste_ledger.py +52 -86
- package/hooks/tezgah_update.py +6 -26
- package/hosts/cursor/hook.py +19 -31
- package/hosts/opencode/plugins/tezgah.js +309 -108
- package/package.json +1 -1
package/hooks/tezgah_lessons.py
CHANGED
|
@@ -7,16 +7,16 @@ The session block, the per-turn block and the per-turn digest
|
|
|
7
7
|
ledger here, so they agree on what a lesson is and on its key. A module of its
|
|
8
8
|
own because the gate must not import `tezgah_context` (its import cost, and the
|
|
9
9
|
rule `hooks/tezgah_task.py::repo_root` states): this one imports only
|
|
10
|
-
`tezgah_paths` at load, and `tezgah_gate` lazily, only for a
|
|
11
|
-
gate rule as its enforcer.
|
|
10
|
+
`tezgah_paths` and `tezgah_store` at load, and `tezgah_gate` lazily, only for a
|
|
11
|
+
line that names a gate rule as its enforcer.
|
|
12
12
|
|
|
13
13
|
Stdlib only."""
|
|
14
14
|
import hashlib
|
|
15
|
-
import json
|
|
16
15
|
import os
|
|
17
16
|
import re
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
import tezgah_store
|
|
19
|
+
from tezgah_paths import off
|
|
20
20
|
|
|
21
21
|
# A line carries its metadata after its text, never in it: from the first
|
|
22
22
|
# `|| check:` or `|| enforced_by:` clause outside a code span to the end of the
|
|
@@ -114,73 +114,28 @@ def is_ledger(path):
|
|
|
114
114
|
and os.path.basename(os.path.dirname(path)) == ".tezgah")
|
|
115
115
|
|
|
116
116
|
|
|
117
|
-
def
|
|
118
|
-
"""The
|
|
119
|
-
each keyed row
|
|
120
|
-
later session reads one
|
|
121
|
-
ledger on the machine (3173 files, 30 MB on the
|
|
122
|
-
2026-10-07)."""
|
|
123
|
-
|
|
124
|
-
return os.path.join(cache_dir(), "lessons", name.hexdigest()[:16] + ".jsonl")
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _index_rows(path):
|
|
128
|
-
"""The parseable keyed rows of an index file, oldest first; [] without one.
|
|
129
|
-
A damaged line costs only itself."""
|
|
130
|
-
rows = []
|
|
131
|
-
try:
|
|
132
|
-
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
133
|
-
for ln in fh:
|
|
134
|
-
try:
|
|
135
|
-
row = json.loads(ln)
|
|
136
|
-
except ValueError:
|
|
137
|
-
continue
|
|
138
|
-
if isinstance(row, dict) and row.get("key"):
|
|
139
|
-
rows.append(row)
|
|
140
|
-
except OSError:
|
|
141
|
-
pass
|
|
142
|
-
return rows
|
|
117
|
+
def taint_root(root):
|
|
118
|
+
"""The key of the repository at `root` in the `lesson_taint` store: the gate
|
|
119
|
+
keeps each keyed row there as well as in the session's ledger, so the
|
|
120
|
+
context of a later session reads one repository's rows for the per-line
|
|
121
|
+
label instead of every ledger on the machine (3173 files, 30 MB on the
|
|
122
|
+
measuring machine, 2026-10-07)."""
|
|
123
|
+
return hashlib.sha1(os.path.realpath(root).encode("utf-8", "replace")).hexdigest()[:16]
|
|
143
124
|
|
|
144
125
|
|
|
145
126
|
def write_taint(root, keep, rows):
|
|
146
127
|
"""Rewrite the index of the repository at `root`: the rows it holds whose key
|
|
147
|
-
is in `keep` (the ledger's lines plus the write's new ones), then `rows
|
|
148
|
-
the
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
path = taint_path(root)
|
|
152
|
-
kept = [r for r in _index_rows(path) if r["key"] in keep] + rows
|
|
153
|
-
tmp = "%s.%d.tmp" % (path, os.getpid())
|
|
154
|
-
try:
|
|
155
|
-
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
156
|
-
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
157
|
-
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
158
|
-
fh.write("".join(json.dumps(r) + "\n" for r in kept))
|
|
159
|
-
os.replace(tmp, path)
|
|
160
|
-
except OSError:
|
|
161
|
-
pass
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# The index as last read, per (path, mtime, size): the session block, the
|
|
165
|
-
# per-turn block and the digest of one prompt each ask for it, and a hook
|
|
166
|
-
# process serves one prompt.
|
|
167
|
-
_TAINTED = {}
|
|
128
|
+
is in `keep` (the ledger's lines plus the write's new ones), then `rows`, in
|
|
129
|
+
one transaction. So the index never outgrows the ledger it labels. Best
|
|
130
|
+
effort, like a ledger row."""
|
|
131
|
+
tezgah_store.write_taint(taint_root(root), keep, rows)
|
|
168
132
|
|
|
169
133
|
|
|
170
134
|
def tainted(root):
|
|
171
135
|
"""{key: source} for the lessons of the repository at `root` that a gate
|
|
172
|
-
recorded as written in a turn that had read untrusted text (`
|
|
173
|
-
{} when there is none.
|
|
174
|
-
|
|
175
|
-
try:
|
|
176
|
-
st = os.stat(path)
|
|
177
|
-
except OSError:
|
|
178
|
-
return {}
|
|
179
|
-
key = (path, st.st_mtime_ns, st.st_size)
|
|
180
|
-
if key not in _TAINTED:
|
|
181
|
-
_TAINTED[key] = {str(r["key"]): str(r.get("source") or "")
|
|
182
|
-
for r in _index_rows(path)}
|
|
183
|
-
return _TAINTED[key]
|
|
136
|
+
recorded as written in a turn that had read untrusted text (`taint_root`);
|
|
137
|
+
{} when there is none."""
|
|
138
|
+
return tezgah_store.taint(taint_root(root))
|
|
184
139
|
|
|
185
140
|
|
|
186
141
|
def lines(root, retired=None):
|
package/hooks/tezgah_paths.py
CHANGED
|
@@ -612,50 +612,40 @@ def reply_lang():
|
|
|
612
612
|
# `authorized` row, the stand-down text) - the control rule refuses the routes
|
|
613
613
|
# the gate sees, and an interpreter writing them is SECURITY.md's residual.
|
|
614
614
|
STAND_DOWN = "tezgah-setup --uninstall --full\n"
|
|
615
|
-
_LATCH = {"ledger": None, "since": None
|
|
615
|
+
_LATCH = {"ledger": None, "since": None}
|
|
616
616
|
|
|
617
617
|
|
|
618
618
|
def latch(ledger):
|
|
619
619
|
"""Bind this process's `off()` to one session's ledger path (None unbinds)."""
|
|
620
|
-
_LATCH.update(ledger=ledger, since=None
|
|
620
|
+
_LATCH.update(ledger=ledger, since=None)
|
|
621
621
|
|
|
622
622
|
|
|
623
623
|
def _first_ts(ledger):
|
|
624
|
+
import tezgah_store # deferred: an unbound process never opens the ledger
|
|
624
625
|
try:
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
except (OSError, ValueError, AttributeError):
|
|
626
|
+
ts = json.loads(tezgah_store.evidence_first(ledger) or "null").get("ts")
|
|
627
|
+
except (*tezgah_store.ERRORS, ValueError, AttributeError):
|
|
628
628
|
return None
|
|
629
629
|
return ts if isinstance(ts, (int, float)) else None
|
|
630
630
|
|
|
631
631
|
|
|
632
632
|
def _authorized(ledger):
|
|
633
|
-
"""The switch names this session's `authorized` rows carry
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
size = os.path.getsize(ledger)
|
|
637
|
-
except OSError:
|
|
638
|
-
return frozenset()
|
|
639
|
-
memo = _LATCH["auth"]
|
|
640
|
-
if memo and memo[0] == size:
|
|
641
|
-
return memo[1]
|
|
633
|
+
"""The switch names this session's `authorized` rows carry: read only when
|
|
634
|
+
a switch is newer than the first row."""
|
|
635
|
+
import tezgah_store
|
|
642
636
|
names = set()
|
|
643
637
|
try:
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
if '"authorized"' not in line:
|
|
647
|
-
continue
|
|
648
|
-
try:
|
|
649
|
-
row = json.loads(line)
|
|
650
|
-
except ValueError:
|
|
651
|
-
continue
|
|
652
|
-
if row.get("kind") == "authorized" and isinstance(
|
|
653
|
-
row.get("authorized"), list):
|
|
654
|
-
names.update(n for n in row["authorized"] if isinstance(n, str))
|
|
655
|
-
except OSError:
|
|
638
|
+
found = tezgah_store.evidence_rows(ledger, kind="authorized")
|
|
639
|
+
except tezgah_store.ERRORS:
|
|
656
640
|
return frozenset()
|
|
657
|
-
|
|
658
|
-
|
|
641
|
+
for text in found:
|
|
642
|
+
try:
|
|
643
|
+
row = json.loads(text)
|
|
644
|
+
except ValueError:
|
|
645
|
+
continue
|
|
646
|
+
if isinstance(row, dict) and isinstance(row.get("authorized"), list):
|
|
647
|
+
names.update(n for n in row["authorized"] if isinstance(n, str))
|
|
648
|
+
return frozenset(names)
|
|
659
649
|
|
|
660
650
|
|
|
661
651
|
def _honored(path, name):
|
package/hooks/tezgah_replay.py
CHANGED
|
@@ -40,8 +40,10 @@ call's own output.
|
|
|
40
40
|
|
|
41
41
|
**Output.** Everything is written under `~/.cache/tezgah/replay/<run>/`, owner
|
|
42
42
|
only, and never leaves the machine: `corpus.jsonl` (the items, with their
|
|
43
|
-
inputs), `results.jsonl
|
|
44
|
-
|
|
43
|
+
inputs), `results.jsonl` and the sheet files. The run's summary is its row in
|
|
44
|
+
the cache database's replay index (`tezgah_store.add_replay`), whose newest
|
|
45
|
+
row is the latest run. The sandbox HOME and the row stream are deleted when
|
|
46
|
+
the child returns.
|
|
45
47
|
"""
|
|
46
48
|
import argparse
|
|
47
49
|
import datetime
|
|
@@ -64,6 +66,7 @@ if HERE not in sys.path:
|
|
|
64
66
|
sys.path.insert(0, HERE)
|
|
65
67
|
import tezgah_integrity as ti # noqa: E402
|
|
66
68
|
import tezgah_paths as tp # noqa: E402
|
|
69
|
+
import tezgah_store # noqa: E402
|
|
67
70
|
|
|
68
71
|
# The one rule-label derivation; nothing in this module maps text to a rule.
|
|
69
72
|
rule_of = ti._deny_rule
|
|
@@ -425,9 +428,6 @@ def child(run):
|
|
|
425
428
|
import tezgah_gate as tg
|
|
426
429
|
items = {it["i"]: it for it in _read_jsonl(os.path.join(run, "corpus.jsonl"))}
|
|
427
430
|
evidence = os.path.join(ti.cache_dir(), "evidence")
|
|
428
|
-
nudged = os.path.join(ti.cache_dir(), "nudged")
|
|
429
|
-
os.makedirs(evidence, mode=0o700, exist_ok=True)
|
|
430
|
-
os.makedirs(nudged, mode=0o700, exist_ok=True)
|
|
431
431
|
now = [0.0]
|
|
432
432
|
|
|
433
433
|
def frozen():
|
|
@@ -454,15 +454,13 @@ def child(run):
|
|
|
454
454
|
now[0] = float(it["ts"])
|
|
455
455
|
results.append(_replay_one(it, rows_of[fname], tg, seen, live_events))
|
|
456
456
|
rows_of[fname].append(row)
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if isinstance(row.get("ts"), (int, float)):
|
|
461
|
-
os.utime(path, (row["ts"], row["ts"]))
|
|
457
|
+
# the row as the live writer stored it; its own `ts` is the time the
|
|
458
|
+
# cross-session read (`writers_elsewhere`) windows on
|
|
459
|
+
ti._append(os.path.join(evidence, fname), json.dumps(row) + "\n")
|
|
462
460
|
if row.get("kind") == "nudge":
|
|
463
461
|
# the once-per-session nudge mark the live gate wrote with this
|
|
464
|
-
# row (`first_nudge`):
|
|
465
|
-
|
|
462
|
+
# row (`first_nudge`): cache state the ledger records
|
|
463
|
+
tezgah_store.mark("nudged", tg.nudge_key(sid))
|
|
466
464
|
_write_jsonl(os.path.join(run, "results.jsonl"), results)
|
|
467
465
|
|
|
468
466
|
|
|
@@ -597,8 +595,7 @@ def run_replay(cutoff=None, since=None):
|
|
|
597
595
|
"fidelity": fidelity(items, results),
|
|
598
596
|
"race_family": family,
|
|
599
597
|
"deny_runs": folds}
|
|
600
|
-
|
|
601
|
-
_owner_write(os.path.join(replay_root(), "latest"), run + "\n")
|
|
598
|
+
tezgah_store.add_replay(_run_key(run), summary, tp.CACHE)
|
|
602
599
|
return summary
|
|
603
600
|
|
|
604
601
|
|
|
@@ -671,7 +668,7 @@ def sheet(run, seed=SEED, rules=None):
|
|
|
671
668
|
"""The blind sample: `sheet.jsonl` ({set, n, text}), `sheet-key.jsonl` (the
|
|
672
669
|
hidden live verdicts) and `instructions.md`. Returns (paths, counts)."""
|
|
673
670
|
items = _read_jsonl(os.path.join(run, "corpus.jsonl"))
|
|
674
|
-
summary =
|
|
671
|
+
summary = _summary(run)
|
|
675
672
|
rng = random.Random(seed)
|
|
676
673
|
pools = defaultdict(list)
|
|
677
674
|
# A race deny written before the ledger stored `target` cannot show its
|
|
@@ -790,7 +787,7 @@ def _rate(k, n):
|
|
|
790
787
|
|
|
791
788
|
|
|
792
789
|
def report(run, label_files=()):
|
|
793
|
-
summary =
|
|
790
|
+
summary = _summary(run)
|
|
794
791
|
out = {k: summary[k] for k in ("run", "cutoff", "cutoff_local", "ledgers", "candidates",
|
|
795
792
|
"excluded", "items", "joined", "join",
|
|
796
793
|
"stop_text_fidelity", "fidelity", "race_family",
|
|
@@ -1010,8 +1007,24 @@ def _pct(k, n):
|
|
|
1010
1007
|
def _run_dir(arg):
|
|
1011
1008
|
if arg:
|
|
1012
1009
|
return arg
|
|
1013
|
-
|
|
1014
|
-
|
|
1010
|
+
run = tezgah_store.replay_latest(tp.CACHE)
|
|
1011
|
+
if not run:
|
|
1012
|
+
raise SystemExit("replay: no run yet; run `tezgah-gate replay` first")
|
|
1013
|
+
return run
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _run_key(run):
|
|
1017
|
+
"""A run's key in the replay index: its directory's real path, so a
|
|
1018
|
+
relative or linked `--run` names the run its row was written under."""
|
|
1019
|
+
return os.path.realpath(run)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _summary(run):
|
|
1023
|
+
"""The summary the run in directory `run` was indexed with (`run_replay`)."""
|
|
1024
|
+
summary = tezgah_store.replay_summary(_run_key(run), tp.CACHE)
|
|
1025
|
+
if summary is None:
|
|
1026
|
+
raise SystemExit("replay: no indexed run at %s" % run)
|
|
1027
|
+
return summary
|
|
1015
1028
|
|
|
1016
1029
|
|
|
1017
1030
|
def main(argv):
|
package/hooks/tezgah_shapes.py
CHANGED
|
@@ -212,7 +212,7 @@ def rule_yield(files=None, shipped=None):
|
|
|
212
212
|
`rule_ship_times()`. Fixture ledgers are left out by `ti.fixture_ledger`, the
|
|
213
213
|
exclusion `counters_all` applies, because a probe run measures the harness
|
|
214
214
|
and not use. A rule's exposure is the real ledgers last written at or after
|
|
215
|
-
it shipped (the
|
|
215
|
+
it shipped (the newest row's `ts`), and its fires are the deny rows under its
|
|
216
216
|
label in those ledgers. `mark` is `retire-candidate` at or below
|
|
217
217
|
`RETIRE_FIRES` fires over at least `MIN_EXPOSURE` ledgers, `low-exposure` at
|
|
218
218
|
or below `RETIRE_FIRES` over fewer (or an unknown ship date), else None.
|
|
@@ -227,9 +227,9 @@ def rule_yield(files=None, shipped=None):
|
|
|
227
227
|
rows = ti._foreign_rows(path)
|
|
228
228
|
if ti.fixture_ledger(rows):
|
|
229
229
|
continue
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
when = max((r["ts"] for r in rows if isinstance(r.get("ts"), (int, float))),
|
|
231
|
+
default=None)
|
|
232
|
+
if when is None:
|
|
233
233
|
continue
|
|
234
234
|
real.append((when, Counter(_label(row.get("detail")) for row in rows
|
|
235
235
|
if row.get("kind") == "deny")))
|
|
@@ -58,6 +58,7 @@ import tezgah_integrity as ti
|
|
|
58
58
|
import tezgah_judge
|
|
59
59
|
import tezgah_rank
|
|
60
60
|
import tezgah_paths as tp
|
|
61
|
+
import tezgah_store
|
|
61
62
|
|
|
62
63
|
ARM = "skill-suggest-on"
|
|
63
64
|
# Below this, the turn's own gate question says no skill is wanted and nothing is
|
|
@@ -216,17 +217,8 @@ def judge(prompt, names, session_id=""):
|
|
|
216
217
|
roots=[os.path.join(tp.PLUGIN_ROOT, "skills")]))
|
|
217
218
|
|
|
218
219
|
|
|
219
|
-
def _path(session_id):
|
|
220
|
-
return os.path.join(tp.cache_dir(), "skill-pick",
|
|
221
|
-
slug(session_id) + ".json")
|
|
222
|
-
|
|
223
|
-
|
|
224
220
|
def _remembered(session_id):
|
|
225
|
-
|
|
226
|
-
with open(_path(session_id), encoding="utf-8") as fh:
|
|
227
|
-
data = json.load(fh)
|
|
228
|
-
except (OSError, ValueError):
|
|
229
|
-
return {}
|
|
221
|
+
data = tezgah_store.doc("skill_pick", slug(session_id))
|
|
230
222
|
return data if isinstance(data, dict) else {}
|
|
231
223
|
|
|
232
224
|
|
|
@@ -237,13 +229,7 @@ def _remember(session_id, digest, line):
|
|
|
237
229
|
data[digest] = line
|
|
238
230
|
for old in list(data)[:max(0, len(data) - KEPT)]:
|
|
239
231
|
del data[old]
|
|
240
|
-
|
|
241
|
-
try:
|
|
242
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
243
|
-
with open(path, "w", encoding="utf-8") as fh:
|
|
244
|
-
json.dump(data, fh)
|
|
245
|
-
except OSError:
|
|
246
|
-
pass
|
|
232
|
+
tezgah_store.put_doc("skill_pick", slug(session_id), data)
|
|
247
233
|
|
|
248
234
|
|
|
249
235
|
def suggest(prompt, session_id=""):
|
|
@@ -617,31 +603,16 @@ def top_section(prompt, roots=None):
|
|
|
617
603
|
return tuple(hit), covered, len(known)
|
|
618
604
|
|
|
619
605
|
|
|
620
|
-
def _hint_path(session_id):
|
|
621
|
-
return os.path.join(tp.cache_dir(), "skill-section-hint",
|
|
622
|
-
slug(session_id) + ".json")
|
|
623
|
-
|
|
624
|
-
|
|
625
606
|
def _shown(session_id):
|
|
626
607
|
"""The sections this session was already pointed at, ["path:start"]."""
|
|
627
|
-
|
|
628
|
-
with open(_hint_path(session_id), encoding="utf-8") as fh:
|
|
629
|
-
data = json.load(fh)
|
|
630
|
-
except (OSError, ValueError):
|
|
631
|
-
return []
|
|
608
|
+
data = tezgah_store.doc("section_hint", slug(session_id))
|
|
632
609
|
return data if isinstance(data, list) else []
|
|
633
610
|
|
|
634
611
|
|
|
635
612
|
def _remember_shown(session_id, shown):
|
|
636
|
-
"""Bounded and best effort, like the judgement's cache: a
|
|
613
|
+
"""Bounded and best effort, like the judgement's cache: a store that cannot
|
|
637
614
|
be written costs a repeated line, never the turn."""
|
|
638
|
-
|
|
639
|
-
try:
|
|
640
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
641
|
-
with open(path, "w", encoding="utf-8") as fh:
|
|
642
|
-
json.dump(shown[-KEPT:], fh)
|
|
643
|
-
except OSError:
|
|
644
|
-
pass
|
|
615
|
+
tezgah_store.put_doc("section_hint", slug(session_id), shown[-KEPT:])
|
|
645
616
|
|
|
646
617
|
|
|
647
618
|
def section_hint(prompt, session_id="", host=None, roots=None):
|
package/hooks/tezgah_snapshot.py
CHANGED
|
@@ -24,11 +24,12 @@ import time
|
|
|
24
24
|
import uuid
|
|
25
25
|
|
|
26
26
|
import tezgah_integrity as ti
|
|
27
|
+
import tezgah_store
|
|
27
28
|
from tezgah_paths import cache_dir, root_for
|
|
28
29
|
|
|
29
30
|
# A snapshot id is exactly what `capture` generates: 12 hex chars. Validated
|
|
30
31
|
# before it is used as a path segment, so an id typed at a shell cannot walk out
|
|
31
|
-
# of the store with `..` or an absolute path and read some other file
|
|
32
|
+
# of the store with `..` or an absolute path and read some other file as a blob.
|
|
32
33
|
SNAPSHOT_ID = re.compile(r"\A[0-9a-f]{12}\Z")
|
|
33
34
|
|
|
34
35
|
# The store is capped, because copying every file the agent touches with no
|
|
@@ -41,7 +42,9 @@ SNAPSHOT_ID = re.compile(r"\A[0-9a-f]{12}\Z")
|
|
|
41
42
|
# mistake is most likely and most expensive, while the snapshot anyone actually
|
|
42
43
|
# asks for is about a recent mistake - the oldest one is what nobody is coming
|
|
43
44
|
# back for. Worst case on disk is CAP * MAX_BYTES = 400 MB; the realistic case
|
|
44
|
-
# is a few MB, because source files are kilobytes.
|
|
45
|
+
# is a few MB, because source files are kilobytes. Each blob is a file in its
|
|
46
|
+
# own directory; the manifests are the `snapshots` rows of the cache database,
|
|
47
|
+
# in capture order, and that order is what eviction reads.
|
|
45
48
|
#
|
|
46
49
|
# MAX_BYTES per file, above which nothing is captured at all. `capture` runs
|
|
47
50
|
# synchronously in the gate a host calls before the write, so copying a
|
|
@@ -61,10 +64,10 @@ HASH_MAX_BYTES = 8 * MAX_BYTES
|
|
|
61
64
|
|
|
62
65
|
|
|
63
66
|
def _store():
|
|
64
|
-
"""Where the pre-write bytes go: beside the
|
|
65
|
-
cache dir tezgah settled on for this environment (a
|
|
66
|
-
writable ~/.cache and falls back to temp), so all
|
|
67
|
-
one root."""
|
|
67
|
+
"""Where the pre-write bytes go: beside the cache database that indexes
|
|
68
|
+
them, in whichever cache dir tezgah settled on for this environment (a
|
|
69
|
+
sandboxed host has no writable ~/.cache and falls back to temp), so all
|
|
70
|
+
tezgah state sits under one root."""
|
|
68
71
|
return os.path.join(cache_dir(), "snapshots")
|
|
69
72
|
|
|
70
73
|
|
|
@@ -76,34 +79,13 @@ def _blob(snapshot_id):
|
|
|
76
79
|
return os.path.join(_dir(snapshot_id), "file")
|
|
77
80
|
|
|
78
81
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
except OSError:
|
|
87
|
-
return []
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _evict(keep=None):
|
|
91
|
-
"""Drop snapshot directories, oldest first, until `keep` (CAP) remain.
|
|
92
|
-
|
|
93
|
-
The order is each directory's own mtime, which is when its capture created
|
|
94
|
-
it - nothing writes inside a snapshot directory afterwards. Best effort: an
|
|
95
|
-
entry that cannot be read or removed costs the cap, never the capture the
|
|
96
|
-
gate is waiting on."""
|
|
97
|
-
keep = CAP if keep is None else keep
|
|
98
|
-
try:
|
|
99
|
-
# ns rather than the float mtime: two captures inside one tick must not
|
|
100
|
-
# evict an arbitrary one of the pair
|
|
101
|
-
entries = sorted((os.stat(p).st_mtime_ns, p) for p in _dirs())
|
|
102
|
-
except OSError:
|
|
103
|
-
return
|
|
104
|
-
for _, path in entries[:-keep]:
|
|
105
|
-
import shutil # deferred: ~3.7 ms of import for a path only a write takes
|
|
106
|
-
shutil.rmtree(path, ignore_errors=True)
|
|
82
|
+
def _drop(snapshot_ids):
|
|
83
|
+
"""Remove the blob directories of `snapshot_ids`. Best effort: an entry
|
|
84
|
+
that cannot be removed costs disk, never the capture the gate is waiting
|
|
85
|
+
on."""
|
|
86
|
+
import shutil # deferred: ~3.7 ms of import for a path only a write takes
|
|
87
|
+
for sid in snapshot_ids:
|
|
88
|
+
shutil.rmtree(_dir(sid), ignore_errors=True)
|
|
107
89
|
|
|
108
90
|
|
|
109
91
|
def _hash_file(path):
|
|
@@ -222,21 +204,19 @@ def _capture_one(path, cwd, session_id):
|
|
|
222
204
|
|
|
223
205
|
|
|
224
206
|
def _save(data, meta):
|
|
225
|
-
"""Write one blob
|
|
226
|
-
|
|
207
|
+
"""Write one blob, index it with its manifest, evict past CAP, and return
|
|
208
|
+
the id - or None, with nothing left behind, when the write failed."""
|
|
227
209
|
sid = uuid.uuid4().hex[:12]
|
|
228
210
|
try:
|
|
229
211
|
os.makedirs(_store(), mode=0o700, exist_ok=True)
|
|
230
212
|
os.makedirs(_dir(sid), mode=0o700, exist_ok=True)
|
|
231
213
|
with _private_open(_blob(sid)) as fh:
|
|
232
214
|
fh.write(data)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
import shutil # deferred: see _evict
|
|
237
|
-
shutil.rmtree(_dir(sid), ignore_errors=True) # no half a snapshot
|
|
215
|
+
evicted = tezgah_store.add_snapshot(sid, dict(meta, id=sid), CAP)
|
|
216
|
+
except tezgah_store.ERRORS:
|
|
217
|
+
_drop([sid]) # no half a snapshot
|
|
238
218
|
return None
|
|
239
|
-
|
|
219
|
+
_drop(evicted)
|
|
240
220
|
return sid
|
|
241
221
|
|
|
242
222
|
|
|
@@ -321,14 +301,13 @@ def restore(snapshot_id, force=False, expect=None):
|
|
|
321
301
|
sid = str(snapshot_id or "")
|
|
322
302
|
meta, data = None, b""
|
|
323
303
|
if SNAPSHOT_ID.match(sid):
|
|
304
|
+
meta = next(iter(tezgah_store.snapshots(sid)), None)
|
|
324
305
|
try:
|
|
325
|
-
with open(_manifest(sid), encoding="utf-8") as fh:
|
|
326
|
-
meta = json.load(fh)
|
|
327
306
|
with open(_blob(sid), "rb") as fh:
|
|
328
307
|
data = fh.read()
|
|
329
|
-
except
|
|
308
|
+
except OSError:
|
|
330
309
|
meta = None
|
|
331
|
-
if not meta:
|
|
310
|
+
if not isinstance(meta, dict):
|
|
332
311
|
raise SnapshotError(
|
|
333
312
|
"unknown snapshot id %r: the store holds no snapshot for it (CAP "
|
|
334
313
|
"keeps the newest %d, and an evicted one is gone for good)"
|