wiki-vault/Sources/Dev/petal-dispatch-phase4-brief.md

142 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
created: '2026-06-06'
path: Sources/Dev
project: petal-dispatch-phase4-brief
tags:
- petal-dispatch
- lovebug
- phase-4
- android
- kotlin
- sse
- fastapi
- tailscale
- p4
- execution
type: project-plan
---
# petal-dispatch — Phase 4 — Native Android Client — Worker Brief
**Project:** petal-dispatch (p4) · **Thread:** 588 · **Repo:** `petal-power/petal-dispatch-android` (Gitea)
**Plan ref:** p4 Phase 4 · Satisfies FR-001 (mobile), FR-003, FR-004, FR-005, FR-006, FR-012 (Android surfacing), FR-007, SC-1, SC-3
**Structure:** Two sequential gated spawns (4a → 4b). Each lands a handoff to thread 588 before the next spawns. A human auth-boundary gate precedes 4a; a human install + on-device-verify gate sits between every build and the next spawn.
## Goal
A native Android client for petal-dispatch that reaches **parity with the live Phase 3 web client** — Chat (orchestrator round-trip via the store) and Threads (list, live-thread highlight, render-only tap-to-switch) — on the harder platform. Phase 3 shipped and is dogfooded in production at `https://dispatch.herbylab.dev/`; Android **re-implements proven surfaces against an already-settled backend contract**, it does not pioneer new ones. Web went first deliberately (p4 D-1) so this phase copies a working reference, not a paper spec.
**Phase 4 success bar:** From the phone, on the tailnet, Travis sends a message and gets a conversational orchestrator response, round-trip through the store (FR-005), rendered (SC-1: connect in ≤3 refreshes). Threads tab renders live thread state with ≤1-action focus switch (SC-3, render-only). **Orchestrator only — no worker spawn, no Tasks surface, no Activity feed this phase.**
The prior `local_851b4b30… "Android Kotlin chat app scaffold"` and the existing `petal-dispatch-android` v0 chat app are **reference-only, not a base.** Mine them for Compose / Material 3 / OkHttp patterns; ship fresh. This resolves the mild tension in p4 Phase 4's own text ("extend the existing chat screen" vs. "drop WebSocket-only") cleanly toward the SSE rewrite — the repo continues, the v0 WebSocket chat code is replaced and survives only in history. Standard worker discipline (zero-check, a-review per handoff) applies per env instructions.
## Locked Decisions
**Scope — lean parity (Travis call).** Chat + Threads only, matching the web client. **Out of scope this phase:** Activity tab (no in-app event feed — debug via the web client or DB), Tasks tab, worker-task/stall surfaces (FR-001 worker case, SC-7), optimistic render (Phase 5), notifications. Rationale: lean Phase 4 has no worker-spawn surface, so Android v1 is a true re-implementation of proven Phase 3 surfaces — no surface where Android is simultaneously the first client to exercise a behavior. The platform is the only new variable.
**Build approach — fresh project, scaffold as reference (Travis call).** New Compose project in the continuing `petal-dispatch-android` repo. Carry forward *patterns* (Compose structure, Material 3 theme, OkHttp transport choice), not the WebSocket chat screen. README updated to the v1 contract. Mirrors 3b discipline (`static/` was reference-only, shipped fresh).
**Scaffold inputs (locked — worker needs these at gradle-init):**
- **Application ID / package:** `dev.herbylab.petaldispatch`.
- **`minSdk` = 36, `targetSdk` = 36** (`compileSdk` 36). Phone is on **Android 16 (API 36)**; single-device, sideloaded, tailnet-only personal tool means no long-tail support pressure, so the floor is matched to the daily driver for the cleanest code (no compat shims, no `Build.VERSION` branching). *Build note: compiling against API 36 may require a recent Android Gradle Plugin; if a headless build throws "unknown compileSdk" or AGP-too-old, the fix is bump AGP, not lower the SDK — surface in 4a recon so it doesn't eat a build cycle.*
- **APK output path:** Lovebug builds to default Gradle output (`app/build/outputs/apk/debug/app-debug.apk`) **and** copies to a stable, timestamped known path — `~/petal-dispatch-apks/4a-<timestamp>.apk` / `~/petal-dispatch-apks/4b-<timestamp>.apk` — so handoff notes point to it cleanly.
- **Repo / branch:** work happens on a **feature branch** off `petal-dispatch-android` that will merge into the project; v0 lives on in history (no sibling-dir, no orphan branch). The fresh build replaces v0 chat code on that branch.
- **Branch + push policy:** Gitea-destined, **auto-push is fine** (standing model) — worker pushes the branch at sensible intervals, i.e. **when a significant feature lands**, not every commit and not held local.
**Auth — tailnet-only open (p4 D-14 / D-7, confirmed).** `OPEN_MODE=1`; **all client↔backend traffic over Tailscale**; no token, no header, no login wall. The Android client hits `https://dispatch.herbylab.dev/` over the tailnet and inherits the same `_check_auth` no-op posture the web client runs under. **Zero new auth code in Phase 4.** Trust boundary is the tailnet; the day that flips to any public exposure, D-14's Authentik flip is a hard prerequisite, not a nice-to-have.
**Backend URL during dev (confirmed).** Production `https://dispatch.herbylab.dev/` over the tailnet — the same origin/route the web client uses. No local/herbys-dev-direct fallback build variant for v1 (round-tripping through Traefik is acceptable at this scope; a direct-to-uvicorn debug variant can be added later if iteration speed demands it, not now).
**Transport (p4 D-6, backend already settled).** The backend transport fork is resolved and live — Android consumes the shipped contract, it does not relitigate it. "Drop WebSocket-only" means dropping the *Android app's* shipped WS (`/api/tasks/events` and the v0 `/ws` chat), not changing the backend. Android uses **REST (POST actions) + SSE (live read)** over the same OkHttp transport.
**Backend contract Android consumes (shipped + live, per checkpoint 1094):**
- `POST /api/chat` — send a message to the orchestrator.
- `GET /api/chat?thread_id=…` — chat history for a thread.
- `GET /api/threads` — thread list.
- `POST /api/threads/<id>/activate` — activate a thread (Threads tab uses **render-only** tap-to-switch this phase; whether Android calls this at all is a 4a recon item — see Open Items).
- `GET /events/stream` (SSE) with `?from_seq=N` backfill — live event read.
- **Reconcile-key on every envelope (D-12)** — render-on-final on Android is identical in shape to the web client's id-keyed path.
**Rendering (p4 D-10/D-12).** Render-on-final, keyed by the reconcile key (PD-issued UUID) — the same id-keyed path optimistic render layers onto later (Phase 5). Do not foreclose it. **Re-implement the web client's proven render logic; do not reinvent it** (see pre-mortem drift guard).
**Tap-to-switch is render-only this phase (copies Phase 3).** Switching focus changes the highlighted tile; no orchestrator-side active-thread repointing is wired from Android.
**Toolchain — headless on herbys-dev (Travis-confirmed present; 4a recon re-confirms).** Android SDK is on herbys-dev (the Lovebug worker host); Travis confirms SDK + Tailscale present. Builds are headless Gradle (`./gradlew assembleDebug`), **no emulator** (herbys-dev is a headless VM). Build verification is "compiles + APK produced," never "runs on phone." *Recon backstop: 4a prints `java -version`, `./gradlew -v`, and the SDK path as a first step — cheap confirmation that the asserted toolchain is real before the build leans on it (the thread-588 open question that prompted this).*
**Device loop — human gate, plain USB.** Lovebug builds the APK to the stable known path and reports it; **Travis transfers via plain USB, installs on the physical phone, and verifies on-device manually.** Lovebug's "done" signal for any sub-phase is **build-green + APK path + what changed** — it can never include on-device behavior. SC-2's crash-grace (≤5/day during build, trending to zero) is measured by Travis on-device; Lovebug cannot observe a crash it cannot run.
## Open Items
### Pre-phase human gate — auth-boundary verification (Travis, before 4a spawns)
`OPEN_MODE=1` makes the tailnet the *entire* trust boundary, so verify the boundary before building on it (same posture as Phase 1 measuring OAuth):
- [x] **Off-tailnet reachability test — DONE.** Confirmed `https://dispatch.herbylab.dev/` is not reachable from off-tailnet. (The wildcard `*.herbylab.dev` cert + public Cloudflare DNS means the hostname *resolves* publicly; reachability was answered by an actual off-tailnet connection attempt, not a DNS lookup.)
- [x] **CORS / origin sanity — DONE, clean.** `GET /api/threads` with a forged `Origin` returns 200 with **no `access-control-allow-origin` header** — default-deny; no CORS middleware advertising cross-origin read permission. Browser-scripted cross-origin reads are blocked. (Lower-stakes than it sounds: native Android sends no `Origin` and is not subject to CORS — this protects the web surface. Same-origin web client is unaffected because `/static` and `/api` share an origin. If the web client ever moves to a different hostname, add `CORSMiddleware` with that origin allowlisted — future-you, not Phase 4.)
- [ ] **Deploy-surface commits — OWED (Travis to run on herbys-dev; commands below).** Commit the uncommitted Traefik config and the untracked migration so the gating config is the *committed* config:
```
cd <petal-dispatch repo on herbys-dev>
git add traefik/petal-dispatch.yml traefik/temp.yml db/migrations/002_dispatch_trellis_read.sql
git commit -m "chore: commit traefik gating config + trellis-read migration (pre-Phase-4 deploy-surface reconcile)"
git push
```
*Note: `traefik/temp.yml` — confirm it is the intended gating config and not a throwaway before committing; if it's genuinely temporary, delete it instead of committing so the gating config is unambiguous.*
- [x] **SDK + Tailscale-on-phone present — Travis-confirmed.** Android SDK present + working on herbys-dev; Tailscale installed and logged into the tailnet on the phone. *(4a recon prints toolchain versions as a backstop; phone-on-tailnet is implied by the off-tailnet test passing + on-tailnet reachability working.)*
### 4a recon (answer before building UI; checkpoint if anything changes scope)
- [ ] **Toolchain sanity:** `java -version`, `./gradlew -v`, SDK path, AGP/Gradle compatible with `compileSdk 36`.
- [ ] **SSE-on-Android wire fidelity:** OkHttp SSE consumer parses the live `/events/stream` envelope (field names, event types, `?from_seq=N` backfill) exactly as the web client does. Contract is settled server-side; the question is the Kotlin consumer matching it.
- [ ] **Reconcile-key population:** which event tiers/types carry a non-NULL `message_id`? Chat rendering keys on it (D-12). Fact lives in the Phase 0 schema + writer / the web client's existing handling.
- [ ] **Threads `/activate` vs. pure render-only:** Phase 3 made tap-to-switch render-only; lean parity copies that. Confirm whether Android calls `POST /api/threads/<id>/activate` or just highlights locally. *(If it does call activate, the switch-guard 409 question below becomes reachable — flag at checkpoint, do not silently expand scope.)*
### Deferred — not answered, not reachable by lean Phase 4 surfaces
- **Worker-spawn `thread_id` override** (from 3b, event 1092) — `start_task` explicit thread_id vs. always-inherit-active. No worker-spawn surface in lean Phase 4; belongs to a later Tasks-tab increment or the orchestrator/harness split.
- **Switch-guard policy** (from 3b) — hard 409 vs. "park the worker, switch anyway." Tap-to-switch is render-only, so unreachable unless 4a recon finds Android wiring real activation against a running worker (it should not, this phase).
## Phases
### 4a — Project skeleton + transport layer proven against the live backend
**Recon first, then build the transport spine. Checkpoint findings before UI if anything changes scope.**
Build:
- [ ] Fresh Compose project in `petal-dispatch-android` on a feature branch; appId `dev.herbylab.petaldispatch`; `min/target/compileSdk` 36; Material 3 theme + OkHttp carried as patterns from the v0 scaffold (reference-only).
- [ ] REST client: `POST /api/chat`, `GET /api/chat?thread_id=…`, `GET /api/threads`, (`POST /api/threads/<id>/activate` only if recon says wire it). Base URL `https://dispatch.herbylab.dev/`.
- [ ] SSE consumer over `/events/stream` with `?from_seq=N` backfill; reconnect = re-subscribe + re-render from store (survives reconnect, FR-003 / FR-004 mobile).
- [ ] **Off-tailnet / connection-failure state renders legibly** — clear "not on tailnet / reconnecting" UI, never a spinner-of-death or unhandled exception (p4 Phase 4 edge case; FR-001-adjacent visibility; SC-2 contributor).
- [ ] Headless build green: `./gradlew assembleDebug` produces an installable debug APK; copy to `~/petal-dispatch-apks/4a-<timestamp>.apk`.
- [ ] Push the feature branch to Gitea at this milestone.
**4a done =** recon answered + checkpointed · transport layer (REST + SSE + reconnect) compiles and the SSE consumer parses the live contract · off-tailnet state renders · build-green + stable APK path + what-changed in handoff to 588. **Human gate:** Travis transfers via USB, installs, confirms the app launches and connects over the tailnet, before 4b spawns.
### 4b — Chat + Threads UI on the transport spine
UI on top of 4a's proven transport. **Re-implement the web client's render logic — do not reinvent it** (pre-mortem drift guard).
- [ ] **Chat** *(success-bar surface)* — send input → orchestrator conversational response → echo via `USER_MESSAGE` through the store (FR-005), rendered. **Orchestrator only, no worker spawn.** Input-during-run queued client-side with visible pending state (FR-007). Render-on-final keyed by reconcile key (D-10/D-12).
- [ ] **Threads** — thread list, live thread highlighted, recent context, tap-to-switch **render-only** (SC-3 ≤1 action). Source: `GET /api/threads` + SSE updates. Lovebug's web-client tiles are the reference.
- [ ] Layout: logical, function over form. Design iteration happens later with Travis — iterate-shaped surface (p4), not a spec gate. No default-tab lock.
- [ ] Headless build green: debug APK produced; copy to `~/petal-dispatch-apks/4b-<timestamp>.apk`.
- [ ] Push the feature branch to Gitea at this milestone.
**4b done =** message sent from phone → orchestrator conversational response → round-trip through store (FR-005), rendered, orchestrator-only no worker · Threads renders live state, tap-to-switch render-only · off-tailnet state legible · survives reconnect · build-green + stable APK path in handoff to 588. **Human gate:** Travis transfers via USB, installs, verifies parity-with-web on-device; SC-1 (≤3 refreshes) and SC-3 (≤1-action switch) confirmed on-device.
### Sideload / distribution
Per p4 D-9: sideload v1 (debug APK → plain-USB install). Self-hosted F-Droid behind Traefik is a fast-follow, gated on first stable v1 release with no daily crashes for 3 consecutive days — **out of Phase 4 scope.**
## Notes
**Why Android copies, doesn't pioneer.** Phase 3 is closed and live (checkpoint 1094): Threads + Chat in production, transport contract settled and proven end-to-end, journal round-trip persisting, all three DB pools attached. Lean Phase 4 deliberately drops Activity/Tasks so every Android surface has a proven web counterpart. The platform is the only new variable.
**Why the human gate sits where it does.** herbys-dev is headless (no emulator) and is the Lovebug host, so builds are autonomous but on-device behavior is unobservable to the worker. The build/verify split is structural, not a preference. Lovebug's handoffs land at build-green + APK path; Travis closes the on-device loop (USB install + verify) between spawns. This is the Android analogue of Phase 3's gated spawns, with a human verification gate replacing the worker-to-worker handoff.
**Why the auth-boundary gate is pre-phase, not in-phase.** `OPEN_MODE=1` feels safe because Tailscale is the gate, which is exactly why it goes unaudited. The Phase 4 pre-mortem (Travis's call) is not "OIDC is hard" — it is "the open backend's trust boundary quietly fails or gets punched through." Mitigation is a verification gate before any Android code, not a code change: prove the route is tailnet-only from off-tailnet (done), make the gating config the committed config (owed), sanity-check CORS (done, clean). Measure the boundary before trusting it.
**Drift guard (the quiet-breakage risk).** The fresh build must re-implement the web client's *proven* reconcile/render logic, not a fresh interpretation of it — reinventing render-on-final keying is the most likely way a solved bug returns on the new platform. Treat the web client's render path as the spec for the Kotlin one.
**Thread 588 metadata.** Slug `petal-dispatch-v3-build` and goal text reference the v3 plan/phases, pre-dating the p3→p4 reframe; checkpoints are current (1094 is p4-aware). Travis's call on refresh-vs-leave recorded separately; not a blocker for Phase 4 handoffs landing on 588.
**Gated structure.** Pre-phase auth-boundary gate (Travis) → 4a recon → checkpoint (any scope findings) → 4a transport build → human USB-install/connect gate → 4b UI build → human parity-verify gate. Sequential spawns, handoff to 588 between each.