zjcode 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
"""LangGraph server lifecycle management for the app.
|
|
2
|
+
|
|
3
|
+
Handles starting/stopping a `langgraph dev` server process and generating the
|
|
4
|
+
required `langgraph.json` configuration file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess # noqa: S404
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
from deepagents_code._env_vars import SERVER_ENV_PREFIX
|
|
24
|
+
from deepagents_code.config import _INHERITED_PYTHONPATH_ENV
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Callable, Iterator
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_DEFAULT_HOST = "127.0.0.1"
|
|
32
|
+
|
|
33
|
+
_EPHEMERAL_PORT = 0
|
|
34
|
+
"""Sentinel port meaning "let `start()` pick a free ephemeral port".
|
|
35
|
+
|
|
36
|
+
The server is internal and ephemeral — callers reach it via `ServerProcess.url`,
|
|
37
|
+
never a typed-in address — so it deliberately avoids binding the well-known
|
|
38
|
+
`langgraph dev` default (2024). Leaving 2024 free lets users run their own
|
|
39
|
+
`langgraph dev` projects alongside `deepagents-code` without a port collision.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_HEALTH_POLL_INTERVAL_LOCAL = 0.1
|
|
43
|
+
|
|
44
|
+
_HEALTH_POLL_INTERVAL_REMOTE = 0.3
|
|
45
|
+
|
|
46
|
+
_HEALTH_TIMEOUT = 60
|
|
47
|
+
|
|
48
|
+
_SHUTDOWN_TIMEOUT = 3
|
|
49
|
+
"""Seconds to wait for a graceful SIGTERM exit before escalating to SIGKILL."""
|
|
50
|
+
|
|
51
|
+
_SIGKILL_TIMEOUT = 2
|
|
52
|
+
"""Seconds to wait for the group/process to exit after SIGKILL."""
|
|
53
|
+
|
|
54
|
+
_PROCESS_GROUP_POLL_INTERVAL = 0.05
|
|
55
|
+
|
|
56
|
+
_LOG_TAIL_CHARS = 3000
|
|
57
|
+
"""Max chars of subprocess log appended to the early-exit `RuntimeError` message.
|
|
58
|
+
|
|
59
|
+
Enough to carry a Python traceback without flooding the TUI banner when it
|
|
60
|
+
surfaces via `ServerStartFailed`.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_STARTUP_ERROR_MARKER = "DEEPAGENTS_STARTUP_ERROR:"
|
|
64
|
+
"""Machine-readable prefix emitted by the server subprocess for known startup errors."""
|
|
65
|
+
|
|
66
|
+
_SERVER_ENV_DENYLIST = frozenset(
|
|
67
|
+
{
|
|
68
|
+
"DYLD_INSERT_LIBRARIES",
|
|
69
|
+
"DYLD_LIBRARY_PATH",
|
|
70
|
+
"GIT_ASKPASS",
|
|
71
|
+
"LD_AUDIT",
|
|
72
|
+
"LD_LIBRARY_PATH",
|
|
73
|
+
"LD_PRELOAD",
|
|
74
|
+
"NODE_OPTIONS",
|
|
75
|
+
"PYTHONEXECUTABLE",
|
|
76
|
+
"PYTHONHOME",
|
|
77
|
+
"PYTHONPATH",
|
|
78
|
+
"PYTHONSTARTUP",
|
|
79
|
+
"SSH_ASKPASS",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
"""Inherited env keys that can alter subprocess startup behavior.
|
|
83
|
+
|
|
84
|
+
`PYTHONPATH` is stripped here so an inherited launch value cannot land on the
|
|
85
|
+
server interpreter's `sys.path` during startup, where a path inside an untrusted
|
|
86
|
+
project could shadow a stdlib/third-party module and run before any approval
|
|
87
|
+
gate. A user who launched with `PYTHONPATH` still wants it for their agent
|
|
88
|
+
`execute` commands, so `_build_server_env` relays the value via
|
|
89
|
+
`config._INHERITED_PYTHONPATH_ENV` and `agent._apply_inherited_pythonpath`
|
|
90
|
+
re-applies it only to the approval-gated shell backend.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _port_in_use(host: str, port: int) -> bool:
|
|
95
|
+
"""Check if a port is already in use.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
host: Host to check.
|
|
99
|
+
port: Port to check.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
`True` if the port is in use.
|
|
103
|
+
"""
|
|
104
|
+
import socket
|
|
105
|
+
|
|
106
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
107
|
+
try:
|
|
108
|
+
s.bind((host, port))
|
|
109
|
+
except OSError:
|
|
110
|
+
return True
|
|
111
|
+
else:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _find_free_port(host: str) -> int:
|
|
116
|
+
"""Find a free port on the given host.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
host: Host to bind to.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
An available port number.
|
|
123
|
+
"""
|
|
124
|
+
import socket
|
|
125
|
+
|
|
126
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
127
|
+
s.bind((host, 0))
|
|
128
|
+
return s.getsockname()[1]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_server_url(host: str = _DEFAULT_HOST, port: int = _EPHEMERAL_PORT) -> str:
|
|
132
|
+
"""Build the server base URL.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
host: Server host.
|
|
136
|
+
port: Server port.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Base URL string.
|
|
140
|
+
"""
|
|
141
|
+
return f"http://{host}:{port}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _extract_startup_error_marker(output: str) -> str | None:
|
|
145
|
+
"""Extract a marked startup error from subprocess output.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
output: Combined stdout/stderr captured from the server subprocess.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The marked startup error message, or `None` if no marker was emitted.
|
|
152
|
+
"""
|
|
153
|
+
for line in reversed(output.splitlines()):
|
|
154
|
+
if _STARTUP_ERROR_MARKER in line:
|
|
155
|
+
_, summary = line.rsplit(_STARTUP_ERROR_MARKER, 1)
|
|
156
|
+
return summary.strip() or None
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def generate_langgraph_json(
|
|
161
|
+
output_dir: str | Path,
|
|
162
|
+
*,
|
|
163
|
+
graph_ref: str = "deepagents_code.server_graph:make_graph",
|
|
164
|
+
env_file: str | None = None,
|
|
165
|
+
checkpointer_path: str | None = None,
|
|
166
|
+
) -> Path:
|
|
167
|
+
"""Generate a `langgraph.json` config file for `langgraph dev`.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
output_dir: Directory to write the config file.
|
|
171
|
+
graph_ref: Python "module:attribute" reference to the graph, where the
|
|
172
|
+
attribute is a graph factory (e.g. `make_graph`) or a graph object.
|
|
173
|
+
env_file: Optional path to an env file.
|
|
174
|
+
checkpointer_path: Import path to an async context manager that yields a
|
|
175
|
+
`BaseCheckpointSaver`. When set, the server persists checkpoint data
|
|
176
|
+
to disk instead of in-memory.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Path to the generated config file.
|
|
180
|
+
"""
|
|
181
|
+
config: dict[str, Any] = {
|
|
182
|
+
"dependencies": ["."],
|
|
183
|
+
"graphs": {
|
|
184
|
+
"agent": graph_ref,
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
if env_file:
|
|
188
|
+
config["env"] = env_file
|
|
189
|
+
if checkpointer_path:
|
|
190
|
+
config["checkpointer"] = {"path": checkpointer_path}
|
|
191
|
+
|
|
192
|
+
output_path = Path(output_dir) / "langgraph.json"
|
|
193
|
+
output_path.write_text(json.dumps(config, indent=2))
|
|
194
|
+
return output_path
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Scoped env-var management
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@contextlib.contextmanager
|
|
203
|
+
def _scoped_env_overrides(
|
|
204
|
+
overrides: dict[str, str],
|
|
205
|
+
) -> Iterator[None]:
|
|
206
|
+
"""Apply env-var overrides, rolling back only on exception.
|
|
207
|
+
|
|
208
|
+
Separates the concern of temporary `os.environ` mutations from subprocess
|
|
209
|
+
management, making both independently testable.
|
|
210
|
+
|
|
211
|
+
On normal exit the overrides are left in place (the caller "keeps"
|
|
212
|
+
them). On exception the previous values are restored so the next attempt
|
|
213
|
+
starts from a known-good state.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
overrides: Key/value pairs to set in `os.environ`.
|
|
217
|
+
|
|
218
|
+
Yields:
|
|
219
|
+
Control to the caller.
|
|
220
|
+
"""
|
|
221
|
+
prev: dict[str, str | None] = {}
|
|
222
|
+
for key, val in overrides.items():
|
|
223
|
+
prev[key] = os.environ.get(key)
|
|
224
|
+
os.environ[key] = val
|
|
225
|
+
try:
|
|
226
|
+
yield
|
|
227
|
+
except Exception:
|
|
228
|
+
for key, old_val in prev.items():
|
|
229
|
+
if old_val is None:
|
|
230
|
+
os.environ.pop(key, None)
|
|
231
|
+
else:
|
|
232
|
+
os.environ[key] = old_val
|
|
233
|
+
raise
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Health checking
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def wait_for_server_healthy(
|
|
242
|
+
url: str,
|
|
243
|
+
*,
|
|
244
|
+
timeout: float = _HEALTH_TIMEOUT, # noqa: ASYNC109
|
|
245
|
+
process: subprocess.Popen | None = None,
|
|
246
|
+
read_log: Callable[[], str] | None = None,
|
|
247
|
+
local: bool = False,
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Poll a LangGraph server health endpoint until it responds.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
url: Server base URL (health endpoint is `{url}/ok`).
|
|
253
|
+
timeout: Max seconds to wait.
|
|
254
|
+
process: Optional subprocess handle; if the process exits early
|
|
255
|
+
we fail fast instead of waiting for the timeout.
|
|
256
|
+
read_log: Optional callable returning log file contents (for
|
|
257
|
+
error messages on early exit).
|
|
258
|
+
local: Use a shorter poll interval for local servers.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
RuntimeError: If the server doesn't become healthy in time.
|
|
262
|
+
"""
|
|
263
|
+
import httpx
|
|
264
|
+
|
|
265
|
+
poll_interval = (
|
|
266
|
+
_HEALTH_POLL_INTERVAL_LOCAL if local else _HEALTH_POLL_INTERVAL_REMOTE
|
|
267
|
+
)
|
|
268
|
+
health_url = f"{url}/ok"
|
|
269
|
+
deadline = time.monotonic() + timeout
|
|
270
|
+
last_status: int | None = None
|
|
271
|
+
last_exc: Exception | None = None
|
|
272
|
+
|
|
273
|
+
async with httpx.AsyncClient() as client:
|
|
274
|
+
while time.monotonic() < deadline:
|
|
275
|
+
if process and process.poll() is not None:
|
|
276
|
+
output = read_log() if read_log else ""
|
|
277
|
+
msg = f"Server process exited with code {process.returncode}"
|
|
278
|
+
if output:
|
|
279
|
+
summary = _extract_startup_error_marker(output)
|
|
280
|
+
if summary:
|
|
281
|
+
msg += f": {summary}"
|
|
282
|
+
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
|
|
283
|
+
raise RuntimeError(msg)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
resp = await client.get(health_url, timeout=2)
|
|
287
|
+
if resp.status_code == 200: # noqa: PLR2004
|
|
288
|
+
logger.info("Server is healthy at %s", url)
|
|
289
|
+
return
|
|
290
|
+
last_status = resp.status_code
|
|
291
|
+
logger.debug("Health check returned status %d", resp.status_code)
|
|
292
|
+
except (httpx.TransportError, OSError) as exc:
|
|
293
|
+
logger.debug("Health check attempt failed: %s", exc)
|
|
294
|
+
last_exc = exc
|
|
295
|
+
|
|
296
|
+
await asyncio.sleep(poll_interval)
|
|
297
|
+
|
|
298
|
+
msg = f"Server did not become healthy within {timeout}s"
|
|
299
|
+
if last_status is not None:
|
|
300
|
+
msg += f" (last status: {last_status})"
|
|
301
|
+
elif last_exc is not None:
|
|
302
|
+
msg += f" (last error: {last_exc})"
|
|
303
|
+
raise RuntimeError(msg)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# Server command / env construction
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _build_server_cmd(config_path: Path, *, host: str, port: int) -> list[str]:
|
|
312
|
+
"""Build the `langgraph dev` command line.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
config_path: Path to the `langgraph.json` config file.
|
|
316
|
+
host: Host to bind.
|
|
317
|
+
port: Port to bind.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Command argv list.
|
|
321
|
+
"""
|
|
322
|
+
return [
|
|
323
|
+
sys.executable,
|
|
324
|
+
"-m",
|
|
325
|
+
"langgraph_cli",
|
|
326
|
+
"dev",
|
|
327
|
+
"--host",
|
|
328
|
+
host,
|
|
329
|
+
"--port",
|
|
330
|
+
str(port),
|
|
331
|
+
"--no-browser",
|
|
332
|
+
"--no-reload",
|
|
333
|
+
# Allow synchronous filesystem I/O on the dev server event loop. The
|
|
334
|
+
# agent graph factory legitimately performs blocking reads during
|
|
335
|
+
# construction (dotenv discovery, project-root detection, and
|
|
336
|
+
# `Path.resolve()`, which calls `os.getcwd()` on Windows). `langgraph
|
|
337
|
+
# dev` unconditionally overwrites `LANGGRAPH_ALLOW_BLOCKING` from this
|
|
338
|
+
# flag's value (defaulting to "false"), so setting only the env var in
|
|
339
|
+
# `_build_server_env` is not enough — without this flag blockbuster is
|
|
340
|
+
# activated and the graph readiness check fails with `BlockingError`.
|
|
341
|
+
"--allow-blocking",
|
|
342
|
+
"--config",
|
|
343
|
+
str(config_path),
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _build_server_env() -> dict[str, str]:
|
|
348
|
+
"""Build the environment dict for the server subprocess.
|
|
349
|
+
|
|
350
|
+
Copies `os.environ`, sets required flags, and strips variables that are not
|
|
351
|
+
needed or can alter subprocess startup behavior.
|
|
352
|
+
|
|
353
|
+
A launch-time `PYTHONPATH` is captured into `config._INHERITED_PYTHONPATH_ENV`
|
|
354
|
+
before being stripped, so the value never reaches the server interpreter's
|
|
355
|
+
`sys.path` but can still be re-applied to agent `execute` commands downstream.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Environment dict for `subprocess.Popen`.
|
|
359
|
+
"""
|
|
360
|
+
env = os.environ.copy()
|
|
361
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
362
|
+
env["LANGGRAPH_AUTH_TYPE"] = "noop"
|
|
363
|
+
# Allow synchronous filesystem I/O on the dev server event loop. The agent
|
|
364
|
+
# graph factory legitimately performs blocking reads during construction
|
|
365
|
+
# (dotenv discovery, project-root detection, and `Path.resolve()`, which
|
|
366
|
+
# calls `os.getcwd()` on Windows). Without this, langgraph dev's blockbuster
|
|
367
|
+
# integration raises `BlockingError` and the graph readiness check fails.
|
|
368
|
+
#
|
|
369
|
+
# This env var is set for completeness, but `langgraph dev` overwrites it
|
|
370
|
+
# from its own `--allow-blocking` flag on startup, so `_build_server_cmd`
|
|
371
|
+
# must also pass that flag for the setting to actually take effect.
|
|
372
|
+
env["LANGGRAPH_ALLOW_BLOCKING"] = "true"
|
|
373
|
+
|
|
374
|
+
# Capture a launch-time PYTHONPATH before stripping it. Never trust an
|
|
375
|
+
# inherited carrier var: pop it first, then set it only from the real value.
|
|
376
|
+
env.pop(_INHERITED_PYTHONPATH_ENV, None)
|
|
377
|
+
inherited_pythonpath = os.environ.get("PYTHONPATH")
|
|
378
|
+
|
|
379
|
+
for key in (
|
|
380
|
+
"LANGGRAPH_AUTH",
|
|
381
|
+
"LANGGRAPH_CLOUD_LICENSE_KEY",
|
|
382
|
+
"LANGSMITH_CONTROL_PLANE_API_KEY",
|
|
383
|
+
"LANGSMITH_TENANT_ID",
|
|
384
|
+
*_SERVER_ENV_DENYLIST,
|
|
385
|
+
):
|
|
386
|
+
env.pop(key, None)
|
|
387
|
+
|
|
388
|
+
if inherited_pythonpath is not None:
|
|
389
|
+
env[_INHERITED_PYTHONPATH_ENV] = inherited_pythonpath
|
|
390
|
+
return env
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
# Process-group teardown
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _server_process_group(pid: int) -> int | None:
|
|
399
|
+
"""Return the server's own process group id to signal, or `None`.
|
|
400
|
+
|
|
401
|
+
The server is spawned with `start_new_session=True` on POSIX, so it leads
|
|
402
|
+
its own session and process group (its pgid equals its pid). Signaling that
|
|
403
|
+
group reaches the whole `langgraph dev` process tree, so descendants receive
|
|
404
|
+
the same shutdown signals as the root rather than being left running when
|
|
405
|
+
only the root is signaled.
|
|
406
|
+
|
|
407
|
+
Returns `None` — meaning "signal only the root process" — on Windows (no
|
|
408
|
+
POSIX process groups) and whenever the server is not the leader of its own
|
|
409
|
+
dedicated group. As a defensive check, the `pgid == os.getpgid(0)` clause
|
|
410
|
+
also refuses to return dcode's own group, so the group handed back can never
|
|
411
|
+
be the one whose termination would take down the TUI.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
pid: Process id of the server subprocess.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
The server's dedicated process group id, or `None` to fall back to
|
|
418
|
+
signaling just the root process.
|
|
419
|
+
"""
|
|
420
|
+
if sys.platform == "win32":
|
|
421
|
+
return None
|
|
422
|
+
try:
|
|
423
|
+
pgid = os.getpgid(pid)
|
|
424
|
+
own_pgid = os.getpgid(0)
|
|
425
|
+
except ProcessLookupError:
|
|
426
|
+
# The process already exited; there is no group left to signal.
|
|
427
|
+
return None
|
|
428
|
+
except OSError:
|
|
429
|
+
# Resolving the group failed unexpectedly (getpgid on an owned child
|
|
430
|
+
# should not). Fall back to root-only signaling, but surface it so a
|
|
431
|
+
# silently orphaned descendant tree is diagnosable rather than invisible.
|
|
432
|
+
logger.warning(
|
|
433
|
+
"Could not resolve process group for pid=%d; "
|
|
434
|
+
"falling back to root-only signaling",
|
|
435
|
+
pid,
|
|
436
|
+
exc_info=True,
|
|
437
|
+
)
|
|
438
|
+
return None
|
|
439
|
+
if pgid != pid or pgid == own_pgid:
|
|
440
|
+
return None
|
|
441
|
+
return pgid
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _wait_for_process_group_exit(
|
|
445
|
+
process: subprocess.Popen[Any], pgid: int, timeout: float
|
|
446
|
+
) -> bool:
|
|
447
|
+
"""Wait until every process in a POSIX process group has exited.
|
|
448
|
+
|
|
449
|
+
`Popen.wait()` only observes the group leader. Poll it on every pass so an
|
|
450
|
+
exited leader does not remain a zombie and keep the group probe alive, then
|
|
451
|
+
continue probing because descendants may remain after the leader exits.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
process: The group leader, reaped as soon as it exits.
|
|
455
|
+
pgid: Process group id to probe.
|
|
456
|
+
timeout: Maximum seconds to wait for the whole group.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
`True` when the group is gone, or `False` on timeout.
|
|
460
|
+
"""
|
|
461
|
+
deadline = time.monotonic() + timeout
|
|
462
|
+
while True:
|
|
463
|
+
# `poll()` reaps an exited leader without blocking. Until that happens,
|
|
464
|
+
# its zombie entry keeps `killpg(..., 0)` reporting the group as alive.
|
|
465
|
+
process.poll()
|
|
466
|
+
try:
|
|
467
|
+
os.killpg(pgid, 0)
|
|
468
|
+
except ProcessLookupError:
|
|
469
|
+
process.wait()
|
|
470
|
+
return True
|
|
471
|
+
except PermissionError:
|
|
472
|
+
# The group still exists even if the probe is not permitted.
|
|
473
|
+
pass
|
|
474
|
+
|
|
475
|
+
remaining = deadline - time.monotonic()
|
|
476
|
+
if remaining <= 0:
|
|
477
|
+
return False
|
|
478
|
+
time.sleep(min(_PROCESS_GROUP_POLL_INTERVAL, remaining))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _terminate_server_process(process: subprocess.Popen[Any]) -> None:
|
|
482
|
+
"""Terminate the `langgraph dev` server and its descendants.
|
|
483
|
+
|
|
484
|
+
Sends SIGTERM, waits `_SHUTDOWN_TIMEOUT` for a graceful exit, then escalates
|
|
485
|
+
to SIGKILL. On POSIX the whole detached process group is signaled via
|
|
486
|
+
`os.killpg`, and teardown waits for the entire group to exit — not just the
|
|
487
|
+
root — so a child that outlives the `langgraph dev` root is still escalated
|
|
488
|
+
to SIGKILL rather than orphaned. On Windows (or if the server is not its own
|
|
489
|
+
group leader) only the root process is signaled. `_server_process_group`
|
|
490
|
+
guarantees dcode's own process group is never targeted.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
process: The running server subprocess to terminate.
|
|
494
|
+
"""
|
|
495
|
+
pid = process.pid
|
|
496
|
+
pgid = _server_process_group(pid)
|
|
497
|
+
scope = "process group" if pgid is not None else "process"
|
|
498
|
+
|
|
499
|
+
logger.info("Stopping langgraph dev server (pid=%d)", pid)
|
|
500
|
+
try:
|
|
501
|
+
if pgid is not None:
|
|
502
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
503
|
+
stopped = _wait_for_process_group_exit(process, pgid, _SHUTDOWN_TIMEOUT)
|
|
504
|
+
else:
|
|
505
|
+
process.send_signal(signal.SIGTERM)
|
|
506
|
+
try:
|
|
507
|
+
process.wait(timeout=_SHUTDOWN_TIMEOUT)
|
|
508
|
+
except subprocess.TimeoutExpired:
|
|
509
|
+
stopped = False
|
|
510
|
+
else:
|
|
511
|
+
stopped = True
|
|
512
|
+
except ProcessLookupError:
|
|
513
|
+
# The server exited before the SIGTERM landed; nothing left to reap.
|
|
514
|
+
logger.debug("Server %s pid=%d already exited before SIGTERM", scope, pid)
|
|
515
|
+
return
|
|
516
|
+
except OSError:
|
|
517
|
+
# SIGTERM could not be delivered (e.g. EPERM). We never reach the SIGKILL
|
|
518
|
+
# escalation, so the server is left running — report it with the same
|
|
519
|
+
# fidelity as a failed SIGKILL rather than a bare "error stopping".
|
|
520
|
+
logger.exception(
|
|
521
|
+
"Failed to signal server %s pid=%d; it may be orphaned", scope, pid
|
|
522
|
+
)
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
if stopped:
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
logger.warning("Server did not stop gracefully, killing %s", scope)
|
|
529
|
+
# Guard escalation explicitly: `ProcessLookupError` means the group exited
|
|
530
|
+
# just before SIGKILL, while any other `OSError` means it may be orphaned.
|
|
531
|
+
try:
|
|
532
|
+
if pgid is not None:
|
|
533
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
534
|
+
if not _wait_for_process_group_exit(process, pgid, _SIGKILL_TIMEOUT):
|
|
535
|
+
logger.warning(
|
|
536
|
+
"Server %s pid=%d did not exit after SIGKILL", scope, pid
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
process.kill()
|
|
540
|
+
try:
|
|
541
|
+
process.wait(timeout=_SIGKILL_TIMEOUT)
|
|
542
|
+
except subprocess.TimeoutExpired:
|
|
543
|
+
logger.warning(
|
|
544
|
+
"Server %s pid=%d did not exit after SIGKILL", scope, pid
|
|
545
|
+
)
|
|
546
|
+
except ProcessLookupError:
|
|
547
|
+
logger.debug("Server %s pid=%d already exited before SIGKILL", scope, pid)
|
|
548
|
+
except OSError:
|
|
549
|
+
logger.exception(
|
|
550
|
+
"Failed to SIGKILL server %s pid=%d; it may be orphaned",
|
|
551
|
+
scope,
|
|
552
|
+
pid,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# ---------------------------------------------------------------------------
|
|
557
|
+
# ServerProcess
|
|
558
|
+
# ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class ServerProcess:
|
|
562
|
+
"""Manages a `langgraph dev` server subprocess.
|
|
563
|
+
|
|
564
|
+
Focuses on subprocess lifecycle (start, stop, restart) and health checking.
|
|
565
|
+
Env-var management for restarts (e.g. configuration changes requiring a full
|
|
566
|
+
restart) is handled by `_scoped_env_overrides`, keeping this class focused
|
|
567
|
+
on process management.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
def __init__(
|
|
571
|
+
self,
|
|
572
|
+
*,
|
|
573
|
+
host: str = _DEFAULT_HOST,
|
|
574
|
+
port: int = _EPHEMERAL_PORT,
|
|
575
|
+
config_dir: str | Path | None = None,
|
|
576
|
+
owns_config_dir: bool = False,
|
|
577
|
+
scaffold: Callable[[Path], None] | None = None,
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Initialize server process manager.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
host: Host to bind the server to.
|
|
583
|
+
port: Initial port to bind the server to. Defaults to
|
|
584
|
+
`_EPHEMERAL_PORT` (0), so `start()` picks a free port and avoids
|
|
585
|
+
squatting the well-known `langgraph dev` default (2024).
|
|
586
|
+
|
|
587
|
+
An explicit port is honored, but `start()` still falls back to a
|
|
588
|
+
free port if it is already in use.
|
|
589
|
+
config_dir: Directory containing `langgraph.json`.
|
|
590
|
+
owns_config_dir: When `True`, the server will delete `config_dir`
|
|
591
|
+
on `stop()`.
|
|
592
|
+
scaffold: Optional callable that (re)generates the working
|
|
593
|
+
directory's `langgraph.json` and supporting files. When the
|
|
594
|
+
config is missing at `start()` (e.g. the temp dir was purged
|
|
595
|
+
between the initial boot and a later `/restart`), it is invoked
|
|
596
|
+
to rebuild the workspace instead of failing.
|
|
597
|
+
"""
|
|
598
|
+
self.host = host
|
|
599
|
+
self.port = port
|
|
600
|
+
self.config_dir = Path(config_dir) if config_dir else None
|
|
601
|
+
self._owns_config_dir = owns_config_dir
|
|
602
|
+
self._scaffold = scaffold
|
|
603
|
+
self._process: subprocess.Popen | None = None
|
|
604
|
+
self._temp_dir: tempfile.TemporaryDirectory | None = None
|
|
605
|
+
self._log_file: tempfile.NamedTemporaryFile | None = None # ty: ignore[invalid-type-form]
|
|
606
|
+
self._env_overrides: dict[str, str] = {}
|
|
607
|
+
self._persistent_env_overrides: dict[str, str] = {}
|
|
608
|
+
|
|
609
|
+
@property
|
|
610
|
+
def url(self) -> str:
|
|
611
|
+
"""Server base URL."""
|
|
612
|
+
return get_server_url(self.host, self.port)
|
|
613
|
+
|
|
614
|
+
@property
|
|
615
|
+
def running(self) -> bool:
|
|
616
|
+
"""Whether the server process is running."""
|
|
617
|
+
return self._process is not None and self._process.poll() is None
|
|
618
|
+
|
|
619
|
+
def _read_log_file(self) -> str:
|
|
620
|
+
"""Read the server log file contents.
|
|
621
|
+
|
|
622
|
+
Returns:
|
|
623
|
+
Log file contents as a string (may be empty).
|
|
624
|
+
"""
|
|
625
|
+
if self._log_file is None:
|
|
626
|
+
return ""
|
|
627
|
+
try:
|
|
628
|
+
self._log_file.flush()
|
|
629
|
+
return Path(self._log_file.name).read_text(
|
|
630
|
+
encoding="utf-8", errors="replace"
|
|
631
|
+
)
|
|
632
|
+
except OSError:
|
|
633
|
+
logger.warning(
|
|
634
|
+
"Failed to read server log file %s",
|
|
635
|
+
self._log_file.name,
|
|
636
|
+
exc_info=True,
|
|
637
|
+
)
|
|
638
|
+
return ""
|
|
639
|
+
|
|
640
|
+
async def start(
|
|
641
|
+
self,
|
|
642
|
+
*,
|
|
643
|
+
timeout: float = _HEALTH_TIMEOUT, # noqa: ASYNC109
|
|
644
|
+
) -> None:
|
|
645
|
+
"""Start the `langgraph dev` server and wait for it to be healthy.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
timeout: Max seconds to wait for the server to become healthy.
|
|
649
|
+
|
|
650
|
+
Raises:
|
|
651
|
+
RuntimeError: If the server fails to start or become healthy.
|
|
652
|
+
"""
|
|
653
|
+
if self.running:
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
work_dir = self.config_dir
|
|
657
|
+
if work_dir is None:
|
|
658
|
+
self._temp_dir = tempfile.TemporaryDirectory(prefix="deepagents_server_")
|
|
659
|
+
work_dir = Path(self._temp_dir.name)
|
|
660
|
+
|
|
661
|
+
config_path = work_dir / "langgraph.json"
|
|
662
|
+
if not config_path.exists() and self._scaffold is not None:
|
|
663
|
+
# The config can vanish between the initial boot and a later
|
|
664
|
+
# `/restart` (e.g. the OS tmp reaper purging the temp work dir).
|
|
665
|
+
# Rebuild it rather than failing the restart.
|
|
666
|
+
logger.info("langgraph.json missing in %s; rescaffolding", work_dir)
|
|
667
|
+
try:
|
|
668
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
669
|
+
self._scaffold(work_dir)
|
|
670
|
+
except OSError as exc:
|
|
671
|
+
# Surface the failure with restart context instead of letting a
|
|
672
|
+
# bare OSError (e.g. ENOSPC/EACCES on a degraded temp fs) escape
|
|
673
|
+
# stripped of the recovery framing. Chained so the root cause
|
|
674
|
+
# stays in the traceback.
|
|
675
|
+
msg = f"Failed to rescaffold server workspace at {work_dir}: {exc}"
|
|
676
|
+
raise RuntimeError(msg) from exc
|
|
677
|
+
if not config_path.exists():
|
|
678
|
+
if self._scaffold is not None:
|
|
679
|
+
# The scaffold hook ran but produced no langgraph.json (a silent
|
|
680
|
+
# no-op or a write to the wrong path). The "call
|
|
681
|
+
# generate_langgraph_json() first" advice below would misdirect,
|
|
682
|
+
# since the scaffold is exactly that call run internally.
|
|
683
|
+
contents = sorted(p.name for p in work_dir.iterdir())
|
|
684
|
+
msg = (
|
|
685
|
+
f"Rescaffolding {work_dir} did not produce langgraph.json "
|
|
686
|
+
f"(directory contents: {contents})."
|
|
687
|
+
)
|
|
688
|
+
else:
|
|
689
|
+
msg = (
|
|
690
|
+
f"langgraph.json not found in {work_dir}. "
|
|
691
|
+
"Call generate_langgraph_json() first."
|
|
692
|
+
)
|
|
693
|
+
raise RuntimeError(msg)
|
|
694
|
+
|
|
695
|
+
if self.port == _EPHEMERAL_PORT:
|
|
696
|
+
self.port = _find_free_port(self.host)
|
|
697
|
+
logger.info("Using ephemeral port %d for langgraph dev server", self.port)
|
|
698
|
+
elif _port_in_use(self.host, self.port):
|
|
699
|
+
self.port = _find_free_port(self.host)
|
|
700
|
+
logger.info("Requested port in use, using port %d instead", self.port)
|
|
701
|
+
|
|
702
|
+
cmd = _build_server_cmd(config_path, host=self.host, port=self.port)
|
|
703
|
+
env = _build_server_env()
|
|
704
|
+
# Persisted overrides are defaults; a one-shot override staged via
|
|
705
|
+
# `update_env()` for THIS restart must win over them. `_env_overrides`
|
|
706
|
+
# is already reflected in the `os.environ` copy above (applied by
|
|
707
|
+
# `_scoped_env_overrides`), but persisted values would otherwise shadow
|
|
708
|
+
# a freshly staged value, so re-apply the one-shot set last.
|
|
709
|
+
env.update(self._persistent_env_overrides)
|
|
710
|
+
env.update(self._env_overrides)
|
|
711
|
+
|
|
712
|
+
logger.info("Starting langgraph dev server: %s", " ".join(cmd))
|
|
713
|
+
self._log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
|
|
714
|
+
prefix="deepagents_server_log_",
|
|
715
|
+
suffix=".txt",
|
|
716
|
+
delete=False,
|
|
717
|
+
mode="w",
|
|
718
|
+
encoding="utf-8",
|
|
719
|
+
)
|
|
720
|
+
self._process = subprocess.Popen( # noqa: S603, ASYNC220
|
|
721
|
+
cmd,
|
|
722
|
+
cwd=str(work_dir),
|
|
723
|
+
env=env,
|
|
724
|
+
stdout=self._log_file,
|
|
725
|
+
stderr=subprocess.STDOUT,
|
|
726
|
+
# Detach the server from dcode's controlling terminal and process
|
|
727
|
+
# group on POSIX. Without a new session the server inherits dcode's
|
|
728
|
+
# session/process group and receives the same terminal-generated
|
|
729
|
+
# signals as the TUI: the SIGTSTP job-control stop when the user
|
|
730
|
+
# suspends dcode (Ctrl+Z), and the SIGHUP hangup when the host
|
|
731
|
+
# terminal is closed — suspending or hanging up the server alongside
|
|
732
|
+
# dcode. `start_new_session=True` runs
|
|
733
|
+
# `setsid()` so the server leads its own session/process group
|
|
734
|
+
# instead. `start_new_session` is ignored on Windows, so it is left
|
|
735
|
+
# off there.
|
|
736
|
+
start_new_session=(sys.platform != "win32"),
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
started = False
|
|
740
|
+
try:
|
|
741
|
+
await wait_for_server_healthy(
|
|
742
|
+
self.url,
|
|
743
|
+
timeout=timeout,
|
|
744
|
+
process=self._process,
|
|
745
|
+
read_log=self._read_log_file,
|
|
746
|
+
local=True,
|
|
747
|
+
)
|
|
748
|
+
started = True
|
|
749
|
+
finally:
|
|
750
|
+
if not started:
|
|
751
|
+
# Reap the subprocess we just spawned if startup did not
|
|
752
|
+
# complete — including cancellation (e.g. Ctrl+D / SIGINT before
|
|
753
|
+
# the health check returns). A `finally` rather than `except
|
|
754
|
+
# Exception` is deliberate: `asyncio.CancelledError` is a
|
|
755
|
+
# `BaseException`, so an `except Exception` guard would skip this
|
|
756
|
+
# and orphan the process. The inner guard stops a `stop()` error
|
|
757
|
+
# from masking the exception already propagating; `stop()` is
|
|
758
|
+
# effectively non-raising today, so if it does fire it signals an
|
|
759
|
+
# unexpected leak — hence `error`, not `warning`.
|
|
760
|
+
try:
|
|
761
|
+
self.stop()
|
|
762
|
+
except Exception:
|
|
763
|
+
logger.exception(
|
|
764
|
+
"Error stopping server during startup cleanup",
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
async def wait_for_graph_ready(
|
|
768
|
+
self,
|
|
769
|
+
graph_name: str = "agent",
|
|
770
|
+
*,
|
|
771
|
+
timeout: float = _HEALTH_TIMEOUT, # noqa: ASYNC109
|
|
772
|
+
) -> None:
|
|
773
|
+
"""Resolve the served graph once so lazy startup failures surface early.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
graph_name: Registered graph name from `langgraph.json`.
|
|
777
|
+
timeout: Max seconds to wait for the graph readiness request.
|
|
778
|
+
|
|
779
|
+
Raises:
|
|
780
|
+
RuntimeError: If the server process exits or the graph endpoint
|
|
781
|
+
does not return a successful response.
|
|
782
|
+
"""
|
|
783
|
+
import httpx
|
|
784
|
+
|
|
785
|
+
if self._process is None:
|
|
786
|
+
msg = "Server process is not running"
|
|
787
|
+
raise RuntimeError(msg)
|
|
788
|
+
|
|
789
|
+
graph_url = f"{self.url}/assistants/{quote(graph_name, safe='')}/graph"
|
|
790
|
+
deadline = time.monotonic() + timeout
|
|
791
|
+
|
|
792
|
+
async with httpx.AsyncClient() as client:
|
|
793
|
+
while time.monotonic() < deadline:
|
|
794
|
+
if self._process.poll() is not None:
|
|
795
|
+
msg = f"Server process exited with code {self._process.returncode}"
|
|
796
|
+
output = self._read_log_file()
|
|
797
|
+
if output:
|
|
798
|
+
summary = _extract_startup_error_marker(output)
|
|
799
|
+
if summary:
|
|
800
|
+
msg += f": {summary}"
|
|
801
|
+
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
|
|
802
|
+
raise RuntimeError(msg)
|
|
803
|
+
|
|
804
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
805
|
+
try:
|
|
806
|
+
resp = await client.get(graph_url, timeout=remaining)
|
|
807
|
+
except (httpx.TransportError, httpx.TimeoutException, OSError) as exc:
|
|
808
|
+
output = self._read_log_file()
|
|
809
|
+
summary = _extract_startup_error_marker(output)
|
|
810
|
+
if self._process.poll() is not None:
|
|
811
|
+
msg = (
|
|
812
|
+
f"Server process exited with code "
|
|
813
|
+
f"{self._process.returncode}"
|
|
814
|
+
)
|
|
815
|
+
else:
|
|
816
|
+
msg = (
|
|
817
|
+
f"Server graph '{graph_name}' did not initialize within "
|
|
818
|
+
f"{timeout}s"
|
|
819
|
+
)
|
|
820
|
+
if summary:
|
|
821
|
+
msg += f": {summary}"
|
|
822
|
+
if output:
|
|
823
|
+
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
|
|
824
|
+
raise RuntimeError(msg) from exc
|
|
825
|
+
|
|
826
|
+
if resp.status_code == 200: # noqa: PLR2004
|
|
827
|
+
logger.info("Server graph %s is ready at %s", graph_name, self.url)
|
|
828
|
+
return
|
|
829
|
+
|
|
830
|
+
output = self._read_log_file()
|
|
831
|
+
msg = (
|
|
832
|
+
f"Server graph '{graph_name}' failed readiness check "
|
|
833
|
+
f"(status: {resp.status_code})"
|
|
834
|
+
)
|
|
835
|
+
summary = _extract_startup_error_marker(output)
|
|
836
|
+
if summary:
|
|
837
|
+
msg += f": {summary}"
|
|
838
|
+
if output:
|
|
839
|
+
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
|
|
840
|
+
raise RuntimeError(msg)
|
|
841
|
+
|
|
842
|
+
msg = f"Server graph '{graph_name}' did not initialize within {timeout}s"
|
|
843
|
+
raise RuntimeError(msg)
|
|
844
|
+
|
|
845
|
+
def _stop_process(self) -> None:
|
|
846
|
+
"""Stop only the server subprocess and its log file.
|
|
847
|
+
|
|
848
|
+
Unlike `stop()`, this does NOT clean up the config directory or temp
|
|
849
|
+
directory, so the server can be restarted with the same config.
|
|
850
|
+
"""
|
|
851
|
+
if self._process is None:
|
|
852
|
+
return
|
|
853
|
+
|
|
854
|
+
if self._process.poll() is None:
|
|
855
|
+
_terminate_server_process(self._process)
|
|
856
|
+
# `_terminate_server_process` is best-effort. If the process is still
|
|
857
|
+
# alive here (e.g. SIGKILL failed with EPERM), then once we drop the
|
|
858
|
+
# handle below we can no longer observe or reap this pid, so surface
|
|
859
|
+
# the still-running process rather than clearing state as if shutdown
|
|
860
|
+
# succeeded.
|
|
861
|
+
if self._process.poll() is None:
|
|
862
|
+
logger.warning(
|
|
863
|
+
"Dropping handle to server pid=%d that is still running; "
|
|
864
|
+
"it may be orphaned",
|
|
865
|
+
self._process.pid,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
self._process = None
|
|
869
|
+
|
|
870
|
+
if self._log_file is not None:
|
|
871
|
+
log_path = Path(self._log_file.name)
|
|
872
|
+
try:
|
|
873
|
+
self._log_file.close()
|
|
874
|
+
except OSError:
|
|
875
|
+
logger.debug("Failed to close log file", exc_info=True)
|
|
876
|
+
|
|
877
|
+
from deepagents_code._env_vars import DEBUG, is_env_truthy
|
|
878
|
+
|
|
879
|
+
if is_env_truthy(DEBUG):
|
|
880
|
+
print( # noqa: T201
|
|
881
|
+
f"Server log preserved at: {log_path}",
|
|
882
|
+
file=sys.stderr,
|
|
883
|
+
)
|
|
884
|
+
else:
|
|
885
|
+
try:
|
|
886
|
+
log_path.unlink()
|
|
887
|
+
except OSError:
|
|
888
|
+
logger.debug("Failed to clean up log file", exc_info=True)
|
|
889
|
+
self._log_file = None
|
|
890
|
+
|
|
891
|
+
def stop(self) -> None:
|
|
892
|
+
"""Stop the server process and clean up all resources."""
|
|
893
|
+
self._stop_process()
|
|
894
|
+
|
|
895
|
+
if self._temp_dir is not None:
|
|
896
|
+
try:
|
|
897
|
+
self._temp_dir.cleanup()
|
|
898
|
+
except OSError:
|
|
899
|
+
logger.debug("Failed to clean up temp dir", exc_info=True)
|
|
900
|
+
self._temp_dir = None
|
|
901
|
+
|
|
902
|
+
if self._owns_config_dir and self.config_dir is not None:
|
|
903
|
+
import shutil
|
|
904
|
+
|
|
905
|
+
try:
|
|
906
|
+
shutil.rmtree(self.config_dir)
|
|
907
|
+
except OSError:
|
|
908
|
+
logger.debug(
|
|
909
|
+
"Failed to clean up config dir %s", self.config_dir, exc_info=True
|
|
910
|
+
)
|
|
911
|
+
self._owns_config_dir = False
|
|
912
|
+
|
|
913
|
+
def update_env(self, **overrides: str) -> None:
|
|
914
|
+
"""Stage env var overrides to apply on the next `restart()`.
|
|
915
|
+
|
|
916
|
+
These are applied to `os.environ` immediately before the subprocess
|
|
917
|
+
starts, keeping mutation scoped to the restart call.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
**overrides: Key/value env var pairs
|
|
921
|
+
(e.g., `DEEPAGENTS_CODE_SERVER_MODEL="anthropic:claude-sonnet-4-6"`).
|
|
922
|
+
"""
|
|
923
|
+
self._env_overrides.update(overrides)
|
|
924
|
+
|
|
925
|
+
def persist_env(self, **overrides: str) -> None:
|
|
926
|
+
"""Persist env var overrides for every future subprocess start.
|
|
927
|
+
|
|
928
|
+
Args:
|
|
929
|
+
**overrides: Key/value env var pairs that should be passed to all
|
|
930
|
+
future server subprocesses.
|
|
931
|
+
|
|
932
|
+
Raises:
|
|
933
|
+
ValueError: If an override is not an app-owned server env var.
|
|
934
|
+
"""
|
|
935
|
+
invalid = [key for key in overrides if not key.startswith(SERVER_ENV_PREFIX)]
|
|
936
|
+
if invalid:
|
|
937
|
+
msg = (
|
|
938
|
+
"persistent server env overrides must use the "
|
|
939
|
+
f"{SERVER_ENV_PREFIX!r} prefix"
|
|
940
|
+
)
|
|
941
|
+
raise ValueError(msg)
|
|
942
|
+
self._persistent_env_overrides.update(overrides)
|
|
943
|
+
|
|
944
|
+
async def restart(self, *, timeout: float = _HEALTH_TIMEOUT) -> None: # noqa: ASYNC109
|
|
945
|
+
"""Restart the server process, reusing the existing config directory.
|
|
946
|
+
|
|
947
|
+
Stops the subprocess, then starts a new one. Any env overrides staged
|
|
948
|
+
via `update_env()` are applied within a `_scoped_env_overrides` context
|
|
949
|
+
manager so that failures automatically roll back the environment to the
|
|
950
|
+
last known-good state.
|
|
951
|
+
|
|
952
|
+
Args:
|
|
953
|
+
timeout: Max seconds to wait for the server to become healthy.
|
|
954
|
+
"""
|
|
955
|
+
logger.info("Restarting langgraph dev server")
|
|
956
|
+
# Offload the synchronous subprocess shutdown (it blocks up to
|
|
957
|
+
# `_SHUTDOWN_TIMEOUT` + SIGKILL grace waiting on `process.wait`) so the
|
|
958
|
+
# caller's event loop — the Textual reactor for `/restart` — keeps
|
|
959
|
+
# processing input instead of freezing the TUI.
|
|
960
|
+
await asyncio.to_thread(self._stop_process)
|
|
961
|
+
|
|
962
|
+
with _scoped_env_overrides(self._env_overrides):
|
|
963
|
+
await self.start(timeout=timeout)
|
|
964
|
+
|
|
965
|
+
self._env_overrides.clear()
|
|
966
|
+
|
|
967
|
+
async def __aenter__(self) -> Self:
|
|
968
|
+
"""Async context manager entry.
|
|
969
|
+
|
|
970
|
+
Returns:
|
|
971
|
+
The server process instance.
|
|
972
|
+
"""
|
|
973
|
+
await self.start()
|
|
974
|
+
return self
|
|
975
|
+
|
|
976
|
+
async def __aexit__(self, *args: object) -> None:
|
|
977
|
+
"""Async context manager exit."""
|
|
978
|
+
self.stop()
|