253 lines
13 KiB
Python
253 lines
13 KiB
Python
"""`harbor doctor`: is the project healthy, and is every agent's app really connected?
|
|
|
|
Each check is {area, check, status (ok|warn|fail|info), detail, fix}. Host checks read the host configuration files
|
|
that `harbor install` writes; "live" checks use what Harbor recorded when agents last reached it.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import tomllib
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from . import install
|
|
|
|
|
|
def _check(area, check, status, detail="", fix=""):
|
|
return {"area": area, "check": check, "status": status, "detail": detail, "fix": fix}
|
|
|
|
|
|
def _text(path):
|
|
try:
|
|
return Path(path).read_text(encoding="utf-8")
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _block(text, start=install.START, end=install.END):
|
|
return bool(text) and start in text and end in text
|
|
|
|
|
|
def codex_checks(agent):
|
|
home = install.user_home() / ".codex"
|
|
out = []
|
|
config = _text(home / "config.toml")
|
|
server = None
|
|
try:
|
|
server = tomllib.loads(config or "").get("mcp_servers", {}).get("harbor")
|
|
except tomllib.TOMLDecodeError as error:
|
|
out.append(_check("Codex", "config.toml parses", "fail", str(error), "fix the TOML by hand"))
|
|
if server and server.get("env", {}).get("HARBOR_AGENT") == agent:
|
|
out.append(_check("Codex", "MCP server", "ok", f"[mcp_servers.harbor] as {agent}"))
|
|
else:
|
|
out.append(_check("Codex", "MCP server", "fail", "missing or for another agent", "harbor install codex"))
|
|
hooks = _text(home / "hooks.json")
|
|
events = set()
|
|
if hooks:
|
|
try:
|
|
events = {e for e, entries in json.loads(hooks).get("hooks", {}).items()
|
|
if any(install._is_ours(x) for x in entries)}
|
|
except ValueError:
|
|
pass
|
|
wanted = {"SessionStart", "UserPromptSubmit", "PreToolUse", "Stop"}
|
|
out.append(_check("Codex", "hooks", "ok" if wanted <= events else "fail",
|
|
", ".join(sorted(events)) or "none", "" if wanted <= events else "harbor install codex"))
|
|
trusted = len(re.findall(r"trusted_hash", config or ""))
|
|
out.append(_check("Codex", "hooks trusted", "ok" if trusted else "warn",
|
|
f"{trusted} trusted hook entr{'y' if trusted == 1 else 'ies'} in config.toml",
|
|
"" if trusted else "Codex: Settings > Hooks > trust the Harbor hooks"))
|
|
out.append(_check("Codex", "AGENTS.md", "ok" if _block(_text(home / "AGENTS.md"), install.MD_START, install.MD_END)
|
|
else "warn", str(home / "AGENTS.md"), "harbor install codex"))
|
|
return out
|
|
|
|
|
|
def opencode_checks(agent):
|
|
home = install.user_home() / ".config" / "opencode"
|
|
out = []
|
|
config = next((p for p in (home / "opencode.jsonc", home / "opencode.json") if p.exists()), None)
|
|
try:
|
|
server = json.loads(_text(config) or "{}").get("mcp", {}).get("harbor") if config else None
|
|
except ValueError:
|
|
server = None
|
|
ok = bool(server) and server.get("environment", {}).get("HARBOR_AGENT") == agent and server.get("enabled", True)
|
|
out.append(_check("OpenCode", "MCP server", "ok" if ok else "fail", str(config or home), "" if ok else
|
|
"harbor install opencode"))
|
|
plugin = _text(home / "plugin" / "harbor.js")
|
|
ok = bool(plugin) and f'const AGENT = "{agent}";' in plugin
|
|
out.append(_check("OpenCode", "plugin", "ok" if ok else "fail", str(home / "plugin" / "harbor.js"),
|
|
"" if ok else "harbor install opencode"))
|
|
out.append(_check("OpenCode", "AGENTS.md", "ok" if _block(_text(home / "AGENTS.md"), install.MD_START,
|
|
install.MD_END) else "warn", "", "harbor install opencode"))
|
|
return out
|
|
|
|
|
|
def deepseek_checks(agent):
|
|
from . import projects
|
|
home = install.user_home() / ".dsh"
|
|
out = []
|
|
patch = _text(home / "profiles" / "desktop" / "cordis.patch.yml")
|
|
rows = []
|
|
if patch:
|
|
try:
|
|
for row in install._TolerantLoader.load(patch):
|
|
if isinstance(row, dict) and "insert" in row:
|
|
rows += [r for r in row["insert"] if isinstance(r, dict)]
|
|
except Exception as error: # noqa: BLE001 - report any YAML problem
|
|
out.append(_check("DeepSeek", "cordis.patch.yml parses", "fail", str(error), "restore it from the backup"))
|
|
mcp = next((r for r in rows if r.get("id") == "mcp-harbor"), None)
|
|
ok = bool(mcp) and (mcp.get("config") or {}).get("env", {}).get("HARBOR_AGENT") == agent
|
|
out.append(_check("DeepSeek", "MCP row", "ok" if ok else "fail", "mcp-harbor", "" if ok else "harbor install deepseek"))
|
|
hooks_row = next((r for r in rows if r.get("id") == "hooks-harbor"), None)
|
|
hooks_file = projects.harbor_home() / "integrations" / "deepseek" / "hooks.json"
|
|
ok = bool(hooks_row) and hooks_file.exists()
|
|
out.append(_check("DeepSeek", "hooks row and file", "ok" if ok else "fail", str(hooks_file),
|
|
"" if ok else "harbor install deepseek"))
|
|
out.append(_check("DeepSeek", "AGENTS.md", "ok" if _block(_text(home / "AGENTS.md"), install.MD_START, install.MD_END)
|
|
else "warn", "", "harbor install deepseek"))
|
|
return out
|
|
|
|
|
|
def kimi_checks(agent, project):
|
|
from . import projects
|
|
out = []
|
|
name = f"harbor-{project.name}"
|
|
folder = projects.harbor_home() / "integrations" / "kimi" / name
|
|
manifest = _text(folder / "kimi.plugin.json")
|
|
ok = bool(manifest) and f'"HARBOR_AGENT": "{agent}"' in manifest
|
|
out.append(_check("Kimi", "plugin folder", "ok" if ok else "fail", str(folder),
|
|
"" if ok else f"harbor --project {project.name} install kimi"))
|
|
share = install.kimi_share() / "daimon"
|
|
registered = (share / "plugin-market" / "personal" / f"{name}.json").exists()
|
|
out.append(_check("Kimi", "registered", "ok" if registered else "fail", "Personal market",
|
|
"" if registered else f"harbor --project {project.name} install kimi"))
|
|
installed, enabled = False, False
|
|
record = share / "runtime" / "kimi-code" / "home" / "plugins" / "installed.json"
|
|
try:
|
|
def walk(node):
|
|
if isinstance(node, dict):
|
|
if node.get("id") == name:
|
|
yield node
|
|
for value in node.values():
|
|
yield from walk(value)
|
|
elif isinstance(node, list):
|
|
for value in node:
|
|
yield from walk(value)
|
|
entry = next(walk(json.loads(_text(record) or "{}")), None)
|
|
installed, enabled = entry is not None, bool(entry and entry.get("enabled", True))
|
|
except ValueError:
|
|
pass
|
|
installed = installed or (share / "plugin-packages" / name).exists()
|
|
out.append(_check("Kimi", "installed", "ok" if installed and enabled else "warn",
|
|
f"{name} " + ("enabled" if enabled else "installed but disabled" if installed else "not installed"),
|
|
"" if installed and enabled else "Kimi: sidebar Plugins > Personal > install (and enable) Harbor"))
|
|
return out
|
|
|
|
|
|
def running_mcp_servers():
|
|
"""{agent: count} of Harbor MCP server processes running right now (started by the agents' apps)."""
|
|
import subprocess
|
|
try:
|
|
out = subprocess.run(["powershell", "-NoProfile", "-Command",
|
|
"Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | "
|
|
"ForEach-Object { $_.CommandLine }"], capture_output=True, text=True, timeout=20).stdout
|
|
except (OSError, subprocess.SubprocessError):
|
|
return {}
|
|
found = {}
|
|
for line in out.splitlines():
|
|
m = re.search(r"(?:run|harbor_mcp|harbor)\.py.*?--as\s+(\w+)\s+mcp\b", line) or (
|
|
re.search(r"harbor_mcp\.py", line) and None)
|
|
if m:
|
|
found[m.group(1)] = found.get(m.group(1), 0) + 1
|
|
return found
|
|
|
|
|
|
HOST_CHECKS = {"codex": codex_checks, "opencode": opencode_checks, "deepseek": deepseek_checks}
|
|
|
|
|
|
def server_check(project, port, server_known):
|
|
url = f"http://127.0.0.1:{port}"
|
|
if server_known:
|
|
return _check("Server", url, "ok", "this page is served by it")
|
|
try:
|
|
with urllib.request.urlopen(f"{url}/api/projects", timeout=2) as response:
|
|
names = [p["name"] for p in json.loads(response.read())]
|
|
except OSError:
|
|
return _check("Server", url, "warn", "not running (agents still work)", "start it: harbor serve, or Chat.cmd")
|
|
except (ValueError, KeyError, TypeError):
|
|
return _check("Server", url, "warn", "something else answers on this port", "free the port or pick another")
|
|
ok = project.name in names
|
|
return _check("Server", url, "ok" if ok else "warn", "serving " + ", ".join(names), "" if ok else "restart the server")
|
|
|
|
|
|
def run(project, harbor, port=None, server_known=False):
|
|
"""server_known: the caller is the server itself (asking it over HTTP would wait on its own lock)."""
|
|
port = port or int(os.environ.get("HARBOR_PORT", "8766"))
|
|
checks = [_check("Project", "file", "ok", str(project.source)),
|
|
_check("Project", "roster", "ok", ", ".join(project.names))]
|
|
try:
|
|
quick = harbor.conn.execute("PRAGMA quick_check").fetchone()[0]
|
|
count = harbor.conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
|
|
checks.append(_check("Database", "integrity", "ok" if quick == "ok" else "fail",
|
|
f"{project.db} ({count} messages): {quick}", "" if quick == "ok" else "restore a backup"))
|
|
except Exception as error: # noqa: BLE001
|
|
checks.append(_check("Database", "integrity", "fail", str(error), "restore a backup"))
|
|
backups = sorted(project.backups.glob("harbor-*.db")) if project.backups.exists() else []
|
|
if backups:
|
|
age = (harbor.clock() - backups[-1].stat().st_mtime) / 3600
|
|
checks.append(_check("Database", "backups", "ok" if age < 24 else "warn",
|
|
f"{len(backups)} copies, newest {age:.1f} h old", "" if age < 24 else "harbor backup"))
|
|
else:
|
|
checks.append(_check("Database", "backups", "warn", "none yet", "harbor backup (the server also makes them)"))
|
|
if project.mirror:
|
|
try:
|
|
with open(project.mirror, "ab"):
|
|
pass
|
|
checks.append(_check("Mirror", "writable", "ok", str(project.mirror)))
|
|
except OSError as error:
|
|
checks.append(_check("Mirror", "writable", "fail", str(error), "check the file's permissions"))
|
|
checks.append(server_check(project, port, server_known))
|
|
links = harbor.connections()
|
|
servers = running_mcp_servers()
|
|
installed = {} # app -> the agent its Harbor install is for (the first one)
|
|
for spec in project.agents:
|
|
if spec["kind"] != "human" and spec.get("host"):
|
|
installed.setdefault(spec["host"], spec["name"])
|
|
for spec in project.agents:
|
|
if spec["kind"] == "human":
|
|
continue
|
|
host = spec.get("host")
|
|
as_agent = installed.get(host, spec["name"])
|
|
if host in HOST_CHECKS:
|
|
checks += HOST_CHECKS[host](as_agent)
|
|
elif host == "kimi":
|
|
checks += kimi_checks(as_agent, project)
|
|
if as_agent != spec["name"]: # a second session of the same app: its folder names it
|
|
ok = bool(spec.get("workdir")) and project.identity_for(spec["workdir"]) == spec["name"]
|
|
checks.append(_check(spec["name"], "session identity", "ok" if ok else "fail",
|
|
f"shares the {host} install with {as_agent}; its folder {spec.get('workdir') or '?'} "
|
|
+ ("names it in [identities]" if ok else "is not in [identities]"),
|
|
"" if ok else "map its worktree folder to it in [identities], or have it call i_am"))
|
|
seen = {(r["channel"], r["host"]): r for r in links.get(spec["name"], [])}
|
|
live = ", ".join(f"{ch}{('/' + h) if h else ''} {r['minutes']} min ago" for (ch, h), r in sorted(seen.items()))
|
|
running = servers.get(spec["name"], 0)
|
|
if running:
|
|
live = f"Harbor tools running in its app now ({running} process{'es' if running > 1 else ''})" + (
|
|
f"; {live}" if live else "")
|
|
status = "ok" if running or any(ch in ("mcp", "hook") for ch, _ in seen) else "info"
|
|
checks.append(_check(spec["name"], "live connection", status, live or "not seen through MCP or hooks yet",
|
|
"" if status == "ok" else "shows once the app starts a session with Harbor installed"))
|
|
return checks
|
|
|
|
|
|
def render(checks):
|
|
mark = {"ok": "OK ", "warn": "WARN", "fail": "FAIL", "info": "info"}
|
|
lines = []
|
|
for c in checks:
|
|
lines.append(f"{mark[c['status']]} {c['area']:<9} {c['check']:<22} {c['detail']}")
|
|
if c["fix"] and c["status"] in ("warn", "fail"):
|
|
lines.append(f"{'':6}{'':<9} -> {c['fix']}")
|
|
bad = sum(c["status"] == "fail" for c in checks)
|
|
warn = sum(c["status"] == "warn" for c in checks)
|
|
lines.append(f"\n{bad} failing, {warn} warnings.")
|
|
return "\n".join(lines)
|