Run imports and deletes on the server, and say so on an event

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.
This commit is contained in:
iris committed 2026-08-31 21:05:33 -04:00
1 parent b172c464ea
commit 3c159fa1e1
12 files changed
+992 -176

No files matched your search

+33 -5
View File
@@ -22,9 +22,10 @@
# ./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, and
# AI_SANDBOX_DELAY -- the last being the server's own `--delay`, which is
# what makes a spinner visible at all. On loopback every request is back in
# 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
@@ -33,6 +34,8 @@ 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)
@@ -96,12 +99,37 @@ done
# 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'
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: [
@@ -165,7 +193,7 @@ done
cat <<INFO
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
sandbox: 8 invented Claude Code sessions under $PROJECTS
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'"