feat(tunnel): add Cloudflare tunnel support for EvoSci deploy and update documentation

This commit is contained in:
X-iZhang
2026-06-11 00:23:55 +01:00
parent 49f23560fd
commit 526c571b10
7 changed files with 297 additions and 3 deletions
+35
View File
@@ -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"
+63
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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 渠道路由修复。
+30 -1
View File
@@ -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"
+27
View File
@@ -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"]
+140
View File
@@ -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"
)