7b3d2f5113ffe34d25b8985994ed5a97324d45af

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

ui: run availability probe asynchronously at startup

Diff

  1diff --git a/cmd/jp/main.go b/cmd/jp/main.go
  2index 7dba3f8f47d3c65b2a77be9d0bf6a561346291cd..3728ca97e20ab89fb59b281a96fb7b5a2e679e9c 100644
  3--- a/cmd/jp/main.go
  4+++ b/cmd/jp/main.go
  5@@ -55,8 +55,6 @@ func runCmd(args []string) {
  6 	personasDir := fs.String("personas", "assets/personas", "directory of persona JSON files")
  7 	fs.Parse(args)
  8 
  9-	services := probeServices(cfg)
 10-
 11 	data, err := os.ReadFile(*mapPath)
 12 	if err != nil {
 13 		fatalf("read map: %v", err)
 14@@ -74,16 +72,24 @@ func runCmd(args []string) {
 15 	playDone := make(chan struct{}, 1)
 16 	orch := buildOrchestrator(cfg, state, personas, playDone)
 17 
 18-	m := ui.NewModel(state, orch, services, playDone, int(recordCap.Seconds()))
 19-	if _, err := tea.NewProgram(m).Run(); err != nil {
 20+	m := ui.NewModel(state, orch, checkingServices(cfg), playDone, int(recordCap.Seconds()))
 21+	p := tea.NewProgram(m)
 22+	availability.CheckAllAsync(cfg, func(svc availability.Service) {
 23+		p.Send(ui.ProbeResultMsg{Svc: svc})
 24+	})
 25+	if _, err := p.Run(); err != nil {
 26 		fatalf("run: %v", err)
 27 	}
 28 }
 29 
 30-func probeServices(cfg config.Config) []availability.Service {
 31-	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
 32-	defer cancel()
 33-	return availability.CheckAll(ctx, cfg)
 34+// checkingServices seeds the UI with one "checking…" row per configured
 35+// service before any probe result has arrived.
 36+func checkingServices(cfg config.Config) []availability.Service {
 37+	return []availability.Service{
 38+		{Name: "LLM", URL: cfg.LLMBaseURL},
 39+		{Name: "TTS", URL: cfg.TTSBaseURL},
 40+		{Name: "STT", URL: cfg.STTURL},
 41+	}
 42 }
 43 
 44 func buildOrchestrator(cfg config.Config, state *game.State, personas []persona.Persona, playDone chan struct{}) *game.Orchestrator {
 45diff --git a/internal/availability/availability.go b/internal/availability/availability.go
 46index 5907843bee3e6acc2fd5dbdf6aad5090e377ff82..0bdb89769cb6f95a658d180fdee9aefa6f7ee514 100644
 47--- a/internal/availability/availability.go
 48+++ b/internal/availability/availability.go
 49@@ -48,6 +48,23 @@ func CheckAll(ctx context.Context, cfg config.Config) []Service {
 50 	}
 51 }
 52 
 53+// CheckAllAsync probes every configured service concurrently and invokes fn
 54+// once per service, in completion order. Each probe keeps its bounded timeout
 55+// and names the affected URL on failure, so one slow or dead service does not
 56+// hold up the others. It returns immediately after launching the probes.
 57+func CheckAllAsync(cfg config.Config, fn func(Service)) {
 58+	client := &http.Client{Timeout: checkTimeout}
 59+	ctx := context.Background()
 60+	jobs := []func() Service{
 61+		func() Service { return checkLLM(ctx, client, cfg.LLMBaseURL) },
 62+		func() Service { return checkTTS(ctx, client, cfg.TTSBaseURL) },
 63+		func() Service { return checkSTT(ctx, client, cfg.STTURL) },
 64+	}
 65+	for _, j := range jobs {
 66+		go func(j func() Service) { fn(j()) }(j)
 67+	}
 68+}
 69+
 70 func checkLLM(ctx context.Context, client *http.Client, base string) Service {
 71 	s := Service{Name: "LLM", URL: base}
 72 	modelsURL := strings.TrimRight(base, "/") + "/models"
 73diff --git a/internal/availability/availability_test.go b/internal/availability/availability_test.go
 74index 5e1450eb0866b0d2e885cb82f47a7b9b84dbf3bf..f92d9c7a533dcb604b5d7ef89d1964f49baf59a7 100644
 75--- a/internal/availability/availability_test.go
 76+++ b/internal/availability/availability_test.go
 77@@ -111,6 +111,55 @@ func TestCheckSTTDown(t *testing.T) {
 78 	}
 79 }
 80 
 81+func TestCheckAllAsyncPerServiceCompletion(t *testing.T) {
 82+	llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 83+		if r.URL.Path == "/models" {
 84+			w.Header().Set("Content-Type", "application/json")
 85+			_, _ = w.Write([]byte(`{"data":[{"id":"jp"}]}`))
 86+			return
 87+		}
 88+		http.NotFound(w, r)
 89+	}))
 90+	defer llmSrv.Close()
 91+
 92+	ttsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 93+		http.NotFound(w, r) // /models 404: server is up, endpoint absent
 94+	}))
 95+	defer ttsSrv.Close()
 96+
 97+	cfg := config.Config{
 98+		LLMBaseURL: llmSrv.URL,
 99+		TTSBaseURL: ttsSrv.URL,
100+		STTURL:     deadURL(t) + "/inference",
101+	}
102+
103+	ch := make(chan Service, 3)
104+	CheckAllAsync(cfg, func(s Service) { ch <- s })
105+
106+	byName := map[string]Service{}
107+	for i := 0; i < 3; i++ {
108+		select {
109+		case s := <-ch:
110+			byName[s.Name] = s
111+		case <-time.After(15 * time.Second):
112+			t.Fatal("a probe did not complete in time")
113+		}
114+	}
115+
116+	if !byName["LLM"].Up || !strings.Contains(byName["LLM"].Detail, "jp") {
117+		t.Errorf("LLM = %+v, want up with model id", byName["LLM"])
118+	}
119+	if !byName["TTS"].Up {
120+		t.Errorf("TTS = %+v, want up", byName["TTS"])
121+	}
122+	if byName["STT"].Up {
123+		t.Errorf("STT = %+v, want down against a dead URL", byName["STT"])
124+	}
125+	if !strings.Contains(byName["STT"].Detail, "127.0.0.1") {
126+		t.Errorf("STT detail should name the configured host: %q", byName["STT"].Detail)
127+	}
128+}
129+
130 func TestCheckAllNamesAndURLs(t *testing.T) {
131 	cfg := config.Config{
132 		LLMBaseURL:    deadURL(t),
133diff --git a/internal/ui/app.go b/internal/ui/app.go
134index 6bd5d0c90a6f603dec7784b704a7663d8a2edbb4..c3ac6380ecc55c74729d24564656324e8170ccea 100644
135--- a/internal/ui/app.go
136+++ b/internal/ui/app.go
137@@ -30,10 +30,17 @@ type finishResultMsg struct{ res game.TurnResult }
138 type tickMsg struct{ t time.Time }
139 type playDoneMsg struct{}
140 
141+// ProbeResultMsg carries one service's availability result as it arrives from
142+// the async startup probe, so the UI can update that service in place.
143+type ProbeResultMsg struct {
144+	Svc availability.Service
145+}
146+
147 type model struct {
148 	state        *game.State
149 	orch         *game.Orchestrator
150 	services     []availability.Service
151+	resolved     map[string]bool
152 	playDone     chan struct{}
153 	recordCapSec int
154 
155@@ -55,6 +62,7 @@ func NewModel(state *game.State, orch *game.Orchestrator, services []availabilit
156 		state:        state,
157 		orch:         orch,
158 		services:     services,
159+		resolved:     make(map[string]bool, len(services)),
160 		playDone:     playDone,
161 		recordCapSec: recordCapSec,
162 	}
163@@ -80,6 +88,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
164 		if cmd := m.listenPlayDone(); cmd != nil {
165 			cmds = append(cmds, cmd)
166 		}
167+	case ProbeResultMsg:
168+		m.applyProbe(v.Svc)
169 	case tickMsg:
170 		if m.phase == phaseRecording {
171 			cmds = append(cmds, nextTick())
172@@ -218,7 +228,7 @@ func (m *model) View() string {
173 		judgeScore:       m.judgeScore,
174 		judgeFeedback:    m.judgeFeedback,
175 		judgeErr:         m.judgeErr,
176-		services:         serviceLines(m.services),
177+		services:         m.serviceLines(),
178 		status:           m.status,
179 	}
180 	if m.phase == phaseProcessing {
181@@ -319,10 +329,30 @@ func locLines(mp game.MapData) []locLine {
182 	return out
183 }
184 
185-func serviceLines(svcs []availability.Service) []serviceLine {
186-	out := make([]serviceLine, 0, len(svcs))
187-	for _, s := range svcs {
188-		out = append(out, serviceLine{name: s.Name, up: s.Up, url: s.URL, detail: s.Detail})
189+// applyProbe records one arrived availability result in place, keyed by name.
190+func (m *model) applyProbe(svc availability.Service) {
191+	for i := range m.services {
192+		if m.services[i].Name == svc.Name {
193+			m.services[i] = svc
194+			break
195+		}
196+	}
197+	m.resolved[svc.Name] = true
198+}
199+
200+func (m *model) serviceLines() []serviceLine {
201+	out := make([]serviceLine, 0, len(m.services))
202+	for _, s := range m.services {
203+		sl := serviceLine{name: s.Name, url: s.URL, detail: s.Detail}
204+		switch {
205+		case !m.resolved[s.Name]:
206+			sl.state = "checking"
207+		case s.Up:
208+			sl.state = "up"
209+		default:
210+			sl.state = "down"
211+		}
212+		out = append(out, sl)
213 	}
214 	return out
215 }
216diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go
217index a8b19d25d1d5867b3839d3c2bb77882178dfe5ce..4f2c89bfedaadb793b72c19cea596252b174518e 100644
218--- a/internal/ui/app_test.go
219+++ b/internal/ui/app_test.go
220@@ -8,6 +8,7 @@ import (
221 
222 	"github.com/charmbracelet/bubbletea"
223 
224+	"japanese/internal/availability"
225 	"japanese/internal/game"
226 	"japanese/internal/llm"
227 )
228@@ -146,6 +147,30 @@ func TestStopIgnoredWhileBeginInFlight(t *testing.T) {
229 	}
230 }
231 
232+func TestProbeCheckingThenTransitionsInPlace(t *testing.T) {
233+	st := testState(t)
234+	svcs := []availability.Service{
235+		{Name: "LLM", URL: "http://llm"},
236+		{Name: "TTS", URL: "http://tts"},
237+		{Name: "STT", URL: "http://stt"},
238+	}
239+	m := NewModel(st, nil, svcs, nil, 10)
240+
241+	v := m.View()
242+	if strings.Count(v, "checking") != 3 {
243+		t.Fatalf("want 3 checking services initially, view:\n%s", v)
244+	}
245+
246+	m.Update(ProbeResultMsg{Svc: availability.Service{Name: "LLM", URL: "http://llm", Up: true, Detail: "models: jp"}})
247+	v = m.View()
248+	if !strings.Contains(v, "LLM up http://llm") {
249+		t.Fatalf("LLM should show up in place:\n%s", v)
250+	}
251+	if strings.Count(v, "checking") != 2 {
252+		t.Fatalf("want 2 checking after LLM result, view:\n%s", v)
253+	}
254+}
255+
256 func TestNpcSpeakingTransitionsToDone(t *testing.T) {
257 	m, _ := buildModel(t)
258 	res := game.TurnResult{
259@@ -197,7 +222,7 @@ func TestRenderNeverShowsJapanese(t *testing.T) {
260 		npcRomaji:  "arigatou gozaimasu",
261 		npcEnglish: "thank you very much",
262 		hasJudge:   true, judgeScore: 90, judgeFeedback: "good job desu",
263-		services: []serviceLine{{name: "LLM", up: true, url: "http://127.0.0.1:8081/v1"}},
264+		services: []serviceLine{{name: "LLM", state: "up", url: "http://127.0.0.1:8081/v1"}},
265 	}
266 	got := render(good)
267 	for _, want := range []string{"konnichiwa desu", "arigatou gozaimasu", "thank you very much", "good job desu"} {
268@@ -213,7 +238,7 @@ func TestRenderNeverShowsJapanese(t *testing.T) {
269 	hostile.judgeFeedback = "よくできました"
270 	hostile.locs[0].label = "ラーメン屋"
271 	hostile.status = "エラー occurred"
272-	hostile.services = []serviceLine{{name: "STT", up: false, url: "http://127.0.0.1:8178/inference", detail: "接続できない"}}
273+	hostile.services = []serviceLine{{name: "STT", state: "down", url: "http://127.0.0.1:8178/inference", detail: "接続できない"}}
274 	assertNoJapanese(t, render(hostile))
275 }
276 
277diff --git a/internal/ui/render.go b/internal/ui/render.go
278index c38561c785b8ef80e01b97397714aeb25f89f0b4..00b85408796e8ec0efff47f6896b9fc1392bd2f2 100644
279--- a/internal/ui/render.go
280+++ b/internal/ui/render.go
281@@ -16,7 +16,7 @@ type locLine struct {
282 
283 type serviceLine struct {
284 	name   string
285-	up     bool
286+	state  string // "checking", "up", or "down"
287 	url    string
288 	detail string
289 }
290@@ -135,11 +135,7 @@ func learningBlock(hasJudge bool, score int, feedback, judgeErr string) string {
291 func serviceBlock(svcs []serviceLine) string {
292 	var b strings.Builder
293 	for _, s := range svcs {
294-		state := "down"
295-		if s.up {
296-			state = "up"
297-		}
298-		fmt.Fprintf(&b, "%s %s %s", sanitizeRomajiOnly(s.name), state, sanitizeRomajiOnly(s.url))
299+		fmt.Fprintf(&b, "%s %s %s", sanitizeRomajiOnly(s.name), s.state, sanitizeRomajiOnly(s.url))
300 		if s.detail != "" {
301 			fmt.Fprintf(&b, "  (%s)", sanitizeRomajiOnly(s.detail))
302 		}