From d5a0f67a3a9b4b77f086d723d2696b512024311f Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 18:18:26 -0400 Subject: [PATCH] Put the service script back until the switch can be sequenced Reverts the switch to `service: Managed(...)`. The switch is still right and the reasoning in that commit still holds; what was wrong was doing it now, unilaterally, to a checkout something is reading live. A dev-updater is running against this working tree, so deleting `server/service` did not wait for a pull to take effect -- the backend card went to "couldn't check -- failed to run the service script: No such file or directory" immediately, and the pushed declaration still names the script, so the tree and the declaration disagreed in the one direction that breaks things. My own commit message had said this change was not safe to pull blind; it turned out not to need a pull at all. The switch needs three steps in order, and only the middle one is mine: Uninstall from the backend card while the script is still declared, then take the change, then Install. Re-apply when Iris is ready to do that, which is also when dev-updater's conversion path can be deleted. --- .dev-updater.ron | 14 +-- AGENTS.md | 19 +--- server/service | 233 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 26 deletions(-) create mode 100755 server/service diff --git a/.dev-updater.ron b/.dev-updater.ron index 54f3613..1c249c0 100644 --- a/.dev-updater.ron +++ b/.dev-updater.ron @@ -18,17 +18,9 @@ components: [ name: "backend", build: "cargo build --release", cwd: "server", - // Dev Updater's own built-in service implementation, generated - // into its data directory and driven through the same interface a - // project-supplied script would be. ai-app carried a script of its - // own until 2026-08-28; it ran `target/release/ai-server` with no - // arguments and no environment, which is the generic case exactly, - // so it was two copies of one thing and the copy that could not be - // tested from here -- the OpenRC branch -- was duplicated with it. - // - // Resolved against this component's `cwd`, so this is - // `server/target/release/ai-server`. - service: Managed("target/release/ai-server"), + // Installs into whichever user-service manager is here, and is how + // ai-server is started, stopped and asked about. See the script. + service: "server/service", ), Apk( name: "app", diff --git a/AGENTS.md b/AGENTS.md index 971bdf3..f8e4013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,21 +48,10 @@ repo is in PLAN.md's "Backend layout" section. event model mirror; `ServerConfig.kt` settings + Keystore-sealed token; screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. - `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: - the backend (built in `server/`, run as `service: Managed(...)`) and then - the APK (built in `app/`), in that order. The project it serves is the - repository, not either half of it, which is why this sits at the root - rather than in `app/`. - `Managed` means Dev Updater supervises `ai-server` with its own built-in - service implementation rather than a script kept here. ai-app had such a - script until 2026-08-28 and it was the generic case exactly — no - arguments, no environment — so the two projects were maintaining one - behaviour twice, including the OpenRC branch neither can test from a - systemd machine. - Worth knowing before pressing it: **Stop** on the backend card stops the - server that a phone reaches through the tunnel, so on that phone it stays - down until someone starts it again from Dev Updater. Dev Updater reaches - it over its own port and is unaffected, which is what makes the button - safe to press and easy to regret. + the backend (built in `server/`, installed and controlled through + `server/service`) and then the APK (built in `app/`), in that order. The + project it serves is the repository, not either half of it, which is why + this sits at the root rather than in `app/`. - `wg-app-link/` — a **git submodule**, and the half of this backend that dev-updater also needed: the pinned CA and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 binding and the certificate's SANs diff --git a/server/service b/server/service new file mode 100755 index 0000000..7a48358 --- /dev/null +++ b/server/service @@ -0,0 +1,233 @@ +#!/bin/sh +# Installs and controls ai-server as a user service, for Dev Updater to +# drive from the phone -- and for you to drive by hand, which is the same +# thing. +# +# Declared as the Server component of this checkout in `.dev-updater.ron` +# at the repository root, so the card drives it like any other project's. +# One button is worth knowing about before pressing it: on a phone that +# reaches ai-server through the tunnel rather than through Dev Updater, +# Stop leaves this server down until someone starts it again here. +# +# ./service install | uninstall | start | stop | restart | status | logs +# +# `status` prints exactly one of `running`, `stopped`, `failed` or +# `not-installed` +# and exits 0. Anything else it prints, or any non-zero exit, means it +# could not tell -- which the card shows as "couldn't check" rather than +# as a service that is down. +# +# Nothing here ever prompts. Dev Updater runs this with stdin closed and no +# terminal, so a `sudo` password prompt would not fail, it would hang until +# the timeout with the card stuck mid-action. Anything needing root exits +# with a message telling you to run it yourself once instead. +# +# User services on purpose: a system unit needs root to install, and a +# build machine's own account is where a dev server belongs. Note that a +# user service stops at logout unless lingering is enabled +# (`loginctl enable-linger $USER`), which you want for this one: the phone +# reaches this server whether or not anyone is logged in at the desk. +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +NAME=ai-server +BINARY="$SCRIPT_DIR/target/release/$NAME" + +# Where this service's output goes, and the one generation kept behind it. +# +# Under $XDG_DATA_HOME rather than the checkout: a log is generated data, +# it outlives any one build, and the repository is shared with a machine +# that should not be able to read it. Rotated on start rather than by size +# or age, so what is kept is exactly "this run and the one before" -- which +# is the pair worth having after a crash and a restart, and is the reason +# the file is not simply appended to forever. +LOG_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/$NAME" +LOG="$LOG_DIR/$NAME.log" +LOG_PREVIOUS="$LOG.1" + +# Which init system is here, decided by asking rather than by looking for a +# binary: a machine can carry both, and an OpenRC older than 0.60 has +# rc-service but no --user at all. +detect() { + if command -v systemctl >/dev/null 2>&1 && + systemctl --user show-environment >/dev/null 2>&1; then + echo systemd + elif command -v rc-service >/dev/null 2>&1 && + rc-service --user --help >/dev/null 2>&1; then + echo openrc + else + echo none + fi +} + +MANAGER=$(detect) +if [ "$MANAGER" = none ]; then + echo "No user-service manager here: this needs systemd with a user bus," >&2 + echo "or OpenRC 0.60+ (older ones have no --user). Run $NAME by hand." >&2 + exit 1 +fi + +# OpenRC keeps user-service state under XDG_RUNTIME_DIR and refuses without +# it. Worth saying plainly: from a server started outside a login session +# it can be unset, and the failure otherwise reads as a broken service +# rather than a missing variable. +if [ "$MANAGER" = openrc ] && [ -z "${XDG_RUNTIME_DIR:-}" ]; then + echo "XDG_RUNTIME_DIR is unset, and OpenRC stores user-service state in it." >&2 + echo "Set it at login (elogind or pam_xdg) and try again." >&2 + exit 1 +fi + +SYSTEMD_UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$NAME.service" +OPENRC_UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/rc/init.d/$NAME" + +installed() { + case "$MANAGER" in + systemd) [ -f "$SYSTEMD_UNIT" ] ;; + openrc) [ -f "$OPENRC_UNIT" ] ;; + esac +} + +# Whether the service fell over, as opposed to being stopped on purpose. +# +# OpenRC prints `crashed` *and* exits non-zero for this, so the word is +# read rather than the exit code -- leaning on the code would report +# "couldn't check", which is a different and less useful thing to say. +# The `running` check above stays on its exit code, which already worked +# and does not depend on wording. +crashed() { + case "$MANAGER" in + systemd) systemctl --user --quiet is-failed "$NAME" ;; + openrc) rc-service --user "$NAME" status 2>/dev/null | grep -qw crashed ;; + esac +} + +require_binary() { + [ -x "$BINARY" ] && return 0 + echo "No built server at $BINARY -- build it first." >&2 + exit 1 +} + +do_install() { + require_binary + mkdir -p "$LOG_DIR" + case "$MANAGER" in + systemd) + mkdir -p "$(dirname "$SYSTEMD_UNIT")" + cat > "$SYSTEMD_UNIT" </dev/null + ;; + openrc) + mkdir -p "$(dirname "$OPENRC_UNIT")" + cat > "$OPENRC_UNIT" </dev/null + ;; + esac +} + +do_uninstall() { + installed || return 0 + case "$MANAGER" in + systemd) + systemctl --user disable --now "$NAME" >/dev/null 2>&1 || true + rm -f "$SYSTEMD_UNIT" + systemctl --user daemon-reload + ;; + openrc) + rc-service --user "$NAME" stop >/dev/null 2>&1 || true + rc-update --user del "$NAME" >/dev/null 2>&1 || true + rm -f "$OPENRC_UNIT" + ;; + esac +} + +# Keeps the finished run and starts a fresh file for the next one. +rotate() { + mkdir -p "$LOG_DIR" + [ -f "$LOG" ] && mv -f "$LOG" "$LOG_PREVIOUS" + : > "$LOG" +} + +control() { + installed || { echo "$NAME is not installed" >&2; exit 1; } + case "$MANAGER" in + systemd) systemctl --user "$1" "$NAME" ;; + openrc) rc-service --user "$NAME" "$1" ;; + esac +} + +case "${1:-}" in + install) do_install ;; + uninstall) do_uninstall ;; + start | restart) + # Rotated before the manager is asked, so the file the service + # opens is the new one. + rotate + control "$1" + ;; + stop) control stop ;; + logs) + # One path per line, newest first, and nothing else: the caller + # wants somewhere to read from, not a formatted report. Printing + # nothing at all is the answer for a service with no log yet, + # which reads as "not supported here" and costs nobody anything. + [ -f "$LOG" ] && echo "$LOG" + [ -f "$LOG_PREVIOUS" ] && echo "$LOG_PREVIOUS" + exit 0 + ;; + status) + if ! installed; then + echo not-installed + elif case "$MANAGER" in + systemd) systemctl --user --quiet is-active "$NAME" ;; + openrc) rc-service --user "$NAME" status >/dev/null 2>&1 ;; + esac then + echo running + elif crashed; then + # Before `stopped`, because a crashed service satisfies + # neither of the other two and would otherwise be reported as + # a state somebody chose. + echo failed + else + echo stopped + fi + ;; + *) + echo "usage: $0 install|uninstall|start|stop|restart|status|logs" >&2 + exit 2 + ;; +esac