cctally 1.114.0 → 1.116.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/_cctally_account.py +32 -8
  3. package/bin/_cctally_cache.py +4491 -295
  4. package/bin/_cctally_completion.py +1355 -0
  5. package/bin/_cctally_config.py +37 -0
  6. package/bin/_cctally_core.py +282 -9
  7. package/bin/_cctally_dashboard.py +8 -11
  8. package/bin/_cctally_dashboard_conversation.py +292 -23
  9. package/bin/_cctally_dashboard_envelope.py +42 -35
  10. package/bin/_cctally_dashboard_sources.py +18 -9
  11. package/bin/_cctally_db.py +401 -9
  12. package/bin/_cctally_doctor.py +469 -27
  13. package/bin/_cctally_five_hour.py +166 -1049
  14. package/bin/_cctally_forecast.py +81 -9
  15. package/bin/_cctally_journal.py +5076 -177
  16. package/bin/_cctally_journal_repair.py +110 -19
  17. package/bin/_cctally_oauth_samples.py +280 -0
  18. package/bin/_cctally_parser.py +47 -0
  19. package/bin/_cctally_project.py +8 -2
  20. package/bin/_cctally_quarantine.py +662 -0
  21. package/bin/_cctally_quota.py +96 -12
  22. package/bin/_cctally_quota_model.py +391 -83
  23. package/bin/_cctally_record.py +1088 -269
  24. package/bin/_cctally_rederive.py +854 -192
  25. package/bin/_cctally_refresh.py +113 -13
  26. package/bin/_cctally_source_analytics.py +23 -2
  27. package/bin/_cctally_statusline.py +7062 -505
  28. package/bin/_cctally_store.py +1244 -91
  29. package/bin/_cctally_transcript.py +8 -7
  30. package/bin/_cctally_tui.py +119 -60
  31. package/bin/_cctally_update.py +25 -0
  32. package/bin/_lib_account_binding.py +152 -0
  33. package/bin/_lib_account_protocol.py +569 -0
  34. package/bin/_lib_aggregators.py +4 -0
  35. package/bin/_lib_cache_coverage.py +13 -1
  36. package/bin/_lib_cache_writer_lock.py +19 -3
  37. package/bin/_lib_clone_copy.py +177 -0
  38. package/bin/_lib_codex_conversation_query.py +133 -19
  39. package/bin/_lib_codex_conversation_watch.py +59 -14
  40. package/bin/_lib_codex_drain_admission.py +214 -0
  41. package/bin/_lib_codex_metadata.py +18 -1
  42. package/bin/_lib_codex_publication_barrier.py +277 -0
  43. package/bin/_lib_codex_rollout_container.py +502 -0
  44. package/bin/_lib_conversation_dispatch.py +3 -0
  45. package/bin/_lib_conversation_query.py +11 -3
  46. package/bin/_lib_dashboard_sources.py +57 -15
  47. package/bin/_lib_detector_access_registry.py +4038 -0
  48. package/bin/_lib_doctor.py +753 -1
  49. package/bin/_lib_five_hour.py +126 -0
  50. package/bin/_lib_ingest_frontier.py +414 -20
  51. package/bin/_lib_journal.py +261 -10
  52. package/bin/_lib_journal_router.py +166 -48
  53. package/bin/_lib_jsonl.py +5 -4
  54. package/bin/_lib_legacy_spool.py +126 -0
  55. package/bin/_lib_legacy_writer_evidence.py +136 -0
  56. package/bin/_lib_meter_rate_change.py +10 -0
  57. package/bin/_lib_oauth_samples.py +238 -0
  58. package/bin/_lib_outcome_quarantine.py +690 -0
  59. package/bin/_lib_pricing.py +7 -2
  60. package/bin/_lib_pricing_check.py +0 -12
  61. package/bin/_lib_provider_activity.py +770 -0
  62. package/bin/_lib_quarantine.py +155 -0
  63. package/bin/_lib_quota_calibration.py +53 -8
  64. package/bin/_lib_quota_copy.py +2 -1
  65. package/bin/_lib_quota_model.py +384 -52
  66. package/bin/_lib_rebuild_progress.py +146 -0
  67. package/bin/_lib_record.py +22 -0
  68. package/bin/_lib_rederive.py +393 -8
  69. package/bin/_lib_segment_summary.py +35 -2
  70. package/bin/_lib_snapshot_cache.py +43 -1
  71. package/bin/_lib_stale_proof.py +708 -0
  72. package/bin/_lib_statusline_candidates.py +356 -27
  73. package/bin/_lib_worker_launches.py +315 -0
  74. package/bin/cctally +29 -12
  75. package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
  76. package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
  77. package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
  78. package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
  79. package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
  80. package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
  81. package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
  82. package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
  83. package/dashboard/static/dashboard.html +1 -1
  84. package/package.json +20 -2
  85. package/bin/_lib_merge_gate.py +0 -725
  86. package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
@@ -0,0 +1,770 @@
1
+ """Provider-activity evidence for status-line contributors (#797, spec 4.1, 4.2).
2
+
3
+ For each status-line arrival, render captures ONE provider-activity instant
4
+ ``t_m``: the latest timestamp, across the session's main transcript and its
5
+ subagent transcripts (``<transcript without .jsonl>/subagents/agent-*.jsonl``),
6
+ of an ``assistant`` entry that carries a nonempty provider ``requestId`` and is
7
+ not synthetic (``message.model == "<synthetic>"``). A provider-linked error
8
+ response that carries a ``requestId`` counts; file mtime and request-send time
9
+ are never evidence. ``t_m`` is an upper bound on the instant the input's quota
10
+ sample was taken (assumption A-T), so a response the scan missed is the unsafe
11
+ direction: anything not fully covered is ``unknown``, and unknown evidence is
12
+ always eligible (O5).
13
+
14
+ - Scanner: each file is scanned backward from the size captured at arrival,
15
+ through its opened descriptor, at most ``MAX_FILE_BYTES`` in ``CHUNK_BYTES``
16
+ chunks, considering only complete JSON lines. A potentially qualifying
17
+ malformed or unfinished record makes the file unknown, never skipped. The
18
+ file's result is its LAST qualifying record in file order; ``t_m`` is the
19
+ latest of those across every file.
20
+ - Coverage and the scan cache (D10a): the subagent inventory is enumerated
21
+ completely up to ``MAX_INVENTORY``. One cache document per contributor
22
+ records, per file incarnation (device, inode, captured size, ``mtime_ns``)
23
+ and parser revision, the result of a completed scan of that captured prefix.
24
+ Each arrival examines the main transcript plus at most
25
+ ``ARRIVAL_FILE_BUDGET`` uncached or changed subagent files, rotated after a
26
+ persisted cursor. The cache is an accelerator, never an authority: its loss,
27
+ contention or invalidity degrades to unknown, and a cached unknown is never
28
+ rescanned into known while its key is unchanged.
29
+
30
+ The result is frozen into a compact ``ActivityWitness`` carried inside the v2
31
+ candidate document; nothing downstream of capture rescans or consults the
32
+ cache. Pure apart from reading the transcripts and the cache it is handed: no
33
+ database, journal, clock or ``cctally`` namespace dependency.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import calendar
38
+ import dataclasses
39
+ import fcntl
40
+ import hashlib
41
+ import json
42
+ import os
43
+ import pathlib
44
+ import re
45
+ import stat
46
+ import time
47
+ from typing import Literal
48
+
49
+ EVIDENCE_REVISION = 1
50
+ PARSER_REVISION = 1
51
+ UNKNOWN_REASONS = (
52
+ "no-transcript-path", "missing", "unreadable", "malformed",
53
+ "scan-exhausted", "coverage-incomplete", "no-qualifying-record",
54
+ )
55
+ MAX_FILE_BYTES = 1 << 20
56
+ CHUNK_BYTES = 64 << 10
57
+ MAX_INVENTORY = 4096
58
+ ARRIVAL_FILE_BUDGET = 8
59
+
60
+ CACHE_SCHEMA_VERSION = 1
61
+ #: 4,097 file records stay far below this; a larger document is invalid.
62
+ CACHE_DOCUMENT_MAX_BYTES = 4 << 20
63
+ _COUNT_MAX = 2**31 - 1
64
+ _DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z")
65
+ _INSTANT_RE = re.compile(
66
+ r"(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?"
67
+ r"(Z|[+-]\d{2}:\d{2})\Z")
68
+ _CANONICAL_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z\Z")
69
+ _MAIN = "main"
70
+
71
+ # Per-file result statuses (cache records and the digest). ``none`` is a
72
+ # completed scan of the captured prefix that found no qualifying record.
73
+ _KNOWN, _NONE, _UNKNOWN, _UNEXAMINED = "known", "none", "unknown", "unexamined"
74
+
75
+
76
+ class MalformedWitness(ValueError):
77
+ """A persisted activity witness is missing, extra or ill-typed."""
78
+
79
+
80
+ @dataclasses.dataclass(frozen=True)
81
+ class ActivityWitness:
82
+ evidence_revision: int
83
+ parser_revision: int
84
+ status: Literal["known", "unknown"]
85
+ #: The latest qualifying record's timestamp at its original precision,
86
+ #: normalized to UTC ``...Z``; None when unknown.
87
+ instant_utc: str | None
88
+ #: One of ``UNKNOWN_REASONS`` when unknown, else None.
89
+ reason: str | None
90
+ files_total: int
91
+ files_examined: int
92
+ #: sha256 over the canonical serialization of the captured inventory and
93
+ #: per-file results. It binds what render captured; it does not verify the
94
+ #: omitted per-file records, which stay in the scan cache.
95
+ digest: str
96
+
97
+
98
+ # --- instants ----------------------------------------------------------------
99
+
100
+
101
+ def canonical_instant(value: object) -> str | None:
102
+ """``value`` as a UTC ``YYYY-MM-DDTHH:MM:SS[.f]Z`` keeping its fractional
103
+ digits exactly, or None when it is not an ISO 8601 instant with an offset."""
104
+ if not isinstance(value, str):
105
+ return None
106
+ match = _INSTANT_RE.fullmatch(value)
107
+ if match is None:
108
+ return None
109
+ fields = tuple(int(match.group(i)) for i in range(1, 7))
110
+ fraction, offset = match.group(7), match.group(8)
111
+ try:
112
+ seconds = calendar.timegm(fields + (0, 0, 0))
113
+ # Round-tripping rejects impossible dates and times (Feb 30, 24:00).
114
+ if tuple(time.gmtime(seconds)[:6]) != fields:
115
+ return None
116
+ if offset != "Z":
117
+ hours, minutes = int(offset[1:3]), int(offset[4:6])
118
+ if hours > 23 or minutes > 59:
119
+ return None
120
+ sign = 1 if offset[0] == "+" else -1
121
+ seconds -= sign * (hours * 3600 + minutes * 60)
122
+ parts = time.gmtime(seconds)
123
+ except (OverflowError, OSError, ValueError):
124
+ return None
125
+ if not 1 <= parts.tm_year <= 9999:
126
+ return None
127
+ text = "%04d-%02d-%02dT%02d:%02d:%02d" % tuple(parts[:6])
128
+ return text + ("" if fraction is None else "." + fraction) + "Z"
129
+
130
+
131
+ def instant_key(text: str) -> int:
132
+ """Integer nanoseconds since the epoch of a canonical instant."""
133
+ match = _INSTANT_RE.fullmatch(text)
134
+ if match is None or match.group(8) != "Z":
135
+ raise ValueError("not a canonical instant")
136
+ seconds = calendar.timegm(tuple(int(match.group(i)) for i in range(1, 7)) + (0, 0, 0))
137
+ fraction = match.group(7) or ""
138
+ return seconds * 1_000_000_000 + int((fraction + "0" * 9)[:9])
139
+
140
+
141
+ def instant_precision(text: str) -> int:
142
+ """The number of fractional-second digits a canonical instant carries."""
143
+ match = _INSTANT_RE.fullmatch(text)
144
+ if match is None:
145
+ raise ValueError("not a canonical instant")
146
+ return len(match.group(7) or "")
147
+
148
+
149
+ # --- the witness codec ---------------------------------------------------------
150
+
151
+
152
+ _WITNESS_KEYS = frozenset({
153
+ "evidenceRevision", "parserRevision", "status", "instant", "reason",
154
+ "filesTotal", "filesExamined", "digest",
155
+ })
156
+
157
+
158
+ def witness_to_json(w: ActivityWitness) -> dict:
159
+ return {
160
+ "evidenceRevision": w.evidence_revision,
161
+ "parserRevision": w.parser_revision,
162
+ "status": w.status,
163
+ "instant": w.instant_utc,
164
+ "reason": w.reason,
165
+ "filesTotal": w.files_total,
166
+ "filesExamined": w.files_examined,
167
+ "digest": w.digest,
168
+ }
169
+
170
+
171
+ def _strict_int(value: object, *, minimum: int) -> bool:
172
+ return (isinstance(value, int) and not isinstance(value, bool)
173
+ and minimum <= value <= _COUNT_MAX)
174
+
175
+
176
+ def witness_from_json(doc: object) -> ActivityWitness:
177
+ """Validate one persisted witness; ``MalformedWitness`` on any missing,
178
+ extra or ill-typed field. Never defaults: ``unknown`` is only ever an
179
+ explicit capture result."""
180
+ if not isinstance(doc, dict) or set(doc) != _WITNESS_KEYS:
181
+ raise MalformedWitness("activity witness keys")
182
+ if not (_strict_int(doc["evidenceRevision"], minimum=1)
183
+ and _strict_int(doc["parserRevision"], minimum=1)
184
+ and _strict_int(doc["filesTotal"], minimum=0)
185
+ and _strict_int(doc["filesExamined"], minimum=0)):
186
+ raise MalformedWitness("activity witness counts")
187
+ if doc["filesExamined"] > doc["filesTotal"]:
188
+ raise MalformedWitness("activity witness coverage")
189
+ if not isinstance(doc["digest"], str) or _DIGEST_RE.fullmatch(doc["digest"]) is None:
190
+ raise MalformedWitness("activity witness digest")
191
+ status, instant, reason = doc["status"], doc["instant"], doc["reason"]
192
+ if status == "known":
193
+ if (not isinstance(instant, str) or _CANONICAL_RE.fullmatch(instant) is None
194
+ or canonical_instant(instant) != instant or reason is not None):
195
+ raise MalformedWitness("known activity witness")
196
+ elif status == "unknown":
197
+ if instant is not None or reason not in UNKNOWN_REASONS:
198
+ raise MalformedWitness("unknown activity witness")
199
+ else:
200
+ raise MalformedWitness("activity witness status")
201
+ return ActivityWitness(
202
+ evidence_revision=doc["evidenceRevision"],
203
+ parser_revision=doc["parserRevision"],
204
+ status=status, instant_utc=instant, reason=reason,
205
+ files_total=doc["filesTotal"], files_examined=doc["filesExamined"],
206
+ digest=doc["digest"],
207
+ )
208
+
209
+
210
+ # --- the per-file scanner ------------------------------------------------------
211
+
212
+
213
+ @dataclasses.dataclass(frozen=True)
214
+ class _Scan:
215
+ status: str # _KNOWN, _NONE or _UNKNOWN
216
+ instant: str | None = None
217
+ reason: str | None = None
218
+ #: The offset just past the last complete line inside the captured prefix
219
+ #: (None when the scan never established it).
220
+ lines_end: int | None = None
221
+
222
+
223
+ def _open_file(path: str) -> int:
224
+ # O_NONBLOCK: a FIFO at the path must never block a render.
225
+ return os.open(path, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
226
+
227
+
228
+ _QUALIFYING, _SKIP, _MALFORMED = "qualifying", "skip", "malformed"
229
+
230
+
231
+ def _classify_line(line: bytes) -> tuple[str, str | None]:
232
+ text = line.strip()
233
+ if not text:
234
+ return _SKIP, None
235
+ # A record that is (or could be) an assistant entry spells "assistant",
236
+ # literally or through a JSON escape; anything else cannot qualify even
237
+ # when it does not parse.
238
+ if b"assistant" not in text and b"\\u" not in text:
239
+ return _SKIP, None
240
+ try:
241
+ record = json.loads(text)
242
+ except (ValueError, UnicodeDecodeError):
243
+ return _MALFORMED, None
244
+ if not isinstance(record, dict):
245
+ return _MALFORMED, None
246
+ if record.get("type") != "assistant":
247
+ return _SKIP, None
248
+ request_id = record.get("requestId")
249
+ if request_id is None or request_id == "":
250
+ return _SKIP, None
251
+ if not isinstance(request_id, str):
252
+ return _MALFORMED, None
253
+ message = record.get("message")
254
+ if isinstance(message, dict) and message.get("model") == "<synthetic>":
255
+ return _SKIP, None
256
+ instant = canonical_instant(record.get("timestamp"))
257
+ if instant is None:
258
+ return _MALFORMED, None
259
+ return _QUALIFYING, instant
260
+
261
+
262
+ def _scan_region(fd: int, start: int, end: int) -> _Scan:
263
+ """Scan ``[start, end)`` backward for its last qualifying record. ``start``
264
+ is a line boundary (0, or a cached prefix's ``lines_end``); at most
265
+ ``MAX_FILE_BYTES`` are read."""
266
+ if end <= start:
267
+ return _Scan(_NONE, lines_end=start)
268
+ budget = MAX_FILE_BYTES
269
+ pos = end
270
+ carry = b""
271
+ lines_end = None
272
+ while pos > start:
273
+ if budget <= 0:
274
+ return _Scan(_UNKNOWN, reason="scan-exhausted", lines_end=lines_end)
275
+ size = min(CHUNK_BYTES, pos - start, budget)
276
+ pos -= size
277
+ budget -= size
278
+ try:
279
+ chunk = os.pread(fd, size, pos)
280
+ except OSError:
281
+ return _Scan(_UNKNOWN, reason="unreadable")
282
+ if len(chunk) != size:
283
+ return _Scan(_UNKNOWN, reason="unreadable")
284
+ data = chunk + carry
285
+ if lines_end is None:
286
+ newline = data.rfind(b"\n")
287
+ if newline < 0:
288
+ if pos > start:
289
+ carry = data
290
+ continue
291
+ if data.strip(): # one unfinished record
292
+ return _Scan(_UNKNOWN, reason="malformed", lines_end=start)
293
+ return _Scan(_NONE, lines_end=start)
294
+ lines_end = pos + newline + 1
295
+ if data[newline + 1:].strip(): # an unfinished final record
296
+ return _Scan(_UNKNOWN, reason="malformed", lines_end=lines_end)
297
+ data = data[:newline + 1]
298
+ pieces = data.split(b"\n")
299
+ pieces.pop() # ``data`` ends with a newline
300
+ if pos > start:
301
+ carry = pieces.pop(0) if pieces else b""
302
+ else:
303
+ carry = b""
304
+ for line in reversed(pieces):
305
+ kind, instant = _classify_line(line)
306
+ if kind == _QUALIFYING:
307
+ return _Scan(_KNOWN, instant=instant, lines_end=lines_end)
308
+ if kind == _MALFORMED:
309
+ return _Scan(_UNKNOWN, reason="malformed", lines_end=lines_end)
310
+ if lines_end is None:
311
+ lines_end = start
312
+ return _Scan(_NONE, lines_end=lines_end)
313
+
314
+
315
+ # --- the scan cache --------------------------------------------------------------
316
+
317
+
318
+ _RECORD_KEYS = frozenset({
319
+ "device", "inode", "size", "mtimeNs", "linesEnd", "status", "instant", "reason"})
320
+ _MAIN_KEYS = _RECORD_KEYS | {"pathDigest"}
321
+ _CACHE_KEYS = frozenset({"schemaVersion", "parserRevision", "main", "subagents", "cursor",
322
+ "observedInstant"})
323
+
324
+
325
+ @dataclasses.dataclass(frozen=True)
326
+ class _Record:
327
+ key: tuple[int, int, int, int] # device, inode, captured size, mtime_ns
328
+ scan: _Scan
329
+
330
+ def document(self) -> dict:
331
+ device, inode, size, mtime_ns = self.key
332
+ return {"device": device, "inode": inode, "size": size, "mtimeNs": mtime_ns,
333
+ "linesEnd": self.scan.lines_end, "status": self.scan.status,
334
+ "instant": self.scan.instant, "reason": self.scan.reason}
335
+
336
+
337
+ class _InvalidCache(ValueError):
338
+ pass
339
+
340
+
341
+ def _record_from(doc: object, keys: frozenset) -> _Record:
342
+ if not isinstance(doc, dict) or set(doc) != keys:
343
+ raise _InvalidCache("record keys")
344
+ numbers = [doc[k] for k in ("device", "inode", "size", "mtimeNs")]
345
+ if not all(isinstance(n, int) and not isinstance(n, bool) and n >= 0 for n in numbers):
346
+ raise _InvalidCache("record key")
347
+ lines_end = doc["linesEnd"]
348
+ if lines_end is not None and not (
349
+ isinstance(lines_end, int) and not isinstance(lines_end, bool)
350
+ and 0 <= lines_end <= doc["size"]):
351
+ raise _InvalidCache("record lines end")
352
+ status, instant, reason = doc["status"], doc["instant"], doc["reason"]
353
+ if status == _KNOWN:
354
+ if (not isinstance(instant, str) or canonical_instant(instant) != instant
355
+ or reason is not None or lines_end is None):
356
+ raise _InvalidCache("known record")
357
+ elif status == _NONE:
358
+ if instant is not None or reason is not None or lines_end is None:
359
+ raise _InvalidCache("none record")
360
+ elif status == _UNKNOWN:
361
+ if instant is not None or reason not in UNKNOWN_REASONS:
362
+ raise _InvalidCache("unknown record")
363
+ else:
364
+ raise _InvalidCache("record status")
365
+ return _Record(tuple(numbers), _Scan(status, instant, reason, lines_end))
366
+
367
+
368
+ @dataclasses.dataclass
369
+ class _Cache:
370
+ main: _Record | None = None
371
+ main_path_digest: str | None = None
372
+ subagents: dict = dataclasses.field(default_factory=dict)
373
+ cursor: str | None = None
374
+ #: The latest qualifying instant any earlier arrival of this transcript
375
+ #: path found in any of its files (F-I3-1). ``t_m`` is an upper bound on
376
+ #: the input's sample instant, so a response already observed keeps
377
+ #: contributing after its file leaves the listing or is replaced: a
378
+ #: deletion never lowers ``t_m``.
379
+ observed_instant: str | None = None
380
+
381
+
382
+ def cache_lock_path(cache_path) -> pathlib.Path:
383
+ path = pathlib.Path(cache_path)
384
+ return path.with_name(path.name + ".lock")
385
+
386
+
387
+ def _load_cache(cache_path: pathlib.Path) -> tuple[_Cache, bool]:
388
+ """The cache and whether it was valid. An absent cache is a valid, empty
389
+ one (it cannot be told apart from a first arrival); a cache written
390
+ under another parser revision is a miss, not invalid data."""
391
+ try:
392
+ with open(cache_path, "rb") as fh:
393
+ raw = fh.read(CACHE_DOCUMENT_MAX_BYTES + 1)
394
+ except FileNotFoundError:
395
+ return _Cache(), True
396
+ except OSError:
397
+ return _Cache(), False
398
+ try:
399
+ if len(raw) > CACHE_DOCUMENT_MAX_BYTES:
400
+ raise _InvalidCache("cache size")
401
+ doc = json.loads(raw)
402
+ if not isinstance(doc, dict) or set(doc) != _CACHE_KEYS:
403
+ raise _InvalidCache("cache keys")
404
+ if doc["schemaVersion"] != CACHE_SCHEMA_VERSION:
405
+ raise _InvalidCache("cache schema")
406
+ revision = doc["parserRevision"]
407
+ if not isinstance(revision, int) or isinstance(revision, bool):
408
+ raise _InvalidCache("cache revision")
409
+ if revision != PARSER_REVISION:
410
+ return _Cache(), True
411
+ cache = _Cache()
412
+ if doc["main"] is not None:
413
+ main = doc["main"]
414
+ if not isinstance(main, dict):
415
+ raise _InvalidCache("main record")
416
+ digest = main.get("pathDigest")
417
+ if (set(main) != _MAIN_KEYS or not isinstance(digest, str)
418
+ or _DIGEST_RE.fullmatch(digest) is None):
419
+ raise _InvalidCache("main record")
420
+ cache.main = _record_from(
421
+ {k: v for k, v in main.items() if k != "pathDigest"}, _RECORD_KEYS)
422
+ cache.main_path_digest = digest
423
+ subagents = doc["subagents"]
424
+ if not isinstance(subagents, dict) or len(subagents) > MAX_INVENTORY:
425
+ raise _InvalidCache("subagent records")
426
+ for name, record in subagents.items():
427
+ if not _is_subagent_name(name):
428
+ raise _InvalidCache("subagent name")
429
+ cache.subagents[name] = _record_from(record, _RECORD_KEYS)
430
+ cursor = doc["cursor"]
431
+ if cursor is not None and not (isinstance(cursor, str) and _is_subagent_name(cursor)):
432
+ raise _InvalidCache("cursor")
433
+ cache.cursor = cursor
434
+ observed = doc["observedInstant"]
435
+ if observed is not None and (not isinstance(observed, str)
436
+ or canonical_instant(observed) != observed):
437
+ raise _InvalidCache("observed instant")
438
+ cache.observed_instant = observed
439
+ return cache, True
440
+ except (_InvalidCache, ValueError, TypeError, KeyError):
441
+ return _Cache(), False
442
+
443
+
444
+ def _cache_document(cache: _Cache) -> dict:
445
+ main = None
446
+ if cache.main is not None:
447
+ main = dict(cache.main.document(), pathDigest=cache.main_path_digest)
448
+ return {
449
+ "schemaVersion": CACHE_SCHEMA_VERSION,
450
+ "parserRevision": PARSER_REVISION,
451
+ "main": main,
452
+ "subagents": {name: cache.subagents[name].document()
453
+ for name in sorted(cache.subagents)},
454
+ "cursor": cache.cursor,
455
+ "observedInstant": cache.observed_instant,
456
+ }
457
+
458
+
459
+ def _store_cache(cache_path: pathlib.Path, document: dict) -> None:
460
+ """Atomic, unsynced, best-effort: the cache is never authoritative."""
461
+ data = json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8")
462
+ if len(data) > CACHE_DOCUMENT_MAX_BYTES:
463
+ return
464
+ temp = cache_path.with_name(f".{cache_path.name}.{os.getpid()}.tmp")
465
+ try:
466
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
467
+ try:
468
+ view = memoryview(data)
469
+ while view:
470
+ view = view[os.write(fd, view):]
471
+ finally:
472
+ os.close(fd)
473
+ os.replace(temp, cache_path)
474
+ except OSError:
475
+ try:
476
+ os.unlink(temp)
477
+ except OSError:
478
+ pass
479
+
480
+
481
+ def _lock_cache(cache_path: pathlib.Path) -> int | None:
482
+ """The contributor's cache lock, non-blocking; None when another arrival
483
+ holds it or it cannot be taken (contention degrades to unknown)."""
484
+ try:
485
+ cache_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
486
+ fd = os.open(cache_lock_path(cache_path),
487
+ os.O_WRONLY | os.O_CREAT | getattr(os, "O_CLOEXEC", 0), 0o600)
488
+ except OSError:
489
+ return None
490
+ try:
491
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
492
+ except OSError:
493
+ os.close(fd)
494
+ return None
495
+ return fd
496
+
497
+
498
+ def _unlock_cache(fd: int) -> None:
499
+ try:
500
+ fcntl.flock(fd, fcntl.LOCK_UN)
501
+ except OSError:
502
+ pass
503
+ try:
504
+ os.close(fd)
505
+ except OSError:
506
+ pass
507
+
508
+
509
+ # --- inventory -------------------------------------------------------------------
510
+
511
+
512
+ def _is_subagent_name(name: object) -> bool:
513
+ return (isinstance(name, str) and name.startswith("agent-") and name.endswith(".jsonl")
514
+ and len(name) > len("agent-.jsonl") and "/" not in name and "\0" not in name)
515
+
516
+
517
+ def _subagent_dir(transcript_path: str) -> str:
518
+ base = transcript_path[:-len(".jsonl")] if transcript_path.endswith(".jsonl") else transcript_path
519
+ return os.path.join(base, "subagents")
520
+
521
+
522
+ def _list_subagents(directory: str) -> tuple[tuple[str, ...], bool]:
523
+ """The subagent transcript names and whether the listing reached a
524
+ confirmed end of directory within ``MAX_INVENTORY`` entries. An absent
525
+ directory is a complete, empty inventory."""
526
+ names = []
527
+ try:
528
+ with os.scandir(directory) as entries:
529
+ for entry in entries:
530
+ if not _is_subagent_name(entry.name):
531
+ continue
532
+ if len(names) >= MAX_INVENTORY:
533
+ return tuple(sorted(names)), False
534
+ names.append(entry.name)
535
+ except (FileNotFoundError, NotADirectoryError):
536
+ return (), True
537
+ except OSError:
538
+ return (), False
539
+ return tuple(sorted(names)), True
540
+
541
+
542
+ # --- one file ------------------------------------------------------------------
543
+
544
+
545
+ def _stat_key(path: str) -> tuple[int, int, int, int] | str:
546
+ """The path's current incarnation key, or a reason it has none."""
547
+ try:
548
+ st = os.stat(path)
549
+ except FileNotFoundError:
550
+ return "missing"
551
+ except OSError:
552
+ return "unreadable"
553
+ if not stat.S_ISREG(st.st_mode):
554
+ return "unreadable"
555
+ return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
556
+
557
+
558
+ def _changed(cached: _Record, key: tuple) -> _Scan | None:
559
+ """None when ``key`` may extend ``cached`` (the same incarnation grown or
560
+ touched in place), else the unknown result of a replacement or
561
+ shrinkage."""
562
+ if (cached.key[0], cached.key[1]) != (key[0], key[1]) or key[2] < cached.key[2]:
563
+ return _Scan(_UNKNOWN, reason="coverage-incomplete")
564
+ return None
565
+
566
+
567
+ def _examine(path: str, cached: _Record | None) -> tuple[_Record | None, _Scan]:
568
+ """Scan one file through its opened descriptor, extending ``cached``
569
+ when this incarnation only grew. Returns the record to cache (None when
570
+ the file has no incarnation) and this arrival's result."""
571
+ try:
572
+ fd = _open_file(path)
573
+ except FileNotFoundError:
574
+ return None, _Scan(_UNKNOWN, reason="missing")
575
+ except OSError:
576
+ return None, _Scan(_UNKNOWN, reason="unreadable")
577
+ try:
578
+ try:
579
+ st = os.fstat(fd)
580
+ except OSError:
581
+ return None, _Scan(_UNKNOWN, reason="unreadable")
582
+ if not stat.S_ISREG(st.st_mode):
583
+ return None, _Scan(_UNKNOWN, reason="unreadable")
584
+ key = (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
585
+ if cached is not None:
586
+ if cached.key == key:
587
+ return cached, cached.scan
588
+ refused = _changed(cached, key)
589
+ if refused is not None:
590
+ return _Record(key, refused), refused
591
+ prefix = cached.scan
592
+ if prefix.lines_end is not None and prefix.reason != "unreadable":
593
+ suffix = _scan_region(fd, prefix.lines_end, key[2])
594
+ if suffix.status == _NONE:
595
+ result = dataclasses.replace(prefix, lines_end=suffix.lines_end)
596
+ else:
597
+ result = suffix
598
+ return _Record(key, result), result
599
+ result = _scan_region(fd, 0, key[2])
600
+ return _Record(key, result), result
601
+ finally:
602
+ os.close(fd)
603
+
604
+
605
+ # --- capture ----------------------------------------------------------------------
606
+
607
+
608
+ def _digest(entries: list, inventory_complete: bool, cache_valid: bool) -> str:
609
+ return hashlib.sha256(json.dumps({
610
+ "evidenceRevision": EVIDENCE_REVISION,
611
+ "parserRevision": PARSER_REVISION,
612
+ "inventoryComplete": inventory_complete,
613
+ "cacheValid": cache_valid,
614
+ "files": entries,
615
+ }, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
616
+
617
+
618
+ def _entry(name: str, key, scan: _Scan) -> list:
619
+ key = list(key) if isinstance(key, tuple) else [None, None, None, None]
620
+ return [name, *key, scan.status, scan.instant, scan.reason]
621
+
622
+
623
+ def _witness(status, instant, reason, total, examined, digest) -> ActivityWitness:
624
+ return ActivityWitness(
625
+ evidence_revision=EVIDENCE_REVISION, parser_revision=PARSER_REVISION,
626
+ status=status, instant_utc=instant, reason=reason,
627
+ files_total=total, files_examined=examined, digest=digest)
628
+
629
+
630
+ def capture_activity(transcript_path: str | None, *, cache_path: pathlib.Path) -> ActivityWitness:
631
+ """Capture one arrival's provider-activity evidence (spec 4.1). Called
632
+ once per arrival, before the intake commit and outside the intake
633
+ critical section; never raises for anything a transcript or the cache
634
+ can contain."""
635
+ if not isinstance(transcript_path, str) or not transcript_path:
636
+ return _witness("unknown", None, "no-transcript-path", 0, 0, _digest([], True, True))
637
+ try:
638
+ return _capture_locked(transcript_path, pathlib.Path(cache_path))
639
+ except Exception:
640
+ # Every transcript and cache condition is classified above; anything
641
+ # else is still an explicit, eligible unknown capture, never a
642
+ # failed render.
643
+ return _witness("unknown", None, "unreadable", 0, 0, _digest([], False, False))
644
+
645
+
646
+ def _capture_locked(transcript_path: str, cache_path: pathlib.Path) -> ActivityWitness:
647
+ lock = _lock_cache(cache_path)
648
+ try:
649
+ if lock is None:
650
+ cache, cache_valid, contended = _Cache(), True, True
651
+ else:
652
+ (cache, cache_valid), contended = _load_cache(cache_path), False
653
+ before = None if contended else _cache_document(cache)
654
+ result = _capture(transcript_path, cache, cache_valid=cache_valid)
655
+ if lock is not None:
656
+ after = _cache_document(cache)
657
+ if after != before or not cache_valid:
658
+ _store_cache(cache_path, after)
659
+ return result
660
+ finally:
661
+ if lock is not None:
662
+ _unlock_cache(lock)
663
+
664
+
665
+ def _latest(instants) -> str:
666
+ return max(instants, key=lambda s: (instant_key(s), instant_precision(s), s))
667
+
668
+
669
+ def _latest_or_none(instants) -> str | None:
670
+ instants = [i for i in instants if i is not None]
671
+ return _latest(instants) if instants else None
672
+
673
+
674
+ def _capture(transcript_path: str, cache: _Cache, *, cache_valid: bool) -> ActivityWitness:
675
+ path_digest = hashlib.sha256(transcript_path.encode("utf-8", "surrogatepass")).hexdigest()
676
+ if cache.main_path_digest != path_digest:
677
+ # Another transcript path: every cached record (the subagent ones
678
+ # name files of another directory) and the cursor are someone else's.
679
+ cache.main, cache.subagents, cache.cursor = None, {}, None
680
+ cache.observed_instant = None
681
+ # Fold every instant earlier arrivals cached before this arrival may
682
+ # drop or replace a record (F-I3-1).
683
+ cache.observed_instant = _latest_or_none(
684
+ [cache.observed_instant]
685
+ + [record.scan.instant for record in [cache.main, *cache.subagents.values()]
686
+ if record is not None and record.scan.status == _KNOWN])
687
+ cached_main = cache.main
688
+ main_key = _stat_key(transcript_path)
689
+ if cached_main is not None and main_key == cached_main.key:
690
+ record, main = cached_main, cached_main.scan
691
+ else:
692
+ record, main = _examine(transcript_path, cached_main)
693
+ cache.main, cache.main_path_digest = record, (None if record is None else path_digest)
694
+ entries = [_entry(_MAIN, None if record is None else record.key, main)]
695
+ if main.status == _UNKNOWN:
696
+ return _witness("unknown", None, main.reason, 1, 1,
697
+ _digest(entries, True, cache_valid))
698
+
699
+ directory = _subagent_dir(transcript_path)
700
+ names, inventory_complete = _list_subagents(directory)
701
+ listed = set(names)
702
+ vanished = inventory_complete and any(name not in listed for name in cache.subagents)
703
+ if inventory_complete:
704
+ for name in [n for n in cache.subagents if n not in listed]:
705
+ del cache.subagents[name]
706
+ results: dict[str, tuple[object, _Scan]] = {}
707
+ work = []
708
+ for name in names:
709
+ path = os.path.join(directory, name)
710
+ key = _stat_key(path)
711
+ cached = cache.subagents.get(name)
712
+ if isinstance(key, str):
713
+ # Listed, but gone or unreadable when examined: a membership
714
+ # change this arrival cannot resolve, or an unreadable member.
715
+ reason = "coverage-incomplete" if key == "missing" else key
716
+ cache.subagents.pop(name, None)
717
+ results[name] = (None, _Scan(_UNKNOWN, reason=reason))
718
+ continue
719
+ if cached is not None and cached.key == key:
720
+ results[name] = (key, cached.scan)
721
+ continue
722
+ if cached is not None:
723
+ refused = _changed(cached, key)
724
+ if refused is not None:
725
+ cache.subagents[name] = _Record(key, refused)
726
+ results[name] = (key, refused)
727
+ continue
728
+ work.append(name)
729
+ # Fair rotation: continue after the persisted cursor, wrapping around.
730
+ if cache.cursor is not None:
731
+ work = ([n for n in work if n > cache.cursor] + [n for n in work if n <= cache.cursor])
732
+ for name in work[:ARRIVAL_FILE_BUDGET]:
733
+ record, scan = _examine(os.path.join(directory, name), cache.subagents.get(name))
734
+ if record is None:
735
+ cache.subagents.pop(name, None)
736
+ if scan.reason == "missing":
737
+ scan = _Scan(_UNKNOWN, reason="coverage-incomplete")
738
+ else:
739
+ cache.subagents[name] = record
740
+ results[name] = (None if record is None else record.key, scan)
741
+ cache.cursor = name
742
+ for name in work[ARRIVAL_FILE_BUDGET:]:
743
+ results[name] = (None, _Scan(_UNEXAMINED))
744
+
745
+ for name in names:
746
+ key, scan = results[name]
747
+ entries.append(_entry(name, key, scan))
748
+ total = 1 + len(names)
749
+ examined = total - sum(1 for _key, scan in results.values() if scan.status == _UNEXAMINED)
750
+ digest = _digest(entries, inventory_complete, cache_valid)
751
+
752
+ def unknown(reason):
753
+ return _witness("unknown", None, reason, total, examined, digest)
754
+
755
+ if not inventory_complete or vanished:
756
+ return unknown("coverage-incomplete")
757
+ for name in names:
758
+ scan = results[name][1]
759
+ if scan.status == _UNKNOWN:
760
+ return unknown(scan.reason)
761
+ if examined < total or not cache_valid:
762
+ return unknown("coverage-incomplete")
763
+ instants = [scan.instant for _key, scan in [(None, main)] + list(results.values())
764
+ if scan.status == _KNOWN]
765
+ if cache.observed_instant is not None:
766
+ instants.append(cache.observed_instant)
767
+ if not instants:
768
+ return unknown("no-qualifying-record")
769
+ latest = _latest(instants)
770
+ return _witness("known", latest, None, total, examined, digest)