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
fleet/core/backend.py ADDED
@@ -0,0 +1,736 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import secrets
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from typing import Any, Optional, Protocol, runtime_checkable
9
+
10
+ import redis.asyncio as aioredis
11
+
12
+
13
+ @dataclass
14
+ class RawItem:
15
+ """Raw backend record. Plugins never see this — primitives wrap it."""
16
+ id: str
17
+ payload: bytes
18
+ tags: dict[str, Any]
19
+ expires_at: Optional[float]
20
+ in_use_until: Optional[float]
21
+
22
+
23
+ @runtime_checkable
24
+ class Backend(Protocol):
25
+ async def kv_set(self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]) -> None: ...
26
+ async def kv_setnx(
27
+ self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]
28
+ ) -> bool: ...
29
+ async def kv_get(self, ns: str, key: str) -> Optional[bytes]: ...
30
+ async def kv_del(self, ns: str, key: str) -> bool: ...
31
+
32
+ async def pool_put(
33
+ self,
34
+ name: str,
35
+ item_id: str,
36
+ payload: bytes,
37
+ tags: dict[str, Any],
38
+ ttl_seconds: Optional[int],
39
+ ) -> None: ...
40
+
41
+ async def pool_list(self, name: str) -> list[RawItem]: ...
42
+ async def pool_remove(self, name: str, item_id: str) -> bool: ...
43
+ async def pool_release(self, name: str, item_id: str) -> bool: ...
44
+
45
+ async def pool_claim_any(
46
+ self,
47
+ name: str,
48
+ where: dict[str, Any],
49
+ hold_seconds: int,
50
+ ) -> Optional[RawItem]: ...
51
+
52
+ async def lock_acquire(self, name: str, hold_seconds: int, wait_seconds: float) -> Optional[str]: ...
53
+ async def lock_release(self, name: str, token: str) -> bool: ...
54
+
55
+ async def counter_incr(self, name: str, by: int) -> int: ...
56
+ async def counter_get(self, name: str) -> int: ...
57
+ async def counter_set(self, name: str, value: int) -> None: ...
58
+ async def counter_del(self, name: str) -> None: ...
59
+
60
+ async def queue_push(self, name: str, body: bytes, *, priority: int = 0) -> None: ...
61
+ async def queue_size(self, name: str) -> int: ...
62
+ async def queue_peek(self, name: str, n: int) -> list[bytes]: ...
63
+ async def pool_size(self, name: str) -> int: ...
64
+ async def queue_reserve(
65
+ self, name: str, timeout_seconds: float, visibility_seconds: int
66
+ ) -> Optional[bytes]: ...
67
+ async def queue_ack(self, name: str, task_id: str) -> bool: ...
68
+ async def queue_nack(
69
+ self, name: str, task_id: str, body: bytes, *, delay_seconds: float
70
+ ) -> bool: ...
71
+ async def queue_sweep_expired(self, name: str, now: Optional[float] = None) -> int: ...
72
+ async def queue_dead(self, name: str, body: bytes) -> None: ...
73
+ async def dlq_peek(self, name: str, n: int) -> list[bytes]: ...
74
+ async def dlq_pop(self, name: str, n: int) -> list[bytes]: ...
75
+ async def dlq_length(self, name: str) -> int: ...
76
+ async def dlq_clear(self, name: str) -> int: ...
77
+
78
+ async def stream_push(
79
+ self,
80
+ name: str,
81
+ envelope: bytes,
82
+ *,
83
+ score: float,
84
+ max_len: int,
85
+ ttl_seconds: int,
86
+ ) -> None: ...
87
+ async def stream_pop(self, name: str, n: int) -> list[bytes]: ...
88
+ async def stream_peek(self, name: str, n: int) -> list[bytes]: ...
89
+ async def stream_length(self, name: str) -> int: ...
90
+ async def stream_trim(self, name: str, *, max_len: int, ttl_seconds: int) -> None: ...
91
+
92
+ async def event_publish(self, topic: str, body: bytes) -> None: ...
93
+
94
+ async def worker_register(
95
+ self, automation_type: str, worker_id: str, hardware: dict[str, Any]
96
+ ) -> None: ...
97
+ async def worker_get_config(
98
+ self, automation_type: str, worker_id: str
99
+ ) -> tuple[dict[str, Any], int]: ...
100
+ async def worker_set_config(
101
+ self, automation_type: str, worker_id: str, config: dict[str, Any]
102
+ ) -> tuple[dict[str, Any], int]: ...
103
+ async def worker_set_state(
104
+ self,
105
+ automation_type: str,
106
+ worker_id: str,
107
+ state: str,
108
+ last_error: Optional[str],
109
+ last_seen: float,
110
+ ) -> None: ...
111
+ async def worker_set_stats(
112
+ self, automation_type: str, worker_id: str, stats: dict[str, Any]
113
+ ) -> None: ...
114
+ async def worker_stats_push(
115
+ self,
116
+ automation_type: str,
117
+ worker_id: str,
118
+ ts: float,
119
+ frame: dict[str, Any],
120
+ *,
121
+ max_entries: int = 240,
122
+ ) -> None: ...
123
+ async def worker_stats_history(
124
+ self,
125
+ automation_type: str,
126
+ worker_id: str,
127
+ *,
128
+ since: float = 0,
129
+ limit: int = 240,
130
+ ) -> list[dict[str, Any]]: ...
131
+ async def worker_get(
132
+ self, automation_type: str, worker_id: str
133
+ ) -> Optional[dict[str, Any]]: ...
134
+ async def worker_list(
135
+ self, automation_type: Optional[str] = None
136
+ ) -> list[tuple[str, str]]: ...
137
+ async def worker_remove(self, automation_type: str, worker_id: str) -> bool: ...
138
+
139
+ async def catalog_set(self, automation_type: str, doc: bytes) -> None: ...
140
+ async def catalog_get(self, automation_type: str) -> Optional[bytes]: ...
141
+ async def catalog_all(self) -> dict[str, bytes]: ...
142
+
143
+
144
+ class RedisBackend:
145
+ KV_KEY = "fleet:kv:{ns}:{key}"
146
+ POOL_HASH = "fleet:pool:{name}"
147
+ POOL_CLAIM = "fleet:pool:{name}:claim:{id}"
148
+ LOCK_KEY = "fleet:lock:{name}"
149
+ COUNTER_KEY = "fleet:counter:{name}"
150
+ QUEUE_KEY = "fleet:queue:{name}"
151
+ QUEUE_HIGH = "fleet:queue:{name}:high"
152
+ QUEUE_LOW = "fleet:queue:{name}:low"
153
+ QUEUE_PROCESSING = "fleet:queue:{name}:processing" # ZSET task_id -> deadline
154
+ QUEUE_PROCESSING_BODY = "fleet:queue:{name}:processing:body" # HASH task_id -> body
155
+ QUEUE_SCHEDULED = "fleet:queue:{name}:scheduled" # ZSET body -> ready_at
156
+ QUEUE_DEAD = "fleet:queue:{name}:dead"
157
+ STREAM_KEY = "stream:{name}" # unprefixed: docs + tests refer to this layout
158
+ EVENT_CHANNEL = "event:{topic}"
159
+ CATALOG_HASH = "fleet:catalog"
160
+ WORKER_FIELD = "wkr:{at}:{wid}:{field}"
161
+ WORKER_TYPE_INDEX = "wkrs:{at}"
162
+ WORKER_ALL_INDEX = "wkrs:all"
163
+
164
+ def __init__(self, url: str = "redis://localhost:6379/0") -> None:
165
+ self._r: aioredis.Redis = aioredis.from_url(url, decode_responses=False)
166
+
167
+ @property
168
+ def r(self) -> aioredis.Redis:
169
+ return self._r
170
+
171
+ async def aclose(self) -> None:
172
+ try:
173
+ await self._r.aclose()
174
+ except Exception:
175
+ pass
176
+
177
+ async def kv_set(self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]) -> None:
178
+ k = self.KV_KEY.format(ns=ns, key=key)
179
+ if ttl_seconds is not None and ttl_seconds > 0:
180
+ await self._r.set(k, value, ex=ttl_seconds)
181
+ else:
182
+ await self._r.set(k, value)
183
+
184
+ async def kv_setnx(
185
+ self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]
186
+ ) -> bool:
187
+ k = self.KV_KEY.format(ns=ns, key=key)
188
+ if ttl_seconds is not None and ttl_seconds > 0:
189
+ res = await self._r.set(k, value, ex=ttl_seconds, nx=True)
190
+ else:
191
+ res = await self._r.set(k, value, nx=True)
192
+ return bool(res)
193
+
194
+ async def kv_get(self, ns: str, key: str) -> Optional[bytes]:
195
+ return await self._r.get(self.KV_KEY.format(ns=ns, key=key))
196
+
197
+ async def kv_del(self, ns: str, key: str) -> bool:
198
+ return bool(await self._r.delete(self.KV_KEY.format(ns=ns, key=key)))
199
+
200
+ # Pool = hash {item_id -> json blob}. Claims are separate keys with TTL
201
+ # so a crashed slot's claim auto-expires.
202
+
203
+ @staticmethod
204
+ def _encode_item(payload: bytes, tags: dict[str, Any], expires_at: Optional[float]) -> bytes:
205
+ from base64 import b64encode
206
+ return json.dumps({
207
+ "p": b64encode(payload).decode("ascii"),
208
+ "t": tags,
209
+ "x": expires_at,
210
+ }).encode("utf-8")
211
+
212
+ @staticmethod
213
+ def _decode_item(raw: bytes) -> tuple[bytes, dict[str, Any], Optional[float]]:
214
+ from base64 import b64decode
215
+ d = json.loads(raw.decode("utf-8"))
216
+ return b64decode(d["p"]), d["t"], d.get("x")
217
+
218
+ async def pool_put(
219
+ self,
220
+ name: str,
221
+ item_id: str,
222
+ payload: bytes,
223
+ tags: dict[str, Any],
224
+ ttl_seconds: Optional[int],
225
+ ) -> None:
226
+ expires_at = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
227
+ encoded = self._encode_item(payload, tags, expires_at)
228
+ await self._r.hset(self.POOL_HASH.format(name=name), item_id, encoded)
229
+ # Per-item expiry is enforced on read; opportunistic prune on every put.
230
+ await self._prune_pool(name)
231
+
232
+ async def _prune_pool(self, name: str) -> None:
233
+ key = self.POOL_HASH.format(name=name)
234
+ all_items = await self._r.hgetall(key)
235
+ now = time.time()
236
+ to_del: list[bytes] = []
237
+ for item_id, raw in all_items.items():
238
+ try:
239
+ _, _, expires_at = self._decode_item(raw)
240
+ except Exception:
241
+ to_del.append(item_id)
242
+ continue
243
+ if expires_at is not None and expires_at < now:
244
+ to_del.append(item_id)
245
+ if to_del:
246
+ await self._r.hdel(key, *to_del)
247
+
248
+ async def pool_list(self, name: str) -> list[RawItem]:
249
+ key = self.POOL_HASH.format(name=name)
250
+ raw_items = await self._r.hgetall(key)
251
+ now = time.time()
252
+ out: list[RawItem] = []
253
+ for item_id_b, raw in raw_items.items():
254
+ try:
255
+ payload, tags, expires_at = self._decode_item(raw)
256
+ except Exception:
257
+ continue
258
+ if expires_at is not None and expires_at < now:
259
+ continue
260
+ item_id = item_id_b.decode("utf-8") if isinstance(item_id_b, bytes) else item_id_b
261
+ claim_key = self.POOL_CLAIM.format(name=name, id=item_id)
262
+ ttl = await self._r.pttl(claim_key)
263
+ in_use_until = (now + ttl / 1000.0) if ttl > 0 else None
264
+ out.append(RawItem(
265
+ id=item_id,
266
+ payload=payload,
267
+ tags=tags,
268
+ expires_at=expires_at,
269
+ in_use_until=in_use_until,
270
+ ))
271
+ return out
272
+
273
+ async def pool_remove(self, name: str, item_id: str) -> bool:
274
+ deleted = await self._r.hdel(self.POOL_HASH.format(name=name), item_id)
275
+ await self._r.delete(self.POOL_CLAIM.format(name=name, id=item_id))
276
+ return bool(deleted)
277
+
278
+ async def pool_release(self, name: str, item_id: str) -> bool:
279
+ return bool(await self._r.delete(self.POOL_CLAIM.format(name=name, id=item_id)))
280
+
281
+ async def pool_claim_any(
282
+ self,
283
+ name: str,
284
+ where: dict[str, Any],
285
+ hold_seconds: int,
286
+ ) -> Optional[RawItem]:
287
+ # Atomic claim is SET NX EX per-item; on race we move on.
288
+ items = await self.pool_list(name)
289
+ items.sort(key=lambda x: (x.expires_at or 0), reverse=True)
290
+ for item in items:
291
+ if item.in_use_until is not None:
292
+ continue
293
+ if not all(item.tags.get(k) == v for k, v in where.items()):
294
+ continue
295
+ claim_key = self.POOL_CLAIM.format(name=name, id=item.id)
296
+ ok = await self._r.set(claim_key, "1", nx=True, ex=hold_seconds)
297
+ if ok:
298
+ return item
299
+ return None
300
+
301
+ async def lock_acquire(self, name: str, hold_seconds: int, wait_seconds: float) -> Optional[str]:
302
+ key = self.LOCK_KEY.format(name=name)
303
+ token = secrets.token_hex(16)
304
+ deadline = time.monotonic() + max(0.0, wait_seconds)
305
+ backoff = 0.05
306
+ while True:
307
+ ok = await self._r.set(key, token, nx=True, ex=max(1, hold_seconds))
308
+ if ok:
309
+ return token
310
+ if time.monotonic() >= deadline:
311
+ return None
312
+ import asyncio
313
+ await asyncio.sleep(min(backoff, deadline - time.monotonic()))
314
+ backoff = min(backoff * 2, 0.5)
315
+
316
+ async def lock_release(self, name: str, token: str) -> bool:
317
+ # Token-checked DEL: prevents releasing a lock that expired and was re-acquired.
318
+ script = (
319
+ 'if redis.call("GET", KEYS[1]) == ARGV[1] '
320
+ 'then return redis.call("DEL", KEYS[1]) else return 0 end'
321
+ )
322
+ key = self.LOCK_KEY.format(name=name)
323
+ result = await self._r.eval(script, 1, key, token)
324
+ return bool(result)
325
+
326
+ async def counter_incr(self, name: str, by: int) -> int:
327
+ return int(await self._r.incrby(self.COUNTER_KEY.format(name=name), by))
328
+
329
+ async def counter_get(self, name: str) -> int:
330
+ raw = await self._r.get(self.COUNTER_KEY.format(name=name))
331
+ return int(raw) if raw is not None else 0
332
+
333
+ async def counter_set(self, name: str, value: int) -> None:
334
+ await self._r.set(self.COUNTER_KEY.format(name=name), value)
335
+
336
+ async def counter_del(self, name: str) -> None:
337
+ await self._r.delete(self.COUNTER_KEY.format(name=name))
338
+
339
+ def _queue_key_for_priority(self, name: str, priority: int) -> str:
340
+ if priority > 0:
341
+ return self.QUEUE_HIGH.format(name=name)
342
+ if priority < 0:
343
+ return self.QUEUE_LOW.format(name=name)
344
+ return self.QUEUE_KEY.format(name=name)
345
+
346
+ def _queue_keys_priority_order(self, name: str) -> list[str]:
347
+ return [
348
+ self.QUEUE_HIGH.format(name=name),
349
+ self.QUEUE_KEY.format(name=name),
350
+ self.QUEUE_LOW.format(name=name),
351
+ ]
352
+
353
+ async def queue_push(self, name: str, body: bytes, *, priority: int = 0) -> None:
354
+ await self._r.rpush(self._queue_key_for_priority(name, priority), body)
355
+
356
+ async def queue_size(self, name: str) -> int:
357
+ # Includes high + normal + low so /metrics is honest.
358
+ total = 0
359
+ for k in self._queue_keys_priority_order(name):
360
+ total += int(await self._r.llen(k))
361
+ return total
362
+
363
+ async def queue_peek(self, name: str, n: int) -> list[bytes]:
364
+ if n <= 0:
365
+ return []
366
+ out: list[bytes] = []
367
+ for k in self._queue_keys_priority_order(name):
368
+ remaining = n - len(out)
369
+ if remaining <= 0:
370
+ break
371
+ chunk = await self._r.lrange(k, 0, remaining - 1)
372
+ out.extend(chunk)
373
+ return out
374
+
375
+ async def pool_size(self, name: str) -> int:
376
+ return int(await self._r.hlen(self.POOL_HASH.format(name=name)))
377
+
378
+ async def _drain_scheduled(self, name: str) -> None:
379
+ now = time.time()
380
+ sched = self.QUEUE_SCHEDULED.format(name=name)
381
+ due = await self._r.zrangebyscore(sched, 0, now)
382
+ if not due:
383
+ return
384
+ pipe = self._r.pipeline()
385
+ pipe.zremrangebyscore(sched, 0, now)
386
+ for body in due:
387
+ pipe.rpush(self.QUEUE_KEY.format(name=name), body)
388
+ await pipe.execute()
389
+
390
+ async def _wait_for_body(self, name: str, timeout_seconds: float) -> Optional[bytes]:
391
+ # BLPOP supports multi-key with priority order; first non-empty wins.
392
+ # 0 timeout means "forever", so sub-second falls back to a poll loop.
393
+ import asyncio
394
+ keys = self._queue_keys_priority_order(name)
395
+ if timeout_seconds >= 1.0:
396
+ res = await self._r.blpop(keys, timeout=int(timeout_seconds))
397
+ if res is None:
398
+ return None
399
+ _key, body = res
400
+ return body
401
+ deadline = time.monotonic() + max(0.0, timeout_seconds)
402
+ while time.monotonic() < deadline:
403
+ for k in keys:
404
+ body = await self._r.lpop(k)
405
+ if body is not None:
406
+ return body
407
+ await asyncio.sleep(0.05)
408
+ return None
409
+
410
+ async def queue_reserve(
411
+ self, name: str, timeout_seconds: float, visibility_seconds: int
412
+ ) -> Optional[bytes]:
413
+ await self._drain_scheduled(name)
414
+ body = await self._wait_for_body(name, timeout_seconds)
415
+ if body is None:
416
+ return None
417
+ try:
418
+ envelope = json.loads(body.decode("utf-8"))
419
+ task_id = str(envelope.get("id", ""))
420
+ except Exception:
421
+ task_id = uuid.uuid4().hex
422
+ if not task_id:
423
+ return body
424
+ deadline = time.time() + max(1, visibility_seconds)
425
+ pipe = self._r.pipeline()
426
+ pipe.zadd(self.QUEUE_PROCESSING.format(name=name), {task_id: deadline})
427
+ pipe.hset(self.QUEUE_PROCESSING_BODY.format(name=name), task_id, body)
428
+ await pipe.execute()
429
+ return body
430
+
431
+ async def queue_ack(self, name: str, task_id: str) -> bool:
432
+ pipe = self._r.pipeline()
433
+ pipe.zrem(self.QUEUE_PROCESSING.format(name=name), task_id)
434
+ pipe.hdel(self.QUEUE_PROCESSING_BODY.format(name=name), task_id)
435
+ results = await pipe.execute()
436
+ return bool(results and results[0])
437
+
438
+ async def queue_nack(
439
+ self, name: str, task_id: str, body: bytes, *, delay_seconds: float
440
+ ) -> bool:
441
+ # Remove the reservation; either re-enqueue immediately or schedule.
442
+ pipe = self._r.pipeline()
443
+ pipe.zrem(self.QUEUE_PROCESSING.format(name=name), task_id)
444
+ pipe.hdel(self.QUEUE_PROCESSING_BODY.format(name=name), task_id)
445
+ if delay_seconds > 0:
446
+ ready_at = time.time() + delay_seconds
447
+ pipe.zadd(self.QUEUE_SCHEDULED.format(name=name), {body: ready_at})
448
+ else:
449
+ pipe.rpush(self.QUEUE_KEY.format(name=name), body)
450
+ await pipe.execute()
451
+ return True
452
+
453
+ async def queue_sweep_expired(self, name: str, now: Optional[float] = None) -> int:
454
+ now = now if now is not None else time.time()
455
+ proc_key = self.QUEUE_PROCESSING.format(name=name)
456
+ body_key = self.QUEUE_PROCESSING_BODY.format(name=name)
457
+ expired_ids = await self._r.zrangebyscore(proc_key, 0, now)
458
+ if not expired_ids:
459
+ return 0
460
+ bodies = await self._r.hmget(body_key, expired_ids)
461
+ pipe = self._r.pipeline()
462
+ for tid, body in zip(expired_ids, bodies, strict=True):
463
+ if body is None:
464
+ continue
465
+ pipe.rpush(self.QUEUE_KEY.format(name=name), body)
466
+ pipe.zrem(proc_key, tid)
467
+ pipe.hdel(body_key, tid)
468
+ await pipe.execute()
469
+ return sum(1 for b in bodies if b is not None)
470
+
471
+ async def queue_dead(self, name: str, body: bytes) -> None:
472
+ await self._r.rpush(self.QUEUE_DEAD.format(name=name), body)
473
+
474
+ async def dlq_peek(self, name: str, n: int) -> list[bytes]:
475
+ return list(await self._r.lrange(self.QUEUE_DEAD.format(name=name), 0, max(0, n - 1)))
476
+
477
+ async def dlq_pop(self, name: str, n: int) -> list[bytes]:
478
+ key = self.QUEUE_DEAD.format(name=name)
479
+ out: list[bytes] = []
480
+ for _ in range(n):
481
+ b = await self._r.lpop(key)
482
+ if b is None:
483
+ break
484
+ out.append(b)
485
+ return out
486
+
487
+ async def dlq_length(self, name: str) -> int:
488
+ return int(await self._r.llen(self.QUEUE_DEAD.format(name=name)))
489
+
490
+ async def dlq_clear(self, name: str) -> int:
491
+ length = await self.dlq_length(name)
492
+ await self._r.delete(self.QUEUE_DEAD.format(name=name))
493
+ return length
494
+
495
+ async def stream_push(
496
+ self,
497
+ name: str,
498
+ envelope: bytes,
499
+ *,
500
+ score: float,
501
+ max_len: int,
502
+ ttl_seconds: int,
503
+ ) -> None:
504
+ key = self.STREAM_KEY.format(name=name)
505
+ pipe = self._r.pipeline()
506
+ pipe.zadd(key, {envelope: score})
507
+ if ttl_seconds > 0:
508
+ pipe.zremrangebyscore(key, 0, score - ttl_seconds)
509
+ if max_len > 0:
510
+ pipe.zremrangebyrank(key, 0, -(max_len + 1))
511
+ pipe.publish(self.EVENT_CHANNEL.format(topic=f"{name}.emit"), str(score))
512
+ await pipe.execute()
513
+
514
+ async def stream_pop(self, name: str, n: int) -> list[bytes]:
515
+ raw = await self._r.zpopmin(self.STREAM_KEY.format(name=name), n)
516
+ return [m for m, _score in raw]
517
+
518
+ async def stream_peek(self, name: str, n: int) -> list[bytes]:
519
+ return list(await self._r.zrange(self.STREAM_KEY.format(name=name), 0, n - 1))
520
+
521
+ async def stream_length(self, name: str) -> int:
522
+ return int(await self._r.zcard(self.STREAM_KEY.format(name=name)))
523
+
524
+ async def stream_trim(self, name: str, *, max_len: int, ttl_seconds: int) -> None:
525
+ key = self.STREAM_KEY.format(name=name)
526
+ now = time.time()
527
+ pipe = self._r.pipeline()
528
+ if ttl_seconds > 0:
529
+ pipe.zremrangebyscore(key, 0, now - ttl_seconds)
530
+ if max_len > 0:
531
+ pipe.zremrangebyrank(key, 0, -(max_len + 1))
532
+ await pipe.execute()
533
+
534
+ async def event_publish(self, topic: str, body: bytes) -> None:
535
+ await self._r.publish(self.EVENT_CHANNEL.format(topic=topic), body)
536
+
537
+ def _wkey(self, at: str, wid: str, field: str) -> str:
538
+ return self.WORKER_FIELD.format(at=at, wid=wid, field=field)
539
+
540
+ async def worker_register(
541
+ self, automation_type: str, worker_id: str, hardware: dict[str, Any]
542
+ ) -> None:
543
+ at, wid = automation_type, worker_id
544
+ await self._r.set(self._wkey(at, wid, "hardware"), json.dumps(hardware).encode("utf-8"))
545
+ await self._r.sadd(self.WORKER_TYPE_INDEX.format(at=at), wid)
546
+ await self._r.sadd(self.WORKER_ALL_INDEX, f"{at}|{wid}")
547
+ if not await self._r.exists(self._wkey(at, wid, "gen")):
548
+ await self._r.set(self._wkey(at, wid, "gen"), "0")
549
+ if not await self._r.exists(self._wkey(at, wid, "config")):
550
+ await self._r.set(self._wkey(at, wid, "config"), b"{}")
551
+
552
+ async def worker_get_config(
553
+ self, automation_type: str, worker_id: str
554
+ ) -> tuple[dict[str, Any], int]:
555
+ raw_cfg = await self._r.get(self._wkey(automation_type, worker_id, "config"))
556
+ raw_gen = await self._r.get(self._wkey(automation_type, worker_id, "gen"))
557
+ cfg = json.loads(raw_cfg.decode("utf-8")) if raw_cfg else {}
558
+ gen = int(raw_gen.decode("utf-8")) if raw_gen else 0
559
+ return cfg, gen
560
+
561
+ async def worker_set_config(
562
+ self, automation_type: str, worker_id: str, config: dict[str, Any]
563
+ ) -> tuple[dict[str, Any], int]:
564
+ at, wid = automation_type, worker_id
565
+ await self._r.set(self._wkey(at, wid, "config"), json.dumps(config).encode("utf-8"))
566
+ new_gen = await self._r.incr(self._wkey(at, wid, "gen"))
567
+ return config, int(new_gen)
568
+
569
+ async def worker_set_state(
570
+ self,
571
+ automation_type: str,
572
+ worker_id: str,
573
+ state: str,
574
+ last_error: Optional[str],
575
+ last_seen: float,
576
+ ) -> None:
577
+ key = self._wkey(automation_type, worker_id, "state")
578
+ await self._r.hset(
579
+ key,
580
+ mapping={
581
+ "state": state,
582
+ "last_seen": str(last_seen),
583
+ "last_error": last_error or "",
584
+ },
585
+ )
586
+
587
+ async def worker_set_stats(
588
+ self, automation_type: str, worker_id: str, stats: dict[str, Any]
589
+ ) -> None:
590
+ await self._r.set(
591
+ self._wkey(automation_type, worker_id, "stats"),
592
+ json.dumps(stats).encode("utf-8"),
593
+ )
594
+
595
+ async def worker_stats_push(
596
+ self,
597
+ automation_type: str,
598
+ worker_id: str,
599
+ ts: float,
600
+ frame: dict[str, Any],
601
+ *,
602
+ max_entries: int = 240,
603
+ ) -> None:
604
+ key = self._wkey(automation_type, worker_id, "stats:history")
605
+ body = json.dumps(frame, default=str).encode("utf-8")
606
+ pipe = self._r.pipeline()
607
+ pipe.zadd(key, {body: ts})
608
+ if max_entries > 0:
609
+ pipe.zremrangebyrank(key, 0, -(max_entries + 1))
610
+ await pipe.execute()
611
+
612
+ async def worker_stats_history(
613
+ self,
614
+ automation_type: str,
615
+ worker_id: str,
616
+ *,
617
+ since: float = 0,
618
+ limit: int = 240,
619
+ ) -> list[dict[str, Any]]:
620
+ key = self._wkey(automation_type, worker_id, "stats:history")
621
+ raw = await self._r.zrangebyscore(key, since, "+inf", start=0, num=limit)
622
+ out: list[dict[str, Any]] = []
623
+ for entry in raw:
624
+ try:
625
+ out.append(json.loads(entry.decode("utf-8") if isinstance(entry, bytes) else entry))
626
+ except Exception:
627
+ continue
628
+ return out
629
+
630
+ async def worker_get(
631
+ self, automation_type: str, worker_id: str
632
+ ) -> Optional[dict[str, Any]]:
633
+ at, wid = automation_type, worker_id
634
+ is_member = await self._r.sismember(self.WORKER_TYPE_INDEX.format(at=at), wid)
635
+ if not is_member:
636
+ return None
637
+ raw_hw = await self._r.get(self._wkey(at, wid, "hardware"))
638
+ raw_stats = await self._r.get(self._wkey(at, wid, "stats"))
639
+ cfg, gen = await self.worker_get_config(at, wid)
640
+ state_h = await self._r.hgetall(self._wkey(at, wid, "state"))
641
+ state_d = {
642
+ (k.decode("utf-8") if isinstance(k, bytes) else k):
643
+ (v.decode("utf-8") if isinstance(v, bytes) else v)
644
+ for k, v in state_h.items()
645
+ }
646
+ return {
647
+ "hardware": json.loads(raw_hw.decode("utf-8")) if raw_hw else {},
648
+ "config": cfg,
649
+ "config_gen": gen,
650
+ "state": state_d.get("state", "IDLE"),
651
+ "last_seen": float(state_d.get("last_seen") or 0),
652
+ "last_error": state_d.get("last_error") or None,
653
+ "stats": json.loads(raw_stats.decode("utf-8")) if raw_stats else {},
654
+ }
655
+
656
+ async def worker_list(
657
+ self, automation_type: Optional[str] = None
658
+ ) -> list[tuple[str, str]]:
659
+ if automation_type is None:
660
+ raw = await self._r.smembers(self.WORKER_ALL_INDEX)
661
+ out: list[tuple[str, str]] = []
662
+ for entry in raw:
663
+ s = entry.decode("utf-8") if isinstance(entry, bytes) else entry
664
+ at, _, wid = s.partition("|")
665
+ if wid:
666
+ out.append((at, wid))
667
+ return out
668
+ raw = await self._r.smembers(self.WORKER_TYPE_INDEX.format(at=automation_type))
669
+ return [
670
+ (automation_type, e.decode("utf-8") if isinstance(e, bytes) else e)
671
+ for e in raw
672
+ ]
673
+
674
+ async def worker_remove(self, automation_type: str, worker_id: str) -> bool:
675
+ at, wid = automation_type, worker_id
676
+ is_member = await self._r.sismember(self.WORKER_TYPE_INDEX.format(at=at), wid)
677
+ if not is_member:
678
+ return False
679
+ for field in ("hardware", "config", "gen", "state", "stats"):
680
+ await self._r.delete(self._wkey(at, wid, field))
681
+ await self._r.srem(self.WORKER_TYPE_INDEX.format(at=at), wid)
682
+ await self._r.srem(self.WORKER_ALL_INDEX, f"{at}|{wid}")
683
+ return True
684
+
685
+ async def catalog_set(self, automation_type: str, doc: bytes) -> None:
686
+ await self._r.hset(self.CATALOG_HASH, automation_type, doc)
687
+
688
+ async def catalog_get(self, automation_type: str) -> Optional[bytes]:
689
+ return await self._r.hget(self.CATALOG_HASH, automation_type)
690
+
691
+ async def catalog_all(self) -> dict[str, bytes]:
692
+ raw = await self._r.hgetall(self.CATALOG_HASH)
693
+ return {
694
+ (k.decode("utf-8") if isinstance(k, bytes) else k): v
695
+ for k, v in raw.items()
696
+ }
697
+
698
+
699
+ def backend_from_url(url: str) -> Backend:
700
+ """Construct a Backend from a URL by scheme.
701
+
702
+ Built-in schemes:
703
+ redis://... -> RedisBackend
704
+ rediss://... -> RedisBackend (TLS)
705
+ memory:// -> InMemoryBackend (single-process)
706
+
707
+ Third-party schemes can register via the `fleet.backends` entry-point
708
+ group. The entry-point name is the URL scheme; the loaded callable
709
+ receives the full URL and must return an object implementing Backend.
710
+ """
711
+ scheme = url.split("://", 1)[0].lower() if "://" in url else url.lower()
712
+ if scheme in ("redis", "rediss"):
713
+ return RedisBackend(url)
714
+ if scheme in ("memory", "mem"):
715
+ from fleet.core.memory_backend import InMemoryBackend
716
+ return InMemoryBackend(url)
717
+ if scheme in ("sqlite", "file"):
718
+ from fleet.core.sqlite_backend import SqliteBackend
719
+ return SqliteBackend(url)
720
+ try:
721
+ from importlib.metadata import entry_points
722
+ eps = entry_points(group="fleet.backends")
723
+ except Exception:
724
+ eps = []
725
+ for ep in eps:
726
+ if ep.name.lower() == scheme:
727
+ factory = ep.load()
728
+ return factory(url)
729
+ raise ValueError(
730
+ f"no backend registered for scheme '{scheme}'. "
731
+ "built-in: redis://, rediss://, memory://. "
732
+ "third-party: install a package that exposes a 'fleet.backends' entry-point."
733
+ )
734
+
735
+
736
+ __all__ = ["Backend", "RawItem", "RedisBackend", "backend_from_url"]