byte-bot 0.1.0__py3-none-any.whl
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.
- byte_bot/__init__.py +3 -0
- byte_bot/__main__.py +3 -0
- byte_bot/adapters/__init__.py +16 -0
- byte_bot/adapters/codex_exec_jsonl.py +325 -0
- byte_bot/adapters/legacy.py +506 -0
- byte_bot/adapters/opencode_http.py +968 -0
- byte_bot/app.py +1965 -0
- byte_bot/cli.py +2518 -0
- byte_bot/config.py +1097 -0
- byte_bot/demo.py +457 -0
- byte_bot/disclosure.py +212 -0
- byte_bot/discovery.py +303 -0
- byte_bot/doctor.py +623 -0
- byte_bot/http_deadline.py +198 -0
- byte_bot/journal_bridge.py +1382 -0
- byte_bot/migration.py +420 -0
- byte_bot/mobile.py +479 -0
- byte_bot/model.py +1248 -0
- byte_bot/notifications.py +1130 -0
- byte_bot/onboarding.py +282 -0
- byte_bot/platform_migration.py +228 -0
- byte_bot/presentation.py +495 -0
- byte_bot/projects.py +934 -0
- byte_bot/repository.py +151 -0
- byte_bot/service_manager.py +240 -0
- byte_bot/state.py +901 -0
- byte_bot/storage.py +443 -0
- byte_bot/structured_source.py +613 -0
- byte_bot/systemd_unit.py +227 -0
- byte_bot/telegram.py +634 -0
- byte_bot/telegram_setup.py +228 -0
- byte_bot/tui.py +738 -0
- byte_bot/updater.py +226 -0
- byte_bot-0.1.0.dist-info/METADATA +116 -0
- byte_bot-0.1.0.dist-info/RECORD +38 -0
- byte_bot-0.1.0.dist-info/WHEEL +4 -0
- byte_bot-0.1.0.dist-info/entry_points.txt +2 -0
- byte_bot-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Static legacy configured-command observation bridge (B12/B13).
|
|
2
|
+
|
|
3
|
+
Uses only the existing bounded executor in :mod:`byte_bot.projects`.
|
|
4
|
+
Opaque command text stays opaque: it is exposed solely as bounded legacy
|
|
5
|
+
content and never parsed into Sessions, lifecycle, checks, blockers,
|
|
6
|
+
events or success. Batches explicitly carry health, completeness and
|
|
7
|
+
capabilities, and :func:`batches_to_snapshot` projects them into the
|
|
8
|
+
normalized :class:`Snapshot` source-health representation with zero typed
|
|
9
|
+
entities. Exact local argv and per-project timeout overrides are
|
|
10
|
+
preserved. External use of opaque text is deny-by-default. No dynamic
|
|
11
|
+
imports and no transport code live in this module.
|
|
12
|
+
|
|
13
|
+
Only the documented ``docs/structured-source.md`` protocol (implemented
|
|
14
|
+
outside this module) may create typed normalized fields. This module
|
|
15
|
+
never parses command output as that protocol, even when the text happens
|
|
16
|
+
to look like JSON or contains lifecycle-like markers.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
|
|
25
|
+
from byte_bot.config import Config, ProjectConfig
|
|
26
|
+
from byte_bot.model import (
|
|
27
|
+
AdapterInfo,
|
|
28
|
+
CollectionError,
|
|
29
|
+
Machine,
|
|
30
|
+
Project,
|
|
31
|
+
Snapshot,
|
|
32
|
+
SourceHealth,
|
|
33
|
+
SourceStatus,
|
|
34
|
+
)
|
|
35
|
+
from byte_bot.projects import run_command
|
|
36
|
+
|
|
37
|
+
ADAPTER_NAME = "legacy-command"
|
|
38
|
+
ADAPTER_VERSION = "1"
|
|
39
|
+
|
|
40
|
+
LEGACY_VIEWS: tuple[str, ...] = ("status", "agents")
|
|
41
|
+
COMPAT_VIEWS: tuple[str, ...] = (
|
|
42
|
+
"status",
|
|
43
|
+
"agents",
|
|
44
|
+
"legacy-mobile-status",
|
|
45
|
+
"legacy-mobile-agents",
|
|
46
|
+
"journal",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
DESTINATION_ID = "telegram:default"
|
|
50
|
+
|
|
51
|
+
DEFAULT_LEGACY_INTERVAL_SECONDS = 30
|
|
52
|
+
COMPAT_ENTRY_MAX_BYTES = 32 * 1024
|
|
53
|
+
COMPAT_TOTAL_MAX_BYTES = 256 * 1024
|
|
54
|
+
|
|
55
|
+
AVAILABLE = "available"
|
|
56
|
+
UNAVAILABLE = "unavailable"
|
|
57
|
+
UNSUPPORTED = "unsupported"
|
|
58
|
+
ERROR = "error"
|
|
59
|
+
|
|
60
|
+
#: Static snapshot capability projection for legacy-command sources. Only
|
|
61
|
+
#: generic opaque-text and source-health capabilities are ever advertised
|
|
62
|
+
#: as available; typed normalized capabilities stay unsupported because
|
|
63
|
+
#: opaque output never yields typed entities.
|
|
64
|
+
SNAPSHOT_CAPABILITIES: tuple[str, ...] = ("legacy-text", "source-health")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def legacy_source_id(project_id: str) -> str:
|
|
68
|
+
"""Return the stable source id for a schema-1 project."""
|
|
69
|
+
return f"legacy:{project_id.casefold()}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Capability:
|
|
74
|
+
"""Per-field availability with a human-readable reason."""
|
|
75
|
+
|
|
76
|
+
name: str
|
|
77
|
+
availability: str
|
|
78
|
+
reason: str
|
|
79
|
+
source_version: str | None = ADAPTER_VERSION
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
if self.availability not in (AVAILABLE, UNAVAILABLE, UNSUPPORTED, ERROR):
|
|
83
|
+
raise ValueError(f"unknown availability {self.availability!r}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class LegacySource:
|
|
88
|
+
"""Exact local observation descriptor for one schema-1 project."""
|
|
89
|
+
|
|
90
|
+
source_id: str
|
|
91
|
+
project_id: str
|
|
92
|
+
status_argv: tuple[str, ...] = ()
|
|
93
|
+
agents_argv: tuple[str, ...] = ()
|
|
94
|
+
timeout_seconds: int = 6
|
|
95
|
+
interval_seconds: int = DEFAULT_LEGACY_INTERVAL_SECONDS
|
|
96
|
+
adapter_name: str = ADAPTER_NAME
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class LegacyDestination:
|
|
101
|
+
"""Optional pre-migration destination identity. No transport behavior."""
|
|
102
|
+
|
|
103
|
+
destination_id: str = DESTINATION_ID
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class LegacyDisclosurePolicy:
|
|
108
|
+
"""Deny-by-default external disclosure for opaque legacy content."""
|
|
109
|
+
|
|
110
|
+
allowed_sources: tuple[str, ...] = ()
|
|
111
|
+
|
|
112
|
+
def allows(self, source_id: str) -> bool:
|
|
113
|
+
return source_id in self.allowed_sources
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class RuntimeSettings:
|
|
118
|
+
"""In-memory schema-1 normalization: sources, destination, disclosure."""
|
|
119
|
+
|
|
120
|
+
sources: tuple[LegacySource, ...] = ()
|
|
121
|
+
destination: LegacyDestination | None = None
|
|
122
|
+
disclosure: LegacyDisclosurePolicy = field(default_factory=LegacyDisclosurePolicy)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class ObservationBatch:
|
|
127
|
+
"""One legacy observation. Typed entity lists are always empty."""
|
|
128
|
+
|
|
129
|
+
source_id: str
|
|
130
|
+
project_id: str
|
|
131
|
+
adapter_name: str
|
|
132
|
+
adapter_version: str
|
|
133
|
+
view: str
|
|
134
|
+
observed_at: datetime
|
|
135
|
+
complete: bool
|
|
136
|
+
health: SourceHealth
|
|
137
|
+
error: CollectionError | None
|
|
138
|
+
capabilities: tuple[Capability, ...]
|
|
139
|
+
sessions: tuple[object, ...] = ()
|
|
140
|
+
checks: tuple[object, ...] = ()
|
|
141
|
+
blockers: tuple[object, ...] = ()
|
|
142
|
+
events: tuple[object, ...] = ()
|
|
143
|
+
legacy_text: str | None = None
|
|
144
|
+
legacy_truncated: bool = False
|
|
145
|
+
argv: tuple[str, ...] = ()
|
|
146
|
+
timeout_seconds: int = 6
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_legacy_source(project: ProjectConfig, default_timeout: int) -> LegacySource:
|
|
150
|
+
"""Preserve exact local argv and the per-project timeout override."""
|
|
151
|
+
timeout = project.command_timeout_seconds or default_timeout
|
|
152
|
+
return LegacySource(
|
|
153
|
+
source_id=legacy_source_id(project.project_id),
|
|
154
|
+
project_id=project.project_id,
|
|
155
|
+
status_argv=tuple(project.status_command),
|
|
156
|
+
agents_argv=tuple(project.agents_command),
|
|
157
|
+
timeout_seconds=timeout,
|
|
158
|
+
interval_seconds=project.interval_seconds,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_runtime_settings(config: Config) -> RuntimeSettings:
|
|
163
|
+
"""Normalize schema-1 config into explicit source/destination/disclosure."""
|
|
164
|
+
sources = tuple(
|
|
165
|
+
build_legacy_source(project, config.command_timeout_seconds)
|
|
166
|
+
for project in config.projects
|
|
167
|
+
)
|
|
168
|
+
return RuntimeSettings(
|
|
169
|
+
sources=sources,
|
|
170
|
+
destination=LegacyDestination(),
|
|
171
|
+
disclosure=LegacyDisclosurePolicy(),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def is_external_opaque_allowed(policy: LegacyDisclosurePolicy, source_id: str) -> bool:
|
|
176
|
+
"""Deny-by-default gate for any opaque external disclosure."""
|
|
177
|
+
if not isinstance(policy, LegacyDisclosurePolicy):
|
|
178
|
+
return False
|
|
179
|
+
return policy.allows(source_id)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _default_capabilities(has_text: bool) -> tuple[Capability, ...]:
|
|
183
|
+
reason = "opaque legacy text never yields typed entities"
|
|
184
|
+
text_availability = AVAILABLE if has_text else UNAVAILABLE
|
|
185
|
+
text_reason = (
|
|
186
|
+
"bounded opaque legacy content" if has_text else "no successful observation"
|
|
187
|
+
)
|
|
188
|
+
return (
|
|
189
|
+
Capability("sessions", UNSUPPORTED, reason),
|
|
190
|
+
Capability("checks", UNSUPPORTED, reason),
|
|
191
|
+
Capability("blockers", UNSUPPORTED, reason),
|
|
192
|
+
Capability("structured-events", UNSUPPORTED, reason),
|
|
193
|
+
Capability("legacy-text", text_availability, text_reason),
|
|
194
|
+
Capability("source-health", AVAILABLE, "executor outcome recorded"),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _classify_failure(text: str) -> str:
|
|
199
|
+
lowered = text.lower()
|
|
200
|
+
if "timed out" in lowered:
|
|
201
|
+
return "timeout"
|
|
202
|
+
if "exceeded" in lowered and "kib" in lowered:
|
|
203
|
+
return "overflow"
|
|
204
|
+
if "not found" in lowered:
|
|
205
|
+
return "not_found"
|
|
206
|
+
if "no command configured" in lowered:
|
|
207
|
+
return "not_configured"
|
|
208
|
+
if "failed to start" in lowered:
|
|
209
|
+
return "start_failed"
|
|
210
|
+
if "invalid" in lowered:
|
|
211
|
+
return "invalid_command"
|
|
212
|
+
return "command_failed"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def observe_legacy(
|
|
216
|
+
source: LegacySource,
|
|
217
|
+
view: str = "status",
|
|
218
|
+
*,
|
|
219
|
+
now: datetime | None = None,
|
|
220
|
+
) -> ObservationBatch:
|
|
221
|
+
"""Run the exact configured argv and wrap the outcome in a batch.
|
|
222
|
+
|
|
223
|
+
Only ``status``/``agents`` views execute local commands. No marker or
|
|
224
|
+
substring in the output ever becomes a Session, check, blocker, event
|
|
225
|
+
or success signal.
|
|
226
|
+
"""
|
|
227
|
+
if view not in LEGACY_VIEWS:
|
|
228
|
+
raise ValueError(f"unknown legacy view {view!r}")
|
|
229
|
+
observed_at = now if now is not None else datetime.now(UTC)
|
|
230
|
+
argv = source.status_argv if view == "status" else source.agents_argv
|
|
231
|
+
if not argv:
|
|
232
|
+
health = SourceHealth(
|
|
233
|
+
source_id=source.source_id,
|
|
234
|
+
status=SourceStatus.ERROR,
|
|
235
|
+
detail="no command configured",
|
|
236
|
+
observed_at=observed_at,
|
|
237
|
+
)
|
|
238
|
+
return ObservationBatch(
|
|
239
|
+
source_id=source.source_id,
|
|
240
|
+
project_id=source.project_id,
|
|
241
|
+
adapter_name=ADAPTER_NAME,
|
|
242
|
+
adapter_version=ADAPTER_VERSION,
|
|
243
|
+
view=view,
|
|
244
|
+
observed_at=observed_at,
|
|
245
|
+
complete=False,
|
|
246
|
+
health=health,
|
|
247
|
+
error=CollectionError(
|
|
248
|
+
code="not_configured",
|
|
249
|
+
message="no command configured",
|
|
250
|
+
source_id=source.source_id,
|
|
251
|
+
),
|
|
252
|
+
capabilities=_default_capabilities(False),
|
|
253
|
+
argv=(),
|
|
254
|
+
timeout_seconds=source.timeout_seconds,
|
|
255
|
+
)
|
|
256
|
+
result = run_command(argv, source.timeout_seconds)
|
|
257
|
+
if result.ok:
|
|
258
|
+
health = SourceHealth(
|
|
259
|
+
source_id=source.source_id,
|
|
260
|
+
status=SourceStatus.OK,
|
|
261
|
+
detail="ok",
|
|
262
|
+
observed_at=observed_at,
|
|
263
|
+
)
|
|
264
|
+
return ObservationBatch(
|
|
265
|
+
source_id=source.source_id,
|
|
266
|
+
project_id=source.project_id,
|
|
267
|
+
adapter_name=ADAPTER_NAME,
|
|
268
|
+
adapter_version=ADAPTER_VERSION,
|
|
269
|
+
view=view,
|
|
270
|
+
observed_at=observed_at,
|
|
271
|
+
complete=True,
|
|
272
|
+
health=health,
|
|
273
|
+
error=None,
|
|
274
|
+
capabilities=_default_capabilities(True),
|
|
275
|
+
legacy_text=result.text,
|
|
276
|
+
legacy_truncated=False,
|
|
277
|
+
argv=tuple(argv),
|
|
278
|
+
timeout_seconds=source.timeout_seconds,
|
|
279
|
+
)
|
|
280
|
+
code = _classify_failure(result.text)
|
|
281
|
+
truncated = code == "overflow"
|
|
282
|
+
health = SourceHealth(
|
|
283
|
+
source_id=source.source_id,
|
|
284
|
+
status=SourceStatus.ERROR,
|
|
285
|
+
detail=result.text[:800],
|
|
286
|
+
observed_at=observed_at,
|
|
287
|
+
)
|
|
288
|
+
return ObservationBatch(
|
|
289
|
+
source_id=source.source_id,
|
|
290
|
+
project_id=source.project_id,
|
|
291
|
+
adapter_name=ADAPTER_NAME,
|
|
292
|
+
adapter_version=ADAPTER_VERSION,
|
|
293
|
+
view=view,
|
|
294
|
+
observed_at=observed_at,
|
|
295
|
+
complete=False,
|
|
296
|
+
health=health,
|
|
297
|
+
error=CollectionError(
|
|
298
|
+
code=code,
|
|
299
|
+
message=result.text[:800],
|
|
300
|
+
source_id=source.source_id,
|
|
301
|
+
),
|
|
302
|
+
capabilities=_default_capabilities(False),
|
|
303
|
+
legacy_text=None,
|
|
304
|
+
legacy_truncated=truncated,
|
|
305
|
+
argv=tuple(argv),
|
|
306
|
+
timeout_seconds=source.timeout_seconds,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def batches_to_snapshot(
|
|
311
|
+
machine: Machine,
|
|
312
|
+
batches: tuple[ObservationBatch, ...] | list[ObservationBatch],
|
|
313
|
+
*,
|
|
314
|
+
generated_at: datetime | None = None,
|
|
315
|
+
projects: tuple[Project, ...] = (),
|
|
316
|
+
) -> Snapshot:
|
|
317
|
+
"""Project legacy batches into a normalized snapshot (B13).
|
|
318
|
+
|
|
319
|
+
Every source appears in ``source_health`` with its executor outcome;
|
|
320
|
+
failed batches additionally appear in ``errors``. Sessions, blockers,
|
|
321
|
+
checks and events are always empty: opaque ``legacy_text`` is never
|
|
322
|
+
parsed, so marker-rich output cannot fabricate typed entities and the
|
|
323
|
+
raw text never enters snapshot JSON. Adapter entries advertise only
|
|
324
|
+
the generic ``SNAPSHOT_CAPABILITIES``.
|
|
325
|
+
"""
|
|
326
|
+
moment = generated_at if generated_at is not None else datetime.now(UTC)
|
|
327
|
+
adapters: dict[str, AdapterInfo] = {}
|
|
328
|
+
health: dict[str, SourceHealth] = {}
|
|
329
|
+
errors: list[CollectionError] = []
|
|
330
|
+
for batch in batches:
|
|
331
|
+
if batch.source_id not in adapters:
|
|
332
|
+
adapters[batch.source_id] = AdapterInfo(
|
|
333
|
+
name=batch.adapter_name,
|
|
334
|
+
instance_id=batch.source_id,
|
|
335
|
+
version=batch.adapter_version,
|
|
336
|
+
capabilities=SNAPSHOT_CAPABILITIES,
|
|
337
|
+
provider=None,
|
|
338
|
+
)
|
|
339
|
+
# Last batch per source wins for health; errors accumulate.
|
|
340
|
+
health[batch.source_id] = batch.health
|
|
341
|
+
if batch.error is not None:
|
|
342
|
+
errors.append(batch.error)
|
|
343
|
+
ordered_errors = sorted(
|
|
344
|
+
errors, key=lambda e: (e.code, e.source_id or "", e.message)
|
|
345
|
+
)
|
|
346
|
+
return Snapshot(
|
|
347
|
+
machine=machine,
|
|
348
|
+
generated_at=moment,
|
|
349
|
+
projects=tuple(projects),
|
|
350
|
+
adapters=tuple(sorted(adapters.values(), key=lambda a: a.instance_id)),
|
|
351
|
+
sessions=(),
|
|
352
|
+
blockers=(),
|
|
353
|
+
checks=(),
|
|
354
|
+
events=(),
|
|
355
|
+
source_health=tuple(sorted(health.values(), key=lambda h: h.source_id)),
|
|
356
|
+
errors=tuple(ordered_errors),
|
|
357
|
+
warnings=(),
|
|
358
|
+
omitted_sessions=0,
|
|
359
|
+
omitted_events=0,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _truncate_entry_text(text: str) -> str:
|
|
364
|
+
raw = text.encode("utf-8")[:COMPAT_ENTRY_MAX_BYTES]
|
|
365
|
+
return raw.decode("utf-8", errors="ignore")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@dataclass
|
|
369
|
+
class CompatibilityEntry:
|
|
370
|
+
"""Bounded private opaque cache record. Never part of snapshot JSON."""
|
|
371
|
+
|
|
372
|
+
text: str
|
|
373
|
+
collected_at: float
|
|
374
|
+
expires_at: float
|
|
375
|
+
sensitivity: str = "opaque"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class CompatibilityCache:
|
|
379
|
+
"""Bounded newest-wins cache keyed by (source_id, view).
|
|
380
|
+
|
|
381
|
+
Limits: 32 KiB per entry, 256 KiB total with oldest-first eviction and
|
|
382
|
+
an explicit omission count. Entries expire after their TTL; expired or
|
|
383
|
+
missing entries report unavailable with no raw fallback.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
def __init__(self) -> None:
|
|
387
|
+
self._entries: dict[tuple[str, str], CompatibilityEntry] = {}
|
|
388
|
+
self.omitted = 0
|
|
389
|
+
|
|
390
|
+
def __len__(self) -> int:
|
|
391
|
+
return len(self._entries)
|
|
392
|
+
|
|
393
|
+
def to_dict(self) -> dict[str, object]:
|
|
394
|
+
"""Return a bounded JSON-safe persistence representation."""
|
|
395
|
+
entries = []
|
|
396
|
+
for (source_id, view), entry in sorted(self._entries.items()):
|
|
397
|
+
entries.append(
|
|
398
|
+
{
|
|
399
|
+
"source_id": source_id,
|
|
400
|
+
"view": view,
|
|
401
|
+
"text": entry.text,
|
|
402
|
+
"collected_at": entry.collected_at,
|
|
403
|
+
"expires_at": entry.expires_at,
|
|
404
|
+
"sensitivity": entry.sensitivity,
|
|
405
|
+
}
|
|
406
|
+
)
|
|
407
|
+
return {"schema": 1, "omitted": self.omitted, "entries": entries}
|
|
408
|
+
|
|
409
|
+
@classmethod
|
|
410
|
+
def from_dict(cls, raw: object) -> CompatibilityCache:
|
|
411
|
+
"""Restore a cache without trusting persisted sizes or field types."""
|
|
412
|
+
if not isinstance(raw, dict) or raw.get("schema", 1) != 1:
|
|
413
|
+
raise ValueError("invalid compatibility cache")
|
|
414
|
+
entries = raw.get("entries", [])
|
|
415
|
+
omitted = raw.get("omitted", 0)
|
|
416
|
+
if not isinstance(entries, list):
|
|
417
|
+
raise ValueError("compatibility entries must be an array")
|
|
418
|
+
if isinstance(omitted, bool) or not isinstance(omitted, int) or omitted < 0:
|
|
419
|
+
raise ValueError("compatibility omitted must be a non-negative integer")
|
|
420
|
+
|
|
421
|
+
cache = cls()
|
|
422
|
+
cache.omitted = omitted
|
|
423
|
+
for item in entries:
|
|
424
|
+
if not isinstance(item, dict):
|
|
425
|
+
raise ValueError("compatibility entry must be an object")
|
|
426
|
+
source_id = item.get("source_id")
|
|
427
|
+
view = item.get("view")
|
|
428
|
+
text = item.get("text")
|
|
429
|
+
collected_at = item.get("collected_at")
|
|
430
|
+
expires_at = item.get("expires_at")
|
|
431
|
+
sensitivity = item.get("sensitivity", "opaque")
|
|
432
|
+
if (
|
|
433
|
+
not isinstance(source_id, str)
|
|
434
|
+
or not source_id
|
|
435
|
+
or view not in COMPAT_VIEWS
|
|
436
|
+
or not isinstance(text, str)
|
|
437
|
+
or not isinstance(collected_at, (int, float))
|
|
438
|
+
or isinstance(collected_at, bool)
|
|
439
|
+
or not isinstance(expires_at, (int, float))
|
|
440
|
+
or isinstance(expires_at, bool)
|
|
441
|
+
or sensitivity != "opaque"
|
|
442
|
+
):
|
|
443
|
+
raise ValueError("invalid compatibility entry")
|
|
444
|
+
cache._entries[(source_id, view)] = CompatibilityEntry(
|
|
445
|
+
text=_truncate_entry_text(text),
|
|
446
|
+
collected_at=float(collected_at),
|
|
447
|
+
expires_at=float(expires_at),
|
|
448
|
+
)
|
|
449
|
+
while cache._total_bytes() > COMPAT_TOTAL_MAX_BYTES and cache._entries:
|
|
450
|
+
oldest = min(cache._entries, key=lambda key: cache._entries[key].collected_at)
|
|
451
|
+
del cache._entries[oldest]
|
|
452
|
+
cache.omitted += 1
|
|
453
|
+
return cache
|
|
454
|
+
|
|
455
|
+
def _total_bytes(self) -> int:
|
|
456
|
+
return sum(len(e.text.encode("utf-8")) for e in self._entries.values())
|
|
457
|
+
|
|
458
|
+
def update(
|
|
459
|
+
self,
|
|
460
|
+
batch: ObservationBatch,
|
|
461
|
+
*,
|
|
462
|
+
now: float | None = None,
|
|
463
|
+
ttl_seconds: float | None = None,
|
|
464
|
+
) -> None:
|
|
465
|
+
moment = time.time() if now is None else float(now)
|
|
466
|
+
ttl = (
|
|
467
|
+
float(3 * DEFAULT_LEGACY_INTERVAL_SECONDS)
|
|
468
|
+
if ttl_seconds is None
|
|
469
|
+
else float(ttl_seconds)
|
|
470
|
+
)
|
|
471
|
+
key = (batch.source_id, batch.view)
|
|
472
|
+
if batch.legacy_text is None or not batch.complete:
|
|
473
|
+
return
|
|
474
|
+
if key[1] not in COMPAT_VIEWS:
|
|
475
|
+
return
|
|
476
|
+
text = _truncate_entry_text(batch.legacy_text)
|
|
477
|
+
self._entries[key] = CompatibilityEntry(
|
|
478
|
+
text=text, collected_at=moment, expires_at=moment + ttl
|
|
479
|
+
)
|
|
480
|
+
while self._total_bytes() > COMPAT_TOTAL_MAX_BYTES and self._entries:
|
|
481
|
+
oldest = min(self._entries, key=lambda k: self._entries[k].collected_at)
|
|
482
|
+
del self._entries[oldest]
|
|
483
|
+
self.omitted += 1
|
|
484
|
+
|
|
485
|
+
def get_local(
|
|
486
|
+
self, source_id: str, view: str, *, now: float | None = None
|
|
487
|
+
) -> str | None:
|
|
488
|
+
"""Local legacy text view; None when missing or expired."""
|
|
489
|
+
moment = time.time() if now is None else float(now)
|
|
490
|
+
entry = self._entries.get((source_id, view))
|
|
491
|
+
if entry is None or moment > entry.expires_at:
|
|
492
|
+
return None
|
|
493
|
+
return entry.text
|
|
494
|
+
|
|
495
|
+
def get_external(
|
|
496
|
+
self,
|
|
497
|
+
source_id: str,
|
|
498
|
+
view: str,
|
|
499
|
+
policy: LegacyDisclosurePolicy,
|
|
500
|
+
*,
|
|
501
|
+
now: float | None = None,
|
|
502
|
+
) -> str | None:
|
|
503
|
+
"""External opaque disclosure. Deny-by-default: None unless allowed."""
|
|
504
|
+
if not is_external_opaque_allowed(policy, source_id):
|
|
505
|
+
return None
|
|
506
|
+
return self.get_local(source_id, view, now=now)
|