feat(tunnel): add Cloudflare tunnel support for EvoSci deploy and update documentation
This commit is contained in:
@@ -46,6 +46,12 @@ def deploy(
|
|||||||
"--port",
|
"--port",
|
||||||
help="Port for langgraph dev (default: config.langgraph_dev_port = 6174)",
|
help="Port for langgraph dev (default: config.langgraph_dev_port = 6174)",
|
||||||
),
|
),
|
||||||
|
tunnel: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--tunnel",
|
||||||
|
help="Expose the server over a public Cloudflare tunnel (no auth — "
|
||||||
|
"anyone with the URL can drive the agent; trusted use only)",
|
||||||
|
),
|
||||||
debug: bool = typer.Option(
|
debug: bool = typer.Option(
|
||||||
False,
|
False,
|
||||||
"--debug",
|
"--debug",
|
||||||
@@ -64,6 +70,7 @@ def deploy(
|
|||||||
RUNTIME,
|
RUNTIME,
|
||||||
_is_port_occupied,
|
_is_port_occupied,
|
||||||
is_langgraph_dev_running,
|
is_langgraph_dev_running,
|
||||||
|
read_tunnel_url,
|
||||||
start_langgraph_dev,
|
start_langgraph_dev,
|
||||||
stop_langgraph_dev,
|
stop_langgraph_dev,
|
||||||
)
|
)
|
||||||
@@ -155,6 +162,13 @@ def deploy(
|
|||||||
f"[bold red]{DANGEROUS_BANNER_MESSAGE}[/bold red]"
|
f"[bold red]{DANGEROUS_BANNER_MESSAGE}[/bold red]"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if tunnel:
|
||||||
|
console.print(
|
||||||
|
"[bold white on red] ⚠ PUBLIC TUNNEL [/bold white on red] "
|
||||||
|
"[bold red]Public URL, no auth — share only with people you "
|
||||||
|
"trust.[/bold red]"
|
||||||
|
)
|
||||||
|
|
||||||
# 6. ccproxy lifecycle (only if any provider uses OAuth)
|
# 6. ccproxy lifecycle (only if any provider uses OAuth)
|
||||||
_ccproxy_proc = None
|
_ccproxy_proc = None
|
||||||
if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth":
|
if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth":
|
||||||
@@ -186,6 +200,7 @@ def deploy(
|
|||||||
file_persistence=file_persistence,
|
file_persistence=file_persistence,
|
||||||
jobs_per_worker=jobs_per_worker,
|
jobs_per_worker=jobs_per_worker,
|
||||||
deploy_mode=True,
|
deploy_mode=True,
|
||||||
|
tunnel=tunnel,
|
||||||
)
|
)
|
||||||
atexit.register(stop_langgraph_dev, proc)
|
atexit.register(stop_langgraph_dev, proc)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -196,13 +211,33 @@ def deploy(
|
|||||||
# here, the subprocess is up.
|
# here, the subprocess is up.
|
||||||
console.print("[green]✓[/green] langgraph dev ready")
|
console.print("[green]✓[/green] langgraph dev ready")
|
||||||
|
|
||||||
|
# 8b. Cloudflare tunnel URL — the local server is healthy, but cloudflared
|
||||||
|
# establishes the public tunnel a few seconds later and prints the random
|
||||||
|
# URL into the log. Poll for it so we can surface it in the ready banner.
|
||||||
|
public_url: str | None = None
|
||||||
|
if tunnel:
|
||||||
|
with console.status(
|
||||||
|
"[dim]Waiting for Cloudflare tunnel URL...[/dim]", spinner="dots"
|
||||||
|
):
|
||||||
|
public_url = read_tunnel_url()
|
||||||
|
if public_url:
|
||||||
|
console.print("[green]✓[/green] tunnel up")
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
"[yellow]⚠ Tunnel URL not detected within the wait window. "
|
||||||
|
f"Check the log ({_shorten(str(RUNTIME.log_file))}) for a "
|
||||||
|
"trycloudflare.com URL.[/yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
# 9. Ready banner
|
# 9. Ready banner
|
||||||
log_hint = _shorten(str(RUNTIME.log_file))
|
log_hint = _shorten(str(RUNTIME.log_file))
|
||||||
|
public_line = f"[bold]Public URL:[/bold] {public_url}\n" if public_url else ""
|
||||||
console.print(
|
console.print(
|
||||||
Panel(
|
Panel(
|
||||||
Text.from_markup(
|
Text.from_markup(
|
||||||
f"[bold]Endpoint:[/bold] "
|
f"[bold]Endpoint:[/bold] "
|
||||||
f"http://localhost:{effective_port}\n"
|
f"http://localhost:{effective_port}\n"
|
||||||
|
f"{public_line}"
|
||||||
f"[bold]Assistant ID:[/bold] EvoScientist\n"
|
f"[bold]Assistant ID:[/bold] EvoScientist\n"
|
||||||
f"[bold]Connect via:[/bold] any LangChain SDK / "
|
f"[bold]Connect via:[/bold] any LangChain SDK / "
|
||||||
f"LangGraph-compatible UI\n"
|
f"LangGraph-compatible UI\n"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import atexit
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -253,6 +254,16 @@ _PROCESS: subprocess.Popen | None = None
|
|||||||
# sub-agents' cwd / EVOSCIENTIST_WORKSPACE_DIR env match the new workspace.
|
# sub-agents' cwd / EVOSCIENTIST_WORKSPACE_DIR env match the new workspace.
|
||||||
_PROCESS_WORKSPACE: Path | None = None
|
_PROCESS_WORKSPACE: Path | None = None
|
||||||
|
|
||||||
|
# Byte offset into ``RUNTIME.log_file`` captured the instant before the current
|
||||||
|
# subprocess was spawned. ``read_tunnel_url`` scans only bytes written after
|
||||||
|
# this point so a stale ``trycloudflare.com`` URL from a previous (appended,
|
||||||
|
# not-yet-rotated) session can never be misreported as the live tunnel.
|
||||||
|
_LOG_OFFSET_AT_START: int = 0
|
||||||
|
|
||||||
|
# Cloudflare quick-tunnel public URL, as printed by cloudflared into the
|
||||||
|
# langgraph dev log. Mirrors langgraph_api/tunneling/cloudflare.py.
|
||||||
|
_TUNNEL_URL_RE = re.compile(r"https://[A-Za-z0-9.-]+\.trycloudflare\.com")
|
||||||
|
|
||||||
# Whether async sub-agents are usable in this process.
|
# Whether async sub-agents are usable in this process.
|
||||||
#
|
#
|
||||||
# - CLI / serve parent process: starts False; flipped True after
|
# - CLI / serve parent process: starts False; flipped True after
|
||||||
@@ -525,6 +536,7 @@ def start_langgraph_dev(
|
|||||||
file_persistence: bool = True,
|
file_persistence: bool = True,
|
||||||
jobs_per_worker: int = 10,
|
jobs_per_worker: int = 10,
|
||||||
deploy_mode: bool = False,
|
deploy_mode: bool = False,
|
||||||
|
tunnel: bool = False,
|
||||||
) -> subprocess.Popen:
|
) -> subprocess.Popen:
|
||||||
"""Start langgraph dev as a background subprocess.
|
"""Start langgraph dev as a background subprocess.
|
||||||
|
|
||||||
@@ -538,6 +550,14 @@ def start_langgraph_dev(
|
|||||||
``.langgraph_api/`` cache so async-task / Store / scheduler state
|
``.langgraph_api/`` cache so async-task / Store / scheduler state
|
||||||
survives subprocess restarts. Set False to suppress periodic
|
survives subprocess restarts. Set False to suppress periodic
|
||||||
flushes (workspace stays cleaner; state is in-memory only).
|
flushes (workspace stays cleaner; state is in-memory only).
|
||||||
|
jobs_per_worker: Concurrent runs per worker (``--n-jobs-per-worker``).
|
||||||
|
deploy_mode: When True, the subprocess loads full MCP + async
|
||||||
|
sub-agents (``EVOSCIENTIST_DEPLOY_MODE=full``); otherwise stripped.
|
||||||
|
tunnel: When True, pass ``--tunnel`` so langgraph dev exposes the
|
||||||
|
server over a public Cloudflare quick-tunnel. The random
|
||||||
|
``*.trycloudflare.com`` URL is written to the log; read it back
|
||||||
|
with :func:`read_tunnel_url`. SECURITY: the tunnel has no auth and
|
||||||
|
the deployed agent can run shell — only enable for trusted use.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The Popen handle for the langgraph dev process.
|
The Popen handle for the langgraph dev process.
|
||||||
@@ -626,6 +646,14 @@ def start_langgraph_dev(
|
|||||||
# one fd — a problem on heavy ``/resume`` cycling that could eventually
|
# one fd — a problem on heavy ``/resume`` cycling that could eventually
|
||||||
# exhaust the process's open-file limit.
|
# exhaust the process's open-file limit.
|
||||||
log_handle = open(RUNTIME.log_file, "ab") # closed in finally below
|
log_handle = open(RUNTIME.log_file, "ab") # closed in finally below
|
||||||
|
# Remember where this session's output begins so ``read_tunnel_url`` only
|
||||||
|
# scans lines this subprocess writes — never a stale URL left in the
|
||||||
|
# appended-to log by a previous tunnel session.
|
||||||
|
global _LOG_OFFSET_AT_START
|
||||||
|
try:
|
||||||
|
_LOG_OFFSET_AT_START = RUNTIME.log_file.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
_LOG_OFFSET_AT_START = 0
|
||||||
|
|
||||||
# Propagate workspace to the subprocess so deployed sub-agents resolve
|
# Propagate workspace to the subprocess so deployed sub-agents resolve
|
||||||
# paths.WORKSPACE_ROOT to the same dir as the CLI's main agent. cwd alone
|
# paths.WORKSPACE_ROOT to the same dir as the CLI's main agent. cwd alone
|
||||||
@@ -685,6 +713,7 @@ def start_langgraph_dev(
|
|||||||
str(jobs_per_worker),
|
str(jobs_per_worker),
|
||||||
"--no-browser",
|
"--no-browser",
|
||||||
"--no-reload",
|
"--no-reload",
|
||||||
|
*(["--tunnel"] if tunnel else []),
|
||||||
],
|
],
|
||||||
cwd=str(workspace_dir),
|
cwd=str(workspace_dir),
|
||||||
stdout=log_handle,
|
stdout=log_handle,
|
||||||
@@ -741,6 +770,40 @@ def start_langgraph_dev(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_tunnel_url(timeout: float = 35.0, poll_interval: float = 0.5) -> str | None:
|
||||||
|
"""Poll the langgraph dev log for the Cloudflare quick-tunnel public URL.
|
||||||
|
|
||||||
|
Started with ``tunnel=True``, langgraph dev shells out to cloudflared,
|
||||||
|
which prints a random ``https://<words>.trycloudflare.com`` URL once the
|
||||||
|
tunnel is established — typically a few seconds after the local server is
|
||||||
|
already healthy. We scan only the bytes written since this subprocess
|
||||||
|
started (``_LOG_OFFSET_AT_START``) so a stale URL from an earlier session
|
||||||
|
in the same appended-to log is never returned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Max seconds to wait for the URL to appear. cloudflared may
|
||||||
|
also need to download its binary on first use, so the default is
|
||||||
|
generous (langgraph_api itself waits up to 30s internally).
|
||||||
|
poll_interval: Seconds between log re-reads.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The public tunnel URL, or ``None`` if it never appeared in time.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with open(RUNTIME.log_file, "rb") as fh:
|
||||||
|
fh.seek(_LOG_OFFSET_AT_START)
|
||||||
|
chunk = fh.read().decode("utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
chunk = ""
|
||||||
|
match = _TUNNEL_URL_RE.search(chunk)
|
||||||
|
if match:
|
||||||
|
return match.group(0)
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def stop_langgraph_dev(proc: subprocess.Popen | None = None) -> None:
|
def stop_langgraph_dev(proc: subprocess.Popen | None = None) -> None:
|
||||||
"""Gracefully stop a langgraph dev process.
|
"""Gracefully stop a langgraph dev process.
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
|
|||||||
<details>
|
<details>
|
||||||
<summary>📦 Release Highlights — version changelog</summary>
|
<summary>📦 Release Highlights — version changelog</summary>
|
||||||
|
|
||||||
- **[10 Jun 2026]** **[v0.1.5](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.5)** — Dangerous mode (real-filesystem access with safety checks), LangGraph streaming v3 pipeline, opt-in Anthropic prompt caching via OpenRouter, claude-fable-5, free-scrolling TUI, Windows CI support.
|
- **[11 Jun 2026]** **[v0.1.5](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.5)** — Dangerous mode (real-filesystem access with safety checks), LangGraph streaming v3 pipeline, opt-in Anthropic prompt caching via OpenRouter, claude-fable-5, free-scrolling TUI, Windows CI support, public Cloudflare tunnel for `EvoSci deploy` (`--tunnel`).
|
||||||
- **[07 Jun 2026]** **[v0.1.4](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.4)** — Auxiliary model for background tasks & tool selection, observation-memory lifecycle, Qwen3.7-Max/Plus (DashScope), UI-backend selection, plus an OpenRouter multi-turn reasoning fix.
|
- **[07 Jun 2026]** **[v0.1.4](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.4)** — Auxiliary model for background tasks & tool selection, observation-memory lifecycle, Qwen3.7-Max/Plus (DashScope), UI-backend selection, plus an OpenRouter multi-turn reasoning fix.
|
||||||
- **[03 Jun 2026]** **[v0.1.3](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.3)** — Multimodal handling (image + PDF/doc flatten/hoisting, text-only model fallback), runtime-context middleware, memory middleware → profile files with stream timeline narration, textual CJK-input fix.
|
- **[03 Jun 2026]** **[v0.1.3](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.3)** — Multimodal handling (image + PDF/doc flatten/hoisting, text-only model fallback), runtime-context middleware, memory middleware → profile files with stream timeline narration, textual CJK-input fix.
|
||||||
- **[02 Jun 2026]** **[v0.1.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.2)** — Browser WebUI mode, `EvoSci deploy` standalone LangGraph server, default model → claude-sonnet-4-6, MiniMax M3, plus sandbox-timeout and async-notifier channel-routing fixes.
|
- **[02 Jun 2026]** **[v0.1.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.2)** — Browser WebUI mode, `EvoSci deploy` standalone LangGraph server, default model → claude-sonnet-4-6, MiniMax M3, plus sandbox-timeout and async-notifier channel-routing fixes.
|
||||||
|
|||||||
+1
-1
@@ -158,7 +158,7 @@ EvoScientist 超越了传统的人在回路(Human-in-the-Loop)模式,采
|
|||||||
<details>
|
<details>
|
||||||
<summary>📦 版本更新摘要(changelog)</summary>
|
<summary>📦 版本更新摘要(changelog)</summary>
|
||||||
|
|
||||||
- **[2026 年 6 月 10 日]** **[v0.1.5](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.5)** — Dangerous 模式(带安全检查的真实文件系统访问)、LangGraph streaming v3 流水线、OpenRouter Anthropic prompt caching(可选启用)、claude-fable-5、TUI 自由滚动,以及 Windows CI 支持。
|
- **[2026 年 6 月 11 日]** **[v0.1.5](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.5)** — Dangerous 模式(带安全检查的真实文件系统访问)、LangGraph streaming v3 流水线、OpenRouter Anthropic prompt caching(可选启用)、claude-fable-5、TUI 自由滚动、Windows CI 支持,以及 `EvoSci deploy` 公网 Cloudflare 隧道(`--tunnel`)。
|
||||||
- **[2026 年 6 月 7 日]** **[v0.1.4](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.4)** — 辅助模型(后台任务与工具选择)、observation 记忆生命周期、Qwen3.7-Max/Plus(DashScope)、UI 后端选择,以及 OpenRouter 多轮推理修复。
|
- **[2026 年 6 月 7 日]** **[v0.1.4](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.4)** — 辅助模型(后台任务与工具选择)、observation 记忆生命周期、Qwen3.7-Max/Plus(DashScope)、UI 后端选择,以及 OpenRouter 多轮推理修复。
|
||||||
- **[2026 年 6 月 3 日]** **[v0.1.3](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.3)** — 多模态处理(图片 + PDF/文档 flatten/hoisting、纯文本模型回退)、runtime-context 中间件、memory 中间件迁移至 profile 文件 + stream 时间线叙述、textual 中文输入修复。
|
- **[2026 年 6 月 3 日]** **[v0.1.3](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.3)** — 多模态处理(图片 + PDF/文档 flatten/hoisting、纯文本模型回退)、runtime-context 中间件、memory 中间件迁移至 profile 文件 + stream 时间线叙述、textual 中文输入修复。
|
||||||
- **[2026 年 6 月 2 日]** **[v0.1.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.2)** — 浏览器 WebUI 模式(beta)、`EvoSci deploy` 独立 LangGraph 服务器、默认模型 → claude-sonnet-4-6、MiniMax M3,以及 sandbox 超时与 async-notifier 渠道路由修复。
|
- **[2026 年 6 月 2 日]** **[v0.1.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.2)** — 浏览器 WebUI 模式(beta)、`EvoSci deploy` 独立 LangGraph 服务器、默认模型 → claude-sonnet-4-6、MiniMax M3,以及 sandbox 超时与 async-notifier 渠道路由修复。
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ def _run_deploy_once(
|
|||||||
cwd: str | None = None,
|
cwd: str | None = None,
|
||||||
port_occupied: bool = False,
|
port_occupied: bool = False,
|
||||||
langgraph_dev_running: bool = True, # health-check passes after start
|
langgraph_dev_running: bool = True, # health-check passes after start
|
||||||
|
tunnel: bool = False,
|
||||||
|
tunnel_url: str | None = None,
|
||||||
):
|
):
|
||||||
"""Run ``deploy()`` end-to-end with all external dependencies mocked.
|
"""Run ``deploy()`` end-to-end with all external dependencies mocked.
|
||||||
Returns a ``captured`` dict with observation points."""
|
Returns a ``captured`` dict with observation points."""
|
||||||
@@ -124,6 +126,7 @@ def _run_deploy_once(
|
|||||||
file_persistence=True,
|
file_persistence=True,
|
||||||
jobs_per_worker=10,
|
jobs_per_worker=10,
|
||||||
deploy_mode=False,
|
deploy_mode=False,
|
||||||
|
tunnel=False,
|
||||||
):
|
):
|
||||||
captured["langgraph_dev_started"] = True
|
captured["langgraph_dev_started"] = True
|
||||||
captured["workspace_passed"] = str(workspace_dir) if workspace_dir else None
|
captured["workspace_passed"] = str(workspace_dir) if workspace_dir else None
|
||||||
@@ -131,6 +134,7 @@ def _run_deploy_once(
|
|||||||
captured["deploy_mode_passed"] = deploy_mode
|
captured["deploy_mode_passed"] = deploy_mode
|
||||||
captured["jobs_per_worker_passed"] = jobs_per_worker
|
captured["jobs_per_worker_passed"] = jobs_per_worker
|
||||||
captured["file_persistence_passed"] = file_persistence
|
captured["file_persistence_passed"] = file_persistence
|
||||||
|
captured["tunnel_passed"] = tunnel
|
||||||
return SimpleNamespace(pid=99999)
|
return SimpleNamespace(pid=99999)
|
||||||
|
|
||||||
def _fake_stop_langgraph_dev(_proc=None):
|
def _fake_stop_langgraph_dev(_proc=None):
|
||||||
@@ -138,6 +142,10 @@ def _run_deploy_once(
|
|||||||
|
|
||||||
monkeypatch.setattr(lgm, "start_langgraph_dev", _fake_start_langgraph_dev)
|
monkeypatch.setattr(lgm, "start_langgraph_dev", _fake_start_langgraph_dev)
|
||||||
monkeypatch.setattr(lgm, "stop_langgraph_dev", _fake_stop_langgraph_dev)
|
monkeypatch.setattr(lgm, "stop_langgraph_dev", _fake_stop_langgraph_dev)
|
||||||
|
# Never poll a real log for the tunnel URL in tests.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lgm, "read_tunnel_url", lambda *a, **k: tunnel_url, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
# ccproxy mocks
|
# ccproxy mocks
|
||||||
from EvoScientist import ccproxy_manager as ccp
|
from EvoScientist import ccproxy_manager as ccp
|
||||||
@@ -196,7 +204,7 @@ def _run_deploy_once(
|
|||||||
if cwd is not None:
|
if cwd is not None:
|
||||||
monkeypatch.setattr(os, "getcwd", lambda: cwd)
|
monkeypatch.setattr(os, "getcwd", lambda: cwd)
|
||||||
|
|
||||||
deploy_server.deploy(workdir=workdir, port=port, debug=debug)
|
deploy_server.deploy(workdir=workdir, port=port, debug=debug, tunnel=tunnel)
|
||||||
return captured
|
return captured
|
||||||
|
|
||||||
|
|
||||||
@@ -234,6 +242,27 @@ def test_deploy_starts_langgraph_dev_with_deploy_mode_true(monkeypatch, tmp_path
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deploy_tunnel_default_off(monkeypatch, tmp_path):
|
||||||
|
"""Without ``--tunnel``, start_langgraph_dev is called with tunnel=False."""
|
||||||
|
config = _make_config(default_workdir=str(tmp_path))
|
||||||
|
captured = _run_deploy_once(monkeypatch, config)
|
||||||
|
|
||||||
|
assert captured["tunnel_passed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_deploy_tunnel_flag_passed_through(monkeypatch, tmp_path):
|
||||||
|
"""``--tunnel`` propagates to start_langgraph_dev(tunnel=True)."""
|
||||||
|
config = _make_config(default_workdir=str(tmp_path))
|
||||||
|
captured = _run_deploy_once(
|
||||||
|
monkeypatch,
|
||||||
|
config,
|
||||||
|
tunnel=True,
|
||||||
|
tunnel_url="https://demo-xyz.trycloudflare.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["tunnel_passed"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_deploy_workdir_cli_arg_beats_config(monkeypatch, tmp_path):
|
def test_deploy_workdir_cli_arg_beats_config(monkeypatch, tmp_path):
|
||||||
cli_ws = tmp_path / "cli_ws"
|
cli_ws = tmp_path / "cli_ws"
|
||||||
cfg_ws = tmp_path / "cfg_ws"
|
cfg_ws = tmp_path / "cfg_ws"
|
||||||
|
|||||||
@@ -267,3 +267,30 @@ def test_async_subagents_available_init_false_without_env(monkeypatch):
|
|||||||
|
|
||||||
reloaded = importlib.reload(mgr)
|
reloaded = importlib.reload(mgr)
|
||||||
assert reloaded._ASYNC_SUBAGENTS_AVAILABLE is False
|
assert reloaded._ASYNC_SUBAGENTS_AVAILABLE is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_tunnel_true_appends_flag(monkeypatch, tmp_path, runtime_paths):
|
||||||
|
"""``tunnel=True`` must add ``--tunnel`` to the langgraph dev argv."""
|
||||||
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
||||||
|
|
||||||
|
with pytest.raises(_PopenAbort):
|
||||||
|
manager.start_langgraph_dev(
|
||||||
|
workspace_dir=tmp_path,
|
||||||
|
port=16190,
|
||||||
|
tunnel=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "--tunnel" in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tunnel_false_default_omits_flag(monkeypatch, tmp_path, runtime_paths):
|
||||||
|
"""``tunnel`` defaults to False — no ``--tunnel`` in the argv."""
|
||||||
|
captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths)
|
||||||
|
|
||||||
|
with pytest.raises(_PopenAbort):
|
||||||
|
manager.start_langgraph_dev(
|
||||||
|
workspace_dir=tmp_path,
|
||||||
|
port=16191,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "--tunnel" not in captured["args"]
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ def reset_module_state():
|
|||||||
manager._PROCESS = None
|
manager._PROCESS = None
|
||||||
manager._PROCESS_WORKSPACE = None
|
manager._PROCESS_WORKSPACE = None
|
||||||
manager._ASYNC_SUBAGENTS_AVAILABLE = False
|
manager._ASYNC_SUBAGENTS_AVAILABLE = False
|
||||||
|
manager._LOG_OFFSET_AT_START = 0
|
||||||
yield
|
yield
|
||||||
manager._PROCESS = None
|
manager._PROCESS = None
|
||||||
manager._PROCESS_WORKSPACE = None
|
manager._PROCESS_WORKSPACE = None
|
||||||
manager._ASYNC_SUBAGENTS_AVAILABLE = False
|
manager._ASYNC_SUBAGENTS_AVAILABLE = False
|
||||||
|
manager._LOG_OFFSET_AT_START = 0
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -468,3 +470,141 @@ class TestStartLanggraphDevRotatesLog:
|
|||||||
# under ``tmp_path``). The ``pid_dir`` we redirected to must
|
# under ``tmp_path``). The ``pid_dir`` we redirected to must
|
||||||
# exist, proving the function reached past the mkdir prelude.
|
# exist, proving the function reached past the mkdir prelude.
|
||||||
assert pid_dir.is_dir()
|
assert pid_dir.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartLanggraphDevCapturesLogOffset:
|
||||||
|
"""``start_langgraph_dev`` must capture ``_LOG_OFFSET_AT_START`` at the
|
||||||
|
right moment — after ``_rotate_log_if_needed`` + ``open('ab')`` but
|
||||||
|
before ``subprocess.Popen`` — so ``read_tunnel_url`` scans only this
|
||||||
|
session's bytes. The existing ``TestReadTunnelUrl`` tests monkeypatch
|
||||||
|
the offset directly (consumer side); these guard the producer side, so
|
||||||
|
a regression moving the capture line would actually be caught.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _patch_prereqs(self, tmp_path, monkeypatch, log):
|
||||||
|
"""Mock everything up to (but not including) Popen, redirecting all
|
||||||
|
runtime paths under ``tmp_path``."""
|
||||||
|
pid_dir = tmp_path / "pids"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
manager,
|
||||||
|
"RUNTIME",
|
||||||
|
dataclasses.replace(
|
||||||
|
manager.LanggraphRuntimePaths.for_directory(pid_dir),
|
||||||
|
log_file=log,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_can_bind_port", lambda port: True)
|
||||||
|
fake_config = tmp_path / "langgraph.json"
|
||||||
|
fake_config.write_text("{}")
|
||||||
|
monkeypatch.setattr(manager, "_langgraph_exe", lambda: "/fake/langgraph")
|
||||||
|
monkeypatch.setattr(manager, "_packaged_langgraph_config", lambda: fake_config)
|
||||||
|
|
||||||
|
def test_offset_equals_existing_log_size(self, tmp_path, monkeypatch):
|
||||||
|
"""No rotation → offset is the pre-existing (appended-to) log size,
|
||||||
|
so a stale URL above that offset is never re-read."""
|
||||||
|
log = tmp_path / "langgraph_dev.log"
|
||||||
|
log.write_bytes(b"x" * 512)
|
||||||
|
# Keep the log well under the rotation threshold so it is NOT rotated.
|
||||||
|
monkeypatch.setattr(manager, "_LOG_ROTATION_BYTES", 10**9)
|
||||||
|
self._patch_prereqs(tmp_path, monkeypatch, log)
|
||||||
|
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _fake_popen(args, **kwargs):
|
||||||
|
# Read the global at the instant Popen is invoked — this is
|
||||||
|
# strictly after the capture line in start_langgraph_dev.
|
||||||
|
captured["offset"] = manager._LOG_OFFSET_AT_START
|
||||||
|
raise FileNotFoundError("stop before real spawn")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"EvoScientist.langgraph_dev.manager.subprocess.Popen", _fake_popen
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
manager.start_langgraph_dev(workspace_dir=tmp_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
assert captured["offset"] == 512
|
||||||
|
|
||||||
|
def test_offset_zero_after_forced_rotation(self, tmp_path, monkeypatch):
|
||||||
|
"""Forced rotation moves the old log away; the fresh ``open('ab')``
|
||||||
|
starts empty → offset 0 (scan the whole new file)."""
|
||||||
|
log = tmp_path / "langgraph_dev.log"
|
||||||
|
log.write_bytes(b"x" * 4096)
|
||||||
|
monkeypatch.setattr(manager, "_LOG_ROTATION_BYTES", 1024)
|
||||||
|
self._patch_prereqs(tmp_path, monkeypatch, log)
|
||||||
|
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _fake_popen(args, **kwargs):
|
||||||
|
captured["offset"] = manager._LOG_OFFSET_AT_START
|
||||||
|
raise FileNotFoundError("stop before real spawn")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"EvoScientist.langgraph_dev.manager.subprocess.Popen", _fake_popen
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
manager.start_langgraph_dev(workspace_dir=tmp_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
assert (tmp_path / "langgraph_dev.log.1").exists() # rotation happened
|
||||||
|
assert captured["offset"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# read_tunnel_url
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadTunnelUrl:
|
||||||
|
"""``read_tunnel_url`` scrapes the Cloudflare tunnel URL from the log,
|
||||||
|
scanning only bytes written after the current subprocess started."""
|
||||||
|
|
||||||
|
def test_returns_url_when_present(self, tmp_path, runtime_paths, monkeypatch):
|
||||||
|
log = tmp_path / "langgraph_dev.log"
|
||||||
|
log.write_text(
|
||||||
|
"INFO server up\n"
|
||||||
|
"[cloudflared] Your quick Tunnel has been created! Visit it at:\n"
|
||||||
|
"[cloudflared] https://happy-tiger-demo.trycloudflare.com\n"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
manager, "RUNTIME", dataclasses.replace(runtime_paths, log_file=log)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_LOG_OFFSET_AT_START", 0)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
manager.read_tunnel_url(timeout=1.0)
|
||||||
|
== "https://happy-tiger-demo.trycloudflare.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_none_on_timeout(self, tmp_path, runtime_paths, monkeypatch):
|
||||||
|
log = tmp_path / "langgraph_dev.log"
|
||||||
|
log.write_text("INFO server up — but no tunnel line ever printed\n")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
manager, "RUNTIME", dataclasses.replace(runtime_paths, log_file=log)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_LOG_OFFSET_AT_START", 0)
|
||||||
|
|
||||||
|
assert manager.read_tunnel_url(timeout=0.2, poll_interval=0.05) is None
|
||||||
|
|
||||||
|
def test_ignores_stale_url_before_offset(
|
||||||
|
self, tmp_path, runtime_paths, monkeypatch
|
||||||
|
):
|
||||||
|
"""A URL from a previous session (before the offset) must be skipped;
|
||||||
|
only this session's bytes count."""
|
||||||
|
stale = "[cloudflared] https://old-stale-url.trycloudflare.com\n"
|
||||||
|
log = tmp_path / "langgraph_dev.log"
|
||||||
|
log.write_text(stale)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
manager, "RUNTIME", dataclasses.replace(runtime_paths, log_file=log)
|
||||||
|
)
|
||||||
|
# Offset points past the stale line — nothing fresh yet → None.
|
||||||
|
monkeypatch.setattr(manager, "_LOG_OFFSET_AT_START", len(stale.encode()))
|
||||||
|
assert manager.read_tunnel_url(timeout=0.2, poll_interval=0.05) is None
|
||||||
|
|
||||||
|
# Now this session appends its own fresh URL → returned.
|
||||||
|
with open(log, "a") as fh:
|
||||||
|
fh.write("[cloudflared] https://fresh-new-url.trycloudflare.com\n")
|
||||||
|
assert (
|
||||||
|
manager.read_tunnel_url(timeout=1.0)
|
||||||
|
== "https://fresh-new-url.trycloudflare.com"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user