diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c0edf..63a42ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [1.0.0.10] - 2026-07-07 + +### Changed +- Chrome now boots on demand: LinkedIn tool calls ping the CDP endpoint before attaching and launch it via `bin/browser-service start` if it's not up, waiting for CDP to come up. Session cookies persist on disk in the Chrome profile, so launchd/systemd no longer need to keep Chrome alive between calls. +- `cron.system_status` no longer logs a startup warning when Chrome's CDP endpoint is unreachable — that's now the expected steady state — and status output/JSON drop the stale "reinstall the always-on service" restart hint in favor of noting it starts on demand. + ## [1.0.0.9] - 2026-07-05 ### Fixed diff --git a/VERSION b/VERSION index 4f2dfe0..6f1dca2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0.9 +1.0.0.10 diff --git a/cron/system_status.py b/cron/system_status.py index 656e260..97b8031 100644 --- a/cron/system_status.py +++ b/cron/system_status.py @@ -71,9 +71,6 @@ def probe_browser() -> dict[str, Any]: except (json.JSONDecodeError, TimeoutError, OSError) as exc: health_error = str(exc) - restart_hint = "bin/browser-service install" - if not managed: - restart_hint = "bin/browser-service install (or ./install.sh)" return { "url": url, "reachable": reachable, @@ -85,7 +82,7 @@ def probe_browser() -> dict[str, Any]: "service_backend": backend, "service_unit_path": str(unit_path) if unit_path else None, "auto_start_on_reboot": managed, - "restart_hint": restart_hint, + "starts_on_demand": True, } @@ -95,15 +92,14 @@ def check_services(*, logger: logging.Logger | None = None) -> dict[str, Any]: whichever is unreachable. Intended to run alongside the startup "auto update check" — no email/paging, just a log line an operator tailing server.log will see. + + Chrome now boots on demand (see outreach/browser.py:_ensure_browser_running), + so it being down at rest is the expected steady state, not a health issue — + only the cron server warrants a startup warning here. """ log = logger or logging.getLogger(__name__) browser = probe_browser() cron_server = probe_cron_server() - if not browser.get("running"): - log.warning( - "Health check: browser CDP unreachable — %s", - browser.get("health_error") or "no response", - ) if not cron_server.get("running"): log.warning( "Health check: cron server unreachable — %s", @@ -124,10 +120,8 @@ def format_browser_lines() -> list[str]: lines.append(f" profile {browser.get('profile')}") else: err = browser.get("health_error") - hint = browser.get("restart_hint", "bin/browser-service install") extra = f" — {err}" if err else "" - lines.append(f" Chrome CDP not running{extra}") - lines.append(f" restart: {hint}") + lines.append(f" Chrome CDP not running{extra} (starts on demand)") if browser.get("managed"): lines.append( f" auto-start {browser.get('service_backend')} ({browser.get('service_unit_path')})" diff --git a/outreach/browser.py b/outreach/browser.py index c3cc40f..56c80c8 100644 --- a/outreach/browser.py +++ b/outreach/browser.py @@ -537,6 +537,44 @@ def _pp_structure_activity_update(index: int, post: dict[str, Any]) -> dict[str, _ZERO_TAB_CDP_ERROR = "setDownloadBehavior" +_REPO_ROOT = Path(__file__).resolve().parent.parent +_BROWSER_SERVICE = _REPO_ROOT / "bin" / "browser-service" + + +def _cdp_reachable(cdp_url: str, timeout: float = 2.0) -> bool: + try: + urllib.request.urlopen(f"{cdp_url}/json/version", timeout=timeout).close() + return True + except Exception: + return False + + +async def _ensure_browser_running(cdp_url: str) -> None: + """ + Boot Chrome on demand via bin/browser-service if the CDP endpoint isn't + reachable — so nothing needs to keep it running between tool calls. + browser-service already waits for CDP to come up before returning. + """ + if await asyncio.to_thread(_cdp_reachable, cdp_url): + return + if not _BROWSER_SERVICE.is_file(): + return + logger.info("Chrome not reachable at %s — starting it on demand", cdp_url) + env = dict(os.environ) + port = urlparse(cdp_url).port + if port: + env["CDP_PORT"] = str(port) + proc = await asyncio.create_subprocess_exec( + str(_BROWSER_SERVICE), + "start", + cwd=str(_REPO_ROOT), + env=env, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + def _open_blank_tab(cdp_url: str) -> None: """Ask Chrome's CDP HTTP endpoint to open a tab (bypasses Playwright context creation). @@ -694,6 +732,7 @@ async def _attach(self) -> None: We never close a tab we didn't open, so the user's browsing is undisturbed. """ + await _ensure_browser_running(self.cdp_url) self._browser = await self._connect_with_zero_tab_retry() self._is_attached = True diff --git a/pyproject.toml b/pyproject.toml index 896e795..7dc2159 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ebase" -version = "1.0.0.9" +version = "1.0.0.10" description = "LinkedIn recruiting outreach that runs inside Claude Code" readme = "README.md" license = "MIT" diff --git a/testing/tests/test_system_status.py b/testing/tests/test_system_status.py index 8257562..8f2bd6f 100644 --- a/testing/tests/test_system_status.py +++ b/testing/tests/test_system_status.py @@ -13,7 +13,7 @@ def test_probe_browser_unreachable() -> None: browser = ss.probe_browser() assert "reachable" in browser assert "managed" in browser - assert "restart_hint" in browser + assert "starts_on_demand" in browser def test_format_browser_lines() -> None: @@ -31,11 +31,16 @@ def test_check_services_returns_both_probes() -> None: def test_check_services_logs_warning_for_unreachable( caplog: pytest.LogCaptureFixture, ) -> None: + # Chrome boots on demand now, so being down is expected and shouldn't warn — + # only the cron server's health is worth a startup warning here. with caplog.at_level(logging.WARNING): result = ss.check_services() - for name, key in (("browser", "browser"), ("cron_server", "cron server")): - if not result[name].get("running"): - assert any( - record.levelno == logging.WARNING and key in record.getMessage().lower() - for record in caplog.records - ), f"expected a warning logged for unreachable {name}" + assert not any( + record.levelno == logging.WARNING and "browser" in record.getMessage().lower() + for record in caplog.records + ) + if not result["cron_server"].get("running"): + assert any( + record.levelno == logging.WARNING and "cron server" in record.getMessage().lower() + for record in caplog.records + ), "expected a warning logged for unreachable cron server" diff --git a/uv.lock b/uv.lock index 0cf0a0d..0b2baa4 100644 --- a/uv.lock +++ b/uv.lock @@ -245,7 +245,7 @@ wheels = [ [[package]] name = "ebase" -version = "1.0.0.9" +version = "1.0.0.10" source = { virtual = "." } dependencies = [ { name = "certifi" },