pulse-coding-agent 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 (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,389 @@
1
+ """Remote Sandbox Client.
2
+
3
+ Implements the client side of the authenticated WebSocket protocol.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import uuid
12
+ from collections.abc import AsyncGenerator
13
+ from contextlib import suppress
14
+ from typing import Any
15
+
16
+ import websockets
17
+ from websockets.protocol import State
18
+
19
+ from pulse.sandbox.remote.models import (
20
+ ExecutionResultModel,
21
+ SubmitExecutionRequest,
22
+ SubmitExecutionResponse,
23
+ )
24
+ from pulse.sandbox.remote.protocol import RemoteSandboxClient
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class RemoteClient(RemoteSandboxClient):
30
+ """Client for the Remote Sandbox Server."""
31
+
32
+ def __init__(self, endpoint_url: str, auth_token: str) -> None:
33
+ self.endpoint_url = endpoint_url
34
+ self.auth_token = auth_token
35
+ self._ws: websockets.WebSocketClientProtocol | None = None
36
+ self._results: dict[str, asyncio.Future[ExecutionResultModel]] = {}
37
+ self._responses: dict[str, asyncio.Future[dict[str, Any]]] = {}
38
+ self._queues: dict[str, asyncio.Queue[tuple[str, str] | None]] = {}
39
+ self._listener_task: asyncio.Task[None] | None = None
40
+
41
+ async def connect(self) -> None:
42
+ """Establish the WebSocket connection."""
43
+ if self._ws and getattr(self._ws, "state", None) is not State.CLOSED:
44
+ return
45
+
46
+ headers = {"Authorization": f"Bearer {self.auth_token}"}
47
+
48
+ import os
49
+ import ssl
50
+
51
+ ssl_context = None
52
+
53
+ tls_cert = os.environ.get("PULSE_TLS_CERT")
54
+ tls_key = os.environ.get("PULSE_TLS_KEY")
55
+ tls_ca = os.environ.get("PULSE_TLS_CA")
56
+
57
+ if self.endpoint_url.startswith("wss://"):
58
+ if not (tls_cert and tls_key and tls_ca):
59
+ raise ValueError(
60
+ "mTLS certificates (PULSE_TLS_CERT, PULSE_TLS_KEY, PULSE_TLS_CA) are required for wss://"
61
+ )
62
+ try:
63
+ ssl_context = ssl.create_default_context(
64
+ ssl.Purpose.SERVER_AUTH, cafile=tls_ca
65
+ )
66
+ ssl_context.load_cert_chain(certfile=tls_cert, keyfile=tls_key)
67
+ except (ssl.SSLError, OSError) as e:
68
+ raise RuntimeError(f"Failed to load mTLS certificates: {e}")
69
+ elif not (
70
+ "127.0.0.1" in self.endpoint_url
71
+ or "localhost" in self.endpoint_url
72
+ or "[::1]" in self.endpoint_url
73
+ ):
74
+ raise RuntimeError(
75
+ "Insecure ws:// connections are only allowed for loopback (127.0.0.1)."
76
+ )
77
+
78
+ self._ws = await websockets.connect(
79
+ self.endpoint_url,
80
+ additional_headers=headers,
81
+ ping_interval=20,
82
+ ping_timeout=20,
83
+ open_timeout=5,
84
+ close_timeout=5,
85
+ ssl=ssl_context,
86
+ )
87
+ self._listener_task = asyncio.create_task(self._listen())
88
+
89
+ async def disconnect(self) -> None:
90
+ """Close the WebSocket connection and reap its listener task."""
91
+ if self._ws:
92
+ await self._ws.close()
93
+ self._ws = None
94
+ if self._listener_task:
95
+ listener = self._listener_task
96
+ self._listener_task = None
97
+ if not listener.done():
98
+ listener.cancel()
99
+ with suppress(asyncio.CancelledError):
100
+ await listener
101
+
102
+ async def _listen(self) -> None:
103
+ """Background task to listen for messages from the server."""
104
+ if not self._ws:
105
+ return
106
+
107
+ try:
108
+ async for message in self._ws:
109
+ try:
110
+ data = json.loads(message)
111
+ msg_type = data.get("type")
112
+ payload = data.get("payload", {})
113
+
114
+ if msg_type == "result":
115
+ execution_id = payload.get("execution_id")
116
+ fut = self._results.get(execution_id) if execution_id else None
117
+ if fut and not fut.done():
118
+ fut.set_result(ExecutionResultModel.from_dict(payload))
119
+ elif execution_id:
120
+ logger.warning(
121
+ "Received result for unknown execution %s.",
122
+ execution_id,
123
+ )
124
+
125
+ queue = self._queues.get(execution_id or "")
126
+ if queue:
127
+ await queue.put(None)
128
+
129
+ elif msg_type == "response":
130
+ request_id = payload.get("request_id")
131
+ if request_id and request_id in self._responses:
132
+ fut = self._responses[request_id]
133
+ if not fut.done():
134
+ fut.set_result(payload)
135
+ continue
136
+ eid = payload.get("execution_id")
137
+ if eid and eid in self._responses:
138
+ fut = self._responses[eid]
139
+ if not fut.done():
140
+ fut.set_result(payload)
141
+ else:
142
+ logger.warning(
143
+ "Received response without a known request ID: %s",
144
+ payload,
145
+ )
146
+
147
+ elif msg_type == "stream":
148
+ stream_data = payload.get("data", "")
149
+ stream_type = payload.get("stream", "stdout")
150
+ execution_id = payload.get("execution_id")
151
+ queue = self._queues.get(execution_id or "")
152
+ if queue:
153
+ await queue.put((stream_type, stream_data))
154
+
155
+ elif msg_type == "error":
156
+ request_id = (
157
+ payload.get("request_id")
158
+ if isinstance(payload, dict)
159
+ else None
160
+ )
161
+ error = (
162
+ payload.get("message", "Remote server error")
163
+ if isinstance(payload, dict)
164
+ else str(payload)
165
+ )
166
+ fut = self._responses.get(request_id) if request_id else None
167
+ if fut and not fut.done():
168
+ fut.set_exception(RuntimeError(error))
169
+ else:
170
+ logger.error("Remote server error: %s", error)
171
+
172
+ except (KeyError, ValueError, RuntimeError) as e:
173
+ # check if the message is a direct string payload from attach history
174
+ if (
175
+ isinstance(message, str)
176
+ and message.startswith("{")
177
+ and '"stream"' in message
178
+ ):
179
+ try:
180
+ line_data = json.loads(message)
181
+ if line_data.get("type") == "stream":
182
+ stream_payload = line_data.get("payload", {})
183
+ stream_data = stream_payload.get("data", "")
184
+ stream_type = stream_payload.get("stream", "stdout")
185
+ for queue in self._queues.values():
186
+ await queue.put((stream_type, stream_data))
187
+ except (TypeError, ValueError):
188
+ logger.error(f"Error processing server message: {e}")
189
+ else:
190
+ logger.error(f"Error processing server message: {e}")
191
+ except websockets.exceptions.ConnectionClosed:
192
+ logger.info("Connection to remote server closed.")
193
+ for fut in self._results.values():
194
+ if not fut.done():
195
+ fut.set_exception(RuntimeError("Connection closed"))
196
+ for fut in self._responses.values():
197
+ if not fut.done():
198
+ fut.set_exception(RuntimeError("Connection closed"))
199
+ for queue in self._queues.values():
200
+ await queue.put(None)
201
+
202
+ async def submit(self, request: SubmitExecutionRequest) -> SubmitExecutionResponse:
203
+ """Submit an execution request to the remote worker."""
204
+ await self.connect()
205
+
206
+ self._results[request.execution_id] = asyncio.get_running_loop().create_future()
207
+ self._responses[request.execution_id] = (
208
+ asyncio.get_running_loop().create_future()
209
+ )
210
+ self._queues[request.execution_id] = asyncio.Queue()
211
+
212
+ payload = request.to_dict()
213
+ payload["request_id"] = request.execution_id
214
+ await self._ws.send(
215
+ json.dumps(
216
+ {
217
+ "action": "submit",
218
+ "payload": payload,
219
+ }
220
+ )
221
+ )
222
+
223
+ resp = await self._responses[request.execution_id]
224
+ return SubmitExecutionResponse(
225
+ execution_id=request.execution_id, status=resp.get("status", "STARTING")
226
+ )
227
+
228
+ async def cancel(self, execution_id: str) -> None:
229
+ """Cancel an ongoing execution on the remote worker."""
230
+ if not self._ws or getattr(self._ws, "state", None) is State.CLOSED:
231
+ return
232
+
233
+ await self._ws.send(
234
+ json.dumps({"action": "cancel", "payload": {"execution_id": execution_id}})
235
+ )
236
+
237
+ async def get_result(self, execution_id: str) -> ExecutionResultModel:
238
+ """Wait for and retrieve the final execution result."""
239
+ fut = self._results.get(execution_id)
240
+ if not fut:
241
+ raise ValueError(f"No active execution found for {execution_id}")
242
+
243
+ try:
244
+ return await fut
245
+ finally:
246
+ self._results.pop(execution_id, None)
247
+
248
+ async def stream_output(
249
+ self, execution_id: str
250
+ ) -> AsyncGenerator[tuple[str, str], None]:
251
+ """Stream stdout and stderr from the remote execution."""
252
+ queue = self._queues.get(execution_id)
253
+ if not queue:
254
+ return
255
+
256
+ try:
257
+ while True:
258
+ item = await queue.get()
259
+ if item is None:
260
+ break
261
+ yield item
262
+ finally:
263
+ self._queues.pop(execution_id, None)
264
+
265
+ async def status(self, execution_id: str) -> str:
266
+ """Query authoritative remote state."""
267
+ await self.connect()
268
+ fut = asyncio.get_running_loop().create_future()
269
+ temp_key = f"status-{uuid.uuid4().hex}"
270
+ self._responses[temp_key] = fut
271
+ try:
272
+ await self._ws.send(
273
+ json.dumps(
274
+ {
275
+ "action": "status",
276
+ "payload": {
277
+ "execution_id": execution_id,
278
+ "request_id": temp_key,
279
+ },
280
+ }
281
+ )
282
+ )
283
+ resp = await fut
284
+ return resp.get("status", "NOT_FOUND")
285
+ finally:
286
+ self._responses.pop(temp_key, None)
287
+
288
+ async def attach(self, execution_id: str) -> ExecutionResultModel:
289
+ """Attach to a live execution and stream results, returning the final result."""
290
+ await self.connect()
291
+
292
+ # Setup tracking
293
+ self._results[execution_id] = asyncio.get_running_loop().create_future()
294
+ request_id = f"attach-{uuid.uuid4().hex}"
295
+ self._responses[request_id] = asyncio.get_running_loop().create_future()
296
+ self._queues[execution_id] = asyncio.Queue()
297
+
298
+ await self._ws.send(
299
+ json.dumps(
300
+ {
301
+ "action": "attach",
302
+ "payload": {"execution_id": execution_id, "request_id": request_id},
303
+ }
304
+ )
305
+ )
306
+
307
+ fut = self._results.get(execution_id)
308
+ if not fut:
309
+ raise ValueError(f"No active execution found for {execution_id}")
310
+
311
+ try:
312
+ response = await self._responses[request_id]
313
+ if response.get("status") not in {"ATTACHED", "COMPLETED", "FAILED"}:
314
+ raise RuntimeError(
315
+ f"Remote execution {execution_id} cannot be attached: "
316
+ f"{response.get('status', 'UNKNOWN')}"
317
+ )
318
+ return await fut
319
+ finally:
320
+ self._results.pop(execution_id, None)
321
+ self._responses.pop(request_id, None)
322
+ self._queues.pop(execution_id, None)
323
+
324
+ async def reconcile(self) -> None:
325
+ """Reconcile orphaned or stale executions with the remote worker."""
326
+ await self.connect()
327
+ fut = asyncio.get_running_loop().create_future()
328
+ temp_key = f"reconcile-{uuid.uuid4().hex}"
329
+ self._responses[temp_key] = fut
330
+ try:
331
+ await self._ws.send(
332
+ json.dumps({"action": "reconcile", "payload": {"request_id": temp_key}})
333
+ )
334
+ await fut
335
+ finally:
336
+ self._responses.pop(temp_key, None)
337
+
338
+ async def upload_artifact(self, execution_id: str, archive_data: bytes) -> None:
339
+ """Upload a workspace snapshot to the remote worker before execution."""
340
+ import base64
341
+
342
+ await self.connect()
343
+ fut = asyncio.get_running_loop().create_future()
344
+ # Use a temporary key for the response
345
+ temp_key = f"upload_{execution_id}"
346
+ self._responses[temp_key] = fut
347
+ try:
348
+ await self._ws.send(
349
+ json.dumps(
350
+ {
351
+ "action": "upload_artifact",
352
+ "payload": {
353
+ "execution_id": execution_id,
354
+ "request_id": temp_key,
355
+ "data": base64.b64encode(archive_data).decode("ascii"),
356
+ },
357
+ }
358
+ )
359
+ )
360
+ await fut
361
+ finally:
362
+ self._responses.pop(temp_key, None)
363
+
364
+ async def download_artifact(self, execution_id: str) -> bytes:
365
+ """Download the modified workspace overlay from the remote worker after execution."""
366
+ import base64
367
+
368
+ await self.connect()
369
+ fut = asyncio.get_running_loop().create_future()
370
+ temp_key = f"download_{execution_id}"
371
+ self._responses[temp_key] = fut
372
+ try:
373
+ await self._ws.send(
374
+ json.dumps(
375
+ {
376
+ "action": "download_artifact",
377
+ "payload": {
378
+ "execution_id": execution_id,
379
+ "request_id": temp_key,
380
+ },
381
+ }
382
+ )
383
+ )
384
+ resp = await fut
385
+ if resp.get("status") == "DOWNLOADED" and "data" in resp:
386
+ return base64.b64decode(resp["data"])
387
+ return b""
388
+ finally:
389
+ self._responses.pop(temp_key, None)
@@ -0,0 +1,167 @@
1
+ """Data models for Remote Sandbox Protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import PurePosixPath
8
+ from typing import Any
9
+
10
+ REMOTE_PROTOCOL_VERSION = "1.0"
11
+ _SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
12
+ _SAFE_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
13
+
14
+
15
+ def validate_execution_id(value: object) -> str:
16
+ if not isinstance(value, str) or not _SAFE_IDENTIFIER.fullmatch(value):
17
+ raise ValueError("execution_id must be a safe 1-128 character identifier")
18
+ return value
19
+
20
+
21
+ @dataclass
22
+ class SubmitExecutionRequest:
23
+ protocol_version: str
24
+ execution_id: str
25
+ idempotency_key: str
26
+ command: str | list[str]
27
+ correlation_id: str | None = None
28
+ working_directory: str | None = None
29
+ env: dict[str, str] | None = None
30
+ resource_policy_dict: dict[str, Any] | None = None
31
+ network_policy_dict: dict[str, Any] | None = None
32
+ secret_policy_dict: dict[str, Any] | None = None
33
+
34
+ def __post_init__(self) -> None:
35
+ if self.protocol_version != REMOTE_PROTOCOL_VERSION:
36
+ raise ValueError("unsupported remote protocol version")
37
+ validate_execution_id(self.execution_id)
38
+ if not _SAFE_IDENTIFIER.fullmatch(self.idempotency_key):
39
+ raise ValueError("idempotency_key must be a safe 1-128 character identifier")
40
+ if isinstance(self.command, str):
41
+ if not self.command or len(self.command) > 65_536 or "\x00" in self.command:
42
+ raise ValueError("command must be 1-65536 characters without NUL bytes")
43
+ elif isinstance(self.command, list):
44
+ if not self.command or len(self.command) > 256 or any(
45
+ not isinstance(item, str)
46
+ or not item
47
+ or len(item) > 65_536
48
+ or "\x00" in item
49
+ for item in self.command
50
+ ):
51
+ raise ValueError("command arguments are invalid or exceed protocol limits")
52
+ else:
53
+ raise TypeError("command must be a string or an array of strings")
54
+ if self.working_directory:
55
+ path = PurePosixPath(self.working_directory.replace("\\", "/"))
56
+ if path.is_absolute() or ".." in path.parts:
57
+ raise ValueError("working_directory must remain inside the execution workspace")
58
+ if self.env is not None:
59
+ if not isinstance(self.env, dict) or len(self.env) > 128:
60
+ raise ValueError("env must be an object with at most 128 entries")
61
+ if any(
62
+ not isinstance(key, str)
63
+ or not _SAFE_ENV_NAME.fullmatch(key)
64
+ or not isinstance(value, str)
65
+ or len(value) > 65_536
66
+ or "\x00" in value
67
+ for key, value in self.env.items()
68
+ ):
69
+ raise ValueError("env contains an invalid name or value")
70
+ for policy in (
71
+ self.resource_policy_dict,
72
+ self.network_policy_dict,
73
+ self.secret_policy_dict,
74
+ ):
75
+ if policy is not None and not isinstance(policy, dict):
76
+ raise ValueError("execution policies must be objects")
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ return {
80
+ "protocol_version": self.protocol_version,
81
+ "execution_id": self.execution_id,
82
+ "idempotency_key": self.idempotency_key,
83
+ "command": self.command,
84
+ "correlation_id": self.correlation_id,
85
+ "working_directory": self.working_directory,
86
+ "env": self.env,
87
+ "resource_policy_dict": self.resource_policy_dict,
88
+ "network_policy_dict": self.network_policy_dict,
89
+ "secret_policy_dict": self.secret_policy_dict,
90
+ }
91
+
92
+ @classmethod
93
+ def from_dict(cls, data: dict[str, Any]) -> SubmitExecutionRequest:
94
+ return cls(
95
+ protocol_version=data["protocol_version"],
96
+ execution_id=data["execution_id"],
97
+ idempotency_key=data["idempotency_key"],
98
+ command=data["command"],
99
+ correlation_id=data.get("correlation_id"),
100
+ working_directory=data.get("working_directory"),
101
+ env=data.get("env"),
102
+ resource_policy_dict=data.get("resource_policy_dict"),
103
+ network_policy_dict=data.get("network_policy_dict"),
104
+ secret_policy_dict=data.get("secret_policy_dict"),
105
+ )
106
+
107
+
108
+ @dataclass
109
+ class SubmitExecutionResponse:
110
+ execution_id: str
111
+ status: str
112
+ error: str | None = None
113
+
114
+ def to_dict(self) -> dict[str, Any]:
115
+ return {
116
+ "execution_id": self.execution_id,
117
+ "status": self.status,
118
+ "error": self.error,
119
+ }
120
+
121
+ @classmethod
122
+ def from_dict(cls, data: dict[str, Any]) -> SubmitExecutionResponse:
123
+ return cls(
124
+ execution_id=data["execution_id"],
125
+ status=data["status"],
126
+ error=data.get("error"),
127
+ )
128
+
129
+
130
+ @dataclass
131
+ class ExecutionResultModel:
132
+ execution_id: str
133
+ command: str
134
+ exit_code: int
135
+ stdout: str
136
+ stderr: str
137
+ duration_ms: float
138
+ timed_out: bool = False
139
+ truncated: bool = False
140
+ termination_reason: str | None = None
141
+
142
+ def to_dict(self) -> dict[str, Any]:
143
+ return {
144
+ "execution_id": self.execution_id,
145
+ "command": self.command,
146
+ "exit_code": self.exit_code,
147
+ "stdout": self.stdout,
148
+ "stderr": self.stderr,
149
+ "duration_ms": self.duration_ms,
150
+ "timed_out": self.timed_out,
151
+ "truncated": self.truncated,
152
+ "termination_reason": self.termination_reason,
153
+ }
154
+
155
+ @classmethod
156
+ def from_dict(cls, data: dict[str, Any]) -> ExecutionResultModel:
157
+ return cls(
158
+ execution_id=data["execution_id"],
159
+ command=data["command"],
160
+ exit_code=data["exit_code"],
161
+ stdout=data["stdout"],
162
+ stderr=data["stderr"],
163
+ duration_ms=data["duration_ms"],
164
+ timed_out=data.get("timed_out", False),
165
+ truncated=data.get("truncated", False),
166
+ termination_reason=data.get("termination_reason"),
167
+ )
@@ -0,0 +1,65 @@
1
+ """Remote Sandbox Protocol definition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncGenerator
6
+ from typing import Protocol
7
+
8
+ from pulse.sandbox.remote.models import (
9
+ ExecutionResultModel,
10
+ SubmitExecutionRequest,
11
+ SubmitExecutionResponse,
12
+ )
13
+
14
+
15
+ class RemoteSandboxClient(Protocol):
16
+ """Protocol for communicating with a Remote Sandbox Worker.
17
+
18
+ The implementation must support:
19
+ - Authenticated transport
20
+ - Artifact snapshot staging and retrieval
21
+ - Execution submission
22
+ - Execution cancellation
23
+ - Execution reconciliation
24
+ """
25
+
26
+ async def submit(self, request: SubmitExecutionRequest) -> SubmitExecutionResponse:
27
+ """Submit an execution request to the remote worker."""
28
+ ...
29
+
30
+ async def cancel(self, execution_id: str) -> None:
31
+ """Cancel an ongoing execution on the remote worker."""
32
+ ...
33
+
34
+ async def get_result(self, execution_id: str) -> ExecutionResultModel:
35
+ """Wait for and retrieve the final execution result."""
36
+ ...
37
+
38
+ async def stream_output(
39
+ self, execution_id: str
40
+ ) -> AsyncGenerator[tuple[str, str], None]:
41
+ """Stream stdout and stderr from the remote execution.
42
+
43
+ Yields tuples of (stdout_chunk, stderr_chunk).
44
+ """
45
+ ...
46
+
47
+ async def status(self, execution_id: str) -> str:
48
+ """Query authoritative execution state (RUNNING, COMPLETED, FAILED, NOT_FOUND)."""
49
+ ...
50
+
51
+ async def attach(self, execution_id: str) -> ExecutionResultModel:
52
+ """Attach to a live execution and stream results, returning the final result."""
53
+ ...
54
+
55
+ async def reconcile(self) -> None:
56
+ """Reconcile orphaned or stale executions with the remote worker."""
57
+ ...
58
+
59
+ async def upload_artifact(self, execution_id: str, archive_data: bytes) -> None:
60
+ """Upload a workspace snapshot to the remote worker before execution."""
61
+ ...
62
+
63
+ async def download_artifact(self, execution_id: str) -> bytes:
64
+ """Download the modified workspace overlay from the remote worker after execution."""
65
+ ...