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.
@@ -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)