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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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
|