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,514 @@
1
+ """Gateway server — the main entry point.
2
+
3
+ Builds the FastAPI app, registers middleware and routes, and starts
4
+ both HTTP and gRPC servers. Mirrors OpenCode's server.ts architecture:
5
+ - Adapter pattern for multiple protocols
6
+ - Middleware stack (auth → logger → cors → error)
7
+ - Route groups (session, agent, config, event)
8
+ - SSE + WebSocket for real-time events
9
+ - EventBus for multi-subscriber broadcast
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import hmac
16
+ import os
17
+ import secrets
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import uvicorn
22
+ from fastapi import FastAPI
23
+
24
+ from crabcode_core import VERSION
25
+ from crabcode_core.logging_utils import get_logger
26
+ from crabcode_core.types.config import GatewaySecuritySettings
27
+ from crabcode_gateway.auth import verify_password
28
+ from crabcode_gateway.computer_use import ComputerUseBroker
29
+ from crabcode_gateway.event_bus import EventBus
30
+ from crabcode_gateway.middleware import register_middleware
31
+ from crabcode_gateway.routes import (
32
+ agent,
33
+ auth,
34
+ config,
35
+ computer_use,
36
+ document,
37
+ event,
38
+ health,
39
+ git_review,
40
+ peer,
41
+ permission,
42
+ schedule,
43
+ session,
44
+ snapshot,
45
+ tasks,
46
+ team,
47
+ usage,
48
+ workspace,
49
+ )
50
+ from crabcode_gateway.session_registry import get_session_lock
51
+ from crabcode_gateway.task_registry import (
52
+ ensure_task_state,
53
+ mark_session_closing,
54
+ shielded_cleanup_session,
55
+ unmark_session_closing,
56
+ )
57
+
58
+ logger = get_logger(__name__)
59
+ _WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
60
+
61
+
62
+ class GatewayServer:
63
+ """CrabCode Gateway server.
64
+
65
+ Usage::
66
+
67
+ server = GatewayServer(port=4096)
68
+ await server.start()
69
+ # ... later
70
+ await server.stop()
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ host: str = "127.0.0.1",
76
+ port: int = 4096,
77
+ grpc_port: int | None = None,
78
+ password: str | None = None,
79
+ security_mode: str | None = None,
80
+ password_hash: str | None = None,
81
+ jwt_secret: str | None = None,
82
+ authorized_keys_path: str | None = None,
83
+ token_ttl_seconds: int = 900,
84
+ cors_origins: list[str] | None = None,
85
+ log_level: str = "info",
86
+ ) -> None:
87
+ self.host = host
88
+ self.port = port
89
+ self.grpc_port = grpc_port
90
+ self.password = password
91
+ self.security_mode = security_mode
92
+ self.password_hash = password_hash
93
+ self.jwt_secret = jwt_secret
94
+ self.authorized_keys_path = authorized_keys_path
95
+ self.token_ttl_seconds = token_ttl_seconds
96
+ self.cors_origins = cors_origins
97
+ self.log_level = log_level
98
+
99
+ self._app: FastAPI | None = None
100
+ self._http_server: uvicorn.Server | None = None
101
+ self._http_task: asyncio.Task[Any] | None = None
102
+ self._grpc_adapter: Any = None
103
+ self._event_bus = EventBus()
104
+ self._stop_task: asyncio.Task[Any] | None = None
105
+ # Serialize start transitions with creation of the shared stop task.
106
+ # The lock is held only during setup/task admission, never while an
107
+ # HTTP server is serving requests.
108
+ self._lifecycle_lock = asyncio.Lock()
109
+
110
+ def build_app(self) -> FastAPI:
111
+ """Build and configure the FastAPI application."""
112
+ app = FastAPI(
113
+ title="crabcode",
114
+ version=VERSION,
115
+ description="CrabCode Gateway API",
116
+ )
117
+
118
+ # App-level state shared across routes
119
+ app.state.sessions: dict[str, Any] = {}
120
+ app.state.default_session_id: str | None = None
121
+ app.state.event_bus = self._event_bus
122
+ app.state.computer_use_broker = ComputerUseBroker()
123
+ app.state.client_contexts: dict[str, Any] = {}
124
+ app.state.standalone_schedule_manager = None
125
+ app.state.standalone_schedule_manager_lock = asyncio.Lock()
126
+ from crabcode_core.config.manager import ConfigManager
127
+
128
+ workspace_settings = ConfigManager(cwd=os.getcwd()).load_gateway_workspace()
129
+ app.state.workspace_info = workspace.build_workspace_info(
130
+ os.getcwd(),
131
+ workspace_settings.browse_roots,
132
+ )
133
+ app.state.session_lock = asyncio.Lock()
134
+ app.state.session_load_lock = asyncio.Lock()
135
+ app.state.model_settings_lock = asyncio.Lock()
136
+ ensure_task_state(app.state)
137
+ app.state.gateway_closing = False
138
+ # gRPC is a separate transport and cannot see FastAPI middleware; make
139
+ # the same gateway credentials available to its adapter explicitly.
140
+ app.state.gateway_username = "crabcode"
141
+ mode = self.security_mode or (
142
+ "password" if self.password or self.password_hash else "none"
143
+ )
144
+ if mode not in ("none", "password", "publickey", "mixed"):
145
+ raise ValueError("security_mode must be none, password, publickey, or mixed")
146
+ security = GatewaySecuritySettings(
147
+ mode=mode,
148
+ password=self.password or os.getenv("CRABCODE_GATEWAY_PASSWORD"),
149
+ password_hash=self.password_hash,
150
+ jwt_secret=self.jwt_secret or os.getenv("CRABCODE_GATEWAY_JWT_SECRET") or secrets.token_urlsafe(32),
151
+ authorized_keys=self.authorized_keys_path or "~/.ssh/authorized_keys",
152
+ token_ttl_seconds=self.token_ttl_seconds,
153
+ )
154
+ if mode in ("password", "mixed") and not (
155
+ security.password or security.password_hash
156
+ ):
157
+ raise ValueError(f"Gateway security mode {mode!r} requires a password")
158
+ if mode != "none" and (
159
+ security.jwt_secret is None or len(security.jwt_secret.encode()) < 32
160
+ ):
161
+ raise ValueError("Gateway jwt_secret must be at least 32 bytes")
162
+ if mode in ("publickey", "mixed") and not Path(
163
+ security.authorized_keys
164
+ ).expanduser().is_file():
165
+ raise ValueError(
166
+ f"Gateway authorized_keys file does not exist: {security.authorized_keys}"
167
+ )
168
+ self._security = security
169
+ app.state.gateway_password = security.password
170
+ app.state.gateway_security = security
171
+ app.state.gateway_jwt_secret = security.jwt_secret
172
+ app.state.auth_challenges: dict[str, int] = {}
173
+ app.state.auth_failures: dict[str, list[float]] = {}
174
+ app.state.verify_gateway_password = lambda value: (
175
+ (security.password is not None and hmac.compare_digest(value, security.password))
176
+ or (security.password_hash is not None and verify_password(value, security.password_hash))
177
+ )
178
+ if mode == "none" and self.host not in ("127.0.0.1", "localhost", "::1"):
179
+ logger.warning(
180
+ "Gateway is listening on %s without authentication", self.host
181
+ )
182
+
183
+ # Middleware stack
184
+ register_middleware(
185
+ app,
186
+ password=security.password,
187
+ password_hash=security.password_hash,
188
+ security_mode=security.mode,
189
+ jwt_secret=security.jwt_secret,
190
+ cors_origins=self.cors_origins,
191
+ )
192
+
193
+ # Routes
194
+ app.include_router(health.router)
195
+ app.include_router(session.router)
196
+ app.include_router(usage.router)
197
+ app.include_router(agent.router)
198
+ app.include_router(permission.router)
199
+ app.include_router(schedule.router)
200
+ app.include_router(config.router)
201
+ app.include_router(auth.router)
202
+ app.include_router(event.router)
203
+ app.include_router(computer_use.router)
204
+ app.include_router(snapshot.router)
205
+ app.include_router(tasks.router)
206
+ app.include_router(peer.router)
207
+ app.include_router(team.router)
208
+ app.include_router(workspace.router)
209
+ app.include_router(git_review.router)
210
+ app.include_router(document.router)
211
+
212
+ self._app = app
213
+ return app
214
+
215
+ async def _start_standalone_schedule_manager(self) -> None:
216
+ """Start the scheduler that covers jobs when no chat is connected."""
217
+ if self._app is None or self._app.state.standalone_schedule_manager is not None:
218
+ return
219
+ from crabcode_core.config.manager import ConfigManager
220
+ from crabcode_core.schedule.manager import ScheduleManager
221
+
222
+ cwd = os.getcwd()
223
+ manager = ScheduleManager(
224
+ settings=ConfigManager(cwd=cwd).load().schedule,
225
+ cwd=cwd,
226
+ session_id="",
227
+ )
228
+ await manager.start()
229
+ self._app.state.standalone_schedule_manager = manager
230
+
231
+ async def start(self) -> None:
232
+ """Start HTTP and optionally gRPC servers."""
233
+ async with self._lifecycle_lock:
234
+ await self._wait_for_previous_stop()
235
+ self._ensure_not_running()
236
+ if self._app is None:
237
+ self.build_app()
238
+
239
+ await self._start_standalone_schedule_manager()
240
+
241
+ async with get_session_lock(self._app.state):
242
+ self._app.state.gateway_closing = False
243
+
244
+ # Start HTTP server
245
+ config = uvicorn.Config(
246
+ app=self._app,
247
+ host=self.host,
248
+ port=self.port,
249
+ log_level=self.log_level,
250
+ loop="asyncio",
251
+ ws_max_size=_WEBSOCKET_MAX_MESSAGE_BYTES,
252
+ )
253
+ self._http_server = uvicorn.Server(config)
254
+
255
+ await self._start_grpc()
256
+
257
+ logger.info("CrabCode Gateway starting on %s:%d", self.host, self.port)
258
+ try:
259
+ await self._http_server.serve()
260
+ except asyncio.CancelledError:
261
+ # ``serve`` can be cancelled by an embedding application. Run
262
+ # the same fenced cleanup path as an explicit stop before
263
+ # propagating cancellation.
264
+ await self.stop()
265
+ raise
266
+ except Exception:
267
+ await self.stop()
268
+ raise
269
+ else:
270
+ # Uvicorn can terminate without an explicit GatewayServer.stop
271
+ # (for example after an internal startup/runtime failure). Do
272
+ # not leave the session registry and gRPC adapter alive in that
273
+ # case.
274
+ await self.stop()
275
+
276
+ async def start_background(self) -> None:
277
+ """Start HTTP server in the background (non-blocking).
278
+
279
+ Unlike ``start()``, this returns immediately so the caller
280
+ can proceed (e.g. to start an ACP agent on stdio).
281
+ """
282
+ async with self._lifecycle_lock:
283
+ await self._wait_for_previous_stop()
284
+ self._ensure_not_running()
285
+ if self._app is None:
286
+ self.build_app()
287
+
288
+ await self._start_standalone_schedule_manager()
289
+
290
+ async with get_session_lock(self._app.state):
291
+ self._app.state.gateway_closing = False
292
+
293
+ await self._start_grpc()
294
+
295
+ config = uvicorn.Config(
296
+ app=self._app,
297
+ host=self.host,
298
+ port=self.port,
299
+ log_level=self.log_level,
300
+ loop="asyncio",
301
+ ws_max_size=_WEBSOCKET_MAX_MESSAGE_BYTES,
302
+ )
303
+ self._http_server = uvicorn.Server(config)
304
+
305
+ logger.info("CrabCode Gateway starting (background) on %s:%d", self.host, self.port)
306
+ self._http_task = asyncio.create_task(self._serve_background())
307
+
308
+ async def _wait_for_previous_stop(self) -> None:
309
+ """Wait for an earlier stop task before reopening the gateway."""
310
+ task = self._stop_task
311
+ if task is not None and not task.done():
312
+ await asyncio.shield(task)
313
+ # Surface an internal cleanup failure instead of starting with a
314
+ # partially torn-down registry/resources.
315
+ if task is not None:
316
+ await task
317
+
318
+ def _ensure_not_running(self) -> None:
319
+ """Reject duplicate starts while a transport is still active."""
320
+ if self._http_task is not None and not self._http_task.done():
321
+ raise RuntimeError("Gateway server is already running")
322
+ if self._http_server is not None and not self._http_server.should_exit:
323
+ raise RuntimeError("Gateway server is already running")
324
+
325
+ async def _serve_background(self) -> None:
326
+ """Run the background HTTP server and fence cleanup on termination.
327
+
328
+ ``start_background`` returns the task handle to the embedding process,
329
+ so that process can cancel it directly. A raw ``uvicorn.serve`` task
330
+ would bypass :meth:`stop` in that case and leave sessions and the gRPC
331
+ adapter alive. Keep the same cleanup contract as foreground
332
+ ``start()`` for both cancellation and unexpected server failures.
333
+ """
334
+ try:
335
+ assert self._http_server is not None
336
+ await self._http_server.serve()
337
+ except asyncio.CancelledError:
338
+ # This task is the HTTP task awaited by ``_stop_impl`` during a
339
+ # normal shutdown. Detach its handle before entering cleanup so
340
+ # cancellation of the task itself cannot create a stop -> await
341
+ # HTTP task -> stop cycle. If another stop is already draining,
342
+ # that owner will finish the cleanup after this task terminates.
343
+ if self._http_task is asyncio.current_task():
344
+ self._http_task = None
345
+ if self._stop_task is None or self._stop_task.done():
346
+ await self.stop()
347
+ raise
348
+ except Exception:
349
+ if self._http_task is asyncio.current_task():
350
+ self._http_task = None
351
+ if self._stop_task is None or self._stop_task.done():
352
+ await self.stop()
353
+ raise
354
+ else:
355
+ # A server can also return normally after an internal shutdown or
356
+ # an embedding caller setting ``should_exit`` directly. Apply the
357
+ # same cleanup contract unless another stop already owns it.
358
+ if self._http_task is asyncio.current_task():
359
+ self._http_task = None
360
+ if self._stop_task is None or self._stop_task.done():
361
+ await self.stop()
362
+
363
+ async def _start_grpc(self) -> None:
364
+ """Start the optional gRPC adapter for both foreground/background modes."""
365
+ if self.grpc_port is None or self._app is None:
366
+ return
367
+ try:
368
+ from crabcode_gateway.grpc.server import GrpcAdapter
369
+
370
+ self._grpc_adapter = GrpcAdapter(
371
+ self._app.state,
372
+ password=self._security.password,
373
+ password_hash=self._security.password_hash,
374
+ security_mode=self._security.mode,
375
+ jwt_secret=self._security.jwt_secret,
376
+ )
377
+ await self._grpc_adapter.start(self.host, self.grpc_port)
378
+ except Exception:
379
+ logger.warning("Failed to start gRPC server", exc_info=True)
380
+ self._grpc_adapter = None
381
+
382
+ async def stop(self) -> None:
383
+ """Gracefully stop all servers."""
384
+ # Keep one cleanup operation for concurrent callers and shield it from
385
+ # cancellation. A cancelled shutdown must not leave sessions in the
386
+ # registry with detached query tasks still running.
387
+ async with self._lifecycle_lock:
388
+ if self._stop_task is None or self._stop_task.done():
389
+ self._stop_task = asyncio.create_task(self._stop_impl())
390
+ stop_task = self._stop_task
391
+ cancelled = False
392
+ try:
393
+ # Keep draining through repeated cancellation requests. Shutdown
394
+ # is a resource-release operation, so returning before the shared
395
+ # child finishes can leak HTTP/gRPC servers and CoreSessions.
396
+ while not stop_task.done():
397
+ try:
398
+ await asyncio.shield(stop_task)
399
+ except asyncio.CancelledError:
400
+ cancelled = True
401
+ await stop_task
402
+ except asyncio.CancelledError:
403
+ cancelled = True
404
+ if cancelled:
405
+ raise asyncio.CancelledError
406
+
407
+ async def _stop_impl(self) -> None:
408
+ """Perform the actual graceful shutdown."""
409
+ app = self._app
410
+
411
+ # Fence all route handlers before stopping either transport. The
412
+ # marker is set while holding the same lock used by new/send/resume
413
+ # and by archive, so no operation can resolve a session after this
414
+ # snapshot and then register new work for it.
415
+ pending: list[tuple[str, Any]] = []
416
+ if app:
417
+ async with get_session_lock(app.state):
418
+ app.state.gateway_closing = True
419
+ sessions: dict = app.state.sessions
420
+ pending = list(sessions.items())
421
+ for sid, _session in pending:
422
+ mark_session_closing(app.state, sid)
423
+ close_session_events = getattr(app.state.event_bus, "close_session", None)
424
+ if callable(close_session_events):
425
+ close_session_events(sid, _session)
426
+ sessions.clear()
427
+ app.state.default_session_id = None
428
+ app.state.client_contexts.clear()
429
+
430
+ if self._http_server:
431
+ self._http_server.should_exit = True
432
+ try:
433
+ await self._http_server.shutdown()
434
+ except Exception:
435
+ logger.warning("Failed to shut down HTTP server cleanly", exc_info=True)
436
+
437
+ http_task = self._http_task
438
+ if http_task and http_task is not asyncio.current_task():
439
+ try:
440
+ await http_task
441
+ except asyncio.CancelledError:
442
+ pass
443
+ except Exception:
444
+ logger.warning("HTTP server task failed during shutdown", exc_info=True)
445
+ if self._http_task is http_task:
446
+ self._http_task = None
447
+
448
+ if self._grpc_adapter:
449
+ try:
450
+ await self._grpc_adapter.stop()
451
+ except Exception:
452
+ logger.warning("Failed to shut down gRPC server cleanly", exc_info=True)
453
+
454
+ # Close all sessions
455
+ if app:
456
+ schedule_manager = getattr(app.state, "standalone_schedule_manager", None)
457
+ if schedule_manager is not None:
458
+ try:
459
+ await schedule_manager.close()
460
+ except Exception:
461
+ logger.warning("Failed to close standalone schedule manager", exc_info=True)
462
+ finally:
463
+ app.state.standalone_schedule_manager = None
464
+ for sid, session in pending:
465
+ try:
466
+ # The marker was installed under the registry lock above;
467
+ # keep it until CoreSession.close() has completed.
468
+ await shielded_cleanup_session(app.state, sid, session)
469
+ except Exception:
470
+ logger.warning("Failed to close session %s", sid, exc_info=True)
471
+ finally:
472
+ # ``shielded_cleanup_session`` normally clears this. The
473
+ # explicit discard also handles a partially initialized
474
+ # or custom session whose close path raises unexpectedly.
475
+ unmark_session_closing(app.state, sid)
476
+ close_all_events = getattr(app.state.event_bus, "close_all", None)
477
+ if callable(close_all_events):
478
+ close_all_events()
479
+
480
+ logger.info("CrabCode Gateway stopped")
481
+
482
+ @property
483
+ def is_running(self) -> bool:
484
+ return self._http_server is not None and not self._http_server.should_exit
485
+
486
+
487
+ def run_server(
488
+ host: str = "127.0.0.1",
489
+ port: int = 4096,
490
+ grpc_port: int | None = None,
491
+ password: str | None = None,
492
+ security_mode: str | None = None,
493
+ password_hash: str | None = None,
494
+ jwt_secret: str | None = None,
495
+ authorized_keys_path: str | None = None,
496
+ token_ttl_seconds: int = 900,
497
+ cors_origins: list[str] | None = None,
498
+ log_level: str = "info",
499
+ ) -> None:
500
+ """Synchronous entry point for the gateway server."""
501
+ server = GatewayServer(
502
+ host=host,
503
+ port=port,
504
+ grpc_port=grpc_port,
505
+ password=password,
506
+ security_mode=security_mode,
507
+ password_hash=password_hash,
508
+ jwt_secret=jwt_secret,
509
+ authorized_keys_path=authorized_keys_path,
510
+ token_ttl_seconds=token_ttl_seconds,
511
+ cors_origins=cors_origins,
512
+ log_level=log_level,
513
+ )
514
+ asyncio.run(server.start())
@@ -0,0 +1,36 @@
1
+ """Shared synchronization for the gateway's in-process session registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ _LOAD_LOCK_ATTR = "session_load_lock"
9
+
10
+
11
+ def get_session_lock(app_state: Any) -> asyncio.Lock:
12
+ """Return the app-wide registry lock, creating it for bare test apps.
13
+
14
+ FastAPI applications built by :class:`GatewayServer` initialize this lock
15
+ eagerly. The lazy fallback keeps route helpers usable with lightweight
16
+ ASGI apps in tests and integrations.
17
+ """
18
+ lock = getattr(app_state, "session_lock", None)
19
+ if lock is None:
20
+ lock = asyncio.Lock()
21
+ app_state.session_lock = lock
22
+ return lock
23
+
24
+
25
+ def get_session_load_lock(app_state: Any) -> asyncio.Lock:
26
+ """Return the lock serializing disk-backed session loads.
27
+
28
+ Session initialization can await provider/LSP setup and must not hold the
29
+ short registry lock while doing so. A separate lock still prevents two
30
+ concurrent resume calls from constructing duplicate CoreSession objects.
31
+ """
32
+ lock = getattr(app_state, _LOAD_LOCK_ATTR, None)
33
+ if lock is None:
34
+ lock = asyncio.Lock()
35
+ setattr(app_state, _LOAD_LOCK_ATTR, lock)
36
+ return lock