feat(server,tui): session stats pane — metrics over the WS bus + Bubble Tea overlay
Surfaces the observability metrics (observability-spec §3-tier-2) in the Go TUI: a session-summary pane showing duration, token throughput per provider, tool time, approval latency per tier, failure counts, and the session wall-time accounting split. Press `S` (or palette → "session stats"). Mirrors the artifacts/config fetch pattern exactly — a WS request/response, not a new transport. Server-side reuses the already-tested MetricsInspectionService (one MetricsReport definition, two wires: REST `correx stats` + WS). Server: - ClientMessage.GetSessionStats + ServerMessage.SessionStats(@SerialName session.stats), reusing MetricsReport as the nested payload. - StreamQueries.sessionStats replays via MetricsInspectionService (pure read, replay-neutral); routed in GlobalStreamHandler. - ServerMessageSerializationTest golden pins the session.stats wire format. TUI (Go/Bubble Tea): - protocol: TypeSessionStats + StatsDto/nested structs + GetSessionStats encoder + golden decode test (cross-language contract). - OverlayStats pane (statsModal), `S` key + palette entry + footer hint, loading/cache-by-session state, bounded breakdown rows. - demo `stats` preview case for serverless visual verification.
This commit is contained in:
@@ -42,6 +42,10 @@ sealed class ClientMessage {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class ListArtifacts(val sessionId: SessionId) : ClientMessage()
|
data class ListArtifacts(val sessionId: SessionId) : ClientMessage()
|
||||||
|
|
||||||
|
/** Operator request for a session's derived metrics (replied to with a SessionStats). */
|
||||||
|
@Serializable
|
||||||
|
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
|
||||||
|
|
||||||
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
|
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data object GetConfig : ClientMessage()
|
data object GetConfig : ClientMessage()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.apps.server.protocol
|
package com.correx.apps.server.protocol
|
||||||
|
|
||||||
|
import com.correx.apps.server.metrics.MetricsReport
|
||||||
import com.correx.core.approvals.Tier
|
import com.correx.core.approvals.Tier
|
||||||
import com.correx.core.events.types.ApprovalRequestId
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
import com.correx.core.events.types.ArtifactId
|
import com.correx.core.events.types.ArtifactId
|
||||||
@@ -425,6 +426,20 @@ sealed interface ServerMessage {
|
|||||||
override val sessionSequence: Long? = null,
|
override val sessionSequence: Long? = null,
|
||||||
) : ServerMessage, NonEventMessage
|
) : ServerMessage, NonEventMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A session's derived metrics, returned in response to a GetSessionStats request. Not
|
||||||
|
* event-derived (it is a projection-replay snapshot), so cursors are null. [stats] is the same
|
||||||
|
* [MetricsReport] the REST `/sessions/{id}/metrics` endpoint serves — one definition, two wires.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("session.stats")
|
||||||
|
data class SessionStats(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val stats: MetricsReport,
|
||||||
|
override val sequence: Long? = null,
|
||||||
|
override val sessionSequence: Long? = null,
|
||||||
|
) : ServerMessage, NonEventMessage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current editable config (reply to GetConfig / UpdateConfig). [restartRequired] lists the
|
* The current editable config (reply to GetConfig / UpdateConfig). [restartRequired] lists the
|
||||||
* keys in a just-applied patch that need a server restart to take effect; [error] is set (and
|
* keys in a just-applied patch that need a server restart to take effect; [error] is set (and
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
|
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
|
||||||
}
|
}
|
||||||
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
|
||||||
|
is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
|
||||||
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
||||||
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
|
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
|
||||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.correx.apps.server.ws
|
|||||||
|
|
||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.apps.server.config.ConfigUpdateResult
|
import com.correx.apps.server.config.ConfigUpdateResult
|
||||||
|
import com.correx.apps.server.metrics.MetricsInspectionService
|
||||||
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
||||||
import com.correx.apps.server.protocol.ConfigFieldDto
|
import com.correx.apps.server.protocol.ConfigFieldDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
@@ -33,6 +34,18 @@ private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true }
|
|||||||
*/
|
*/
|
||||||
class StreamQueries(private val module: ServerModule) {
|
class StreamQueries(private val module: ServerModule) {
|
||||||
|
|
||||||
|
private val metricsService = MetricsInspectionService(module.eventStore)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A session's derived metrics as a snapshot frame. Replays the event log through
|
||||||
|
* [MetricsInspectionService] (the same projection the REST metrics endpoint uses) — pure read,
|
||||||
|
* no events appended, so it is replay-neutral (invariant #1/#8).
|
||||||
|
*/
|
||||||
|
suspend fun sessionStats(sessionId: SessionId): ServerMessage.SessionStats {
|
||||||
|
val report = withContext(Dispatchers.IO) { metricsService.inspect(sessionId) }
|
||||||
|
return ServerMessage.SessionStats(sessionId = sessionId, stats = report)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a session's events to assemble its artifact listing (creation order preserved),
|
* Reads a session's events to assemble its artifact listing (creation order preserved),
|
||||||
* resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —
|
* resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —
|
||||||
|
|||||||
+47
@@ -1,5 +1,10 @@
|
|||||||
package com.correx.apps.server.protocol
|
package com.correx.apps.server.protocol
|
||||||
|
|
||||||
|
import com.correx.apps.server.metrics.FailureMetrics
|
||||||
|
import com.correx.apps.server.metrics.MetricsReport
|
||||||
|
import com.correx.apps.server.metrics.ProviderStatsDto
|
||||||
|
import com.correx.apps.server.metrics.TierApprovalStatsDto
|
||||||
|
import com.correx.apps.server.metrics.ToolStatsDto
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
@@ -146,6 +151,48 @@ class ServerMessageSerializationTest {
|
|||||||
assertEquals(listOf("server.port"), decoded.restartRequired)
|
assertEquals(listOf("server.port"), decoded.restartRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SessionStats encodes type, sessionId and nested metrics`() {
|
||||||
|
val msg = ServerMessage.SessionStats(
|
||||||
|
sessionId = SessionId("sess-stats"),
|
||||||
|
stats = MetricsReport(
|
||||||
|
sessionId = "sess-stats",
|
||||||
|
eventCount = 12,
|
||||||
|
sessionDurationMs = 60_000,
|
||||||
|
inferenceCount = 3,
|
||||||
|
inferenceMs = 6000,
|
||||||
|
promptTokens = 310,
|
||||||
|
completionTokens = 170,
|
||||||
|
tokensPerSecond = 28.3,
|
||||||
|
perProvider = listOf(ProviderStatsDto("lfm:a", 2, 5000, 300, 150, 30.0)),
|
||||||
|
toolCount = 3,
|
||||||
|
toolMs = 1500,
|
||||||
|
perTool = listOf(ToolStatsDto("shell.read", 2, 1200)),
|
||||||
|
approvalsRequested = 2,
|
||||||
|
approvalsResolved = 1,
|
||||||
|
approvalsPending = 1,
|
||||||
|
approvalWaitMs = 30_000,
|
||||||
|
avgApprovalWaitMs = 30_000,
|
||||||
|
perTier = listOf(TierApprovalStatsDto("T2", 2, 1, 30_000, 30_000)),
|
||||||
|
failures = FailureMetrics(inferenceFailures = 1, toolFailures = 1, workflowFailures = 1),
|
||||||
|
inferencePct = 10.0,
|
||||||
|
toolPct = 2.5,
|
||||||
|
approvalWaitPct = 50.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
|
||||||
|
assert(jsonStr.contains("\"type\":\"session.stats\"")) { "expected type=session.stats" }
|
||||||
|
assert(jsonStr.contains("\"sessionId\":\"sess-stats\"")) { "expected sessionId" }
|
||||||
|
assert(jsonStr.contains("\"completionTokens\":170")) { "expected nested metric field" }
|
||||||
|
assert(jsonStr.contains("\"provider\":\"lfm:a\"")) { "expected per-provider row" }
|
||||||
|
|
||||||
|
val decoded = json.decodeFromString<ServerMessage.SessionStats>(jsonStr)
|
||||||
|
assertEquals("sess-stats", decoded.stats.sessionId)
|
||||||
|
assertEquals(3, decoded.stats.inferenceCount)
|
||||||
|
assertEquals(1, decoded.stats.perProvider.size)
|
||||||
|
assertEquals(50.0, decoded.stats.approvalWaitPct)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SessionAnnounced round-trips through JSON`() {
|
fun `SessionAnnounced round-trips through JSON`() {
|
||||||
val original = ServerMessage.SessionAnnounced(
|
val original = ServerMessage.SessionAnnounced(
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestConfigSnapshotClearsStagedOnCleanReply(t *testing.T) {
|
|||||||
m := configModel()
|
m := configModel()
|
||||||
m.configStaged["project.enabled"] = "true"
|
m.configStaged["project.enabled"] = "true"
|
||||||
m.applyServer(protocol.ServerMessage{
|
m.applyServer(protocol.ServerMessage{
|
||||||
Type: protocol.TypeConfigSnapshot,
|
Type: protocol.TypeConfigSnapshot,
|
||||||
ConfigFields: []protocol.ConfigFieldDto{{Key: "project.enabled", Type: "BOOL", Value: "true"}},
|
ConfigFields: []protocol.ConfigFieldDto{{Key: "project.enabled", Type: "BOOL", Value: "true"}},
|
||||||
})
|
})
|
||||||
if len(m.configStaged) != 0 {
|
if len(m.configStaged) != 0 {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
|
import "github.com/correx/tui-go/internal/protocol"
|
||||||
|
|
||||||
// PreviewFrame renders a single static frame for a named UI state at the given
|
// PreviewFrame renders a single static frame for a named UI state at the given
|
||||||
// terminal size. Used by cmd/preview to screenshot the look without a live
|
// terminal size. Used by cmd/preview to screenshot the look without a live
|
||||||
// server. Not part of the runtime path.
|
// server. Not part of the runtime path.
|
||||||
@@ -138,10 +140,57 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
m.selectedID = "04a546aa"
|
m.selectedID = "04a546aa"
|
||||||
m.overlay = OverlayPalette
|
m.overlay = OverlayPalette
|
||||||
m.paletteFilter = "to"
|
m.paletteFilter = "to"
|
||||||
|
|
||||||
|
case "stats":
|
||||||
|
m.connected = true
|
||||||
|
m.currentModel = "llama-cpp:default"
|
||||||
|
m.sessions = sampleSessions()
|
||||||
|
m.selectedID = "04a546aa"
|
||||||
|
m.sessionEntered = true
|
||||||
|
m.overlay = OverlayStats
|
||||||
|
m.statsFor = "04a546aa"
|
||||||
|
m.stats = sampleStats()
|
||||||
}
|
}
|
||||||
return m.View()
|
return m.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sampleStats() *protocol.StatsDto {
|
||||||
|
return &protocol.StatsDto{
|
||||||
|
SessionID: "04a546aa",
|
||||||
|
EventCount: 28,
|
||||||
|
SessionDurationMs: 204_000,
|
||||||
|
InferenceCount: 5,
|
||||||
|
InferenceMs: 38_400,
|
||||||
|
PromptTokens: 4120,
|
||||||
|
CompletionTokens: 1860,
|
||||||
|
TokensPerSecond: 48.4,
|
||||||
|
PerProvider: []protocol.ProviderStatsDto{
|
||||||
|
{Provider: "llama-cpp:qwen2.5-coder-14b", CompletedCount: 4, TotalLatencyMs: 33_200, PromptTokens: 3800, CompletionTokens: 1700, TokensPerSecond: 51.2},
|
||||||
|
{Provider: "llama-cpp:default", CompletedCount: 1, TotalLatencyMs: 5200, PromptTokens: 320, CompletionTokens: 160, TokensPerSecond: 30.8},
|
||||||
|
},
|
||||||
|
ToolCount: 6,
|
||||||
|
ToolMs: 4300,
|
||||||
|
PerTool: []protocol.ToolStatsDto{
|
||||||
|
{ToolName: "file_write", CompletedCount: 2, TotalDurationMs: 2600},
|
||||||
|
{ToolName: "read_file", CompletedCount: 3, TotalDurationMs: 1200},
|
||||||
|
{ToolName: "shell_exec", CompletedCount: 1, TotalDurationMs: 500},
|
||||||
|
},
|
||||||
|
ApprovalsRequested: 2,
|
||||||
|
ApprovalsResolved: 2,
|
||||||
|
ApprovalsPending: 0,
|
||||||
|
ApprovalWaitMs: 45_000,
|
||||||
|
AvgApprovalWaitMs: 22_500,
|
||||||
|
PerTier: []protocol.TierApprovalStatsDto{
|
||||||
|
{Tier: "T2", RequestedCount: 1, ResolvedCount: 1, TotalWaitMs: 15_000, AvgWaitMs: 15_000},
|
||||||
|
{Tier: "T3", RequestedCount: 1, ResolvedCount: 1, TotalWaitMs: 30_000, AvgWaitMs: 30_000},
|
||||||
|
},
|
||||||
|
Failures: protocol.FailureMetricsDto{ToolFailures: 1},
|
||||||
|
InferencePct: 18.8,
|
||||||
|
ToolPct: 2.1,
|
||||||
|
ApprovalWaitPct: 22.1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sampleWorkflows() []Workflow {
|
func sampleWorkflows() []Workflow {
|
||||||
return []Workflow{
|
return []Workflow{
|
||||||
{ID: "healthcheck", Description: "ping endpoints and report status"},
|
{ID: "healthcheck", Description: "ping endpoints and report status"},
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const (
|
|||||||
OverlayModels
|
OverlayModels
|
||||||
OverlayArtifacts
|
OverlayArtifacts
|
||||||
OverlayConfig
|
OverlayConfig
|
||||||
|
OverlayStats
|
||||||
)
|
)
|
||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// RouterEntry is one line in a session's conversation transcript.
|
||||||
@@ -143,15 +144,15 @@ type Model struct {
|
|||||||
reconnecting bool
|
reconnecting bool
|
||||||
|
|
||||||
// sessions
|
// sessions
|
||||||
sessions []Session
|
sessions []Session
|
||||||
selectedID string
|
selectedID string
|
||||||
filter string
|
filter string
|
||||||
workflows []Workflow
|
workflows []Workflow
|
||||||
wfIndex int // -1 = not in workflow picker
|
wfIndex int // -1 = not in workflow picker
|
||||||
wfVisible bool
|
wfVisible bool
|
||||||
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
|
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
|
||||||
wfPendingName string
|
wfPendingName string
|
||||||
bgUpdates int
|
bgUpdates int
|
||||||
|
|
||||||
// input
|
// input
|
||||||
editMode EditMode
|
editMode EditMode
|
||||||
@@ -209,6 +210,11 @@ type Model struct {
|
|||||||
configRestart []string // keys from the last save that need a restart
|
configRestart []string // keys from the last save that need a restart
|
||||||
configLoading bool
|
configLoading bool
|
||||||
|
|
||||||
|
// session stats (OverlayStats) — populated by the session.stats reply
|
||||||
|
stats *protocol.StatsDto
|
||||||
|
statsFor string // sessionId the current stats belong to
|
||||||
|
statsLoading bool
|
||||||
|
|
||||||
// command palette
|
// command palette
|
||||||
paletteFilter string
|
paletteFilter string
|
||||||
paletteIndex int
|
paletteIndex int
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.artifactsModal())
|
return m.center(m.artifactsModal())
|
||||||
case OverlayConfig:
|
case OverlayConfig:
|
||||||
return m.center(m.configModal())
|
return m.center(m.configModal())
|
||||||
|
case OverlayStats:
|
||||||
|
return m.center(m.statsModal())
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
@@ -545,6 +548,116 @@ func (m Model) artifactsModal() string {
|
|||||||
return t.Overlay.Width(w).Render(b.String())
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statsBreakdownRows caps how many per-provider / per-tool / per-tier rows the pane
|
||||||
|
// shows; the server already sorts each list by descending cost, so the cap keeps the
|
||||||
|
// heaviest contributors and the modal bounded.
|
||||||
|
const statsBreakdownRows = 6
|
||||||
|
|
||||||
|
// humanDurMs renders a millisecond span as a compact "1h 2m 3s" string.
|
||||||
|
func humanDurMs(ms int64) string {
|
||||||
|
if ms <= 0 {
|
||||||
|
return "0s"
|
||||||
|
}
|
||||||
|
total := ms / 1000
|
||||||
|
h := total / 3600
|
||||||
|
mn := (total / 60) % 60
|
||||||
|
s := total % 60
|
||||||
|
out := ""
|
||||||
|
if h > 0 {
|
||||||
|
out += itoa(int(h)) + "h "
|
||||||
|
}
|
||||||
|
if h > 0 || mn > 0 {
|
||||||
|
out += itoa(int(mn)) + "m "
|
||||||
|
}
|
||||||
|
return out + itoa(int(s)) + "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) statsModal() string {
|
||||||
|
t := m.theme
|
||||||
|
w := m.modalWidth()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(m.titleLine("session stats"))
|
||||||
|
if m.selectedID != "" {
|
||||||
|
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
|
||||||
|
}
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
|
||||||
|
msg := "loading…"
|
||||||
|
if !m.statsLoading && m.stats == nil {
|
||||||
|
msg = "no stats for this session"
|
||||||
|
}
|
||||||
|
b.WriteString(mbg(t, " "+msg, t.P.Faint) + "\n")
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
s := m.stats
|
||||||
|
section := func(label string) string {
|
||||||
|
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
|
||||||
|
}
|
||||||
|
line := func(s string) string { return mbg(t, s, t.P.Fg) }
|
||||||
|
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
|
||||||
|
|
||||||
|
// header row: duration + event count
|
||||||
|
b.WriteString(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
|
||||||
|
faint(" events ") + line(itoa(int(s.EventCount))) + "\n\n")
|
||||||
|
|
||||||
|
// inference
|
||||||
|
b.WriteString(section("Inference") + "\n")
|
||||||
|
b.WriteString(line(fmt.Sprintf(" %d calls · %d prompt + %d completion = %d tok · %.1f tok/s",
|
||||||
|
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.PromptTokens+s.CompletionTokens, s.TokensPerSecond)) + "\n")
|
||||||
|
for i, p := range s.PerProvider {
|
||||||
|
if i >= statsBreakdownRows {
|
||||||
|
b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerProvider)-statsBreakdownRows)) + "\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(faint(fmt.Sprintf(" %-20s %3d calls %6d tok %7d ms %.1f tok/s",
|
||||||
|
padRaw(p.Provider, 20), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TotalLatencyMs, p.TokensPerSecond)) + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// tools
|
||||||
|
b.WriteString(section("Tools") + "\n")
|
||||||
|
b.WriteString(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)) + "\n")
|
||||||
|
for i, tl := range s.PerTool {
|
||||||
|
if i >= statsBreakdownRows {
|
||||||
|
b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerTool)-statsBreakdownRows)) + "\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(faint(fmt.Sprintf(" %-24s %3d %7d ms", padRaw(tl.ToolName, 24), tl.CompletedCount, tl.TotalDurationMs)) + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// approvals
|
||||||
|
b.WriteString(section("Approvals") + "\n")
|
||||||
|
b.WriteString(line(fmt.Sprintf(" %d req · %d resolved · %d pending · avg wait %d ms",
|
||||||
|
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)) + "\n")
|
||||||
|
for _, tr := range s.PerTier {
|
||||||
|
b.WriteString(faint(fmt.Sprintf(" %-4s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)) + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// failures
|
||||||
|
f := s.Failures
|
||||||
|
b.WriteString(section("Failures") + "\n")
|
||||||
|
b.WriteString(line(fmt.Sprintf(" inference %d · timeouts %d · tool %d · rejected %d · stage %d · workflow %d",
|
||||||
|
f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)) + "\n\n")
|
||||||
|
|
||||||
|
// time accounting
|
||||||
|
other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
|
||||||
|
if other < 0 {
|
||||||
|
other = 0
|
||||||
|
}
|
||||||
|
b.WriteString(section("Time") + " " +
|
||||||
|
line(fmt.Sprintf("inference %.1f%% · tools %.1f%% · approval-wait %.1f%% · other %.1f%%",
|
||||||
|
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)) + "\n")
|
||||||
|
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
// shortID trims a long artifact id for the list column while staying identifiable.
|
// shortID trims a long artifact id for the list column while staying identifiable.
|
||||||
func shortID(id string) string {
|
func shortID(id string) string {
|
||||||
if len(id) <= 14 {
|
if len(id) <= 14 {
|
||||||
|
|||||||
@@ -304,6 +304,10 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
if m.artifactsIndex >= len(m.artifacts) {
|
if m.artifactsIndex >= len(m.artifacts) {
|
||||||
m.artifactsIndex = 0
|
m.artifactsIndex = 0
|
||||||
}
|
}
|
||||||
|
case protocol.TypeSessionStats:
|
||||||
|
m.stats = msg.Stats
|
||||||
|
m.statsFor = msg.SessionID
|
||||||
|
m.statsLoading = false
|
||||||
case protocol.TypeConfigSnapshot:
|
case protocol.TypeConfigSnapshot:
|
||||||
m.configFields = msg.ConfigFields
|
m.configFields = msg.ConfigFields
|
||||||
m.configRestart = msg.ConfigRestartRequired
|
m.configRestart = msg.ConfigRestartRequired
|
||||||
@@ -332,7 +336,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
|||||||
protocol.TypeProtocolError, protocol.TypeProviderStatus,
|
protocol.TypeProtocolError, protocol.TypeProviderStatus,
|
||||||
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
||||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||||||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot:
|
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats:
|
||||||
return ""
|
return ""
|
||||||
default:
|
default:
|
||||||
return msg.SessionID
|
return msg.SessionID
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.overlay = OverlayToolPalette
|
m.overlay = OverlayToolPalette
|
||||||
case "v":
|
case "v":
|
||||||
m.openArtifacts()
|
m.openArtifacts()
|
||||||
|
case "S":
|
||||||
|
m.openStats()
|
||||||
case "g":
|
case "g":
|
||||||
m.openConfig()
|
m.openConfig()
|
||||||
case "m":
|
case "m":
|
||||||
@@ -429,6 +431,10 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case runeIs(k, "m"):
|
case runeIs(k, "m"):
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
}
|
}
|
||||||
|
case OverlayStats:
|
||||||
|
if runeIs(k, "S") {
|
||||||
|
m.overlay = OverlayNone
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -450,6 +456,21 @@ func (m *Model) openArtifacts() {
|
|||||||
m.client.Send(protocol.ListArtifacts(m.selectedID))
|
m.client.Send(protocol.ListArtifacts(m.selectedID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openStats opens the session-stats pane for the selected session and requests its
|
||||||
|
// metrics from the server. No-op when no session is selected.
|
||||||
|
func (m *Model) openStats() {
|
||||||
|
if m.selectedID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.overlay = OverlayStats
|
||||||
|
// Reuse a cached report only if it's for this session; otherwise show a loading state.
|
||||||
|
if m.statsFor != m.selectedID {
|
||||||
|
m.stats = nil
|
||||||
|
m.statsLoading = true
|
||||||
|
}
|
||||||
|
m.client.Send(protocol.GetSessionStats(m.selectedID))
|
||||||
|
}
|
||||||
|
|
||||||
// openModelsOverlay opens the model picker, pre-selecting the resident model.
|
// openModelsOverlay opens the model picker, pre-selecting the resident model.
|
||||||
func (m *Model) openModelsOverlay() {
|
func (m *Model) openModelsOverlay() {
|
||||||
m.overlay = OverlayModels
|
m.overlay = OverlayModels
|
||||||
@@ -502,6 +523,7 @@ func paletteCommands() []paletteCmd {
|
|||||||
{"models", "swap model", "pick / pin the local model"},
|
{"models", "swap model", "pick / pin the local model"},
|
||||||
{"events", "event inspector", "browse the event stream"},
|
{"events", "event inspector", "browse the event stream"},
|
||||||
{"artifacts", "view artifacts", "browse this session's artifacts"},
|
{"artifacts", "view artifacts", "browse this session's artifacts"},
|
||||||
|
{"stats", "session stats", "metrics for the selected session"},
|
||||||
{"config", "edit config", "view / change correx settings"},
|
{"config", "edit config", "view / change correx settings"},
|
||||||
{"mode", "toggle mode", "switch chat / steering"},
|
{"mode", "toggle mode", "switch chat / steering"},
|
||||||
{"cancel", "cancel session", "stop the selected session"},
|
{"cancel", "cancel session", "stop the selected session"},
|
||||||
@@ -539,6 +561,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
|||||||
m.overlayEventIdx = 0
|
m.overlayEventIdx = 0
|
||||||
case "artifacts":
|
case "artifacts":
|
||||||
m.openArtifacts()
|
m.openArtifacts()
|
||||||
|
case "stats":
|
||||||
|
m.openStats()
|
||||||
case "config":
|
case "config":
|
||||||
m.openConfig()
|
m.openConfig()
|
||||||
case "mode":
|
case "mode":
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func (m Model) renderFooter() string {
|
|||||||
case m.displayState() == StateIdle:
|
case m.displayState() == StateIdle:
|
||||||
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")}
|
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")}
|
||||||
default: // StateInSession
|
default: // StateInSession
|
||||||
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
||||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||||||
hints = append(hints, hint("a", "approval (pending)"))
|
hints = append(hints, hint("a", "approval (pending)"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,39 @@ func TestDecodeGoldenServerFrames(t *testing.T) {
|
|||||||
wantType: TypeSnapshotComplete,
|
wantType: TypeSnapshotComplete,
|
||||||
eventBear: false,
|
eventBear: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "session.stats carries nested derived metrics",
|
||||||
|
json: `{"type":"session.stats","sessionId":"s1","stats":{"sessionId":"s1","eventCount":12,` +
|
||||||
|
`"sessionDurationMs":60000,"inferenceCount":3,"inferenceMs":6000,"promptTokens":310,` +
|
||||||
|
`"completionTokens":170,"tokensPerSecond":28.3,` +
|
||||||
|
`"perProvider":[{"provider":"lfm:a","completedCount":2,"totalLatencyMs":5000,` +
|
||||||
|
`"promptTokens":300,"completionTokens":150,"tokensPerSecond":30.0}],` +
|
||||||
|
`"toolCount":3,"toolMs":1500,"perTool":[{"toolName":"shell.read","completedCount":2,"totalDurationMs":1200}],` +
|
||||||
|
`"approvalsRequested":2,"approvalsResolved":1,"approvalsPending":1,"approvalWaitMs":30000,` +
|
||||||
|
`"avgApprovalWaitMs":30000,"perTier":[{"tier":"T2","requestedCount":2,"resolvedCount":1,` +
|
||||||
|
`"totalWaitMs":30000,"avgWaitMs":30000}],` +
|
||||||
|
`"failures":{"inferenceFailures":1,"inferenceTimeouts":0,"toolFailures":1,"toolRejections":0,` +
|
||||||
|
`"stageFailures":0,"workflowFailures":1},"inferencePct":10.0,"toolPct":2.5,"approvalWaitPct":50.0}}`,
|
||||||
|
wantType: TypeSessionStats,
|
||||||
|
eventBear: false,
|
||||||
|
check: func(t *testing.T, m ServerMessage) {
|
||||||
|
if m.SessionID != "s1" {
|
||||||
|
t.Fatalf("session.stats sessionId: %+v", m)
|
||||||
|
}
|
||||||
|
if m.Stats == nil {
|
||||||
|
t.Fatalf("session.stats: Stats is nil")
|
||||||
|
}
|
||||||
|
if m.Stats.InferenceCount != 3 || m.Stats.CompletionTokens != 170 {
|
||||||
|
t.Fatalf("session.stats inference totals: %+v", m.Stats)
|
||||||
|
}
|
||||||
|
if len(m.Stats.PerProvider) != 1 || m.Stats.PerProvider[0].Provider != "lfm:a" {
|
||||||
|
t.Fatalf("session.stats perProvider: %+v", m.Stats.PerProvider)
|
||||||
|
}
|
||||||
|
if m.Stats.Failures.WorkflowFailures != 1 || m.Stats.ApprovalWaitPct != 50.0 {
|
||||||
|
t.Fatalf("session.stats failures/pct: %+v", m.Stats)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const (
|
|||||||
TypeRouterNarration = "router.narration"
|
TypeRouterNarration = "router.narration"
|
||||||
TypeArtifactList = "artifact.list"
|
TypeArtifactList = "artifact.list"
|
||||||
TypeConfigSnapshot = "config.snapshot"
|
TypeConfigSnapshot = "config.snapshot"
|
||||||
|
TypeSessionStats = "session.stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerMessage is a flat decode of every server->client variant. Field names
|
// ServerMessage is a flat decode of every server->client variant. Field names
|
||||||
@@ -131,6 +132,68 @@ type ServerMessage struct {
|
|||||||
ConfigFields []ConfigFieldDto `json:"fields"`
|
ConfigFields []ConfigFieldDto `json:"fields"`
|
||||||
ConfigRestartRequired []string `json:"restartRequired"`
|
ConfigRestartRequired []string `json:"restartRequired"`
|
||||||
ConfigError *string `json:"error"`
|
ConfigError *string `json:"error"`
|
||||||
|
|
||||||
|
// session.stats — derived metrics for a session (reply to GetSessionStats)
|
||||||
|
Stats *StatsDto `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsDto mirrors the server's MetricsReport: a session's derived metrics
|
||||||
|
// (observability-spec §2/§3). Raw sums plus the derived ratios the pane renders.
|
||||||
|
type StatsDto struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
EventCount int64 `json:"eventCount"`
|
||||||
|
SessionDurationMs int64 `json:"sessionDurationMs"`
|
||||||
|
InferenceCount int64 `json:"inferenceCount"`
|
||||||
|
InferenceMs int64 `json:"inferenceMs"`
|
||||||
|
PromptTokens int64 `json:"promptTokens"`
|
||||||
|
CompletionTokens int64 `json:"completionTokens"`
|
||||||
|
TokensPerSecond float64 `json:"tokensPerSecond"`
|
||||||
|
PerProvider []ProviderStatsDto `json:"perProvider"`
|
||||||
|
ToolCount int64 `json:"toolCount"`
|
||||||
|
ToolMs int64 `json:"toolMs"`
|
||||||
|
PerTool []ToolStatsDto `json:"perTool"`
|
||||||
|
ApprovalsRequested int64 `json:"approvalsRequested"`
|
||||||
|
ApprovalsResolved int64 `json:"approvalsResolved"`
|
||||||
|
ApprovalsPending int64 `json:"approvalsPending"`
|
||||||
|
ApprovalWaitMs int64 `json:"approvalWaitMs"`
|
||||||
|
AvgApprovalWaitMs int64 `json:"avgApprovalWaitMs"`
|
||||||
|
PerTier []TierApprovalStatsDto `json:"perTier"`
|
||||||
|
Failures FailureMetricsDto `json:"failures"`
|
||||||
|
InferencePct float64 `json:"inferencePct"`
|
||||||
|
ToolPct float64 `json:"toolPct"`
|
||||||
|
ApprovalWaitPct float64 `json:"approvalWaitPct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderStatsDto struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
CompletedCount int64 `json:"completedCount"`
|
||||||
|
TotalLatencyMs int64 `json:"totalLatencyMs"`
|
||||||
|
PromptTokens int64 `json:"promptTokens"`
|
||||||
|
CompletionTokens int64 `json:"completionTokens"`
|
||||||
|
TokensPerSecond float64 `json:"tokensPerSecond"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolStatsDto struct {
|
||||||
|
ToolName string `json:"toolName"`
|
||||||
|
CompletedCount int64 `json:"completedCount"`
|
||||||
|
TotalDurationMs int64 `json:"totalDurationMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TierApprovalStatsDto struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
RequestedCount int64 `json:"requestedCount"`
|
||||||
|
ResolvedCount int64 `json:"resolvedCount"`
|
||||||
|
TotalWaitMs int64 `json:"totalWaitMs"`
|
||||||
|
AvgWaitMs int64 `json:"avgWaitMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FailureMetricsDto struct {
|
||||||
|
InferenceFailures int64 `json:"inferenceFailures"`
|
||||||
|
InferenceTimeouts int64 `json:"inferenceTimeouts"`
|
||||||
|
ToolFailures int64 `json:"toolFailures"`
|
||||||
|
ToolRejections int64 `json:"toolRejections"`
|
||||||
|
StageFailures int64 `json:"stageFailures"`
|
||||||
|
WorkflowFailures int64 `json:"workflowFailures"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigFieldDto struct {
|
type ConfigFieldDto struct {
|
||||||
@@ -275,6 +338,11 @@ func ListArtifacts(sessionID string) []byte {
|
|||||||
return encode("ListArtifacts", map[string]any{"sessionId": sessionID})
|
return encode("ListArtifacts", map[string]any{"sessionId": sessionID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSessionStats asks the server for a session's derived metrics (replied to with session.stats).
|
||||||
|
func GetSessionStats(sessionID string) []byte {
|
||||||
|
return encode("GetSessionStats", map[string]any{"sessionId": sessionID})
|
||||||
|
}
|
||||||
|
|
||||||
// GetConfig requests the current editable config (replied to with a config.snapshot).
|
// GetConfig requests the current editable config (replied to with a config.snapshot).
|
||||||
func GetConfig() []byte {
|
func GetConfig() []byte {
|
||||||
return encode("GetConfig", map[string]any{})
|
return encode("GetConfig", map[string]any{})
|
||||||
|
|||||||
Reference in New Issue
Block a user