Rename Castle -> Wild PC across the repo

Repo-side rename only (Phases 1-3 of the migration plan); the live box
(~/.castle, systemd units, /data/castle, domains) is a separate cutover.

- Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api),
  dist names, entry point `wildpc = wildpc_cli.main:main`.
- Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*.
- Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml ->
  wildpc.yaml, /data/castle -> /data/wildpc.
- Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc.
- Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend.
- Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated;
  redundant nested uv.lock files dropped (workspace root lock is authoritative).

Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds.
Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
This commit is contained in:
2026-07-18 22:55:08 -07:00
parent 25d7a522cc
commit 05b28cb584
190 changed files with 2428 additions and 3337 deletions

View File

@@ -0,0 +1,3 @@
"""Wild PC API."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,234 @@
"""Resolve launchable agents for the dashboard terminal UX.
Config (a `wildpc.yaml` `agents:` block) declares agents; this layer adds the
runtime view: is the binary present, and what's the absolute cwd. Wild PC stays
agnostic — it only ever runs `command args` in a pty.
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from wildpc_core.config import USER_TOOL_PATH_DIRS
from wildpc_api.config import get_wildpc_root, get_config
# Zero-config fallback so the feature works out of the box. A wildpc.yaml
# `agents:` block overrides this entirely.
_DEFAULT_AGENTS: dict[str, dict] = {
"claude": {
"command": "claude",
"description": "Anthropic Claude Code",
# Bare --resume pops claude's interactive session-history picker (whereas
# --continue would silently reopen only the most recent conversation).
"resume_args": ["--resume"],
},
"opencode": {
"command": "opencode",
"description": "opencode",
# opencode has no CLI flag to pop a picker, but it lists sessions as JSON
# and resumes by id — so the dashboard renders the picker itself.
"sessions": {
"list_command": ["opencode", "session", "list", "--format", "json"],
"resume": ["--session", "{id}"],
"id_field": "id",
"title_field": "title",
"time_field": "updated",
},
},
"amplifier": {
"command": "amplifier",
"description": "Amplifier",
# `amplifier resume` is a subcommand that interactively selects a past
# session (like claude --resume). (`amplifier continue` = most recent.)
"resume_args": ["resume"],
},
}
# Extra dirs to look in beyond the process PATH — where user CLIs actually live.
# opencode installs to ~/.opencode/bin, which the systemd service PATH omits.
_EXTRA_PATH_DIRS = [
*USER_TOOL_PATH_DIRS,
Path.home() / ".opencode" / "bin",
]
def _augmented_path() -> str:
extra = [str(d) for d in _EXTRA_PATH_DIRS if d.exists()]
current = os.environ.get("PATH", "")
return os.pathsep.join([*extra, current]) if extra else current
@dataclass
class ResolvedAgent:
name: str
command: str
args: list[str] = field(default_factory=list)
cwd: str = ""
env: dict[str, str] = field(default_factory=dict)
description: str | None = None
resume_args: list[str] = field(default_factory=list)
sessions: dict | None = None # SessionsSpec-shaped, or None
resolved_command: str | None = None # absolute path if found on PATH
@property
def available(self) -> bool:
return self.resolved_command is not None
@property
def can_continue(self) -> bool:
return bool(self.resume_args)
@property
def can_list_sessions(self) -> bool:
return bool(self.sessions and self.sessions.get("list_command"))
def launch_env(self) -> dict[str, str]:
"""Env overrides for the pty: augmented PATH so the agent's own tool
lookups (node, etc.) resolve, plus any configured env."""
return {"PATH": _augmented_path(), **self.env}
def info(self) -> dict:
return {
"name": self.name,
"command": self.command,
"available": self.available,
"cwd": self.cwd,
"description": self.description,
"can_continue": self.can_continue,
"can_list_sessions": self.can_list_sessions,
}
def _agent_specs() -> dict[str, dict]:
"""Configured agents (as plain dicts), or the built-in defaults."""
try:
config = get_config()
except FileNotFoundError:
return _DEFAULT_AGENTS
if config.agents:
return {n: s.model_dump(exclude_none=True) for n, s in config.agents.items()}
return _DEFAULT_AGENTS
def default_cwd() -> str:
"""Where agents launch by default: the wildpc git repo (its CLAUDE.md /
AGENTS.md and `wildpc` sources), falling back to the config root, then home."""
try:
repo = get_config().repo
if repo:
return str(repo)
except FileNotFoundError:
pass
root = get_wildpc_root()
return str(root) if root else str(Path.home())
def _resolve(name: str, spec: dict) -> ResolvedAgent:
command = spec["command"]
cwd = spec.get("cwd") or default_cwd()
resolved = shutil.which(command, path=_augmented_path())
return ResolvedAgent(
name=name,
command=command,
args=list(spec.get("args") or []),
cwd=cwd,
env=dict(spec.get("env") or {}),
description=spec.get("description"),
resume_args=list(spec.get("resume_args") or []),
sessions=spec.get("sessions"),
resolved_command=resolved,
)
def list_agents() -> list[ResolvedAgent]:
return [_resolve(name, spec) for name, spec in _agent_specs().items()]
def resolve_agent(name: str) -> ResolvedAgent | None:
specs = _agent_specs()
spec = specs.get(name)
if spec is None:
return None
return _resolve(name, spec)
def _dig(obj: object, path: str) -> object:
"""Read a (possibly dotted) field path off a nested dict, else None."""
cur = obj
for part in path.split("."):
if isinstance(cur, dict):
cur = cur.get(part)
else:
return None
return cur
def resume_argv(agent: ResolvedAgent, session_id: str) -> list[str] | None:
"""Build the argv that resumes a specific past session by id, or None."""
if not agent.sessions:
return None
template = agent.sessions.get("resume") or []
if not template:
return None
base = agent.resolved_command or agent.command
return [base, *(part.replace("{id}", session_id) for part in template)]
async def list_agent_history(agent: ResolvedAgent, limit: int = 40) -> list[dict]:
"""Run the agent's declared list_command and normalize it to
[{id, title, time}] — newest first. Best-effort: any failure yields []."""
if not agent.can_list_sessions:
return []
assert agent.sessions is not None
argv = agent.sessions["list_command"]
env = {**os.environ, "PATH": _augmented_path()}
try:
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
cwd=agent.cwd or None,
env=env,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
except (OSError, asyncio.TimeoutError):
return []
try:
data = json.loads(out.decode() or "[]")
except json.JSONDecodeError:
return []
items = (
data
if isinstance(data, list)
else (data.get("sessions") or data.get("data") or [])
)
id_f = agent.sessions.get("id_field", "id")
title_f = agent.sessions.get("title_field", "title")
time_f = agent.sessions.get("time_field", "updated")
out_rows: list[dict] = []
for item in items:
if not isinstance(item, dict):
continue
sid = _dig(item, id_f)
if not sid:
continue
out_rows.append(
{
"agent": agent.name,
"id": str(sid),
"title": str(_dig(item, title_f) or ""),
"time": _dig(item, time_f),
}
)
# Newest first when the time field is sortable; keep input order otherwise.
try:
out_rows.sort(key=lambda r: r["time"] or 0, reverse=True)
except TypeError:
pass
return out_rows[:limit]

View File

@@ -0,0 +1,448 @@
"""Live agent/terminal session registry.
A session is a running terminal (an agent CLI or a shell) whose lifetime is
decoupled from any WebSocket connection — closing a tab detaches, it does not
kill the process. Sessions can be listed, re-attached (resumed) by id, and
explicitly terminated.
Two backends behind one interface, chosen at startup:
- **tmux** (default when `tmux` is on PATH): each session is a tmux session, so
it **survives a wildpc-api restart** and is rediscoverable. The tmux server is
started inside its own systemd scope so it isn't in wildpc-api's cgroup (which
systemd would kill on restart); tmux's own systemd integration puts each pane
in its own scope too. Each WebSocket is a `tmux attach` client, so multi-client
and repaint-on-attach come for free.
- **memory** (fallback when tmux is absent, or `WILDPC_API_AGENT_BACKEND=memory`):
wildpc-api owns the pty directly and fans output out to subscribers with a
scrollback buffer. Simpler, but sessions die on restart.
The WebSocket handler is backend-agnostic: it calls `manager.create/get_info/
list/close`, then `manager.attach(id)` and drives the returned `Attachment`.
"""
from __future__ import annotations
import asyncio
import logging
import os
import shlex
import shutil
import time
import uuid
from collections.abc import AsyncIterator
from typing import Protocol
from wildpc_core.config import WILDPC_HOME
from wildpc_api.pty_session import PtySession
logger = logging.getLogger(__name__)
_SCROLLBACK_BYTES = 256 * 1024 # memory backend replay cap
_EXITED_TTL = 3600 # memory backend: reap exited sessions after this
class Attachment(Protocol):
"""One WebSocket's live view of a session."""
def reader(self) -> AsyncIterator[bytes]: ...
def write(self, data: bytes) -> None: ...
def resize(self, cols: int, rows: int) -> None: ...
async def aclose(self) -> None: ...
# ---------------------------------------------------------------------------
# In-memory backend
# ---------------------------------------------------------------------------
class _MemSession:
def __init__(self, sid: str, agent: str, command: str, cwd: str) -> None:
self.id = sid
self.agent = agent
self.command = command
self.cwd = cwd
self.created_at = time.time()
self.pty: PtySession | None = None
self.scrollback = bytearray()
self.subscribers: set[asyncio.Queue[bytes | None]] = set()
self.exited = False
self.exit_code: int | None = None
self.exited_at: float | None = None
def _on_output(self, data: bytes) -> None:
self.scrollback.extend(data)
if len(self.scrollback) > _SCROLLBACK_BYTES:
del self.scrollback[: len(self.scrollback) - _SCROLLBACK_BYTES]
for q in list(self.subscribers):
try:
q.put_nowait(data)
except asyncio.QueueFull:
self.subscribers.discard(q)
def _on_exit(self) -> None:
self.exited = True
self.exit_code = self.pty.proc.returncode if self.pty else None
self.exited_at = time.time()
for q in list(self.subscribers):
q.put_nowait(None)
@property
def running(self) -> bool:
return not self.exited and self.pty is not None and self.pty.running
def info(self) -> dict:
return {
"id": self.id,
"agent": self.agent,
"command": self.command,
"cwd": self.cwd,
"created_at": self.created_at,
"running": self.running,
"exited": self.exited,
"exit_code": self.exit_code,
"cols": self.pty.cols if self.pty else None,
"rows": self.pty.rows if self.pty else None,
"clients": len(self.subscribers),
}
class _MemAttachment:
def __init__(
self, session: _MemSession, q: asyncio.Queue[bytes | None], snap: bytes
):
self._s = session
self._q = q
self._snap = snap
async def reader(self) -> AsyncIterator[bytes]:
if self._snap:
yield self._snap
while (chunk := await self._q.get()) is not None:
yield chunk
def write(self, data: bytes) -> None:
if self._s.pty:
self._s.pty.write(data)
def resize(self, cols: int, rows: int) -> None:
if self._s.pty:
self._s.pty.resize(cols, rows)
async def aclose(self) -> None:
self._s.subscribers.discard(self._q)
class MemoryManager:
backend = "memory"
def __init__(self) -> None:
self._sessions: dict[str, _MemSession] = {}
def _reap(self) -> None:
now = time.time()
for sid in [
s.id
for s in self._sessions.values()
if s.exited and s.exited_at and now - s.exited_at > _EXITED_TTL
]:
self._sessions.pop(sid, None)
async def create(
self,
agent: str,
argv: list[str],
cwd: str,
env: dict[str, str],
cols: int,
rows: int,
) -> str:
self._reap()
sid = uuid.uuid4().hex[:12]
s = _MemSession(sid, agent, shlex.join(argv), cwd)
s.pty = await PtySession.start(
argv[0],
args=argv[1:],
cwd=cwd or None,
env=env,
cols=cols,
rows=rows,
on_output=s._on_output,
on_exit=s._on_exit,
)
self._sessions[sid] = s
return sid
async def get_info(self, sid: str) -> dict | None:
s = self._sessions.get(sid)
return s.info() if s else None
async def list(self) -> list[dict]:
self._reap()
return [
s.info()
for s in sorted(
self._sessions.values(), key=lambda s: s.created_at, reverse=True
)
]
async def attach(self, sid: str, cols: int, rows: int) -> Attachment | None:
s = self._sessions.get(sid)
if s is None or not s.running or s.pty is None:
return None
# Subscribe + snapshot atomically (no await between → the output callback,
# in this same loop, can't interleave), so replay has no gap/dup.
q: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=256)
s.subscribers.add(q)
snap = bytes(s.scrollback)
s.pty.resize(cols, rows)
return _MemAttachment(s, q, snap)
async def close(self, sid: str) -> bool:
s = self._sessions.pop(sid, None)
if s is None:
return False
if s.pty:
await s.pty.close()
for q in list(s.subscribers):
q.put_nowait(None)
return True
async def close_all(self) -> None:
for sid in list(self._sessions):
await self.close(sid)
# ---------------------------------------------------------------------------
# tmux backend
# ---------------------------------------------------------------------------
_TMUX_SOCK = WILDPC_HOME / "run" / "agents.sock"
_PREFIX = "wildpc-"
_KEEPALIVE = "__keepalive__" # hidden session that keeps the isolated server alive
class _TmuxAttachment:
def __init__(self, pty: PtySession, q: asyncio.Queue[bytes | None]):
self._pty = pty
self._q = q
@classmethod
async def start(
cls, tmux_bin: str, sock: str, name: str, cols: int, rows: int
) -> _TmuxAttachment:
q: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=256)
pty = await PtySession.start(
tmux_bin,
args=["-S", sock, "attach-session", "-t", name],
cols=cols,
rows=rows,
on_output=lambda d: q.put_nowait(d),
on_exit=lambda: q.put_nowait(None),
)
return cls(pty, q)
async def reader(self) -> AsyncIterator[bytes]:
while (chunk := await self._q.get()) is not None:
yield chunk
def write(self, data: bytes) -> None:
self._pty.write(data)
def resize(self, cols: int, rows: int) -> None:
self._pty.resize(cols, rows)
async def aclose(self) -> None:
await self._pty.close() # kills the attach client → detaches; session lives
class TmuxManager:
backend = "tmux"
def __init__(self, tmux_bin: str) -> None:
self._bin = tmux_bin
self._sock = str(_TMUX_SOCK)
self._ready = False
self._lock = asyncio.Lock()
async def _raw(self, *args: str) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
self._bin,
"-S",
self._sock,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
return (
proc.returncode or 0,
out.decode(errors="replace"),
err.decode(errors="replace"),
)
async def _server_running(self) -> bool:
_, out, err = await self._raw("list-sessions")
text = (out + err).lower()
return not any(
m in text for m in ("no server", "error connecting", "no such file")
)
async def _ensure(self) -> None:
if self._ready:
return
async with self._lock:
if self._ready:
return
_TMUX_SOCK.parent.mkdir(parents=True, exist_ok=True)
if not await self._server_running():
# Start the server inside its OWN systemd scope so it is NOT in
# wildpc-api's cgroup (which a restart kills). It must be born with
# a keepalive session: an empty server races its own empty-exit
# check against `exit-empty off` and dies, after which a plain
# `new-session` would restart it inside wildpc-api's cgroup. The
# keepalive (a detached `sleep infinity`, name-filtered from
# listings) guarantees the isolated server persists.
proc = await asyncio.create_subprocess_exec(
"systemd-run",
"--user",
"--scope",
"--quiet",
"--collect",
self._bin,
"-S",
self._sock,
"new-session",
"-d",
"-s",
_KEEPALIVE,
"sleep infinity",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, err = await proc.communicate()
if proc.returncode != 0 or not await self._server_running():
logger.warning(
"agent tmux server not isolated (systemd-run: %s); sessions "
"will still work but won't survive a wildpc-api restart",
err.decode(errors="replace").strip() or "unknown error",
)
# Clean, terminal-like defaults (idempotent).
for opt in (
("set", "-g", "status", "off"), # no tmux status bar
("set", "-g", "window-size", "latest"), # newest client's size wins
("set", "-g", "escape-time", "0"),
("set", "-g", "destroy-unattached", "off"),
("set", "-s", "exit-empty", "off"),
):
await self._raw(*opt)
self._ready = True
async def create(
self,
agent: str,
argv: list[str],
cwd: str,
env: dict[str, str],
cols: int,
rows: int,
) -> str:
await self._ensure()
sid = uuid.uuid4().hex[:12]
name = f"{_PREFIX}{sid}"
cmd = shlex.join(argv) # tmux runs this as the pane's shell-command
args = [
"new-session",
"-d",
"-s",
name,
"-x",
str(cols),
"-y",
str(rows),
]
if cwd:
args += ["-c", cwd]
for k, v in env.items():
args += ["-e", f"{k}={v}"]
args += [cmd]
rc, _, err = await self._raw(*args)
if rc != 0:
raise RuntimeError(f"tmux new-session failed: {err.strip()}")
await self._raw("set-option", "-t", name, "@agent", agent)
return sid
async def _list_raw(self) -> list[dict]:
fmt = "#{session_name}\t#{session_created}\t#{@agent}\t#{session_attached}\t#{window_width}\t#{window_height}"
rc, out, _ = await self._raw("list-sessions", "-F", fmt)
if rc != 0:
return []
rows: list[dict] = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) < 6 or not parts[0].startswith(_PREFIX):
continue
name, created, agent, attached, w, h = parts[:6]
rows.append(
{
"id": name[len(_PREFIX) :],
"agent": agent or name[len(_PREFIX) :],
"command": "",
"cwd": "",
"created_at": float(created) if created.isdigit() else None,
"running": True,
"exited": False,
"exit_code": None,
"cols": int(w) if w.isdigit() else None,
"rows": int(h) if h.isdigit() else None,
"clients": int(attached) if attached.isdigit() else 0,
}
)
return rows
async def get_info(self, sid: str) -> dict | None:
for row in await self._list_raw():
if row["id"] == sid:
return row
return None
async def list(self) -> list[dict]:
rows = await self._list_raw()
rows.sort(key=lambda r: r["created_at"] or 0, reverse=True)
return rows
async def attach(self, sid: str, cols: int, rows: int) -> Attachment | None:
if await self.get_info(sid) is None:
return None
return await _TmuxAttachment.start(
self._bin, self._sock, f"{_PREFIX}{sid}", cols, rows
)
async def close(self, sid: str) -> bool:
rc, _, _ = await self._raw("kill-session", "-t", f"{_PREFIX}{sid}")
return rc == 0
async def close_all(self) -> None:
# No-op: tmux sessions are meant to SURVIVE a wildpc-api shutdown.
return None
# ---------------------------------------------------------------------------
# Backend selection
# ---------------------------------------------------------------------------
def _make_manager() -> MemoryManager | TmuxManager:
choice = os.environ.get("WILDPC_API_AGENT_BACKEND", "auto").lower()
tmux_bin = shutil.which("tmux")
if choice == "memory":
return MemoryManager()
if choice == "tmux" and not tmux_bin:
raise RuntimeError("WILDPC_API_AGENT_BACKEND=tmux but tmux is not on PATH")
if tmux_bin and choice in ("auto", "tmux"):
return TmuxManager(tmux_bin)
return MemoryManager()
manager: MemoryManager | TmuxManager = _make_manager()

View File

@@ -0,0 +1,257 @@
"""Agent terminal UX — list agents, run/resume them in a terminal over WebSocket.
Wild PC is assistant-agnostic: it launches a configured command (or a shell) in a
real tty and streams raw bytes to an xterm.js terminal in the browser. Sessions
outlive their WebSocket connection (and, with the tmux backend, a wildpc-api
restart), so they can be listed, resumed, and killed. The session backend lives
in `agent_sessions.py`; this module is the HTTP/WebSocket surface.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shlex
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
from starlette.websockets import WebSocketState
from wildpc_api.agent_registry import (
default_cwd,
list_agent_history,
list_agents,
resolve_agent,
resume_argv,
)
from wildpc_api.agent_sessions import manager
from wildpc_api.config import get_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/agents", tags=["agents"])
# The reserved agent name that launches the user's login shell (Option A: plain
# terminal). Not a configured agent — Wild PC knows nothing about what you run in it.
TERMINAL_AGENT = "terminal"
# --- Origin allowlist (this endpoint is an interactive shell) ---------------
def _origin_allowed(origin: str | None) -> bool:
"""Allow same-node/local and configured-domain origins for the WS handshake.
CORS (`*`) governs XHR, not WebSocket upgrades, so we gate the handshake
here. Non-browser clients (no Origin) and localhost are allowed; browser
origins must match the gateway domain (or `WILDPC_API_TERMINAL_ORIGINS`).
Set `WILDPC_API_ALLOW_ALL_ORIGINS=1` to disable the check entirely.
"""
if os.environ.get("WILDPC_API_ALLOW_ALL_ORIGINS"):
return True
if not origin:
return True # curl/websocat and other non-browser clients
host = (urlparse(origin).hostname or "").lower()
if host in ("localhost", "127.0.0.1", "::1"):
return True
try:
domain = get_config().gateway.domain
except Exception:
domain = None
if domain and (host == domain or host.endswith("." + domain)):
return True
extra = os.environ.get("WILDPC_API_TERMINAL_ORIGINS", "")
allowed = {h.strip().lower() for h in extra.split(",") if h.strip()}
return host in allowed
# --- HTTP: agents + sessions ------------------------------------------------
@router.get("")
def get_agents() -> list[dict]:
"""List launchable agents with availability (binary present) + default cwd."""
return [a.info() for a in list_agents()]
@router.get("/sessions")
async def get_sessions() -> list[dict]:
"""List live terminal sessions (running, backend-managed)."""
return await manager.list()
@router.get("/history")
async def get_history() -> list[dict]:
"""Unified list of agents' own past sessions (from each agent's declared
`sessions.list_command`), newest first. Agents without the capability
(or with none stored) simply contribute nothing."""
agents = [a for a in list_agents() if a.available and a.can_list_sessions]
results = await asyncio.gather(*(list_agent_history(a) for a in agents))
rows = [row for agent_rows in results for row in agent_rows]
try:
rows.sort(key=lambda r: r["time"] or 0, reverse=True)
except TypeError:
pass
return rows
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str) -> dict:
"""Kill a session and drop it from the backend."""
ok = await manager.close(session_id)
if not ok:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="session not found"
)
return {"status": "closed", "id": session_id}
# --- WebSocket: interactive session ----------------------------------------
def _login_shell() -> str:
return os.environ.get("SHELL") or "/bin/bash"
async def _build_launch(
name: str, cont: bool = False, resume_session: str | None = None
) -> tuple[str, list[str], str, dict[str, str]]:
"""Return (agent_label, argv, cwd, env) for a new session.
Everything runs under the user's *interactive login* shell (`-lic`) so it
inherits exactly the environment a real terminal would — the systemd service
env is stripped down and would otherwise miss vars from shell rc files (API
keys, etc.). Agents are `exec`'d so the terminal's foreground process is the
agent itself. `cont` appends the agent's `resume_args`; `resume_session`
builds its resume-by-id argv — wildpc only passes declared flags through.
"""
shell = _login_shell()
if name == TERMINAL_AGENT:
return TERMINAL_AGENT, [shell, "-l"], default_cwd(), {}
agent = resolve_agent(name)
if agent is None:
raise LookupError(f"unknown agent: {name}")
if not agent.available:
raise LookupError(f"agent not installed: {name}")
if resume_session:
inner = resume_argv(agent, resume_session)
if inner is None:
raise LookupError(f"agent cannot resume by id: {name}")
else:
inner = [agent.resolved_command or agent.command, *agent.args]
if cont and agent.resume_args:
inner += agent.resume_args
argv = [shell, "-lic", f"exec {shlex.join(inner)}"]
return name, argv, agent.cwd, agent.env
@router.websocket("/{name}/session")
async def agent_session(ws: WebSocket, name: str) -> None:
"""Run (or resume) an agent in a terminal; stream it to the browser.
Query params (mutually exclusive, all optional):
- `session=<id>` resume an existing live session.
- `resume_session=<id>` start the agent resumed to one of *its own* past
sessions (agent-native, by id).
- `mode=continue` start the agent with its `resume_args`.
Disconnecting detaches — it does NOT kill the session.
"""
if not _origin_allowed(ws.headers.get("origin")):
await ws.close(code=1008) # policy violation
return
resume_id = ws.query_params.get("session")
session_id: str
is_resume = False
agent_label = name
try:
if resume_id:
info = await manager.get_info(resume_id)
if not info or not info.get("running"):
await ws.accept()
await ws.send_json({"type": "error", "error": "session not resumable"})
await ws.close()
return
session_id = resume_id
is_resume = True
agent_label = info.get("agent") or name
else:
cont = ws.query_params.get("mode") == "continue"
resume_session = ws.query_params.get("resume_session")
agent_label, argv, cwd, env = await _build_launch(
name, cont, resume_session
)
session_id = await manager.create(
agent_label, argv, cwd, env, cols=80, rows=24
)
except LookupError as e:
await ws.accept()
await ws.send_json({"type": "error", "error": str(e)})
await ws.close()
return
except Exception:
logger.exception("failed to launch agent %s", name)
await ws.accept()
await ws.send_json({"type": "error", "error": "failed to launch"})
await ws.close()
return
await ws.accept()
att = await manager.attach(session_id, cols=80, rows=24)
if att is None:
await ws.send_json({"type": "error", "error": "session not attachable"})
await ws.close()
return
await ws.send_json(
{
"type": "session",
"id": session_id,
"agent": agent_label,
"resumed": is_resume,
}
)
async def pump() -> None:
try:
async for chunk in att.reader():
if ws.application_state != WebSocketState.CONNECTED:
break
await ws.send_bytes(chunk)
# reader ended without cancellation → the session ended
if ws.application_state == WebSocketState.CONNECTED:
info = await manager.get_info(session_id)
code = info.get("exit_code") if info else None
await ws.send_json({"type": "exit", "code": code})
await ws.close()
except Exception:
pass
pump_task = asyncio.create_task(pump())
try:
while True:
msg = await ws.receive()
if msg["type"] == "websocket.disconnect":
break
data = msg.get("bytes")
text = msg.get("text")
if data is not None:
att.write(data)
elif text is not None:
try:
ctrl = json.loads(text)
except json.JSONDecodeError:
continue
if ctrl.get("type") == "resize":
att.resize(int(ctrl["cols"]), int(ctrl["rows"]))
elif ctrl.get("type") == "input":
att.write(str(ctrl.get("data", "")).encode())
except WebSocketDisconnect:
pass
finally:
pump_task.cancel()
await att.aclose()
# Intentionally do NOT close the session — it lives on for resume.

View File

@@ -0,0 +1,67 @@
"""Configuration for wildpc-api."""
from pathlib import Path
from pydantic_settings import BaseSettings
from wildpc_core.config import WildpcConfig, load_config
from wildpc_core.registry import NodeRegistry, load_registry
class Settings(BaseSettings):
"""Service settings loaded from environment variables."""
host: str = "0.0.0.0"
port: int = 9020
# Mesh coordination (all off by default — single-node works without them)
nats_enabled: bool = False
nats_url: str = "nats://localhost:4222"
nats_token: str | None = None
mdns_enabled: bool = False
# LLM assist (off by default). Calls the litellm proxy's OpenAI-compatible
# /chat/completions to generate a tool-call schema for CLIs the deterministic
# parser can't structure (subcommand trees). The key is NOT stored here — it's
# read from the secret backend by name at call time.
llm_enabled: bool = False
llm_base_url: str = "https://litellm.civil.payne.io/v1"
llm_model: str = "qwen"
llm_api_key_secret: str = "LITELLM_MASTER_KEY"
model_config = {
"env_prefix": "WILDPC_API_",
"env_file": ".env",
}
settings = Settings()
def get_registry() -> NodeRegistry:
"""Load the node registry. Raises if not found."""
return load_registry()
def get_wildpc_root() -> Path | None:
"""Get the wildpc repo root from the registry, if available."""
try:
registry = load_registry()
if registry.node.wildpc_root:
return Path(registry.node.wildpc_root)
except (FileNotFoundError, ValueError):
pass
return None
def get_config() -> WildpcConfig:
"""Load wildpc.yaml via the registry's wildpc_root.
Raises FileNotFoundError if repo not available.
"""
root = get_wildpc_root()
if root is None:
raise FileNotFoundError(
"Wild PC repo not available. Set wildpc_root in registry."
)
return load_config(root)

View File

@@ -0,0 +1,595 @@
"""Config editor — read, validate, save, and apply wildpc.yaml changes."""
from __future__ import annotations
import asyncio
from pathlib import Path
import yaml
from fastapi import APIRouter, Body, HTTPException, status
from pydantic import BaseModel
from wildpc_core.config import (
KINDS,
WildpcConfig,
_DEPLOYMENT_ADAPTER,
_program_to_yaml_dict,
_spec_to_yaml_dict,
load_config,
parse_gateway,
save_config,
write_deployment_file,
write_program_file,
)
from wildpc_core.manifest import ProgramSpec, kind_for
from wildpc_api.config import get_wildpc_root, get_config
from wildpc_api.stream import broadcast
router = APIRouter(prefix="/config", tags=["config"])
class ConfigResponse(BaseModel):
yaml_content: str
class ConfigSaveRequest(BaseModel):
yaml_content: str
class ConfigSaveResponse(BaseModel):
ok: bool
program_count: int
service_count: int
job_count: int
errors: list[str]
class ApplyResponse(BaseModel):
ok: bool
actions: list[str]
errors: list[str]
class ProgramConfigRequest(BaseModel):
config: dict
class ServiceConfigRequest(BaseModel):
config: dict
class JobConfigRequest(BaseModel):
config: dict
def _require_repo() -> Path:
"""Return the wildpc repo root, or raise 503 if it's not available."""
root = get_wildpc_root()
if root is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Wild PC repo not available on this node.",
)
return root
def _aggregate_yaml(config: WildpcConfig) -> str:
"""Build a unified virtual wildpc.yaml from the directory-per-resource config."""
data: dict = {"gateway": {"port": config.gateway.port}}
if config.repo:
data["repo"] = str(config.repo)
if config.role and config.role != "follower":
data["role"] = config.role
# `secrets:` isn't modeled on WildpcConfig — surface it from the raw file so the
# aggregate view/round-trip includes it.
try:
raw = yaml.safe_load((config.root / "wildpc.yaml").read_text()) or {}
if raw.get("secrets"):
data["secrets"] = raw["secrets"]
except Exception:
pass
if config.programs:
data["programs"] = {
n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()
}
deps = {n: _spec_to_yaml_dict(s) for _k, n, s in config.all_deployments()}
if deps:
data["deployments"] = deps
return yaml.dump(data, default_flow_style=False, sort_keys=False)
@router.get("", response_model=ConfigResponse)
def get_config_yaml() -> ConfigResponse:
"""Get a unified virtual wildpc.yaml aggregated from all resource files."""
root = _require_repo()
config = load_config(root)
return ConfigResponse(yaml_content=_aggregate_yaml(config))
@router.put("", response_model=ConfigSaveResponse)
def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
"""Validate and save wildpc.yaml. Does NOT apply changes."""
root = _require_repo()
errors: list[str] = []
# Parse YAML
try:
data = yaml.safe_load(request.yaml_content)
except yaml.YAMLError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid YAML: {e}",
)
if not isinstance(data, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="YAML must be a mapping",
)
# repo: drives repo-relative source resolution (fall back to existing config)
repo_path = None
if data.get("repo"):
repo_path = Path(data["repo"]).expanduser()
else:
try:
repo_path = load_config(root).repo
except Exception:
repo_path = None
def _resolve_source(spec: ProgramSpec) -> None:
if not spec.source:
return
if spec.source.startswith("repo:") and repo_path:
spec.source = str(repo_path / spec.source[5:])
elif not Path(spec.source).is_absolute():
spec.source = str(root / spec.source)
# Validate programs
programs: dict[str, ProgramSpec] = {}
programs_data = data.get("programs") or {}
for name, comp_data in programs_data.items():
try:
comp_data_copy = dict(comp_data) if comp_data else {}
comp_data_copy["id"] = name
spec = ProgramSpec.model_validate(comp_data_copy)
_resolve_source(spec)
programs[name] = spec
except Exception as e:
errors.append(f"programs.{name}: {e}")
# Validate deployments (a flat name→spec map in the manager-discriminated shape).
deployments = {}
for name, dep_data in (data.get("deployments") or {}).items():
try:
dep_copy = dict(dep_data) if dep_data else {}
dep_copy["id"] = name
deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(dep_copy)
except Exception as e:
errors.append(f"deployments.{name}: {e}")
if errors:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"errors": errors},
)
prog_count = len(programs)
svc_count = sum(1 for d in deployments.values() if kind_for(d) == "service")
job_count = sum(1 for d in deployments.values() if kind_for(d) == "job")
config = WildpcConfig(
root=root,
repo=repo_path,
# Parse the FULL gateway block (tls/domain/tunnel/cert_hook/…), not just
# port — otherwise a whole-file save silently wipes the gateway config.
gateway=parse_gateway(data.get("gateway", {})),
programs=programs,
deployments=deployments,
)
save_config(config)
return ConfigSaveResponse(
ok=True,
program_count=prog_count,
service_count=svc_count,
job_count=job_count,
errors=[],
)
@router.put("/programs/{name}")
def save_program(name: str, request: ProgramConfigRequest) -> dict:
"""Update a single program's config in wildpc.yaml (PATCH semantics).
Like deployments: the incoming config is shallow-merged over the existing
program spec, so a partial save can't drop source/stack/commands/build/… that
the client didn't send. Omitted keys are preserved; an explicit ``null`` clears.
"""
_require_repo()
config = get_config()
incoming = dict(request.config)
if name in config.programs:
base = config.programs[name].model_dump(mode="json", exclude_none=True)
merged = {**base, **incoming, "id": name}
merged = {k: v for k, v in merged.items() if v is not None}
else:
merged = {**incoming, "id": name}
try:
spec = ProgramSpec.model_validate(merged)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid program config: {e}",
)
config.programs[name] = spec
write_program_file(config, name) # PATCH: only this program file
return {"ok": True, "program": name}
@router.delete("/programs/{name}")
async def delete_program(name: str, cascade: bool = False) -> dict:
"""Remove a program from wildpc.yaml.
Without ``cascade``, refuses (409) if any deployment still references the
program. With ``cascade=true`` it first tears down and removes those
deployments — dispatched by manager: uninstall a tool from PATH, stop+disable
a service or job, drop a static route — so nothing is left running, installed,
or served, then removes the program. A program and its 1:1 tool/static
deployment are one thing to the user, so this makes "Delete" just work.
"""
config = get_config()
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Program '{name}' not found",
)
refs = [(k, d) for k, d, spec in config.all_deployments() if spec.program == name]
if refs and not cascade:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"'{name}' still has deployments ({', '.join(d for _k, d in refs)}). "
"Delete them first, or pass cascade=true to remove them too."
),
)
removed: list[str] = []
if refs:
from wildpc_core.lifecycle import deactivate
for kind, ref in refs:
# Best-effort teardown (uninstall/stop/disable); still remove the config
# even if the runtime is already gone.
try:
await deactivate(ref, kind, config, config.root)
except Exception:
pass
del config.store_for(kind)[ref]
write_deployment_file(config, kind, ref) # unlinks the removed deployment
removed.append(ref)
del config.programs[name]
write_program_file(config, name) # unlinks the program file only
if removed:
# Converge the runtime: prune any orphan units and regenerate the Caddyfile
# (dropping static routes), then reload the gateway.
from wildpc_core.deploy import deploy
try:
deploy()
except Exception:
pass
return {
"ok": True,
"program": name,
"action": "deleted",
"removed_deployments": removed,
}
def _save_deployment(name: str, config_dict: dict, kind: str | None = None) -> dict:
"""Create/update a deployment (any manager) with PATCH semantics.
The incoming config is shallow-merged over the existing spec, so a save can
never silently drop a field the client didn't send (the astro/postgres bug):
a present key replaces wholesale, an **omitted** key is preserved, and an
explicit ``null`` clears the key (back to its default). On CREATE there's no
base, so the incoming config stands alone.
``kind`` pins the twin this save targets — a kind-scoped endpoint
(``/services|/jobs|/tools|/static``) passes it so a partial patch to a
``backup`` service can never bleed into a ``backup`` job/tool sharing the
name. The kind-agnostic ``/deployments/{name}`` leaves it None and infers.
"""
_require_repo()
config = get_config()
incoming = dict(config_dict)
# Resolve the (name, kind) this save targets. An explicit kind is
# authoritative (kind-scoped endpoint). Otherwise: a partial patch (e.g. just
# {reach: off}) has no manager, so we can't derive kind from it — prefer the
# existing same-named deployment when there's exactly one; else derive the
# kind from the incoming spec (a create, or disambiguating a shared name).
named = config.deployments_named(name)
existing = None
if kind is not None:
existing = config.deployment(kind, name)
elif len(named) == 1:
existing = named[0][1]
else:
try:
probe = _DEPLOYMENT_ADAPTER.validate_python({**incoming, "id": name})
existing = config.deployment(kind_for(probe), name)
except Exception:
existing = None
if existing is not None:
base = existing.model_dump(mode="json", exclude_none=True)
merged = {**base, **incoming, "id": name}
# An explicit null means "clear" — drop the key so its default applies.
merged = {k: v for k, v in merged.items() if v is not None}
else:
merged = {**incoming, "id": name}
# On CREATE with no description, inherit the referenced program's.
if not merged.get("description"):
prog = merged.get("program")
if prog and prog in config.programs and config.programs[prog].description:
merged["description"] = config.programs[prog].description
try:
dep = _DEPLOYMENT_ADAPTER.validate_python(merged)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid deployment config: {e}",
)
target_kind = kind_for(dep)
# A field edit that changes the derived kind (e.g. adds a schedule) moves the
# spec to the new store; drop the stale entry under the requested kind.
if kind is not None and target_kind != kind:
config.store_for(kind).pop(name, None)
write_deployment_file(config, kind, name) # spec now absent → unlinks old file
config.store_for(target_kind)[name] = dep
write_deployment_file(config, target_kind, name) # PATCH: only this file
return {"ok": True, "deployment": name}
def _delete_deployment(name: str, kind: str | None = None) -> dict:
"""Remove a deployment. A kind-scoped delete drops only that twin; the
kind-agnostic path removes every kind sharing the name."""
config = get_config()
removed_kinds = []
kinds = (kind,) if kind is not None else KINDS
for k in kinds:
if name in config.store_for(k):
del config.store_for(k)[name]
removed_kinds.append(k)
if not removed_kinds:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Deployment '{name}' not found",
)
for k in removed_kinds:
write_deployment_file(config, k, name) # spec absent → unlinks
return {"ok": True, "deployment": name, "action": "deleted"}
# The deployment endpoints — `deployments` is canonical; `services`/`jobs` remain
# as aliases (the kind is derived, so all three target the one collection).
@router.put("/deployments/{name}")
def save_deployment(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update a deployment of any kind (service/job/tool/static)."""
return _save_deployment(name, request.config)
@router.delete("/deployments/{name}")
def delete_deployment(name: str) -> dict:
return _delete_deployment(name)
class EnabledRequest(BaseModel):
enabled: bool
@router.put("/deployments/{name}/enabled")
def set_deployment_enabled(name: str, request: EnabledRequest) -> dict:
"""Set a deployment's declared `enabled` state (desired on/off).
Edits config only — the caller runs `POST /apply` to converge. Keeps the
declarative flow: change what you want, then apply.
"""
config = get_config()
deps = config.deployments_named(name)
if not deps:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Deployment '{name}' not found",
)
# A name may span kinds — toggle all of them together.
for kind, dep in deps:
dep.enabled = request.enabled
write_deployment_file(config, kind, name)
return {"ok": True, "deployment": name, "enabled": request.enabled}
# Kind-scoped endpoints — pin the twin so a save/delete can't hit a same-named
# deployment of another kind (a `backup` service vs job vs tool).
@router.put("/services/{name}")
def save_service(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update the *service* named `name`."""
return _save_deployment(name, request.config, kind="service")
@router.delete("/services/{name}")
def delete_service(name: str) -> dict:
return _delete_deployment(name, kind="service")
@router.put("/jobs/{name}")
def save_job(name: str, request: JobConfigRequest) -> dict:
"""Create/update the *job* named `name`."""
return _save_deployment(name, request.config, kind="job")
@router.delete("/jobs/{name}")
def delete_job(name: str) -> dict:
return _delete_deployment(name, kind="job")
@router.put("/tools/{name}")
def save_tool(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update the *tool* named `name`."""
return _save_deployment(name, request.config, kind="tool")
@router.delete("/tools/{name}")
def delete_tool(name: str) -> dict:
return _delete_deployment(name, kind="tool")
@router.post("/tools/{name}/schema")
async def generate_tool_schema(
name: str, deep: bool = False, assist: str | None = None
) -> dict:
"""Generate a *draft* tool-call schema (neutral core) from the tool's ``--help``.
Not saved — the client reviews/edits it and persists via ``PUT
/config/tools/{name}`` (the schema rides in the deployment config as
``tool_schema``). Two modes:
* default (``assist`` unset) — deterministic: parse ``--help``. ``deep`` walks
subcommands. 422 if the tool isn't installed / emits no help.
* ``assist=llm`` — send the recursive ``--help`` to the litellm proxy for a
structured schema (the escape hatch for subcommand trees the parser can only
render as a ``command`` string). 503 if LLM assist is disabled; 502 on an
upstream/validation failure.
"""
from wildpc_core.tool_schema import (
ToolSchemaError,
collect_tool_help,
derive_tool_schema,
)
config = get_config()
if config.deployment("tool", name) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool '{name}' not found",
)
if assist == "llm":
from wildpc_api.config import settings
from wildpc_api.llm import LLMAssistError, generate_tool_schema_llm
if not settings.llm_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LLM assist is disabled (set WILDPC_API_LLM_ENABLED=true).",
)
try:
help_text = collect_tool_help(config, name)
except ToolSchemaError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
)
try:
schema = await generate_tool_schema_llm(help_text, name)
except LLMAssistError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)
)
return {"ok": True, "schema": schema, "assist": "llm"}
try:
schema = derive_tool_schema(config, name, deep=deep)
except ToolSchemaError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e),
)
return {"ok": True, "schema": schema}
@router.post("/tools/schema/validate")
def validate_tool_schema_endpoint(core: dict = Body(...)) -> dict:
"""Deterministically validate a tool-call schema core (no LLM) — the shape and
that ``parameters`` is a valid JSON Schema. Lets the UI check a hand-edited
schema. Returns ``{valid, errors}``."""
from wildpc_core.tool_schema import validate_tool_schema_core
errors = validate_tool_schema_core(core)
return {"valid": not errors, "errors": errors}
@router.put("/static/{name}")
def save_static(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update the *static* frontend named `name`."""
return _save_deployment(name, request.config, kind="static")
@router.delete("/static/{name}")
def delete_static(name: str) -> dict:
return _delete_deployment(name, kind="static")
@router.put("/references/{name}")
def save_reference(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update an external *reference* (manager: none, base_url) — an
endpoint wildpc doesn't run (a SaaS API, a remote/external service)."""
return _save_deployment(name, request.config, kind="reference")
@router.delete("/references/{name}")
def delete_reference(name: str) -> dict:
return _delete_deployment(name, kind="reference")
@router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse:
"""Converge the running system to match wildpc.yaml (a thin wrapper on core
``apply``). Renders units/Caddyfile/tunnel, then reconciles the runtime —
activating what's enabled and down, restarting only what changed, deactivating
the disabled. Kept as ``/config/apply`` for compatibility; ``/apply`` exposes
the same converge with per-deployment targeting and ``--plan``.
"""
from wildpc_core.deploy import apply
# apply is blocking (systemctl + gateway reload) — run off the event loop.
try:
result = await asyncio.to_thread(apply)
except Exception as e:
return ApplyResponse(ok=False, actions=[], errors=[f"Apply failed: {e}"])
actions: list[str] = []
for verb, names in (
("Activated", result.activated),
("Restarted", result.restarted),
("Deactivated", result.deactivated),
):
if names:
actions.append(f"{verb} {', '.join(sorted(names))}")
if not result.changed:
actions.append("Already converged — nothing to do")
await broadcast("config-changed", {"actions": actions})
return ApplyResponse(ok=True, actions=actions, errors=[])
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
proc = await asyncio.create_subprocess_exec(
"systemctl",
"--user",
action,
unit,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode == 0, (stdout or stderr or b"").decode().strip()

View File

@@ -0,0 +1,72 @@
"""Apply (converge) API endpoint.
`POST /apply` reconciles the running system to match config — render units/
Caddyfile/tunnel, then activate/restart/deactivate to match. It replaces the old
`/deploy` (+ separate start/enable calls). `?plan=true` returns the diff without
touching anything.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from wildpc_core.config import WildpcDirError
from wildpc_core.deploy import apply
router = APIRouter(tags=["apply"])
class ApplyRequest(BaseModel):
"""Optional request body for apply."""
name: str | None = None
plan: bool = False
class ApplyResponse(BaseModel):
"""The diff a converge enacted (or would enact, for a plan)."""
status: str
planned: bool
changed: bool
activated: list[str]
restarted: list[str]
deactivated: list[str]
unchanged: list[str]
messages: list[str]
@router.post("/apply", response_model=ApplyResponse)
def run_apply(request: ApplyRequest | None = None) -> ApplyResponse:
"""Converge the running system to match wildpc.yaml.
Renders systemd units + the Caddyfile + tunnel config, then reconciles the
runtime: activate enabled deployments that are down, restart any whose unit
changed, deactivate disabled ones. Pass a name to converge one deployment,
or `plan: true` to compute the diff without changing anything.
"""
name = request.name if request else None
plan = request.plan if request else False
try:
result = apply(target_name=name, plan=plan)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except WildpcDirError as e:
# A fixable misconfiguration (e.g. data_dir points somewhere unwritable), not a
# server fault — return the actionable message so the dashboard can show it.
raise HTTPException(status_code=422, detail=str(e)) from e
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return ApplyResponse(
status="ok",
planned=result.planned,
changed=result.changed,
activated=result.activated,
restarted=result.restarted,
deactivated=result.deactivated,
unchanged=result.unchanged,
messages=result.messages,
)

View File

@@ -0,0 +1,39 @@
"""The relationship model as JSON — repos, `requires` edges, derived status.
Read-only diagnostic (see docs/relationships.md). Everything is computed on the
fly: repos from git, predicates (functional/fresh) from config + git. Nothing
stored.
"""
from __future__ import annotations
import dataclasses
from fastapi import APIRouter
from wildpc_core.audit import suggest_consumption
from wildpc_core.relations import build_model
from wildpc_api.config import get_config
graph_router = APIRouter(tags=["graph"])
@graph_router.get("/graph")
def get_graph() -> dict:
"""The whole relationship model: repos (with freshness), deployment nodes (with
`functional?`), and `requires` edges."""
model = build_model(get_config(), check=True, freshness=True)
return {
"repos": [dataclasses.asdict(r) for r in model.repos],
"nodes": [dataclasses.asdict(n) for n in model.nodes],
"edges": [dataclasses.asdict(e) for e in model.edges],
}
@graph_router.get("/graph/suggestions")
def get_suggestions() -> dict:
"""Undeclared-consumption *suggestions* — an opt-in advisory that matches each
deployment's env endpoint values against provider sockets. Never writes; the
graph itself stays declaration-derived. Accept one by declaring the `requires`."""
return {"suggestions": [dataclasses.asdict(s) for s in suggest_consumption(get_config())]}

View File

@@ -0,0 +1,72 @@
"""Async health checker — fans out HTTP requests to service health endpoints."""
from __future__ import annotations
import asyncio
import time
import httpx
from wildpc_core.registry import NodeRegistry
from wildpc_api.models import HealthStatus
async def check_all_health(registry: NodeRegistry) -> list[HealthStatus]:
"""Check health of all deployed components.
Services with a port + health_path are checked via HTTP.
Managed services without an HTTP health endpoint fall back to systemd unit status.
"""
http_targets: list[tuple[str, str]] = []
systemd_targets: list[str] = []
for _kind, name, deployed in registry.all():
if deployed.port and deployed.health_path:
url = f"http://127.0.0.1:{deployed.port}{deployed.health_path}"
http_targets.append((name, url))
elif deployed.managed and not deployed.schedule:
# Managed service with no HTTP health endpoint — use systemd
systemd_targets.append(name)
tasks: list[asyncio.Task[HealthStatus]] = []
async with httpx.AsyncClient(timeout=3.0) as client:
for name, url in http_targets:
tasks.append(asyncio.ensure_future(_check_http(client, name, url)))
for name in systemd_targets:
tasks.append(asyncio.ensure_future(_check_systemd(name)))
if not tasks:
return []
return list(await asyncio.gather(*tasks))
async def _check_http(client: httpx.AsyncClient, name: str, url: str) -> HealthStatus:
"""Check a single service's health endpoint."""
start = time.monotonic()
try:
resp = await client.get(url)
latency = int((time.monotonic() - start) * 1000)
# 2xx/3xx = the server answered (a 3xx redirect still means it's alive —
# e.g. the gateway's :<port> now redirects to the dashboard subdomain).
if resp.status_code < 400:
return HealthStatus(id=name, status="up", latency_ms=latency)
return HealthStatus(id=name, status="down", latency_ms=latency)
except httpx.HTTPError:
latency = int((time.monotonic() - start) * 1000)
return HealthStatus(id=name, status="down", latency_ms=latency)
async def _check_systemd(name: str) -> HealthStatus:
"""Check a managed service's health via its systemd unit state."""
unit = f"wildpc-{name}.service"
proc = await asyncio.create_subprocess_exec(
"systemctl",
"--user",
"is-active",
unit,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
state = (stdout or b"").decode().strip()
return HealthStatus(id=name, status="up" if state == "active" else "down")

View File

@@ -0,0 +1,159 @@
"""LLM assist — generate a tool-call schema from a CLI's --help via the litellm proxy.
wildpc's first LLM-backed feature. The deterministic parser
(``wildpc_core.tool_schema``) falls back to a single ``command`` string for
subcommand trees (git, wildpc); this asks an LLM to read the recursive ``--help``
and produce a *structured* neutral tool-call core instead.
Goes through wildpc's litellm proxy over its OpenAI-compatible
``/chat/completions`` (model-agnostic; the fleet standardizes on litellm), using
forced tool-calling to constrain the output. Every response is validated
deterministically (``validate_tool_schema_core``); on failure the errors are fed
back to the model to repair, up to a cap — so a weak model's malformed draft is
fixed rather than surfaced. The result is a reviewed draft — never auto-saved.
"""
from __future__ import annotations
import json
import httpx
from wildpc_core.config import read_secret
from wildpc_core.tool_schema import validate_tool_schema_core
from wildpc_api.config import settings
_TIMEOUT = 60.0
# Total attempts = 1 initial + repairs. Sonnet is valid first try; the extra
# rounds salvage weaker models (repairing a bad enum, etc.).
_MAX_ATTEMPTS = 3
_SYSTEM = (
"You convert a command-line tool's --help output into a tool-call schema for "
"an AI agent. Call the `emit_tool_schema` function exactly once. Produce a "
"JSON Schema in `parameters` (type: object) whose properties let an agent "
"invoke the tool: one property per meaningful option or subcommand path, each "
"typed (boolean for flags, string for valued options, enum as a LIST of the "
"allowed values for fixed choices) with a concise description drawn from the "
"help. For a tool with subcommands, include a `subcommand` property (an enum "
"listing the available subcommands) plus the important shared options. `name` "
"must match ^[a-zA-Z0-9_-]{1,64}$. Do not invent options not in the help."
)
_EMIT_TOOL = {
"type": "function",
"function": {
"name": "emit_tool_schema",
"description": "Emit the structured tool-call definition for this CLI.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Sanitized tool name."},
"description": {
"type": "string",
"description": "One-line description of what the tool does.",
},
"parameters": {
"type": "object",
"description": (
"A JSON Schema object (type: object with a properties map) "
"describing the tool's invocation arguments."
),
},
},
"required": ["name", "description", "parameters"],
},
},
}
class LLMAssistError(Exception):
"""The LLM assist couldn't produce a valid schema (config, upstream, or output)."""
def _extract_args(data: dict) -> dict | None:
"""Pull the emit_tool_schema arguments out of a chat-completions response.
Returns the parsed arguments dict (even if not yet valid — the caller
validates), or None if the model didn't call the function / returned non-JSON.
"""
try:
call = data["choices"][0]["message"]["tool_calls"][0]
args = call["function"]["arguments"]
except (KeyError, IndexError, TypeError):
return None
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
return None
return args if isinstance(args, dict) else None
async def _complete(messages: list[dict], key: str) -> dict:
"""One forced-tool-call chat completion against the litellm proxy."""
payload = {
"model": settings.llm_model,
"messages": messages,
"tools": [_EMIT_TOOL],
"tool_choice": {"type": "function", "function": {"name": "emit_tool_schema"}},
}
url = f"{settings.llm_base_url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {key}"}
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
try:
resp = await client.post(url, json=payload, headers=headers)
except httpx.HTTPError as e:
raise LLMAssistError(f"litellm request failed: {e}") from e
if resp.status_code >= 400:
raise LLMAssistError(f"litellm returned {resp.status_code}: {resp.text[:300]}")
return resp.json()
def _repair_message(prior: dict, errors: list[str]) -> dict:
"""A user turn that shows the invalid schema + its errors and asks for a fix."""
return {
"role": "user",
"content": (
"Your previous emit_tool_schema output failed validation:\n"
+ "\n".join(f"- {e}" for e in errors)
+ "\n\nHere is what you returned:\n"
+ json.dumps(prior, indent=2)
+ "\n\nCall emit_tool_schema again with every problem fixed. Remember: "
"`enum` must be a list of the allowed values, not a count."
),
}
async def generate_tool_schema_llm(help_text: str, name: str) -> dict:
"""Ask the LLM for a structured neutral tool-call core, repairing invalid
output against ``validate_tool_schema_core`` until it passes (up to
``_MAX_ATTEMPTS``). Raises ``LLMAssistError`` on config/upstream failure or if
it can't be made valid."""
key = read_secret(settings.llm_api_key_secret)
if not key:
raise LLMAssistError(
f"LLM assist enabled but secret '{settings.llm_api_key_secret}' is unset."
)
base = [
{"role": "system", "content": _SYSTEM},
{"role": "user", "content": f"Tool name: {name}\n\n--- help ---\n{help_text}"},
]
messages = list(base)
errors = ["model did not call emit_tool_schema with JSON arguments"]
for _attempt in range(_MAX_ATTEMPTS):
data = await _complete(messages, key)
args = _extract_args(data)
if args is not None:
# Pin the name we were given so a model name-drift never costs a repair.
args["name"] = name
errors = validate_tool_schema_core(args)
if not errors:
return args
# Rebuild from base + a single repair turn (avoids provider-specific
# tool_call/tool_result threading; each repair is a fresh forced call).
messages = [*base, _repair_message(args or {}, errors)]
raise LLMAssistError(
f"could not produce a valid schema after {_MAX_ATTEMPTS} attempts: "
+ "; ".join(errors)
)

View File

@@ -0,0 +1,102 @@
"""Log streaming — tail journalctl output for systemd-managed services."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from fastapi import APIRouter, HTTPException, Query, status
from starlette.responses import StreamingResponse
from wildpc_core.generators.systemd import unit_name
from wildpc_api.config import get_wildpc_root
router = APIRouter(prefix="/logs", tags=["logs"])
UNIT_PREFIX = "wildpc-"
@router.get("/{name}", response_model=None)
async def get_logs(
name: str,
n: int = Query(default=100, ge=1, le=5000, description="Number of lines"),
follow: bool = Query(default=False, description="Stream new lines via SSE"),
) -> StreamingResponse | dict:
"""Get logs for a systemd-managed service."""
root = get_wildpc_root()
if root:
from wildpc_core.config import load_config
config = load_config(root)
# A name may span kinds — the managed (systemd) one owns the journal.
dep_kind = next(
(
(k, s)
for k, s in config.deployments_named(name)
if getattr(s, "manage", None)
),
None,
)
if dep_kind is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service",
)
kind, _spec = dep_kind
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Wild PC root not available",
)
unit = unit_name(name, kind)
if follow:
return StreamingResponse(
_follow_logs(unit, n),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Static tail
proc = await asyncio.create_subprocess_exec(
"journalctl",
"--user",
"-u",
unit,
"-n",
str(n),
"--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
lines = (stdout or b"").decode().splitlines()
return {"name": name, "lines": lines}
async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:
"""Stream journalctl -f output as SSE events."""
proc = await asyncio.create_subprocess_exec(
"journalctl",
"--user",
"-u",
unit,
"-n",
str(n),
"-f",
"--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
assert proc.stdout is not None
async for line in proc.stdout:
text = line.decode().rstrip()
yield f"data: {text}\n\n"
except asyncio.CancelledError:
pass
finally:
proc.kill()
await proc.wait()

View File

@@ -0,0 +1,186 @@
"""Wild PC API — dashboard data, health aggregation, and service management."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import StreamingResponse
from wildpc_api.agent_sessions import manager as agent_session_manager
from wildpc_api.agents import router as agents_router
from wildpc_api.config import get_registry, settings
from wildpc_api.config_editor import router as config_router
from wildpc_api.deploy_routes import router as deploy_router
from wildpc_api.graph import graph_router
from wildpc_api.repos import repos_router
from wildpc_api.logs import router as logs_router
from wildpc_api.routes import router as dashboard_router
from wildpc_api.secrets import router as secrets_router
from wildpc_api.services import router as services_router
from wildpc_api.stream import (
close_all_subscribers,
health_poll_loop,
subscribe,
unsubscribe,
)
from wildpc_api.nodes import router as nodes_router
from wildpc_api.programs import programs_router
logger = logging.getLogger(__name__)
# Set by _watch_shutdown when uvicorn begins its shutdown sequence.
_shutting_down = False
async def _watch_shutdown(server: uvicorn.Server) -> None:
"""Poll uvicorn's should_exit flag and close SSE subscribers promptly."""
while not server.should_exit:
await asyncio.sleep(0.5)
global _shutting_down
_shutting_down = True
close_all_subscribers()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
global _shutting_down
_shutting_down = False
poll_task = asyncio.create_task(health_poll_loop())
# --- Mesh coordination (opt-in) ---
nats_client = None
mdns_service = None
if settings.nats_enabled:
try:
from wildpc_api.nats_client import WildpcNATSClient
registry = get_registry()
nats_client = WildpcNATSClient(
local_hostname=registry.node.hostname,
local_registry=registry,
servers=settings.nats_url,
token=settings.nats_token,
)
await nats_client.start()
app.state.nats_client = nats_client
except Exception:
logger.exception("Failed to start NATS mesh client")
nats_client = None
if settings.mdns_enabled:
try:
from wildpc_api.mdns import WildpcMDNS
registry = get_registry()
mdns_service = WildpcMDNS(
hostname=registry.node.hostname,
gateway_port=registry.node.gateway_port,
api_port=settings.port,
)
mdns_service.start()
app.state.mdns = mdns_service
except Exception:
logger.exception("Failed to start mDNS")
mdns_service = None
yield
_shutting_down = True
poll_task.cancel()
if nats_client:
await nats_client.stop()
if mdns_service:
mdns_service.stop()
await agent_session_manager.close_all()
close_all_subscribers()
app = FastAPI(
title="wildpc-api",
description="Wild PC API and service management",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(config_router)
app.include_router(dashboard_router)
app.include_router(logs_router)
app.include_router(nodes_router)
app.include_router(secrets_router)
app.include_router(services_router)
app.include_router(programs_router)
app.include_router(deploy_router)
app.include_router(agents_router)
app.include_router(graph_router)
app.include_router(repos_router)
@app.get("/health")
def health() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "ok"}
@app.get("/stream")
async def sse_stream() -> StreamingResponse:
"""SSE stream — pushes health updates and service action events."""
q = subscribe()
async def event_generator() -> AsyncGenerator[str, None]:
try:
yield "event: connected\ndata: {}\n\n"
while True:
msg = await q.get()
if not msg:
break
yield msg
except asyncio.CancelledError:
pass
finally:
unsubscribe(q)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
def run() -> None:
"""Run the application with uvicorn."""
config = uvicorn.Config(
"wildpc_api.main:app",
host=settings.host,
port=settings.port,
reload=False,
)
server = uvicorn.Server(config)
async def serve_with_watcher() -> None:
watcher = asyncio.create_task(_watch_shutdown(server))
await server.serve()
watcher.cancel()
asyncio.run(serve_with_watcher())
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,124 @@
"""mDNS discovery for wildpc mesh — zero-config LAN peer discovery.
Advertises this node as _wildpc._tcp and browses for _wildpc._tcp peers. This is
the on-LAN convenience path; cross-network meshes use explicit NATS seed URLs.
"""
from __future__ import annotations
import logging
import socket
from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf
logger = logging.getLogger(__name__)
WILDPC_SERVICE_TYPE = "_wildpc._tcp.local."
class WildpcMDNS:
"""Advertise this wildpc node and discover peers via mDNS."""
def __init__(
self,
hostname: str,
gateway_port: int,
api_port: int,
) -> None:
self._hostname = hostname
self._gateway_port = gateway_port
self._api_port = api_port
self._zeroconf: Zeroconf | None = None
self._browsers: list[ServiceBrowser] = []
self._service_info: ServiceInfo | None = None
# Discovered state
self.peers: dict[
str, dict
] = {} # hostname -> {gateway_port, api_port, addresses}
def _on_service_state_change(
self,
zeroconf: Zeroconf,
service_type: str,
name: str,
state_change: ServiceStateChange,
) -> None:
"""Handle discovered/removed services."""
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
info = zeroconf.get_service_info(service_type, name)
if info is None:
return
if service_type == WILDPC_SERVICE_TYPE:
self._handle_wildpc_peer(info)
elif state_change == ServiceStateChange.Removed:
if service_type == WILDPC_SERVICE_TYPE:
# Extract hostname from service name (format: "hostname._wildpc._tcp.local.")
peer_hostname = name.replace(f".{WILDPC_SERVICE_TYPE}", "")
if peer_hostname != self._hostname and peer_hostname in self.peers:
del self.peers[peer_hostname]
logger.info("mDNS: peer %s removed", peer_hostname)
def _handle_wildpc_peer(self, info: ServiceInfo) -> None:
"""Process a discovered wildpc peer."""
props = {
k.decode() if isinstance(k, bytes) else k: v.decode()
if isinstance(v, bytes)
else v
for k, v in info.properties.items()
}
peer_hostname = props.get("hostname", "")
if not peer_hostname or peer_hostname == self._hostname:
return
addresses = [
socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4
]
self.peers[peer_hostname] = {
"gateway_port": int(props.get("gateway_port", 9000)),
"api_port": int(props.get("api_port", 9020)),
"addresses": addresses,
}
logger.info("mDNS: discovered peer %s at %s", peer_hostname, addresses)
def start(self) -> None:
"""Start advertising and browsing."""
self._zeroconf = Zeroconf()
# Advertise ourselves
self._service_info = ServiceInfo(
WILDPC_SERVICE_TYPE,
f"{self._hostname}.{WILDPC_SERVICE_TYPE}",
port=self._gateway_port,
properties={
"hostname": self._hostname,
"gateway_port": str(self._gateway_port),
"api_port": str(self._api_port),
},
)
self._zeroconf.register_service(self._service_info)
logger.info(
"mDNS: advertising %s on port %d", self._hostname, self._gateway_port
)
# Browse for peer wildpc nodes
self._browsers.append(
ServiceBrowser(
self._zeroconf,
WILDPC_SERVICE_TYPE,
handlers=[self._on_service_state_change],
)
)
def stop(self) -> None:
"""Stop advertising and close."""
if self._zeroconf:
if self._service_info:
self._zeroconf.unregister_service(self._service_info)
self._zeroconf.close()
self._zeroconf = None
self._browsers.clear()
logger.info("mDNS: stopped")

View File

@@ -0,0 +1,78 @@
"""Mesh state manager — aggregates remote node registries."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from wildpc_core.registry import NodeRegistry
logger = logging.getLogger(__name__)
# Remote registries older than this are considered stale.
STALE_TTL_SECONDS = 300 # 5 minutes
@dataclass
class RemoteNode:
"""A remote node's registry and metadata."""
registry: NodeRegistry
last_seen: float = field(default_factory=time.time)
online: bool = True
@property
def is_stale(self) -> bool:
return (time.time() - self.last_seen) > STALE_TTL_SECONDS
class MeshStateManager:
"""Singleton holding remote node state discovered via MQTT.
Thread-safe for reads from the FastAPI request handlers.
Mutations happen only from the MQTT callback task.
"""
def __init__(self) -> None:
self._nodes: dict[str, RemoteNode] = {}
def update_node(self, hostname: str, registry: NodeRegistry) -> None:
"""Add or update a remote node's registry."""
self._nodes[hostname] = RemoteNode(registry=registry)
logger.info(
"Mesh: updated node %s (%d deployed)", hostname, len(registry.deployed)
)
def set_offline(self, hostname: str) -> None:
"""Mark a node as offline (LWT received)."""
if hostname in self._nodes:
self._nodes[hostname].online = False
logger.info("Mesh: node %s went offline", hostname)
def remove_node(self, hostname: str) -> None:
"""Remove a node entirely."""
if self._nodes.pop(hostname, None):
logger.info("Mesh: removed node %s", hostname)
def get_node(self, hostname: str) -> RemoteNode | None:
"""Get a specific remote node."""
return self._nodes.get(hostname)
def all_nodes(self, *, include_stale: bool = False) -> dict[str, RemoteNode]:
"""Return all remote nodes, optionally filtering out stale ones."""
if include_stale:
return dict(self._nodes)
return {h: n for h, n in self._nodes.items() if not n.is_stale}
def prune_stale(self) -> list[str]:
"""Remove nodes that have gone stale. Returns list of pruned hostnames."""
pruned = [h for h, n in self._nodes.items() if n.is_stale]
for h in pruned:
del self._nodes[h]
logger.info("Mesh: pruned stale node %s", h)
return pruned
# Module-level singleton — imported by MQTT client and API routes.
mesh_state = MeshStateManager()

View File

@@ -0,0 +1,55 @@
"""Regenerate the gateway with cross-node (remote) routes from the live mesh.
Local routes come from `wildpc apply` (static). Remote routes are dynamic — they
appear/vanish as peers join/leave — so the API owns them: on a mesh change it
re-renders the Caddyfile (same generator `apply` uses, plus remote routes for
online peers) and reloads the gateway iff the content changed.
Safety: with no cross-node `requires`, the output equals the local-only Caddyfile,
so this is a verified no-op until this node actually consumes a peer service.
"""
from __future__ import annotations
import asyncio
import logging
import subprocess
from wildpc_core.config import SPECS_DIR
from wildpc_core.generators.caddyfile import generate_caddyfile_from_registry
from wildpc_api.config import get_registry
from wildpc_api.mesh import mesh_state
logger = logging.getLogger(__name__)
_GATEWAY_UNIT = "wildpc-wildpc-gateway.service"
def _regenerate(reload: bool) -> bool:
try:
reg = get_registry()
except Exception:
return False
remotes = {h: n.registry for h, n in mesh_state.all_nodes().items()}
try:
content = generate_caddyfile_from_registry(reg, remotes)
except Exception:
logger.exception("mesh gateway: Caddyfile generation failed")
return False
path = SPECS_DIR / "Caddyfile"
old = path.read_text() if path.exists() else ""
if content == old:
return False
path.write_text(content)
logger.info("mesh gateway: Caddyfile updated with cross-node routes")
if reload:
subprocess.run(
["systemctl", "--user", "reload", _GATEWAY_UNIT], check=False
)
return True
async def refresh_remote_routes(reload: bool = True) -> bool:
"""Async wrapper — runs the blocking regen off the event loop."""
return await asyncio.to_thread(_regenerate, reload)

View File

@@ -0,0 +1,105 @@
"""Mesh wire format — (de)serialize a NodeRegistry for cross-node transport.
Transport-agnostic (no MQTT/NATS imports). Only the fields needed for mesh
routing are included; env vars, run_cmd, and wildpc_root are **excluded** to
avoid leaking secrets — this invariant is load-bearing and must be preserved by
any transport that carries this payload.
"""
from __future__ import annotations
import json
from wildpc_core.registry import (
Deployment,
NodeConfig,
NodeRegistry,
)
def registry_to_json(registry: NodeRegistry) -> str:
"""Serialize a NodeRegistry to JSON (secret-stripped)."""
data: dict = {
"node": {
"hostname": registry.node.hostname,
"gateway_port": registry.node.gateway_port,
# acme domain — lets peers build launch URLs (<subdomain>.<gateway_domain>)
# for this node's exposed apps. Omitted when the node has no domain.
"gateway_domain": registry.node.gateway_domain,
# fleet role — so peers know which node is the config/secret authority.
"role": registry.node.role,
# routable host peers proxy to for this node's services.
"address": registry.node.address,
},
"deployed": {},
}
for _kind, name, comp in registry.all():
entry: dict = {
"manager": comp.manager,
"launcher": comp.launcher,
"kind": comp.kind,
}
if comp.stack:
entry["stack"] = comp.stack
if comp.description:
entry["description"] = comp.description
if comp.port is not None:
entry["port"] = comp.port
if comp.health_path:
entry["health_path"] = comp.health_path
if comp.subdomain:
entry["subdomain"] = comp.subdomain
if comp.schedule:
entry["schedule"] = comp.schedule
if comp.managed:
entry["managed"] = comp.managed
# Socket surface + external target — so a peer can resolve cross-node
# consumption endpoints (still no secrets: only ports/URLs).
if getattr(comp, "tcp_port", None) is not None:
entry["tcp_port"] = comp.tcp_port
if getattr(comp, "base_url", None):
entry["base_url"] = comp.base_url
# requires — deployment refs (no secrets), so peers can draw cross-node deps.
if getattr(comp, "requires", None):
entry["requires"] = comp.requires
data["deployed"][NodeRegistry.key(comp.kind, name)] = entry
return json.dumps(data)
def json_to_registry(payload: str) -> NodeRegistry:
"""Deserialize a NodeRegistry from a JSON payload."""
data = json.loads(payload)
node_data = data.get("node", {})
node = NodeConfig(
hostname=node_data.get("hostname", ""),
wildpc_root=node_data.get("wildpc_root"),
gateway_port=node_data.get("gateway_port", 9000),
gateway_domain=node_data.get("gateway_domain"),
role=node_data.get("role", "follower"),
address=node_data.get("address"),
)
deployed: dict[str, Deployment] = {}
for key, comp_data in data.get("deployed", {}).items():
key_kind, name = key.split("/", 1) if "/" in key else (None, key)
kind = comp_data.get("kind") or key_kind or "service"
deployed[NodeRegistry.key(kind, name)] = Deployment(
manager=comp_data.get("manager", "systemd"),
launcher=comp_data.get("launcher"),
run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}),
description=comp_data.get("description"),
name=name,
kind=kind,
stack=comp_data.get("stack"),
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
subdomain=comp_data.get("subdomain"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
tcp_port=comp_data.get("tcp_port"),
base_url=comp_data.get("base_url"),
requires=comp_data.get("requires", []),
)
return NodeRegistry(node=node, deployed=deployed)

View File

@@ -0,0 +1,258 @@
"""Response models for the dashboard API."""
from pydantic import BaseModel
class SystemdInfo(BaseModel):
"""Systemd unit information for a managed component."""
unit_name: str
unit_path: str
timer: bool = False
class DeploymentSummary(BaseModel):
"""Summary of a single component."""
id: str
category: str | None = None # "program", "service", or "job"
description: str | None = None
kind: str | None = None # derived: service|job|tool|static|reference
stack: str | None = None
manager: str | None = None # systemd|caddy|path|none
launcher: str | None = None # python|command|container|compose|node (systemd only)
port: int | None = None
health_path: str | None = None
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
managed: bool = False
systemd: SystemdInfo | None = None
version: str | None = None
source: str | None = None
repo: str | None = None
ref: str | None = None
commands: dict[str, list[list[str]]] | None = None
system_dependencies: list[str] = []
schedule: str | None = None
installed: bool | None = None
active: bool | None = None # uniform lifecycle state (on PATH / running / served)
enabled: bool = True # declared desired state; `apply` converges to it
node: str | None = None
class DeploymentDetail(DeploymentSummary):
"""Full detail for a single component, including raw manifest."""
manifest: dict
class ServiceSummary(BaseModel):
"""Summary of a service — a systemd daemon OR a caddy-served static site.
Both are "services" (exposed, URL-reachable things); `kind`/`manager`
distinguish them (service+systemd vs static+caddy).
"""
id: str
description: str | None = None
stack: str | None = None
kind: str | None = None # service | static
manager: str | None = None # systemd | caddy
launcher: str | None = None # python|command|container|compose|node (systemd only)
run_target: str | None = None # what it runs: program name, argv, image, …
port: int | None = None
health_path: str | None = None
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
managed: bool = False
systemd: SystemdInfo | None = None
program: str | None = None # the program this deployment references, if any
source: str | None = None
enabled: bool = True # declared desired state; `apply` converges to it
node: str | None = None
class ServiceDetail(ServiceSummary):
"""Full detail for a service, including raw manifest."""
manifest: dict
class JobSummary(BaseModel):
"""Summary of a job (scheduled task)."""
id: str
description: str | None = None
stack: str | None = None
launcher: str | None = None # python|command|container|compose|node
run_target: str | None = None # what it runs: program name, argv, …
schedule: str | None = None
managed: bool = False
systemd: SystemdInfo | None = None
program: str | None = None # the program this deployment references, if any
source: str | None = None
enabled: bool = True # declared desired state; `apply` converges to it
node: str | None = None
class JobDetail(JobSummary):
"""Full detail for a job, including raw manifest."""
manifest: dict
class DeploymentRef(BaseModel):
"""A reference to one of a program's deployments (name + its derived kind)."""
name: str
kind: str # service | job | tool | static | reference
class ProgramSummary(BaseModel):
"""Summary of a program (software catalog entry).
A program has NO kind of its own — it *has deployments*, each with a kind
(a program can be a tool AND a job). `deployments` is that list.
"""
id: str
description: str | None = None
stack: str | None = None
version: str | None = None
source: str | None = None
repo: str | None = None
ref: str | None = None
commands: dict[str, list[list[str]]] | None = None
system_dependencies: list[str] = []
installed: bool | None = None
active: bool | None = None # uniform lifecycle state (on PATH / running / served)
actions: list[str] = []
deployments: list[DeploymentRef] = [] # this program's deployments (name + kind)
node: str | None = None
class ProgramDetail(ProgramSummary):
"""Full detail for a program, including raw manifest."""
manifest: dict
class ToolStatusModel(BaseModel):
"""One host toolchain a stack needs, and whether it's present where used."""
command: str
purpose: str
phase: str # "run" | "build" | "both"
present: bool
install_hint: str
version: str | None = None
class StackStatusModel(BaseModel):
"""A stack's dependency health — its tools + who uses it. Powers the Stacks page."""
name: str
tools: list[ToolStatusModel]
programs: list[str]
deployments: list[str]
verbs: list[str]
has_enabled_deployment: bool
in_use: bool
ok: bool
class HealthStatus(BaseModel):
"""Health status of a single component."""
id: str
status: str # "up", "down", "unknown"
latency_ms: int | None = None
class StatusResponse(BaseModel):
"""Aggregated health status for all exposed components."""
statuses: list[HealthStatus]
class GatewayRoute(BaseModel):
"""One gateway route: a public address mapped to a target.
kind is `static` (Caddy serves a built dir), `proxy` (reverse-proxy a local
service), or `remote` (reverse-proxy another node). address is a path prefix
(`/foo`) or a host (`foo.lan`); target is the serve dir or `host:port`.
"""
address: str
kind: str
target: str
name: str | None = None
node: str
# Public exposure via the tunnel: the public URL, or None if this route is
# LAN-only. Set when the backing service has `public: true`.
public_url: str | None = None
class GatewayInfo(BaseModel):
"""Gateway configuration summary."""
port: int
hostname: str
deployment_count: int
service_count: int
managed_count: int
routes: list[GatewayRoute] = []
# TLS mode: None/"off" → HTTP-only; "acme" → Let's Encrypt wildcard (publicly
# trusted, no client CA setup) for host routes.
tls: str | None = None
# Routing/exposure config (editable from the dashboard).
domain: str | None = None # acme zone → <service>.<domain>
public_domain: str | None = None # tunnel zone → <service>.<public_domain>
tunnel_id: str | None = None
tunnel_connected: bool = False # cloudflared service active
class GatewayConfigRequest(BaseModel):
"""Editable gateway settings (saved to wildpc.yaml; deploy to apply)."""
tls: str | None = None
domain: str | None = None
public_domain: str | None = None
tunnel_id: str | None = None
class NodeSummary(BaseModel):
"""Summary of a discovered node in the mesh."""
hostname: str
gateway_port: int
gateway_domain: str | None = None # acme domain → dashboard at wildpc.<domain>
deployed_count: int
service_count: int
is_local: bool = False
online: bool = True
is_stale: bool = False
last_seen: float | None = None
class NodeDetail(NodeSummary):
"""Full detail for a node, including its deployed components."""
deployed: list[DeploymentSummary] = []
class MeshStatus(BaseModel):
"""Current state of the mesh coordination layer."""
enabled: bool = False
connected: bool = False
nats_url: str | None = None
mdns_enabled: bool = False
peer_count: int = 0
peers: list[str] = []
class ServiceActionResponse(BaseModel):
"""Response from a service management action."""
program: str
action: str
status: str

View File

@@ -0,0 +1,276 @@
"""NATS JetStream client for inter-node mesh coordination.
Replaces the MQTT transport. State lives in a JetStream **KV bucket** rather than
retained MQTT messages:
wildpc-registry — key=<hostname>, value=secret-stripped NodeRegistry JSON
Lifecycle:
* on connect: ensure the bucket, PUT our registry, seed local state from every
existing key, then watch the bucket for peer changes.
* heartbeat: re-PUT our registry every HEARTBEAT_SEC so peers refresh their
last-seen clock (crash liveness rides the existing stale-TTL).
* graceful stop: DELETE our key → peers get an immediate offline signal.
Being asyncio-native, the watch callback calls ``broadcast`` directly — no
cross-thread ``run_coroutine_threadsafe`` hop (which the paho client needed).
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import nats
from nats.js.api import KeyValueConfig
from wildpc_core.registry import NodeRegistry
from wildpc_api.mesh import mesh_state
from wildpc_api.mesh_gateway import refresh_remote_routes
from wildpc_api.mesh_wire import json_to_registry, registry_to_json
from wildpc_api.stream import broadcast
logger = logging.getLogger(__name__)
REGISTRY_BUCKET = "wildpc-registry"
PRESENCE_BUCKET = "wildpc-presence"
CONFIG_BUCKET = "wildpc-config"
HEARTBEAT_SEC = 30.0
PRUNE_SEC = 30.0
PRESENCE_TTL = 90.0 # a node whose presence key expires within this is gone
class WildpcNATSClient:
"""Async NATS/JetStream mesh client."""
def __init__(
self,
local_hostname: str,
local_registry: NodeRegistry,
servers: str | list[str] = "nats://localhost:4222",
token: str | None = None,
) -> None:
self._local_hostname = local_hostname
self._local_registry = local_registry
self._servers = servers
self._token = token or None
self._nc: nats.NATS | None = None
self._kv = None
self._presence_kv = None
self._config_kv = None
self._tasks: list[asyncio.Task] = []
self._last_json: dict[str, str] = {}
self._online: set[str] = set()
@property
def connected(self) -> bool:
return self._nc is not None and self._nc.is_connected
@property
def servers(self) -> str | list[str]:
return self._servers
@property
def role(self) -> str:
"""This node's fleet role — 'authority' or 'follower'."""
return self._local_registry.node.role
async def start(self) -> None:
"""Connect, publish our registry, seed state, and start watchers."""
# A `tls://` server URL makes nats-py verify the server against the system
# CA bundle — which trusts the wildcard's Let's Encrypt issuer, so no custom
# CA is needed. `token` authenticates this node to the broker.
self._nc = await nats.connect(
self._servers,
name=f"wildpc-{self._local_hostname}",
token=self._token,
max_reconnect_attempts=-1, # reconnect forever — nodes come and go
)
js = self._nc.jetstream()
self._kv = await self._ensure_bucket(js, REGISTRY_BUCKET, history=1)
# Presence: a short-TTL key each node renews; its expiry = the node is gone.
self._presence_kv = await self._ensure_bucket(
js, PRESENCE_BUCKET, history=1, ttl=PRESENCE_TTL
)
# Shared config: authority-written, followers watch + reconcile.
self._config_kv = await self._ensure_bucket(js, CONFIG_BUCKET, history=5)
await self.publish_registry(self._local_registry)
await self._presence_kv.put(self._local_hostname, b"online")
await self._seed_existing()
await refresh_remote_routes() # establish any cross-node routes on startup
self._tasks = [
asyncio.create_task(self._watch_loop()),
asyncio.create_task(self._config_watch_loop()),
asyncio.create_task(self._heartbeat_loop()),
asyncio.create_task(self._prune_loop()),
]
logger.info(
"NATS mesh client started (servers=%s, role=%s)",
self._servers,
self.role,
)
@staticmethod
async def _ensure_bucket(js, bucket: str, *, history: int = 1, ttl=None):
"""Bind an existing KV bucket or create it."""
try:
return await js.key_value(bucket)
except Exception:
return await js.create_key_value(
config=KeyValueConfig(bucket=bucket, history=history, ttl=ttl)
)
async def stop(self) -> None:
"""Delete our key (immediate offline to peers) and disconnect."""
for t in self._tasks:
t.cancel()
for t in self._tasks:
with contextlib.suppress(asyncio.CancelledError, Exception):
await t
self._tasks = []
if self._kv is not None:
with contextlib.suppress(Exception):
await self._kv.delete(self._local_hostname)
if self._presence_kv is not None:
with contextlib.suppress(Exception):
await self._presence_kv.delete(self._local_hostname)
if self._nc is not None:
# Bound the drain so a wedged connection can't hang systemd shutdown.
with contextlib.suppress(Exception):
await asyncio.wait_for(self._nc.drain(), timeout=5.0)
with contextlib.suppress(Exception):
await self._nc.close()
self._nc = None
logger.info("NATS mesh client stopped")
async def publish_registry(self, registry: NodeRegistry) -> None:
"""PUT (or refresh) our local registry into the KV bucket."""
self._local_registry = registry
if self._kv is None:
return
await self._kv.put(
self._local_hostname, registry_to_json(registry).encode()
)
async def _seed_existing(self) -> None:
"""Load every peer key already present in the bucket."""
if self._kv is None:
return
try:
keys = await self._kv.keys()
except Exception:
keys = [] # empty bucket raises NoKeysError in nats-py
for key in keys:
if key == self._local_hostname:
continue
with contextlib.suppress(Exception):
entry = await self._kv.get(key)
if entry.value:
self._apply_put(key, entry.value.decode())
async def _watch_loop(self) -> None:
assert self._kv is not None
watcher = await self._kv.watchall()
async for entry in watcher:
if entry is None: # "caught up with current values" sentinel
continue
key = entry.key
if key == self._local_hostname:
continue
try:
if entry.operation in ("DEL", "PURGE"):
await self._apply_delete(key)
elif entry.value:
changed = self._apply_put(key, entry.value.decode())
if changed:
await broadcast(
"mesh", {"event": "node_updated", "hostname": key}
)
asyncio.create_task(refresh_remote_routes())
except Exception:
logger.exception("Error handling mesh entry for %s", key)
def _apply_put(self, hostname: str, payload: str) -> bool:
"""Update mesh state from a peer PUT. Returns True if content changed."""
registry = json_to_registry(payload)
mesh_state.update_node(hostname, registry) # always refresh last-seen
self._online.add(hostname)
changed = self._last_json.get(hostname) != payload
self._last_json[hostname] = payload
return changed
async def _apply_delete(self, hostname: str) -> None:
mesh_state.set_offline(hostname)
self._online.discard(hostname)
self._last_json.pop(hostname, None)
await broadcast("mesh", {"event": "node_offline", "hostname": hostname})
asyncio.create_task(refresh_remote_routes())
async def _heartbeat_loop(self) -> None:
while True:
await asyncio.sleep(HEARTBEAT_SEC)
with contextlib.suppress(Exception):
await self.publish_registry(self._local_registry)
if self._presence_kv is not None:
with contextlib.suppress(Exception):
await self._presence_kv.put(self._local_hostname, b"online")
# --- Shared config (authority writes, followers reconcile) ---
async def get_shared_config(self, key: str) -> str | None:
"""Read a shared-config value from the mesh (None if unset)."""
if self._config_kv is None:
return None
try:
entry = await self._config_kv.get(key)
return entry.value.decode() if entry.value else None
except Exception:
return None
async def put_shared_config(self, key: str, value: str) -> None:
"""Write a shared-config value. Only the authority may write."""
if self.role != "authority":
raise PermissionError("only the authority node may write shared config")
if self._config_kv is None:
raise RuntimeError("config bucket not available")
await self._config_kv.put(key, value.encode())
async def list_shared_config(self) -> list[str]:
"""All shared-config keys (empty if none/unavailable)."""
if self._config_kv is None:
return []
try:
return sorted(await self._config_kv.keys())
except Exception:
return [] # empty bucket raises NoKeysError in nats-py
async def _config_watch_loop(self) -> None:
"""Watch shared config; announce changes so followers can reconcile.
The reconcile action (trigger `wildpc apply` on a follower) hangs off this
SSE event; it's inert on the authority and on a single node.
"""
if self._config_kv is None:
return
watcher = await self._config_kv.watchall()
async for entry in watcher:
if entry is None:
continue
op = "delete" if entry.operation in ("DEL", "PURGE") else "put"
await broadcast(
"mesh", {"event": "config_changed", "key": entry.key, "op": op}
)
async def _prune_loop(self) -> None:
"""Mark crashed peers (no refresh within the stale TTL) offline."""
while True:
await asyncio.sleep(PRUNE_SEC)
all_nodes = mesh_state.all_nodes(include_stale=True)
for host in list(self._online):
node = all_nodes.get(host)
if node is None or node.is_stale:
await self._apply_delete(host)

View File

@@ -0,0 +1,201 @@
"""Nodes router — discover and inspect mesh nodes."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel
from wildpc_api.config import get_registry, settings
from wildpc_api.mesh import mesh_state
from wildpc_api.models import DeploymentSummary, MeshStatus, NodeDetail, NodeSummary
router = APIRouter(tags=["nodes"])
class ConfigValue(BaseModel):
value: str
@router.get("/mesh/config")
async def list_mesh_config(request: Request) -> dict:
"""List shared-config keys + this node's role (only the authority may write)."""
client = getattr(request.app.state, "nats_client", None)
if client is None:
return {"keys": [], "role": get_registry().node.role}
return {"keys": await client.list_shared_config(), "role": client.role}
@router.get("/mesh/config/{key:path}")
async def get_mesh_config(key: str, request: Request) -> dict:
"""Read a shared-config value."""
client = getattr(request.app.state, "nats_client", None)
value = await client.get_shared_config(key) if client else None
if value is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"config key '{key}' not set")
return {"key": key, "value": value}
@router.put("/mesh/config/{key:path}")
async def set_mesh_config(key: str, body: ConfigValue, request: Request) -> dict:
"""Write a shared-config value (authority only)."""
client = getattr(request.app.state, "nats_client", None)
if client is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "mesh not enabled")
try:
await client.put_shared_config(key, body.value)
except PermissionError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc)) from exc
return {"key": key, "ok": True}
def _local_node_summary(registry: object) -> NodeSummary:
"""Build a NodeSummary for the local node from the registry."""
return NodeSummary(
hostname=registry.node.hostname,
gateway_port=registry.node.gateway_port,
gateway_domain=registry.node.gateway_domain,
deployed_count=len(registry.deployed),
service_count=sum(1 for d in registry.deployed.values() if d.port is not None),
is_local=True,
online=True,
is_stale=False,
)
def _remote_node_summary(hostname: str, remote: object) -> NodeSummary:
"""Build a NodeSummary from a RemoteNode."""
reg = remote.registry
return NodeSummary(
hostname=hostname,
gateway_port=reg.node.gateway_port,
gateway_domain=getattr(reg.node, "gateway_domain", None),
deployed_count=len(reg.deployed),
service_count=sum(1 for d in reg.deployed.values() if d.port is not None),
is_local=False,
online=remote.online,
is_stale=remote.is_stale,
last_seen=remote.last_seen,
)
def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSummary]:
"""Convert deployed components from a registry into DeploymentSummary list."""
summaries = []
for _kind, name, d in registry.all():
summaries.append(
DeploymentSummary(
id=name,
category="job" if d.schedule else "service",
description=d.description,
kind=d.kind,
stack=d.stack,
manager=d.manager,
launcher=d.launcher,
port=d.port,
health_path=d.health_path,
subdomain=d.subdomain,
managed=d.managed,
schedule=d.schedule,
node=hostname,
)
)
return summaries
@router.get("/mesh/status", response_model=MeshStatus)
def get_mesh_status(request: Request) -> MeshStatus:
"""Get the current state of the mesh coordination layer."""
nats_client = getattr(request.app.state, "nats_client", None)
peers = list(mesh_state.all_nodes(include_stale=True).keys())
return MeshStatus(
enabled=settings.nats_enabled,
connected=nats_client.connected if nats_client else False,
nats_url=str(nats_client.servers) if nats_client else None,
mdns_enabled=settings.mdns_enabled,
peer_count=len(peers),
peers=peers,
)
@router.get("/nodes", response_model=list[NodeSummary])
def list_nodes() -> list[NodeSummary]:
"""List all known nodes (local + discovered remote)."""
registry = get_registry()
nodes = [_local_node_summary(registry)]
for hostname, remote in mesh_state.all_nodes(include_stale=True).items():
nodes.append(_remote_node_summary(hostname, remote))
return nodes
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
def _endpoints_of_registry(d: object) -> list[dict]:
"""Derive display endpoints from a registry deployment. Mirrors the local
relations derivation, which gates the http endpoint on being exposed — here the
registry's `subdomain` is that signal (a reach:off service has none). Without
this, remote reach:off services show a phantom port (e.g. wildpc-gateway :9000)."""
eps: list[dict] = []
port = getattr(d, "port", None)
if port is not None and getattr(d, "subdomain", None):
eps.append({"protocol": "http", "port": port})
tcp = getattr(d, "tcp_port", None)
if tcp is not None:
eps.append({"protocol": _TCP_PROTOCOL.get(tcp, "tcp"), "port": tcp})
return eps
@router.get("/mesh/deployments")
def mesh_deployments() -> dict:
"""Flattened remote (mesh-discovered) deployments with derived endpoints — the
data the System Map needs to render other machines. Local node excluded (it's
already in /graph). Each entry carries its node's `domain` (gateway acme domain)
so peers can build launch URLs `<subdomain>.<domain>` for exposed apps."""
out: list[dict] = []
for hostname, remote in mesh_state.all_nodes(include_stale=True).items():
domain = getattr(remote.registry.node, "gateway_domain", None)
for _kind, name, d in remote.registry.all():
out.append(
{
"name": name,
"kind": d.kind,
"node": hostname,
"domain": domain,
"port": d.port,
"base_url": getattr(d, "base_url", None),
"subdomain": d.subdomain,
"endpoints": _endpoints_of_registry(d),
"requires": [
r.get("ref") for r in (getattr(d, "requires", None) or []) if r.get("ref")
],
}
)
return {"deployments": out}
@router.get("/nodes/{hostname}", response_model=NodeDetail)
def get_node(hostname: str) -> NodeDetail:
"""Get detailed info for a specific node."""
registry = get_registry()
# Local node
if hostname == registry.node.hostname:
summary = _local_node_summary(registry)
deployed = _deployed_to_summaries(registry, hostname)
return NodeDetail(**summary.model_dump(), deployed=deployed)
# Remote node
remote = mesh_state.get_node(hostname)
if remote is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Node '{hostname}' not found",
)
summary = _remote_node_summary(hostname, remote)
deployed = _deployed_to_summaries(remote.registry, hostname)
return NodeDetail(**summary.model_dump(), deployed=deployed)

View File

@@ -0,0 +1,303 @@
"""Program action endpoints."""
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from wildpc_core import git
from wildpc_core.adopt import (
AdoptError,
build_adopted_program,
is_git_url,
looks_like_program,
)
from wildpc_core.config import write_program_file
from wildpc_core.stacks import available_actions, available_stacks, run_action
from wildpc_api import stream
from wildpc_api.config import get_config
from wildpc_api.models import StackStatusModel, ToolStatusModel
if TYPE_CHECKING:
from wildpc_core.stack_status import StackStatus
programs_router = APIRouter(tags=["programs"])
@programs_router.get("/stacks")
def list_stacks() -> list[str]:
"""Stack names wildpc has handlers for — populates the dashboard's stack select
and keeps it in sync with the backend (no hardcoded frontend list)."""
return available_stacks()
def _stack_model(st: StackStatus) -> StackStatusModel:
return StackStatusModel(
name=st.name,
tools=[ToolStatusModel(**asdict(t)) for t in st.tools],
programs=st.programs,
deployments=st.deployments,
verbs=st.verbs,
has_enabled_deployment=st.has_enabled_deployment,
in_use=st.in_use,
ok=st.ok,
)
@programs_router.get("/stacks/status")
def stacks_status() -> list[StackStatusModel]:
"""Every stack's dependency health — tools present-where-needed (run-phase tools
against the service runtime PATH), who uses it, and the fix for anything missing.
The Stacks page renders this; `wildpc stack list` is its CLI twin."""
from wildpc_core.stack_status import all_stack_status
return [_stack_model(s) for s in all_stack_status(get_config())]
@programs_router.get("/stacks/{name}")
def stack_detail(name: str) -> StackStatusModel:
"""One stack's dependency detail (tool versions included)."""
from wildpc_core.stack_status import stack_status
st = stack_status(get_config(), name)
if st is None:
raise HTTPException(status_code=404, detail=f"No stack '{name}'")
return _stack_model(st)
# ---------------------------------------------------------------------------
# Filesystem browse + adopt — powers the dashboard's "Add program" flow, the
# web equivalent of `wildpc program add <path|git-url>`. Programs live on the
# server's filesystem, so the picker browses the *server's* dirs (a browser's
# native file dialog only sees the client machine).
# ---------------------------------------------------------------------------
@programs_router.get("/fs/browse")
def browse_filesystem(path: str | None = None) -> dict:
"""List sub-directories of ``path`` (default: the repos dir) so the dashboard
can browse to a program on the server. Directories only; hidden dirs skipped.
Each entry is flagged when it looks adoptable (a project manifest or git repo).
"""
config = get_config()
base = Path(path).expanduser() if path else config.repos_dir
try:
base = base.resolve()
except OSError as e:
raise HTTPException(status_code=400, detail=f"Invalid path: {e}")
if not base.exists():
raise HTTPException(status_code=404, detail=f"Path does not exist: {base}")
if not base.is_dir():
raise HTTPException(status_code=400, detail=f"Not a directory: {base}")
try:
children = sorted(base.iterdir(), key=lambda p: p.name.lower())
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {base}")
entries: list[dict] = []
for child in children:
if child.name.startswith("."):
continue
# A single unreadable child (can't stat/traverse) shouldn't sink the whole
# listing — skip it rather than 403 the directory.
try:
if not child.is_dir():
continue
entries.append(
{
"name": child.name,
"path": str(child),
"is_program": looks_like_program(child),
"is_git": (child / ".git").exists(),
}
)
except OSError:
continue
parent = str(base.parent) if base.parent != base else None
return {
"path": str(base),
"parent": parent,
"repos_dir": str(config.repos_dir),
"entries": entries,
}
class AdoptRequest(BaseModel):
target: str # a local server path or a git URL
name: str | None = None
description: str = ""
@programs_router.post("/programs/adopt")
def adopt_program(request: AdoptRequest) -> dict:
"""Adopt an existing repo as a program (the web `wildpc program add`).
``target`` is a local server path or a git URL. Writes just the new program's
file; declaring a deployment (service/job/tool/static) stays a separate step.
"""
target = request.target.strip()
if not target:
raise HTTPException(status_code=422, detail="A path or git URL is required.")
config = get_config()
try:
adopted = build_adopted_program(
config, target, name=request.name, description=request.description
)
except AdoptError as e:
raise HTTPException(status_code=422, detail=str(e))
config.programs[adopted.name] = adopted.spec
write_program_file(config, adopted.name)
return {
"ok": True,
"program": adopted.name,
"source": adopted.source,
"stack": adopted.stack,
"repo": adopted.repo,
"commands": adopted.commands,
"is_git_url": is_git_url(target),
}
# ---------------------------------------------------------------------------
# Git sync — pull a program's source working copy up to date (pull only; no
# build/apply/restart — converge stays an explicit, separate step). Declared
# BEFORE the generic /{action} route below so the literal `git`/`sync` segments
# win over the `{action}` path param (Starlette matches in declaration order).
# ---------------------------------------------------------------------------
def _program_source(name: str):
"""The (program, config) for a named program, or raise the standard 404/400s."""
config = get_config()
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
return config.programs[name], config
@programs_router.get("/programs/{name}/git")
def program_git_status(name: str) -> dict:
"""Git status of a program's working copy (branch, dirty, ahead/behind).
Fetches from the remote first so ``behind`` is current. A program with no
source or a non-git source returns a benign ``{"is_repo": false}`` (not an
error) so the dashboard can simply hide the sync control."""
comp, config = _program_source(name)
if not comp.source:
return {"is_repo": False}
out = asdict(git.git_status(comp.source, fetch=True))
# Repo context: which repo this program's source lives in and who else shares it
# (a monorepo). Sync is a repo operation — the UI labels it and lists siblings.
from wildpc_core.relations import derive_repos
for key, repo in derive_repos(config).items():
if name in repo.programs:
out["repo"] = {
"key": key,
"programs": repo.programs,
"multi": repo.multi,
"deployments": repo.deployments,
}
break
return out
@programs_router.post("/programs/{name}/sync")
async def program_sync(name: str) -> dict:
"""Fast-forward a program's working copy (``git pull --ff-only``).
Pull-only: it updates the source on disk and reports which deployments may now
need a restart/apply, but does not build, apply, or restart anything itself."""
comp, config = _program_source(name)
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
if not git.is_git_repo(comp.source):
raise HTTPException(
status_code=400, detail=f"'{name}' source is not a git repository"
)
before = git.head(comp.source)
ok, output = git.pull(comp.source)
if not ok:
raise HTTPException(status_code=500, detail=output or "git pull failed")
pulled = git.head(comp.source) != before
deployments = [dname for dname, _ in config.deployments_of(name)]
if pulled:
# Nudge other clients to refresh this program's git status.
await stream.broadcast(
"program-sync", {"program": name, "deployments": deployments}
)
return {
"program": name,
"status": "ok",
"output": output,
"pulled": pulled,
"deployments": deployments if pulled else [],
}
# ---------------------------------------------------------------------------
# Unified program action endpoint
# ---------------------------------------------------------------------------
# Dev verbs only. Activation (install/uninstall of tools/statics) is convergence:
# it happens through `POST /apply`, not as a program action.
_VALID_ACTIONS = {
"build",
"test",
"lint",
"type-check",
"check",
}
@programs_router.post("/programs/{name}/{action}")
async def program_action(name: str, action: str) -> dict:
"""Run a lifecycle action on a program.
Resolution-aware: a declared `commands:` entry overrides the stack default,
so a program with no stack can still be linted/tested/built/installed.
"""
if action not in _VALID_ACTIONS:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
config = get_config()
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
comp = config.programs[name]
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
if action not in available_actions(comp):
raise HTTPException(
status_code=400,
detail=f"Action '{action}' not available for '{name}' "
f"(no declared command and no stack handler provides it)",
)
result = await run_action(action, name, comp, config.root)
if result.status != "ok":
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
return {
"program": result.program,
"action": result.action,
"status": result.status,
"output": result.output,
}

View File

@@ -0,0 +1,172 @@
"""Run a command inside a pseudo-terminal and stream it over asyncio.
Wild PC stays assistant-agnostic: it just launches a command in a real TTY and
shunts raw bytes both ways. The command's own TUI (an agent CLI, a shell, …)
renders exactly as it would in a terminal — Wild PC never parses its output.
The PTY master is a raw OS fd, not an asyncio stream, so we watch it with
``loop.add_reader`` (no extra thread, no busy-poll). Output is delivered to an
``on_output`` callback (a fan-out layer above can broadcast it to any number of
attached clients and buffer scrollback), so a session's lifetime is decoupled
from any one WebSocket connection. Keystroke writes are tiny, so a plain
``os.write`` is fine.
"""
from __future__ import annotations
import asyncio
import fcntl
import os
import pty
import signal
import struct
import termios
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
def _set_winsize(fd: int, rows: int, cols: int) -> None:
"""Apply a terminal window size via TIOCSWINSZ (struct winsize)."""
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def _acquire_controlling_tty() -> None:
"""Child-side setup (preexec), run after the pty slave is dup'd to fd 0/1/2.
``setsid()`` starts a new session (own process group, so cleanup can
``killpg`` the whole tree). ``TIOCSCTTY`` then makes the slave our
*controlling terminal* — without it the kernel has no foreground process
group to deliver ``SIGWINCH`` to, so a later winsize change (a browser resize)
is silently dropped and the app never reflows. This is what makes live resize
work, not just the initial size.
"""
os.setsid()
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
@dataclass
class PtySession:
"""A child process attached to a pty master.
Output is pushed to ``on_output(bytes)`` as it arrives and ``on_exit()`` is
fired once when the child closes the tty. Both run in the event-loop thread.
"""
proc: asyncio.subprocess.Process
master_fd: int
on_output: Callable[[bytes], None] | None = None
on_exit: Callable[[], None] | None = None
cols: int = 80
rows: int = 24
_closed: bool = field(default=False)
@classmethod
async def start(
cls,
command: str,
args: list[str] | None = None,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
cols: int = 80,
rows: int = 24,
on_output: Callable[[bytes], None] | None = None,
on_exit: Callable[[], None] | None = None,
) -> PtySession:
"""Spawn ``command args`` on a fresh pty, sized ``cols`` x ``rows``."""
master_fd, slave_fd = pty.openpty()
# Size the tty before exec so the first TUI render lays out correctly.
_set_winsize(master_fd, rows, cols)
proc_env = {**os.environ, "TERM": "xterm-256color"}
if env:
proc_env.update(env)
proc = await asyncio.create_subprocess_exec(
command,
*(args or []),
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=str(cwd) if cwd else None,
env=proc_env,
# setsid + TIOCSCTTY in the child: own session/group (for killpg) AND
# the slave as controlling tty (for SIGWINCH resize delivery).
preexec_fn=_acquire_controlling_tty,
close_fds=True,
)
os.close(slave_fd) # parent keeps only the master
os.set_blocking(master_fd, False)
session = cls(proc=proc, master_fd=master_fd, cols=cols, rows=rows)
if on_output is not None:
session.on_output = on_output
if on_exit is not None:
session.on_exit = on_exit
asyncio.get_running_loop().add_reader(master_fd, session._drain_master)
return session
def _drain_master(self) -> None:
"""Reader callback: pull available bytes off the master and fan them out."""
try:
data = os.read(self.master_fd, 65536)
except (BlockingIOError, InterruptedError):
return
except OSError:
data = b"" # slave closed → child gone
if not data:
try:
asyncio.get_running_loop().remove_reader(self.master_fd)
except (ValueError, OSError):
pass
if self.on_exit is not None:
self.on_exit()
return
if self.on_output is not None:
self.on_output(data)
def write(self, data: bytes) -> None:
"""Forward keystroke bytes to the child's tty."""
try:
os.write(self.master_fd, data)
except OSError:
pass
def resize(self, cols: int, rows: int) -> None:
"""Resize the tty (browser terminal changed dimensions)."""
self.cols, self.rows = cols, rows
try:
_set_winsize(self.master_fd, rows, cols)
except OSError:
pass
@property
def running(self) -> bool:
return self.proc.returncode is None
async def close(self) -> None:
"""Kill the whole process group and release the master fd (idempotent)."""
if self._closed:
return
self._closed = True
try:
asyncio.get_running_loop().remove_reader(self.master_fd)
except (ValueError, OSError):
pass
if self.proc.returncode is None:
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
except ProcessLookupError:
pass
try:
await asyncio.wait_for(self.proc.wait(), timeout=3)
except asyncio.TimeoutError:
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
try:
os.close(self.master_fd)
except OSError:
pass

View File

@@ -0,0 +1,83 @@
"""Repo endpoints — a repo (git working copy) is the unit of sync. A monorepo backs
several programs; an adopted program is a repo of one. See docs/relationships.md.
Sync is pull-only (fast-forward); converge (build/apply/restart) stays a separate,
explicit step.
"""
from __future__ import annotations
import dataclasses
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from wildpc_core import git
from wildpc_core.relations import Repo, derive_repos
from wildpc_api import stream
from wildpc_api.config import get_config
repos_router = APIRouter(tags=["repos"])
def _resolve(key: str) -> tuple[Repo, object]:
config = get_config()
repos = derive_repos(config)
if key not in repos:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"repo '{key}' not found"
)
return repos[key], config
@repos_router.get("/repos")
def list_repos() -> list[dict]:
"""Every repo with members and last-known git state (no fetch — fast)."""
out: list[dict] = []
for repo in derive_repos(get_config()).values():
st = git.git_status(Path(repo.path), fetch=False)
out.append(
{
**dataclasses.asdict(repo),
"branch": st.branch,
"behind": st.behind,
"dirty": st.dirty,
}
)
return out
@repos_router.get("/repos/{key}/git")
def repo_git(key: str) -> dict:
"""A repo's git status (fetches, so ``behind`` is current) plus its members."""
repo, _ = _resolve(key)
return {
**dataclasses.asdict(git.git_status(Path(repo.path), fetch=True)),
"key": key,
"programs": repo.programs,
"deployments": repo.deployments,
}
@repos_router.post("/repos/{key}/sync")
async def repo_sync(key: str) -> dict:
"""Fast-forward the repo's working copy. Pull-only — reports which deployments
may now need a restart/apply, but does not converge them."""
repo, _ = _resolve(key)
before = git.head(Path(repo.path))
ok, output = git.pull(Path(repo.path))
if not ok:
raise HTTPException(status_code=500, detail=output or "git pull failed")
pulled = git.head(Path(repo.path)) != before
if pulled:
await stream.broadcast(
"repo-sync", {"repo": key, "deployments": repo.deployments}
)
return {
"repo": key,
"status": "ok",
"output": output,
"pulled": pulled,
"deployments": repo.deployments if pulled else [],
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
"""Secrets management — routes through the active backend (file or OpenBao)."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from wildpc_core.config import SECRETS_DIR, _secrets_settings
from wildpc_core.secret_backends import OpenBaoBackend, build_backend
from wildpc_api.config import get_registry
router = APIRouter(prefix="/secrets", tags=["secrets"])
def _backend():
# Same selection as the rest of wildpc (wildpc.yaml `secrets:` block).
return build_backend(SECRETS_DIR, _secrets_settings())
class SecretValue(BaseModel):
value: str
@router.get("")
def list_secrets() -> list[str]:
"""List all secret names (not values)."""
return _backend().list_names()
@router.get("/info")
def secrets_info() -> dict:
"""The active backend + whether this node may write (for the UI)."""
settings = _secrets_settings()
backend = _backend()
kind = "openbao" if isinstance(backend, OpenBaoBackend) else "file"
role = get_registry().node.role
# File is always writable; a vault follower holds a read-only token.
writable = kind == "file" or role == "authority"
return {
"backend": kind,
"addr": settings.get("addr") if kind == "openbao" else None,
"role": role,
"writable": writable,
}
@router.get("/overrides")
def list_overrides() -> dict:
"""Per-node secret overrides ({host: [names]}). Empty unless OpenBao."""
backend = _backend()
if not isinstance(backend, OpenBaoBackend):
return {"overrides": {}}
return {"overrides": backend.list_node_overrides()}
@router.get("/overrides/{node}/{name:path}")
def get_override(node: str, name: str) -> dict:
"""Read a node's override value."""
value = _backend().read(f"nodes/{node}/{name}")
if value is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"no override '{name}' for node '{node}'"
)
return {"node": node, "name": name, "value": value}
@router.put("/overrides/{node}/{name:path}")
def set_override(node: str, name: str, body: SecretValue) -> dict:
"""Set a per-node override (authority only; needs the OpenBao backend)."""
backend = _backend()
if not isinstance(backend, OpenBaoBackend):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "node overrides require the OpenBao backend"
)
try:
backend.write(f"nodes/{node}/{name}", body.value)
except Exception as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc)) from exc
return {"node": node, "name": name, "ok": True}
@router.delete("/overrides/{node}/{name:path}")
def delete_override(node: str, name: str) -> dict:
"""Remove a per-node override."""
_backend().delete(f"nodes/{node}/{name}")
return {"node": node, "name": name, "ok": True}
@router.get("/{name}")
def get_secret(name: str) -> dict:
"""Get a secret value."""
_validate_name(name)
value = _backend().read(name)
if value is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Secret '{name}' not found",
)
return {"name": name, "value": value}
@router.put("/{name}")
def set_secret(name: str, body: SecretValue) -> dict:
"""Set a secret value."""
_validate_name(name)
_backend().write(name, body.value)
return {"name": name, "ok": True}
@router.delete("/{name}")
def delete_secret(name: str) -> dict:
"""Delete a secret."""
_validate_name(name)
_backend().delete(name)
return {"name": name, "ok": True}
def _validate_name(name: str) -> None:
"""Reject path traversal attempts."""
if "/" in name or "\\" in name or ".." in name or not name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid secret name",
)

View File

@@ -0,0 +1,171 @@
"""Service management — start/stop/restart systemd-managed components."""
from __future__ import annotations
import asyncio
import time
from fastapi import APIRouter, HTTPException, status
from starlette.responses import JSONResponse
from wildpc_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
unit_name,
)
from wildpc_api.config import get_wildpc_root, get_registry
from wildpc_api.health import check_all_health
from wildpc_api.models import HealthStatus
from wildpc_api.stream import broadcast
router = APIRouter(prefix="/services", tags=["services"])
UNIT_PREFIX = "wildpc-"
SELF_NAME = "wildpc-api"
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
"""Run a systemctl --user command. Returns (success, output)."""
proc = await asyncio.create_subprocess_exec(
"systemctl",
"--user",
action,
unit,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
output = (stdout or stderr or b"").decode().strip()
return proc.returncode == 0, output
async def _get_unit_status(unit: str) -> str:
"""Get the active status of a systemd unit."""
proc = await asyncio.create_subprocess_exec(
"systemctl",
"--user",
"is-active",
unit,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
return (stdout or b"").decode().strip()
def _managed(name: str):
"""The managed deployment with this name (a name may span kinds; take the
managed systemd one — service or job), or None."""
return next((d for d in get_registry().named(name) if d.managed), None)
def _validate_managed(name: str) -> None:
"""Raise 404 if the component isn't managed in the registry."""
if _managed(name) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service",
)
async def _broadcast_health_with_override(
override_name: str, override_status: str
) -> None:
"""Run health checks but override one component's status from systemd."""
registry = get_registry()
statuses = await check_all_health(registry)
result = []
for s in statuses:
if s.id == override_name:
result.append(
HealthStatus(
id=override_name,
status="down" if override_status != "active" else "up",
latency_ms=None,
)
)
else:
result.append(s)
await broadcast(
"health",
{
"statuses": [s.model_dump() for s in result],
"timestamp": time.time(),
},
)
async def _deferred_systemctl(action: str, unit: str, delay: float = 0.5) -> None:
"""Run a systemctl action after a delay, allowing the HTTP response to flush."""
await asyncio.sleep(delay)
await _systemctl(action, unit)
async def _do_action(name: str, action: str) -> JSONResponse:
"""Execute a systemctl action and broadcast updated health."""
deployed = _managed(name)
unit = (
unit_name(name, deployed.kind) if deployed else f"{UNIT_PREFIX}{name}.service"
)
# Self-restart: defer the systemctl call so the response can be sent first
if name == SELF_NAME and action in ("restart", "stop"):
asyncio.create_task(_deferred_systemctl(action, unit))
return JSONResponse(
status_code=202,
content={"program": name, "action": action, "status": "accepted"},
)
ok, output = await _systemctl(action, unit)
unit_status = await _get_unit_status(unit)
if not ok:
raise HTTPException(status_code=500, detail=output or f"Failed to {action}")
await _broadcast_health_with_override(name, unit_status)
return JSONResponse(
content={"program": name, "action": action, "status": unit_status},
)
@router.get("/{name}/unit")
def get_unit(name: str) -> dict[str, str | None]:
"""Return the generated systemd unit file(s) for a managed component."""
_validate_managed(name)
deployed = _managed(name)
# Get systemd spec from config if repo available
systemd_spec = None
schedule = None
description = None
root = get_wildpc_root()
if root:
from wildpc_core.config import load_config
config = load_config(root)
dep = config.deployment(deployed.kind, name)
if dep is not None:
manage = getattr(dep, "manage", None)
if manage and manage.systemd:
systemd_spec = manage.systemd
description = dep.description
schedule = getattr(dep, "schedule", None)
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
timer = generate_timer(name, schedule, description) if schedule else None
return {"service": unit, "timer": timer}
@router.post("/{name}/restart")
async def restart_service(name: str) -> JSONResponse:
"""Restart a systemd-managed service — the imperative bounce.
Lifecycle (start/stop/enable/disable) is convergence now: set `enabled` in the
deployment config and POST /apply. Restart stays as a way to re-actualize the
current desired state without changing it.
"""
return await _do_action(name, "restart")

View File

@@ -0,0 +1,72 @@
"""SSE stream — pushes health updates and service action events to connected clients."""
from __future__ import annotations
import asyncio
import json
import logging
import time
from wildpc_api.config import get_registry
from wildpc_api.health import check_all_health
logger = logging.getLogger(__name__)
# All connected SSE clients receive events through this queue-based broadcast.
_subscribers: list[asyncio.Queue[str]] = []
def subscribe() -> asyncio.Queue[str]:
"""Register a new SSE client. Returns a queue to read events from."""
q: asyncio.Queue[str] = asyncio.Queue(maxsize=64)
_subscribers.append(q)
return q
def unsubscribe(q: asyncio.Queue[str]) -> None:
"""Remove a disconnected SSE client."""
try:
_subscribers.remove(q)
except ValueError:
pass
def close_all_subscribers() -> None:
"""Unblock all SSE generators so they exit during shutdown."""
for q in list(_subscribers):
try:
q.put_nowait("")
except asyncio.QueueFull:
pass
_subscribers.clear()
async def broadcast(event_type: str, data: dict) -> None:
"""Send an event to all connected SSE clients."""
payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
dead: list[asyncio.Queue[str]] = []
for q in _subscribers:
try:
q.put_nowait(payload)
except asyncio.QueueFull:
dead.append(q)
for q in dead:
unsubscribe(q)
async def health_poll_loop(interval: float = 10.0) -> None:
"""Background task that polls health and broadcasts updates."""
while True:
try:
registry = get_registry()
statuses = await check_all_health(registry)
await broadcast(
"health",
{
"statuses": [s.model_dump() for s in statuses],
"timestamp": time.time(),
},
)
except Exception:
logger.exception("Health poll failed")
await asyncio.sleep(interval)