feat(deploy): trigger service deploys to the redroid box (systemd), not plum (launchd)

Per the corrected .infra.yaml, the trigger service is a PROD unit that runs ON the
redroid box (next to the Android container → local adb), with plum as dev-only. Replace
the (wrong) macOS LaunchAgent with the box-native pattern, mirroring @redroid's
deploy-droplet.sh / mrnumber-ocr.service:
- deploy/mr-number-service.service: systemd unit (multi-user.target, EnvironmentFile for
  tokens, MR_NUMBER_DEVICE=localhost:5555, __BUN__ resolved at deploy time).
- deploy/deploy-service.sh: prereq-checks the box (bun/python3/redroid_client/adb), scp's
  service+client to /opt/mr-number-service, installs the unit, seeds a 0600 env template,
  enables only once tokens are filled (no crashloop). Does NOT mint tokens.
- service/run: drop launchd installer; plain dev launcher (loads cocotte-secrets).
- install.sh: plum = dev setup only; points prod at deploy-service.sh.
- CLAUDE.md/README: box-deploy ownership split (@redroid owns the box; we own our unit).

Syntax-checked; box SSH (:22) unreachable from this env so not yet run against the box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 17:19:40 -04:00
parent afe2917a52
commit c5c124faa1
6 changed files with 142 additions and 85 deletions

View file

@ -44,9 +44,17 @@ Use "We…" / "The collective…". Never "I'll…" / "Let me…" / "You're absol
service that exposes this lives **here** (see README "Trigger service"). The persons service that exposes this lives **here** (see README "Trigger service"). The persons
DB / signal model lives in cocotte (`@platforms/cocottetech`); the prospect gate DB / signal model lives in cocotte (`@platforms/cocottetech`); the prospect gate
lives in **Prospector** (`@applications/prospector`). Never POST to quinn.api. lives in **Prospector** (`@applications/prospector`). Never POST to quinn.api.
- **Deploy target = the redroid box (prod), plum = dev only.** Per `.infra.yaml`, the
deployable unit is the **trigger service** (`service/`), which runs as the systemd unit
`mr-number-service` ON the box (next to the Android container, so adb is local).
Deploy it with `deploy/deploy-service.sh` (scp to `/opt/mr-number-service` + install the
unit). The plum/fennel side (`client/` lookup + console-tray + stdio MCP) is dev/setup
tooling, NOT a deploy target.
- **IaC / box ownership:** the redroid droplet's canonical Terraform lives in - **IaC / box ownership:** the redroid droplet's canonical Terraform lives in
`~/Code/@projects/uvlava/terraform/do/`; the box-side services + deploy live in the `~/Code/@projects/uvlava/terraform/do/`; the **box itself** + the shared on-box services
**`@redroid`** app. NOT here. Never `terraform apply` or deploy the box from this repo. (adb-keyboard, ocr-service, ws-scrcpy, the Android emulator) are owned by the **`@redroid`**
app. This repo owns only its own `mr-number-service` unit on that box. Never `terraform
apply` the box or deploy @redroid's services from here.
- **Secrets:** flat 0600 files on plum — `~/.config/cocotte-secrets/people-service.token` - **Secrets:** flat 0600 files on plum — `~/.config/cocotte-secrets/people-service.token`
(people signal recording) and `~/.config/cocotte-secrets/mr-number.service-token` (people signal recording) and `~/.config/cocotte-secrets/mr-number.service-token`
(inbound trigger auth). SSH key for the droplet console: `~/.ssh/id_ed25519_1984`. (inbound trigger auth). SSH key for the droplet console: `~/.ssh/id_ed25519_1984`.

View file

@ -100,10 +100,17 @@ The inbound HTTP surface Prospector calls. Durable SQLite queue + a single seria
worker (one Android box) that drains by invoking `mr_lookup.py`; the verdict lands as worker (one Android box) that drains by invoking `mr_lookup.py`; the verdict lands as
the people-service signal. the people-service signal.
**Prod: runs ON the redroid box** (systemd unit `mr-number-service`, next to the Android
container so adb is local), bound `0.0.0.0:8787`. Deploy from here:
```bash ```bash
export MRNUMBER_SERVICE_TOKEN="$(cat ~/.config/cocotte-secrets/mr-number.service-token)" deploy/deploy-service.sh # scp service+client to /opt, install unit, restart
export PEOPLE_BASE_URL=... PEOPLE_SERVICE_TOKEN=... MR_NUMBER_DEVICE=45.55.191.82:5555 # then on the box fill /etc/mr-number-service.env (tokens), and point Prospector at:
cd service && ./run # binds 0.0.0.0:8787 (mesh-reachable); bun test / bun run typecheck # MRNUMBER_BASE_URL=http://10.20.0.4:8787 (box VPC)
```
**Local dev (plum):**
```bash
cd service && ./run # loads ~/.config/cocotte-secrets/*; binds 0.0.0.0:8787
bun test && bun run typecheck # host-free
``` ```
| Method + path | Auth | Body / result | | Method + path | Auth | Body / result |

72
deploy/deploy-service.sh Executable file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Deploy the mr-number TRIGGER SERVICE onto the redroid box (DigitalOcean
# lilith-store-redroid). The service runs NEXT TO the redroid Android container so adb
# is local. The box itself is provisioned by uvlava IaC + the @redroid app — this only
# installs/(re)starts the `mr-number-service` systemd unit and its code under /opt.
#
# Prereqs ON THE BOX (this script verifies them and fails loudly if missing):
# bun, python3 + redroid_client (pip: lilith-redroid-client from cocotte-forge),
# the claude-code-batch-sdk (for vision), a reachable people service, and a filled
# /etc/mr-number-service.env (tokens). It does NOT mint tokens.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${MRNUMBER_DEPLOY_HOST:-root@45.55.191.82}"
KEY="${MRNUMBER_DEPLOY_KEY:-$HOME/.ssh/id_ed25519_1984}"
SSH=(ssh -i "$KEY" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 "$HOST")
SCP=(scp -i "$KEY" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15)
DEST=/opt/mr-number-service
echo "[mr-number] verifying box prerequisites…"
BUN_PATH="$("${SSH[@]}" 'command -v bun || true')"
[ -n "$BUN_PATH" ] || { echo "FATAL: bun not on the box. Install it first (curl -fsSL https://bun.sh/install | bash), then re-run." >&2; exit 1; }
"${SSH[@]}" '
set -e
command -v python3 >/dev/null || { echo "FATAL: python3 missing on box" >&2; exit 1; }
python3 -c "import redroid_client" 2>/dev/null || { echo "FATAL: redroid_client not installed on box (pip install lilith-redroid-client from cocotte-forge)" >&2; exit 1; }
adb devices 2>/dev/null | grep -qE "device$" || { echo "FATAL: no adb device on box (redroid container down?)" >&2; exit 1; }
'
echo "[mr-number] box ok (bun=$BUN_PATH)."
echo "[mr-number] copying service + client to $DEST"
"${SSH[@]}" "mkdir -p $DEST/service $DEST/client"
"${SCP[@]}" \
"$ROOT/service/index.ts" "$ROOT/service/config.ts" "$ROOT/service/queue.ts" \
"$ROOT/service/worker.ts" "$ROOT/service/validate.ts" "$ROOT/service/package.json" \
"$ROOT/service/tsconfig.json" "$HOST:$DEST/"
"${SCP[@]}" "$ROOT/client/mr_lookup.py" "$HOST:$DEST/client/mr_lookup.py"
echo "[mr-number] installing systemd unit (resolved bun=$BUN_PATH)…"
TMP_UNIT="$(mktemp)"; trap 'rm -f "$TMP_UNIT"' EXIT
sed "s#__BUN__#$BUN_PATH#" "$ROOT/deploy/mr-number-service.service" > "$TMP_UNIT"
"${SCP[@]}" "$TMP_UNIT" "$HOST:/etc/systemd/system/mr-number-service.service"
echo "[mr-number] ensuring /etc/mr-number-service.env (0600) exists…"
"${SSH[@]}" '
if [ ! -f /etc/mr-number-service.env ]; then
umask 077
cat > /etc/mr-number-service.env <<ENV
# 0600 — fill these in, then: systemctl restart mr-number-service
MRNUMBER_SERVICE_TOKEN=
PEOPLE_SERVICE_TOKEN=
PEOPLE_BASE_URL=http://10.9.0.5:3061
CLAUDE_CODE_BATCH_SDK_PATH=
ENV
echo " created /etc/mr-number-service.env TEMPLATE — fill the two tokens + SDK path before it will start."
else
echo " /etc/mr-number-service.env already present (left untouched)."
fi
'
echo "[mr-number] (re)starting mr-number-service…"
"${SSH[@]}" '
systemctl daemon-reload
if grep -q "^MRNUMBER_SERVICE_TOKEN=.\+" /etc/mr-number-service.env && grep -q "^PEOPLE_SERVICE_TOKEN=.\+" /etc/mr-number-service.env; then
systemctl enable --now mr-number-service
sleep 2
printf " status: "; systemctl is-active mr-number-service || true
curl -sf -m5 http://127.0.0.1:8787/health && echo || echo " WARN: /health not responding yet — check: journalctl -u mr-number-service -n50"
else
echo " tokens not filled in /etc/mr-number-service.env — NOT enabling (would crashloop). Fill them, then: systemctl enable --now mr-number-service"
fi
'
echo "[mr-number] done. Prospector → MRNUMBER_BASE_URL=http://10.20.0.4:8787 (box VPC) or http://45.55.191.82:8787."

View file

@ -75,17 +75,16 @@ cat <<EOC
EOC EOC
# --- 5. trigger service (the inbound HTTP surface Prospector calls) --- # --- 5. trigger service ---
echo "[mr-number] installing trigger-service LaunchAgent (autostart; KeepAlive)…" # This install.sh sets up the PLUM DEV side only (client/ + mcp/ + console). The trigger
if [ -f "$HOME/.config/cocotte-secrets/mr-number.service-token" ]; then # service is a PROD unit that runs ON THE REDROID BOX (systemd, next to the Android
"$ROOT/service/run" install-launchagent || true # container) — deploy it from here with deploy/deploy-service.sh, not as a plum agent.
else cat <<EOS
cat <<EOS
[mr-number] SKIPPED trigger-service LaunchAgent — secret not found. [mr-number] trigger service (prod) deploys to the redroid box, not plum:
Provision it first (0600), then run: $ROOT/service/run install-launchagent • fill tokens on the box: /etc/mr-number-service.env (MRNUMBER_SERVICE_TOKEN, PEOPLE_SERVICE_TOKEN)
• ~/.config/cocotte-secrets/mr-number.service-token (inbound auth; Prospector's MRNUMBER_SERVICE_TOKEN) • deploy: $ROOT/deploy/deploy-service.sh
• ~/.config/cocotte-secrets/people-service.token (recording auth; PEOPLE_SERVICE_TOKEN) • Prospector → MRNUMBER_BASE_URL=http://10.20.0.4:8787 (box VPC)
Then point Prospector at it: MRNUMBER_BASE_URL=http://10.9.0.3:8787 Local dev on plum: cd $ROOT/service && ./run (loads ~/.config/cocotte-secrets/*)
EOS EOS
fi

View file

@ -0,0 +1,23 @@
[Unit]
Description=mr-number trigger service — POST /api/screening/requests, drives local redroid + records screening_mrnumber people signal
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/mr-number-service
# Secrets + env live in a 0600 file on the box (NOT in this unit / NOT in git):
# MRNUMBER_SERVICE_TOKEN, PEOPLE_SERVICE_TOKEN, PEOPLE_BASE_URL, MR_NUMBER_DEVICE,
# CLAUDE_CODE_BATCH_SDK_PATH (see deploy/deploy-service.sh for the template)
EnvironmentFile=/etc/mr-number-service.env
# On the box adb is local; mr_lookup.py talks to the redroid container on loopback.
Environment=MR_NUMBER_DEVICE=localhost:5555
Environment=MRNUMBER_PORT=8787
Environment=MR_NUMBER_LOOKUP_SCRIPT=/opt/mr-number-service/client/mr_lookup.py
# __BUN__ is substituted with the box's resolved bun path by deploy-service.sh.
ExecStart=__BUN__ run /opt/mr-number-service/index.ts
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

View file

@ -1,76 +1,24 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# mr-number trigger service launcher + LaunchAgent installer. # mr-number trigger service launcher (dev/manual). In PROD the service runs on the
# redroid box as the systemd unit `mr-number-service` (see deploy/deploy-service.sh);
# that unit gets its env from /etc/mr-number-service.env, not this script.
# #
# ./run launch the service (loads secrets + env, then bun run index.ts) # Use this for local dev/testing on plum (fennel). Secrets are 0600 files under
# ./run install-launchagent install the plum LaunchAgent (autostart on login/boot) # ~/.config/cocotte-secrets/ (env wins if already set):
# # mr-number.service-token inbound auth Prospector presents (MRNUMBER_SERVICE_TOKEN)
# Secrets are 0600 files under ~/.config/cocotte-secrets/ (env wins if already set):
# mr-number.service-token inbound auth Prospector must present (MRNUMBER_SERVICE_TOKEN)
# people-service.token recording auth for mr_lookup.py (PEOPLE_SERVICE_TOKEN) # people-service.token recording auth for mr_lookup.py (PEOPLE_SERVICE_TOKEN)
set -euo pipefail set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)" HERE="$(cd "$(dirname "$0")" && pwd)"
SECRETS="$HOME/.config/cocotte-secrets" SECRETS="$HOME/.config/cocotte-secrets"
PLIST_LABEL="com.lilith.redroid-mrnumber-service"
PLIST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
LOG_DIR="$HOME/Library/Logs/redroid-mrnumber-service"
load_env() { # File → env, only if the env var isn't already set. Tokens stay out of argv.
# File → env, only if the env var isn't already set. Tokens stay out of argv. [ -n "${MRNUMBER_SERVICE_TOKEN:-}" ] || { [ -f "$SECRETS/mr-number.service-token" ] && export MRNUMBER_SERVICE_TOKEN="$(cat "$SECRETS/mr-number.service-token")"; }
[ -n "${MRNUMBER_SERVICE_TOKEN:-}" ] || { [ -f "$SECRETS/mr-number.service-token" ] && export MRNUMBER_SERVICE_TOKEN="$(cat "$SECRETS/mr-number.service-token")"; } [ -n "${PEOPLE_SERVICE_TOKEN:-}" ] || { [ -f "$SECRETS/people-service.token" ] && export PEOPLE_SERVICE_TOKEN="$(cat "$SECRETS/people-service.token")"; }
[ -n "${PEOPLE_SERVICE_TOKEN:-}" ] || { [ -f "$SECRETS/people-service.token" ] && export PEOPLE_SERVICE_TOKEN="$(cat "$SECRETS/people-service.token")"; } export PEOPLE_BASE_URL="${PEOPLE_BASE_URL:-http://10.9.0.5:3061}"
export PEOPLE_BASE_URL="${PEOPLE_BASE_URL:-http://10.9.0.5:3061}" export MR_NUMBER_DEVICE="${MR_NUMBER_DEVICE:-45.55.191.82:5555}" # plum dev drives the box remotely; on-box prod uses localhost:5555
export MR_NUMBER_DEVICE="${MR_NUMBER_DEVICE:-45.55.191.82:5555}" export MRNUMBER_PORT="${MRNUMBER_PORT:-8787}"
export MRNUMBER_PORT="${MRNUMBER_PORT:-8787}" [ -n "${CLAUDE_CODE_BATCH_SDK_PATH:-}" ] || export CLAUDE_CODE_BATCH_SDK_PATH="$HOME/Code/@quinn/@applications/ml/@packages/@py/claude-code-batch-sdk/src"
[ -n "${CLAUDE_CODE_BATCH_SDK_PATH:-}" ] || export CLAUDE_CODE_BATCH_SDK_PATH="$HOME/Code/@quinn/@applications/ml/@packages/@py/claude-code-batch-sdk/src"
}
launch() { cd "$HERE"
load_env exec bun run index.ts
cd "$HERE"
exec bun run index.ts
}
install_launchagent() {
mkdir -p "$(dirname "$PLIST")" "$LOG_DIR"
cat > "$PLIST" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$PLIST_LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-l</string>
<string>-c</string>
<string>$HERE/run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$LOG_DIR/launchd.stdout.log</string>
<key>StandardErrorPath</key>
<string>$LOG_DIR/launchd.stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
PLIST
launchctl bootout "gui/$(id -u)/$PLIST_LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null || true
echo "LaunchAgent $PLIST_LABEL installed (autostart; KeepAlive). Logs in $LOG_DIR"
echo "Listening on 0.0.0.0:${MRNUMBER_PORT:-8787} — set Prospector MRNUMBER_BASE_URL=http://10.9.0.3:8787"
}
case "${1:-launch}" in
launch) launch ;;
install-launchagent) install_launchagent ;;
*) echo "usage: $0 [launch|install-launchagent]" >&2; exit 2 ;;
esac