soothe-client-python 0.9.4__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.
- soothe_client/__init__.py +171 -0
- soothe_client/appkit/__init__.py +138 -0
- soothe_client/appkit/attachments.py +118 -0
- soothe_client/appkit/broadcaster.py +116 -0
- soothe_client/appkit/chunk_filter.py +99 -0
- soothe_client/appkit/classifier.py +498 -0
- soothe_client/appkit/daemon_session.py +657 -0
- soothe_client/appkit/events.py +63 -0
- soothe_client/appkit/managed_client.py +163 -0
- soothe_client/appkit/observability.py +29 -0
- soothe_client/appkit/pool.py +258 -0
- soothe_client/appkit/query_gate.py +93 -0
- soothe_client/appkit/session_store.py +100 -0
- soothe_client/appkit/thinking_step.py +147 -0
- soothe_client/appkit/turn.py +362 -0
- soothe_client/appkit/turn_runner.py +565 -0
- soothe_client/errors.py +65 -0
- soothe_client/helpers.py +404 -0
- soothe_client/intent_hints.py +43 -0
- soothe_client/protocol_params.py +604 -0
- soothe_client/schemas.py +55 -0
- soothe_client/session.py +199 -0
- soothe_client/websocket.py +1626 -0
- soothe_client/ws_command_client.py +633 -0
- soothe_client_python-0.9.4.dist-info/METADATA +131 -0
- soothe_client_python-0.9.4.dist-info/RECORD +28 -0
- soothe_client_python-0.9.4.dist-info/WHEEL +4 -0
- soothe_client_python-0.9.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""WebSocket command client for daemon command endpoints.
|
|
2
|
+
|
|
3
|
+
Provides synchronous and async clients for sending commands over WebSocket
|
|
4
|
+
and receiving responses. Replaces HTTP REST clients for autopilot, cron,
|
|
5
|
+
and memory profiling operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from soothe_sdk.wire.codec import (
|
|
16
|
+
ConnectionInitEnvelope,
|
|
17
|
+
ConnectionInitParams,
|
|
18
|
+
ErrorEnvelope,
|
|
19
|
+
MessageType,
|
|
20
|
+
WireEnvelope,
|
|
21
|
+
decode_envelope,
|
|
22
|
+
encode_envelope,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from soothe_client.helpers import websocket_url_from_config
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_TRANSITIONAL_DAEMON_READY_STATES = frozenset({"starting", "warming"})
|
|
30
|
+
_DAEMON_READY_POLL_INTERVAL_S = 0.05
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from soothe_sdk import __version__ as _client_version
|
|
34
|
+
except Exception: # pragma: no cover
|
|
35
|
+
_client_version = "0.0.0"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_cron_add_result(result: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
"""Normalize daemon cron_add payloads to ``{"job": {...}}`` for CLI callers."""
|
|
40
|
+
if "job" in result:
|
|
41
|
+
out = dict(result)
|
|
42
|
+
elif result.get("job_id") or result.get("id"):
|
|
43
|
+
job_id = result.get("job_id") or result.get("id")
|
|
44
|
+
job = dict(result)
|
|
45
|
+
job["id"] = job_id
|
|
46
|
+
job.pop("job_id", None)
|
|
47
|
+
out = {"job": job}
|
|
48
|
+
else:
|
|
49
|
+
return result
|
|
50
|
+
if result.get("duplicate"):
|
|
51
|
+
out["duplicate"] = True
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_cron_show_result(result: dict[str, Any]) -> dict[str, Any]:
|
|
56
|
+
"""Normalize daemon cron_show payloads to ``{"job": {...}}`` for CLI callers."""
|
|
57
|
+
if "job" in result:
|
|
58
|
+
return result
|
|
59
|
+
job_id = result.get("job_id") or result.get("id")
|
|
60
|
+
if not job_id:
|
|
61
|
+
return {"job": None}
|
|
62
|
+
job = dict(result)
|
|
63
|
+
job["id"] = job_id
|
|
64
|
+
job.pop("job_id", None)
|
|
65
|
+
return {"job": job}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _perform_handshake(ws: Any, *, timeout: float) -> None:
|
|
69
|
+
"""Complete protocol-1 ``connection_init`` / ``connection_ack`` (RFC-450 §8.2).
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
ws: Connected WebSocket.
|
|
73
|
+
timeout: Maximum seconds to wait for a ready ``connection_ack``.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
RuntimeError: If the handshake fails or times out.
|
|
77
|
+
"""
|
|
78
|
+
init = ConnectionInitEnvelope(
|
|
79
|
+
params=ConnectionInitParams(
|
|
80
|
+
client_version=_client_version,
|
|
81
|
+
client_name="soothe-sdk",
|
|
82
|
+
accept_proto=["1"],
|
|
83
|
+
capabilities=["streaming", "batch", "heartbeat"],
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async with asyncio.timeout(timeout):
|
|
88
|
+
await ws.send(encode_envelope(init))
|
|
89
|
+
while True:
|
|
90
|
+
response_str = await ws.recv()
|
|
91
|
+
response = decode_envelope(response_str)
|
|
92
|
+
if not isinstance(response, dict):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
msg_type = response.get("type")
|
|
96
|
+
if msg_type == "status":
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if msg_type == MessageType.ERROR.value:
|
|
100
|
+
err = ErrorEnvelope.from_wire_dict(response)
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"[{err.code}] {err.message}" + (f" ({err.data})" if err.data else "")
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if msg_type != "connection_ack":
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
result = response.get("result") or {}
|
|
109
|
+
state = result.get("readiness_state")
|
|
110
|
+
if state == "incompatible":
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
"Protocol version incompatible: "
|
|
113
|
+
f"daemon returned {result.get('protocol_version')!r}"
|
|
114
|
+
)
|
|
115
|
+
if state == "ready":
|
|
116
|
+
return
|
|
117
|
+
if state == "error":
|
|
118
|
+
raise RuntimeError("Daemon startup failed")
|
|
119
|
+
if state == "degraded":
|
|
120
|
+
raise RuntimeError("Daemon is degraded")
|
|
121
|
+
if state in _TRANSITIONAL_DAEMON_READY_STATES:
|
|
122
|
+
await asyncio.sleep(_DAEMON_READY_POLL_INTERVAL_S)
|
|
123
|
+
await ws.send(encode_envelope(init))
|
|
124
|
+
continue
|
|
125
|
+
raise RuntimeError(f"Daemon state is {state}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Command message types
|
|
129
|
+
_AUTOPilot_COMMANDS = {
|
|
130
|
+
"status": "autopilot_status",
|
|
131
|
+
"submit": "autopilot_submit",
|
|
132
|
+
"list_goals": "autopilot_list_goals",
|
|
133
|
+
"get_goal": "autopilot_get_goal",
|
|
134
|
+
"cancel_goal": "autopilot_cancel_goal",
|
|
135
|
+
"wake": "autopilot_wake",
|
|
136
|
+
"dream": "autopilot_dream",
|
|
137
|
+
"resume": "autopilot_resume",
|
|
138
|
+
"list_jobs": "autopilot_list_jobs",
|
|
139
|
+
"get_job": "autopilot_get_job",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_CRON_COMMANDS = {
|
|
143
|
+
"add": "cron_add",
|
|
144
|
+
"list": "cron_list",
|
|
145
|
+
"list_jobs": "cron_list",
|
|
146
|
+
"show": "cron_show",
|
|
147
|
+
"cancel": "cron_cancel",
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_MEMORY_COMMANDS = {
|
|
151
|
+
"stats": "memory_stats",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class WsCommandClient:
|
|
156
|
+
"""Async WebSocket client for daemon command endpoints.
|
|
157
|
+
|
|
158
|
+
Connects to daemon WebSocket, sends command messages, and waits for
|
|
159
|
+
response messages with a request/response pattern.
|
|
160
|
+
|
|
161
|
+
Usage:
|
|
162
|
+
client = WsCommandClient(ws_url)
|
|
163
|
+
result = await client.autopilot_status()
|
|
164
|
+
result = await client.cron_add("in 1 hour remind me to deploy")
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
ws_url: WebSocket URL (e.g. ``ws://127.0.0.1:8765``).
|
|
168
|
+
timeout: Command timeout in seconds.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(self, ws_url: str, *, timeout: float = 30.0) -> None:
|
|
172
|
+
self._ws_url = ws_url.rstrip("/")
|
|
173
|
+
self._timeout = timeout
|
|
174
|
+
|
|
175
|
+
async def _send_command(
|
|
176
|
+
self, command_type: str, payload: dict[str, Any] | None = None
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
"""Send a protocol-1 request envelope and wait for the response.
|
|
179
|
+
|
|
180
|
+
Builds a ``WireEnvelope`` with ``type='request'``, ``method=command_type``,
|
|
181
|
+
``params=payload``, and a UUID4 correlation ``id`` (RFC-450 §5, IG-522
|
|
182
|
+
Phase 6). The envelope is serialized with :func:`encode_envelope` and the
|
|
183
|
+
reply is parsed with :func:`decode_envelope`. Responses are matched by
|
|
184
|
+
``id``; ``response`` envelopes return their ``result`` and ``error``
|
|
185
|
+
envelopes raise :class:`RuntimeError` carrying the daemon's code/message.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
command_type: RPC method name (e.g. ``"autopilot_status"``).
|
|
189
|
+
payload: Structured parameters object for the method.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The ``result`` dict from the matching ``response`` envelope.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
RuntimeError: If the daemon replies with an ``error`` envelope, the
|
|
196
|
+
connection fails, or the command times out.
|
|
197
|
+
"""
|
|
198
|
+
import websockets
|
|
199
|
+
|
|
200
|
+
req_id = str(uuid4())
|
|
201
|
+
envelope = WireEnvelope(
|
|
202
|
+
type=MessageType.REQUEST.value,
|
|
203
|
+
method=command_type,
|
|
204
|
+
params=payload or {},
|
|
205
|
+
id=req_id,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
async with websockets.connect(self._ws_url, open_timeout=self._timeout) as ws:
|
|
210
|
+
await _perform_handshake(ws, timeout=self._timeout)
|
|
211
|
+
|
|
212
|
+
# Send the protocol-1 request envelope.
|
|
213
|
+
await ws.send(encode_envelope(envelope))
|
|
214
|
+
|
|
215
|
+
# Wait for the matching response/error envelope.
|
|
216
|
+
while True:
|
|
217
|
+
response_str = await asyncio.wait_for(ws.recv(), timeout=self._timeout)
|
|
218
|
+
response = decode_envelope(response_str)
|
|
219
|
+
if not isinstance(response, dict):
|
|
220
|
+
raise RuntimeError(f"Unexpected response: {response_str!r}")
|
|
221
|
+
|
|
222
|
+
msg_type = response.get("type")
|
|
223
|
+
|
|
224
|
+
# Error envelope: raise with the daemon's code/message.
|
|
225
|
+
if msg_type == MessageType.ERROR.value:
|
|
226
|
+
err = ErrorEnvelope.from_wire_dict(response)
|
|
227
|
+
raise RuntimeError(
|
|
228
|
+
f"[{err.code}] {err.message}" + (f" ({err.data})" if err.data else "")
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Success: return the result payload.
|
|
232
|
+
if msg_type == MessageType.RESPONSE.value:
|
|
233
|
+
if response.get("id") != req_id:
|
|
234
|
+
# Not our response; keep waiting for the match.
|
|
235
|
+
continue
|
|
236
|
+
return response.get("result") or {}
|
|
237
|
+
|
|
238
|
+
# Other message types (next/complete/etc.) are unexpected
|
|
239
|
+
# for a blocking request; keep reading.
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
except TimeoutError:
|
|
243
|
+
raise RuntimeError(f"Command timeout after {self._timeout}s") from None
|
|
244
|
+
except websockets.exceptions.ConnectionClosedError as exc:
|
|
245
|
+
raise RuntimeError(f"WebSocket connection closed: {exc}") from exc
|
|
246
|
+
except RuntimeError:
|
|
247
|
+
raise
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
raise RuntimeError(f"Command failed: {exc}") from exc
|
|
250
|
+
|
|
251
|
+
# Autopilot commands
|
|
252
|
+
|
|
253
|
+
async def autopilot_status(self) -> dict[str, Any]:
|
|
254
|
+
"""Get autopilot status."""
|
|
255
|
+
return await self._send_command("autopilot_status")
|
|
256
|
+
|
|
257
|
+
async def autopilot_submit(
|
|
258
|
+
self, description: str, *, priority: int = 50, workspace: str | None = None
|
|
259
|
+
) -> dict[str, Any]:
|
|
260
|
+
"""Submit a new autopilot task."""
|
|
261
|
+
payload = {"description": description, "priority": priority}
|
|
262
|
+
if workspace:
|
|
263
|
+
payload["workspace"] = workspace
|
|
264
|
+
return await self._send_command("autopilot_submit", payload)
|
|
265
|
+
|
|
266
|
+
async def autopilot_list_goals(self) -> dict[str, Any]:
|
|
267
|
+
"""List all goals."""
|
|
268
|
+
return await self._send_command("autopilot_list_goals")
|
|
269
|
+
|
|
270
|
+
async def autopilot_get_goal(self, goal_id: str) -> dict[str, Any]:
|
|
271
|
+
"""Get goal details."""
|
|
272
|
+
return await self._send_command("autopilot_get_goal", {"goal_id": goal_id})
|
|
273
|
+
|
|
274
|
+
async def autopilot_cancel_goal(self, goal_id: str) -> dict[str, Any]:
|
|
275
|
+
"""Cancel a goal."""
|
|
276
|
+
return await self._send_command("autopilot_cancel_goal", {"goal_id": goal_id})
|
|
277
|
+
|
|
278
|
+
async def autopilot_wake(self) -> dict[str, Any]:
|
|
279
|
+
"""Exit dreaming mode."""
|
|
280
|
+
return await self._send_command("autopilot_wake")
|
|
281
|
+
|
|
282
|
+
async def autopilot_dream(self) -> dict[str, Any]:
|
|
283
|
+
"""Force dreaming mode."""
|
|
284
|
+
return await self._send_command("autopilot_dream")
|
|
285
|
+
|
|
286
|
+
async def autopilot_resume(self, goal_id: str) -> dict[str, Any]:
|
|
287
|
+
"""Resume a suspended/blocked goal."""
|
|
288
|
+
return await self._send_command("autopilot_resume", {"goal_id": goal_id})
|
|
289
|
+
|
|
290
|
+
async def autopilot_list_jobs(self) -> dict[str, Any]:
|
|
291
|
+
"""List root goals (jobs) only."""
|
|
292
|
+
return await self._send_command("autopilot_list_jobs")
|
|
293
|
+
|
|
294
|
+
async def autopilot_get_job(self, job_id: str) -> dict[str, Any]:
|
|
295
|
+
"""Get job status with DAG snapshot."""
|
|
296
|
+
return await self._send_command("autopilot_get_job", {"job_id": job_id})
|
|
297
|
+
|
|
298
|
+
async def autopilot_subscribe(self) -> dict[str, Any]:
|
|
299
|
+
"""Subscribe to autopilot worker events."""
|
|
300
|
+
return await self._send_command("autopilot_subscribe")
|
|
301
|
+
|
|
302
|
+
async def autopilot_unsubscribe(self) -> dict[str, Any]:
|
|
303
|
+
"""Unsubscribe from autopilot worker events."""
|
|
304
|
+
return await self._send_command("autopilot_unsubscribe")
|
|
305
|
+
|
|
306
|
+
# RFC-228 canonical job commands (recommended)
|
|
307
|
+
|
|
308
|
+
async def job_create(
|
|
309
|
+
self,
|
|
310
|
+
goal: str,
|
|
311
|
+
*,
|
|
312
|
+
workspace: str | None = None,
|
|
313
|
+
autonomous: bool = False,
|
|
314
|
+
max_iterations: int | None = None,
|
|
315
|
+
guidance: str | None = None,
|
|
316
|
+
) -> dict[str, Any]:
|
|
317
|
+
"""Create a new autopilot job (RFC-228 canonical method).
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
goal: Job goal text (required).
|
|
321
|
+
workspace: Optional workspace path.
|
|
322
|
+
autonomous: Whether to run autonomously.
|
|
323
|
+
max_iterations: Optional iteration limit.
|
|
324
|
+
guidance: Optional initial guidance.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Dict with job_id and status.
|
|
328
|
+
"""
|
|
329
|
+
payload = {"goal": goal}
|
|
330
|
+
if workspace:
|
|
331
|
+
payload["workspace"] = workspace
|
|
332
|
+
if autonomous:
|
|
333
|
+
payload["autonomous"] = autonomous
|
|
334
|
+
if max_iterations:
|
|
335
|
+
payload["max_iterations"] = max_iterations
|
|
336
|
+
if guidance:
|
|
337
|
+
payload["guidance"] = guidance
|
|
338
|
+
return await self._send_command("job_create", payload)
|
|
339
|
+
|
|
340
|
+
async def job_status(self, job_id: str) -> dict[str, Any]:
|
|
341
|
+
"""Get job status with goal counts and workers (RFC-228 canonical method).
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
job_id: Job identifier.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Dict with job_id, status, active_goals, completed_goals, workers.
|
|
348
|
+
"""
|
|
349
|
+
return await self._send_command("job_status", {"job_id": job_id})
|
|
350
|
+
|
|
351
|
+
async def job_pause(self, job_id: str) -> dict[str, Any]:
|
|
352
|
+
"""Pause a running job (RFC-228 canonical method).
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
job_id: Job identifier.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Dict with job_id and status="suspended".
|
|
359
|
+
"""
|
|
360
|
+
return await self._send_command("job_pause", {"job_id": job_id})
|
|
361
|
+
|
|
362
|
+
async def job_resume(self, job_id: str) -> dict[str, Any]:
|
|
363
|
+
"""Resume a paused job (RFC-228 canonical method).
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
job_id: Job identifier.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Dict with job_id and status="pending".
|
|
370
|
+
"""
|
|
371
|
+
return await self._send_command("job_resume", {"job_id": job_id})
|
|
372
|
+
|
|
373
|
+
async def job_cancel(self, job_id: str) -> dict[str, Any]:
|
|
374
|
+
"""Cancel a job (RFC-228 canonical method).
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
job_id: Job identifier.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Dict with job_id and status="cancelled".
|
|
381
|
+
"""
|
|
382
|
+
return await self._send_command("job_cancel", {"job_id": job_id})
|
|
383
|
+
|
|
384
|
+
async def job_dag(self, job_id: str) -> dict[str, Any]:
|
|
385
|
+
"""Get job DAG snapshot for visualization (RFC-228 canonical method).
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
job_id: Job identifier.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
Dict with job_id and dag (nodes/edges).
|
|
392
|
+
"""
|
|
393
|
+
return await self._send_command("job_dag", {"job_id": job_id})
|
|
394
|
+
|
|
395
|
+
async def job_guidance(
|
|
396
|
+
self, job_id: str, content: str, *, goal_id: str | None = None
|
|
397
|
+
) -> dict[str, Any]:
|
|
398
|
+
"""Send guidance to a job or specific goal (RFC-228 canonical method).
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
job_id: Job identifier.
|
|
402
|
+
content: Guidance text.
|
|
403
|
+
goal_id: Optional specific goal to target.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Dict with job_id, goal_id, absorbed.
|
|
407
|
+
"""
|
|
408
|
+
payload = {"job_id": job_id, "content": content}
|
|
409
|
+
if goal_id:
|
|
410
|
+
payload["goal_id"] = goal_id
|
|
411
|
+
return await self._send_command("job_guidance", payload)
|
|
412
|
+
|
|
413
|
+
# Cron commands
|
|
414
|
+
|
|
415
|
+
async def cron_add(self, text: str, *, priority: int | None = None) -> dict[str, Any]:
|
|
416
|
+
"""Submit a natural-language scheduled job."""
|
|
417
|
+
payload = {"text": text}
|
|
418
|
+
if priority is not None:
|
|
419
|
+
payload["priority"] = priority
|
|
420
|
+
result = await self._send_command("cron_add", payload)
|
|
421
|
+
return _normalize_cron_add_result(result)
|
|
422
|
+
|
|
423
|
+
async def cron_list(self, *, status: str | None = None) -> dict[str, Any]:
|
|
424
|
+
"""List scheduled jobs (RFC-229 canonical method).
|
|
425
|
+
|
|
426
|
+
Sends the ``cron_list`` method, which the daemon routes to
|
|
427
|
+
``_handle_cron_list``. The former ``cron_list_jobs`` method name is
|
|
428
|
+
deprecated; it sent a method the daemon did not handle.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
status: Optional status filter.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
Dict with jobs list.
|
|
435
|
+
"""
|
|
436
|
+
payload = {}
|
|
437
|
+
if status:
|
|
438
|
+
payload["status"] = status
|
|
439
|
+
return await self._send_command("cron_list", payload)
|
|
440
|
+
|
|
441
|
+
async def cron_show(self, job_id: str) -> dict[str, Any]:
|
|
442
|
+
"""Get job details."""
|
|
443
|
+
result = await self._send_command("cron_show", {"job_id": job_id})
|
|
444
|
+
return _normalize_cron_show_result(result)
|
|
445
|
+
|
|
446
|
+
async def cron_cancel(self, job_id: str) -> dict[str, Any]:
|
|
447
|
+
"""Cancel a scheduled job."""
|
|
448
|
+
return await self._send_command("cron_cancel", {"job_id": job_id})
|
|
449
|
+
|
|
450
|
+
# Memory commands
|
|
451
|
+
|
|
452
|
+
async def memory_stats(self, mode: str = "daemon") -> dict[str, Any]:
|
|
453
|
+
"""Query daemon memory profiling stats."""
|
|
454
|
+
return await self._send_command("memory_stats", {"mode": mode})
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class SyncWsCommandClient:
|
|
458
|
+
"""Synchronous wrapper for WsCommandClient.
|
|
459
|
+
|
|
460
|
+
Provides a synchronous interface for CLI commands that need to call
|
|
461
|
+
daemon endpoints without async context.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
ws_url: WebSocket URL.
|
|
465
|
+
timeout: Command timeout in seconds.
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
def __init__(self, ws_url: str, *, timeout: float = 30.0) -> None:
|
|
469
|
+
self._client = WsCommandClient(ws_url, timeout=timeout)
|
|
470
|
+
|
|
471
|
+
def _run_async(self, coro: Any) -> Any:
|
|
472
|
+
"""Run async coroutine in sync context."""
|
|
473
|
+
try:
|
|
474
|
+
loop = asyncio.get_running_loop()
|
|
475
|
+
except RuntimeError:
|
|
476
|
+
loop = None
|
|
477
|
+
|
|
478
|
+
if loop is not None:
|
|
479
|
+
# Already in async context - create task
|
|
480
|
+
return asyncio.ensure_future(coro)
|
|
481
|
+
else:
|
|
482
|
+
# Not in async context - run in new loop
|
|
483
|
+
return asyncio.run(coro)
|
|
484
|
+
|
|
485
|
+
def autopilot_status(self) -> dict[str, Any]:
|
|
486
|
+
"""Get autopilot status (sync)."""
|
|
487
|
+
return self._run_async(self._client.autopilot_status())
|
|
488
|
+
|
|
489
|
+
def autopilot_submit(
|
|
490
|
+
self, description: str, *, priority: int = 50, workspace: str | None = None
|
|
491
|
+
) -> dict[str, Any]:
|
|
492
|
+
"""Submit a new autopilot task (sync)."""
|
|
493
|
+
return self._run_async(
|
|
494
|
+
self._client.autopilot_submit(description, priority=priority, workspace=workspace)
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def autopilot_list_goals(self) -> dict[str, Any]:
|
|
498
|
+
"""List all goals (sync)."""
|
|
499
|
+
return self._run_async(self._client.autopilot_list_goals())
|
|
500
|
+
|
|
501
|
+
def autopilot_get_goal(self, goal_id: str) -> dict[str, Any]:
|
|
502
|
+
"""Get goal details (sync)."""
|
|
503
|
+
return self._run_async(self._client.autopilot_get_goal(goal_id))
|
|
504
|
+
|
|
505
|
+
def autopilot_cancel_goal(self, goal_id: str) -> dict[str, Any]:
|
|
506
|
+
"""Cancel a goal (sync)."""
|
|
507
|
+
return self._run_async(self._client.autopilot_cancel_goal(goal_id))
|
|
508
|
+
|
|
509
|
+
def autopilot_wake(self) -> dict[str, Any]:
|
|
510
|
+
"""Exit dreaming mode (sync)."""
|
|
511
|
+
return self._run_async(self._client.autopilot_wake())
|
|
512
|
+
|
|
513
|
+
def autopilot_dream(self) -> dict[str, Any]:
|
|
514
|
+
"""Force dreaming mode (sync)."""
|
|
515
|
+
return self._run_async(self._client.autopilot_dream())
|
|
516
|
+
|
|
517
|
+
def autopilot_resume(self, goal_id: str) -> dict[str, Any]:
|
|
518
|
+
"""Resume a suspended/blocked goal (sync)."""
|
|
519
|
+
return self._run_async(self._client.autopilot_resume(goal_id))
|
|
520
|
+
|
|
521
|
+
def autopilot_list_jobs(self) -> dict[str, Any]:
|
|
522
|
+
"""List root goals (jobs) only (sync)."""
|
|
523
|
+
return self._run_async(self._client.autopilot_list_jobs())
|
|
524
|
+
|
|
525
|
+
def autopilot_get_job(self, job_id: str) -> dict[str, Any]:
|
|
526
|
+
"""Get job status with DAG snapshot (sync)."""
|
|
527
|
+
return self._run_async(self._client.autopilot_get_job(job_id))
|
|
528
|
+
|
|
529
|
+
def job_pause(self, job_id: str) -> dict[str, Any]:
|
|
530
|
+
"""Pause a running autopilot job (sync)."""
|
|
531
|
+
return self._run_async(self._client.job_pause(job_id))
|
|
532
|
+
|
|
533
|
+
def job_guidance(self, job_id: str, text: str, *, goal_id: str | None = None) -> dict[str, Any]:
|
|
534
|
+
"""Send guidance to an autopilot job or specific goal (sync)."""
|
|
535
|
+
return self._run_async(self._client.job_guidance(job_id, text, goal_id=goal_id))
|
|
536
|
+
|
|
537
|
+
def job_create(
|
|
538
|
+
self,
|
|
539
|
+
goal: str,
|
|
540
|
+
*,
|
|
541
|
+
workspace: str | None = None,
|
|
542
|
+
autonomous: bool = False,
|
|
543
|
+
max_iterations: int | None = None,
|
|
544
|
+
guidance: str | None = None,
|
|
545
|
+
) -> dict[str, Any]:
|
|
546
|
+
"""Create a new autopilot job (sync, RFC-228 canonical)."""
|
|
547
|
+
return self._run_async(
|
|
548
|
+
self._client.job_create(
|
|
549
|
+
goal,
|
|
550
|
+
workspace=workspace,
|
|
551
|
+
autonomous=autonomous,
|
|
552
|
+
max_iterations=max_iterations,
|
|
553
|
+
guidance=guidance,
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def job_status(self, job_id: str) -> dict[str, Any]:
|
|
558
|
+
"""Get job status with goal counts and workers (sync, RFC-228 canonical)."""
|
|
559
|
+
return self._run_async(self._client.job_status(job_id))
|
|
560
|
+
|
|
561
|
+
def job_resume(self, job_id: str) -> dict[str, Any]:
|
|
562
|
+
"""Resume a paused autopilot job (sync, RFC-228 canonical)."""
|
|
563
|
+
return self._run_async(self._client.job_resume(job_id))
|
|
564
|
+
|
|
565
|
+
def job_cancel(self, job_id: str) -> dict[str, Any]:
|
|
566
|
+
"""Cancel an autopilot job (sync, RFC-228 canonical)."""
|
|
567
|
+
return self._run_async(self._client.job_cancel(job_id))
|
|
568
|
+
|
|
569
|
+
def job_dag(self, job_id: str) -> dict[str, Any]:
|
|
570
|
+
"""Get job DAG snapshot for visualization (sync, RFC-228 canonical)."""
|
|
571
|
+
return self._run_async(self._client.job_dag(job_id))
|
|
572
|
+
|
|
573
|
+
def autopilot_subscribe(self) -> dict[str, Any]:
|
|
574
|
+
"""Subscribe to autopilot worker events (sync)."""
|
|
575
|
+
return self._run_async(self._client.autopilot_subscribe())
|
|
576
|
+
|
|
577
|
+
def autopilot_unsubscribe(self) -> dict[str, Any]:
|
|
578
|
+
"""Unsubscribe from autopilot worker events (sync)."""
|
|
579
|
+
return self._run_async(self._client.autopilot_unsubscribe())
|
|
580
|
+
|
|
581
|
+
def cron_add(self, text: str, *, priority: int | None = None) -> dict[str, Any]:
|
|
582
|
+
"""Submit a natural-language scheduled job (sync)."""
|
|
583
|
+
return self._run_async(self._client.cron_add(text, priority=priority))
|
|
584
|
+
|
|
585
|
+
def cron_list(self, *, status: str | None = None) -> dict[str, Any]:
|
|
586
|
+
"""List scheduled jobs (sync)."""
|
|
587
|
+
return self._run_async(self._client.cron_list(status=status))
|
|
588
|
+
|
|
589
|
+
def cron_show(self, job_id: str) -> dict[str, Any]:
|
|
590
|
+
"""Get job details (sync)."""
|
|
591
|
+
return self._run_async(self._client.cron_show(job_id))
|
|
592
|
+
|
|
593
|
+
def cron_cancel(self, job_id: str) -> dict[str, Any]:
|
|
594
|
+
"""Cancel a scheduled job (sync)."""
|
|
595
|
+
return self._run_async(self._client.cron_cancel(job_id))
|
|
596
|
+
|
|
597
|
+
def memory_stats(self, mode: str = "daemon") -> dict[str, Any]:
|
|
598
|
+
"""Query daemon memory profiling stats (sync)."""
|
|
599
|
+
return self._run_async(self._client.memory_stats(mode))
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def ws_command_client_from_config(cfg: Any) -> SyncWsCommandClient:
|
|
603
|
+
"""Build a WebSocket command client from CLI or soothe config.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
cfg: CLI, daemon, or soothe config exposing websocket host/port.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
SyncWsCommandClient instance.
|
|
610
|
+
"""
|
|
611
|
+
ws_url = websocket_url_from_config(cfg)
|
|
612
|
+
return SyncWsCommandClient(ws_url)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def async_ws_command_client_from_config(cfg: Any) -> WsCommandClient:
|
|
616
|
+
"""Build an async WebSocket command client from config.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
cfg: CLI, daemon, or soothe config exposing websocket host/port.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
WsCommandClient instance.
|
|
623
|
+
"""
|
|
624
|
+
ws_url = websocket_url_from_config(cfg)
|
|
625
|
+
return WsCommandClient(ws_url)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
__all__ = [
|
|
629
|
+
"WsCommandClient",
|
|
630
|
+
"SyncWsCommandClient",
|
|
631
|
+
"ws_command_client_from_config",
|
|
632
|
+
"async_ws_command_client_from_config",
|
|
633
|
+
]
|