crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,655 @@
1
+ """Event bus for SSE / WebSocket event distribution.
2
+
3
+ Inspired by OpenCode's event.ts + AsyncQueue pattern. Supports
4
+ multiple subscribers per session with 10 s heartbeat keep-alive.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import uuid
12
+ from collections import OrderedDict, deque
13
+ from dataclasses import dataclass
14
+ from typing import Any, AsyncIterator, Callable
15
+
16
+ from crabcode_core.logging_utils import get_logger
17
+ from crabcode_gateway.schemas import (
18
+ ServerConnectedPayload,
19
+ ServerHeartbeatPayload,
20
+ core_event_to_payload,
21
+ )
22
+
23
+ logger = get_logger(__name__)
24
+
25
+ _HEARTBEAT_INTERVAL = 10 # seconds
26
+ _DEFAULT_SUBSCRIBER_QUEUE_SIZE = 1024
27
+
28
+ _ORDINARY_EVENT = 0
29
+ _INTERACTIVE_CONTROL_EVENT = 1
30
+ _TERMINAL_CONTROL_EVENT = 2
31
+ _LIFECYCLE_CONTROL_EVENT = 3
32
+
33
+
34
+ def _event_priority(data: str | None) -> int:
35
+ """Classify buffered payloads for bounded-queue eviction."""
36
+ if data is None:
37
+ return _LIFECYCLE_CONTROL_EVENT
38
+ try:
39
+ payload = json.loads(data)
40
+ except (TypeError, json.JSONDecodeError):
41
+ return _ORDINARY_EVENT
42
+ if not isinstance(payload, dict):
43
+ return _ORDINARY_EVENT
44
+
45
+ event_type = payload.get("type")
46
+ if event_type == "server.connected":
47
+ return _LIFECYCLE_CONTROL_EVENT
48
+ if event_type in {"permission_request", "choice_request"}:
49
+ return _INTERACTIVE_CONTROL_EVENT
50
+ # Only an explicit turn boundary is terminal. Recoverable foreground
51
+ # errors can be followed by retries or a later TurnCompleteEvent; treating
52
+ # them as terminal here could evict the actual boundary from a full queue.
53
+ if event_type == "turn_complete":
54
+ return _TERMINAL_CONTROL_EVENT
55
+ return _ORDINARY_EVENT
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class _BufferedEvent:
60
+ data: str | None
61
+ priority: int
62
+
63
+
64
+ class _BoundedEventQueue:
65
+ """FIFO buffer with O(1) removal of the oldest item at a priority."""
66
+
67
+ def __init__(self, maxsize: int) -> None:
68
+ self.maxsize = maxsize
69
+ self._next_id = 0
70
+ self._items: OrderedDict[int, _BufferedEvent] = OrderedDict()
71
+ self._ids_by_priority = tuple(
72
+ deque() for _ in range(_LIFECYCLE_CONTROL_EVENT + 1)
73
+ )
74
+ self._not_empty = asyncio.Event()
75
+
76
+ def qsize(self) -> int:
77
+ return len(self._items)
78
+
79
+ def empty(self) -> bool:
80
+ return not self._items
81
+
82
+ def full(self) -> bool:
83
+ return len(self._items) >= self.maxsize
84
+
85
+ def put_nowait(
86
+ self,
87
+ data: str | None,
88
+ *,
89
+ priority: int | None = None,
90
+ ) -> None:
91
+ if self.full():
92
+ raise asyncio.QueueFull
93
+ cached_priority = _event_priority(data) if priority is None else priority
94
+ item_id = self._next_id
95
+ self._next_id += 1
96
+ self._items[item_id] = _BufferedEvent(data, cached_priority)
97
+ self._ids_by_priority[cached_priority].append(item_id)
98
+ self._not_empty.set()
99
+
100
+ def get_nowait(self) -> str | None:
101
+ if not self._items:
102
+ raise asyncio.QueueEmpty
103
+ item_id, item = self._items.popitem(last=False)
104
+ priority_ids = self._ids_by_priority[item.priority]
105
+ popped_id = priority_ids.popleft()
106
+ assert popped_id == item_id
107
+ if not self._items:
108
+ self._not_empty.clear()
109
+ return item.data
110
+
111
+ async def get(self) -> str | None:
112
+ while not self._items:
113
+ self._not_empty.clear()
114
+ await self._not_empty.wait()
115
+ return self.get_nowait()
116
+
117
+ def discard_oldest(self, max_priority: int) -> bool:
118
+ """Discard the oldest item at the lowest available priority."""
119
+ victim_priority = next(
120
+ (
121
+ priority
122
+ for priority in range(max_priority + 1)
123
+ if self._ids_by_priority[priority]
124
+ ),
125
+ None,
126
+ )
127
+ if victim_priority is None:
128
+ return False
129
+ item_id = self._ids_by_priority[victim_priority].popleft()
130
+ self._items.pop(item_id)
131
+ if not self._items:
132
+ self._not_empty.clear()
133
+ return True
134
+
135
+
136
+ async def _cancel_and_drain(task: asyncio.Task[Any]) -> None:
137
+ """Cancel a queue waiter and settle it through repeated cancellation.
138
+
139
+ ``asyncio.wait_for(coro)`` creates an internal task. If the surrounding
140
+ task is cancelled between loop iterations, that internal task can be
141
+ created after the cancellation has already been delivered and become an
142
+ orphaned ``Queue.get`` waiter. Keeping the handle explicit lets every
143
+ transport teardown drain it deterministically.
144
+ """
145
+ if not task.done():
146
+ task.cancel()
147
+ caller = asyncio.current_task()
148
+ caller_cancelled = bool(caller is not None and caller.cancelling())
149
+ while not task.done():
150
+ try:
151
+ await asyncio.shield(task)
152
+ except asyncio.CancelledError:
153
+ # ``task.cancel()`` also makes ``shield(task)`` raise
154
+ # ``CancelledError`` once the child settles. That is expected
155
+ # during a timeout and must not be propagated as cancellation of
156
+ # the transport itself. ``Task.cancelling`` identifies an
157
+ # outstanding cancellation request on this waiter even when the
158
+ # child happens to settle in the same event-loop turn.
159
+ if caller is not None and caller.cancelling():
160
+ caller_cancelled = True
161
+ try:
162
+ await task
163
+ except asyncio.CancelledError:
164
+ pass
165
+ if caller_cancelled:
166
+ raise asyncio.CancelledError
167
+
168
+
169
+ async def _next_with_timeout(subscriber: Any, timeout: float) -> Any:
170
+ """Read one subscriber item without leaking a timeout/cancel waiter."""
171
+ next_task = asyncio.create_task(subscriber.next())
172
+ try:
173
+ done, _ = await asyncio.wait({next_task}, timeout=timeout)
174
+ if not done:
175
+ await _cancel_and_drain(next_task)
176
+ raise asyncio.TimeoutError
177
+ return next_task.result()
178
+ except BaseException:
179
+ if not next_task.done():
180
+ await _cancel_and_drain(next_task)
181
+ else:
182
+ # ``result`` may not have been reached if cancellation raced with
183
+ # task completion; retrieve any exception to avoid warnings.
184
+ try:
185
+ next_task.exception()
186
+ except (asyncio.CancelledError, Exception):
187
+ pass
188
+ raise
189
+
190
+
191
+ class _Subscriber:
192
+ """A single SSE / WS subscriber backed by an async queue."""
193
+
194
+ def __init__(
195
+ self,
196
+ session_id: str,
197
+ *,
198
+ max_queue_size: int,
199
+ connected: str,
200
+ ) -> None:
201
+ self.session_id = session_id
202
+ self._queue = _BoundedEventQueue(max_queue_size)
203
+ # The protocol handshake is not business traffic and must not consume
204
+ # bounded queue capacity. Keeping it out of band also guarantees that
205
+ # a size-one subscriber can observe both the handshake and one event.
206
+ self._connected = connected
207
+ self._connected_pending = True
208
+ self._closed = False
209
+ self._overflowed = False
210
+ self.dropped_messages = 0
211
+
212
+ @property
213
+ def closed(self) -> bool:
214
+ return self._closed
215
+
216
+ @property
217
+ def overflowed(self) -> bool:
218
+ return self._overflowed
219
+
220
+ async def push(self, data: str) -> None:
221
+ # Publishing must never wait for a slow transport. Keep the newest
222
+ # events in a bounded buffer so one disconnected or stalled client
223
+ # cannot stall every producer or grow memory without limit.
224
+ self.push_nowait(data)
225
+
226
+ def push_nowait(self, data: str) -> None:
227
+ if self._closed:
228
+ return
229
+ incoming_priority = _event_priority(data)
230
+ if self._queue.full():
231
+ # Ordinary traffic remains lossy, but a control event must never
232
+ # silently replace another control event: either evict ordinary
233
+ # traffic or terminate the subscriber so its transport can recover
234
+ # instead of continuing with an incomplete control history.
235
+ if not self._queue.discard_oldest(_ORDINARY_EVENT):
236
+ if incoming_priority > _ORDINARY_EVENT:
237
+ self._overflowed = True
238
+ self.dropped_messages += self._queue.qsize() + 1
239
+ self.close()
240
+ return
241
+ self.dropped_messages += 1
242
+ return
243
+ self.dropped_messages += 1
244
+ try:
245
+ self._queue.put_nowait(data, priority=incoming_priority)
246
+ except asyncio.QueueFull:
247
+ # Defensive handling for callers publishing across threads. A
248
+ # raced control event follows the same fail-fast rule as above.
249
+ if incoming_priority > _ORDINARY_EVENT:
250
+ self._overflowed = True
251
+ self.dropped_messages += self._queue.qsize() + 1
252
+ self.close()
253
+ else:
254
+ self.dropped_messages += 1
255
+
256
+ async def next(self) -> str | None:
257
+ if self._connected_pending:
258
+ self._connected_pending = False
259
+ return self._connected
260
+ return await self._queue.get()
261
+
262
+ def close(self) -> None:
263
+ if self._closed:
264
+ return
265
+ self._closed = True
266
+ # A closed session must not flush buffered events before the sentinel.
267
+ # In particular, a session id can be resumed immediately after close;
268
+ # retaining even one queued payload would leak the old lifecycle to a
269
+ # client that is still draining this subscriber. The connection
270
+ # handshake is stored out of band and remains observable before EOF.
271
+ while True:
272
+ try:
273
+ self._queue.get_nowait()
274
+ except asyncio.QueueEmpty:
275
+ break
276
+ try:
277
+ self._queue.put_nowait(None, priority=_LIFECYCLE_CONTROL_EVENT)
278
+ except asyncio.QueueFull:
279
+ # The queue was drained synchronously above, so this can only be a
280
+ # cross-thread race. A subsequent publisher observes _closed and
281
+ # cannot add more data; retry after clearing the raced item.
282
+ while True:
283
+ try:
284
+ self._queue.get_nowait()
285
+ except asyncio.QueueEmpty:
286
+ break
287
+ self._queue.put_nowait(None, priority=_LIFECYCLE_CONTROL_EVENT)
288
+
289
+ def drop_session(self, session_id: str) -> None:
290
+ """Remove queued payloads belonging to a closed session lifecycle."""
291
+ retained: list[str | None] = []
292
+ while True:
293
+ try:
294
+ item = self._queue.get_nowait()
295
+ except asyncio.QueueEmpty:
296
+ break
297
+ if item is None:
298
+ retained.append(item)
299
+ continue
300
+ try:
301
+ payload = json.loads(item)
302
+ except (TypeError, json.JSONDecodeError):
303
+ retained.append(item)
304
+ continue
305
+ if not isinstance(payload, dict) or payload.get("session_id") != session_id:
306
+ retained.append(item)
307
+ else:
308
+ self.dropped_messages += 1
309
+ for item in retained:
310
+ try:
311
+ self._queue.put_nowait(item)
312
+ except asyncio.QueueFull:
313
+ # The queue was drained above, so this is only a defensive
314
+ # guard for a concurrent producer. Preserve the newest data.
315
+ self.dropped_messages += 1
316
+
317
+
318
+ class EventBus:
319
+ """Session-scoped event bus with multi-subscriber broadcast.
320
+
321
+ All CoreEvents for a session are published here and fanned out
322
+ to every connected subscriber (CLI, VSCode, web UI, etc.).
323
+ """
324
+
325
+ def __init__(
326
+ self,
327
+ *,
328
+ subscriber_queue_size: int = _DEFAULT_SUBSCRIBER_QUEUE_SIZE,
329
+ ) -> None:
330
+ if subscriber_queue_size <= 0:
331
+ raise ValueError("subscriber_queue_size must be greater than zero")
332
+ self._subscriber_queue_size = subscriber_queue_size
333
+ self._subscribers: dict[str, list[_Subscriber]] = {}
334
+ self._global_subscribers: list[_Subscriber] = []
335
+ # The same session id can be reused after archive/resume. Keep the
336
+ # currently installed object so late events from the old object cannot
337
+ # enter a replacement session's global stream.
338
+ self._session_sources: dict[str, object] = {}
339
+ self._invalidated_sessions: set[str] = set()
340
+
341
+ def register_session(self, session_id: str, source: object) -> None:
342
+ """Bind a session id to its currently installed CoreSession object."""
343
+ previous = self._session_sources.get(session_id)
344
+ if previous is not None and previous is not source:
345
+ # Defensive fencing for integrations that install a replacement
346
+ # without first calling ``close_session``. Drop queued events and
347
+ # terminate id-bound streams before exposing the new source.
348
+ self.close_session(session_id, previous)
349
+ self._session_sources[session_id] = source
350
+ self._invalidated_sessions.discard(session_id)
351
+
352
+ def _source_is_current(
353
+ self,
354
+ session_id: str,
355
+ source: object | None,
356
+ event: Any | None = None,
357
+ ) -> bool:
358
+ # CoreSession objects are reused by the synchronous ``new_session``
359
+ # API. Private lifecycle metadata prevents an event that was already
360
+ # in flight from being relabelled with the replacement session id.
361
+ event_session_id = getattr(event, "_crabcode_core_session_id", None)
362
+ event_generation = getattr(
363
+ event,
364
+ "_crabcode_core_lifecycle_generation",
365
+ None,
366
+ )
367
+ if event_session_id is not None and event_session_id != session_id:
368
+ return False
369
+ if source is not None:
370
+ source_session_id = getattr(source, "session_id", None)
371
+ if (
372
+ event_session_id is not None
373
+ and source_session_id
374
+ and event_session_id != source_session_id
375
+ ):
376
+ return False
377
+ source_generation = getattr(source, "_lifecycle_generation", None)
378
+ if (
379
+ event_generation is not None
380
+ and source_generation is not None
381
+ and event_generation != source_generation
382
+ ):
383
+ return False
384
+ if source is None:
385
+ # Keep legacy callers that publish before a session is registered
386
+ # working, but do not let an unscoped late callback leak into a
387
+ # lifecycle that was explicitly invalidated. Re-registration
388
+ # clears the tombstone before the replacement can publish.
389
+ return session_id not in self._invalidated_sessions
390
+ if session_id in self._invalidated_sessions:
391
+ return False
392
+ current = self._session_sources.get(session_id)
393
+ # Legacy callers may publish before the gateway has registered the
394
+ # object. Once a lifecycle has been explicitly invalidated, however,
395
+ # late events from that object must remain suppressed until re-register.
396
+ return current is None or current is source
397
+
398
+ # ── Subscribe ────────────────────────────────────────────────
399
+
400
+ def subscribe(self, session_id: str | None = None) -> _Subscriber:
401
+ """Subscribe to events for a specific session (or all sessions)."""
402
+ connected = ServerConnectedPayload().model_dump_json()
403
+ sub = _Subscriber(
404
+ session_id or "__global__",
405
+ max_queue_size=self._subscriber_queue_size,
406
+ connected=connected,
407
+ )
408
+
409
+ if session_id is None:
410
+ self._global_subscribers.append(sub)
411
+ else:
412
+ self._subscribers.setdefault(session_id, []).append(sub)
413
+
414
+ return sub
415
+
416
+ def unsubscribe(self, sub: _Subscriber) -> None:
417
+ """Remove a subscriber and close its queue."""
418
+ sid = sub.session_id
419
+ if sid == "__global__":
420
+ if sub in self._global_subscribers:
421
+ self._global_subscribers.remove(sub)
422
+ else:
423
+ subs = self._subscribers.get(sid, [])
424
+ if sub in subs:
425
+ subs.remove(sub)
426
+ if not subs:
427
+ self._subscribers.pop(sid, None)
428
+ sub.close()
429
+
430
+ def close_session(self, session_id: str, source: object | None = None) -> None:
431
+ """Terminate subscribers bound to a session lifecycle.
432
+
433
+ Session IDs may be resumed after archive or process restart. Leaving
434
+ an old SSE subscriber registered would make it receive events from the
435
+ replacement object with the same ID, which is a cross-lifecycle data
436
+ leak. Global subscribers are intentionally kept alive; their caller
437
+ filters active-session events.
438
+ """
439
+ if source is not None:
440
+ current = self._session_sources.get(session_id)
441
+ # If a source is bound, only its owner may close the lifecycle.
442
+ # With no binding there is nothing to compare against; still
443
+ # perform cleanup so legacy/embedded callers cannot strand a
444
+ # session-scoped subscriber created before registration.
445
+ if current is not None and current is not source:
446
+ return
447
+ self._invalidated_sessions.add(session_id)
448
+ self._session_sources.pop(session_id, None)
449
+ subscribers = self._subscribers.pop(session_id, [])
450
+ for sub in subscribers:
451
+ sub.close()
452
+ # Global WebSocket subscribers intentionally survive session switches,
453
+ # but queued payloads from a closed lifecycle must not be replayed after
454
+ # the same id is resumed.
455
+ for sub in self._global_subscribers:
456
+ sub.drop_session(session_id)
457
+
458
+ def close_all(self) -> None:
459
+ """Close every subscriber and invalidate all session sources."""
460
+ # Include ids that only had a session-scoped subscriber. Embedded
461
+ # callers are allowed to subscribe before registering a source; those
462
+ # ids must still be fenced after a gateway-wide shutdown.
463
+ self._invalidated_sessions.update(self._session_sources)
464
+ self._invalidated_sessions.update(self._subscribers)
465
+ self._session_sources.clear()
466
+ for session_id in list(self._subscribers):
467
+ subscribers = self._subscribers.pop(session_id, [])
468
+ for sub in subscribers:
469
+ sub.close()
470
+ subscribers = self._global_subscribers[:]
471
+ self._global_subscribers.clear()
472
+ for sub in subscribers:
473
+ sub.close()
474
+
475
+ # ── Publish ──────────────────────────────────────────────────
476
+
477
+ async def publish(
478
+ self,
479
+ session_id: str,
480
+ event: Any,
481
+ *,
482
+ source: object | None = None,
483
+ operation_id: str | None = None,
484
+ operation_scope: str | None = None,
485
+ ) -> None:
486
+ """Publish a CoreEvent to all subscribers of the session."""
487
+ if not self._source_is_current(session_id, source, event):
488
+ return
489
+ payload = core_event_to_payload(event)
490
+ data_dict = json.loads(payload.model_dump_json())
491
+ data_dict["session_id"] = session_id
492
+ if operation_id is not None:
493
+ data_dict["operation_id"] = operation_id
494
+ if operation_scope is not None:
495
+ data_dict["operation_scope"] = operation_scope
496
+ data = json.dumps(data_dict)
497
+
498
+ targets = list(self._subscribers.get(session_id, []))
499
+ targets.extend(self._global_subscribers)
500
+
501
+ for sub in targets:
502
+ await sub.push(data)
503
+ if sub.closed:
504
+ self.unsubscribe(sub)
505
+
506
+ def publish_nowait(
507
+ self,
508
+ session_id: str,
509
+ event: Any,
510
+ *,
511
+ source: object | None = None,
512
+ operation_id: str | None = None,
513
+ operation_scope: str | None = None,
514
+ ) -> None:
515
+ """Non-async publish (for use from sync contexts)."""
516
+ if not self._source_is_current(session_id, source, event):
517
+ return
518
+ payload = core_event_to_payload(event)
519
+ data_dict = json.loads(payload.model_dump_json())
520
+ data_dict["session_id"] = session_id
521
+ if operation_id is not None:
522
+ data_dict["operation_id"] = operation_id
523
+ if operation_scope is not None:
524
+ data_dict["operation_scope"] = operation_scope
525
+ data = json.dumps(data_dict)
526
+
527
+ targets = list(self._subscribers.get(session_id, []))
528
+ targets.extend(self._global_subscribers)
529
+
530
+ for sub in targets:
531
+ sub.push_nowait(data)
532
+ if sub.closed:
533
+ self.unsubscribe(sub)
534
+
535
+ async def publish_background(
536
+ self,
537
+ session_id: str,
538
+ event: Any,
539
+ *,
540
+ source: object | None = None,
541
+ ) -> None:
542
+ """Publish an event that is not owned by a client foreground turn."""
543
+ await self.publish(
544
+ session_id,
545
+ event,
546
+ source=source,
547
+ operation_id=uuid.uuid4().hex,
548
+ operation_scope="background",
549
+ )
550
+
551
+ # ── SSE stream helper ────────────────────────────────────────
552
+
553
+ async def sse_stream(
554
+ self,
555
+ session_id: str | None = None,
556
+ *,
557
+ subscriber: _Subscriber | None = None,
558
+ ) -> AsyncIterator[str]:
559
+ """Yield SSE-formatted event strings with heartbeat.
560
+
561
+ Usage with sse-starlette::
562
+
563
+ return EventSourceResponse(event_bus.sse_stream(session_id))
564
+ """
565
+ # Most callers let the generator subscribe lazily. HTTP handlers
566
+ # that validate a session selector before returning the response may
567
+ # pass a pre-created subscriber instead: async-generator bodies do
568
+ # not run until after the handler yields, leaving a window in which a
569
+ # session can be archived and its id reused by a new CoreSession.
570
+ sub = subscriber if subscriber is not None else self.subscribe(session_id)
571
+ try:
572
+ while True:
573
+ # Wait for event or heartbeat timeout
574
+ try:
575
+ data = await _next_with_timeout(sub, _HEARTBEAT_INTERVAL)
576
+ except asyncio.TimeoutError:
577
+ heartbeat = ServerHeartbeatPayload().model_dump_json()
578
+ yield heartbeat
579
+ continue
580
+
581
+ if data is None:
582
+ return
583
+ yield data
584
+ finally:
585
+ self.unsubscribe(sub)
586
+
587
+ # ── WebSocket helper ─────────────────────────────────────────
588
+
589
+ async def ws_stream(
590
+ self,
591
+ ws: Any,
592
+ session_id: str | None = None,
593
+ *,
594
+ session_id_getter: (
595
+ Callable[[], str | set[str] | frozenset[str] | None] | None
596
+ ) = None,
597
+ subscriber: _Subscriber | None = None,
598
+ ) -> None:
599
+ """Push events to a WebSocket connection.
600
+
601
+ The caller should run this in a background task and cancel
602
+ it when the WebSocket disconnects.
603
+ """
604
+ # Callers that validate a selector under a registry lock can pass a
605
+ # pre-created subscriber. Lazy subscription here would leave a small
606
+ # shutdown race: close_all() could run after validation but before this
607
+ # background task starts, leaking a queue outside the shutdown sweep.
608
+ sub = subscriber if subscriber is not None else self.subscribe(session_id)
609
+ try:
610
+ while True:
611
+ try:
612
+ data = await _next_with_timeout(sub, _HEARTBEAT_INTERVAL)
613
+ except asyncio.TimeoutError:
614
+ await ws.send_text(ServerHeartbeatPayload().model_dump_json())
615
+ continue
616
+ if data is None:
617
+ return
618
+ if isinstance(data, str):
619
+ if session_id is None and session_id_getter is not None:
620
+ # Global subscriptions are useful while a connection
621
+ # switches sessions. Only sessions explicitly selected
622
+ # by this connection are forwarded; a single active id
623
+ # would drop events from an already-started background
624
+ # operation as soon as the UI switches conversations.
625
+ try:
626
+ selected = session_id_getter()
627
+ payload = json.loads(data)
628
+ except (TypeError, json.JSONDecodeError):
629
+ selected = None
630
+ payload = {}
631
+ if isinstance(selected, str):
632
+ selected_ids = {selected} if selected else set()
633
+ elif isinstance(selected, (set, frozenset)):
634
+ selected_ids = {
635
+ value for value in selected if isinstance(value, str) and value
636
+ }
637
+ else:
638
+ selected_ids = set()
639
+ event_session_id = payload.get("session_id")
640
+ if not selected_ids or (
641
+ event_session_id and event_session_id not in selected_ids
642
+ ):
643
+ continue
644
+ await ws.send_text(data)
645
+ else:
646
+ await ws.send_text(str(data))
647
+ except asyncio.CancelledError:
648
+ pass
649
+ except Exception:
650
+ # A client can disappear between the queue wake-up and send_text.
651
+ # Treat transport failures as a normal stream termination so the
652
+ # endpoint can finish its cleanup without an unhandled task error.
653
+ logger.debug("WebSocket event subscriber disconnected", exc_info=True)
654
+ finally:
655
+ self.unsubscribe(sub)
File without changes