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.
- package/CHANGELOG.md +41 -0
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +4491 -295
- package/bin/_cctally_completion.py +1355 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +282 -9
- package/bin/_cctally_dashboard.py +8 -11
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +42 -35
- package/bin/_cctally_dashboard_sources.py +18 -9
- package/bin/_cctally_db.py +401 -9
- package/bin/_cctally_doctor.py +469 -27
- package/bin/_cctally_five_hour.py +166 -1049
- package/bin/_cctally_forecast.py +81 -9
- package/bin/_cctally_journal.py +5076 -177
- package/bin/_cctally_journal_repair.py +110 -19
- package/bin/_cctally_oauth_samples.py +280 -0
- package/bin/_cctally_parser.py +47 -0
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +662 -0
- package/bin/_cctally_quota.py +96 -12
- package/bin/_cctally_quota_model.py +391 -83
- package/bin/_cctally_record.py +1088 -269
- package/bin/_cctally_rederive.py +854 -192
- package/bin/_cctally_refresh.py +113 -13
- package/bin/_cctally_source_analytics.py +23 -2
- package/bin/_cctally_statusline.py +7062 -505
- package/bin/_cctally_store.py +1244 -91
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +119 -60
- package/bin/_cctally_update.py +25 -0
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +569 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +177 -0
- package/bin/_lib_codex_conversation_query.py +133 -19
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +11 -3
- package/bin/_lib_dashboard_sources.py +57 -15
- package/bin/_lib_detector_access_registry.py +4038 -0
- package/bin/_lib_doctor.py +753 -1
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +261 -10
- package/bin/_lib_journal_router.py +166 -48
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_oauth_samples.py +238 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_pricing.py +7 -2
- package/bin/_lib_pricing_check.py +0 -12
- package/bin/_lib_provider_activity.py +770 -0
- package/bin/_lib_quarantine.py +155 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +384 -52
- package/bin/_lib_rebuild_progress.py +146 -0
- package/bin/_lib_record.py +22 -0
- package/bin/_lib_rederive.py +393 -8
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_snapshot_cache.py +43 -1
- package/bin/_lib_stale_proof.py +708 -0
- package/bin/_lib_statusline_candidates.py +356 -27
- package/bin/_lib_worker_launches.py +315 -0
- package/bin/cctally +29 -12
- package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
- package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
- package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
- package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
- package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +20 -2
- package/bin/_lib_merge_gate.py +0 -725
- 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)
|