fleet-framework 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.
Files changed (85) hide show
  1. fleet/__init__.py +1 -0
  2. fleet/cli.py +290 -0
  3. fleet/core/__init__.py +69 -0
  4. fleet/core/automation.py +125 -0
  5. fleet/core/backend.py +736 -0
  6. fleet/core/config.py +38 -0
  7. fleet/core/context.py +102 -0
  8. fleet/core/contract.py +87 -0
  9. fleet/core/country_presets.py +50 -0
  10. fleet/core/events.py +55 -0
  11. fleet/core/logging.py +97 -0
  12. fleet/core/memory_backend.py +492 -0
  13. fleet/core/metrics.py +61 -0
  14. fleet/core/otel.py +97 -0
  15. fleet/core/primitives.py +310 -0
  16. fleet/core/protocol.py +171 -0
  17. fleet/core/proxy.py +166 -0
  18. fleet/core/reconcile.py +75 -0
  19. fleet/core/sqlite_backend.py +1117 -0
  20. fleet/core/store.py +104 -0
  21. fleet/master/__init__.py +3 -0
  22. fleet/master/api.py +324 -0
  23. fleet/master/app.py +105 -0
  24. fleet/master/auth.py +132 -0
  25. fleet/master/broadcaster.py +37 -0
  26. fleet/master/dashboard/__init__.py +4 -0
  27. fleet/master/dashboard/router.py +36 -0
  28. fleet/master/dashboard/static/style.css +97 -0
  29. fleet/master/dashboard/templates/index.html +372 -0
  30. fleet/master/metrics_route.py +141 -0
  31. fleet/master/ratelimit.py +55 -0
  32. fleet/master/ws_router.py +142 -0
  33. fleet/worker/__init__.py +3 -0
  34. fleet/worker/agent.py +173 -0
  35. fleet/worker/reconcile_loop.py +246 -0
  36. fleet/worker/slot_runner.py +256 -0
  37. fleet/worker/ws_client.py +164 -0
  38. fleet_browser/__init__.py +21 -0
  39. fleet_browser/browser.py +277 -0
  40. fleet_browser/cert.py +68 -0
  41. fleet_browser/fingerprint.py +327 -0
  42. fleet_browser/humanizer.py +157 -0
  43. fleet_browser/pool.py +241 -0
  44. fleet_browser/proxy_extension.py +122 -0
  45. fleet_browser/solver.py +51 -0
  46. fleet_browser/stealth.py +80 -0
  47. fleet_cloudflare/__init__.py +22 -0
  48. fleet_cloudflare/bypasser.py +168 -0
  49. fleet_cloudflare/harvest.py +266 -0
  50. fleet_cloudflare/replay.py +82 -0
  51. fleet_cloudflare/solver.py +28 -0
  52. fleet_content/__init__.py +24 -0
  53. fleet_content/automation.py +43 -0
  54. fleet_content/contracts.py +76 -0
  55. fleet_detect/__init__.py +26 -0
  56. fleet_detect/contracts.py +67 -0
  57. fleet_detect/detect.py +126 -0
  58. fleet_framework-0.1.0.dist-info/METADATA +160 -0
  59. fleet_framework-0.1.0.dist-info/RECORD +85 -0
  60. fleet_framework-0.1.0.dist-info/WHEEL +5 -0
  61. fleet_framework-0.1.0.dist-info/entry_points.txt +9 -0
  62. fleet_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
  63. fleet_framework-0.1.0.dist-info/top_level.txt +14 -0
  64. fleet_headers/__init__.py +28 -0
  65. fleet_headers/profiles.py +131 -0
  66. fleet_jobs/__init__.py +28 -0
  67. fleet_jobs/automation.py +34 -0
  68. fleet_jobs/contracts.py +143 -0
  69. fleet_marketplace/__init__.py +33 -0
  70. fleet_marketplace/automation.py +32 -0
  71. fleet_marketplace/contracts.py +151 -0
  72. fleet_news/__init__.py +21 -0
  73. fleet_news/automation.py +51 -0
  74. fleet_news/contracts.py +59 -0
  75. fleet_place/__init__.py +33 -0
  76. fleet_place/automation.py +37 -0
  77. fleet_place/contracts.py +156 -0
  78. fleet_provider_dataimpulse/__init__.py +82 -0
  79. fleet_provider_evomi/__init__.py +76 -0
  80. fleet_serp/__init__.py +30 -0
  81. fleet_serp/automation.py +47 -0
  82. fleet_serp/contracts.py +100 -0
  83. fleet_social/__init__.py +34 -0
  84. fleet_social/automation.py +44 -0
  85. fleet_social/contracts.py +172 -0
@@ -0,0 +1,1117 @@
1
+ """Sqlite-backed Backend implementation.
2
+
3
+ Targets single-host deployments where running Redis is overkill — a CLI tool, a
4
+ laptop demo, a tiny VPS. One file on disk, WAL mode for concurrency, the same
5
+ Backend protocol as RedisBackend and InMemoryBackend.
6
+
7
+ The implementation uses one sqlite3 connection guarded by a threading.Lock.
8
+ Every public method dispatches the actual SQL to a worker thread via
9
+ asyncio.to_thread so the event loop never blocks on a slow disk.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import secrets
17
+ import sqlite3
18
+ import threading
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ from fleet.core.backend import RawItem
24
+
25
+ _SCHEMA = """
26
+ CREATE TABLE IF NOT EXISTS kv (
27
+ ns TEXT NOT NULL,
28
+ key TEXT NOT NULL,
29
+ value BLOB NOT NULL,
30
+ expires_at REAL,
31
+ PRIMARY KEY(ns, key)
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS pool_items (
35
+ name TEXT NOT NULL,
36
+ item_id TEXT NOT NULL,
37
+ payload BLOB NOT NULL,
38
+ tags TEXT NOT NULL,
39
+ expires_at REAL,
40
+ PRIMARY KEY(name, item_id)
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS pool_claims (
44
+ name TEXT NOT NULL,
45
+ item_id TEXT NOT NULL,
46
+ until REAL NOT NULL,
47
+ PRIMARY KEY(name, item_id)
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS locks (
51
+ name TEXT PRIMARY KEY,
52
+ token TEXT NOT NULL,
53
+ expires_at REAL NOT NULL
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS counters (
57
+ name TEXT PRIMARY KEY,
58
+ value INTEGER NOT NULL
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS queues (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ name TEXT NOT NULL,
64
+ tier TEXT NOT NULL,
65
+ body BLOB NOT NULL
66
+ );
67
+ CREATE INDEX IF NOT EXISTS queues_name_tier ON queues(name, tier, id);
68
+
69
+ CREATE TABLE IF NOT EXISTS queue_processing (
70
+ name TEXT NOT NULL,
71
+ task_id TEXT NOT NULL,
72
+ body BLOB NOT NULL,
73
+ deadline REAL NOT NULL,
74
+ PRIMARY KEY(name, task_id)
75
+ );
76
+
77
+ CREATE TABLE IF NOT EXISTS queue_scheduled (
78
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
79
+ name TEXT NOT NULL,
80
+ available_at REAL NOT NULL,
81
+ body BLOB NOT NULL
82
+ );
83
+ CREATE INDEX IF NOT EXISTS queue_scheduled_name_at ON queue_scheduled(name, available_at);
84
+
85
+ CREATE TABLE IF NOT EXISTS queue_dead (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ name TEXT NOT NULL,
88
+ body BLOB NOT NULL
89
+ );
90
+ CREATE INDEX IF NOT EXISTS queue_dead_name_id ON queue_dead(name, id);
91
+
92
+ CREATE TABLE IF NOT EXISTS streams (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ name TEXT NOT NULL,
95
+ score REAL NOT NULL,
96
+ body BLOB NOT NULL
97
+ );
98
+ CREATE INDEX IF NOT EXISTS streams_name_score ON streams(name, score);
99
+
100
+ CREATE TABLE IF NOT EXISTS workers (
101
+ automation_type TEXT NOT NULL,
102
+ worker_id TEXT NOT NULL,
103
+ hardware TEXT NOT NULL,
104
+ config TEXT NOT NULL,
105
+ config_gen INTEGER NOT NULL DEFAULT 0,
106
+ state TEXT NOT NULL DEFAULT 'IDLE',
107
+ last_seen REAL NOT NULL DEFAULT 0,
108
+ last_error TEXT,
109
+ stats TEXT NOT NULL DEFAULT '{}',
110
+ PRIMARY KEY(automation_type, worker_id)
111
+ );
112
+
113
+ CREATE TABLE IF NOT EXISTS worker_stats_history (
114
+ automation_type TEXT NOT NULL,
115
+ worker_id TEXT NOT NULL,
116
+ ts REAL NOT NULL,
117
+ frame TEXT NOT NULL
118
+ );
119
+ CREATE INDEX IF NOT EXISTS wsh_at_wid_ts ON worker_stats_history(automation_type, worker_id, ts);
120
+
121
+ CREATE TABLE IF NOT EXISTS catalog (
122
+ automation_type TEXT PRIMARY KEY,
123
+ doc BLOB NOT NULL
124
+ );
125
+ """
126
+
127
+
128
+ def _tier_for_priority(priority: int) -> str:
129
+ if priority > 0:
130
+ return "high"
131
+ if priority < 0:
132
+ return "low"
133
+ return "normal"
134
+
135
+
136
+ _TIER_ORDER = ("high", "normal", "low")
137
+
138
+
139
+ class SqliteBackend:
140
+ def __init__(self, url_or_path: str = "sqlite://:memory:") -> None:
141
+ path = _path_from_url(url_or_path)
142
+ if path != ":memory:":
143
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
144
+ self._conn = sqlite3.connect(
145
+ path,
146
+ isolation_level=None, # autocommit; we manage txns by hand
147
+ check_same_thread=False,
148
+ timeout=30.0,
149
+ )
150
+ self._lock = threading.Lock()
151
+ self._conn.execute("PRAGMA journal_mode=WAL;")
152
+ self._conn.execute("PRAGMA synchronous=NORMAL;")
153
+ self._conn.execute("PRAGMA busy_timeout=5000;")
154
+ self._conn.executescript(_SCHEMA)
155
+
156
+ async def aclose(self) -> None:
157
+ await asyncio.to_thread(self._close_sync)
158
+
159
+ def _close_sync(self) -> None:
160
+ with self._lock:
161
+ try:
162
+ self._conn.close()
163
+ except Exception:
164
+ pass
165
+
166
+ # ------- KV ----------
167
+
168
+ async def kv_set(self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]) -> None:
169
+ exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
170
+ await asyncio.to_thread(self._kv_set_sync, ns, key, value, exp)
171
+
172
+ def _kv_set_sync(self, ns: str, key: str, value: bytes, exp: Optional[float]) -> None:
173
+ with self._lock:
174
+ self._conn.execute(
175
+ "INSERT OR REPLACE INTO kv(ns, key, value, expires_at) VALUES(?,?,?,?)",
176
+ (ns, key, value, exp),
177
+ )
178
+
179
+ async def kv_setnx(
180
+ self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]
181
+ ) -> bool:
182
+ exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
183
+ return await asyncio.to_thread(self._kv_setnx_sync, ns, key, value, exp)
184
+
185
+ def _kv_setnx_sync(self, ns: str, key: str, value: bytes, exp: Optional[float]) -> bool:
186
+ now = time.time()
187
+ with self._lock:
188
+ self._conn.execute(
189
+ "DELETE FROM kv WHERE ns=? AND key=? AND expires_at IS NOT NULL AND expires_at < ?",
190
+ (ns, key, now),
191
+ )
192
+ try:
193
+ self._conn.execute(
194
+ "INSERT INTO kv(ns, key, value, expires_at) VALUES(?,?,?,?)",
195
+ (ns, key, value, exp),
196
+ )
197
+ return True
198
+ except sqlite3.IntegrityError:
199
+ return False
200
+
201
+ async def kv_get(self, ns: str, key: str) -> Optional[bytes]:
202
+ return await asyncio.to_thread(self._kv_get_sync, ns, key)
203
+
204
+ def _kv_get_sync(self, ns: str, key: str) -> Optional[bytes]:
205
+ now = time.time()
206
+ with self._lock:
207
+ row = self._conn.execute(
208
+ "SELECT value, expires_at FROM kv WHERE ns=? AND key=?",
209
+ (ns, key),
210
+ ).fetchone()
211
+ if row is None:
212
+ return None
213
+ value, exp = row
214
+ if exp is not None and exp < now:
215
+ # Lazy expiry: delete and pretend it wasn't there.
216
+ with self._lock:
217
+ self._conn.execute("DELETE FROM kv WHERE ns=? AND key=?", (ns, key))
218
+ return None
219
+ return value
220
+
221
+ async def kv_del(self, ns: str, key: str) -> bool:
222
+ return await asyncio.to_thread(self._kv_del_sync, ns, key)
223
+
224
+ def _kv_del_sync(self, ns: str, key: str) -> bool:
225
+ with self._lock:
226
+ cur = self._conn.execute("DELETE FROM kv WHERE ns=? AND key=?", (ns, key))
227
+ return cur.rowcount > 0
228
+
229
+ # ------- Pool ----------
230
+
231
+ async def pool_put(
232
+ self,
233
+ name: str,
234
+ item_id: str,
235
+ payload: bytes,
236
+ tags: dict[str, Any],
237
+ ttl_seconds: Optional[int],
238
+ ) -> None:
239
+ exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
240
+ await asyncio.to_thread(self._pool_put_sync, name, item_id, payload, tags, exp)
241
+
242
+ def _pool_put_sync(
243
+ self, name: str, item_id: str, payload: bytes, tags: dict[str, Any], exp: Optional[float]
244
+ ) -> None:
245
+ with self._lock:
246
+ self._conn.execute(
247
+ "INSERT OR REPLACE INTO pool_items(name, item_id, payload, tags, expires_at) "
248
+ "VALUES(?,?,?,?,?)",
249
+ (name, item_id, payload, json.dumps(tags), exp),
250
+ )
251
+
252
+ async def pool_list(self, name: str) -> list[RawItem]:
253
+ return await asyncio.to_thread(self._pool_list_sync, name)
254
+
255
+ def _pool_list_sync(self, name: str) -> list[RawItem]:
256
+ now = time.time()
257
+ with self._lock:
258
+ self._conn.execute(
259
+ "DELETE FROM pool_items WHERE name=? AND expires_at IS NOT NULL AND expires_at < ?",
260
+ (name, now),
261
+ )
262
+ self._conn.execute(
263
+ "DELETE FROM pool_claims WHERE name=? AND until < ?",
264
+ (name, now),
265
+ )
266
+ rows = self._conn.execute(
267
+ "SELECT item_id, payload, tags, expires_at FROM pool_items WHERE name=?",
268
+ (name,),
269
+ ).fetchall()
270
+ claims = dict(
271
+ self._conn.execute(
272
+ "SELECT item_id, until FROM pool_claims WHERE name=?", (name,)
273
+ ).fetchall()
274
+ )
275
+ out: list[RawItem] = []
276
+ for iid, payload, tags_json, exp in rows:
277
+ in_use = claims.get(iid)
278
+ out.append(RawItem(
279
+ id=iid,
280
+ payload=payload,
281
+ tags=json.loads(tags_json),
282
+ expires_at=exp,
283
+ in_use_until=in_use if (in_use is not None and in_use > now) else None,
284
+ ))
285
+ return out
286
+
287
+ async def pool_size(self, name: str) -> int:
288
+ return await asyncio.to_thread(self._pool_size_sync, name)
289
+
290
+ def _pool_size_sync(self, name: str) -> int:
291
+ now = time.time()
292
+ with self._lock:
293
+ row = self._conn.execute(
294
+ "SELECT COUNT(*) FROM pool_items WHERE name=? AND "
295
+ "(expires_at IS NULL OR expires_at >= ?)",
296
+ (name, now),
297
+ ).fetchone()
298
+ return int(row[0])
299
+
300
+ async def pool_remove(self, name: str, item_id: str) -> bool:
301
+ return await asyncio.to_thread(self._pool_remove_sync, name, item_id)
302
+
303
+ def _pool_remove_sync(self, name: str, item_id: str) -> bool:
304
+ with self._lock:
305
+ self._conn.execute(
306
+ "DELETE FROM pool_claims WHERE name=? AND item_id=?",
307
+ (name, item_id),
308
+ )
309
+ cur = self._conn.execute(
310
+ "DELETE FROM pool_items WHERE name=? AND item_id=?",
311
+ (name, item_id),
312
+ )
313
+ return cur.rowcount > 0
314
+
315
+ async def pool_release(self, name: str, item_id: str) -> bool:
316
+ return await asyncio.to_thread(self._pool_release_sync, name, item_id)
317
+
318
+ def _pool_release_sync(self, name: str, item_id: str) -> bool:
319
+ with self._lock:
320
+ cur = self._conn.execute(
321
+ "DELETE FROM pool_claims WHERE name=? AND item_id=?",
322
+ (name, item_id),
323
+ )
324
+ return cur.rowcount > 0
325
+
326
+ async def pool_claim_any(
327
+ self,
328
+ name: str,
329
+ where: dict[str, Any],
330
+ hold_seconds: int,
331
+ ) -> Optional[RawItem]:
332
+ return await asyncio.to_thread(self._pool_claim_any_sync, name, where, hold_seconds)
333
+
334
+ def _pool_claim_any_sync(
335
+ self, name: str, where: dict[str, Any], hold_seconds: int
336
+ ) -> Optional[RawItem]:
337
+ now = time.time()
338
+ until = now + max(1, hold_seconds)
339
+ with self._lock:
340
+ # purge expired items + claims first
341
+ self._conn.execute(
342
+ "DELETE FROM pool_items WHERE name=? AND expires_at IS NOT NULL AND expires_at < ?",
343
+ (name, now),
344
+ )
345
+ self._conn.execute(
346
+ "DELETE FROM pool_claims WHERE name=? AND until < ?",
347
+ (name, now),
348
+ )
349
+ # newest items first (largest expires_at; NULL last).
350
+ rows = self._conn.execute(
351
+ "SELECT i.item_id, i.payload, i.tags, i.expires_at "
352
+ "FROM pool_items i "
353
+ "LEFT JOIN pool_claims c ON c.name=i.name AND c.item_id=i.item_id "
354
+ "WHERE i.name=? AND c.item_id IS NULL "
355
+ "ORDER BY (i.expires_at IS NULL), i.expires_at DESC",
356
+ (name,),
357
+ ).fetchall()
358
+ for iid, payload, tags_json, exp in rows:
359
+ tags = json.loads(tags_json)
360
+ if not all(tags.get(k) == v for k, v in where.items()):
361
+ continue
362
+ try:
363
+ self._conn.execute(
364
+ "INSERT INTO pool_claims(name, item_id, until) VALUES(?,?,?)",
365
+ (name, iid, until),
366
+ )
367
+ except sqlite3.IntegrityError:
368
+ continue
369
+ return RawItem(
370
+ id=iid,
371
+ payload=payload,
372
+ tags=tags,
373
+ expires_at=exp,
374
+ in_use_until=until,
375
+ )
376
+ return None
377
+
378
+ # ------- Locks ----------
379
+
380
+ async def lock_acquire(
381
+ self, name: str, hold_seconds: int, wait_seconds: float
382
+ ) -> Optional[str]:
383
+ deadline = time.monotonic() + max(0.0, wait_seconds)
384
+ backoff = 0.05
385
+ while True:
386
+ tok = await asyncio.to_thread(self._lock_try_acquire_sync, name, hold_seconds)
387
+ if tok is not None:
388
+ return tok
389
+ if time.monotonic() >= deadline:
390
+ return None
391
+ await asyncio.sleep(min(backoff, max(0.0, deadline - time.monotonic())))
392
+ backoff = min(backoff * 2, 0.5)
393
+
394
+ def _lock_try_acquire_sync(self, name: str, hold_seconds: int) -> Optional[str]:
395
+ now = time.time()
396
+ until = now + max(1, hold_seconds)
397
+ with self._lock:
398
+ self._conn.execute(
399
+ "DELETE FROM locks WHERE name=? AND expires_at < ?", (name, now)
400
+ )
401
+ token = secrets.token_hex(16)
402
+ try:
403
+ self._conn.execute(
404
+ "INSERT INTO locks(name, token, expires_at) VALUES(?,?,?)",
405
+ (name, token, until),
406
+ )
407
+ return token
408
+ except sqlite3.IntegrityError:
409
+ return None
410
+
411
+ async def lock_release(self, name: str, token: str) -> bool:
412
+ return await asyncio.to_thread(self._lock_release_sync, name, token)
413
+
414
+ def _lock_release_sync(self, name: str, token: str) -> bool:
415
+ with self._lock:
416
+ cur = self._conn.execute(
417
+ "DELETE FROM locks WHERE name=? AND token=?", (name, token)
418
+ )
419
+ return cur.rowcount > 0
420
+
421
+ # ------- Counters ----------
422
+
423
+ async def counter_incr(self, name: str, by: int) -> int:
424
+ return await asyncio.to_thread(self._counter_incr_sync, name, by)
425
+
426
+ def _counter_incr_sync(self, name: str, by: int) -> int:
427
+ with self._lock:
428
+ self._conn.execute(
429
+ "INSERT INTO counters(name, value) VALUES(?,0) "
430
+ "ON CONFLICT(name) DO NOTHING",
431
+ (name,),
432
+ )
433
+ self._conn.execute(
434
+ "UPDATE counters SET value = value + ? WHERE name=?",
435
+ (by, name),
436
+ )
437
+ row = self._conn.execute(
438
+ "SELECT value FROM counters WHERE name=?", (name,)
439
+ ).fetchone()
440
+ return int(row[0])
441
+
442
+ async def counter_get(self, name: str) -> int:
443
+ return await asyncio.to_thread(self._counter_get_sync, name)
444
+
445
+ def _counter_get_sync(self, name: str) -> int:
446
+ with self._lock:
447
+ row = self._conn.execute(
448
+ "SELECT value FROM counters WHERE name=?", (name,)
449
+ ).fetchone()
450
+ return int(row[0]) if row else 0
451
+
452
+ async def counter_set(self, name: str, value: int) -> None:
453
+ await asyncio.to_thread(self._counter_set_sync, name, value)
454
+
455
+ def _counter_set_sync(self, name: str, value: int) -> None:
456
+ with self._lock:
457
+ self._conn.execute(
458
+ "INSERT OR REPLACE INTO counters(name, value) VALUES(?,?)",
459
+ (name, value),
460
+ )
461
+
462
+ async def counter_del(self, name: str) -> None:
463
+ await asyncio.to_thread(self._counter_del_sync, name)
464
+
465
+ def _counter_del_sync(self, name: str) -> None:
466
+ with self._lock:
467
+ self._conn.execute("DELETE FROM counters WHERE name=?", (name,))
468
+
469
+ # ------- Queues ----------
470
+
471
+ async def queue_push(self, name: str, body: bytes, *, priority: int = 0) -> None:
472
+ await asyncio.to_thread(self._queue_push_sync, name, body, priority)
473
+
474
+ def _queue_push_sync(self, name: str, body: bytes, priority: int) -> None:
475
+ tier = _tier_for_priority(priority)
476
+ with self._lock:
477
+ self._conn.execute(
478
+ "INSERT INTO queues(name, tier, body) VALUES(?,?,?)",
479
+ (name, tier, body),
480
+ )
481
+
482
+ async def queue_size(self, name: str) -> int:
483
+ return await asyncio.to_thread(self._queue_size_sync, name)
484
+
485
+ def _queue_size_sync(self, name: str) -> int:
486
+ with self._lock:
487
+ row = self._conn.execute(
488
+ "SELECT COUNT(*) FROM queues WHERE name=?", (name,)
489
+ ).fetchone()
490
+ return int(row[0])
491
+
492
+ async def queue_peek(self, name: str, n: int) -> list[bytes]:
493
+ if n <= 0:
494
+ return []
495
+ return await asyncio.to_thread(self._queue_peek_sync, name, n)
496
+
497
+ def _queue_peek_sync(self, name: str, n: int) -> list[bytes]:
498
+ out: list[bytes] = []
499
+ with self._lock:
500
+ for tier in _TIER_ORDER:
501
+ remaining = n - len(out)
502
+ if remaining <= 0:
503
+ break
504
+ rows = self._conn.execute(
505
+ "SELECT body FROM queues WHERE name=? AND tier=? "
506
+ "ORDER BY id ASC LIMIT ?",
507
+ (name, tier, remaining),
508
+ ).fetchall()
509
+ out.extend(row[0] for row in rows)
510
+ return out
511
+
512
+ async def queue_reserve(
513
+ self, name: str, timeout_seconds: float, visibility_seconds: int
514
+ ) -> Optional[bytes]:
515
+ deadline = time.monotonic() + max(0.0, timeout_seconds)
516
+ backoff = 0.05
517
+ while True:
518
+ body = await asyncio.to_thread(self._queue_reserve_sync, name, visibility_seconds)
519
+ if body is not None:
520
+ return body
521
+ if time.monotonic() >= deadline:
522
+ return None
523
+ await asyncio.sleep(min(backoff, max(0.0, deadline - time.monotonic())))
524
+ backoff = min(backoff * 2, 0.5)
525
+
526
+ def _queue_reserve_sync(
527
+ self, name: str, visibility_seconds: int
528
+ ) -> Optional[bytes]:
529
+ now = time.time()
530
+ with self._lock:
531
+ # 1. Move due-scheduled into the queue (normal tier).
532
+ due = self._conn.execute(
533
+ "SELECT id, body FROM queue_scheduled WHERE name=? AND available_at<=?",
534
+ (name, now),
535
+ ).fetchall()
536
+ if due:
537
+ ids = [row[0] for row in due]
538
+ placeholders = ",".join("?" * len(ids))
539
+ for _id, body in due:
540
+ self._conn.execute(
541
+ "INSERT INTO queues(name, tier, body) VALUES(?,?,?)",
542
+ (name, "normal", body),
543
+ )
544
+ self._conn.execute(
545
+ f"DELETE FROM queue_scheduled WHERE id IN ({placeholders})",
546
+ ids,
547
+ )
548
+ # 2. Pop one body in priority order.
549
+ body: Optional[bytes] = None
550
+ for tier in _TIER_ORDER:
551
+ row = self._conn.execute(
552
+ "SELECT id, body FROM queues WHERE name=? AND tier=? "
553
+ "ORDER BY id ASC LIMIT 1",
554
+ (name, tier),
555
+ ).fetchone()
556
+ if row is not None:
557
+ self._conn.execute("DELETE FROM queues WHERE id=?", (row[0],))
558
+ body = row[1]
559
+ break
560
+ if body is None:
561
+ return None
562
+ # 3. Add to processing table.
563
+ try:
564
+ envelope = json.loads(body.decode("utf-8"))
565
+ task_id = str(envelope.get("id", ""))
566
+ except Exception:
567
+ task_id = ""
568
+ if task_id:
569
+ self._conn.execute(
570
+ "INSERT OR REPLACE INTO queue_processing(name, task_id, body, deadline) "
571
+ "VALUES(?,?,?,?)",
572
+ (name, task_id, body, now + max(1, visibility_seconds)),
573
+ )
574
+ return body
575
+
576
+ async def queue_ack(self, name: str, task_id: str) -> bool:
577
+ return await asyncio.to_thread(self._queue_ack_sync, name, task_id)
578
+
579
+ def _queue_ack_sync(self, name: str, task_id: str) -> bool:
580
+ with self._lock:
581
+ cur = self._conn.execute(
582
+ "DELETE FROM queue_processing WHERE name=? AND task_id=?",
583
+ (name, task_id),
584
+ )
585
+ return cur.rowcount > 0
586
+
587
+ async def queue_nack(
588
+ self, name: str, task_id: str, body: bytes, *, delay_seconds: float
589
+ ) -> bool:
590
+ return await asyncio.to_thread(
591
+ self._queue_nack_sync, name, task_id, body, delay_seconds,
592
+ )
593
+
594
+ def _queue_nack_sync(
595
+ self, name: str, task_id: str, body: bytes, delay_seconds: float
596
+ ) -> bool:
597
+ with self._lock:
598
+ cur = self._conn.execute(
599
+ "DELETE FROM queue_processing WHERE name=? AND task_id=?",
600
+ (name, task_id),
601
+ )
602
+ had = cur.rowcount > 0
603
+ if delay_seconds > 0:
604
+ self._conn.execute(
605
+ "INSERT INTO queue_scheduled(name, available_at, body) VALUES(?,?,?)",
606
+ (name, time.time() + delay_seconds, body),
607
+ )
608
+ else:
609
+ self._conn.execute(
610
+ "INSERT INTO queues(name, tier, body) VALUES(?,?,?)",
611
+ (name, "normal", body),
612
+ )
613
+ return had
614
+
615
+ async def queue_sweep_expired(self, name: str, now: Optional[float] = None) -> int:
616
+ return await asyncio.to_thread(self._queue_sweep_sync, name, now)
617
+
618
+ def _queue_sweep_sync(self, name: str, now: Optional[float]) -> int:
619
+ n = now if now is not None else time.time()
620
+ with self._lock:
621
+ rows = self._conn.execute(
622
+ "SELECT task_id, body FROM queue_processing WHERE name=? AND deadline<=?",
623
+ (name, n),
624
+ ).fetchall()
625
+ for task_id, body in rows:
626
+ self._conn.execute(
627
+ "INSERT INTO queues(name, tier, body) VALUES(?,?,?)",
628
+ (name, "normal", body),
629
+ )
630
+ self._conn.execute(
631
+ "DELETE FROM queue_processing WHERE name=? AND task_id=?",
632
+ (name, task_id),
633
+ )
634
+ return len(rows)
635
+
636
+ async def queue_dead(self, name: str, body: bytes) -> None:
637
+ await asyncio.to_thread(self._queue_dead_sync, name, body)
638
+
639
+ def _queue_dead_sync(self, name: str, body: bytes) -> None:
640
+ with self._lock:
641
+ self._conn.execute(
642
+ "INSERT INTO queue_dead(name, body) VALUES(?,?)", (name, body),
643
+ )
644
+
645
+ async def dlq_peek(self, name: str, n: int) -> list[bytes]:
646
+ return await asyncio.to_thread(self._dlq_peek_sync, name, n)
647
+
648
+ def _dlq_peek_sync(self, name: str, n: int) -> list[bytes]:
649
+ with self._lock:
650
+ rows = self._conn.execute(
651
+ "SELECT body FROM queue_dead WHERE name=? ORDER BY id ASC LIMIT ?",
652
+ (name, n),
653
+ ).fetchall()
654
+ return [r[0] for r in rows]
655
+
656
+ async def dlq_pop(self, name: str, n: int) -> list[bytes]:
657
+ return await asyncio.to_thread(self._dlq_pop_sync, name, n)
658
+
659
+ def _dlq_pop_sync(self, name: str, n: int) -> list[bytes]:
660
+ with self._lock:
661
+ rows = self._conn.execute(
662
+ "SELECT id, body FROM queue_dead WHERE name=? ORDER BY id ASC LIMIT ?",
663
+ (name, n),
664
+ ).fetchall()
665
+ ids = [r[0] for r in rows]
666
+ if ids:
667
+ placeholders = ",".join("?" * len(ids))
668
+ self._conn.execute(
669
+ f"DELETE FROM queue_dead WHERE id IN ({placeholders})", ids,
670
+ )
671
+ return [r[1] for r in rows]
672
+
673
+ async def dlq_length(self, name: str) -> int:
674
+ return await asyncio.to_thread(self._dlq_length_sync, name)
675
+
676
+ def _dlq_length_sync(self, name: str) -> int:
677
+ with self._lock:
678
+ row = self._conn.execute(
679
+ "SELECT COUNT(*) FROM queue_dead WHERE name=?", (name,)
680
+ ).fetchone()
681
+ return int(row[0])
682
+
683
+ async def dlq_clear(self, name: str) -> int:
684
+ return await asyncio.to_thread(self._dlq_clear_sync, name)
685
+
686
+ def _dlq_clear_sync(self, name: str) -> int:
687
+ with self._lock:
688
+ cur = self._conn.execute("DELETE FROM queue_dead WHERE name=?", (name,))
689
+ return cur.rowcount
690
+
691
+ # ------- Streams ----------
692
+
693
+ async def stream_push(
694
+ self,
695
+ name: str,
696
+ envelope: bytes,
697
+ *,
698
+ score: float,
699
+ max_len: int,
700
+ ttl_seconds: int,
701
+ ) -> None:
702
+ await asyncio.to_thread(
703
+ self._stream_push_sync, name, envelope, score, max_len, ttl_seconds,
704
+ )
705
+
706
+ def _stream_push_sync(
707
+ self, name: str, envelope: bytes, score: float, max_len: int, ttl_seconds: int,
708
+ ) -> None:
709
+ with self._lock:
710
+ self._conn.execute(
711
+ "INSERT INTO streams(name, score, body) VALUES(?,?,?)",
712
+ (name, score, envelope),
713
+ )
714
+ if ttl_seconds > 0:
715
+ cutoff = score - ttl_seconds
716
+ self._conn.execute(
717
+ "DELETE FROM streams WHERE name=? AND score<?", (name, cutoff),
718
+ )
719
+ if max_len > 0:
720
+ row = self._conn.execute(
721
+ "SELECT COUNT(*) FROM streams WHERE name=?", (name,)
722
+ ).fetchone()
723
+ count = int(row[0])
724
+ if count > max_len:
725
+ extra = count - max_len
726
+ self._conn.execute(
727
+ "DELETE FROM streams WHERE id IN ("
728
+ "SELECT id FROM streams WHERE name=? ORDER BY score ASC LIMIT ?)",
729
+ (name, extra),
730
+ )
731
+
732
+ async def stream_pop(self, name: str, n: int) -> list[bytes]:
733
+ return await asyncio.to_thread(self._stream_pop_sync, name, n)
734
+
735
+ def _stream_pop_sync(self, name: str, n: int) -> list[bytes]:
736
+ if n <= 0:
737
+ return []
738
+ with self._lock:
739
+ rows = self._conn.execute(
740
+ "SELECT id, body FROM streams WHERE name=? ORDER BY score ASC LIMIT ?",
741
+ (name, n),
742
+ ).fetchall()
743
+ ids = [r[0] for r in rows]
744
+ if ids:
745
+ placeholders = ",".join("?" * len(ids))
746
+ self._conn.execute(
747
+ f"DELETE FROM streams WHERE id IN ({placeholders})", ids,
748
+ )
749
+ return [r[1] for r in rows]
750
+
751
+ async def stream_peek(self, name: str, n: int) -> list[bytes]:
752
+ return await asyncio.to_thread(self._stream_peek_sync, name, n)
753
+
754
+ def _stream_peek_sync(self, name: str, n: int) -> list[bytes]:
755
+ with self._lock:
756
+ rows = self._conn.execute(
757
+ "SELECT body FROM streams WHERE name=? ORDER BY score ASC LIMIT ?",
758
+ (name, n),
759
+ ).fetchall()
760
+ return [r[0] for r in rows]
761
+
762
+ async def stream_length(self, name: str) -> int:
763
+ return await asyncio.to_thread(self._stream_length_sync, name)
764
+
765
+ def _stream_length_sync(self, name: str) -> int:
766
+ with self._lock:
767
+ row = self._conn.execute(
768
+ "SELECT COUNT(*) FROM streams WHERE name=?", (name,)
769
+ ).fetchone()
770
+ return int(row[0])
771
+
772
+ async def stream_trim(self, name: str, *, max_len: int, ttl_seconds: int) -> None:
773
+ await asyncio.to_thread(self._stream_trim_sync, name, max_len, ttl_seconds)
774
+
775
+ def _stream_trim_sync(self, name: str, max_len: int, ttl_seconds: int) -> None:
776
+ with self._lock:
777
+ if ttl_seconds > 0:
778
+ cutoff = time.time() - ttl_seconds
779
+ self._conn.execute(
780
+ "DELETE FROM streams WHERE name=? AND score<?", (name, cutoff),
781
+ )
782
+ if max_len > 0:
783
+ row = self._conn.execute(
784
+ "SELECT COUNT(*) FROM streams WHERE name=?", (name,)
785
+ ).fetchone()
786
+ count = int(row[0])
787
+ if count > max_len:
788
+ extra = count - max_len
789
+ self._conn.execute(
790
+ "DELETE FROM streams WHERE id IN ("
791
+ "SELECT id FROM streams WHERE name=? ORDER BY score ASC LIMIT ?)",
792
+ (name, extra),
793
+ )
794
+
795
+ async def event_publish(self, topic: str, body: bytes) -> None:
796
+ # No subscribers in a single-process sqlite deploy. Same caveat as
797
+ # InMemoryBackend: plugins that need pub/sub must use a real broker.
798
+ return None
799
+
800
+ # ------- Workers ----------
801
+
802
+ async def worker_register(
803
+ self, automation_type: str, worker_id: str, hardware: dict[str, Any]
804
+ ) -> None:
805
+ await asyncio.to_thread(self._worker_register_sync, automation_type, worker_id, hardware)
806
+
807
+ def _worker_register_sync(
808
+ self, automation_type: str, worker_id: str, hardware: dict[str, Any]
809
+ ) -> None:
810
+ hw_json = json.dumps(hardware)
811
+ with self._lock:
812
+ row = self._conn.execute(
813
+ "SELECT 1 FROM workers WHERE automation_type=? AND worker_id=?",
814
+ (automation_type, worker_id),
815
+ ).fetchone()
816
+ if row is None:
817
+ self._conn.execute(
818
+ "INSERT INTO workers(automation_type, worker_id, hardware, config, "
819
+ "config_gen, state, last_seen, last_error, stats) "
820
+ "VALUES(?,?,?,?,0,'IDLE',0,NULL,'{}')",
821
+ (automation_type, worker_id, hw_json, "{}"),
822
+ )
823
+ else:
824
+ self._conn.execute(
825
+ "UPDATE workers SET hardware=? WHERE automation_type=? AND worker_id=?",
826
+ (hw_json, automation_type, worker_id),
827
+ )
828
+
829
+ async def worker_get_config(
830
+ self, automation_type: str, worker_id: str
831
+ ) -> tuple[dict[str, Any], int]:
832
+ return await asyncio.to_thread(self._worker_get_config_sync, automation_type, worker_id)
833
+
834
+ def _worker_get_config_sync(
835
+ self, automation_type: str, worker_id: str
836
+ ) -> tuple[dict[str, Any], int]:
837
+ with self._lock:
838
+ row = self._conn.execute(
839
+ "SELECT config, config_gen FROM workers WHERE automation_type=? AND worker_id=?",
840
+ (automation_type, worker_id),
841
+ ).fetchone()
842
+ if row is None:
843
+ return {}, 0
844
+ return json.loads(row[0]), int(row[1])
845
+
846
+ async def worker_set_config(
847
+ self, automation_type: str, worker_id: str, config: dict[str, Any]
848
+ ) -> tuple[dict[str, Any], int]:
849
+ return await asyncio.to_thread(self._worker_set_config_sync, automation_type, worker_id, config)
850
+
851
+ def _worker_set_config_sync(
852
+ self, automation_type: str, worker_id: str, config: dict[str, Any]
853
+ ) -> tuple[dict[str, Any], int]:
854
+ cfg_json = json.dumps(config)
855
+ with self._lock:
856
+ row = self._conn.execute(
857
+ "SELECT config_gen FROM workers WHERE automation_type=? AND worker_id=?",
858
+ (automation_type, worker_id),
859
+ ).fetchone()
860
+ if row is None:
861
+ self._conn.execute(
862
+ "INSERT INTO workers(automation_type, worker_id, hardware, config, "
863
+ "config_gen, state, last_seen, last_error, stats) "
864
+ "VALUES(?,?,'{}',?,1,'IDLE',0,NULL,'{}')",
865
+ (automation_type, worker_id, cfg_json),
866
+ )
867
+ new_gen = 1
868
+ else:
869
+ new_gen = int(row[0]) + 1
870
+ self._conn.execute(
871
+ "UPDATE workers SET config=?, config_gen=? WHERE automation_type=? AND worker_id=?",
872
+ (cfg_json, new_gen, automation_type, worker_id),
873
+ )
874
+ return json.loads(cfg_json), new_gen
875
+
876
+ async def worker_set_state(
877
+ self,
878
+ automation_type: str,
879
+ worker_id: str,
880
+ state: str,
881
+ last_error: Optional[str],
882
+ last_seen: float,
883
+ ) -> None:
884
+ await asyncio.to_thread(
885
+ self._worker_set_state_sync, automation_type, worker_id, state, last_error, last_seen,
886
+ )
887
+
888
+ def _worker_set_state_sync(
889
+ self,
890
+ automation_type: str,
891
+ worker_id: str,
892
+ state: str,
893
+ last_error: Optional[str],
894
+ last_seen: float,
895
+ ) -> None:
896
+ with self._lock:
897
+ row = self._conn.execute(
898
+ "SELECT 1 FROM workers WHERE automation_type=? AND worker_id=?",
899
+ (automation_type, worker_id),
900
+ ).fetchone()
901
+ if row is None:
902
+ self._conn.execute(
903
+ "INSERT INTO workers(automation_type, worker_id, hardware, config, "
904
+ "config_gen, state, last_seen, last_error, stats) "
905
+ "VALUES(?,?,'{}','{}',0,?,?,?,'{}')",
906
+ (automation_type, worker_id, state, last_seen, last_error),
907
+ )
908
+ else:
909
+ self._conn.execute(
910
+ "UPDATE workers SET state=?, last_seen=?, last_error=? "
911
+ "WHERE automation_type=? AND worker_id=?",
912
+ (state, last_seen, last_error, automation_type, worker_id),
913
+ )
914
+
915
+ async def worker_set_stats(
916
+ self, automation_type: str, worker_id: str, stats: dict[str, Any]
917
+ ) -> None:
918
+ await asyncio.to_thread(self._worker_set_stats_sync, automation_type, worker_id, stats)
919
+
920
+ def _worker_set_stats_sync(
921
+ self, automation_type: str, worker_id: str, stats: dict[str, Any]
922
+ ) -> None:
923
+ stats_json = json.dumps(stats)
924
+ with self._lock:
925
+ row = self._conn.execute(
926
+ "SELECT 1 FROM workers WHERE automation_type=? AND worker_id=?",
927
+ (automation_type, worker_id),
928
+ ).fetchone()
929
+ if row is None:
930
+ self._conn.execute(
931
+ "INSERT INTO workers(automation_type, worker_id, hardware, config, "
932
+ "config_gen, state, last_seen, last_error, stats) "
933
+ "VALUES(?,?,'{}','{}',0,'IDLE',0,NULL,?)",
934
+ (automation_type, worker_id, stats_json),
935
+ )
936
+ else:
937
+ self._conn.execute(
938
+ "UPDATE workers SET stats=? WHERE automation_type=? AND worker_id=?",
939
+ (stats_json, automation_type, worker_id),
940
+ )
941
+
942
+ async def worker_stats_push(
943
+ self,
944
+ automation_type: str,
945
+ worker_id: str,
946
+ ts: float,
947
+ frame: dict[str, Any],
948
+ *,
949
+ max_entries: int = 240,
950
+ ) -> None:
951
+ await asyncio.to_thread(
952
+ self._worker_stats_push_sync, automation_type, worker_id, ts, frame, max_entries,
953
+ )
954
+
955
+ def _worker_stats_push_sync(
956
+ self,
957
+ automation_type: str,
958
+ worker_id: str,
959
+ ts: float,
960
+ frame: dict[str, Any],
961
+ max_entries: int,
962
+ ) -> None:
963
+ frame_json = json.dumps(frame, default=str)
964
+ with self._lock:
965
+ self._conn.execute(
966
+ "INSERT INTO worker_stats_history(automation_type, worker_id, ts, frame) "
967
+ "VALUES(?,?,?,?)",
968
+ (automation_type, worker_id, ts, frame_json),
969
+ )
970
+ if max_entries > 0:
971
+ row = self._conn.execute(
972
+ "SELECT COUNT(*) FROM worker_stats_history "
973
+ "WHERE automation_type=? AND worker_id=?",
974
+ (automation_type, worker_id),
975
+ ).fetchone()
976
+ count = int(row[0])
977
+ if count > max_entries:
978
+ extra = count - max_entries
979
+ self._conn.execute(
980
+ "DELETE FROM worker_stats_history WHERE rowid IN ("
981
+ "SELECT rowid FROM worker_stats_history "
982
+ "WHERE automation_type=? AND worker_id=? "
983
+ "ORDER BY ts ASC LIMIT ?)",
984
+ (automation_type, worker_id, extra),
985
+ )
986
+
987
+ async def worker_stats_history(
988
+ self,
989
+ automation_type: str,
990
+ worker_id: str,
991
+ *,
992
+ since: float = 0,
993
+ limit: int = 240,
994
+ ) -> list[dict[str, Any]]:
995
+ return await asyncio.to_thread(
996
+ self._worker_stats_history_sync, automation_type, worker_id, since, limit,
997
+ )
998
+
999
+ def _worker_stats_history_sync(
1000
+ self,
1001
+ automation_type: str,
1002
+ worker_id: str,
1003
+ since: float,
1004
+ limit: int,
1005
+ ) -> list[dict[str, Any]]:
1006
+ with self._lock:
1007
+ rows = self._conn.execute(
1008
+ "SELECT frame FROM worker_stats_history "
1009
+ "WHERE automation_type=? AND worker_id=? AND ts>=? "
1010
+ "ORDER BY ts ASC LIMIT ?",
1011
+ (automation_type, worker_id, since, limit),
1012
+ ).fetchall()
1013
+ return [json.loads(r[0]) for r in rows]
1014
+
1015
+ async def worker_get(
1016
+ self, automation_type: str, worker_id: str
1017
+ ) -> Optional[dict[str, Any]]:
1018
+ return await asyncio.to_thread(self._worker_get_sync, automation_type, worker_id)
1019
+
1020
+ def _worker_get_sync(
1021
+ self, automation_type: str, worker_id: str
1022
+ ) -> Optional[dict[str, Any]]:
1023
+ with self._lock:
1024
+ row = self._conn.execute(
1025
+ "SELECT hardware, config, config_gen, state, last_seen, last_error, stats "
1026
+ "FROM workers WHERE automation_type=? AND worker_id=?",
1027
+ (automation_type, worker_id),
1028
+ ).fetchone()
1029
+ if row is None:
1030
+ return None
1031
+ return {
1032
+ "hardware": json.loads(row[0]),
1033
+ "config": json.loads(row[1]),
1034
+ "config_gen": int(row[2]),
1035
+ "state": row[3],
1036
+ "last_seen": float(row[4]),
1037
+ "last_error": row[5],
1038
+ "stats": json.loads(row[6]),
1039
+ }
1040
+
1041
+ async def worker_list(
1042
+ self, automation_type: Optional[str] = None
1043
+ ) -> list[tuple[str, str]]:
1044
+ return await asyncio.to_thread(self._worker_list_sync, automation_type)
1045
+
1046
+ def _worker_list_sync(self, automation_type: Optional[str]) -> list[tuple[str, str]]:
1047
+ with self._lock:
1048
+ if automation_type is None:
1049
+ rows = self._conn.execute(
1050
+ "SELECT automation_type, worker_id FROM workers"
1051
+ ).fetchall()
1052
+ else:
1053
+ rows = self._conn.execute(
1054
+ "SELECT automation_type, worker_id FROM workers WHERE automation_type=?",
1055
+ (automation_type,),
1056
+ ).fetchall()
1057
+ return [(r[0], r[1]) for r in rows]
1058
+
1059
+ async def worker_remove(self, automation_type: str, worker_id: str) -> bool:
1060
+ return await asyncio.to_thread(self._worker_remove_sync, automation_type, worker_id)
1061
+
1062
+ def _worker_remove_sync(self, automation_type: str, worker_id: str) -> bool:
1063
+ with self._lock:
1064
+ cur = self._conn.execute(
1065
+ "DELETE FROM workers WHERE automation_type=? AND worker_id=?",
1066
+ (automation_type, worker_id),
1067
+ )
1068
+ self._conn.execute(
1069
+ "DELETE FROM worker_stats_history WHERE automation_type=? AND worker_id=?",
1070
+ (automation_type, worker_id),
1071
+ )
1072
+ return cur.rowcount > 0
1073
+
1074
+ async def catalog_set(self, automation_type: str, doc: bytes) -> None:
1075
+ await asyncio.to_thread(self._catalog_set_sync, automation_type, doc)
1076
+
1077
+ def _catalog_set_sync(self, automation_type: str, doc: bytes) -> None:
1078
+ with self._lock:
1079
+ self._conn.execute(
1080
+ "INSERT OR REPLACE INTO catalog(automation_type, doc) VALUES(?,?)",
1081
+ (automation_type, doc),
1082
+ )
1083
+
1084
+ async def catalog_get(self, automation_type: str) -> Optional[bytes]:
1085
+ return await asyncio.to_thread(self._catalog_get_sync, automation_type)
1086
+
1087
+ def _catalog_get_sync(self, automation_type: str) -> Optional[bytes]:
1088
+ with self._lock:
1089
+ row = self._conn.execute(
1090
+ "SELECT doc FROM catalog WHERE automation_type=?", (automation_type,),
1091
+ ).fetchone()
1092
+ return row[0] if row else None
1093
+
1094
+ async def catalog_all(self) -> dict[str, bytes]:
1095
+ return await asyncio.to_thread(self._catalog_all_sync)
1096
+
1097
+ def _catalog_all_sync(self) -> dict[str, bytes]:
1098
+ with self._lock:
1099
+ rows = self._conn.execute("SELECT automation_type, doc FROM catalog").fetchall()
1100
+ return {r[0]: r[1] for r in rows}
1101
+
1102
+
1103
+ def _path_from_url(url: str) -> str:
1104
+ if "://" not in url:
1105
+ return url
1106
+ scheme, rest = url.split("://", 1)
1107
+ if scheme.lower() not in ("sqlite", "file"):
1108
+ raise ValueError(f"SqliteBackend cannot handle scheme: {scheme}")
1109
+ if rest in ("", ":memory:"):
1110
+ return ":memory:"
1111
+ if rest.startswith("/"):
1112
+ # sqlite:///abs/path → /abs/path; sqlite:////abs/path also works
1113
+ return rest
1114
+ return rest
1115
+
1116
+
1117
+ __all__ = ["SqliteBackend"]