diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go
index 53e6b96..437ef23 100644
--- a/cmd/mavweb/handlers_test.go
+++ b/cmd/mavweb/handlers_test.go
@@ -65,6 +65,7 @@ type fakeCore struct {
// for handleTrace tests
tickTrace ipc.TickTrace
traceErr error
+ turns []ipc.TurnDecision
// for handleChatAPI tests
chatText string
@@ -173,6 +174,10 @@ func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) {
return f.revertNewID, nil
}
+func (f *fakeCore) TurnDecisions(_ context.Context, _ int) ([]ipc.TurnDecision, error) {
+ return f.turns, nil
+}
+
func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) {
if f.traceErr != nil {
return ipc.TickTrace{}, f.traceErr
@@ -825,6 +830,41 @@ func TestHandleTrace(t *testing.T) {
t.Error("rendered 'nothing fired' but a winner was set")
}
})
+
+ // The turn arbitration shares this page (V-564). A reader must see the
+ // winner, a loser and the claimants that were never asked, because the last
+ // of those is what the hardcoded ordering hides.
+ t.Run("renders the turn decision record", func(t *testing.T) {
+ core := &fakeCore{turns: []ipc.TurnDecision{{
+ Ts: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC),
+ Utterance: "какая погода в риме",
+ Winner: "query:weather",
+ Claims: []ipc.TurnClaim{
+ {Stage: "query", Claimant: "weather", Intent: "query", Outcome: "won"},
+ {Stage: "query", Claimant: "calendar", Outcome: "declined", Reason: "no answer"},
+ {Stage: "query", Claimant: "kiwix", Outcome: "never_asked"},
+ },
+ }}}
+ rr := httptest.NewRecorder()
+ handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
+ body := rr.Body.String()
+ for _, want := range []string{"какая погода в риме", "query:weather", "calendar", "kiwix", "never_asked"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("rendered page is missing %q", want)
+ }
+ }
+ })
+
+ t.Run("no turns renders the empty note, not an error", func(t *testing.T) {
+ rr := httptest.NewRecorder()
+ handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), &fakeCore{})
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "no turn has run") {
+ t.Error("empty ring did not render its note")
+ }
+ })
}
// --- handleRevert ---
diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go
index 88b4414..0d89d76 100644
--- a/cmd/mavweb/main.go
+++ b/cmd/mavweb/main.go
@@ -1415,12 +1415,29 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
+ // The turn records share this page rather than getting one of their own
+ // (V-564): both answer the same question — who won, who lost and why — and
+ // one is about nudges while the other is about utterances. A read failure
+ // here is not fatal to the page: the rule trace above it still renders, and
+ // a daemon too old to know the method is the ordinary case during a rolling
+ // deploy.
+ turns, err := core.TurnDecisions(ctx, 25)
+ if err != nil {
+ log.Printf("trace: turn decisions: %v", err)
+ }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := traceTmpl.Execute(w, trace); err != nil {
+ if err := traceTmpl.Execute(w, traceData{Tick: trace, Turns: turns}); err != nil {
log.Printf("trace render: %v", err)
}
}
+// traceData — what trace.html renders: the last tick's rule arbitration and the
+// last turns' claim arbitration.
+type traceData struct {
+ Tick ipc.TickTrace
+ Turns []ipc.TurnDecision
+}
+
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable)
diff --git a/cmd/mavweb/trace.html b/cmd/mavweb/trace.html
index 3d95037..4c997b2 100644
--- a/cmd/mavweb/trace.html
+++ b/cmd/mavweb/trace.html
@@ -1,9 +1,9 @@
{{template "shellTop" "trace"}}
Rule Trace
-{{.Now | ago}} — winner: {{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}
+{{.Tick.Now | ago}} — winner: {{if .Tick.Winner}}{{.Tick.Winner}}{{else}}nothing fired{{end}}
+
+Turn Decisions
+Who claimed each utterance, who lost it, and who was never asked. In memory, newest first, cleared on restart.
+{{if not .Turns}}no turn has run since the daemon started
{{end}}
+{{range .Turns}}
+
+{{.Utterance}} — {{if .Winner}}{{.Winner}}{{else}}nobody{{end}} {{.Ts | ago}}
+
+
+{{end}}
{{template "shellBottom"}}