dosync 0.4.1__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.
dosync/db.py ADDED
@@ -0,0 +1,826 @@
1
+ """
2
+ DoSync — Persistence Layer (SQLite)
3
+ Todos los datos criticos del hub sobreviven reinicios.
4
+
5
+ Un solo archivo dosync.db en el directorio del proyecto.
6
+ Sin dependencias externas — sqlite3 viene con Python.
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import json
11
+ import logging
12
+ import sqlite3
13
+ import time
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ log = logging.getLogger("dosync.db")
19
+
20
+ # Schema SQL
21
+ SCHEMA = """
22
+ CREATE TABLE IF NOT EXISTS devices (
23
+ device_id TEXT PRIMARY KEY,
24
+ manifest_json TEXT NOT NULL,
25
+ registered_at REAL NOT NULL,
26
+ updated_at REAL NOT NULL
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS family_profile (
30
+ id INTEGER PRIMARY KEY CHECK (id = 1),
31
+ profile_json TEXT NOT NULL,
32
+ updated_at REAL NOT NULL
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS audit_log (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ entry_json TEXT NOT NULL,
38
+ hash TEXT NOT NULL,
39
+ timestamp REAL NOT NULL
40
+ );
41
+
42
+ -- AUDIT-ARCHIVE (2026-07-19): metadata for chain segmentation. When older
43
+ -- entries are archived to a segment file, the live chain no longer starts at
44
+ -- genesis — it starts at the ANCHOR (the last archived entry's hash), stored
45
+ -- here so verification and hub restore know where the chain begins.
46
+ CREATE TABLE IF NOT EXISTS audit_meta (
47
+ key TEXT PRIMARY KEY,
48
+ value_json TEXT NOT NULL
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS presence_signals (
52
+ device_id TEXT PRIMARY KEY,
53
+ signal_json TEXT NOT NULL,
54
+ timestamp REAL NOT NULL
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS api_keys (
58
+ key_hash TEXT PRIMARY KEY,
59
+ label TEXT NOT NULL,
60
+ created_at REAL NOT NULL,
61
+ last_used_at REAL
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
65
+ CREATE INDEX IF NOT EXISTS idx_presence_timestamp ON presence_signals(timestamp);
66
+ CREATE TABLE IF NOT EXISTS device_health (
67
+ device_id TEXT NOT NULL,
68
+ action TEXT NOT NULL,
69
+ success INTEGER NOT NULL, -- 1 = ok, 0 = fail
70
+ error TEXT,
71
+ timestamp REAL NOT NULL
72
+ );
73
+ CREATE INDEX IF NOT EXISTS idx_health_device ON device_health(device_id, timestamp);
74
+ CREATE TABLE IF NOT EXISTS device_state (
75
+ device_id TEXT PRIMARY KEY,
76
+ state_json TEXT NOT NULL,
77
+ updated_at REAL NOT NULL
78
+ );
79
+ CREATE TABLE IF NOT EXISTS intent_classes (
80
+ name TEXT PRIMARY KEY,
81
+ urgency TEXT NOT NULL DEFAULT 'info',
82
+ resolution_tags TEXT NOT NULL DEFAULT '[]',
83
+ resolution_actuators TEXT NOT NULL DEFAULT '[]',
84
+ description TEXT NOT NULL DEFAULT '',
85
+ domain TEXT NOT NULL DEFAULT 'general',
86
+ is_universal INTEGER NOT NULL DEFAULT 0,
87
+ composition_kind TEXT DEFAULT NULL,
88
+ created_at REAL NOT NULL
89
+ );
90
+ CREATE TABLE IF NOT EXISTS rate_limit_log (
91
+ device_id TEXT NOT NULL,
92
+ ts REAL NOT NULL
93
+ );
94
+ CREATE INDEX IF NOT EXISTS idx_rate_limit_device_ts ON rate_limit_log (device_id, ts);
95
+ """
96
+
97
+
98
+ class DoSyncDB:
99
+ """
100
+ Capa de persistencia SQLite para el DoSync Hub.
101
+
102
+ Uso:
103
+ db = DoSyncDB("dosync.db")
104
+ db.init()
105
+
106
+ # Guardar un dispositivo
107
+ db.save_device("lock-01", manifest_dict)
108
+
109
+ # Cargar todos los dispositivos al iniciar el hub
110
+ devices = db.load_devices()
111
+ """
112
+
113
+ def __init__(self, db_path: str = "dosync.db"):
114
+ self.db_path = Path(db_path)
115
+ self._conn: Optional[sqlite3.Connection] = None
116
+
117
+ # ── Conexion ──────────────────────────────────────────────────────────────
118
+
119
+ def init(self) -> None:
120
+ """Abre la conexion y crea las tablas si no existen."""
121
+ self._conn = sqlite3.connect(
122
+ str(self.db_path),
123
+ check_same_thread=False, # FastAPI usa multiples threads
124
+ )
125
+ self._conn.row_factory = sqlite3.Row
126
+ self._conn.execute("PRAGMA journal_mode=WAL") # mejor concurrencia
127
+ self._conn.execute("PRAGMA foreign_keys=ON")
128
+ self._conn.executescript(SCHEMA)
129
+ self._conn.commit()
130
+ self._migrate_schema()
131
+ log.info("Database initialized: %s", self.db_path.resolve())
132
+ self._seed_universal_intents()
133
+
134
+ def _migrate_schema(self) -> None:
135
+ """Idempotent, additive migrations for databases created before a column
136
+ existed. CREATE TABLE IF NOT EXISTS never alters an existing table, so new
137
+ nullable columns are added here. Safe to run on every startup: each ADD
138
+ COLUMN is guarded by a column-existence check, so an already-migrated DB is
139
+ untouched and no existing row is affected.
140
+ """
141
+ def _has_column(table: str, column: str) -> bool:
142
+ rows = self._conn.execute(f"PRAGMA table_info({table})").fetchall()
143
+ return any(r["name"] == column for r in rows)
144
+
145
+ # composition_kind: marks an intent class as a composition intent (e.g.
146
+ # "perimeter" for inspect_area). NULL = a normal flat intent (unchanged path).
147
+ if not _has_column("intent_classes", "composition_kind"):
148
+ self._conn.execute(
149
+ "ALTER TABLE intent_classes ADD COLUMN composition_kind TEXT DEFAULT NULL")
150
+ self._conn.commit()
151
+ log.info("Migration: added intent_classes.composition_kind")
152
+
153
+
154
+ def _seed_universal_intents(self) -> None:
155
+ """Seed the 5 universal intent classes if not already present.
156
+ These are the only intents defined at the protocol level — valid in any domain."""
157
+ import json, time
158
+ universals = [
159
+ ("ensure_safety", "emergency", ["emergency","alarm","communication","notification"], ["alarm","notify","call","turn_on","set_brightness"], "Safety emergency — protect people and property", "universal", 1),
160
+ ("alert_anomaly", "alert", ["communication","notification","sensor"], ["notify","call"], "Unexpected condition detected — investigate", "universal", 1),
161
+ ("control_access", "alert", ["lock"], ["lock","unlock"], "Manage physical access to a space", "universal", 1),
162
+ ("report_status", "info", [], [], "Generate a status report of the environment", "universal", 1),
163
+ ("notify", "info", ["communication","notification","display"], ["notify","display","call"], "Push information to any target", "universal", 1),
164
+ ]
165
+ now = time.time()
166
+ for name, urgency, tags, actuators, desc, domain, is_univ in universals:
167
+ row = self._conn.execute(
168
+ "SELECT resolution_tags, resolution_actuators FROM intent_classes WHERE name = ?", (name,)
169
+ ).fetchone()
170
+ if not row:
171
+ self._conn.execute(
172
+ "INSERT INTO intent_classes (name,urgency,resolution_tags,resolution_actuators,description,domain,is_universal,created_at) VALUES (?,?,?,?,?,?,?,?)",
173
+ (name, urgency, json.dumps(tags), json.dumps(actuators), desc, domain, is_univ, now)
174
+ )
175
+ elif row[0] != json.dumps(tags) or row[1] != json.dumps(actuators):
176
+ # Universal intent classes are protocol-defined, not user data:
177
+ # reconcile existing deployments to the canonical definition on
178
+ # startup (custom/domain classes are never touched). Without this,
179
+ # a seed fix would never reach an already-initialized DB.
180
+ self._conn.execute(
181
+ "UPDATE intent_classes SET urgency=?, resolution_tags=?, resolution_actuators=?, description=? WHERE name=? AND is_universal=1",
182
+ (urgency, json.dumps(tags), json.dumps(actuators), desc, name)
183
+ )
184
+ self._conn.commit()
185
+
186
+ def close(self) -> None:
187
+ if self._conn:
188
+ self._conn.close()
189
+ self._conn = None
190
+
191
+ @contextmanager
192
+ def _cursor(self):
193
+ cur = self._conn.cursor()
194
+ try:
195
+ yield cur
196
+ self._conn.commit()
197
+ except Exception:
198
+ self._conn.rollback()
199
+ raise
200
+ finally:
201
+ cur.close()
202
+
203
+ # ── Device registry ───────────────────────────────────────────────────────
204
+
205
+ def save_device(self, device_id: str, manifest: dict) -> None:
206
+ """Guarda o actualiza un dispositivo."""
207
+ now = time.time()
208
+ with self._cursor() as cur:
209
+ cur.execute("""
210
+ INSERT INTO devices (device_id, manifest_json, registered_at, updated_at)
211
+ VALUES (?, ?, ?, ?)
212
+ ON CONFLICT(device_id) DO UPDATE SET
213
+ manifest_json = excluded.manifest_json,
214
+ updated_at = excluded.updated_at
215
+ """, (device_id, json.dumps(manifest), now, now))
216
+ log.debug("Saved device: %s", device_id)
217
+
218
+ def delete_device(self, device_id: str) -> None:
219
+ """Elimina un dispositivo del registry."""
220
+ with self._cursor() as cur:
221
+ cur.execute("DELETE FROM devices WHERE device_id = ?", (device_id,))
222
+ log.debug("Deleted device: %s", device_id)
223
+
224
+ def load_devices(self) -> list[dict]:
225
+ """Carga todos los dispositivos registrados."""
226
+ with self._cursor() as cur:
227
+ cur.execute("SELECT manifest_json FROM devices ORDER BY registered_at")
228
+ rows = cur.fetchall()
229
+ manifests = [json.loads(r["manifest_json"]) for r in rows]
230
+ log.info("Loaded %d device(s) from database", len(manifests))
231
+ return manifests
232
+
233
+ def device_count(self) -> int:
234
+ with self._cursor() as cur:
235
+ cur.execute("SELECT COUNT(*) as n FROM devices")
236
+ return cur.fetchone()["n"]
237
+
238
+ # ── Family profile ────────────────────────────────────────────────────────
239
+
240
+ def save_family_profile(self, profile: dict) -> None:
241
+ """Guarda el perfil familiar (unico, siempre id=1)."""
242
+ now = time.time()
243
+ with self._cursor() as cur:
244
+ cur.execute("""
245
+ INSERT INTO family_profile (id, profile_json, updated_at)
246
+ VALUES (1, ?, ?)
247
+ ON CONFLICT(id) DO UPDATE SET
248
+ profile_json = excluded.profile_json,
249
+ updated_at = excluded.updated_at
250
+ """, (json.dumps(profile), now))
251
+ log.debug("Saved family profile")
252
+
253
+ def load_family_profile(self) -> Optional[dict]:
254
+ """Carga el perfil familiar, o None si no existe."""
255
+ with self._cursor() as cur:
256
+ cur.execute("SELECT profile_json FROM family_profile WHERE id = 1")
257
+ row = cur.fetchone()
258
+ if row:
259
+ log.info("Loaded family profile from database")
260
+ return json.loads(row["profile_json"])
261
+ return None
262
+
263
+ # ── Audit log ─────────────────────────────────────────────────────────────
264
+
265
+ def append_audit(self, entry: dict) -> None:
266
+ """
267
+ Persiste una entrada del audit log.
268
+ El hash ya viene calculado dentro del entry dict.
269
+ """
270
+ with self._cursor() as cur:
271
+ cur.execute("""
272
+ INSERT INTO audit_log (entry_json, hash, timestamp)
273
+ VALUES (?, ?, ?)
274
+ """, (
275
+ json.dumps(entry),
276
+ entry.get("hash", ""),
277
+ entry.get("timestamp", time.time()),
278
+ ))
279
+
280
+ def get_audit_anchor(self) -> dict | None:
281
+ """The archive anchor, or None if the chain has never been archived."""
282
+ with self._cursor() as cur:
283
+ cur.execute("SELECT value_json FROM audit_meta WHERE key = 'archive_anchor'")
284
+ row = cur.fetchone()
285
+ return json.loads(row[0]) if row else None
286
+
287
+ def set_audit_anchor(self, anchor: dict) -> None:
288
+ with self._cursor() as cur:
289
+ cur.execute("""
290
+ INSERT INTO audit_meta (key, value_json) VALUES ('archive_anchor', ?)
291
+ ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json
292
+ """, (json.dumps(anchor),))
293
+
294
+ def load_audit_log(self) -> list[dict]:
295
+ """Carga el audit log completo ordenado por timestamp."""
296
+ with self._cursor() as cur:
297
+ cur.execute(
298
+ "SELECT entry_json FROM audit_log ORDER BY timestamp, id"
299
+ )
300
+ rows = cur.fetchall()
301
+ entries = [json.loads(r["entry_json"]) for r in rows]
302
+ log.info("Loaded %d audit log entries from database", len(entries))
303
+ return entries
304
+
305
+ def audit_count(self) -> int:
306
+ with self._cursor() as cur:
307
+ cur.execute("SELECT COUNT(*) as n FROM audit_log")
308
+ return cur.fetchone()["n"]
309
+
310
+ # ── Rate limit persistence ────────────────────────────────────────────────
311
+
312
+ def append_rate_limit_event(self, device_id: str, ts: float) -> None:
313
+ """Record an actuator action for rate limit tracking."""
314
+ self._conn.execute(
315
+ "INSERT INTO rate_limit_log (device_id, ts) VALUES (?, ?)",
316
+ (device_id, ts),
317
+ )
318
+ self._conn.commit()
319
+
320
+ def load_rate_limit_events(self, window_seconds: float) -> dict[str, list[float]]:
321
+ """Load all rate limit events within the current window on startup."""
322
+ cutoff = __import__("time").time() - window_seconds
323
+ rows = self._conn.execute(
324
+ "SELECT device_id, ts FROM rate_limit_log WHERE ts >= ? ORDER BY ts",
325
+ (cutoff,),
326
+ ).fetchall()
327
+ result: dict[str, list[float]] = {}
328
+ for device_id, ts in rows:
329
+ result.setdefault(device_id, []).append(ts)
330
+ return result
331
+
332
+ def purge_rate_limit_events(self, window_seconds: float) -> int:
333
+ """Delete expired rate limit events. Call periodically to keep table small."""
334
+ cutoff = __import__("time").time() - window_seconds
335
+ cur = self._conn.execute(
336
+ "DELETE FROM rate_limit_log WHERE ts < ?", (cutoff,)
337
+ )
338
+ self._conn.commit()
339
+ return cur.rowcount
340
+
341
+
342
+ # ── Presence signals ──────────────────────────────────────────────────────
343
+
344
+ def save_presence_signal(self, device_id: str, signal: dict) -> None:
345
+ """Guarda o actualiza la señal de presencia de un dispositivo."""
346
+ now = signal.get("timestamp", time.time())
347
+ with self._cursor() as cur:
348
+ cur.execute("""
349
+ INSERT INTO presence_signals (device_id, signal_json, timestamp)
350
+ VALUES (?, ?, ?)
351
+ ON CONFLICT(device_id) DO UPDATE SET
352
+ signal_json = excluded.signal_json,
353
+ timestamp = excluded.timestamp
354
+ """, (device_id, json.dumps(signal), now))
355
+
356
+ def load_presence_signals(self) -> list[dict]:
357
+ """Carga todas las señales de presencia activas."""
358
+ with self._cursor() as cur:
359
+ cur.execute(
360
+ "SELECT signal_json FROM presence_signals ORDER BY timestamp"
361
+ )
362
+ rows = cur.fetchall()
363
+ signals = [json.loads(r["signal_json"]) for r in rows]
364
+ if signals:
365
+ log.info("Loaded %d presence signal(s) from database", len(signals))
366
+ return signals
367
+
368
+ def delete_presence_signal(self, device_id: str) -> None:
369
+ with self._cursor() as cur:
370
+ cur.execute(
371
+ "DELETE FROM presence_signals WHERE device_id = ?", (device_id,)
372
+ )
373
+
374
+ # ── Stats ─────────────────────────────────────────────────────────────────
375
+
376
+ def stats(self) -> dict:
377
+ """Resumen del estado de la base de datos."""
378
+ return {
379
+ "db_path": str(self.db_path.resolve()),
380
+ "db_size_kb": round(self.db_path.stat().st_size / 1024, 1)
381
+ if self.db_path.exists() else 0,
382
+ "devices": self.device_count(),
383
+ "audit_entries": self.audit_count(),
384
+ }
385
+
386
+ # ── API Keys ──────────────────────────────────────────────────────────────
387
+
388
+ def save_api_key(self, key_hash: str, label: str) -> None:
389
+ """Guarda una API key (almacenamos el hash, nunca la key en texto plano)."""
390
+ with self._cursor() as cur:
391
+ cur.execute("""
392
+ INSERT OR IGNORE INTO api_keys (key_hash, label, created_at)
393
+ VALUES (?, ?, ?)
394
+ """, (key_hash, label, time.time()))
395
+
396
+ def verify_api_key(self, key_hash: str) -> bool:
397
+ """Verifica si una API key existe y actualiza last_used_at."""
398
+ with self._cursor() as cur:
399
+ cur.execute(
400
+ "SELECT key_hash FROM api_keys WHERE key_hash = ?", (key_hash,)
401
+ )
402
+ row = cur.fetchone()
403
+ if row:
404
+ cur.execute(
405
+ "UPDATE api_keys SET last_used_at = ? WHERE key_hash = ?",
406
+ (time.time(), key_hash)
407
+ )
408
+ return True
409
+ return False
410
+
411
+ def list_api_keys(self) -> list[dict]:
412
+ """Lista todas las API keys registradas (sin el hash completo)."""
413
+ with self._cursor() as cur:
414
+ cur.execute("SELECT key_hash, label, created_at, last_used_at FROM api_keys")
415
+ rows = cur.fetchall()
416
+ return [
417
+ {
418
+ "key_preview": r["key_hash"][:8] + "...",
419
+ "label": r["label"],
420
+ "created_at": r["created_at"],
421
+ "last_used_at": r["last_used_at"],
422
+ }
423
+ for r in rows
424
+ ]
425
+
426
+ def delete_api_key(self, key_hash: str) -> bool:
427
+ """Elimina una API key. Retorna True si existía."""
428
+ with self._cursor() as cur:
429
+ cur.execute("DELETE FROM api_keys WHERE key_hash = ?", (key_hash,))
430
+ return cur.rowcount > 0
431
+
432
+ def has_any_key(self) -> bool:
433
+ """True si hay al menos una API key registrada."""
434
+ with self._cursor() as cur:
435
+ cur.execute("SELECT COUNT(*) as n FROM api_keys")
436
+ return cur.fetchone()["n"] > 0
437
+
438
+ # ── Device State (StateAwareResolver persistence) ─────────────────────────
439
+
440
+ def save_device_state(self, device_id: str, state: dict) -> None:
441
+ """Persiste el estado de un dispositivo. Upsert por device_id."""
442
+ with self._cursor() as cur:
443
+ cur.execute(
444
+ """
445
+ INSERT INTO device_state (device_id, state_json, updated_at)
446
+ VALUES (?, ?, ?)
447
+ ON CONFLICT(device_id) DO UPDATE SET
448
+ state_json = excluded.state_json,
449
+ updated_at = excluded.updated_at
450
+ """,
451
+ (device_id, json.dumps(state), time.time()),
452
+ )
453
+
454
+ def load_device_state(self, device_id: str) -> dict:
455
+ """Carga el estado de un dispositivo. Retorna {} si no existe."""
456
+ with self._cursor() as cur:
457
+ cur.execute(
458
+ "SELECT state_json FROM device_state WHERE device_id = ?",
459
+ (device_id,),
460
+ )
461
+ row = cur.fetchone()
462
+ if row:
463
+ return json.loads(row[0])
464
+ return {}
465
+
466
+ def load_all_device_states(self) -> dict:
467
+ """Carga todos los estados persistidos. Retorna {device_id: state_dict}."""
468
+ with self._cursor() as cur:
469
+ cur.execute("SELECT device_id, state_json FROM device_state")
470
+ rows = cur.fetchall()
471
+ return {row[0]: json.loads(row[1]) for row in rows}
472
+
473
+ # ── Device Health Monitor ─────────────────────────────────────────────────
474
+
475
+ def record_execution(self, device_id: str, action: str,
476
+ success: bool, error: str = None) -> None:
477
+ """Registra el resultado de una ejecución. Llamar tras cada adapter.execute()."""
478
+ with self._cursor() as cur:
479
+ cur.execute(
480
+ """INSERT INTO device_health (device_id, action, success, error, timestamp)
481
+ VALUES (?, ?, ?, ?, ?)""",
482
+ (device_id, action, 1 if success else 0, error, time.time()),
483
+ )
484
+
485
+ def get_device_health(self, device_id: str, last_n: int = 100) -> dict:
486
+ """
487
+ Estadísticas de salud de un dispositivo.
488
+ Retorna: {device_id, total, success, failed, success_rate, last_error, last_seen}
489
+ """
490
+ with self._cursor() as cur:
491
+ cur.execute(
492
+ """SELECT action, success, error, timestamp
493
+ FROM device_health
494
+ WHERE device_id = ?
495
+ ORDER BY timestamp DESC
496
+ LIMIT ?""",
497
+ (device_id, last_n),
498
+ )
499
+ rows = cur.fetchall()
500
+
501
+ if not rows:
502
+ return {
503
+ "device_id": device_id,
504
+ "total": 0,
505
+ "success": 0,
506
+ "failed": 0,
507
+ "success_rate": None,
508
+ "last_error": None,
509
+ "last_seen": None,
510
+ }
511
+
512
+ total = len(rows)
513
+ success = sum(1 for r in rows if r[1] == 1)
514
+ failed = total - success
515
+ last_error = next((r[2] for r in rows if r[1] == 0), None)
516
+ last_seen = rows[0][3] if rows else None
517
+
518
+ return {
519
+ "device_id": device_id,
520
+ "total": total,
521
+ "success": success,
522
+ "failed": failed,
523
+ "success_rate": round(success / total, 3) if total else None,
524
+ "last_error": last_error,
525
+ "last_seen": last_seen,
526
+ }
527
+
528
+ def get_all_health(self, last_n: int = 100, min_executions: int = 1) -> list:
529
+ """
530
+ Estadísticas de salud de todos los dispositivos con al menos min_executions.
531
+ Ordenado por tasa de éxito ascendente (peores primero).
532
+ """
533
+ with self._cursor() as cur:
534
+ cur.execute("SELECT DISTINCT device_id FROM device_health")
535
+ device_ids = [r[0] for r in cur.fetchall()]
536
+
537
+ results = []
538
+ for device_id in device_ids:
539
+ health = self.get_device_health(device_id, last_n)
540
+ if health["total"] >= min_executions:
541
+ results.append(health)
542
+
543
+ # Peores primero (tasa de éxito ascendente), None al final
544
+ results.sort(key=lambda x: x["success_rate"] if x["success_rate"] is not None else 1.1)
545
+ return results
546
+
547
+ def get_health_alerts(self, threshold: float = 0.7, last_n: int = 100) -> list:
548
+ """
549
+ Dispositivos cuya tasa de éxito está por debajo del umbral.
550
+ threshold=0.7 significa alertar cuando menos del 70% de las ejecuciones son exitosas.
551
+ """
552
+ all_health = self.get_all_health(last_n=last_n, min_executions=3)
553
+ return [
554
+ h for h in all_health
555
+ if h["success_rate"] is not None and h["success_rate"] < threshold
556
+ ]
557
+
558
+ # ── Device tokens ─────────────────────────────────────────────────────────
559
+
560
+ def init_device_tokens_table(self) -> None:
561
+ """Crear tabla device_tokens si no existe."""
562
+ self._conn.execute("""
563
+ CREATE TABLE IF NOT EXISTS device_tokens (
564
+ device_id TEXT PRIMARY KEY,
565
+ token_hash TEXT NOT NULL,
566
+ label TEXT DEFAULT '',
567
+ created_at REAL NOT NULL
568
+ )
569
+ """)
570
+ self._conn.commit()
571
+
572
+ def save_device_token(self, device_id: str, token_hash: str, label: str = "") -> None:
573
+ import time
574
+ self._conn.execute("""
575
+ INSERT OR REPLACE INTO device_tokens (device_id, token_hash, label, created_at)
576
+ VALUES (?, ?, ?, ?)
577
+ """, (device_id, token_hash, label, time.time()))
578
+ self._conn.commit()
579
+
580
+ def verify_device_token(self, device_id: str, token_hash: str) -> bool:
581
+ cur = self._conn.execute(
582
+ "SELECT 1 FROM device_tokens WHERE device_id=? AND token_hash=?",
583
+ (device_id, token_hash)
584
+ )
585
+ return cur.fetchone() is not None
586
+
587
+ def device_is_provisioned(self, device_id: str) -> bool:
588
+ cur = self._conn.execute(
589
+ "SELECT 1 FROM device_tokens WHERE device_id=?", (device_id,)
590
+ )
591
+ return cur.fetchone() is not None
592
+
593
+ def delete_device_token(self, device_id: str) -> bool:
594
+ cur = self._conn.execute(
595
+ "DELETE FROM device_tokens WHERE device_id=?", (device_id,)
596
+ )
597
+ self._conn.commit()
598
+ return cur.rowcount > 0
599
+
600
+ def list_device_tokens(self) -> list[dict]:
601
+ cur = self._conn.execute(
602
+ "SELECT device_id, label, created_at FROM device_tokens ORDER BY created_at DESC"
603
+ )
604
+ return [{"device_id": r[0], "label": r[1], "created_at": r[2]} for r in cur.fetchall()]
605
+
606
+ # ── Emergency snapshots ───────────────────────────────────────────────────
607
+
608
+ def init_emergency_snapshots_table(self) -> None:
609
+ """Tabla para persistir intents de emergencia activos."""
610
+ self._conn.execute("""
611
+ CREATE TABLE IF NOT EXISTS emergency_snapshots (
612
+ intent_id TEXT PRIMARY KEY,
613
+ intent_class TEXT NOT NULL,
614
+ urgency TEXT NOT NULL,
615
+ context TEXT NOT NULL,
616
+ fired_at REAL NOT NULL,
617
+ resolved_at REAL,
618
+ status TEXT DEFAULT 'active'
619
+ )
620
+ """)
621
+ self._conn.commit()
622
+
623
+ def save_emergency_snapshot(self, intent_id: str, intent_class: str,
624
+ urgency: str, context: dict) -> None:
625
+ import time, json
626
+ self._conn.execute("""
627
+ INSERT OR REPLACE INTO emergency_snapshots
628
+ (intent_id, intent_class, urgency, context, fired_at, status)
629
+ VALUES (?, ?, ?, ?, ?, 'active')
630
+ """, (intent_id, intent_class, urgency, json.dumps(context), time.time()))
631
+ self._conn.commit()
632
+
633
+ def resolve_emergency_snapshot(self, intent_id: str) -> None:
634
+ import time
635
+ self._conn.execute("""
636
+ UPDATE emergency_snapshots
637
+ SET resolved_at=?, status='resolved'
638
+ WHERE intent_id=?
639
+ """, (time.time(), intent_id))
640
+ self._conn.commit()
641
+
642
+ def get_active_emergency_snapshots(self) -> list[dict]:
643
+ import json
644
+ cur = self._conn.execute("""
645
+ SELECT intent_id, intent_class, urgency, context, fired_at
646
+ FROM emergency_snapshots
647
+ WHERE status='active'
648
+ ORDER BY fired_at DESC
649
+ """)
650
+ return [
651
+ {"intent_id": r[0], "intent_class": r[1], "urgency": r[2],
652
+ "context": json.loads(r[3]), "fired_at": r[4]}
653
+ for r in cur.fetchall()
654
+ ]
655
+
656
+ def clear_old_snapshots(self, max_age_hours: int = 24) -> int:
657
+ """Limpia snapshots resueltos o muy antiguos."""
658
+ import time
659
+ cutoff = time.time() - (max_age_hours * 3600)
660
+ cur = self._conn.execute("""
661
+ DELETE FROM emergency_snapshots
662
+ WHERE status='resolved' OR fired_at < ?
663
+ """, (cutoff,))
664
+ self._conn.commit()
665
+ return cur.rowcount
666
+
667
+ # ── Long-running operations (execution_model) ─────────────────────────────
668
+ # Persists active long_running operations so they survive a hub restart. The
669
+ # panel's hard requirement: without this, a hub reboot orphans a drone still
670
+ # flying toward a waypoint — the hub comes back with no record it ever existed.
671
+ # On restart the hub reloads active operations and reconciles them against
672
+ # telemetry (reconciliation lives in the operations/reconciler layer; the DB
673
+ # only stores and retrieves). Follows the emergency_snapshots pattern exactly.
674
+ #
675
+ # The full operation (state machine + transition history) is owned by
676
+ # operations.Operation; here it is stored as its to_dict() JSON blob under a
677
+ # few indexed columns. The DB never interprets the lifecycle — it persists it.
678
+
679
+ def init_operations_table(self) -> None:
680
+ """Tabla para persistir operaciones long_running activas."""
681
+ self._conn.execute("""
682
+ CREATE TABLE IF NOT EXISTS operations (
683
+ operation_id TEXT PRIMARY KEY,
684
+ device_id TEXT NOT NULL,
685
+ action TEXT NOT NULL,
686
+ state TEXT NOT NULL,
687
+ created_at REAL NOT NULL,
688
+ state_entered_at REAL NOT NULL,
689
+ updated_at REAL NOT NULL,
690
+ terminal INTEGER NOT NULL DEFAULT 0,
691
+ data TEXT NOT NULL
692
+ )
693
+ """)
694
+ self._conn.commit()
695
+
696
+ def save_operation(self, op_dict: dict, terminal: bool) -> None:
697
+ """Inserta o actualiza una operación. `op_dict` es Operation.to_dict();
698
+ `terminal` indica si la operación llegó a un estado terminal (para que
699
+ get_active_operations la excluya sin reinterpretar el estado)."""
700
+ import time, json
701
+ self._conn.execute("""
702
+ INSERT OR REPLACE INTO operations
703
+ (operation_id, device_id, action, state, created_at,
704
+ state_entered_at, updated_at, terminal, data)
705
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
706
+ """, (
707
+ op_dict["operation_id"],
708
+ op_dict["device_id"],
709
+ op_dict["action"],
710
+ op_dict["state"],
711
+ op_dict["created_at"],
712
+ op_dict["state_entered_at"],
713
+ time.time(),
714
+ 1 if terminal else 0,
715
+ json.dumps(op_dict),
716
+ ))
717
+ self._conn.commit()
718
+
719
+ def get_active_operations(self) -> list[dict]:
720
+ """Devuelve las operaciones NO terminales — las que siguen vivas y deben
721
+ reconciliarse tras un reinicio. Cada elemento es el Operation.to_dict()
722
+ original, listo para rehidratar."""
723
+ import json
724
+ cur = self._conn.execute("""
725
+ SELECT data FROM operations
726
+ WHERE terminal = 0
727
+ ORDER BY created_at DESC
728
+ """)
729
+ return [json.loads(r[0]) for r in cur.fetchall()]
730
+
731
+ def get_operation(self, operation_id: str) -> dict | None:
732
+ """Recupera una operación puntual por id (terminal o no)."""
733
+ import json
734
+ cur = self._conn.execute(
735
+ "SELECT data FROM operations WHERE operation_id = ?", (operation_id,)
736
+ )
737
+ row = cur.fetchone()
738
+ return json.loads(row[0]) if row else None
739
+
740
+ def clear_old_operations(self, max_age_hours: int = 24) -> int:
741
+ """Limpia operaciones terminales o muy antiguas. Las operaciones activas
742
+ (terminal=0) NUNCA se borran por antigüedad — una operación vieja pero
743
+ viva es justamente la que hay que preservar para reconciliar."""
744
+ import time
745
+ cutoff = time.time() - (max_age_hours * 3600)
746
+ cur = self._conn.execute("""
747
+ DELETE FROM operations
748
+ WHERE terminal = 1 AND updated_at < ?
749
+ """, (cutoff,))
750
+ self._conn.commit()
751
+ return cur.rowcount
752
+
753
+
754
+ # ── Custom Intent Classes ─────────────────────────────────────────────────
755
+
756
+ def save_intent_class(self, name: str, urgency: str,
757
+ resolution_tags: list, resolution_actuators: list,
758
+ description: str, domain: str,
759
+ composition_kind: str | None = None) -> None:
760
+ """Insert or update an intent class. Never modifies is_universal flag.
761
+
762
+ composition_kind: marks the intent as a composition intent (e.g. "perimeter"
763
+ for inspect_area). None = a normal flat intent. On update, if not provided
764
+ the existing value is preserved (so a plain re-save does not clear it)."""
765
+ import time, json
766
+ existing = self._conn.execute(
767
+ "SELECT is_universal, composition_kind FROM intent_classes WHERE name = ?",
768
+ (name,)
769
+ ).fetchone()
770
+ is_universal = existing["is_universal"] if existing else 0
771
+ # Preserve an existing composition_kind unless a new one is explicitly given.
772
+ if composition_kind is None and existing is not None:
773
+ composition_kind = existing["composition_kind"]
774
+ self._conn.execute("""
775
+ INSERT OR REPLACE INTO intent_classes
776
+ (name, urgency, resolution_tags, resolution_actuators,
777
+ description, domain, is_universal, composition_kind, created_at)
778
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
779
+ """, (name, urgency, json.dumps(resolution_tags),
780
+ json.dumps(resolution_actuators),
781
+ description, domain, is_universal, composition_kind, time.time()))
782
+ self._conn.commit()
783
+
784
+ def get_intent_class(self, name: str) -> dict | None:
785
+ import json
786
+ row = self._conn.execute(
787
+ "SELECT * FROM intent_classes WHERE name = ?", (name,)
788
+ ).fetchone()
789
+ if not row:
790
+ return None
791
+ return {
792
+ "name": row["name"],
793
+ "urgency": row["urgency"],
794
+ "resolution_tags": json.loads(row["resolution_tags"]),
795
+ "resolution_actuators": json.loads(row["resolution_actuators"]),
796
+ "description": row["description"],
797
+ "domain": row["domain"],
798
+ "created_at": row["created_at"],
799
+ "is_universal": bool(row["is_universal"]),
800
+ "composition_kind": row["composition_kind"] if "composition_kind" in row.keys() else None,
801
+ }
802
+
803
+ def list_intent_classes(self) -> list[dict]:
804
+ import json
805
+ rows = self._conn.execute(
806
+ "SELECT * FROM intent_classes ORDER BY created_at ASC"
807
+ ).fetchall()
808
+ return [{
809
+ "name": r["name"],
810
+ "urgency": r["urgency"],
811
+ "resolution_tags": json.loads(r["resolution_tags"]),
812
+ "resolution_actuators": json.loads(r["resolution_actuators"]),
813
+ "description": r["description"],
814
+ "domain": r["domain"],
815
+ "is_universal": bool(r["is_universal"]),
816
+ "composition_kind": r["composition_kind"] if "composition_kind" in r.keys() else None,
817
+ "created_at": r["created_at"],
818
+ } for r in rows]
819
+
820
+ def delete_intent_class(self, name: str) -> bool:
821
+ cur = self._conn.execute(
822
+ "DELETE FROM intent_classes WHERE name = ?", (name,)
823
+ )
824
+ self._conn.commit()
825
+ return cur.rowcount > 0
826
+