fleet-python 0.2.12__py3-none-any.whl → 0.2.15__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.
Potentially problematic release.
This version of fleet-python might be problematic. Click here for more details.
- examples/diff_example.py +161 -0
- examples/dsl_example.py +50 -1
- examples/example.py +1 -1
- examples/example_action_log.py +28 -0
- examples/example_mcp_anthropic.py +77 -0
- examples/example_mcp_openai.py +27 -0
- examples/example_sync.py +1 -1
- examples/example_task.py +199 -0
- examples/example_verifier.py +71 -0
- examples/query_builder_example.py +117 -0
- fleet/__init__.py +51 -40
- fleet/_async/base.py +15 -2
- fleet/_async/client.py +141 -23
- fleet/_async/env/client.py +5 -5
- fleet/_async/instance/__init__.py +2 -3
- fleet/_async/instance/base.py +5 -2
- fleet/_async/instance/client.py +5 -4
- fleet/_async/playwright.py +2 -2
- fleet/_async/resources/base.py +1 -1
- fleet/_async/resources/browser.py +1 -1
- fleet/_async/resources/sqlite.py +656 -2
- fleet/_async/tasks.py +44 -0
- fleet/_async/verifiers/__init__.py +17 -0
- fleet/_async/verifiers/bundler.py +699 -0
- fleet/_async/verifiers/verifier.py +301 -0
- fleet/base.py +14 -1
- fleet/client.py +650 -17
- fleet/config.py +2 -1
- fleet/instance/__init__.py +1 -2
- fleet/instance/base.py +5 -2
- fleet/instance/client.py +16 -6
- fleet/models.py +171 -4
- fleet/resources/browser.py +7 -8
- fleet/resources/mcp.py +60 -0
- fleet/resources/sqlite.py +654 -0
- fleet/tasks.py +44 -0
- fleet/types.py +18 -0
- fleet/verifiers/__init__.py +11 -5
- fleet/verifiers/bundler.py +699 -0
- fleet/verifiers/decorator.py +103 -0
- fleet/verifiers/verifier.py +301 -0
- {fleet_python-0.2.12.dist-info → fleet_python-0.2.15.dist-info}/METADATA +3 -42
- fleet_python-0.2.15.dist-info/RECORD +69 -0
- scripts/fix_sync_imports.py +30 -12
- fleet/_async/config.py +0 -8
- fleet/_async/instance/models.py +0 -141
- fleet/_async/models.py +0 -109
- fleet_python-0.2.12.dist-info/RECORD +0 -55
- {fleet_python-0.2.12.dist-info → fleet_python-0.2.15.dist-info}/WHEEL +0 -0
- {fleet_python-0.2.12.dist-info → fleet_python-0.2.15.dist-info}/licenses/LICENSE +0 -0
- {fleet_python-0.2.12.dist-info → fleet_python-0.2.15.dist-info}/top_level.txt +0 -0
fleet/client.py
CHANGED
|
@@ -14,44 +14,158 @@
|
|
|
14
14
|
|
|
15
15
|
"""Fleet API Client for making HTTP requests to Fleet services."""
|
|
16
16
|
|
|
17
|
-
import
|
|
17
|
+
import base64
|
|
18
|
+
import cloudpickle
|
|
18
19
|
import httpx
|
|
19
20
|
import logging
|
|
20
|
-
|
|
21
|
+
import os
|
|
22
|
+
from typing import List, Optional
|
|
23
|
+
import json
|
|
24
|
+
import time
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from typing import Dict, List, Any, Tuple
|
|
21
27
|
|
|
22
28
|
from .base import EnvironmentBase, SyncWrapper
|
|
23
|
-
from .models import
|
|
29
|
+
from .models import (
|
|
30
|
+
InstanceRequest,
|
|
31
|
+
InstanceRecord,
|
|
32
|
+
Environment as EnvironmentModel,
|
|
33
|
+
VerifiersCheckResponse,
|
|
34
|
+
VerificationResponse,
|
|
35
|
+
VerifiersExecuteResponse,
|
|
36
|
+
ToolLogEntry,
|
|
37
|
+
ActionLogEntry,
|
|
38
|
+
EnvironmentSnapshot,
|
|
39
|
+
SnapshotValidation,
|
|
40
|
+
ToolLogResponse,
|
|
41
|
+
ToolSessionStartRequest,
|
|
42
|
+
ToolSessionStartResponse,
|
|
43
|
+
ToolLogQueryRequest,
|
|
44
|
+
)
|
|
24
45
|
|
|
25
46
|
from .instance import (
|
|
26
47
|
InstanceClient,
|
|
27
48
|
ResetRequest,
|
|
28
49
|
ResetResponse,
|
|
29
|
-
ValidatorType,
|
|
30
50
|
ExecuteFunctionResponse,
|
|
31
51
|
)
|
|
32
|
-
from .config import DEFAULT_MAX_RETRIES, REGION_BASE_URL
|
|
52
|
+
from .config import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, REGION_BASE_URL
|
|
33
53
|
from .instance.base import default_httpx_client
|
|
54
|
+
from .instance.client import ValidatorType
|
|
55
|
+
from .instance.models import (
|
|
56
|
+
Resource as ResourceModel,
|
|
57
|
+
CDPDescribeResponse, # Add this
|
|
58
|
+
)
|
|
34
59
|
from .resources.base import Resource
|
|
35
60
|
from .resources.sqlite import SQLiteResource
|
|
36
61
|
from .resources.browser import BrowserResource
|
|
62
|
+
from .resources.mcp import MCPResource
|
|
37
63
|
|
|
38
64
|
logger = logging.getLogger(__name__)
|
|
39
65
|
|
|
40
66
|
|
|
67
|
+
class LoggingBrowserResource(BrowserResource):
|
|
68
|
+
"""Browser resource wrapper that automatically logs all tool usage."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
resource: ResourceModel,
|
|
73
|
+
client: "SyncWrapper",
|
|
74
|
+
session_id: Optional[str] = None,
|
|
75
|
+
):
|
|
76
|
+
super().__init__(resource, client)
|
|
77
|
+
self._session_id = session_id
|
|
78
|
+
|
|
79
|
+
def _log_tool_action(
|
|
80
|
+
self,
|
|
81
|
+
action: str,
|
|
82
|
+
parameters: Dict[str, Any],
|
|
83
|
+
result: Any = None,
|
|
84
|
+
error: str = None,
|
|
85
|
+
):
|
|
86
|
+
"""Log a tool action to the tool_log table."""
|
|
87
|
+
if not self._session_id:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
start_time = time.time()
|
|
91
|
+
try:
|
|
92
|
+
self.client.request(
|
|
93
|
+
"POST",
|
|
94
|
+
"/log-tool",
|
|
95
|
+
json={
|
|
96
|
+
"tool_name": "browser",
|
|
97
|
+
"action": action,
|
|
98
|
+
"parameters": parameters,
|
|
99
|
+
"result": result if result else {},
|
|
100
|
+
"success": error is None,
|
|
101
|
+
"error": error,
|
|
102
|
+
"duration_ms": int((time.time() - start_time) * 1000),
|
|
103
|
+
"session_id": self._session_id,
|
|
104
|
+
"user_agent": "fleet-sdk",
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.warning(f"Failed to log tool action: {e}")
|
|
109
|
+
|
|
110
|
+
def start(self, width: int = 1920, height: int = 1080) -> CDPDescribeResponse:
|
|
111
|
+
"""Start browser and log the action."""
|
|
112
|
+
parameters = {
|
|
113
|
+
"width": width,
|
|
114
|
+
"height": height,
|
|
115
|
+
"resolution": f"{width}x{height}",
|
|
116
|
+
}
|
|
117
|
+
try:
|
|
118
|
+
result = super().start(width, height)
|
|
119
|
+
self._log_tool_action("start", parameters, {"success": True})
|
|
120
|
+
return result
|
|
121
|
+
except Exception as e:
|
|
122
|
+
self._log_tool_action("start", parameters, error=str(e))
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
def screenshot(self) -> Dict[str, Any]:
|
|
126
|
+
"""Take screenshot and log the action."""
|
|
127
|
+
parameters = {}
|
|
128
|
+
try:
|
|
129
|
+
# This assumes there's a screenshot method - you may need to implement it
|
|
130
|
+
result = {
|
|
131
|
+
"url": self.devtools_url(),
|
|
132
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
133
|
+
}
|
|
134
|
+
self._log_tool_action("screenshot", parameters, result)
|
|
135
|
+
return result
|
|
136
|
+
except Exception as e:
|
|
137
|
+
self._log_tool_action("screenshot", parameters, error=str(e))
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
|
|
41
141
|
class Environment(EnvironmentBase):
|
|
42
|
-
def __init__(self, client: SyncWrapper, **kwargs):
|
|
142
|
+
def __init__(self, client: Optional[SyncWrapper], **kwargs):
|
|
43
143
|
super().__init__(**kwargs)
|
|
44
144
|
self._client = client
|
|
45
145
|
self._instance: Optional[InstanceClient] = None
|
|
146
|
+
self._session_id: Optional[str] = None # ADD THIS
|
|
46
147
|
|
|
47
148
|
@property
|
|
48
149
|
def instance(self) -> InstanceClient:
|
|
49
150
|
if self._instance is None:
|
|
50
151
|
self._instance = InstanceClient(
|
|
51
|
-
self.manager_url,
|
|
152
|
+
self.manager_url,
|
|
153
|
+
self.env_key,
|
|
154
|
+
self._client.httpx_client if self._client else None,
|
|
52
155
|
)
|
|
53
156
|
return self._instance
|
|
54
157
|
|
|
158
|
+
@property
|
|
159
|
+
def session_id(self) -> Optional[str]: # ADD THIS PROPERTY
|
|
160
|
+
"""Get the current tool logging session ID."""
|
|
161
|
+
return self._session_id
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def _load_client(self) -> SyncWrapper:
|
|
165
|
+
if self._client is None:
|
|
166
|
+
raise ValueError("Client not initialized")
|
|
167
|
+
return self._client
|
|
168
|
+
|
|
55
169
|
def reset(
|
|
56
170
|
self, seed: Optional[int] = None, timestamp: Optional[int] = None
|
|
57
171
|
) -> ResetResponse:
|
|
@@ -60,8 +174,17 @@ class Environment(EnvironmentBase):
|
|
|
60
174
|
def db(self, name: str = "current") -> SQLiteResource:
|
|
61
175
|
return self.instance.db(name)
|
|
62
176
|
|
|
177
|
+
@property
|
|
178
|
+
def mcp(self) -> MCPResource:
|
|
179
|
+
return self.instance.mcp()
|
|
180
|
+
|
|
63
181
|
def browser(self, name: str = "cdp") -> BrowserResource:
|
|
64
|
-
|
|
182
|
+
"""Get browser resource with automatic logging."""
|
|
183
|
+
base_browser = self.instance.browser(name)
|
|
184
|
+
# Wrap it with logging capabilities
|
|
185
|
+
return LoggingBrowserResource(
|
|
186
|
+
base_browser.resource, base_browser.client, self._session_id
|
|
187
|
+
)
|
|
65
188
|
|
|
66
189
|
def state(self, uri: str) -> Resource:
|
|
67
190
|
return self.instance.state(uri)
|
|
@@ -70,10 +193,16 @@ class Environment(EnvironmentBase):
|
|
|
70
193
|
return self.instance.resources()
|
|
71
194
|
|
|
72
195
|
def close(self) -> InstanceRecord:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
196
|
+
if hasattr(self, "_session_id") and self._session_id:
|
|
197
|
+
try:
|
|
198
|
+
self.instance.client.request(
|
|
199
|
+
"POST", f"/end-tool-session/{self._session_id}"
|
|
200
|
+
)
|
|
201
|
+
logger.info(f"Ended tool logging session: {self._session_id}")
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.warning(f"Failed to end tool logging session: {e}")
|
|
204
|
+
|
|
205
|
+
return _delete_instance(self._load_client, self.instance_id)
|
|
77
206
|
|
|
78
207
|
def verify(self, validator: ValidatorType) -> ExecuteFunctionResponse:
|
|
79
208
|
return self.instance.verify(validator)
|
|
@@ -83,6 +212,155 @@ class Environment(EnvironmentBase):
|
|
|
83
212
|
) -> ExecuteFunctionResponse:
|
|
84
213
|
return self.instance.verify_raw(function_code, function_name)
|
|
85
214
|
|
|
215
|
+
def check_bundle_exists(self, bundle_hash: str) -> VerifiersCheckResponse:
|
|
216
|
+
return _check_bundle_exists(self._load_client, bundle_hash)
|
|
217
|
+
|
|
218
|
+
def execute_verifier_remote(
|
|
219
|
+
self,
|
|
220
|
+
bundle_data: bytes,
|
|
221
|
+
bundle_sha: str,
|
|
222
|
+
key: str,
|
|
223
|
+
function_name: str,
|
|
224
|
+
args: tuple,
|
|
225
|
+
kwargs: dict,
|
|
226
|
+
timeout: Optional[int] = 30,
|
|
227
|
+
needs_upload: bool = True,
|
|
228
|
+
) -> VerifiersExecuteResponse:
|
|
229
|
+
return _execute_verifier_remote(
|
|
230
|
+
self._load_client,
|
|
231
|
+
bundle_data,
|
|
232
|
+
bundle_sha,
|
|
233
|
+
key,
|
|
234
|
+
function_name,
|
|
235
|
+
args,
|
|
236
|
+
kwargs,
|
|
237
|
+
timeout,
|
|
238
|
+
needs_upload,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def __getstate__(self):
|
|
242
|
+
state = self.__dict__.copy()
|
|
243
|
+
state.pop("_client", None)
|
|
244
|
+
state.pop("_instance", None)
|
|
245
|
+
return state
|
|
246
|
+
|
|
247
|
+
def __setstate__(self, state):
|
|
248
|
+
self.__dict__.update(state)
|
|
249
|
+
|
|
250
|
+
def get_snapshot(
|
|
251
|
+
self, browser_resource: Optional[BrowserResource] = None
|
|
252
|
+
) -> EnvironmentSnapshot:
|
|
253
|
+
"""
|
|
254
|
+
Get a snapshot of the current environment state including action logs and tool logs.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
browser_resource: Optional browser resource to capture current state from
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
EnvironmentSnapshot containing all logs and state information
|
|
261
|
+
"""
|
|
262
|
+
# Get current timestamp
|
|
263
|
+
timestamp = datetime.utcnow().isoformat() + "Z"
|
|
264
|
+
|
|
265
|
+
# Get session ID from current tool logs or generate new one
|
|
266
|
+
session_id = self._get_current_session_id() or f"snapshot-{int(time.time())}"
|
|
267
|
+
|
|
268
|
+
# Query tool logs
|
|
269
|
+
tool_logs_response = self.instance.client.request(
|
|
270
|
+
"POST",
|
|
271
|
+
"/query-tool-logs",
|
|
272
|
+
json={
|
|
273
|
+
"session_id": session_id,
|
|
274
|
+
"limit": None, # Get all logs for this session
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
tool_logs_data = tool_logs_response.json()
|
|
278
|
+
tool_logs = [
|
|
279
|
+
ToolLogEntry(**entry) for entry in tool_logs_data.get("entries", [])
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
# Query action logs
|
|
283
|
+
action_logs_query = """
|
|
284
|
+
SELECT id, timestamp, action_type, payload, sql, args, path
|
|
285
|
+
FROM action_log
|
|
286
|
+
ORDER BY timestamp DESC
|
|
287
|
+
LIMIT 10000
|
|
288
|
+
"""
|
|
289
|
+
action_logs_response = self.instance.client.request(
|
|
290
|
+
"POST",
|
|
291
|
+
"/resources/sqlite/action_log/query",
|
|
292
|
+
json={"query": action_logs_query, "read_only": True},
|
|
293
|
+
)
|
|
294
|
+
action_logs_data = action_logs_response.json()
|
|
295
|
+
|
|
296
|
+
action_logs = []
|
|
297
|
+
if action_logs_data.get("success") and action_logs_data.get("rows"):
|
|
298
|
+
columns = action_logs_data["columns"]
|
|
299
|
+
for row in action_logs_data["rows"]:
|
|
300
|
+
entry_dict = dict(zip(columns, row))
|
|
301
|
+
action_logs.append(ActionLogEntry(**entry_dict))
|
|
302
|
+
|
|
303
|
+
# Get current page URL and viewport if browser is available
|
|
304
|
+
page_url = ""
|
|
305
|
+
viewport_size = (1920, 1080) # Default
|
|
306
|
+
|
|
307
|
+
if browser_resource:
|
|
308
|
+
try:
|
|
309
|
+
# Get current page URL via CDP
|
|
310
|
+
cdp_info = browser_resource.describe()
|
|
311
|
+
# You might need to implement a method to get current URL via CDP
|
|
312
|
+
# For now, we'll look for the last navigation in tool logs
|
|
313
|
+
for log in reversed(tool_logs):
|
|
314
|
+
if log.tool_name == "browser" and log.action == "screenshot":
|
|
315
|
+
if log.result and "url" in log.result:
|
|
316
|
+
page_url = log.result["url"]
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
# Get viewport size from last browser start or from logs
|
|
320
|
+
for log in reversed(tool_logs):
|
|
321
|
+
if log.tool_name == "browser" and log.action == "start":
|
|
322
|
+
if log.parameters and "resolution" in log.parameters:
|
|
323
|
+
res = log.parameters["resolution"].split("x")
|
|
324
|
+
viewport_size = (int(res[0]), int(res[1]))
|
|
325
|
+
break
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.warning(f"Could not get browser state: {e}")
|
|
328
|
+
|
|
329
|
+
# Create snapshot
|
|
330
|
+
return EnvironmentSnapshot(
|
|
331
|
+
env_key=self.env_key,
|
|
332
|
+
instance_id=self.instance_id,
|
|
333
|
+
timestamp=timestamp,
|
|
334
|
+
session_id=session_id,
|
|
335
|
+
tool_logs=tool_logs,
|
|
336
|
+
action_logs=action_logs,
|
|
337
|
+
page_url=page_url,
|
|
338
|
+
viewport_size=viewport_size,
|
|
339
|
+
metadata={
|
|
340
|
+
"snapshot_version": "1.0",
|
|
341
|
+
"tool_log_count": len(tool_logs),
|
|
342
|
+
"action_log_count": len(action_logs),
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def _get_current_session_id(self) -> Optional[str]:
|
|
347
|
+
"""Get the current session ID from the environment."""
|
|
348
|
+
# First try to use the stored session ID
|
|
349
|
+
if hasattr(self, "_session_id") and self._session_id:
|
|
350
|
+
return self._session_id
|
|
351
|
+
|
|
352
|
+
# Otherwise, try to get the most recent session ID from tool logs
|
|
353
|
+
try:
|
|
354
|
+
response = self.instance.client.request(
|
|
355
|
+
"POST", "/query-tool-logs", json={"limit": 1}
|
|
356
|
+
)
|
|
357
|
+
data = response.json()
|
|
358
|
+
if data.get("entries") and len(data["entries"]) > 0:
|
|
359
|
+
return data["entries"][0].get("session_id")
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
return None
|
|
363
|
+
|
|
86
364
|
|
|
87
365
|
class Fleet:
|
|
88
366
|
def __init__(
|
|
@@ -91,8 +369,9 @@ class Fleet:
|
|
|
91
369
|
base_url: Optional[str] = None,
|
|
92
370
|
httpx_client: Optional[httpx.Client] = None,
|
|
93
371
|
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
372
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
94
373
|
):
|
|
95
|
-
self._httpx_client = httpx_client or default_httpx_client(max_retries)
|
|
374
|
+
self._httpx_client = httpx_client or default_httpx_client(max_retries, timeout)
|
|
96
375
|
self.client = SyncWrapper(
|
|
97
376
|
api_key=api_key,
|
|
98
377
|
base_url=base_url,
|
|
@@ -112,7 +391,10 @@ class Fleet:
|
|
|
112
391
|
return EnvironmentModel(**response.json())
|
|
113
392
|
|
|
114
393
|
def make(
|
|
115
|
-
self,
|
|
394
|
+
self,
|
|
395
|
+
env_key: str,
|
|
396
|
+
region: Optional[str] = None,
|
|
397
|
+
session_id: Optional[str] = None,
|
|
116
398
|
) -> Environment:
|
|
117
399
|
if ":" in env_key:
|
|
118
400
|
env_key_part, version = env_key.split(":", 1)
|
|
@@ -132,6 +414,31 @@ class Fleet:
|
|
|
132
414
|
)
|
|
133
415
|
instance = Environment(client=self.client, **response.json())
|
|
134
416
|
instance.instance.load()
|
|
417
|
+
|
|
418
|
+
# Start tool logging session automatically
|
|
419
|
+
if session_id is None:
|
|
420
|
+
session_id = f"env-{instance.instance_id}-{int(time.time())}"
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
instance.instance.client.request(
|
|
424
|
+
"POST",
|
|
425
|
+
"/start-tool-session",
|
|
426
|
+
json={
|
|
427
|
+
"session_id": session_id,
|
|
428
|
+
"metadata": {
|
|
429
|
+
"env_key": env_key,
|
|
430
|
+
"instance_id": instance.instance_id,
|
|
431
|
+
"region": region or "default",
|
|
432
|
+
"started_at": datetime.utcnow().isoformat() + "Z",
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
instance._session_id = session_id
|
|
437
|
+
logger.info(f"Started tool logging session: {session_id}")
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logger.warning(f"Failed to start tool logging session: {e}")
|
|
440
|
+
instance._session_id = None
|
|
441
|
+
|
|
135
442
|
return instance
|
|
136
443
|
|
|
137
444
|
def instances(
|
|
@@ -155,8 +462,334 @@ class Fleet:
|
|
|
155
462
|
instance.instance.load()
|
|
156
463
|
return instance
|
|
157
464
|
|
|
465
|
+
def check_bundle_exists(self, bundle_hash: str) -> VerifiersCheckResponse:
|
|
466
|
+
return _check_bundle_exists(self.client, bundle_hash)
|
|
467
|
+
|
|
468
|
+
def execute_verifier_remote(
|
|
469
|
+
self, bundle_data: bytes, args: tuple, kwargs: dict, timeout: Optional[int] = 30
|
|
470
|
+
) -> VerifiersExecuteResponse:
|
|
471
|
+
return _execute_verifier_remote(self.client, bundle_data, args, kwargs, timeout)
|
|
472
|
+
|
|
158
473
|
def delete(self, instance_id: str) -> InstanceRecord:
|
|
159
|
-
|
|
160
|
-
|
|
474
|
+
return _delete_instance(self.client, instance_id)
|
|
475
|
+
|
|
476
|
+
def resume(
|
|
477
|
+
self,
|
|
478
|
+
snapshot: EnvironmentSnapshot,
|
|
479
|
+
validate: bool = True,
|
|
480
|
+
playback_speed: float = 1.0,
|
|
481
|
+
) -> Tuple[Environment, SnapshotValidation]:
|
|
482
|
+
"""
|
|
483
|
+
Resume an environment from a snapshot by recreating the state.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
snapshot: EnvironmentSnapshot to resume from
|
|
487
|
+
validate: Whether to validate the resumed state matches the snapshot
|
|
488
|
+
playback_speed: Speed multiplier for replaying actions (1.0 = normal speed)
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
Tuple of (new Environment instance, validation results)
|
|
492
|
+
"""
|
|
493
|
+
# Create new environment instance
|
|
494
|
+
new_env = self.make(snapshot.env_key)
|
|
495
|
+
|
|
496
|
+
# Start a new tool session for tracking
|
|
497
|
+
replay_session_id = f"replay-{snapshot.session_id}-{int(time.time())}"
|
|
498
|
+
new_env.instance.client.request(
|
|
499
|
+
"POST",
|
|
500
|
+
"/start-tool-session",
|
|
501
|
+
json={
|
|
502
|
+
"session_id": replay_session_id,
|
|
503
|
+
"metadata": {
|
|
504
|
+
"type": "snapshot_replay",
|
|
505
|
+
"original_session_id": snapshot.session_id,
|
|
506
|
+
"snapshot_timestamp": snapshot.timestamp,
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Update tool logs with environment name before replay
|
|
512
|
+
for log in snapshot.tool_logs:
|
|
513
|
+
log_dict = log.dict()
|
|
514
|
+
log_dict["session_id"] = replay_session_id
|
|
515
|
+
log_dict["metadata"] = {"env_name": snapshot.env_key, "replay": True}
|
|
516
|
+
|
|
517
|
+
# Start browser with same viewport
|
|
518
|
+
browser = new_env.browser()
|
|
519
|
+
browser.start(width=snapshot.viewport_size[0], height=snapshot.viewport_size[1])
|
|
520
|
+
|
|
521
|
+
from fleet.playwright import FleetPlaywrightWrapper
|
|
522
|
+
|
|
523
|
+
playwright_wrapper = FleetPlaywrightWrapper(
|
|
524
|
+
cdp_url=browser.cdp_url(), instance_client=new_env.instance
|
|
161
525
|
)
|
|
162
|
-
|
|
526
|
+
|
|
527
|
+
# Replay tool logs in order
|
|
528
|
+
validation_errors = []
|
|
529
|
+
last_timestamp = None
|
|
530
|
+
|
|
531
|
+
for i, tool_log in enumerate(snapshot.tool_logs):
|
|
532
|
+
try:
|
|
533
|
+
# Calculate wait time between actions
|
|
534
|
+
if last_timestamp and playback_speed > 0:
|
|
535
|
+
current_ts = datetime.fromisoformat(tool_log.timestamp.rstrip("Z"))
|
|
536
|
+
last_ts = datetime.fromisoformat(last_timestamp.rstrip("Z"))
|
|
537
|
+
wait_time = (current_ts - last_ts).total_seconds() / playback_speed
|
|
538
|
+
if wait_time > 0:
|
|
539
|
+
time.sleep(min(wait_time, 5)) # Cap at 5 seconds
|
|
540
|
+
|
|
541
|
+
# Replay the tool action
|
|
542
|
+
_replay_tool_action(
|
|
543
|
+
playwright_wrapper,
|
|
544
|
+
tool_log,
|
|
545
|
+
new_env.instance._client,
|
|
546
|
+
replay_session_id,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
last_timestamp = tool_log.timestamp
|
|
550
|
+
|
|
551
|
+
except Exception as e:
|
|
552
|
+
error_msg = f"Failed to replay action {i}: {tool_log.tool_name}.{tool_log.action} - {e}"
|
|
553
|
+
logger.error(error_msg)
|
|
554
|
+
validation_errors.append(error_msg)
|
|
555
|
+
|
|
556
|
+
# End replay session
|
|
557
|
+
new_env.instance.client.request(
|
|
558
|
+
"POST", f"/end-tool-session/{replay_session_id}"
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Validate if requested
|
|
562
|
+
validation = SnapshotValidation(
|
|
563
|
+
success=True,
|
|
564
|
+
page_match=True,
|
|
565
|
+
action_log_match=True,
|
|
566
|
+
discrepancies=validation_errors,
|
|
567
|
+
message="Replay completed",
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
if validate:
|
|
571
|
+
validation = _validate_resumed_state(
|
|
572
|
+
new_env, snapshot, playwright_wrapper, validation_errors
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return new_env, validation
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# Shared
|
|
579
|
+
def _delete_instance(client: SyncWrapper, instance_id: str) -> InstanceRecord:
|
|
580
|
+
response = client.request("DELETE", f"/v1/env/instances/{instance_id}")
|
|
581
|
+
return InstanceRecord(**response.json())
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _check_bundle_exists(
|
|
585
|
+
client: SyncWrapper, bundle_hash: str
|
|
586
|
+
) -> VerifiersCheckResponse:
|
|
587
|
+
response = client.request("GET", f"/v1/verifiers/check?sha256={bundle_hash}")
|
|
588
|
+
return VerifiersCheckResponse(**response.json())
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _execute_verifier_remote(
|
|
592
|
+
client: SyncWrapper,
|
|
593
|
+
bundle_data: bytes,
|
|
594
|
+
bundle_sha: str,
|
|
595
|
+
key: str,
|
|
596
|
+
function_name: str,
|
|
597
|
+
args: tuple,
|
|
598
|
+
kwargs: dict,
|
|
599
|
+
timeout: Optional[int] = 30,
|
|
600
|
+
needs_upload: bool = True,
|
|
601
|
+
) -> VerifiersExecuteResponse:
|
|
602
|
+
# Pickle args and kwargs together
|
|
603
|
+
# The first arg should be None as a placeholder for env
|
|
604
|
+
args_with_none = (None,) + args
|
|
605
|
+
args_kwargs_pickled = cloudpickle.dumps({"args": args_with_none, "kwargs": kwargs})
|
|
606
|
+
args_kwargs_b64 = base64.b64encode(args_kwargs_pickled).decode("utf-8")
|
|
607
|
+
|
|
608
|
+
# Build request data
|
|
609
|
+
request_data = {
|
|
610
|
+
"key": key,
|
|
611
|
+
"sha256": bundle_sha,
|
|
612
|
+
"args": args_kwargs_b64,
|
|
613
|
+
"function_name": function_name,
|
|
614
|
+
"timeout": timeout,
|
|
615
|
+
"region": "us-west-1", # TODO: make configurable
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
# Add bundle data only if upload is needed
|
|
619
|
+
if needs_upload:
|
|
620
|
+
bundle_b64 = base64.b64encode(bundle_data).decode("utf-8")
|
|
621
|
+
request_data["bundle"] = bundle_b64
|
|
622
|
+
|
|
623
|
+
# Debug logging
|
|
624
|
+
logger.debug(
|
|
625
|
+
f"Sending verifier execute request: key={key}, sha256={bundle_sha[:8]}..., function_name={function_name}"
|
|
626
|
+
)
|
|
627
|
+
logger.debug(f"Request has bundle: {needs_upload}")
|
|
628
|
+
logger.debug(f"Using client with base_url: {client.base_url}")
|
|
629
|
+
logger.debug(f"Request data keys: {list(request_data.keys())}")
|
|
630
|
+
logger.debug(
|
|
631
|
+
f"Bundle size: {len(request_data.get('bundle', ''))} chars"
|
|
632
|
+
if "bundle" in request_data
|
|
633
|
+
else "No bundle"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# Note: This should be called on the instance URL, not the orchestrator
|
|
637
|
+
# The instance has manager URLs for verifier execution
|
|
638
|
+
response = client.request("POST", "/v1/verifiers/execute", json=request_data)
|
|
639
|
+
|
|
640
|
+
# Debug the response
|
|
641
|
+
response_json = response.json()
|
|
642
|
+
logger.debug(f"Verifier execute response: {response_json}")
|
|
643
|
+
|
|
644
|
+
return VerifiersExecuteResponse(**response_json)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _replay_tool_action(
|
|
648
|
+
playwright_wrapper: "FleetPlaywrightWrapper",
|
|
649
|
+
tool_log: ToolLogEntry,
|
|
650
|
+
client: "SyncWrapper",
|
|
651
|
+
session_id: str,
|
|
652
|
+
) -> None:
|
|
653
|
+
"""Replay a single tool action."""
|
|
654
|
+
start_time = time.time()
|
|
655
|
+
|
|
656
|
+
try:
|
|
657
|
+
if tool_log.tool_name == "browser":
|
|
658
|
+
# Map browser actions to playwright wrapper methods
|
|
659
|
+
action_map = {
|
|
660
|
+
"screenshot": lambda: playwright_wrapper.screenshot(),
|
|
661
|
+
"left_click": lambda: playwright_wrapper.click(
|
|
662
|
+
tool_log.parameters.get("x"), tool_log.parameters.get("y")
|
|
663
|
+
),
|
|
664
|
+
"type": lambda: playwright_wrapper.type(
|
|
665
|
+
tool_log.parameters.get("text", "")
|
|
666
|
+
),
|
|
667
|
+
"key": lambda: playwright_wrapper.key(
|
|
668
|
+
tool_log.parameters.get("text", "")
|
|
669
|
+
),
|
|
670
|
+
"scroll": lambda: playwright_wrapper.scroll(
|
|
671
|
+
direction=tool_log.parameters.get("scroll_direction", "down"),
|
|
672
|
+
amount=tool_log.parameters.get("scroll_amount", 3),
|
|
673
|
+
),
|
|
674
|
+
"mouse_move": lambda: playwright_wrapper.mouse_move(
|
|
675
|
+
tool_log.parameters.get("x"), tool_log.parameters.get("y")
|
|
676
|
+
),
|
|
677
|
+
"wait": lambda: time.sleep(tool_log.parameters.get("duration", 1)),
|
|
678
|
+
# Add more action mappings as needed
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if tool_log.action in action_map:
|
|
682
|
+
result = action_map[tool_log.action]()
|
|
683
|
+
else:
|
|
684
|
+
logger.warning(f"Unknown browser action: {tool_log.action}")
|
|
685
|
+
result = None
|
|
686
|
+
|
|
687
|
+
elif tool_log.tool_name == "complete_task":
|
|
688
|
+
# Don't replay task completion
|
|
689
|
+
logger.info("Skipping complete_task during replay")
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
elif tool_log.tool_name == "report_issue":
|
|
693
|
+
# Log but don't replay issue reports
|
|
694
|
+
logger.info(f"Previous issue reported: {tool_log.parameters}")
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
elif tool_log.tool_name == "give_up":
|
|
698
|
+
# Don't replay give up
|
|
699
|
+
logger.warning(f"Original session gave up: {tool_log.parameters}")
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
# Log the replayed action
|
|
703
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
704
|
+
|
|
705
|
+
client.request(
|
|
706
|
+
"POST",
|
|
707
|
+
"/log-tool",
|
|
708
|
+
json={
|
|
709
|
+
"tool_name": tool_log.tool_name,
|
|
710
|
+
"action": tool_log.action,
|
|
711
|
+
"parameters": tool_log.parameters,
|
|
712
|
+
"result": result if result else tool_log.result,
|
|
713
|
+
"success": True,
|
|
714
|
+
"duration_ms": duration_ms,
|
|
715
|
+
"session_id": session_id,
|
|
716
|
+
"user_agent": "snapshot_replay",
|
|
717
|
+
},
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
except Exception as e:
|
|
721
|
+
# Log failure
|
|
722
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
723
|
+
|
|
724
|
+
client.request(
|
|
725
|
+
"POST",
|
|
726
|
+
"/log-tool",
|
|
727
|
+
json={
|
|
728
|
+
"tool_name": tool_log.tool_name,
|
|
729
|
+
"action": tool_log.action,
|
|
730
|
+
"parameters": tool_log.parameters,
|
|
731
|
+
"success": False,
|
|
732
|
+
"error": str(e),
|
|
733
|
+
"duration_ms": duration_ms,
|
|
734
|
+
"session_id": session_id,
|
|
735
|
+
"user_agent": "snapshot_replay",
|
|
736
|
+
},
|
|
737
|
+
)
|
|
738
|
+
raise
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _validate_resumed_state(
|
|
742
|
+
new_env: Environment,
|
|
743
|
+
snapshot: EnvironmentSnapshot,
|
|
744
|
+
playwright_wrapper: "FleetPlaywrightWrapper",
|
|
745
|
+
existing_errors: List[str],
|
|
746
|
+
) -> SnapshotValidation:
|
|
747
|
+
"""Validate that the resumed state matches the snapshot."""
|
|
748
|
+
discrepancies = existing_errors.copy()
|
|
749
|
+
|
|
750
|
+
# Check current page URL
|
|
751
|
+
page_match = True
|
|
752
|
+
try:
|
|
753
|
+
current_screenshot = playwright_wrapper.screenshot()
|
|
754
|
+
current_url = current_screenshot.get("url", "")
|
|
755
|
+
|
|
756
|
+
if current_url != snapshot.page_url:
|
|
757
|
+
page_match = False
|
|
758
|
+
discrepancies.append(
|
|
759
|
+
f"Page URL mismatch: expected '{snapshot.page_url}', got '{current_url}'"
|
|
760
|
+
)
|
|
761
|
+
except Exception as e:
|
|
762
|
+
page_match = False
|
|
763
|
+
discrepancies.append(f"Could not verify page URL: {e}")
|
|
764
|
+
|
|
765
|
+
# Compare action logs
|
|
766
|
+
action_log_match = True
|
|
767
|
+
try:
|
|
768
|
+
# Get new action logs
|
|
769
|
+
new_snapshot = new_env.get_snapshot()
|
|
770
|
+
|
|
771
|
+
# Compare counts
|
|
772
|
+
if len(new_snapshot.action_logs) != len(snapshot.action_logs):
|
|
773
|
+
action_log_match = False
|
|
774
|
+
discrepancies.append(
|
|
775
|
+
f"Action log count mismatch: expected {len(snapshot.action_logs)}, "
|
|
776
|
+
f"got {len(new_snapshot.action_logs)}"
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
# Could do more detailed comparison here if needed
|
|
780
|
+
|
|
781
|
+
except Exception as e:
|
|
782
|
+
action_log_match = False
|
|
783
|
+
discrepancies.append(f"Could not verify action logs: {e}")
|
|
784
|
+
|
|
785
|
+
success = page_match and action_log_match and len(discrepancies) == 0
|
|
786
|
+
|
|
787
|
+
return SnapshotValidation(
|
|
788
|
+
success=success,
|
|
789
|
+
page_match=page_match,
|
|
790
|
+
action_log_match=action_log_match,
|
|
791
|
+
discrepancies=discrepancies,
|
|
792
|
+
message="Validation completed"
|
|
793
|
+
if success
|
|
794
|
+
else "Validation failed with discrepancies",
|
|
795
|
+
)
|