From 2a2e99ab2b59aafeb34bb7510ba23c06d4bbaded Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 31 May 2026 18:23:18 -0600 Subject: [PATCH] =?UTF-8?q?feat(@projects/@claire):=20=E2=9C=A8=20add=20sy?= =?UTF-8?q?stemd=20agent=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- app.manifest.yaml | 38 ++++++++++++++ deployments/systemd/claire-agent.service | 20 +++++++ scripts/deploy-agent.sh | 67 ++++++++++++++++++++++++ src/claire/cli.py | 20 +++++++ 4 files changed, 145 insertions(+) create mode 100644 app.manifest.yaml create mode 100644 deployments/systemd/claire-agent.service create mode 100755 scripts/deploy-agent.sh diff --git a/app.manifest.yaml b/app.manifest.yaml new file mode 100644 index 0000000..247bb1d --- /dev/null +++ b/app.manifest.yaml @@ -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'" diff --git a/deployments/systemd/claire-agent.service b/deployments/systemd/claire-agent.service new file mode 100644 index 0000000..b0b53ce --- /dev/null +++ b/deployments/systemd/claire-agent.service @@ -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 diff --git a/scripts/deploy-agent.sh b/scripts/deploy-agent.sh new file mode 100755 index 0000000..b032440 --- /dev/null +++ b/scripts/deploy-agent.sh @@ -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 }" +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 <&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." diff --git a/src/claire/cli.py b/src/claire/cli.py index 7960d8c..eec52f5 100644 --- a/src/claire/cli.py +++ b/src/claire/cli.py @@ -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 # ---------------------------------------------------------------------------