feat(@projects/@claire): add systemd agent deployment

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-31 18:23:18 -06:00
parent 3057182717
commit 2a2e99ab2b
4 changed files with 145 additions and 0 deletions

38
app.manifest.yaml Normal file
View file

@ -0,0 +1,38 @@
name: claire-agent
description: Headless Claire peer-node daemon — sync + supervisor + telemetry
type: service
version: 0.1.0
# The orchestrator (claire serve) + tray run on plum; this manifest covers the
# Linux peer-node agent that runs on the worker hosts.
platforms:
apricot:
os: linux
service:
type: systemd-user
unit: claire-agent.service
bind: "127.0.0.1"
default_port: 8766
deploy:
script: scripts/deploy-agent.sh
args: ["apricot"]
status:
command: "ssh apricot 'systemctl --user is-active claire-agent.service'"
type: process
logs:
command: "ssh apricot 'journalctl --user -u claire-agent.service -f'"
black:
os: linux
service:
type: systemd-user
unit: claire-agent.service
bind: "127.0.0.1"
default_port: 8766
deploy:
script: scripts/deploy-agent.sh
args: ["black"]
status:
command: "ssh black 'systemctl --user is-active claire-agent.service'"
type: process
logs:
command: "ssh black 'journalctl --user -u claire-agent.service -f'"

View file

@ -0,0 +1,20 @@
[Unit]
Description=Claire peer-node daemon (sync + supervisor + telemetry)
Documentation=https://forge.black.lan/lilith/claire
# Clock sync is a hard precondition: peer sync uses HMAC with a 300s skew
# window, so a drifting clock breaks auth. Wait for time-sync before start.
After=time-sync.target network-online.target
Wants=network-online.target
[Service]
Type=simple
# Use the venv binary directly — no dependency on a ~/.local/bin symlink.
ExecStart=%h/Code/@projects/@claire/.venv/bin/claire agent run
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=claire-agent
[Install]
WantedBy=default.target

67
scripts/deploy-agent.sh Executable file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
#
# Deploy the headless `claire agent` peer node to a Linux host (apricot|black).
# Runs FROM plum. Idempotent. Code + systemd unit + peer config (injects plum's
# sync_secret so the host can sync to plum).
#
# scripts/deploy-agent.sh apricot
#
# Requires: `remote-run` on PATH (~/Code/@scripts/session-tools), ssh access,
# uv + python3.12+ on the remote, and NTP-synced clocks (HMAC skew window 300s).
set -euo pipefail
HOST="${1:?usage: deploy-agent.sh <host>}"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REMOTE_DIR="Code/@projects/@claire" # relative to remote $HOME
PLUM_TOML="${CLAIRE_TOML:-$HOME/.config/claire/claire.toml}"
say() { printf '\033[1;35m▸\033[0m %s\n' "$*"; }
# Plum's bind + sync_secret — the peer host signs sync requests to plum with it.
read -r PLUM_URL PLUM_SECRET <<EOF
$("$SRC/.venv/bin/python" - "$PLUM_TOML" <<'PY'
import sys, tomllib, pathlib
c = tomllib.loads(pathlib.Path(sys.argv[1]).read_text())
web = c.get("web", {})
host = web.get("host", "127.0.0.1")
if host in ("0.0.0.0", "::", ""):
host = "127.0.0.1"
print(f"http://{host}:{web.get('port', 8765)}", c.get("sync_secret", ""))
PY
)
EOF
[ -n "$PLUM_SECRET" ] || { echo "ERROR: plum has no sync_secret in $PLUM_TOML" >&2; exit 1; }
say "plum peer URL = $PLUM_URL (secret ${PLUM_SECRET:0:4}…)"
say "[$HOST] reachability + clock"
ssh -o ConnectTimeout=8 -o BatchMode=yes "$HOST" 'true' \
|| { echo "ERROR: cannot ssh $HOST" >&2; exit 1; }
ssh "$HOST" 'timedatectl show -p NTPSynchronized --value 2>/dev/null || echo unknown'
say "[$HOST] rsync source"
ssh "$HOST" "mkdir -p ~/$REMOTE_DIR"
rsync -az --delete \
--exclude='.venv/' --exclude='.git/' --exclude='__pycache__/' \
--exclude='*.pyc' --exclude='.pytest_cache/' --exclude='.ruff_cache/' \
--exclude='claire.toml' \
--exclude='src/claire/web/app/node_modules/' \
--exclude='src/claire/web/app/dist/' \
"$SRC/" "${HOST}:${REMOTE_DIR}/"
say "[$HOST] install (uv) + init"
remote-run "$HOST" "cd ~/$REMOTE_DIR && { [ -d .venv ] || uv venv; } && uv pip install -e . && .venv/bin/claire init"
say "[$HOST] configure peer (idempotent — points this host at plum)"
remote-run "$HOST" "cd ~/$REMOTE_DIR && .venv/bin/claire agent add-peer --url '$PLUM_URL' --secret '$PLUM_SECRET'"
say "[$HOST] install + enable systemd --user unit"
remote-run "$HOST" "
mkdir -p ~/.config/systemd/user
cp ~/$REMOTE_DIR/deployments/systemd/claire-agent.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now claire-agent.service
loginctl enable-linger \$(whoami) 2>/dev/null || true
sleep 2
systemctl --user --no-pager status claire-agent.service | head -5
"
say "[$HOST] done."

View file

@ -150,6 +150,26 @@ def agent_run(
)
@agent_app.command("add-peer")
def agent_add_peer(
url: Annotated[str, typer.Option("--url", help="Peer base URL, e.g. http://10.9.0.3:8767")],
secret: Annotated[str | None, typer.Option("--secret", help="HMAC secret to sign requests TO this peer (= the peer's sync_secret)")] = None,
) -> None:
"""Idempotently add a sync peer to this host's config + ensure [agent] is set.
Used by deploy-agent.sh to point a worker host at plum. Re-running with the
same URL is a no-op (updates the secret if it changed).
"""
from .config import PeerConfig, _serialize, default_config_path
cfg = load_or_init()
peers = [p for p in cfg.peers if p.url != url] # replace existing entry for url
peers.append(PeerConfig(url=url, secret=secret))
cfg = cfg.model_copy(update={"peers": peers})
default_config_path().write_text(_serialize(cfg), encoding="utf-8")
console.print(f"[green]✓[/green] peers: {[p.url for p in peers]}")
# ---------------------------------------------------------------------------
# SPA build management
# ---------------------------------------------------------------------------