From 4673492301029d3a7d66a5d27ff90a39f6d4960a Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sat, 4 Jul 2026 12:04:26 -0700 Subject: [PATCH 1/3] add system check --- bin/outreach-update-check | 20 +++- outreach/skills/outreach-upgrade/SKILL.md | 14 ++- .../skills/send-connection-request/SKILL.md | 11 +- outreach/skills/setup-outreach/SKILL.md | 13 ++- testing/tests/test_system_status.py | 7 ++ tools/server.py | 104 ++++++++++-------- 6 files changed, 104 insertions(+), 65 deletions(-) diff --git a/bin/outreach-update-check b/bin/outreach-update-check index 5893f3b..e870fe4 100755 --- a/bin/outreach-update-check +++ b/bin/outreach-update-check @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# outreach-update-check — periodic version check for outreach skills. +# outreach-update-check — periodic system check for outreach skills: +# version check + cron/browser service health. # -# Output (one line, or nothing): +# Output (zero or more lines): +# SERVICE_DOWN (browser or cron; printed first, non-blocking) # UPGRADE_AVAILABLE # UPGRADED # JUST_UPGRADED @@ -12,6 +14,20 @@ # --apply run upgrade when an update is available set -euo pipefail +CDP_PORT="${CDP_PORT:-9222}" +WEB_HOST="${WEB_HOST:-127.0.0.1}" +WEB_PORT="${WEB_PORT:-3847}" + +check_services() { + command -v curl >/dev/null 2>&1 || return 0 + local browser_url="http://127.0.0.1:${CDP_PORT}/json/version" + local cron_url="http://${WEB_HOST}:${WEB_PORT}/health" + curl -sf "$browser_url" >/dev/null 2>&1 || echo "SERVICE_DOWN browser $browser_url" + curl -sf "$cron_url" >/dev/null 2>&1 || echo "SERVICE_DOWN cron $cron_url" +} + +check_services + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEFAULT_REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" STATE_DIR="${OUTREACH_STATE_DIR:-$HOME/.ebase}" diff --git a/outreach/skills/outreach-upgrade/SKILL.md b/outreach/skills/outreach-upgrade/SKILL.md index 867129a..eec45c5 100644 --- a/outreach/skills/outreach-upgrade/SKILL.md +++ b/outreach/skills/outreach-upgrade/SKILL.md @@ -15,7 +15,7 @@ Voice triggers: "upgrade outreach", "update linkedin outreach", "get latest vers --- -## Inline upgrade flow (referenced by setup-outreach, send-connection-request) +## Inline system check flow (referenced by setup-outreach, send-connection-request) Run at the **start** of those skills (before any outreach work): @@ -23,16 +23,19 @@ Run at the **start** of those skills (before any outreach work): bin/outreach-update-check 2>/dev/null || true ``` -Parse the **one-line** output: +This can print **zero or more** lines — a `SERVICE_DOWN` line per unreachable +service, then at most one version line. Parse each: | Output | Action | |--------|--------| +| `SERVICE_DOWN ` | Tell the user: "{service} is unreachable ({url})." Suggest `bin/browser-service start` or `bin/cron-service start` accordingly. Do **not** ask a question — inform and continue. | | `UPGRADE_AVAILABLE ` | Follow **Step 1** below (always ask the user) | | `UPGRADED ` | Log success, continue with the invoking skill | | `JUST_UPGRADED ` | Log success, continue with the invoking skill | | `UP_TO_DATE ` or empty | Continue silently | -Network or git failures produce no output — **do not block** the invoking skill. +Network or git failures produce no version output — **do not block** the invoking skill. +`SERVICE_DOWN` is informational only and never blocks either. ### Step 1: Ask the user @@ -99,8 +102,9 @@ Resume the skill the user originally invoked (setup-outreach, send-connection-re ```bash bin/outreach-update-check --force 2>/dev/null || true ``` -2. If `UPGRADE_AVAILABLE `: follow Steps 1–2. -3. If no output: tell the user they're on the latest version (read `VERSION` in the repo root). +2. If any `SERVICE_DOWN ` lines print, tell the user (informational, non-blocking). +3. If `UPGRADE_AVAILABLE `: follow Steps 1–2. +4. If no version line: tell the user they're on the latest version (read `VERSION` in the repo root). --- diff --git a/outreach/skills/send-connection-request/SKILL.md b/outreach/skills/send-connection-request/SKILL.md index 382b825..f7a7633 100644 --- a/outreach/skills/send-connection-request/SKILL.md +++ b/outreach/skills/send-connection-request/SKILL.md @@ -36,17 +36,18 @@ If `mcp__linkedin__*` tools are not registered in the current session, **stop an operator the LinkedIn MCP is not registered** (fix: run `./install.sh` or `make claude-install`). Do **not** pick up a different browser tool as a fallback. -## Update check (run first) +## System check (run first) -Before connecting, check for a newer ebase version: +Before connecting, check service health and for a newer ebase version: ```bash bin/outreach-update-check 2>/dev/null || true ``` -If output is `UPGRADE_AVAILABLE `, follow the inline flow in skill -**`outreach-upgrade`** (ask to upgrade). On -`UPGRADED`, `JUST_UPGRADED`, `UP_TO_DATE`, or empty output, continue below. +Follow the inline flow in skill **`outreach-upgrade`** for every line printed: +`SERVICE_DOWN ` (inform the user, non-blocking), then +`UPGRADE_AVAILABLE` (ask to upgrade), `UPGRADED`/`JUST_UPGRADED` (log and +continue), or `UP_TO_DATE`/empty (continue silently). Do not block on network failures. Scrape a LinkedIn profile, then immediately send a connection request — no confirmation step needed. diff --git a/outreach/skills/setup-outreach/SKILL.md b/outreach/skills/setup-outreach/SKILL.md index 01ca88a..4ae98d7 100644 --- a/outreach/skills/setup-outreach/SKILL.md +++ b/outreach/skills/setup-outreach/SKILL.md @@ -45,17 +45,18 @@ If `mcp__linkedin__*` tools are not registered in the current session, **stop an operator the LinkedIn MCP is not registered** (fix: run `./install.sh` or `make claude-install`). Do **not** pick up a different browser tool as a fallback. -## Update check (run first) +## System check (run first) -Before setup work, check for a newer ebase version: +Before setup work, check service health and for a newer ebase version: ```bash bin/outreach-update-check 2>/dev/null || true ``` -If output is `UPGRADE_AVAILABLE `, follow the inline flow in skill -**`outreach-upgrade`** (ask to upgrade). On -`UPGRADED`, `JUST_UPGRADED`, `UP_TO_DATE`, or empty output, continue below. +Follow the inline flow in skill **`outreach-upgrade`** for every line printed: +`SERVICE_DOWN ` (inform the user, non-blocking), then +`UPGRADE_AVAILABLE` (ask to upgrade), `UPGRADED`/`JUST_UPGRADED` (log and +continue), or `UP_TO_DATE`/empty (continue silently). Do not block on network failures. **Filesystem rule:** Never read or write `outreach/config/` via raw paths or shell. Use MCP **`get_conversation_planner_config`**, **`get_style_example_prompts`**, **`merge_conversation_planner_identity`**, and **`upsert_conversation_planner_config`** only. @@ -318,7 +319,7 @@ Mark all checklist items done. | **`get_conversation_planner_config`** | Read merged config (persona + planner) | | **`get_style_example_prompts`** | Tone + style-example questionnaire (steps 3b–3c) | | **`upsert_conversation_planner_config`** | Campaign + tone + style examples (step 3); writes the full planner JSON minus persona/organization | -| **`outreach-upgrade`** (skill) | Version check + optional git pull when `UPGRADE_AVAILABLE` (run at skill start) | +| **`outreach-upgrade`** (skill) | System check: service health + version check, optional git pull when `UPGRADE_AVAILABLE` (run at skill start) | For a standalone LinkedIn-only identity refresh (no wizard), use **`sync-planner-persona-from-linkedin`** (`parse_profile`-first). diff --git a/testing/tests/test_system_status.py b/testing/tests/test_system_status.py index fd23dc8..bd46e50 100644 --- a/testing/tests/test_system_status.py +++ b/testing/tests/test_system_status.py @@ -15,3 +15,10 @@ def test_probe_browser_unreachable() -> None: def test_format_browser_lines() -> None: lines = ss.format_browser_lines() assert any("Chrome CDP" in line for line in lines) + + +def test_check_services_returns_both_probes() -> None: + result = ss.check_services() + assert "browser" in result and "cron_server" in result + assert "reachable" in result["browser"] + assert "reachable" in result["cron_server"] diff --git a/tools/server.py b/tools/server.py index a3245bc..a7beb8f 100644 --- a/tools/server.py +++ b/tools/server.py @@ -85,68 +85,78 @@ ) logger = logging.getLogger("linkedin.server") -# ── Background upgrade check ───────────────────────────────────────────────── +# ── Background system check ────────────────────────────────────────────────── # Non-blocking: fires a daemon thread that shells out to bin/outreach-update-check -# and logs the result. Never blocks mcp.run(); dies with the process. +# and probes the cron/browser services, logging results. Never blocks mcp.run(); +# dies with the process. -_upgrade_info: dict = {} +_system_status: dict = {"upgrade": {}, "service_health": {}} -def _run_upgrade_check() -> None: - """Shell out to bin/outreach-update-check and log the result.""" +def _run_system_check() -> None: + """Run the version-upgrade check and the cron/browser health probes, logging results.""" import subprocess check_bin = _ROOT / "bin" / "outreach-update-check" - if not check_bin.is_file(): - return + if check_bin.is_file(): + try: + result = subprocess.run( + [str(check_bin)], + capture_output=True, + text=True, + timeout=15, + cwd=str(_ROOT), + env={**__import__("os").environ, "OUTREACH_REPO_ROOT": str(_ROOT)}, + ) + line = (result.stdout or "").strip() + if line: + parts = line.split() + status = parts[0] if parts else "" + + if status == "UPGRADE_AVAILABLE" and len(parts) >= 3: + old, new = parts[1], parts[2] + _system_status["upgrade"].update( + status="upgrade_available", old=old, new=new + ) + logger.warning( + "ebase v%s available (current: v%s). " + "Run /outreach-upgrade or make upgrade.", + new, + old, + ) + elif status == "JUST_UPGRADED" and len(parts) >= 3: + old, new = parts[1], parts[2] + _system_status["upgrade"].update( + status="just_upgraded", old=old, new=new + ) + logger.info( + "ebase upgraded from v%s to v%s.", + old, + new, + ) + elif status == "UP_TO_DATE" and len(parts) >= 2: + _system_status["upgrade"].update( + status="up_to_date", version=parts[1] + ) + else: + _system_status["upgrade"].update(status="unknown", raw=line) + except subprocess.TimeoutExpired: + logger.debug("Upgrade check timed out (15s) — skipping.") + except Exception: + logger.debug("Upgrade check failed — skipping.", exc_info=True) try: - result = subprocess.run( - [str(check_bin)], - capture_output=True, - text=True, - timeout=15, - cwd=str(_ROOT), - env={**__import__("os").environ, "OUTREACH_REPO_ROOT": str(_ROOT)}, - ) - line = (result.stdout or "").strip() - if not line: - return + from cron.system_status import check_services - parts = line.split() - status = parts[0] if parts else "" - - if status == "UPGRADE_AVAILABLE" and len(parts) >= 3: - old, new = parts[1], parts[2] - _upgrade_info.update(status="upgrade_available", old=old, new=new) - logger.warning( - "ebase v%s available (current: v%s). " - "Run /outreach-upgrade or make upgrade.", - new, - old, - ) - elif status == "JUST_UPGRADED" and len(parts) >= 3: - old, new = parts[1], parts[2] - _upgrade_info.update(status="just_upgraded", old=old, new=new) - logger.info( - "ebase upgraded from v%s to v%s.", - old, - new, - ) - elif status == "UP_TO_DATE" and len(parts) >= 2: - _upgrade_info.update(status="up_to_date", version=parts[1]) - else: - _upgrade_info.update(status="unknown", raw=line) - except subprocess.TimeoutExpired: - logger.debug("Upgrade check timed out (15s) — skipping.") + _system_status["service_health"].update(check_services(logger=logger)) except Exception: - logger.debug("Upgrade check failed — skipping.", exc_info=True) + logger.debug("Service health check failed — skipping.", exc_info=True) import threading as _threading -_upgrade_thread = _threading.Thread(target=_run_upgrade_check, daemon=True) -_upgrade_thread.start() +_system_check_thread = _threading.Thread(target=_run_system_check, daemon=True) +_system_check_thread.start() # ── MCP imports ────────────────────────────────────────────────────────────── From 12ba9646a651727103e325b3b89ebee3401369f0 Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sat, 4 Jul 2026 12:04:45 -0700 Subject: [PATCH 2/3] version --- CHANGELOG.md | 9 +++++++++ VERSION | 2 +- cron/system_status.py | 26 +++++++++++++++++++++++++- pyproject.toml | 2 +- uv.lock | 2 +- 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f30d30..ee8168c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [1.0.0.6] - 2026-07-04 + +### Added +- `cron.system_status.check_services()` — probes cron + browser health, logging a warning for either that's unreachable +- `bin/outreach-update-check` now also probes cron/browser health and prints `SERVICE_DOWN ` lines; `outreach-upgrade`, `setup-outreach`, and `send-connection-request` skills surface these to the operator at skill start (non-blocking, no email) + +### Changed +- MCP server's background startup thread renamed `_run_upgrade_check` → `_run_system_check`; now runs the version check and the service health probes together, tracked in a single `_system_status` dict + ## [1.0.0.5] - 2026-07-04 ### Fixed diff --git a/VERSION b/VERSION index 6f30e0c..b658d66 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0.5 +1.0.0.6 diff --git a/cron/system_status.py b/cron/system_status.py index d002183..656e260 100644 --- a/cron/system_status.py +++ b/cron/system_status.py @@ -5,6 +5,7 @@ from __future__ import annotations import json +import logging import os import platform import sys @@ -13,7 +14,7 @@ from pathlib import Path from typing import Any -from cron.status_report import build_cron_status, format_sweep_lines +from cron.status_report import build_cron_status, format_sweep_lines, probe_cron_server _BROWSER_LAUNCHD_LABEL = "com.embeddingvc.ebase.browser" _BROWSER_SYSTEMD_UNIT = "ebase-browser.service" @@ -88,6 +89,29 @@ def probe_browser() -> dict[str, Any]: } +def check_services(*, logger: logging.Logger | None = None) -> dict[str, Any]: + """ + Probe the cron server and browser CDP endpoint, logging a warning for + 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. + """ + 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", + cron_server.get("health_error") or "no response", + ) + return {"browser": browser, "cron_server": cron_server} + + def format_browser_lines() -> list[str]: browser = probe_browser() lines: list[str] = [] diff --git a/pyproject.toml b/pyproject.toml index 0569133..3eb6022 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ebase" -version = "1.0.0.5" +version = "1.0.0.6" description = "LinkedIn recruiting outreach that runs inside Claude Code" readme = "README.md" license = "MIT" diff --git a/uv.lock b/uv.lock index 593550b..36fca5d 100644 --- a/uv.lock +++ b/uv.lock @@ -245,7 +245,7 @@ wheels = [ [[package]] name = "ebase" -version = "1.0.0.5" +version = "1.0.0.6" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 91f5cf7af10d22c5f7e426bf3e31c1738d175d5a Mon Sep 17 00:00:00 2001 From: Ruoqi Huang Date: Sat, 4 Jul 2026 15:01:21 -0700 Subject: [PATCH 3/3] Add curl timeout and test coverage for service health check curl calls in check_services() had no timeout, so an unreachable-but-not- refusing host could hang the "non-blocking" health check. Also adds coverage for the warning-log path and the bash SERVICE_DOWN output, both previously untested. Co-Authored-By: Claude Sonnet 5 --- bin/outreach-update-check | 4 +- testing/tests/test_outreach_update_check.py | 64 +++++++++++++++++++++ testing/tests/test_system_status.py | 17 ++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 testing/tests/test_outreach_update_check.py diff --git a/bin/outreach-update-check b/bin/outreach-update-check index e870fe4..5736194 100755 --- a/bin/outreach-update-check +++ b/bin/outreach-update-check @@ -22,8 +22,8 @@ check_services() { command -v curl >/dev/null 2>&1 || return 0 local browser_url="http://127.0.0.1:${CDP_PORT}/json/version" local cron_url="http://${WEB_HOST}:${WEB_PORT}/health" - curl -sf "$browser_url" >/dev/null 2>&1 || echo "SERVICE_DOWN browser $browser_url" - curl -sf "$cron_url" >/dev/null 2>&1 || echo "SERVICE_DOWN cron $cron_url" + curl -sf --max-time 2 "$browser_url" >/dev/null 2>&1 || echo "SERVICE_DOWN browser $browser_url" + curl -sf --max-time 2 "$cron_url" >/dev/null 2>&1 || echo "SERVICE_DOWN cron $cron_url" } check_services diff --git a/testing/tests/test_outreach_update_check.py b/testing/tests/test_outreach_update_check.py new file mode 100644 index 0000000..7c5141c --- /dev/null +++ b/testing/tests/test_outreach_update_check.py @@ -0,0 +1,64 @@ +"""Tests for bin/outreach-update-check's SERVICE_DOWN health probe.""" + +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path + +CORE_ROOT = Path(__file__).resolve().parent.parent.parent +SCRIPT = CORE_ROOT / "bin" / "outreach-update-check" + + +def _fake_curl(tmp_path: Path, *, exit_code: int) -> Path: + """Write a stub `curl` on PATH that always exits with `exit_code`.""" + bin_dir = tmp_path / "fakebin" + bin_dir.mkdir() + curl = bin_dir / "curl" + curl.write_text(f"#!/usr/bin/env bash\nexit {exit_code}\n") + curl.chmod(curl.stat().st_mode | stat.S_IEXEC) + return bin_dir + + +def test_service_down_printed_when_curl_fails(tmp_path: Path) -> None: + fake_bin = _fake_curl(tmp_path, exit_code=1) + empty_repo = tmp_path / "repo" + empty_repo.mkdir() + + result = subprocess.run( + [str(SCRIPT)], + capture_output=True, + text=True, + timeout=10, + env={ + **os.environ, + "PATH": f"{fake_bin}:{os.environ.get('PATH', '')}", + "OUTREACH_REPO_ROOT": str(empty_repo), + "OUTREACH_STATE_DIR": str(tmp_path / "state"), + }, + ) + + assert "SERVICE_DOWN browser" in result.stdout + assert "SERVICE_DOWN cron" in result.stdout + + +def test_no_service_down_when_curl_succeeds(tmp_path: Path) -> None: + fake_bin = _fake_curl(tmp_path, exit_code=0) + empty_repo = tmp_path / "repo" + empty_repo.mkdir() + + result = subprocess.run( + [str(SCRIPT)], + capture_output=True, + text=True, + timeout=10, + env={ + **os.environ, + "PATH": f"{fake_bin}:{os.environ.get('PATH', '')}", + "OUTREACH_REPO_ROOT": str(empty_repo), + "OUTREACH_STATE_DIR": str(tmp_path / "state"), + }, + ) + + assert "SERVICE_DOWN" not in result.stdout diff --git a/testing/tests/test_system_status.py b/testing/tests/test_system_status.py index bd46e50..8257562 100644 --- a/testing/tests/test_system_status.py +++ b/testing/tests/test_system_status.py @@ -2,6 +2,10 @@ from __future__ import annotations +import logging + +import pytest + from cron import system_status as ss @@ -22,3 +26,16 @@ def test_check_services_returns_both_probes() -> None: assert "browser" in result and "cron_server" in result assert "reachable" in result["browser"] assert "reachable" in result["cron_server"] + + +def test_check_services_logs_warning_for_unreachable( + caplog: pytest.LogCaptureFixture, +) -> None: + 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}"