- 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>
162 lines
5.9 KiB
Bash
Executable File
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
|