Skip to content
Closed
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
61 changes: 45 additions & 16 deletions src/pinky_daemon/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8021,7 +8021,7 @@
# ── Admin: Update & Restart ───────────────────────────

@app.post("/admin/update")
async def admin_update(branch: str = "", dry_run: bool = False):
async def admin_update(branch: str = "", dry_run: bool = False, force_deps: bool = False):
"""Pull latest code, rebuild if needed, and restart the daemon.

The process manager (launchctl/systemd) must be installed for
Expand All @@ -8031,9 +8031,15 @@
Beta channel: pulls HEAD of the beta branch.
Branch defaults to PINKYBOT_CHANNEL env var ("stable" -> release tags,
"beta" -> beta branch), falling back to "stable" if unset.

force_deps: if True, reinstall dependencies even when git HEAD didn't
change. Use this when installed package versions have drifted from
pyproject.toml (e.g., manual pip install bypassed, or the daemon was
seeded from a stale image).
"""
import shutil
import subprocess as sp
import sys

if not branch:
channel = os.environ.get("PINKYBOT_CHANNEL", "stable")
Expand Down Expand Up @@ -8162,23 +8168,45 @@
except Exception:
summary = ""

# Detect dependency changes
# Detect dependency changes — rebuild whenever pyproject.toml or uv.lock
# changed in the pull, or when force_deps=True is passed (escape hatch
# for installed-vs-pinned drift that git diff can't see).
deps_rebuilt = False
deps_error = ""
try:
changed = sp.check_output(
["git", "diff", "--name-only", before_hash, after_hash, "--", "pyproject.toml"],
cwd=repo_dir, stderr=sp.DEVNULL, timeout=10,
).decode().strip()
if changed:
venv_pip = str(Path(repo_dir) / ".venv" / "bin" / "pip")
if Path(venv_pip).exists():
sp.check_output(
[venv_pip, "install", "-e", ".[all]", "--quiet"],
cwd=repo_dir, stderr=sp.STDOUT, timeout=120,
)
deps_rebuilt = True
except Exception:
pass
if before_hash != after_hash:
changed = sp.check_output(
["git", "diff", "--name-only", before_hash, after_hash, "--",
"pyproject.toml", "uv.lock"],
cwd=repo_dir, stderr=sp.DEVNULL, timeout=10,
).decode().strip()
else:
changed = ""

if changed or force_deps:
# Prefer project venv pip if present, else use the running daemon's
# interpreter (sys.executable). This works for both venv and
# system-python deployments — the prior `.venv/bin/pip`-only path
# silently skipped rebuilds on system-python hosts.
venv_pip = Path(repo_dir) / ".venv" / "bin" / "pip"
if venv_pip.exists():
pip_cmd = [str(venv_pip), "install", "-e", ".[all]", "--quiet"]
else:
# PEP 668: system pythons (Homebrew, Debian) mark themselves
# externally-managed. --break-system-packages lets us install
# into the same env the daemon imports from.
pip_cmd = [
sys.executable, "-m", "pip", "install",
"-e", ".[all]", "--quiet", "--break-system-packages",
]
sp.check_output(pip_cmd, cwd=repo_dir, stderr=sp.STDOUT, timeout=180)
deps_rebuilt = True
except sp.CalledProcessError as e:
deps_error = f"pip install failed: {e.output.decode()[:500] if e.output else e}"
_log(f"admin: {deps_error}")
except Exception as e:
deps_error = f"deps rebuild failed: {e}"
_log(f"admin: {deps_error}")

# Always rebuild frontend on update to keep compiled assets fresh
frontend_rebuilt = False
Expand All @@ -8197,7 +8225,7 @@
frontend_rebuilt = True
elif not npm_path:
frontend_error = "npm not found — install Node.js 18+ to enable auto frontend builds"
_log(f"admin: {frontend_error}")

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
Stack trace information
flows to this location and may be exposed to an external user.
Stack trace information flows to this location and may be exposed to an external user.
Stack trace information flows to this location and may be exposed to an external user.
except Exception as e:
frontend_error = f"Frontend build failed: {e}"
_log(f"admin: {frontend_error}")
Expand All @@ -8209,6 +8237,7 @@
"release": target_tag if use_release_tags else None,
"commits": summary.splitlines() if summary else [],
"deps_rebuilt": deps_rebuilt,
"deps_error": deps_error or None,
"frontend_rebuilt": frontend_rebuilt,
"frontend_error": frontend_error or None,
"restarting": before_hash != after_hash or deps_rebuilt,
Expand Down
21 changes: 17 additions & 4 deletions src/pinky_self/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1933,11 +1933,22 @@ def check_for_updates() -> str:
return "\n".join(parts)

@mcp.tool()
def update_and_restart(branch: str = "") -> str:
"""Pull latest code, rebuild if needed, and restart the daemon."""
url = "/admin/update"
def update_and_restart(branch: str = "", force_deps: bool = False) -> str:
"""Pull latest code, rebuild if needed, and restart the daemon.

force_deps: reinstall dependencies even when git HEAD didn't move.
Useful when installed package versions have drifted from
pyproject.toml (e.g., daemon seeded from a stale image, or a
previous deploy skipped the pip install step).
"""
params = []
if branch:
url += f"?branch={branch}"
params.append(f"branch={branch}")
if force_deps:
params.append("force_deps=true")
url = "/admin/update"
if params:
url += "?" + "&".join(params)
result = _api("POST", url)
if "error" in result:
return f"Update failed: {result['error']}"
Expand All @@ -1959,6 +1970,8 @@ def update_and_restart(branch: str = "") -> str:

if result.get("deps_rebuilt"):
parts.append("\nDependencies rebuilt.")
if result.get("deps_error"):
parts.append(f"\nDeps rebuild error: {result['deps_error']}")
if result.get("frontend_rebuilt"):
parts.append("Frontend rebuilt.")

Expand Down
56 changes: 56 additions & 0 deletions tests/test_pinky_self_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,62 @@ def test_error(self, srv):
result = _tools(srv)["update_and_restart"]()
assert "failed" in result.lower()

def test_force_deps_in_url(self, srv):
"""force_deps=True must reach /admin/update as a query param."""
captured = {}

def _urlopen(req, timeout=30):
captured["url"] = req.full_url if hasattr(req, "full_url") else str(req)
body = json.dumps({
"updated": True,
"before_hash": "a", "after_hash": "a",
"deps_rebuilt": True, "restarting": True,
}).encode()
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp

with patch("urllib.request.urlopen", side_effect=_urlopen):
_tools(srv)["update_and_restart"](force_deps=True)
assert "force_deps=true" in captured["url"]

def test_force_deps_with_branch(self, srv):
"""force_deps and branch must combine cleanly in the URL."""
captured = {}

def _urlopen(req, timeout=30):
captured["url"] = req.full_url if hasattr(req, "full_url") else str(req)
body = json.dumps({
"updated": True,
"before_hash": "a", "after_hash": "a",
"restarting": False,
}).encode()
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp

with patch("urllib.request.urlopen", side_effect=_urlopen):
_tools(srv)["update_and_restart"](branch="beta", force_deps=True)
assert "branch=beta" in captured["url"]
assert "force_deps=true" in captured["url"]

def test_deps_error_surfaced(self, srv):
"""Backend deps_error string is surfaced to the caller."""
with _ok({
"updated": True,
"before_hash": "a", "after_hash": "b",
"deps_rebuilt": False,
"deps_error": "pip install failed: boom",
"restarting": True,
}):
result = _tools(srv)["update_and_restart"]()
assert "deps rebuild error" in result.lower()
assert "boom" in result


# ── restart_daemon ────────────────────────────────────────────────────────────

Expand Down
Loading