# istrain — THE MILL stack (parallel stand-up started 2026-07-18; MILL-MIGRATION.md is the tracker). # Deployed by mill/deploy.sh into the Dockge stack dir (stacks/istrain) — Dockge owns the lifecycle, # the repo owns the truth. Web runs from day one on dual-pushed data (istrain-push-mill on the VM). # # airband + worker sit behind the "radio" profile until dongle day: # docker compose --profile radio up -d ← run this when 1002 is plugged into the Mill # (plain `up -d` starts web only; `--profile radio build` pre-builds the radio images now.) # # ONE CONF HOME: the capture dir mounts at /tmp/airband inside airband+worker, so the VM's canonical # airband/istrain.conf and transcribe-worker defaults run VERBATIM — no Mill fork of the channel plan. # Clips land in ./data/captures = web's CAPTURES_DIR, so the captures panel reads Mill-captured clips # the same way it reads VM-pushed ones. (Unpruned local growth: a DATA.md question for dongle day.) services: web: build: { context: ., dockerfile: web.Dockerfile } image: istrain-web:mill container_name: istrain-web restart: unless-stopped ports: - "3456:3456" volumes: - ./app:/app:ro - ./data:/data environment: - BIND=0.0.0.0 - PORT=3456 # NO PUSH_KEY (security pass 2026-07-24): the Mill AUTHORS its own data (push retired 07-18), # so /api/push + /api/push-clip must stay DISABLED — an unset key makes both return 403. Setting # a key here would re-open a public, key-in-repo data-overwrite path behind the Cloudflare name. - EOT_LOG=/data/eot.jsonl - BOT_LOG=/data/bot.jsonl - MID_LOG=/data/midtrain.jsonl - HEARD_LOG=/data/heard.jsonl - LEVEL_LOG=/data/level.json - SUPERVISOR_SNAPSHOT=/data/supervisor.json - HITS_LOG=/data/hits.json - CAPTURES_DIR=/data/captures - TRANSCRIPTS_LOG=/data/transcripts.jsonl # NO CTL_FORWARD (2026-07-18): the old target was the VM dash, whose buttons now control # disabled units. Control is parked (403s gracefully; supervisor rows carry can_ctl=false via # supervisor-probe.py) until a docker control relay exists — MILL-MIGRATION.md open item. airband: build: { context: ., dockerfile: airband.Dockerfile } image: istrain-airband:mill container_name: istrain-airband restart: unless-stopped profiles: ["radio"] # whole-bus bind + cgroup rule (189 = usb) so the dongle survives replug/re-enumeration without # a container restart; librtlsdr is built with DETACH_KERNEL_DRIVER=ON as the second line of # defense behind the host dvb blacklist. rtl_airband exits nonzero with no dongle — the restart # policy does the waiting (the 2026-07-18 boot-race lesson, baked in from day one). volumes: - /dev/bus/usb:/dev/bus/usb - ./conf/istrain.conf:/etc/rtl_airband/istrain.conf:ro - ./data/captures:/tmp/airband device_cgroup_rules: - "c 189:* rwm" environment: # the conf's localtime=true honors TZ; without it clip filenames stamp UTC (caught 2026-07-18, # first Mill clips named _1454xx against 10:54 local — breaks eyeball-parity with VM-era names) - TZ=America/New_York worker: build: { context: ., dockerfile: worker.Dockerfile } image: istrain-worker:mill container_name: istrain-worker restart: unless-stopped profiles: ["radio"] volumes: - ./app:/app:ro - ./data:/data - ./data/captures:/tmp/airband environment: # whisper is the istrain-whisper stack on this same box; host tailnet IP reaches it (:9000) - MILL_ASR=http://MILL-IP:9000 - TRANSCRIPTS_LOG=/data/transcripts.jsonl - TZ=America/New_York command: ["python3", "/app/scripts/transcribe-worker.py"] trim: image: istrain-worker:mill container_name: istrain-trim restart: unless-stopped volumes: - ./data/captures:/tmp/airband environment: # same policy as the VM era: voice audio >24h pruned AFTER heard.jsonl ingest (the script # aborts if the dash is unreachable — an unlogged clip can never be deleted); transcripts + # detections are the durable record. Keeps ./data/captures bounded on flash. - DASH_URL=http://web:3456 - CAP_KEEP_HOURS=24 - TZ=America/New_York command: ["sh", "-c", "while :; do python3 /app/scripts/trim-captures.py; sleep 3600; done"] depends_on: [web] # ---- the 1001 set (profile radio1001) — start ONLY once 1001 is plugged into the Mill: --------- # docker compose --profile radio --profile radio1001 up -d # Separate profile from 1002's so a compose cycle can never point rtl_tcp at the wrong dongle # while only one radio has moved. The chain writes under ~/istrain (hardcoded in iq_channelize), # so ./data mounts at /root/istrain — the SAME files web serves; push-seeded history continues # in place. ⚠ istrain-push-mill on the VM must be DISABLED before first start (its 60s full-file # replace would fight the appends — the PUSH_SKIP lesson, now for every log). scanhop: build: { context: ., dockerfile: scanhop.Dockerfile } image: istrain-scanhop:mill container_name: istrain-scanhop restart: unless-stopped profiles: ["radio1001"] volumes: - /dev/bus/usb:/dev/bus/usb - ./app:/app:ro - ./data:/root/istrain device_cgroup_rules: - "c 189:* rwm" environment: - HOP_DWELL_452=28 - HOP_DWELL_457=28 - TZ=America/New_York eot-decode: image: istrain-worker:mill container_name: istrain-eot-decode restart: unless-stopped profiles: ["radio1001"] volumes: - ./app:/app:ro - ./data:/root/istrain environment: - TZ=America/New_York command: ["python3", "/app/scripts/eot-decode.py"] bot-recover: image: istrain-worker:mill container_name: istrain-bot-recover restart: unless-stopped profiles: ["radio1001"] volumes: - ./app:/app:ro - ./data:/root/istrain environment: - TZ=America/New_York command: ["python3", "/app/scripts/bot-recover.py"]