Leaving the import screen used to cancel the batch it had started: the
request was the work, so the coroutine that owned it died with the screen
and coming back showed no sign anything had happened. A half-imported
session is the expensive kind of missing -- the row is back looking
untouched, and taking it again is the second `--resume` the import path
exists to prevent.
So the work runs on the server now. Delete and a new per-session import both
answer 202 and spawn the work, and `session::pending` is the record of it:
what is running, and how the last attempt failed. The phone reads that two
ways and needs both. Every row of the listing carries `pending` and `error`,
which is what a phone that was asleep, out of range or freshly opened has to
go on; `GET /setups/{id}/importable/events` streams the changes, which is
what makes a screen somebody is watching change by itself.
Neither alone is enough, and that is not theoretical. A broadcast has no
memory, so an operation that started and finished while the stream was still
connecting was one nothing would ever be said about -- with responses held
back far enough to make it visible, one row of a pair of deletes cleared and
the other sat on "waiting" for good. The screen now asks again after a
handover when anything still looks outstanding, and takes its row states
from that answer rather than from what it remembers.
The single tap still waits, because "take me to it" needs the session that
was made and 202 does not carry one. Both paths go through the same `spawn`
so they cannot drift about what importing means.
Resolving one importable session no longer lists every one of them:
`import::find` is the same script with one glob narrower, which takes the
import seed off the 3.7-second full scan that `delete` came off earlier.
The SSE connection and its framing are now `Sse`, shared with the session
transcript stream rather than written a second time.
204 lines
7.6 KiB
Bash
Executable File
204 lines
7.6 KiB
Bash
Executable File
#!/bin/sh
|
|
# An ai-server with invented sessions in it, for driving the phone UI.
|
|
#
|
|
# The import screen lists whatever Claude Code has on the machine, and in
|
|
# this VM that is real agent transcripts -- so exercising *delete* against
|
|
# the ordinary server means deleting somebody's conversation, and exercising
|
|
# *import* means starting a real `claude --resume` on the owner's account. Both
|
|
# are the wrong price for looking at a list.
|
|
#
|
|
# So this starts a second server that can see neither. `$HOME` is pointed at
|
|
# a sandbox directory, which is the only thing the importer's own script
|
|
# consults (`$HOME/.claude/projects/*/*.jsonl`), and the config and session
|
|
# data live there too. What it lists is invented here, and deleting all of
|
|
# it costs nothing.
|
|
#
|
|
# Three things are deliberately shared with the real server, because the
|
|
# installed APK is built against them: the TLS certificates (the app pins
|
|
# that CA and would refuse a fresh one) and the port. Run it while the real
|
|
# server is down.
|
|
#
|
|
# Usage:
|
|
# ./ui-sandbox.sh start it, print the enrolment command
|
|
# ./ui-sandbox.sh stop stop it
|
|
#
|
|
# Environment: AI_SANDBOX_ROOT, AI_SANDBOX_TOKEN, AI_SANDBOX_PORT,
|
|
# AI_SANDBOX_DELAY -- the server's own `--delay`, which is what makes a
|
|
# spinner visible at all -- and AI_SANDBOX_SPAWN_DELAY, which holds an
|
|
# import open for that many seconds. On loopback every request is back in
|
|
# under a millisecond, so a busy state that is correct is still a busy state
|
|
# nobody can see.
|
|
set -eu
|
|
|
|
ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox}
|
|
TOKEN=${AI_SANDBOX_TOKEN:-sandbox}
|
|
PORT=${AI_SANDBOX_PORT:-8443}
|
|
DELAY=${AI_SANDBOX_DELAY:-1200}
|
|
SPAWN_DELAY=${AI_SANDBOX_SPAWN_DELAY:-0}
|
|
BIG_MB=${AI_SANDBOX_BIG_MB:-40}
|
|
CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}
|
|
|
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
SERVER_DIR=$SCRIPT_DIR/../server
|
|
PIDFILE=$ROOT/server.pid
|
|
LOG=$ROOT/server.log
|
|
|
|
# By pid rather than by pattern: a `pkill -f` for something as generic as
|
|
# "ai-server" also matches the shell running this script, which kills the
|
|
# script mid-flight and leaves the restart never having happened.
|
|
stop_server() {
|
|
[ -f "$PIDFILE" ] || return 0
|
|
pid=$(cat "$PIDFILE")
|
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
|
kill "$pid" 2>/dev/null || true
|
|
echo "sandbox: stopped server $pid"
|
|
fi
|
|
rm -f "$PIDFILE"
|
|
}
|
|
|
|
if [ "${1:-start}" = stop ]; then
|
|
stop_server
|
|
exit 0
|
|
fi
|
|
|
|
stop_server
|
|
rm -rf "$ROOT/home" "$ROOT/sessions" "$ROOT/config.ron"
|
|
PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox
|
|
mkdir -p "$PROJECTS" "$ROOT/sessions"
|
|
|
|
# Eight of them, because the point of the screen is a list long enough that
|
|
# picking rows one at a time is the annoyance being fixed. Ids are the same
|
|
# shape the CLI writes (a uuid, and the file name *is* the session id), and
|
|
# each carries a `cwd` and a few user turns so the row has a title, a path
|
|
# and a line count to show.
|
|
i=1
|
|
while [ "$i" -le 8 ]; do
|
|
id="0000000${i}-5eed-4a11-9c0d-000000000${i}00"
|
|
file=$PROJECTS/$id.jsonl
|
|
cwd="/home/bob/repos/sandbox/project-$i"
|
|
: >"$file"
|
|
turn=1
|
|
while [ "$turn" -le $((i + 2)) ]; do
|
|
printf '{"type":"user","cwd":"%s","message":{"role":"user","content":[{"type":"text","text":"sandbox session %s, turn %s"}]}}\n' \
|
|
"$cwd" "$i" "$turn" >>"$file"
|
|
turn=$((turn + 1))
|
|
done
|
|
# A usage record on the last line, which is where the importer reads the
|
|
# context figure from. Left off two of them on purpose: "no turn has
|
|
# recorded any" is a state the row has to be able to show, and a list
|
|
# where every row has a number never exercises it.
|
|
if [ "$i" -ne 3 ] && [ "$i" -ne 6 ]; then
|
|
printf '{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":%s,"output_tokens":128}}}\n' \
|
|
"$((i * 9000))" >>"$file"
|
|
fi
|
|
i=$((i + 1))
|
|
done
|
|
|
|
# A CLI that does nothing, so importing one of these is free and safe.
|
|
# Everything the spawn path cares about is here: it holds the fifo open,
|
|
# records a real pid, writes nothing, and dies on a signal. A real
|
|
# `claude --resume` against an invented session id would either fail in a
|
|
# way that tests nothing or start a turn on somebody's account.
|
|
cat >"$ROOT/fake-claude" <<FAKE
|
|
#!/bin/sh
|
|
# Slow to start, on purpose. An import against this finishes in
|
|
# milliseconds otherwise, so every state on the way -- the row marked
|
|
# "importing", the queue behind it, the event that clears them -- is over
|
|
# before anything can observe it, and a broken one looks exactly like a
|
|
# working one. AI_SANDBOX_SPAWN_DELAY is how long that window is held open.
|
|
sleep $SPAWN_DELAY
|
|
cat > /dev/null
|
|
FAKE
|
|
chmod +x "$ROOT/fake-claude"
|
|
|
|
# One big one, because size is what makes importing take any time at all.
|
|
# A spawn replays the whole file into this app's transcript, so against the
|
|
# four-line sessions above it is over in milliseconds and every state on the
|
|
# way is unobservable -- which is how a row that should have been marked
|
|
# "importing" went unnoticed for not being marked at all. AI_SANDBOX_BIG_MB
|
|
# sets how large.
|
|
big=$PROJECTS/0000000b-5eed-4a11-9c0d-00000000b000.jsonl
|
|
awk -v mb="$BIG_MB" 'BEGIN {
|
|
target = mb * 1000000
|
|
line = "{\"type\":\"user\",\"cwd\":\"/home/bob/repos/sandbox/big\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"a long sandbox turn, number %d, with enough text on it that the file reaches a realistic size rather than a token one\"}]}}"
|
|
written = 0
|
|
for (i = 1; written < target; i++) {
|
|
out = sprintf(line, i)
|
|
print out
|
|
written += length(out) + 1
|
|
}
|
|
print "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{\"input_tokens\":180000,\"output_tokens\":900}}}"
|
|
}' > "$big"
|
|
|
|
hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1)
|
|
cat >"$ROOT/config.ron" <<RON
|
|
tokens: [
|
|
(
|
|
name: "sandbox",
|
|
sha256: "$hash",
|
|
),
|
|
],
|
|
setups: [
|
|
(
|
|
id: "local",
|
|
name: "sandbox",
|
|
providers: [
|
|
(
|
|
name: "echo",
|
|
kind: echo,
|
|
),
|
|
(
|
|
name: "claude-cli",
|
|
kind: claude_cli,
|
|
command: "$ROOT/fake-claude",
|
|
models: [
|
|
"haiku",
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
sessions: [],
|
|
RON
|
|
|
|
echo "sandbox: building"
|
|
(cd "$SERVER_DIR" && cargo build --quiet)
|
|
|
|
# Fully detached, so it outlives the shell that started it. HOME is the
|
|
# whole isolation: the importer's script reads it, and nothing else here
|
|
# looks outside the paths passed explicitly below.
|
|
HOME=$ROOT/home setsid nohup "$SERVER_DIR/target/debug/ai-server" \
|
|
--bind 127.0.0.1 \
|
|
--port "$PORT" \
|
|
--config "$ROOT/config.ron" \
|
|
--data-dir "$ROOT/sessions" \
|
|
--models-dir "$ROOT/models" \
|
|
--certs "$CERTS" \
|
|
--delay "$DELAY" \
|
|
>"$LOG" 2>&1 &
|
|
pid=$!
|
|
disown -h "$pid" 2>/dev/null || true
|
|
echo "$pid" >"$PIDFILE"
|
|
|
|
# Waited for rather than assumed: the enrolment below fails silently against
|
|
# a server that has not bound yet, and the app then shows a network error
|
|
# that has nothing to do with what is being tested.
|
|
tries=0
|
|
while [ "$tries" -lt 50 ]; do
|
|
if grep -q "listening\|Listening" "$LOG" 2>/dev/null; then break; fi
|
|
kill -0 "$pid" 2>/dev/null || { echo "sandbox: server exited; see $LOG" >&2; tail -5 "$LOG" >&2; exit 1; }
|
|
tries=$((tries + 1))
|
|
sleep 0.2
|
|
done
|
|
|
|
cat <<INFO
|
|
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
|
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
|
|
|
enrol the emulator:
|
|
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$TOKEN'"
|
|
|
|
stop it:
|
|
./ui-sandbox.sh stop
|
|
INFO
|