Files
ai-agent/scripts/standalone-smoke.sh
Gabriel Vidal 2b2ca23ad3 feat(workers): Settings → Workers, composer Runs-on chip, badges, takeover confirm
- lib/workers.tsx: useWorkers (workers SSE), WorkerSelect chip (auto from
  the account, amber when it fell back to the lab), WorkerBadge, StatusDot
- Settings → Workers page + pairing form (one string; account picker only
  when the login matches no account), install instructions
- ResumeBox: static worker badge; terminal_live 409 → confirm → takeover
- home banner for an offline/refused worker; typed worker run errors
- mock: workers seed (online/offline/unauthorized) + /api/workers handlers
- docs: CLAUDE.md Workers section, GOAL item, README box, worker/README.md
- standalone smoke asserts the runner's /feed + /pair routes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 14:06:39 +02:00

162 lines
5.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Prove the image still runs standalone: no homelab, no sidecar, no mounts beyond
# a workspace and a data dir. Boots the image against a throwaway workspace and
# asserts every catalog endpoint answers 200 and the PWA shell serves.
#
# This guards a GOAL.md property ("replace homelab-specific mounts/config with a
# generic first-run setup" / "one-liner docker run"): the homelab compose mounts
# ~8 host paths, and it is easy to add code that assumes one of them exists. A
# missing catalog is a valid state — an empty list, never a 500.
#
# scripts/standalone-smoke.sh # smoke the current homelab-ai-agent image
# scripts/standalone-smoke.sh --build # build the image first, then smoke it
# AI_AGENT_SMOKE_IMAGE=foo:bar scripts/standalone-smoke.sh
#
# Exit 0 = the standalone path is intact. Exit 1 = it regressed (details printed).
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
IMAGE="${AI_AGENT_SMOKE_IMAGE:-homelab-ai-agent:latest}"
NAME="ai-agent-smoke-$$"
WORKDIR="$(mktemp -d)"
cleanup() {
docker rm -f "$NAME" >/dev/null 2>&1 || true
rm -rf "$WORKDIR"
}
trap cleanup EXIT
if [[ "${1:-}" == "--build" ]]; then
echo "==> building $IMAGE"
# network: host so the frontend stage reaches the host-bound private registry.
docker build --network host -t "$IMAGE" . >/dev/null
fi
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "!! image $IMAGE not found — run with --build, or set AI_AGENT_SMOKE_IMAGE" >&2
exit 1
fi
# The whole point: a bare workspace. No projects/, no templates/, no .claude/, no
# services/, no transcripts, no memories, no screenshots, no sidecar.
mkdir -p "$WORKDIR/workspace" "$WORKDIR/data"
printf '# Smoke workspace\n\nA repo with nothing but this file.\n' \
> "$WORKDIR/workspace/CLAUDE.md"
echo "==> booting $IMAGE standalone (workspace + data only)"
# No published port: bind-mount nothing else and probe from inside, so the smoke
# test can't collide with whatever already owns a port on the host.
# RUNNER_IN_CONTAINER=1 is the standalone shape (docker-compose.standalone.yml):
# the bundled Claude Code CLI + sidecar.py come up on the container's loopback.
# No API key is passed, so a *spawn* would fail on auth — what's asserted here is
# that the runner is present and healthy, which is the packaging property that
# regresses silently.
docker run -d --name "$NAME" \
-u "$(id -u):$(id -g)" \
-e RUNNER_IN_CONTAINER=1 \
-v "$WORKDIR/workspace:/workspace" \
-v "$WORKDIR/data:/data" \
"$IMAGE" >/dev/null
# The image has python but no curl; probe over the loopback inside the container
# (127.0.0.1 is a trusted caller, so the API gate lets it through).
probe() {
docker exec "$NAME" python -c "
import sys, urllib.request
try:
r = urllib.request.urlopen('http://127.0.0.1:8080$1', timeout=20)
body = r.read(200).decode('utf-8', 'replace')
print(f'{r.status} {body[:110]}')
sys.exit(0 if r.status == 200 else 1)
except urllib.error.HTTPError as e:
print(f'{e.code} {e.read(200).decode(\"utf-8\", \"replace\")[:110]}')
sys.exit(1)
except Exception as e:
print(f'ERR {e}')
sys.exit(1)
" 2>&1
}
# Wait for uvicorn to answer at all before judging anything.
for i in $(seq 1 30); do
probe /api/health >/dev/null 2>&1 && break
if [[ $i == 30 ]]; then
echo "!! never became healthy; container logs:" >&2
docker logs "$NAME" 2>&1 | tail -30 >&2
exit 1
fi
sleep 1
done
ENDPOINTS=(
/api/health
/api/bundle
/api/conversations
/api/projects
/api/services
/api/memories
/api/templates
/api/plans
/api/cron
/api/models
/ # the PWA shell
)
failed=0
for ep in "${ENDPOINTS[@]}"; do
out="$(probe "$ep")" && status=ok || status=FAIL
printf ' %-6s %-22s %s\n' "$status" "$ep" "$out"
[[ $status == ok ]] || failed=1
done
# The in-image runner: the CLI must be on PATH and its sidecar must answer on the
# loopback. Without both, the standalone image silently degrades back to a
# read-only viewer — which is exactly the GOAL gap this closes.
if out="$(docker exec "$NAME" claude --version 2>&1)"; then
printf ' %-6s %-22s %s\n' ok 'claude CLI' "$out"
else
printf ' %-6s %-22s %s\n' FAIL 'claude CLI' "$out"; failed=1
fi
if out="$(docker exec "$NAME" python -c "
import urllib.request
print(urllib.request.urlopen('http://127.0.0.1:8790/health', timeout=10).read().decode()[:110])" 2>&1)"; then
printf ' %-6s %-22s %s\n' ok 'runner /health' "$out"
else
printf ' %-6s %-22s %s\n' FAIL 'runner /health' "$out"; failed=1
fi
# The worker endpoints ride on every runner (sidecar/feed.py, pairing.py): the
# feed must want the bearer (401 without it) and /pair must answer "no code"
# (409) rather than 404 — a missing route means the image lost a module.
if out="$(docker exec "$NAME" python -c "
import urllib.request, urllib.error
def code(url, data=None):
try:
return urllib.request.urlopen(urllib.request.Request(url, data=data,
headers={'Content-Type': 'application/json'}), timeout=10).status
except urllib.error.HTTPError as e:
return e.code
f = code('http://127.0.0.1:8790/feed')
p = code('http://127.0.0.1:8790/pair', b'{\"code\": \"NOPE\"}')
print(f'feed={f} pair={p}')
assert f == 401 and p in (403, 409), 'worker endpoints missing'" 2>&1)"; then
printf ' %-6s %-22s %s\n' ok 'runner /feed + /pair' "$out"
else
printf ' %-6s %-22s %s\n' FAIL 'runner /feed + /pair' "$out"; failed=1
fi
# A crash inside a background thread (indexer/watcher) won't fail a request, so
# check the log too — those are exactly the paths that assume a homelab mount.
if docker logs "$NAME" 2>&1 | grep -qiE 'traceback|unhandled|FileNotFoundError'; then
echo "!! tracebacks in the container log:" >&2
docker logs "$NAME" 2>&1 | grep -iE -A4 'traceback|FileNotFoundError' | head -30 >&2
failed=1
fi
if [[ $failed == 0 ]]; then
echo "==> standalone OK: every catalog degrades cleanly with no homelab mounts"
else
echo "==> standalone REGRESSED (see above)" >&2
fi
exit $failed