Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <service> <url>` 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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0.5
1.0.0.6
20 changes: 18 additions & 2 deletions bin/outreach-update-check
Original file line number Diff line number Diff line change
@@ -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 <service> <url> (browser or cron; printed first, non-blocking)
# UPGRADE_AVAILABLE <local> <remote>
# UPGRADED <old> <remote>
# JUST_UPGRADED <old> <local>
Expand All @@ -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 --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

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
STATE_DIR="${OUTREACH_STATE_DIR:-$HOME/.ebase}"
Expand Down
26 changes: 25 additions & 1 deletion cron/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import annotations

import json
import logging
import os
import platform
import sys
Expand All @@ -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"
Expand Down Expand Up @@ -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] = []
Expand Down
14 changes: 9 additions & 5 deletions outreach/skills/outreach-upgrade/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,27 @@ 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):

```bash
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 <service> <url>` | 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 <old> <new>` | Follow **Step 1** below (always ask the user) |
| `UPGRADED <old> <new>` | Log success, continue with the invoking skill |
| `JUST_UPGRADED <old> <new>` | Log success, continue with the invoking skill |
| `UP_TO_DATE <ver>` 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

Expand Down Expand Up @@ -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 <old> <new>`: 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 <service> <url>` lines print, tell the user (informational, non-blocking).
3. If `UPGRADE_AVAILABLE <old> <new>`: follow Steps 1–2.
4. If no version line: tell the user they're on the latest version (read `VERSION` in the repo root).

---

Expand Down
11 changes: 6 additions & 5 deletions outreach/skills/send-connection-request/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <old> <new>`, 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 <service> <url>` (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.
Expand Down
13 changes: 7 additions & 6 deletions outreach/skills/setup-outreach/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <old> <new>`, 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 <service> <url>` (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.
Expand Down Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
64 changes: 64 additions & 0 deletions testing/tests/test_outreach_update_check.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions testing/tests/test_system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

from __future__ import annotations

import logging

import pytest

from cron import system_status as ss


Expand All @@ -15,3 +19,23 @@ 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"]


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}"
Loading
Loading