@r1z4x/tezgah 1.3.0 → 1.5.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 +103 -1
- package/MANIFEST +3 -0
- package/agents/tezgah-reviewer.md +19 -10
- package/bin/tezgah-docs +3 -3
- package/bin/tezgah-doctor +75 -18
- package/bin/tezgah-route +5 -2
- package/bin/tezgah-setup +30 -18
- package/bin/tezgah-status +3 -1
- package/bin/tezgah-task +130 -37
- package/bin/tezgah-taste +74 -55
- package/bin/tezgah-triage +12 -7
- package/docs/architecture.md +4 -4
- package/docs/clarity-baseline.json +9 -9
- package/docs/contract.md +22 -12
- package/docs/environment.md +4 -1
- package/docs/evidence.md +71 -34
- package/docs/gate.md +21 -21
- package/docs/glossary.md +5 -5
- package/docs/hosts.md +8 -8
- package/docs/judge.md +93 -27
- package/docs/operations.md +69 -30
- package/docs/skills.md +2 -2
- package/docs/status-line.md +7 -7
- package/docs/vision.md +10 -5
- package/hooks/tezgah_agents.py +22 -11
- package/hooks/tezgah_attest.py +15 -22
- package/hooks/tezgah_context.py +84 -124
- 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 +270 -122
- package/hooks/tezgah_lessons.py +18 -63
- package/hooks/tezgah_paths.py +183 -55
- package/hooks/tezgah_policy.py +35 -8
- package/hooks/tezgah_replay.py +57 -33
- 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_task.py +146 -40
- package/hooks/tezgah_taste.py +21 -18
- package/hooks/tezgah_taste_ledger.py +63 -88
- package/hooks/tezgah_update.py +6 -26
- package/hosts/codex/hook.py +5 -0
- package/hosts/cursor/hook.py +19 -31
- package/hosts/opencode/plugins/tezgah.js +309 -108
- package/package.json +1 -1
- package/skills/harness/SKILL.md +4 -0
- package/skills/plan-add/SKILL.md +23 -7
- package/skills/plan-add/render_table.py +3 -2
- package/skills/plan-status/SKILL.md +12 -4
- package/skills/plan-sync/SKILL.md +19 -9
- package/skills/tezgah-contract/SKILL.md +25 -12
package/hooks/tezgah_replay.py
CHANGED
|
@@ -6,7 +6,7 @@ code, a blind label sheet drawn from them, and the report that reads the labels
|
|
|
6
6
|
tezgah-gate replay [--cutoff WHEN] [--since WHEN] [--json]
|
|
7
7
|
tezgah-gate replay --report [--run DIR] [--labels FILE ...] [--json]
|
|
8
8
|
tezgah-gate replay --sheet [--run DIR] [--seed N] [--rules FILE]
|
|
9
|
-
tezgah-gate replay --label-model --prompt FILE [--provider
|
|
9
|
+
tezgah-gate replay --label-model --prompt FILE [--provider jev|deepseek]
|
|
10
10
|
[--model ID] [--run DIR]
|
|
11
11
|
|
|
12
12
|
**Corpus.** Every ledger under the cache's `evidence/` is read read-only; a
|
|
@@ -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",
|
|
@@ -867,9 +864,9 @@ def report(run, label_files=()):
|
|
|
867
864
|
|
|
868
865
|
# ----------------------------------------------------------------- raters --
|
|
869
866
|
|
|
870
|
-
# Rater 2 (H2 amendment 2026-10-07):
|
|
871
|
-
#
|
|
872
|
-
# `bin/tezgah-triage::PRICE_PER_MILLION` quotes.
|
|
867
|
+
# Rater 2 (H2 amendment 2026-10-07): the Jev model through the judge seam, any
|
|
868
|
+
# Jev carrier (`tezgah_judge.JEV_CARRIERS`), billed per million input tokens and
|
|
869
|
+
# nothing for output, at the price `bin/tezgah-triage::PRICE_PER_MILLION` quotes.
|
|
873
870
|
RATER_MODEL = "jev-latest"
|
|
874
871
|
JEV_PRICE_PER_MILLION = 0.042
|
|
875
872
|
LABEL_WORKERS = 4
|
|
@@ -906,9 +903,10 @@ def _deepseek(secret, model):
|
|
|
906
903
|
return ask
|
|
907
904
|
|
|
908
905
|
|
|
909
|
-
def label_model(run, prompt_file, provider="
|
|
906
|
+
def label_model(run, prompt_file, provider="jev", model=None):
|
|
910
907
|
"""One `choice` question per blind sheet row, the row's text redacted
|
|
911
|
-
(`ti.redact`). `typesafe` asks
|
|
908
|
+
(`ti.redact`). `jev` (old name `typesafe`) asks the Jev model through the
|
|
909
|
+
judge seam, whichever carrier resolves, and writes
|
|
912
910
|
`labels-model.jsonl` (rater 2); `deepseek` asks DeepSeek's chat endpoint and
|
|
913
911
|
writes `labels-deepseek.jsonl` (rater 1). Rows already labelled there are not
|
|
914
912
|
asked again; a reply without a label among the prompt's criteria stays
|
|
@@ -923,13 +921,15 @@ def label_model(run, prompt_file, provider="typesafe", model=None):
|
|
|
923
921
|
ask, rater = _deepseek(secret, model or "deepseek-v4-pro"), "deepseek"
|
|
924
922
|
price = DEEPSEEK_PRICE_PER_MILLION
|
|
925
923
|
else:
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
"
|
|
924
|
+
if tj.tp.off("judge-off") or not tj.named(("jev",)):
|
|
925
|
+
raise SystemExit("label-model: the judge seam must answer through a Jev "
|
|
926
|
+
"carrier (TypeSafe, OpenRouter System One, Cloudflare "
|
|
927
|
+
"Workers AI or JEV_API_BASE_URL; TEZGAH_JEV_PROVIDER picks "
|
|
928
|
+
"one), and none resolves")
|
|
930
929
|
|
|
931
930
|
def ask(state, questions):
|
|
932
|
-
return tj.ask(state, questions, model=model or RATER_MODEL, timeout=60
|
|
931
|
+
return tj.ask(state, questions, model=model or RATER_MODEL, timeout=60,
|
|
932
|
+
only=("jev",))
|
|
933
933
|
rater, price = "model", (JEV_PRICE_PER_MILLION, 0.0)
|
|
934
934
|
with open(prompt_file, encoding="utf-8") as fh:
|
|
935
935
|
prompt = json.load(fh)
|
|
@@ -945,7 +945,7 @@ def label_model(run, prompt_file, provider="typesafe", model=None):
|
|
|
945
945
|
label = tj.choice(result, "label")
|
|
946
946
|
return row, result, label if label in question["criteria"] else None
|
|
947
947
|
|
|
948
|
-
tokens, out_tokens, models, new = 0, 0, Counter(), 0
|
|
948
|
+
tokens, out_tokens, models, new, spent, prices = 0, 0, Counter(), 0, 0.0, set()
|
|
949
949
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
950
950
|
os.fchmod(fd, 0o600)
|
|
951
951
|
# Each answered row is appended as it arrives, so a run that dies mid-way
|
|
@@ -956,6 +956,12 @@ def label_model(run, prompt_file, provider="typesafe", model=None):
|
|
|
956
956
|
tokens += result["usage"]["input_tokens"]
|
|
957
957
|
out_tokens += result["usage"]["output_tokens"]
|
|
958
958
|
models["%s/%s" % (result["provider"], result["model"])] += 1
|
|
959
|
+
# a typed carrier is billed at its own price (Clef's is not Jev's)
|
|
960
|
+
typed = tj.input_price(result["provider"])
|
|
961
|
+
row_price = (typed, 0.0) if typed is not None else price
|
|
962
|
+
prices.add(row_price)
|
|
963
|
+
spent += (result["usage"]["input_tokens"] * row_price[0]
|
|
964
|
+
+ result["usage"]["output_tokens"] * row_price[1]) / 1e6
|
|
959
965
|
if label:
|
|
960
966
|
fh.write(json.dumps({"set": row["set"], "n": row["n"], "label": label,
|
|
961
967
|
"rater": rater, "model": result["model"],
|
|
@@ -965,8 +971,9 @@ def label_model(run, prompt_file, provider="typesafe", model=None):
|
|
|
965
971
|
new += 1
|
|
966
972
|
return {"path": path, "asked": len(todo), "labelled": new,
|
|
967
973
|
"total": len(done) + new, "input_tokens": tokens, "output_tokens": out_tokens,
|
|
968
|
-
|
|
969
|
-
"
|
|
974
|
+
# the printed formula names the rate the rows were billed at, when one
|
|
975
|
+
"price": prices.pop() if len(prices) == 1 else price,
|
|
976
|
+
"cost_usd": spent,
|
|
970
977
|
"models": dict(models)}
|
|
971
978
|
|
|
972
979
|
|
|
@@ -1010,8 +1017,24 @@ def _pct(k, n):
|
|
|
1010
1017
|
def _run_dir(arg):
|
|
1011
1018
|
if arg:
|
|
1012
1019
|
return arg
|
|
1013
|
-
|
|
1014
|
-
|
|
1020
|
+
run = tezgah_store.replay_latest(tp.CACHE)
|
|
1021
|
+
if not run:
|
|
1022
|
+
raise SystemExit("replay: no run yet; run `tezgah-gate replay` first")
|
|
1023
|
+
return run
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _run_key(run):
|
|
1027
|
+
"""A run's key in the replay index: its directory's real path, so a
|
|
1028
|
+
relative or linked `--run` names the run its row was written under."""
|
|
1029
|
+
return os.path.realpath(run)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _summary(run):
|
|
1033
|
+
"""The summary the run in directory `run` was indexed with (`run_replay`)."""
|
|
1034
|
+
summary = tezgah_store.replay_summary(_run_key(run), tp.CACHE)
|
|
1035
|
+
if summary is None:
|
|
1036
|
+
raise SystemExit("replay: no indexed run at %s" % run)
|
|
1037
|
+
return summary
|
|
1015
1038
|
|
|
1016
1039
|
|
|
1017
1040
|
def main(argv):
|
|
@@ -1021,7 +1044,8 @@ def main(argv):
|
|
|
1021
1044
|
p.add_argument("--report", action="store_true")
|
|
1022
1045
|
p.add_argument("--label-model", action="store_true")
|
|
1023
1046
|
p.add_argument("--prompt")
|
|
1024
|
-
|
|
1047
|
+
# `typesafe` is the old name of `jev`, kept for the commands already written down
|
|
1048
|
+
p.add_argument("--provider", choices=("jev", "typesafe", "deepseek"), default="jev")
|
|
1025
1049
|
p.add_argument("--model")
|
|
1026
1050
|
p.add_argument("--sheet", action="store_true")
|
|
1027
1051
|
p.add_argument("--run")
|
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)"
|