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.
@@ -0,0 +1,199 @@
1
+ """Shared WebSocket session bootstrap for CLI headless and TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from soothe_sdk.wire.codec import ProtocolError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _CONNECT_RETRY_COUNT = 40
15
+ _CONNECT_RETRY_DELAY_S = 0.25
16
+ _CONNECT_TIMEOUT_S = 5.0
17
+ _DAEMON_READY_TIMEOUT_S = 20.0
18
+ _SESSION_BOOTSTRAP_TIMEOUT_S = 30.0
19
+
20
+ # Wire-compat: interactive loops are always solo; daemon jobs use AutopilotService.
21
+ _LEGACY_LOOP_AUTOPILOT_MODE = "solo"
22
+
23
+
24
+ async def connect_websocket_with_retries(client: Any) -> None:
25
+ """Connect to the daemon with bounded retries for cold-start races.
26
+
27
+ Args:
28
+ client: WebSocketClient instance.
29
+
30
+ Raises:
31
+ ConnectionError: If connection fails after all retries.
32
+ """
33
+ last_error: OSError | ConnectionError | TimeoutError | None = None
34
+ for attempt in range(_CONNECT_RETRY_COUNT):
35
+ try:
36
+ await asyncio.wait_for(client.connect(), timeout=_CONNECT_TIMEOUT_S)
37
+ except (ConnectionRefusedError, OSError, ConnectionError, TimeoutError) as exc:
38
+ last_error = exc
39
+ if attempt == _CONNECT_RETRY_COUNT - 1:
40
+ raise
41
+ await asyncio.sleep(_CONNECT_RETRY_DELAY_S)
42
+ else:
43
+ return
44
+
45
+ if last_error is not None:
46
+ raise last_error
47
+
48
+
49
+ async def bootstrap_loop_session(
50
+ client: Any,
51
+ *,
52
+ resume_loop_id: str | None,
53
+ workspace: str | Path | None = None,
54
+ user_id: str | None = None,
55
+ client_workspace_id: str | None = None,
56
+ stream_delivery: str = "adaptive",
57
+ is_ephemeral: bool = False,
58
+ daemon_ready_timeout_s: float = _DAEMON_READY_TIMEOUT_S,
59
+ subscribe_timeout_s: float = _SESSION_BOOTSTRAP_TIMEOUT_S,
60
+ ) -> dict[str, Any]:
61
+ """Handshake with the daemon, create or attach to a loop, and subscribe for events.
62
+
63
+ Uses the protocol-1 wire contract (RFC-450): ``connection_init``/``ack``
64
+ handshake, then ``request('loop_new')`` or ``request('loop_reattach')`` for
65
+ resume, then ``subscribe('loop_events')`` for the event stream.
66
+
67
+ Args:
68
+ client: ``WebSocketClient`` instance (connected).
69
+ resume_loop_id: If set, reattach to this existing loop via
70
+ ``loop_reattach``. Otherwise create a new loop via ``loop_new``.
71
+ stream_delivery: Daemon stream shaping — one of ``batch`` | ``adaptive``
72
+ (default, IG-441) | ``streaming``.
73
+ is_ephemeral: When True, loop execution data is GC'd after idle period.
74
+ workspace: Optional client project directory (e.g. user's CWD). Sent as
75
+ ``workspace`` on ``loop_new`` and used directly by the runner when set.
76
+ Ignored on resume when the loop already has workspace metadata.
77
+ user_id: Optional user id for ``$SOOTHE_HOME/workspaces/<user>/`` layout.
78
+ client_workspace_id: Optional stable scope when ``workspace`` is omitted.
79
+ daemon_ready_timeout_s: Max seconds for connection_ack handshake.
80
+ subscribe_timeout_s: Max seconds for ``loop_new`` / ``loop_reattach`` /
81
+ ``loop_events`` RPCs.
82
+
83
+ Returns:
84
+ A dict with at least ``loop_id`` on success, or an ``error`` event-shaped dict.
85
+
86
+ Raises:
87
+ TimeoutError: If a waited step times out.
88
+ RuntimeError: If daemon reports not-ready during handshake.
89
+ ConnectionError: If the WebSocket is closed and cannot be re-established.
90
+ ProtocolError: If the daemon returns a protocol-level error for
91
+ ``loop_new``, ``loop_reattach``, or the ``loop_events`` subscription.
92
+ """
93
+ alive_check = getattr(client, "is_connection_alive", None)
94
+ if alive_check is not None and not alive_check():
95
+ await client.close()
96
+ await connect_websocket_with_retries(client)
97
+
98
+ await client.request_connection_init()
99
+ await client.wait_for_connection_ack(ack_timeout_s=daemon_ready_timeout_s)
100
+
101
+ mapping_data: dict[str, Any] | None = None
102
+
103
+ if resume_loop_id:
104
+ # Resume path: reattach to the existing loop via ``loop_reattach`` so the
105
+ # daemon replays card history before we subscribe to the live event stream.
106
+ reattach_params: dict[str, Any] = {"loop_id": resume_loop_id}
107
+ try:
108
+ await client.request(
109
+ "loop_reattach",
110
+ reattach_params,
111
+ timeout=subscribe_timeout_s,
112
+ )
113
+ except ProtocolError:
114
+ logger.warning(
115
+ "loop_reattach failed for loop %s; subscribing without replay",
116
+ resume_loop_id,
117
+ exc_info=True,
118
+ )
119
+ loop_id = resume_loop_id
120
+ else:
121
+ # New-loop path: ``loop_new`` creates the conversation. Per RFC-450 §10.1
122
+ # the field is ``workspace`` (renamed from ``client_workspace``).
123
+ loop_new_params: dict[str, Any] = {}
124
+ if workspace is not None:
125
+ workspace_str = str(workspace).strip()
126
+ if workspace_str:
127
+ loop_new_params["workspace"] = workspace_str
128
+ if user_id is not None and str(user_id).strip():
129
+ loop_new_params["user_id"] = str(user_id).strip()
130
+ if client_workspace_id is not None and str(client_workspace_id).strip():
131
+ loop_new_params["client_workspace_id"] = str(client_workspace_id).strip()
132
+ if is_ephemeral:
133
+ loop_new_params["is_ephemeral"] = True
134
+ # ``request`` raises ``ProtocolError`` if the daemon returns an error
135
+ # envelope (e.g. INVALID_PARAMS, WORKSPACE_RESOLUTION_FAILED). Let it
136
+ # propagate — bootstrap cannot continue without a valid loop_id.
137
+ new_resp = await client.request(
138
+ "loop_new",
139
+ loop_new_params,
140
+ timeout=subscribe_timeout_s,
141
+ )
142
+ loop_id = str(new_resp.get("loop_id") or "")
143
+ if not loop_id:
144
+ raise ValueError("loop_new response missing loop_id")
145
+
146
+ # RFC-621: parse workspace mapping for container path translation
147
+ mapping_data = new_resp.get("workspace_mapping")
148
+ if mapping_data and mapping_data.get("host_root") and mapping_data.get("container_root"):
149
+ from soothe_sdk.wire.protocol import WorkspaceMapping
150
+
151
+ workspace_mapping = WorkspaceMapping(
152
+ host_root=mapping_data["host_root"],
153
+ container_root=mapping_data["container_root"],
154
+ )
155
+ # Store on client for use in event path translation
156
+ if hasattr(client, "workspace_mapping"):
157
+ client.workspace_mapping = workspace_mapping
158
+
159
+ # IG-441: three first-class modes (batch / adaptive / streaming). Unknown
160
+ # values fall back to ``adaptive`` (the new bootstrap default).
161
+ delivery = (
162
+ stream_delivery if stream_delivery in ("batch", "adaptive", "streaming") else "adaptive"
163
+ )
164
+ # Subscribe to the loop's event stream. Protocol-1 uses ``subscribe`` with
165
+ # the ``loop_events`` target — the subscription is confirmed implicitly
166
+ # (RFC-450 §9.4). If the daemon cannot honour it, an ``error`` with the
167
+ # subscription ``id`` arrives within the timeout window and ``subscribe``
168
+ # raises ``ProtocolError``.
169
+ await client.subscribe(
170
+ "loop_events",
171
+ {
172
+ "loop_id": loop_id,
173
+ "stream_delivery": delivery,
174
+ "wire_tier": "full",
175
+ },
176
+ timeout=subscribe_timeout_s,
177
+ )
178
+
179
+ logger.info(
180
+ "Subscribed to loop %s with stream_delivery=%s",
181
+ loop_id,
182
+ delivery,
183
+ )
184
+ result: dict[str, Any] = {
185
+ "type": "session_ready",
186
+ "loop_id": loop_id,
187
+ "success": True,
188
+ # Deprecated wire field: interactive loops are always solo.
189
+ "autopilot_mode": _LEGACY_LOOP_AUTOPILOT_MODE,
190
+ }
191
+ if mapping_data and mapping_data.get("host_root"):
192
+ result["workspace_mapping"] = mapping_data
193
+ return result
194
+
195
+
196
+ __all__ = [
197
+ "bootstrap_loop_session",
198
+ "connect_websocket_with_retries",
199
+ ]