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,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
|