fleet-python 0.2.120__tar.gz → 0.2.122__tar.gz

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 (138) hide show
  1. {fleet_python-0.2.120/fleet_python.egg-info → fleet_python-0.2.122}/PKG-INFO +1 -1
  2. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/__init__.py +1 -1
  3. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/__init__.py +1 -1
  4. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/base.py +1 -1
  5. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/base.py +1 -1
  6. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/daemon.py +1 -3
  7. fleet_python-0.2.122/fleet/track/queue.py +206 -0
  8. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/uploader.py +4 -2
  9. {fleet_python-0.2.120 → fleet_python-0.2.122/fleet_python.egg-info}/PKG-INFO +1 -1
  10. {fleet_python-0.2.120 → fleet_python-0.2.122}/pyproject.toml +1 -1
  11. fleet_python-0.2.120/fleet/track/queue.py +0 -158
  12. {fleet_python-0.2.120 → fleet_python-0.2.122}/LICENSE +0 -0
  13. {fleet_python-0.2.120 → fleet_python-0.2.122}/README.md +0 -0
  14. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/diff_example.py +0 -0
  15. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/dsl_example.py +0 -0
  16. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example.py +0 -0
  17. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/exampleResume.py +0 -0
  18. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example_account.py +0 -0
  19. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example_action_log.py +0 -0
  20. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example_client.py +0 -0
  21. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example_mcp_anthropic.py +0 -0
  22. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example_mcp_openai.py +0 -0
  23. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example_sync.py +0 -0
  24. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example_task.py +0 -0
  25. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example_tasks.py +0 -0
  26. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/example_verifier.py +0 -0
  27. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/export_tasks.py +0 -0
  28. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/fetch_tasks.py +0 -0
  29. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/gemini_example.py +0 -0
  30. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/import_tasks.py +0 -0
  31. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/iterate_verifiers.py +0 -0
  32. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/json_tasks_example.py +0 -0
  33. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/nova_act_example.py +0 -0
  34. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/openai_example.py +0 -0
  35. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/openai_simple_example.py +0 -0
  36. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/query_builder_example.py +0 -0
  37. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/quickstart.py +0 -0
  38. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/task_bundle_editing/download_task.py +0 -0
  39. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/task_bundle_editing/launch_job.py +0 -0
  40. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/task_bundle_editing/upload_task.py +0 -0
  41. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/task_bundle_editing/validate_task.py +0 -0
  42. {fleet_python-0.2.120 → fleet_python-0.2.122}/examples/test_cdp_logging.py +0 -0
  43. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/client.py +0 -0
  44. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/env/__init__.py +0 -0
  45. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/env/client.py +0 -0
  46. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/exceptions.py +0 -0
  47. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/global_client.py +0 -0
  48. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/instance/__init__.py +0 -0
  49. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/instance/base.py +0 -0
  50. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/instance/client.py +0 -0
  51. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/models.py +0 -0
  52. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/resources/__init__.py +0 -0
  53. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/resources/api.py +0 -0
  54. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/resources/base.py +0 -0
  55. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/resources/browser.py +0 -0
  56. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/resources/mcp.py +0 -0
  57. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/resources/sqlite.py +0 -0
  58. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/tasks.py +0 -0
  59. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/verifiers/__init__.py +0 -0
  60. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/verifiers/bundler.py +0 -0
  61. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_async/verifiers/verifier.py +0 -0
  62. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/_supabase.py +0 -0
  63. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/__init__.py +0 -0
  64. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/gemini_cua/Dockerfile +0 -0
  65. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/gemini_cua/__init__.py +0 -0
  66. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/gemini_cua/agent.py +0 -0
  67. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/gemini_cua/mcp/main.py +0 -0
  68. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
  69. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
  70. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
  71. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/gemini_cua/requirements.txt +0 -0
  72. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/gemini_cua/start.sh +0 -0
  73. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/orchestrator.py +0 -0
  74. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/types.py +0 -0
  75. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/agent/utils.py +0 -0
  76. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/auth.py +0 -0
  77. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/cli.py +0 -0
  78. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/client.py +0 -0
  79. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/config.py +0 -0
  80. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/env/__init__.py +0 -0
  81. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/env/client.py +0 -0
  82. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/eval/__init__.py +0 -0
  83. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/eval/uploader.py +0 -0
  84. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/exceptions.py +0 -0
  85. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/global_client.py +0 -0
  86. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/instance/__init__.py +0 -0
  87. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/instance/base.py +0 -0
  88. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/instance/client.py +0 -0
  89. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/instance/models.py +0 -0
  90. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/models.py +0 -0
  91. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/proxy/__init__.py +0 -0
  92. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/proxy/proxy.py +0 -0
  93. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/proxy/whitelist.py +0 -0
  94. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/resources/__init__.py +0 -0
  95. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/resources/api.py +0 -0
  96. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/resources/base.py +0 -0
  97. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/resources/browser.py +0 -0
  98. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/resources/mcp.py +0 -0
  99. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/resources/sqlite.py +0 -0
  100. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/tasks.py +0 -0
  101. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/__init__.py +0 -0
  102. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/api.py +0 -0
  103. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/cli.py +0 -0
  104. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/install.py +0 -0
  105. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/merkle.py +0 -0
  106. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/scrubber.py +0 -0
  107. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/sources.py +0 -0
  108. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/status.py +0 -0
  109. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/track/watcher.py +0 -0
  110. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/types.py +0 -0
  111. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/utils/__init__.py +0 -0
  112. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/utils/http_logging.py +0 -0
  113. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/utils/logging.py +0 -0
  114. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/utils/playwright.py +0 -0
  115. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/verifiers/__init__.py +0 -0
  116. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/verifiers/bundler.py +0 -0
  117. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/verifiers/code.py +0 -0
  118. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/verifiers/db.py +0 -0
  119. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/verifiers/decorator.py +0 -0
  120. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/verifiers/parse.py +0 -0
  121. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/verifiers/sql_differ.py +0 -0
  122. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet/verifiers/verifier.py +0 -0
  123. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet_python.egg-info/SOURCES.txt +0 -0
  124. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet_python.egg-info/dependency_links.txt +0 -0
  125. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet_python.egg-info/entry_points.txt +0 -0
  126. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet_python.egg-info/requires.txt +0 -0
  127. {fleet_python-0.2.120 → fleet_python-0.2.122}/fleet_python.egg-info/top_level.txt +0 -0
  128. {fleet_python-0.2.120 → fleet_python-0.2.122}/scripts/fix_sync_imports.py +0 -0
  129. {fleet_python-0.2.120 → fleet_python-0.2.122}/scripts/unasync.py +0 -0
  130. {fleet_python-0.2.120 → fleet_python-0.2.122}/setup.cfg +0 -0
  131. {fleet_python-0.2.120 → fleet_python-0.2.122}/tests/__init__.py +0 -0
  132. {fleet_python-0.2.120 → fleet_python-0.2.122}/tests/test_app_method.py +0 -0
  133. {fleet_python-0.2.120 → fleet_python-0.2.122}/tests/test_expect_exactly.py +0 -0
  134. {fleet_python-0.2.120 → fleet_python-0.2.122}/tests/test_expect_only.py +0 -0
  135. {fleet_python-0.2.120 → fleet_python-0.2.122}/tests/test_instance_dispatch.py +0 -0
  136. {fleet_python-0.2.120 → fleet_python-0.2.122}/tests/test_sqlite_resource_dual_mode.py +0 -0
  137. {fleet_python-0.2.120 → fleet_python-0.2.122}/tests/test_sqlite_shared_memory_behavior.py +0 -0
  138. {fleet_python-0.2.120 → fleet_python-0.2.122}/tests/test_verifier_from_string.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.120
3
+ Version: 0.2.122
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -73,7 +73,7 @@ from . import env
73
73
  from . import global_client as _global_client
74
74
  from ._async import global_client as _async_global_client
75
75
 
76
- __version__ = "0.2.120"
76
+ __version__ = "0.2.122"
77
77
 
78
78
  __all__ = [
79
79
  # Core classes
@@ -44,7 +44,7 @@ from ..types import VerifierFunction
44
44
  from .. import env
45
45
  from . import global_client as _async_global_client
46
46
 
47
- __version__ = "0.2.120"
47
+ __version__ = "0.2.122"
48
48
 
49
49
  __all__ = [
50
50
  # Core classes
@@ -26,7 +26,7 @@ from .exceptions import (
26
26
  try:
27
27
  from .. import __version__
28
28
  except ImportError:
29
- __version__ = "0.2.120"
29
+ __version__ = "0.2.122"
30
30
 
31
31
  logger = logging.getLogger(__name__)
32
32
 
@@ -27,7 +27,7 @@ from .exceptions import (
27
27
  try:
28
28
  from . import __version__
29
29
  except ImportError:
30
- __version__ = "0.2.120"
30
+ __version__ = "0.2.122"
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
@@ -98,7 +98,6 @@ class Daemon:
98
98
  self._confirmed_map: dict[str, str] = {}
99
99
  self._confirmed_lock = threading.Lock()
100
100
  self._manifest_dirty = False # set when confirmed_map gains new entries
101
- self._drain_lock = threading.Lock() # serialises claim_batch across threads
102
101
 
103
102
  # ------------------------------------------------------------------ #
104
103
  # Public entry point #
@@ -273,8 +272,7 @@ class Daemon:
273
272
  if not self._pool:
274
273
  return
275
274
 
276
- with self._drain_lock:
277
- items = self._queue.claim_batch(n=32)
275
+ items = self._queue.claim_batch(n=32)
278
276
  if not items:
279
277
  return
280
278
 
@@ -0,0 +1,206 @@
1
+ """WAL-backed SQLite upload queue.
2
+
3
+ Survives daemon crashes. Items transition: pending → in_flight → done/failed.
4
+ Failed items are retried with exponential backoff.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from .merkle import STATE_DB, TRACK_DIR
18
+
19
+ log = logging.getLogger("fleet.track.queue")
20
+
21
+ MAX_ATTEMPTS = 10
22
+ BASE_BACKOFF_SECS = 0.5
23
+ MAX_BACKOFF_SECS = 1800 # 30 min
24
+
25
+
26
+ def _backoff(attempts: int) -> float:
27
+ return min(BASE_BACKOFF_SECS * (2 ** attempts), MAX_BACKOFF_SECS)
28
+
29
+
30
+ @dataclass
31
+ class QueueItem:
32
+ path: str # relative to $HOME
33
+ sha256: str
34
+ attempts: int
35
+ last_error: Optional[str]
36
+
37
+
38
+ class UploadQueue:
39
+ def __init__(self) -> None:
40
+ TRACK_DIR.mkdir(parents=True, exist_ok=True)
41
+ self._lock = threading.Lock()
42
+ self._conn = self._open_conn()
43
+
44
+ def _open_conn(self) -> sqlite3.Connection:
45
+ conn = sqlite3.connect(STATE_DB, timeout=10, check_same_thread=False)
46
+ conn.execute("PRAGMA journal_mode=WAL")
47
+ conn.execute("PRAGMA synchronous=NORMAL")
48
+ conn.execute("""
49
+ CREATE TABLE IF NOT EXISTS queue (
50
+ path TEXT NOT NULL,
51
+ sha256 TEXT NOT NULL,
52
+ status TEXT NOT NULL DEFAULT 'pending',
53
+ attempts INTEGER DEFAULT 0,
54
+ last_error TEXT,
55
+ next_attempt_at INTEGER NOT NULL DEFAULT 0,
56
+ enqueued_at INTEGER NOT NULL,
57
+ updated_at INTEGER NOT NULL,
58
+ UNIQUE(path, sha256)
59
+ )
60
+ """)
61
+ conn.execute("CREATE INDEX IF NOT EXISTS queue_status ON queue(status, next_attempt_at)")
62
+ conn.commit()
63
+
64
+ result = conn.execute("PRAGMA integrity_check").fetchone()
65
+ if result[0] != "ok":
66
+ conn.close()
67
+ self._wipe_and_reinit()
68
+ return self._open_conn()
69
+
70
+ return conn
71
+
72
+ def _wipe_and_reinit(self) -> None:
73
+ """Delete corrupt database files and start fresh. Queue items are lost but
74
+ the next reconcile will re-enqueue anything not yet on S3."""
75
+ log.warning("queue database is corrupt — wiping and reinitialising (next reconcile will re-upload missing files)")
76
+ for suffix in ("", "-shm", "-wal"):
77
+ p = Path(str(STATE_DB) + suffix)
78
+ if p.exists():
79
+ p.unlink(missing_ok=True)
80
+
81
+ def enqueue(self, path: str, sha256: str) -> None:
82
+ """Add path to the queue. Idempotent: same (path, sha256) is a no-op."""
83
+ now = int(time.time())
84
+ with self._lock:
85
+ self._conn.execute(
86
+ """
87
+ INSERT OR IGNORE INTO queue (path, sha256, status, next_attempt_at, enqueued_at, updated_at)
88
+ VALUES (?, ?, 'pending', 0, ?, ?)
89
+ """,
90
+ (path, sha256, now, now),
91
+ )
92
+ self._conn.commit()
93
+
94
+ def enqueue_batch(self, items: list[tuple[str, str]]) -> None:
95
+ now = int(time.time())
96
+ with self._lock:
97
+ self._conn.executemany(
98
+ """
99
+ INSERT OR IGNORE INTO queue (path, sha256, status, next_attempt_at, enqueued_at, updated_at)
100
+ VALUES (?, ?, 'pending', 0, ?, ?)
101
+ """,
102
+ [(path, sha256, now, now) for path, sha256 in items],
103
+ )
104
+ self._conn.commit()
105
+
106
+ def claim_batch(self, n: int = 16) -> list[QueueItem]:
107
+ """Atomically claim up to n pending items for upload."""
108
+ now = int(time.time())
109
+ with self._lock:
110
+ rows = self._conn.execute(
111
+ """
112
+ SELECT path, sha256, attempts, last_error FROM queue
113
+ WHERE status = 'pending' AND next_attempt_at <= ?
114
+ ORDER BY enqueued_at ASC
115
+ LIMIT ?
116
+ """,
117
+ (now, n),
118
+ ).fetchall()
119
+
120
+ if not rows:
121
+ return []
122
+
123
+ # Match on (path, sha256) — the natural key — so we never transition a
124
+ # row that wasn't in the SELECT result. Two rows can share a path with
125
+ # different sha256 values; path-only matching would orphan the extra row
126
+ # (set to in_flight with no upload attempted and no callback to resolve it).
127
+ placeholders = ",".join("(?,?)" for _ in rows)
128
+ pairs = [v for r in rows for v in (r[0], r[1])]
129
+ self._conn.execute(
130
+ f"UPDATE queue SET status = 'in_flight', updated_at = ? WHERE status = 'pending' AND (path, sha256) IN ({placeholders})",
131
+ [now] + pairs,
132
+ )
133
+ self._conn.commit()
134
+
135
+ return [QueueItem(r[0], r[1], r[2], r[3]) for r in rows]
136
+
137
+ def mark_done(self, path: str, sha256: str) -> None:
138
+ now = int(time.time())
139
+ with self._lock:
140
+ self._conn.execute(
141
+ "UPDATE queue SET status = 'done', updated_at = ? WHERE path = ? AND sha256 = ?",
142
+ (now, path, sha256),
143
+ )
144
+ # Prune rows for the same path that were enqueued *before* this one —
145
+ # they are older versions superseded by this upload.
146
+ # Rows enqueued *after* (newer file content) are preserved so they
147
+ # upload without waiting for the next reconcile.
148
+ self._conn.execute(
149
+ """
150
+ DELETE FROM queue
151
+ WHERE path = ? AND sha256 != ? AND status IN ('failed', 'pending')
152
+ AND enqueued_at <= (
153
+ SELECT enqueued_at FROM queue WHERE path = ? AND sha256 = ?
154
+ )
155
+ """,
156
+ (path, sha256, path, sha256),
157
+ )
158
+ self._conn.commit()
159
+
160
+ def mark_failed(self, path: str, sha256: str, error: str) -> None:
161
+ now = int(time.time())
162
+ with self._lock:
163
+ row = self._conn.execute(
164
+ "SELECT attempts FROM queue WHERE path = ? AND sha256 = ?", (path, sha256)
165
+ ).fetchone()
166
+ attempts = (row[0] if row else 0) + 1
167
+ status = "failed" if attempts >= MAX_ATTEMPTS else "pending"
168
+ next_attempt = now + int(_backoff(attempts))
169
+ self._conn.execute(
170
+ """
171
+ UPDATE queue
172
+ SET status = ?, attempts = ?, last_error = ?, next_attempt_at = ?, updated_at = ?
173
+ WHERE path = ? AND sha256 = ?
174
+ """,
175
+ (status, attempts, error[:500], next_attempt, now, path, sha256),
176
+ )
177
+ self._conn.commit()
178
+
179
+ def reset_failed(self) -> int:
180
+ """Re-queue permanently failed items (used by reconciliation loop)."""
181
+ now = int(time.time())
182
+ with self._lock:
183
+ cur = self._conn.execute(
184
+ "UPDATE queue SET status = 'pending', attempts = 0, next_attempt_at = 0, updated_at = ? WHERE status = 'failed'",
185
+ (now,),
186
+ )
187
+ self._conn.commit()
188
+ return cur.rowcount
189
+
190
+ def remove_done(self) -> None:
191
+ """Purge successfully uploaded items older than 24h."""
192
+ cutoff = int(time.time()) - 86400
193
+ with self._lock:
194
+ self._conn.execute("DELETE FROM queue WHERE status = 'done' AND updated_at < ?", (cutoff,))
195
+ self._conn.commit()
196
+
197
+ def stats(self) -> dict[str, int]:
198
+ with self._lock:
199
+ rows = self._conn.execute(
200
+ "SELECT status, COUNT(*) FROM queue GROUP BY status"
201
+ ).fetchall()
202
+ return {row[0]: row[1] for row in rows}
203
+
204
+ def close(self) -> None:
205
+ with self._lock:
206
+ self._conn.close()
@@ -19,7 +19,9 @@ from .scrubber import scrub_bytes
19
19
  log = logging.getLogger("fleet.track.uploader")
20
20
 
21
21
  WORKERS = 8
22
- UPLOAD_TIMEOUT_SECS = 120
22
+ # Per-phase timeouts: connect/read can be short, write needs to be generous
23
+ # for large JSONL files on slow connections.
24
+ UPLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=300.0, pool=10.0)
23
25
 
24
26
 
25
27
  def _read_safe(path: Path) -> Optional[bytes]:
@@ -55,7 +57,7 @@ def upload_one(path: Path, presigned_url: str) -> bool:
55
57
  presigned_url,
56
58
  content=scrubbed,
57
59
  headers={"Content-Type": "application/octet-stream"},
58
- timeout=UPLOAD_TIMEOUT_SECS,
60
+ timeout=UPLOAD_TIMEOUT,
59
61
  )
60
62
  if resp.status_code not in (200, 204):
61
63
  log.warning("upload %s → HTTP %s", path.name, resp.status_code)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleet-python
3
- Version: 0.2.120
3
+ Version: 0.2.122
4
4
  Summary: Python SDK for Fleet environments
5
5
  Author-email: Fleet AI <nic@fleet.so>
6
6
  License: Apache-2.0
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "fleet-python"
7
7
 
8
- version = "0.2.120"
8
+ version = "0.2.122"
9
9
  description = "Python SDK for Fleet environments"
10
10
  authors = [
11
11
  {name = "Fleet AI", email = "nic@fleet.so"},
@@ -1,158 +0,0 @@
1
- """WAL-backed SQLite upload queue.
2
-
3
- Survives daemon crashes. Items transition: pending → in_flight → done/failed.
4
- Failed items are retried with exponential backoff.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import sqlite3
10
- import time
11
- from dataclasses import dataclass
12
- from pathlib import Path
13
- from typing import Optional
14
-
15
- from .merkle import STATE_DB, TRACK_DIR
16
-
17
- MAX_ATTEMPTS = 10
18
- BASE_BACKOFF_SECS = 0.5
19
- MAX_BACKOFF_SECS = 1800 # 30 min
20
-
21
-
22
- def _backoff(attempts: int) -> float:
23
- return min(BASE_BACKOFF_SECS * (2 ** attempts), MAX_BACKOFF_SECS)
24
-
25
-
26
- @dataclass
27
- class QueueItem:
28
- path: str # relative to $HOME
29
- sha256: str
30
- attempts: int
31
- last_error: Optional[str]
32
-
33
-
34
- class UploadQueue:
35
- def __init__(self) -> None:
36
- TRACK_DIR.mkdir(parents=True, exist_ok=True)
37
- self._conn = sqlite3.connect(STATE_DB, timeout=10, check_same_thread=False)
38
- self._conn.execute("PRAGMA journal_mode=WAL")
39
- self._conn.execute("PRAGMA synchronous=NORMAL")
40
- self._conn.execute("""
41
- CREATE TABLE IF NOT EXISTS queue (
42
- path TEXT NOT NULL,
43
- sha256 TEXT NOT NULL,
44
- status TEXT NOT NULL DEFAULT 'pending',
45
- attempts INTEGER DEFAULT 0,
46
- last_error TEXT,
47
- next_attempt_at INTEGER NOT NULL DEFAULT 0,
48
- enqueued_at INTEGER NOT NULL,
49
- updated_at INTEGER NOT NULL,
50
- UNIQUE(path, sha256)
51
- )
52
- """)
53
- self._conn.execute("CREATE INDEX IF NOT EXISTS queue_status ON queue(status, next_attempt_at)")
54
- self._conn.commit()
55
-
56
- def enqueue(self, path: str, sha256: str) -> None:
57
- """Add path to the queue. Idempotent: same (path, sha256) is a no-op."""
58
- now = int(time.time())
59
- self._conn.execute(
60
- """
61
- INSERT OR IGNORE INTO queue (path, sha256, status, next_attempt_at, enqueued_at, updated_at)
62
- VALUES (?, ?, 'pending', 0, ?, ?)
63
- """,
64
- (path, sha256, now, now),
65
- )
66
- self._conn.commit()
67
-
68
- def enqueue_batch(self, items: list[tuple[str, str]]) -> None:
69
- now = int(time.time())
70
- self._conn.executemany(
71
- """
72
- INSERT OR IGNORE INTO queue (path, sha256, status, next_attempt_at, enqueued_at, updated_at)
73
- VALUES (?, ?, 'pending', 0, ?, ?)
74
- """,
75
- [(path, sha256, now, now) for path, sha256 in items],
76
- )
77
- self._conn.commit()
78
-
79
- def claim_batch(self, n: int = 16) -> list[QueueItem]:
80
- """Atomically claim up to n pending items for upload."""
81
- now = int(time.time())
82
- rows = self._conn.execute(
83
- """
84
- SELECT path, sha256, attempts, last_error FROM queue
85
- WHERE status = 'pending' AND next_attempt_at <= ?
86
- ORDER BY enqueued_at ASC
87
- LIMIT ?
88
- """,
89
- (now, n),
90
- ).fetchall()
91
-
92
- if not rows:
93
- return []
94
-
95
- # Match on (path, sha256) — the natural key — so we never transition a
96
- # row that wasn't in the SELECT result. Two rows can share a path with
97
- # different sha256 values; path-only matching would orphan the extra row
98
- # (set to in_flight with no upload attempted and no callback to resolve it).
99
- placeholders = ",".join("(?,?)" for _ in rows)
100
- pairs = [v for r in rows for v in (r[0], r[1])]
101
- self._conn.execute(
102
- f"UPDATE queue SET status = 'in_flight', updated_at = ? WHERE status = 'pending' AND (path, sha256) IN ({placeholders})",
103
- [now] + pairs,
104
- )
105
- self._conn.commit()
106
-
107
- return [QueueItem(r[0], r[1], r[2], r[3]) for r in rows]
108
-
109
- def mark_done(self, path: str, sha256: str) -> None:
110
- now = int(time.time())
111
- self._conn.execute(
112
- "UPDATE queue SET status = 'done', updated_at = ? WHERE path = ? AND sha256 = ?",
113
- (now, path, sha256),
114
- )
115
- self._conn.commit()
116
-
117
- def mark_failed(self, path: str, sha256: str, error: str) -> None:
118
- now = int(time.time())
119
- row = self._conn.execute(
120
- "SELECT attempts FROM queue WHERE path = ? AND sha256 = ?", (path, sha256)
121
- ).fetchone()
122
- attempts = (row[0] if row else 0) + 1
123
- status = "failed" if attempts >= MAX_ATTEMPTS else "pending"
124
- next_attempt = now + int(_backoff(attempts))
125
- self._conn.execute(
126
- """
127
- UPDATE queue
128
- SET status = ?, attempts = ?, last_error = ?, next_attempt_at = ?, updated_at = ?
129
- WHERE path = ? AND sha256 = ?
130
- """,
131
- (status, attempts, error[:500], next_attempt, now, path, sha256),
132
- )
133
- self._conn.commit()
134
-
135
- def reset_failed(self) -> int:
136
- """Re-queue permanently failed items (used by reconciliation loop)."""
137
- now = int(time.time())
138
- cur = self._conn.execute(
139
- "UPDATE queue SET status = 'pending', attempts = 0, next_attempt_at = 0, updated_at = ? WHERE status = 'failed'",
140
- (now,),
141
- )
142
- self._conn.commit()
143
- return cur.rowcount
144
-
145
- def remove_done(self) -> None:
146
- """Purge successfully uploaded items older than 24h."""
147
- cutoff = int(time.time()) - 86400
148
- self._conn.execute("DELETE FROM queue WHERE status = 'done' AND updated_at < ?", (cutoff,))
149
- self._conn.commit()
150
-
151
- def stats(self) -> dict[str, int]:
152
- rows = self._conn.execute(
153
- "SELECT status, COUNT(*) FROM queue GROUP BY status"
154
- ).fetchall()
155
- return {row[0]: row[1] for row in rows}
156
-
157
- def close(self) -> None:
158
- self._conn.close()
File without changes
File without changes
File without changes