@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.
@@ -135,13 +135,11 @@ def attest_session(host, session_id, cwd):
135
135
  return tezgah_attest.run(host, session_id, cwd)
136
136
 
137
137
 
138
- def import_crash_mark(session_id):
139
- """The mark an import failure leaves for the status line, one per session.
140
- Built here from stdlib alone (the same `~/.cache/tezgah` as
141
- `tezgah_paths.CACHE`), because the module that failed may be tezgah_paths."""
138
+ def import_crash_key(session_id):
139
+ """The key of the mark an import failure leaves for the status line, one
140
+ per session: the `import_crash` store of the cache database."""
142
141
  import hashlib
143
- return os.path.join(os.path.expanduser("~"), ".cache", "tezgah", "import-crash",
144
- hashlib.sha256(str(session_id).encode()).hexdigest()[:16])
142
+ return hashlib.sha256(str(session_id).encode()).hexdigest()[:16]
145
143
 
146
144
 
147
145
  def import_failed(exc, code=0):
@@ -154,7 +152,7 @@ def import_failed(exc, code=0):
154
152
  the rename that crashed every omp hook). A hook fails open (`code` 0) like
155
153
  every other caught fault, so the dead core must be visible elsewhere: one
156
154
  stderr line always, a `crash` row when the ledger's own modules still
157
- import, and a mark the status line draws (`import_crash_mark`). A CLI a
155
+ import, and a mark the status line draws (`import_crash_key`). A CLI a
158
156
  person or a tool asks for a verdict (`tezgah-gate check`) passes a non-zero
159
157
  `code`: an empty answer from it would read as a pass."""
160
158
  detail = "import: %s: %s" % (type(exc).__name__, str(exc)[:120])
@@ -163,10 +161,10 @@ def import_failed(exc, code=0):
163
161
  % (os.path.basename(sys.argv[0] if sys.argv else "hook"), detail))
164
162
  session = _payload_session()
165
163
  try:
166
- mark = import_crash_mark(session)
167
- os.makedirs(os.path.dirname(mark), exist_ok=True)
168
- with open(mark, "w", encoding="utf-8") as fh:
169
- fh.write(detail + "\n")
164
+ # the store's own import may be what failed: then no mark, as no
165
+ # status line that imports it can draw one either
166
+ import tezgah_store
167
+ tezgah_store.put_doc("import_crash", import_crash_key(session), detail)
170
168
  except Exception:
171
169
  pass
172
170
  try:
@@ -34,11 +34,12 @@ import time
34
34
 
35
35
  try:
36
36
  import fcntl
37
- except ImportError: # not POSIX: the append stays unlocked, as it was before
37
+ except ImportError: # not POSIX: a legacy JSONL ledger was appended unlocked
38
38
  fcntl = None
39
39
 
40
40
  from tezgah_paths import (SWITCHES, _toplevel, cache_dir, latch, off, reply_lang,
41
41
  root_for)
42
+ import tezgah_store as ts
42
43
 
43
44
  # A command that actually checks the change, as opposed to one that merely runs.
44
45
  # Command position, like UI_CHECK: `echo pytest` and `cat pytest.ini` were
@@ -748,15 +749,6 @@ def _stored_text(value):
748
749
  return redact(str(value or ""))[:DETAIL_MAX]
749
750
 
750
751
 
751
- # How long an append waits for the lock before falling back to the unlocked
752
- # write it replaces. The holders are other hook processes appending one line, so
753
- # the wait is normally microseconds; the bound is what keeps a stuck holder from
754
- # wedging a hook, and the fallback is what keeps the row from being lost to the
755
- # lock that was meant to protect it.
756
- LOCK_WAIT = 1.0
757
- LOCK_POLL = 0.01
758
-
759
-
760
752
  def committed_size(handle):
761
753
  """The byte offset just past the last `\\n` in `handle`'s file, or 0 when the
762
754
  file holds no newline at all.
@@ -765,8 +757,8 @@ def committed_size(handle):
765
757
  record. A process killed inside a write leaves a trailing fragment that no
766
758
  newline ever terminated, and that fragment is not a record - it is the gap
767
759
  between what the writer meant to say and what reached the disk. The read
768
- walks backwards in TAIL_CHUNK steps (the tail reader's own chunk below), so
769
- its cost does not grow with a ledger that has run for a long session.
760
+ walks backwards in TAIL_CHUNK steps, so its cost does not grow with a file
761
+ that has run for a long session.
770
762
 
771
763
  `handle` is an open binary file object and is left positioned wherever the
772
764
  walk stopped: the callers truncate or append next, and neither reads the
@@ -795,54 +787,35 @@ def truncate_to_committed(handle):
795
787
 
796
788
 
797
789
  def _append(path, line):
798
- """Append one line to `path` under an exclusive flock on the file itself.
799
-
800
- Two writers reach one ledger file for real: a host fires PostToolUse once per
801
- call of a parallel batch, each in its own process. Serialized, a row is
802
- written by one writer at a time - the construction guarantee, not the
803
- kernel's per-write atomicity on whichever filesystem the cache sits on. The
804
- lock is the ledger's own descriptor, so no sidecar file appears beside it: a
805
- reader lists that directory to find a session's ledger, and one extra name
806
- per session would be a false record there. It dies with the process, so a
807
- crash leaves nothing held.
808
-
809
- A lock that cannot be taken within LOCK_WAIT is not taken, and the append
810
- falls back to the write that was there before: no worse than the unlocked
811
- path this replaced, and a busy lock never costs a row.
812
-
813
- The torn tail is repaired before the write - under the lock when it is held,
814
- and on the fallback too, because a fragment left in place would be terminated
815
- by this very write and become the unparseable committed line the reader has
816
- to refuse. The boundary is the last newline in the file, so the repair
817
- removes a fragment and can never cut a record (the fallback's row says so:
818
- `_unlocked`)."""
819
- handle = None
790
+ """Append one row (`line`: its JSON and a newline) to the ledger at `path`.
791
+
792
+ The row goes into the cache's evidence database (`tezgah_store`) as one
793
+ INSERT in autocommit, so it is whole or absent: no torn tail, and two
794
+ writers queue on the database's lock instead of a file's. A row the
795
+ database cannot take (busy past its timeout during a sweep or a large
796
+ import, a damaged file) goes to the session's legacy JSONL file instead,
797
+ marked `unlocked` (`_jsonl_append`): the next read or write imports it, and
798
+ a lost `began` row would otherwise turn an honest pass into an orphan. Best
799
+ effort: a write that fails both ways is not fatal, as `note` promises."""
800
+ try:
801
+ ts.append_evidence(path, line.rstrip("\n"))
802
+ except ts.ERRORS:
803
+ _jsonl_append(path, _unlocked(line))
804
+
805
+
806
+ def _jsonl_append(path, line):
807
+ """Append one line to the legacy JSONL file at `path`, without a lock (the
808
+ row says so: `_unlocked`), after cutting a torn tail back to the committed
809
+ boundary so the line is not merged into a fragment."""
820
810
  try:
821
- # owner-only: a row carries commands and paths, and the default umask
822
- # left the dir 0755 and the file 0644 (audit SEC-05 / L-6). The modes
823
- # apply when the dir and the file are created.
811
+ # owner-only: a row carries commands and paths (audit SEC-05 / L-6)
824
812
  os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
825
- # binary: the boundary below is counted in bytes, not in characters
826
- handle = os.fdopen(os.open(path, os.O_RDWR | os.O_APPEND | os.O_CREAT,
827
- 0o600), "a+b")
828
- if fcntl is not None:
829
- deadline = time.time() + LOCK_WAIT
830
- while True:
831
- try:
832
- fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
833
- break
834
- except OSError:
835
- if time.time() >= deadline:
836
- line = _unlocked(line)
837
- break
838
- time.sleep(LOCK_POLL)
839
- truncate_to_committed(handle)
840
- handle.write(line.encode("utf-8"))
813
+ with os.fdopen(os.open(path, os.O_RDWR | os.O_APPEND | os.O_CREAT, 0o600),
814
+ "a+b") as handle:
815
+ truncate_to_committed(handle)
816
+ handle.write(line.encode("utf-8"))
841
817
  except OSError:
842
818
  pass
843
- finally:
844
- if handle is not None:
845
- handle.close() # flushes the line and releases the flock
846
819
 
847
820
 
848
821
  def note_path(path, kind, detail="", **fields):
@@ -884,22 +857,17 @@ def note(session_id, kind, detail="", **fields):
884
857
 
885
858
 
886
859
  def ledgers():
887
- """Every evidence ledger, newest activity first.
860
+ """Every evidence ledger, newest activity first, as the paths
861
+ `events_path` reads: the sessions of this cache's evidence database, after
862
+ the bulk import of the legacy files (`tezgah_store.sessions`).
888
863
 
889
864
  The newest first is what a reader without a session id needs: the ledger a
890
865
  refusal was just written to is the one whose activity is newest."""
891
-
892
- def mtime(path):
893
- try:
894
- return os.path.getmtime(path)
895
- except OSError:
896
- return 0.0
897
-
898
866
  d = os.path.join(cache_dir(), "evidence")
899
867
  try:
900
- return sorted((os.path.join(d, n) for n in os.listdir(d)
901
- if n.endswith(".jsonl")), key=mtime, reverse=True)
902
- except OSError:
868
+ return [os.path.join(d, session + ".jsonl")
869
+ for session, _ts in ts.sessions(cache_dir())]
870
+ except ts.ERRORS:
903
871
  return []
904
872
 
905
873
 
@@ -908,56 +876,21 @@ def kinds(session_id):
908
876
  return {str(e.get("kind")) for e in events(session_id) if e.get("kind")}
909
877
 
910
878
 
911
- # How much of the file one backwards read pulls in. The tail is a bounded number
912
- # of lines, so a chunk size only decides how many read() calls it takes.
879
+ # How much of a file one backwards read pulls in (`committed_size`).
913
880
  TAIL_CHUNK = 8192
914
881
 
915
882
 
916
- def _tail_lines(path, n):
917
- """The last `n` lines of a file, read by seeking from the end.
883
+ def _ledger_lines(path, tail=None):
884
+ """The ledger's rows at `path` as text lines, each ending in a newline (the
885
+ shape `_parse` reads), oldest first; the last `tail` with one.
918
886
 
919
- An unreadable file yields nothing: this is the reader the PreToolUse path
920
- uses, and a gate that cannot see the ledger must let the call through."""
887
+ A ledger that cannot be read yields nothing: this is the reader the
888
+ PreToolUse path uses, and a gate that cannot see the ledger must let the
889
+ call through."""
921
890
  try:
922
- with open(path, "rb") as fh:
923
- fh.seek(0, os.SEEK_END)
924
- pos = fh.tell()
925
- data = b""
926
- while pos > 0 and data.count(b"\n") <= n:
927
- step = min(TAIL_CHUNK, pos)
928
- pos -= step
929
- fh.seek(pos)
930
- data = fh.read(step) + data
931
- except OSError:
891
+ return [text + "\n" for text in ts.evidence_rows(path, tail)]
892
+ except ts.ERRORS:
932
893
  return []
933
- # `keepends`: whether the last line was terminated is what `_parse` reads to
934
- # tell a torn tail from a committed record, and splitting it away here would
935
- # leave every line looking unterminated.
936
- return _lines(data)[-n:]
937
-
938
-
939
- def _lines(data):
940
- """A ledger's bytes as text lines, ends kept, split on b"\\n" alone.
941
-
942
- `str.splitlines` also splits on U+2028, U+2029 and U+0085, which a JSON
943
- writer may leave raw inside a string (opencode's JSON.stringify does), so an
944
- honest row read as two damaged ones. A line that is not UTF-8 becomes a
945
- marker no JSON reader parses, so `_parse` names it as damage; decoding the
946
- whole file strictly raised instead, and the Stop rule failed open."""
947
- parts = data.split(b"\n")
948
- out = []
949
- for i, part in enumerate(parts):
950
- if i < len(parts) - 1:
951
- part += b"\n"
952
- elif not part:
953
- break
954
- try:
955
- out.append(part.decode("utf-8"))
956
- except UnicodeDecodeError:
957
- out.append("\x00not utf-8 %s%s" % (
958
- hashlib.sha1(part).hexdigest()[:12],
959
- "\n" if part.endswith(b"\n") else ""))
960
- return out
961
894
 
962
895
 
963
896
  # The kind a damaged ledger line is recorded under: one row per damaged line,
@@ -976,15 +909,11 @@ _DAMAGE_SEEN = set()
976
909
 
977
910
  def _note_damage(path, key, line):
978
911
  """Write the one `ledger_damage` row for a damaged line, unless the ledger
979
- already holds it. Damage is rare, so the dedup reads the whole file."""
912
+ already holds it. Damage is rare, so the dedup reads the whole ledger."""
980
913
  if (path, key) in _DAMAGE_SEEN:
981
914
  return
982
915
  _DAMAGE_SEEN.add((path, key))
983
- try:
984
- with open(path, encoding="utf-8", errors="replace") as fh:
985
- if any(DAMAGE_KIND in held and key in held for held in fh):
986
- return
987
- except OSError:
916
+ if any(DAMAGE_KIND in held and key in held for held in _ledger_lines(path)):
988
917
  return
989
918
  note_path(path, DAMAGE_KIND, cut(line.strip(), 80), key=key)
990
919
 
@@ -1045,21 +974,14 @@ def events_path(path, tail=None):
1045
974
  """Every parseable ledger entry at an explicit ledger path, oldest first.
1046
975
  `events` is this function with the path derived from a session id. Every
1047
976
  `verify_ok` is paired with the gate's `began` row on the way out (`_pair`)."""
1048
- if tail:
1049
- lines = _tail_lines(path, tail)
1050
- else:
1051
- try:
1052
- with open(path, "rb") as fh:
1053
- lines = _lines(fh.read())
1054
- except OSError:
1055
- return []
977
+ lines = _ledger_lines(path, tail)
1056
978
  return _pair(_parse(lines, path), path, lines)
1057
979
 
1058
980
 
1059
981
  def events(session_id, tail=None):
1060
982
  """Every parseable ledger entry for this session, oldest first.
1061
983
 
1062
- With `tail`, only the last `tail` lines are read. The file grows with the
984
+ With `tail`, only the last `tail` rows are read. The ledger grows with the
1063
985
  session and the gate reads it on every gated call, so the tail path must
1064
986
  never parse the whole of it."""
1065
987
  return events_path(_path(session_id), tail)
@@ -1100,11 +1022,7 @@ def turn_rows(session_id, turns=False, agent=None):
1100
1022
  taint another's effects (security-09). The parent (`agent` None) keeps the
1101
1023
  whole turn, its subagents' work included - that work is the parent's turn."""
1102
1024
  path = _path(session_id)
1103
- try:
1104
- with open(path, "rb") as fh:
1105
- lines = _lines(fh.read())
1106
- except OSError:
1107
- return ([], 0) if turns else []
1025
+ lines = _ledger_lines(path)
1108
1026
  start = _turn_line(lines)
1109
1027
  rows = _pair(_parse(lines[start:], path), path, lines, lines[:start])
1110
1028
  if agent:
@@ -1194,13 +1112,6 @@ def prior_calls(session_id, digest, tail=200, agent=None):
1194
1112
  turn[-1].get("fail_class"))
1195
1113
 
1196
1114
 
1197
- # How much of a sibling session's ledger a cross-session read parses. A write
1198
- # inside the window is that ledger's newest activity by definition of "inside",
1199
- # so the tail is where it is; a session whose window-write sits further back
1200
- # than this many rows is missed.
1201
- WRITE_TAIL = 200
1202
-
1203
-
1204
1115
  def _abs_target(path, cwd):
1205
1116
  """`path` as an absolute real path, resolved against `cwd` when relative.
1206
1117
 
@@ -1276,11 +1187,9 @@ def writers_elsewhere(path, session_id, minutes=10, cwd=None):
1276
1187
  back to a reader that takes a session id (`_path` would slug it a second
1277
1188
  time and open another file).
1278
1189
 
1279
- Neutral value: [] when the cache cannot be listed; a ledger that cannot be
1280
- read or holds a damaged committed line costs only its own rows
1281
- (`_foreign_rows`, audit GAP-02 / M-7). A cross-session rule that cannot see
1282
- the other ledgers has learned nothing, and must stay silent rather than act
1283
- on a guess.
1190
+ Neutral value: [] when the ledger cannot be read; a row that does not
1191
+ parse is skipped. A cross-session rule that cannot see the other ledgers has
1192
+ learned nothing, and must stay silent rather than act on a guess.
1284
1193
 
1285
1194
  ponytail: a shell write counts only through the target its `run` row
1286
1195
  carries (a redirect or `tee`, `tezgah_gate.write_paths`): a sibling that
@@ -1289,38 +1198,23 @@ def writers_elsewhere(path, session_id, minutes=10, cwd=None):
1289
1198
  want = _abs_target(path, cwd)
1290
1199
  if not want:
1291
1200
  return []
1292
- d = os.path.join(cache_dir(), "evidence")
1293
- mine = _slug(session_id) + ".jsonl"
1294
1201
  now = time.time()
1295
- window = minutes * 60
1296
- found = []
1202
+ found = {}
1297
1203
  try:
1298
- entries = list(os.scandir(d))
1299
- except OSError:
1204
+ recent = ts.recent_rows(cache_dir(), now - minutes * 60, ("edit", "run"),
1205
+ _slug(session_id))
1206
+ except ts.ERRORS:
1300
1207
  return []
1301
- for entry in entries:
1302
- if not entry.name.endswith(".jsonl") or entry.name == mine:
1303
- continue
1208
+ # the `ts` column, not the row's own field: a row inserted outside the
1209
+ # hooks need not carry one, and nothing here may raise out of the gate
1210
+ for stem, stamp, text in recent:
1304
1211
  try:
1305
- # nothing in a file whose last write is older than the window can
1306
- # be inside it, so most of a long-lived cache is skipped unread
1307
- if now - entry.stat().st_mtime > window:
1308
- continue
1309
- except OSError:
1212
+ row = json.loads(text)
1213
+ except ValueError:
1310
1214
  continue
1311
- newest = None
1312
- for row in _foreign_rows(entry.path, WRITE_TAIL):
1313
- if row.get("kind") not in ("edit", "run"):
1314
- continue
1315
- ts = row.get("ts")
1316
- if not isinstance(ts, (int, float)) or now - ts > window:
1317
- continue
1318
- if row.get("target") != want:
1319
- continue
1320
- newest = ts if newest is None else max(newest, ts)
1321
- if newest is not None:
1322
- found.append((newest, entry.name[:-len(".jsonl")]))
1323
- return [stem for _, stem in sorted(found, key=lambda pair: -pair[0])]
1215
+ if isinstance(row, dict) and row.get("target") == want:
1216
+ found[stem] = max(found.get(stem, stamp), stamp)
1217
+ return sorted(found, key=lambda stem: -found[stem])
1324
1218
 
1325
1219
 
1326
1220
  def note_turn(session_id, prompt, workspace=None):
@@ -5055,10 +4949,12 @@ def changed_files_notice(session_id, base=None):
5055
4949
 
5056
4950
 
5057
4951
  # --- pairing: a pass the gate never saw begin (plan 051 part 8) --------------
5058
- # The field `_append` adds to a row it wrote without the lock, and the in-memory
5059
- # mark `_pair` puts on a `verify_ok` no gate-written `began` row answers. The
5060
- # mark is never written: `passing_check` refuses a marked row, and the Stop rule
5061
- # reads one in the turn as "evidence tampered".
4952
+ # The field a JSONL append adds to a row it wrote without the lock (`_append`'s
4953
+ # fallback when the database refuses a row, or the old file writer's LOCK_WAIT
4954
+ # fallback, whose imported rows may still carry it), and the in-memory mark
4955
+ # `_pair` puts on a `verify_ok` no gate-written `began` row answers. The mark is
4956
+ # never written: `passing_check` refuses a marked row, and the Stop rule reads
4957
+ # one in the turn as "evidence tampered".
5062
4958
  UNLOCKED = "unlocked"
5063
4959
  ORPHAN = "orphan"
5064
4960
  ORPHANED = (
@@ -5075,7 +4971,7 @@ PAIR_SEED = 200
5075
4971
 
5076
4972
 
5077
4973
  def _unlocked(line):
5078
- """`line` with `unlocked: 1` added to its row (`_append`'s LOCK_WAIT
4974
+ """`line` with `unlocked: 1` added to its row (`_append`'s JSONL
5079
4975
  fallback), or as it was when it is not one row."""
5080
4976
  try:
5081
4977
  row = json.loads(line)
@@ -5106,8 +5002,11 @@ def _pair(rows, path, lines, before=()):
5106
5002
  - in a turn holding a `crash` row: the gate that writes `began` may be the
5107
5003
  code that crashed;
5108
5004
  - with `pretooluse-off` armed: no gate ran;
5109
- - when an append took `_append`'s unlocked fallback, or this platform has
5110
- no lock at all: that truncate can cut a concurrent writer's `began` row;
5005
+ - when a row says a JSONL append wrote it without the lock (UNLOCKED:
5006
+ `_append`'s fallback when the database refused a row, or the old file
5007
+ writer's), or this platform has no flock, so a legacy file's appends
5008
+ were all unlocked: that append's truncate can have cut a concurrent
5009
+ writer's `began` row, and a row the database refused may be one;
5111
5010
  - for a ledger in the sandbox fallback cache, or one whose session also has
5112
5011
  a ledger in the other cache dir: a sandboxed host can split one call's two
5113
5012
  rows over two files (`tezgah_context`'s fallback-cache note).
@@ -5185,4 +5084,4 @@ def _unpaired_exempt(path, lines):
5185
5084
  fallback = os.path.realpath(os.path.join(fallback_cache(), "evidence"))
5186
5085
  dirs = {os.path.realpath(os.path.join(CACHE, "evidence")), fallback} - {here}
5187
5086
  return here == fallback or any(
5188
- os.path.exists(os.path.join(d, os.path.basename(path))) for d in dirs)
5087
+ ts.has_evidence(os.path.join(d, os.path.basename(path))) for d in dirs)
@@ -55,6 +55,7 @@ import urllib.parse
55
55
  import urllib.request
56
56
 
57
57
  import tezgah_paths as tp
58
+ import tezgah_store
58
59
 
59
60
  URL = "https://api.typesafe.ai/v1/systemone"
60
61
  KEY_FILE = "~/.config/typesafe/key"
@@ -308,8 +309,8 @@ def _ask_one(provider, secret, state, questions, model, timeout, attempts, stop)
308
309
  def send():
309
310
  left = timeout if stop is None else max(0.1, min(timeout, stop - time.monotonic()))
310
311
  return _session_request(provider, secret, prompt, left, questions)
311
- marker = _down_marker(provider, url, secret)
312
- if _down(marker):
312
+ down = _down_key(provider, url, secret)
313
+ if _down(down):
313
314
  return None, "marked down for %ds after a refusal" % DOWN_FOR
314
315
  except Exception as exc:
315
316
  return None, _why(exc)
@@ -324,7 +325,7 @@ def _ask_one(provider, secret, state, questions, model, timeout, attempts, stop)
324
325
  code = getattr(exc, "code", None)
325
326
  if code in (401, 402) or (isinstance(code, int) and code >= 500) \
326
327
  or isinstance(exc, SessionFailed):
327
- _mark_down(marker)
328
+ _mark_down(down)
328
329
  return None, _why(exc)
329
330
  continue
330
331
  named = result.get("model")
@@ -535,35 +536,29 @@ def cheap_default():
535
536
  # How long a provider stays marked down after a 401, 402 or 5xx: five minutes,
536
537
  # so a dead key or an empty account costs one refusal per five minutes rather
537
538
  # than one per prompt, and a provider that comes back is asked again within the
538
- # same working session. The marker is keyed on the provider, the endpoint and a
539
- # digest of the credential, so a rotated key is asked at once.
539
+ # same working session. The mark is keyed on the provider, the endpoint and a
540
+ # digest of the credential, so a rotated key is asked at once; the store keeps
541
+ # the time it expires.
540
542
  DOWN_FOR = 300
541
543
 
542
544
 
543
- def _down_marker(provider, url, secret):
545
+ def _down_key(provider, url, secret):
544
546
  import hashlib
545
- digest = hashlib.sha256("\0".join((provider, url, secret)).encode()).hexdigest()
546
- return os.path.join(tp.cache_dir(), "judge-down", digest[:16])
547
+ return hashlib.sha256("\0".join((provider, url, secret)).encode()).hexdigest()[:16]
547
548
 
548
549
 
549
- def _down(marker):
550
- """True while the marker is younger than `DOWN_FOR`; a marker dated in the
551
- future (a clock step back, a copied cache) and any read error are up."""
552
- try:
553
- return 0 <= time.time() - os.path.getmtime(marker) < DOWN_FOR
554
- except OSError:
555
- return False
550
+ def _down(key):
551
+ """True while the mark of `key` has not expired; one expiring more than
552
+ `DOWN_FOR` from now (a clock step back, a copied cache) and any read error
553
+ are up."""
554
+ until = tezgah_store.down_until(key)
555
+ return until is not None and 0 < until - time.time() <= DOWN_FOR
556
556
 
557
557
 
558
- def _mark_down(marker):
559
- """Best effort, like every other write a hook makes: a cache that cannot be
558
+ def _mark_down(key):
559
+ """Best effort, like every other write a hook makes: a store that cannot be
560
560
  written costs the next call a request, never an exception."""
561
- try:
562
- os.makedirs(os.path.dirname(marker), exist_ok=True)
563
- with open(marker, "w", encoding="utf-8"):
564
- pass
565
- except OSError:
566
- pass
561
+ tezgah_store.set_down(key, time.time() + DOWN_FOR)
567
562
 
568
563
 
569
564
  def _bounded(send, stop):
@@ -666,11 +661,6 @@ def _why(exc):
666
661
  return ("%s: %s" % (type(exc).__name__, exc))[:200]
667
662
 
668
663
 
669
- # The last judgement's record, one file: what answered, and why it was not the
670
- # session's own CLI when it was not. `tezgah-status --judge` prints it.
671
- LAST = "judge-last.json"
672
-
673
-
674
664
  def _say(line):
675
665
  try:
676
666
  import sys
@@ -680,9 +670,11 @@ def _say(line):
680
670
 
681
671
 
682
672
  def _record(provider, model, fallback=None, failed=None, refused=None):
683
- """Write the last-use record and say any fallback or refusal on stderr.
684
- Best effort like every hook write: a cache that cannot be written loses
685
- the record, never the answer."""
673
+ """Write the last-use record (the `judge_last` store's one document: what
674
+ answered, and why it was not the session's own CLI when it was not;
675
+ `tezgah-status --judge` prints it) and say any fallback or refusal on
676
+ stderr. Best effort like every hook write: a store that cannot be written
677
+ loses the record, never the answer."""
686
678
  if fallback and provider:
687
679
  _say("answered by %s/%s - %s" % (provider, model, fallback))
688
680
  elif refused:
@@ -690,20 +682,10 @@ def _record(provider, model, fallback=None, failed=None, refused=None):
690
682
  row = {"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "provider": provider,
691
683
  "model": model, "fallback": fallback, "failed": failed or None,
692
684
  "refused": refused, "policy": tp.fallback_policy()}
693
- try:
694
- path = os.path.join(tp.cache_dir(), LAST)
695
- with open(path + ".tmp", "w", encoding="utf-8") as fh:
696
- json.dump(row, fh)
697
- os.replace(path + ".tmp", path)
698
- except (OSError, TypeError, ValueError):
699
- pass
685
+ tezgah_store.put_doc("judge_last", "", row)
700
686
 
701
687
 
702
688
  def last_use():
703
689
  """The last judgement's record (see `_record`), or None when none was kept."""
704
- try:
705
- with open(os.path.join(tp.cache_dir(), LAST), encoding="utf-8") as fh:
706
- row = json.load(fh)
707
- except (OSError, ValueError):
708
- return None
690
+ row = tezgah_store.doc("judge_last", "")
709
691
  return row if isinstance(row, dict) else None