diff --git a/README.md b/README.md index bae73ad..20fff4a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ llama.cpp, vLLM, MTPLX and oMLX all run from those same few lines — change `PROVIDER` and the same file serves the model on a different engine. → [Serving a local model](#serving-a-local-model) +

+ The spinloop serve view: Gemma4 running locally under llama.cpp, with live engine and machine metrics +

+ +> In this screenshot, Gemma4 running locally under llama.cpp, with live engine and machine metrics. + ### 2. On every machine you own Whilst `serve` holds a terminal open for as long as the model runs, `spinloop daemon` @@ -67,16 +73,16 @@ Start the whole fleet with `spinloop up` or just one with `spinloop fleet start The spinloop fleet dashboard: four nodes serving Qwen3.8-27B under llama.cpp, and a fifth cloud node not yet deployed

-In this screenshot, five nodes are configured and four are up, each serving the same -`Qwen3.8-27B` model through llama.cpp. Three of them — `dev-2`, `dev-3`, -`dev-4` — are mid-request: one slot running apiece, GPU util between 91% and -98%. `dev-1` finished about a minute ago, so its GPU util has dropped to 0% -while GPU memory stays at 89% — the weights are still loaded, and the next -request it takes starts generating without a reload. - -The keys along the bottom drive the fleet from here: `s` start, `x` stop, `r` -refresh, and `` for one node full-screen with its engine log tailed live. -→ [The fleet](#the-fleet) +> In this screenshot, five nodes are configured and four are up, each serving the same +> `Qwen3.8-27B` model through llama.cpp. Three of them — `dev-2`, `dev-3`, +> `dev-4` — are mid-request: one slot running apiece, GPU util between 91% and +> 98%. `dev-1` finished about a minute ago, so its GPU util has dropped to 0% +> while GPU memory stays at 89% — the weights are still loaded, and the next +> request it takes starts generating without a reload. +> +> The keys along the bottom drive the fleet from here: `s` start, `x` stop, `r` +> refresh, and `` for one node full-screen with its engine log tailed live. +> → [The fleet](#the-fleet) ### 3. On a cloud GPU, for as long as you need one diff --git a/cmd/spinloop/metrics_render.go b/cmd/spinloop/metrics_render.go index ea2de6d..702a0cb 100644 --- a/cmd/spinloop/metrics_render.go +++ b/cmd/spinloop/metrics_render.go @@ -9,6 +9,7 @@ package main import ( "fmt" "io" + "strings" "time" "github.com/spinloop-ai/spinloop/internal/metrics" @@ -137,6 +138,11 @@ const barLineW = 40 // full row fits the tile exactly and the clip never takes the percentage. const dashBarLineW = 25 +// gaugeW is the gauge's draw width in the one-shot formats — the width +// renderGauge has always drawn at. The serve view draws its gauge half at +// serveGaugeW instead, beside the bar half of serveBarW. +const gaugeW = 25 + // barGlyphs is the seven sub-full block elements the sparkline draws with, // lightest to heaviest: a series' value maps to the one whose fill height is // nearest. The set stops one grade short of the full block, so the tallest row @@ -191,6 +197,15 @@ func poolMax(values []float64, width int) []float64 { // gauge's 80/90 thresholds, and the trailing figure is the latest sample's // percentage — the exact value the last glyph approximates. func renderSparkline(w io.Writer, label string, samples []float64, width int) { + block, last := sparklineBlock(samples, width) + fmt.Fprintf(w, " %-9s %s %.0f%%\n", label, block, last) +} + +// sparklineBlock returns the sparkline's drawing across width, one glyph per +// sample newest on the right, the window's leading columns blank while it +// still fills, and the final glyph in the state colour — the label and the +// trailing figure excluded — along with the latest sample the figure reports. +func sparklineBlock(samples []float64, width int) (string, float64) { pooled := poolMax(samples, width) last := pooled[len(pooled)-1] colour := ansiGreen @@ -199,26 +214,30 @@ func renderSparkline(w io.Writer, label string, samples []float64, width int) { } else if last >= 80 { colour = ansiYellow } - fmt.Fprintf(w, " %-9s ", label) - for i := 0; i < width-len(pooled); i++ { - fmt.Fprint(w, " ") - } + var b strings.Builder + b.WriteString(strings.Repeat(" ", width-len(pooled))) for i, v := range pooled { if i == len(pooled)-1 { - fmt.Fprintf(w, "%s%c%s", colour, barGlyph(v), ansiReset) + b.WriteString(fmt.Sprintf("%s%c%s", colour, barGlyph(v), ansiReset)) } else { - fmt.Fprintf(w, "%c", barGlyph(v)) + b.WriteRune(barGlyph(v)) } } - fmt.Fprintf(w, " %.0f%%\n", last) + return b.String(), last } // renderGauge draws one resource series as a horizontal progress gauge: the // filled portion in the state colour, the unfilled portion in light shade, // the percentage in the terminal's default colour. It draws the current // reading only — it carries no history. -func renderGauge(w io.Writer, label string, pct float64) { - const width = 25 +func renderGauge(w io.Writer, label string, pct float64, width int) { + fmt.Fprintf(w, " %-9s %s %.0f%%\n", label, gaugeBlock(pct, width), pct) +} + +// gaugeBlock returns the gauge's drawing at width — the filled portion in +// the state colour, the rest in light shade — the label and the trailing +// figure excluded. +func gaugeBlock(pct float64, width int) string { colour := ansiGreen if pct > 90 { colour = ansiRed @@ -229,17 +248,7 @@ func renderGauge(w io.Writer, label string, pct float64) { if filled > width { filled = width } - empty := width - filled - fmt.Fprintf(w, " %-9s ", label) - fmt.Fprintf(w, "%s", colour) - for i := 0; i < filled; i++ { - fmt.Fprint(w, "█") - } - fmt.Fprintf(w, "%s", ansiReset) - for i := 0; i < empty; i++ { - fmt.Fprint(w, "░") - } - fmt.Fprintf(w, " %.0f%%\n", pct) + return colour + strings.Repeat("█", filled) + ansiReset + strings.Repeat("░", width-filled) } // barSeries is one resource series the bar and gauge formats draw: the label @@ -366,8 +375,45 @@ func renderStatBars(w io.Writer, cpu *metrics.CpuStat, mem *metrics.MemoryStat, if len(s.history) > 0 { renderSparkline(w, s.label, s.history, lineW) } else { - renderGauge(w, s.label, *s.current) + renderGauge(w, s.label, *s.current, gaugeW) + } + } +} + +// serveGaugeW and serveBarW are the two halves of the serve view's combined +// line: gauge and sparkline at these widths with the figure between them, +// one line per series, sized so the line fits the default 80-column window +// label and figure included. +const ( + serveGaugeW = 20 + serveBarW = 25 +) + +// renderStatCombined draws the resource series in the serve view's format: +// each series on one line — its gauge of the current reading, the figure, +// and its bar of the retained history — so "now" and "trend" sit together +// per resource instead of being a toggle. The figure is the current reading, +// falling back to the bar's latest sample where the reading carries no +// current one; a series with no history leaves its bar half blank and a +// series with no current reading its gauge half blank, so the lines align +// and the figure never draws a half's own number twice. +func renderStatCombined(w io.Writer, cpu *metrics.CpuStat, mem *metrics.MemoryStat, gpus []metrics.GpuStat, history []metrics.HistorySample) { + for _, s := range barSeriesList(cpu, mem, gpus, history) { + gaugeHalf := strings.Repeat(" ", serveGaugeW) + barHalf := strings.Repeat(" ", serveBarW) + figure := 0.0 + if s.current != nil { + gaugeHalf = gaugeBlock(*s.current, serveGaugeW) + figure = *s.current + } + if len(s.history) > 0 { + var last float64 + barHalf, last = sparklineBlock(s.history, serveBarW) + if s.current == nil { + figure = last + } } + fmt.Fprintf(w, " %-9s %s %.0f%% %s\n", s.label, gaugeHalf, figure, barHalf) } } @@ -376,7 +422,7 @@ func renderStatBars(w io.Writer, cpu *metrics.CpuStat, mem *metrics.MemoryStat, func renderStatGauges(w io.Writer, cpu *metrics.CpuStat, mem *metrics.MemoryStat, gpus []metrics.GpuStat) { for _, s := range barSeriesList(cpu, mem, gpus, nil) { if s.current != nil { - renderGauge(w, s.label, *s.current) + renderGauge(w, s.label, *s.current, gaugeW) } } } diff --git a/cmd/spinloop/metrics_render_test.go b/cmd/spinloop/metrics_render_test.go index 00ee879..cbe384c 100644 --- a/cmd/spinloop/metrics_render_test.go +++ b/cmd/spinloop/metrics_render_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/charmbracelet/lipgloss" "github.com/spinloop-ai/spinloop/internal/metrics" "github.com/spinloop-ai/spinloop/internal/remote" ) @@ -110,14 +111,14 @@ func TestRenderSparklineColoursOnlyTheLastPoint(t *testing.T) { func TestRenderGauge(t *testing.T) { var b bytes.Buffer - renderGauge(&b, "CPU", 42) + renderGauge(&b, "CPU", 42, gaugeW) want := " CPU " + ansiGreen + strings.Repeat("█", 10) + ansiReset + strings.Repeat("░", 15) + " 42%\n" if got := b.String(); got != want { t.Errorf("gauge = %q, want %q", got, want) } // A value beyond 100 fills the gauge rather than spilling past it. b.Reset() - renderGauge(&b, "CPU", 150) + renderGauge(&b, "CPU", 150, gaugeW) want = " CPU " + ansiRed + strings.Repeat("█", 25) + ansiReset + " 150%\n" if got := b.String(); got != want { t.Errorf("out-of-range gauge = %q, want %q", got, want) @@ -307,6 +308,138 @@ func TestRenderStatGaugesIgnoresHistory(t *testing.T) { } } +// The serve view's format draws both halves together: each series as a gauge +// of its current reading with its retained history as a sparkline beneath, +// the gauge carrying the label and the sparkline a blank one, so the pair +// stacks in the label column. +func TestRenderStatCombined(t *testing.T) { + cpu := &metrics.CpuStat{Utilization: 42} + mem := &metrics.MemoryStat{Total: 1000, Used: 300} + gpus := []metrics.GpuStat{{Index: 0, Name: "H100", Utilization: 61, MemoryUsed: 80, MemoryTotal: 160}} + history := []metrics.HistorySample{ + {Time: 1, CPU: ptrPct(10), Mem: ptrPct(20), GPUs: []metrics.HistoryGPU{{Index: 0, Util: 30, Mem: ptrPct(10)}}}, + {Time: 2, CPU: ptrPct(95), Mem: ptrPct(30), GPUs: []metrics.HistoryGPU{{Index: 0, Util: 91, Mem: ptrPct(20)}}}, + } + var b bytes.Buffer + renderStatCombined(&b, cpu, mem, gpus, history) + lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") + // One line per series, in the bar series' order. + if len(lines) != 4 { + t.Fatalf("drew %d lines, want 4: %q", len(lines), b.String()) + } + for i, label := range []string{" CPU ", " RAM ", " GPU util ", " GPU mem "} { + if !strings.HasPrefix(lines[i], label) { + t.Errorf("line for %s: %q", label, lines[i]) + } + } + // The line is label, gauge half, figure, bar half — the figure between + // the two halves, and it is the current reading, not the bar's last + // sample. + cpuBar, _ := sparklineBlock([]float64{10, 95}, serveBarW) + wantCPU := " CPU " + gaugeBlock(42, serveGaugeW) + " 42% " + cpuBar + if lines[0] != wantCPU { + t.Errorf("CPU line:\ngot %q\nwant %q", lines[0], wantCPU) + } + // Both halves take the state colour: the gauge over its whole fill, the + // sparkline on its last glyph only — and the sparkline stops one grade + // short of the full block, so the 95% sample draws the top of the seven + // sub-full glyphs. + if !strings.Contains(lines[0], ansiGreen+strings.Repeat("█", 8)+ansiReset+strings.Repeat("░", 12)) { + t.Errorf("CPU gauge: %q", lines[0]) + } + if !strings.Contains(lines[0], ansiRed+"▇"+ansiReset) { + t.Errorf("CPU sparkline: %q", lines[0]) + } + // Every line is label, gauge half, figure and bar half: the halves sit + // side by side, aligned across the series. 62 is label (12), gauge half, + // " 42%"-style figure (4, two digits in this data), a space, bar half. + for i, line := range lines { + if w := lipgloss.Width(line); w != 12+serveGaugeW+4+1+serveBarW { + t.Errorf("line %d is %d columns wide, want %d: %q", i, w, 12+serveGaugeW+4+1+serveBarW, line) + } + } +} + +// A memory reading with no total reports 0, not a division by it, in either +// half of the combined format. +func TestRenderStatCombinedMemoryWithoutTotal(t *testing.T) { + var b bytes.Buffer + renderStatCombined(&b, nil, &metrics.MemoryStat{Total: 0, Used: 100}, nil, nil) + if !strings.Contains(b.String(), " 0%") || strings.Contains(b.String(), "NaN") { + t.Errorf("a memory reading with no total: %q", b.String()) + } +} + +// Without retained history each series carries its gauge alone, its bar +// half left blank. +func TestRenderStatCombinedWithoutHistory(t *testing.T) { + cpu := &metrics.CpuStat{Utilization: 42} + mem := &metrics.MemoryStat{Total: 1000, Used: 300} + gpus := []metrics.GpuStat{{Index: 0, Name: "H100", Utilization: 61, MemoryUsed: 80, MemoryTotal: 160}} + var b bytes.Buffer + renderStatCombined(&b, cpu, mem, gpus, nil) + lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") + blankBar := strings.Repeat(" ", serveBarW) + want := []string{ + " CPU " + gaugeBlock(42, serveGaugeW) + " 42% " + blankBar, + " RAM " + gaugeBlock(30, serveGaugeW) + " 30% " + blankBar, + " GPU util " + gaugeBlock(61, serveGaugeW) + " 61% " + blankBar, + " GPU mem " + gaugeBlock(50, serveGaugeW) + " 50% " + blankBar, + } + if len(lines) != len(want) { + t.Fatalf("drew %d lines, want %d: %q", len(lines), len(want), b.String()) + } + for i := range want { + if lines[i] != want[i] { + t.Errorf("line %d:\ngot %q\nwant %q", i, lines[i], want[i]) + } + } +} + +// A stopped engine carries no current figures, so the combined format draws +// the retained readings alone — the sparkline lines with their blank label +// column, no gauges at all. +// A stopped engine carries no current figures, so the combined format draws +// each series' history alone: the gauge half blank, the series' label kept, +// and the bar's latest sample as the line's figure. +func TestRenderStatCombinedStoppedEngineDrawsHistoryAlone(t *testing.T) { + history := []metrics.HistorySample{ + {Time: 1, CPU: ptrPct(10), Mem: ptrPct(20), GPUs: []metrics.HistoryGPU{{Index: 0, Util: 50, Mem: ptrPct(50)}}}, + {Time: 2, CPU: ptrPct(20), Mem: ptrPct(30), GPUs: []metrics.HistoryGPU{{Index: 0, Util: 60, Mem: ptrPct(60)}}}, + } + var b bytes.Buffer + renderStatCombined(&b, nil, nil, nil, history) + lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") + if len(lines) != 4 { + t.Fatalf("drew %d lines, want CPU, RAM, GPU util and GPU mem: %q", len(lines), b.String()) + } + blankGauge := strings.Repeat(" ", serveGaugeW) + wantPrefix := []string{ + " CPU " + blankGauge + " 20% ", + " RAM " + blankGauge + " 30% ", + " GPU util " + blankGauge + " 60% ", + " GPU mem " + blankGauge + " 60% ", + } + for i, want := range wantPrefix { + if !strings.HasPrefix(lines[i], want) { + t.Errorf("line %d:\ngot %q\nwant prefix %q", i, lines[i], want) + } + } + // The bar ends the line, its two samples as its two right-most glyphs, + // the newest in the state colour. + wantSuffix := []string{ + "▁" + ansiGreen + "▂" + ansiReset, + "▂" + ansiGreen + "▃" + ansiReset, + "▄" + ansiGreen + "▅" + ansiReset, + "▄" + ansiGreen + "▅" + ansiReset, + } + for i, want := range wantSuffix { + if !strings.HasSuffix(lines[i], want) { + t.Errorf("line %d must end with the bar's two samples:\ngot %q", i, lines[i]) + } + } +} + func TestFormatMetricsBarStoppedWithHistory(t *testing.T) { resp := &remote.StatsResponse{ Environment: "prod", State: "stopped", ModelID: "org/qwen:q4", diff --git a/cmd/spinloop/serve.go b/cmd/spinloop/serve.go index 500dd43..4ed091b 100644 --- a/cmd/spinloop/serve.go +++ b/cmd/spinloop/serve.go @@ -9,6 +9,7 @@ package main import ( "errors" "fmt" + "io" "net/url" "os" "os/exec" @@ -22,6 +23,7 @@ import ( "github.com/spinloop-ai/spinloop/internal/preset" "github.com/spinloop-ai/spinloop/internal/spinloop" "github.com/spinloop-ai/spinloop/internal/spinloopsrc" + "golang.org/x/term" ) // llamaServerBinary is the llama.cpp server executable that `serve` launches. @@ -40,6 +42,13 @@ var vllmBinary = "vllm" // tests can point it at a stub instead of a real install. var mtlxBinary = "mtplx" +// stdoutIsTerminal reports whether serve's stdout is a terminal — the check +// that decides whether the run draws the serve view. A variable so a test +// takes either path without a terminal. +var stdoutIsTerminal = func() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + // omlxBundleBinary is where the macOS app installs its CLI. oMLX ships as a // signed app rather than a PATH install, so a user who has only ever launched // it from the menu bar still has this and nothing on their PATH. @@ -208,7 +217,15 @@ func serveCmd() *cobra.Command { With a PRESET it turns the matching section into the command, reading it in that engine's flag vocabulary; otherwise it derives one from the Spinloop's own instructions. Prints the command before running it; --dry-run/-n prints -without launching the server.`, orList(servedProviders())), +without launching the server. + +On a terminal the server runs under a full-screen view: its metrics — each +resource series as a gauge of the current reading with its retained history +as a bar beneath — above its log, which follows new output as it is written. +The arrow keys scroll the log, page up and page down move it by a page, f +pauses and resumes the follow, and q or Ctrl+C leaves, stopping the server +with it. Piped and redirected runs forward the server's output as they +always have, with no view.`, orList(servedProviders())), Args: cobra.ArbitraryArgs, SilenceErrors: true, SilenceUsage: true, @@ -252,21 +269,34 @@ func runServe(args []string, dryRun, apiOn bool, apiAddr, logLevel string) error if err != nil { return err } - argv, err := buildServeArgv(engine, sel, spinloopPath) + // On a terminal the view draws on stdout, so the command and the + // narration go to stderr there — and stay on stdout wherever a pipe or + // redirect reads them. Never for a dry run, which prints and stops. + viewOn := !dryRun && stdoutIsTerminal() + narration := os.Stdout + if viewOn { + narration = os.Stderr + } + argv, err := buildServeArgv(narration, engine, sel, spinloopPath) if err != nil { return err } - if apiOn { + if apiOn || viewOn { // A supervised engine gets its metrics endpoint switched on, exactly - // as the cloud path does for a deployed one. + // as the cloud path does for a deployed one. Applied here so the + // printed command is the one that runs; the shared construction + // applies it to its own copy, where it is a no-op. argv = withMetricsArgs(argv, engine) } - fmt.Printf("%s\n\n", preset.FormatCommand(argv)) + fmt.Fprintf(narration, "%s\n\n", preset.FormatCommand(argv)) if dryRun { return nil } + if viewOn { + return runServeView(sel, spinloopPath, engine, argv, apiOn, apiAddr, logLevel) + } if apiOn { return runServeForegroundAPI(sel, spinloopPath, engine, argv, apiAddr, logLevel) } @@ -286,9 +316,9 @@ func runServe(args []string, dryRun, apiOn bool, apiAddr, logLevel string) error // buildServeArgv turns a Spinloop into the engine command, from its PRESET // section when it names one and from the Spinloop's own instructions otherwise, -// narrating which source it used. It is `spinloop serve`'s alone: the daemon -// reads no Spinloop, so nothing else builds a command this way. -func buildServeArgv(engine serveEngine, sel spinloop.Selection, spinloopPath string) ([]string, error) { +// narrating which source it used to w. It is `spinloop serve`'s alone: the +// daemon reads no Spinloop, so nothing else builds a command this way. +func buildServeArgv(w io.Writer, engine serveEngine, sel spinloop.Selection, spinloopPath string) ([]string, error) { // Anything the Spinloop states overrides the preset's own values. params, err := engine.params(sel) if err != nil { @@ -315,7 +345,7 @@ func buildServeArgv(engine serveEngine, sel spinloop.Selection, spinloopPath str return nil, fmt.Errorf("%s: %w", presetPath, err) } argv := pre.CommandIn(engine.dialect, engine.binary(), subcommandFor(engine, sel), sec, params) - fmt.Printf("Using preset %s (model %s)\n\n", presetPath, sec.Name) + fmt.Fprintf(w, "Using preset %s (model %s)\n\n", presetPath, sec.Name) return argv, nil } @@ -324,11 +354,11 @@ func buildServeArgv(engine serveEngine, sel spinloop.Selection, spinloopPath str } argv := assembleEngineArgv(engine, subcommandFor(engine, sel), params, nil) if sel.Model != "" { - fmt.Printf("Serving %s from %s\n\n", sel.Model, spinloopPath) + fmt.Fprintf(w, "Serving %s from %s\n\n", sel.Model, spinloopPath) } else { // An engine that needs no model to start (oMLX serves a whole // directory) has nothing to name but itself. - fmt.Printf("Starting %s from %s\n\n", sel.Provider, spinloopPath) + fmt.Fprintf(w, "Starting %s from %s\n\n", sel.Provider, spinloopPath) } return argv, nil } diff --git a/cmd/spinloop/serve_argv_test.go b/cmd/spinloop/serve_argv_test.go index 227ee81..e1c57cd 100644 --- a/cmd/spinloop/serve_argv_test.go +++ b/cmd/spinloop/serve_argv_test.go @@ -1,6 +1,7 @@ package main import ( + "io" "path/filepath" "reflect" "strings" @@ -185,7 +186,7 @@ func TestBuildServeArgvPresetlessPerEngine(t *testing.T) { } spinloopPath := filepath.Join(t.TempDir(), spinloop.DefaultFile) captureStdout(t, func() { - argv, err := buildServeArgv(eng, tc.sel, spinloopPath) + argv, err := buildServeArgv(io.Discard, eng, tc.sel, spinloopPath) if err != nil { t.Fatal(err) } @@ -217,7 +218,7 @@ func TestBuildServeArgvPresetBranch(t *testing.T) { var argv []string captureStdout(t, func() { var err error - argv, err = buildServeArgv(eng, sel, filepath.Join(dir, spinloop.DefaultFile)) + argv, err = buildServeArgv(io.Discard, eng, sel, filepath.Join(dir, spinloop.DefaultFile)) if err != nil { t.Fatal(err) } @@ -239,7 +240,7 @@ func TestBuildServeArgvPresetBranch(t *testing.T) { t.Fatal(err) } sel := spinloop.Selection{Provider: "llamacpp", Model: "/a.gguf", Alias: "wrong", Preset: "preset.ini"} - _, err = buildServeArgv(eng, sel, filepath.Join(dir, spinloop.DefaultFile)) + _, err = buildServeArgv(io.Discard, eng, sel, filepath.Join(dir, spinloop.DefaultFile)) if err == nil { t.Fatal("want an error for an alias that matches none of several preset sections, got nil") } diff --git a/cmd/spinloop/serve_daemon.go b/cmd/spinloop/serve_daemon.go index 5112900..b1aaa69 100644 --- a/cmd/spinloop/serve_daemon.go +++ b/cmd/spinloop/serve_daemon.go @@ -201,13 +201,30 @@ func runDaemonCommand(args []string, apiAddr, apiToken, apiTokenFile, logLevel s return nil } -// runServeForegroundAPI is `spinloop serve --api`: the engine runs in the -// foreground with stdio forwarded as ever, with the control API alongside it. Start over the API always fails — the engine is -// foreground-managed and already running — and stop terminates it, after -// which serve exits exactly as it does when the engine exits on its own. -func runServeForegroundAPI(sel spinloop.Selection, spinloopPath string, engine serveEngine, argv []string, apiAddr string, logLevel string) error { +// supervisedForeground is one foreground engine serve supervises — the view +// run and the --api run alike: the daemon and supervisor it runs under, the +// stop that ends it gracefully, and the wait that reports its exit as the +// run's result. +type supervisedForeground struct { + d *daemon.Daemon + stop func() // stop the engine gracefully, escalating as a stop does elsewhere; blocks until it is down + wait func() error // block until the engine exits and the run is wound down; nil where a stop on request or a clean exit counts as success +} + +// startSupervisedForeground is the supervised-foreground construction a +// `spinloop serve` run shares whatever its foreground behaviour is: the +// supervisor, the daemon with its served name, scrape target, engine +// endpoint and refusing start stub, the metrics switch for an engine that +// has one, the sampler for the run's life, the signal relay, the graceful +// stop, and the exit-status rule. It is parameterised only by the +// supervisor's log target — the state-dir engine log for a view run, empty +// for a run that forwards stdio — and whether the control API listener comes +// up, which is --api's own say. Nothing else differs, so the paths cannot +// drift: served, scraped and probed the same way, stopped the same way, +// exited the same way. +func startSupervisedForeground(sel spinloop.Selection, spinloopPath string, engine serveEngine, argv []string, apiOn bool, apiAddr, logLevel, logPath string) (*supervisedForeground, error) { if err := applySpinloopEnv(sel, spinloopPath); err != nil { - return err + return nil, err } token := os.Getenv(daemon.TokenEnvVar) // Resolved after the Spinloop's environment is in place, so unlike the @@ -216,19 +233,24 @@ func runServeForegroundAPI(sel spinloop.Selection, spinloopPath string, engine s // before anything listens. logger, err := commandLogger(logLevel) if err != nil { - return err + return nil, err } - ln, err := daemon.Listen(apiAddr, token) - if err != nil { - return err + var ln net.Listener + if apiOn { + ln, err = daemon.Listen(apiAddr, token) + if err != nil { + return nil, err + } } stateDir, err := daemon.StateDir() if err != nil { - ln.Close() - return err + if ln != nil { + ln.Close() + } + return nil, err } - sup := daemon.NewSupervisor("") // empty LogPath: stdio stays forwarded + sup := daemon.NewSupervisor(logPath) sup.Logger = logger d := &daemon.Daemon{ Sup: sup, @@ -246,6 +268,10 @@ func runServeForegroundAPI(sel spinloop.Selection, spinloopPath string, engine s model = sel.Alias } d.SetServed(sel.Provider, model) + // A supervised engine gets its metrics endpoint switched on, exactly as + // the cloud path does for a deployed one; an engine with no metrics + // dialect gets no added switch and the host's series. + argv = withMetricsArgs(argv, engine) d.SetScrape(scrapeTargetFor(engine, sel.BaseURL, argv)) d.SetEngineEndpoint(engineEndpointFor(engine, sel.BaseURL, argv)) @@ -254,42 +280,66 @@ func runServeForegroundAPI(sel spinloop.Selection, spinloopPath string, engine s // window exists where a signal kills serve and orphans it. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + stop := func() { sup.Stop() } go func() { <-sigCh - sup.Stop() + stop() }() if err := sup.Start(argv); err != nil { - ln.Close() + if ln != nil { + ln.Close() + } signal.Stop(sigCh) if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%s not found — %s", argv[0], engine.installHint) + return nil, fmt.Errorf("%s not found — %s", argv[0], engine.installHint) } - return err + return nil, err } // The engine started through the supervisor directly rather than through // StartEngine, so the activity record is stamped here instead. d.MarkActive() - srv := &http.Server{Handler: d.Handler(token)} - logger.Info("control API listening", slog.String("api", ln.Addr().String())) - go srv.Serve(ln) + var srv *http.Server + if apiOn { + srv = &http.Server{Handler: d.Handler(token)} + logger.Info("control API listening", slog.String("api", ln.Addr().String())) + go srv.Serve(ln) + } sampleCtx, stopSampling := context.WithCancel(context.Background()) go d.SampleActivity(sampleCtx) - waitErr := sup.Wait() - stopSampling() - signal.Stop(sigCh) - // Graceful shutdown: a stop requested over the API lands here while its - // response is still in flight — let it finish rather than cutting the - // connection. - shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - srv.Shutdown(shutdownCtx) - cancel() - if state, _, _ := sup.Status(); state == daemon.StateStopped { - // Stopped on request (signal or API) or exited cleanly: not an error. - return nil + wait := func() error { + waitErr := sup.Wait() + stopSampling() + signal.Stop(sigCh) + // Graceful shutdown: a stop requested over the API lands here while + // its response is still in flight — let it finish rather than cutting + // the connection. + if srv != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + srv.Shutdown(shutdownCtx) + cancel() + } + if state, _, _ := sup.Status(); state == daemon.StateStopped { + // Stopped on request (signal or API) or exited cleanly: not an error. + return nil + } + return waitErr + } + return &supervisedForeground{d: d, stop: stop, wait: wait}, nil +} + +// runServeForegroundAPI is `spinloop serve --api` off the terminal: the +// engine runs in the foreground with stdio forwarded as ever, with the +// control API alongside it. Start over the API always fails — the engine is +// foreground-managed and already running — and stop terminates it, after +// which serve exits exactly as it does when the engine exits on its own. +func runServeForegroundAPI(sel spinloop.Selection, spinloopPath string, engine serveEngine, argv []string, apiAddr string, logLevel string) error { + run, err := startSupervisedForeground(sel, spinloopPath, engine, argv, true, apiAddr, logLevel, "") + if err != nil { + return err } - return waitErr + return run.wait() } // daemonAPIAddr resolves the daemon's listen address from its two flags. diff --git a/cmd/spinloop/serve_daemon_test.go b/cmd/spinloop/serve_daemon_test.go index c597ccd..53ba1ff 100644 --- a/cmd/spinloop/serve_daemon_test.go +++ b/cmd/spinloop/serve_daemon_test.go @@ -5,6 +5,7 @@ package main import ( "encoding/json" "fmt" + "io" "net" "net/http" "os" @@ -15,6 +16,7 @@ import ( "testing" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/spinloop-ai/spinloop/internal/daemon" "github.com/spinloop-ai/spinloop/internal/remote" ) @@ -477,6 +479,380 @@ func TestCmdDaemon_StartCarriesDeployConfig(t *testing.T) { } } +// stubEngineView points llamaServerBinary at a script that records its argv, +// writes the named line to its own stdout as it starts, then either runs the +// remainder of body or sleeps until signalled, and restores the binary after. +func stubEngineView(t *testing.T, argsFile, startupLine, body string) { + t.Helper() + script := filepath.Join(t.TempDir(), "llama-server") + src := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + argsFile + "\necho " + startupLine + "\n" + if body != "" { + src += body + "\n" + } else { + src += "trap 'exit 0' TERM\nwhile true; do sleep 0.05; done\n" + } + if err := os.WriteFile(script, []byte(src), 0o755); err != nil { + t.Fatal(err) + } + orig := llamaServerBinary + llamaServerBinary = script + t.Cleanup(func() { llamaServerBinary = orig }) +} + +// fakeTerminal flips the gate serve checks for a terminal, so the view path +// runs in the suite without one. +func fakeTerminal(t *testing.T) { + t.Helper() + orig := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + t.Cleanup(func() { stdoutIsTerminal = orig }) +} + +// fakeViewProgram runs the view on a program with injected input and output, +// so the suite executes it without a terminal: input disabled, the key rules +// being the model tests' — and output discarded, the frame being the render +// tests'. +func fakeViewProgram(t *testing.T) { + t.Helper() + orig := newServeProgram + newServeProgram = func(m tea.Model, opts ...tea.ProgramOption) *tea.Program { + return tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(io.Discard)) + } + t.Cleanup(func() { newServeProgram = orig }) +} + +// TestCmdServe_ViewRunCapturesEngineOutput covers the view run end to end: +// the engine's output lands in the state-dir engine log rather than on serve's +// stdio, the engine starts with its metrics endpoint on, and the command +// serve prints goes to stderr — stdout is the view's screen. +func TestCmdServe_ViewRunCapturesEngineOutput(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv(daemon.TokenEnvVar, "tok") + argsFile := filepath.Join(t.TempDir(), "args") + stubEngineView(t, argsFile, "'engine up'", "sleep 0.3\necho 'engine down'\nexit 0") + fakeTerminal(t) + fakeViewProgram(t) + dir := t.TempDir() + spinloopPath := filepath.Join(dir, "Spinloop") + mustWrite(t, spinloopPath, "PROVIDER llamacpp\nMODEL org/model:Q4_K_M\n") + + done := make(chan error, 1) + var stderr, stdout string + captureStdout(t, func() { + stderr = captureStderr(t, func() { + go func() { done <- cmdServe([]string{spinloopPath}) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("serve exited with %v after a clean engine exit", err) + } + case <-time.After(20 * time.Second): + t.Fatal("serve did not exit after the engine did") + } + }) + }) + // The printed command goes to stderr, and stays off the view's screen. + if !strings.Contains(stderr, "llama-server") { + t.Errorf("the printed command must go to stderr under the view:\n%s", stderr) + } + if strings.Contains(stdout, "llama-server") { + t.Errorf("the printed command must stay off the view's screen:\n%s", stdout) + } + stateDir, err := daemon.StateDir() + if err != nil { + t.Fatal(err) + } + log := waitForFile(t, filepath.Join(stateDir, "engine.log")) + for _, want := range []string{"engine up", "engine down"} { + if !strings.Contains(log, want) { + t.Errorf("the engine log is missing %q:\n%s", want, log) + } + } + args := waitForFile(t, argsFile) + if !strings.Contains(args, "--metrics") { + t.Errorf("the view run must switch the metrics endpoint on:\n%s", args) + } +} + +// TestCmdServe_ViewRunExitsWithEngineStatus covers the exit-status rule: an +// engine that fails on its own takes serve down with it, whatever closed the +// view. +func TestCmdServe_ViewRunExitsWithEngineStatus(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv(daemon.TokenEnvVar, "tok") + argsFile := filepath.Join(t.TempDir(), "args") + stubEngineView(t, argsFile, "'engine up'", "exit 3") + fakeTerminal(t) + fakeViewProgram(t) + dir := t.TempDir() + spinloopPath := filepath.Join(dir, "Spinloop") + mustWrite(t, spinloopPath, "PROVIDER llamacpp\nMODEL org/model:Q4_K_M\n") + + done := make(chan error, 1) + captureStdout(t, func() { + captureStderr(t, func() { + go func() { done <- cmdServe([]string{spinloopPath}) }() + select { + case err := <-done: + if err == nil { + t.Fatal("serve must report the engine's failure status") + } + case <-time.After(20 * time.Second): + t.Fatal("serve did not exit after the engine did") + } + }) + }) +} + +// TestCmdServe_ViewQuitStopsTheEngine covers the quit key end to end: q goes +// through the view's own stop — the engine goes down through the supervisor, +// and serve exits cleanly after it is actually stopped. The Spinloop names no +// MODEL, so the supervised run serves under its alias. +func TestCmdServe_ViewQuitStopsTheEngine(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv(daemon.TokenEnvVar, "tok") + argsFile := filepath.Join(t.TempDir(), "args") + stubEngineView(t, argsFile, "'engine up'", + "trap 'echo \"engine stopped on TERM\"; exit 0' TERM\nwhile true; do sleep 0.05; done\n") + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "preset.ini"), samplePreset) + spinloopPath := filepath.Join(dir, "Spinloop") + mustWrite(t, spinloopPath, "PROVIDER llamacpp\nALIAS qwen\nPRESET preset.ini\n") + + pr, pw := io.Pipe() + origProgram := newServeProgram + newServeProgram = func(m tea.Model, _ ...tea.ProgramOption) *tea.Program { + return tea.NewProgram(m, tea.WithInput(pr), tea.WithOutput(io.Discard)) + } + t.Cleanup(func() { + newServeProgram = origProgram + pw.Close() + }) + fakeTerminal(t) + + go func() { + time.Sleep(2 * time.Second) + fmt.Fprint(pw, "q") + }() + + done := make(chan error, 1) + captureStdout(t, func() { + captureStderr(t, func() { + go func() { done <- cmdServe([]string{spinloopPath}) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("serve must exit cleanly after q, got %v", err) + } + case <-time.After(20 * time.Second): + t.Fatal("serve did not exit after q") + } + }) + }) + stateDir, err := daemon.StateDir() + if err != nil { + t.Fatal(err) + } + log := waitForFile(t, filepath.Join(stateDir, "engine.log")) + if !strings.Contains(log, "engine stopped on TERM") { + t.Errorf("the engine must be stopped through the supervisor on q:\n%s", log) + } +} + +// TestCmdServe_DryRunOnTerminalNeverOpensTheView pins the gate's dry-run +// half: on a terminal, --dry-run must still print the command and start +// nothing — no view, no engine, no engine log. +func TestCmdServe_DryRunOnTerminalNeverOpensTheView(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + argsFile := filepath.Join(t.TempDir(), "args") + stubEngineView(t, argsFile, "'engine up'", "exit 0") + fakeTerminal(t) + viewOpened := false + origProgram := newServeProgram + newServeProgram = func(m tea.Model, _ ...tea.ProgramOption) *tea.Program { + viewOpened = true + return tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(io.Discard)) + } + t.Cleanup(func() { newServeProgram = origProgram }) + + dir := t.TempDir() + spinloopPath := filepath.Join(dir, "Spinloop") + mustWrite(t, spinloopPath, "PROVIDER llamacpp\nMODEL org/model:Q4_K_M\n") + + out := captureStdout(t, func() { + captureStderr(t, func() { + if err := cmdServe([]string{"--dry-run", spinloopPath}); err != nil { + t.Fatalf("dry run: %v", err) + } + }) + }) + if viewOpened { + t.Error("--dry-run must not open the view, even on a terminal") + } + if !strings.Contains(out, "llama-server") { + t.Errorf("the dry run must still print the command on stdout:\n%s", out) + } + if _, err := os.Stat(argsFile); !os.IsNotExist(err) { + t.Error("the dry run must not start the engine") + } + stateDir, err := daemon.StateDir() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(stateDir, "engine.log")); !os.IsNotExist(err) { + t.Error("the dry run must not open an engine log") + } +} + +// TestCmdServe_ViewRunMissingBinaryFailsAroundNoView covers the view's +// startup order: the engine starts before the view opens, so a missing +// binary fails with its install hint — and no view, no engine log. +func TestCmdServe_ViewRunMissingBinaryFailsAroundNoView(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv(daemon.TokenEnvVar, "tok") + viewOpened := false + origProgram := newServeProgram + newServeProgram = func(m tea.Model, _ ...tea.ProgramOption) *tea.Program { + viewOpened = true + return tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(io.Discard)) + } + t.Cleanup(func() { newServeProgram = origProgram }) + origBin := llamaServerBinary + llamaServerBinary = filepath.Join(t.TempDir(), "no-such-llama-server") + t.Cleanup(func() { llamaServerBinary = origBin }) + fakeTerminal(t) + + dir := t.TempDir() + spinloopPath := filepath.Join(dir, "Spinloop") + mustWrite(t, spinloopPath, "PROVIDER llamacpp\nMODEL org/model:Q4_K_M\n") + + err := cmdServe([]string{spinloopPath}) + if err == nil { + t.Fatal("a missing engine binary must fail the serve") + } + if !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "install llama.cpp") { + t.Errorf("the failure must carry the install hint: %v", err) + } + if viewOpened { + t.Error("a missing binary must fail around no view") + } + stateDir, serr := daemon.StateDir() + if serr != nil { + t.Fatal(serr) + } + log, _ := os.ReadFile(filepath.Join(stateDir, "engine.log")) + if len(log) != 0 { + t.Errorf("a missing binary must not write an engine log: %q", log) + } +} + +// TestCmdServe_SupervisedRunRefusesAnInsecureListen covers the control API's +// listen guard on the supervised path: a non-loopback address without a token +// is refused before the engine starts — nothing listens, nothing runs. +func TestCmdServe_SupervisedRunRefusesAnInsecureListen(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv(daemon.TokenEnvVar, "") + argsFile := filepath.Join(t.TempDir(), "args") + stubEngineView(t, argsFile, "'engine up'", "exit 0") + + dir := t.TempDir() + spinloopPath := filepath.Join(dir, "Spinloop") + mustWrite(t, spinloopPath, "PROVIDER llamacpp\nMODEL org/model:Q4_K_M\n") + + err := cmdServe([]string{"-a", "--api-addr", "0.0.0.0:0", spinloopPath}) + if err == nil { + t.Fatal("a non-loopback listen without a token must be refused") + } + if !strings.Contains(err.Error(), "refusing to serve the control API on non-loopback") { + t.Errorf("the refusal must name what it refuses and the fix: %v", err) + } + if _, statErr := os.Stat(argsFile); !os.IsNotExist(statErr) { + t.Error("the engine must not start when the listen is refused") + } +} + +// TestCmdServe_ViewRunAPILogServesTheCapture covers serve --api under the +// view: the control API listens beside the run, and its log endpoint serves +// the captured engine log rather than reporting the log missing. +func TestCmdServe_ViewRunAPILogServesTheCapture(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv(daemon.TokenEnvVar, "tok") + argsFile := filepath.Join(t.TempDir(), "args") + stubEngineView(t, argsFile, "'boot line'", "") + fakeTerminal(t) + fakeViewProgram(t) + dir := t.TempDir() + spinloopPath := filepath.Join(dir, "Spinloop") + mustWrite(t, spinloopPath, "PROVIDER llamacpp\nMODEL org/model:Q4_K_M\n") + + waitAddr := apiAddrFromStderr(t) + done := make(chan error, 1) + go func() { + var serveErr error + captureStdout(t, func() { + serveErr = cmdServe([]string{"-a", "--api-addr", "127.0.0.1:0", spinloopPath}) + }) + done <- serveErr + }() + base := "http://" + waitAddr() + + // The engine's output reaches the log the API serves, not its stdio. + deadline := time.Now().Add(10 * time.Second) + var code int + var body map[string]any + for { + code, body = apiDo(t, "GET", base+"/v1/logs", "tok", "") + content, _ := body["content"].(string) + if code == 200 && strings.Contains(content, "boot line") { + break + } + if time.Now().After(deadline) { + t.Fatalf("the log endpoint never carried the engine's output: %d %v", code, body) + } + time.Sleep(50 * time.Millisecond) + } + + // Stop over the API; the stop on request exits serve as success. + if code, body := apiDo(t, "POST", base+"/v1/stop", "tok", ""); code != 200 || body["state"] != "stopped" { + t.Fatalf("stop = %d %v", code, body) + } + select { + case err := <-done: + if err != nil { + t.Fatalf("serve exited with %v after an API stop", err) + } + case <-time.After(20 * time.Second): + t.Fatal("serve did not exit after the foreground engine stopped") + } +} + +// TestCmdServe_ForegroundAPIStopExitsServe covers the non-terminal --api run +// against the same shared construction: the engine runs in the foreground +// with stdio forwarded, the API listens beside it, and a stop over the API +// exits serve as success. +// TestCmdServe_OffTerminalCapturesNothing covers the other side of the gate: +// off the terminal the run forwards the engine's output to serve's stdio and +// writes no engine log file — the capture is the view's own. +func TestCmdServe_OffTerminalCapturesNothing(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + stateDir, err := daemon.StateDir() + if err != nil { + t.Fatal(err) + } + stubLlamaServer(t, filepath.Join(t.TempDir(), "args")) + spinloopPath := writePresetSpinloop(t, "PROVIDER llamacpp\nPRESET ./preset.ini\nALIAS qwen\n") + captureStdout(t, func() { + captureStderr(t, func() { + if err := cmdServe([]string{spinloopPath}); err != nil { + t.Error(err) + } + }) + }) + if _, err := os.Stat(filepath.Join(stateDir, "engine.log")); !os.IsNotExist(err) { + t.Errorf("a run off the terminal must write no engine log (stat = %v)", err) + } +} + func TestCmdServe_ForegroundAPIStopExitsServe(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv(daemon.TokenEnvVar, "tok") diff --git a/cmd/spinloop/serve_view.go b/cmd/spinloop/serve_view.go new file mode 100644 index 0000000..bdb2d74 --- /dev/null +++ b/cmd/spinloop/serve_view.go @@ -0,0 +1,510 @@ +// The serve view: what `spinloop serve` draws on a terminal — the engine's +// metrics above, its tailed log below, and a footer naming the keys the view +// answers to. The frame is the fleet dashboard's node detail screen three- +// section layout: the metrics section is the same lines the detail view and +// the metrics formats print for the same reading — state with uptime, what +// is served, the last-active line, the resource series, the token counters — +// with each resource series drawn in both formats at once, the gauge of the +// current reading and, beneath it, the bar of the retained history. The log +// section follows the engine's own log the way the detail view follows its +// node's. Everything it reads is in-process: the daemon the serve process +// runs itself, not the network. Bubble Tea drives the model through +// Init/Update/View, but every rule here is plain Go over plain data, so the +// suite runs the whole logic without a terminal. + +package main + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spinloop-ai/spinloop/internal/daemon" + "github.com/spinloop-ai/spinloop/internal/metrics" + "github.com/spinloop-ai/spinloop/internal/spinloop" +) + +// newServeProgram builds the program the view runs on: the model, the +// alternate screen, and nothing else. A variable so a test runs the view with +// injected input and output, where opening the terminal's own input would +// fail. +var newServeProgram = func(m tea.Model, opts ...tea.ProgramOption) *tea.Program { + return tea.NewProgram(m, append([]tea.ProgramOption{tea.WithAltScreen()}, opts...)...) +} + +// serveViewLogBudget is how many lines of the tailed log the view retains in +// memory. A few thousand is well past any pane the frame can show, so the +// scroll can reach back as far as the frame can ever go; the file on disk +// keeps the whole record, so the budget only bounds the copy. +const serveViewLogBudget = 2000 + +// serveViewLogTailBytes is the byte budget of the first read of a freshly +// opened view — the backlog — the same way the detail view budgets its tail. +const serveViewLogTailBytes = 200 * bytesPerLineGuess + +// serveViewMetricsTimeout caps one in-process metrics read. The read takes +// host stats and the sampler's own counters — no network — so a deadline this +// loose only catches a wedged host probe, which is exactly what the age +// beside a stale reading is for. +const serveViewMetricsTimeout = 5 * time.Second + +// serveViewKeys is the view's footer key help: exactly the keys the view +// answers to — the scroll, the follow, and the quit — and nothing the view +// cannot do. Starting, stopping, keeping and aborting are not among them: +// the engine is serve's own, and leaving is what stops it. +const serveViewKeys = "↑↓ scroll pgup/pgdown page f follow q quit" + +// serveView is the program's state: the metrics reading and when it was +// taken, the tailed log and where its window sits in it, and the window size +// the frame draws to. +type serveView struct { + spinloopPath string + + stats metrics.Stats // the last successful read; zero until the first + statsAt time.Time // when it was taken; a failed read leaves the last one here, and its age is drawn from it + + logOffset int64 // where the next poll resumes from; TailLog for the first, which is the backlog + logContent string // the tailed lines, most recent last, trimmed to the budget + logBudget int // how many lines the tail retains + logBehind int // how many lines the window's bottom sits behind the newest; 0 is on the newest line + logFollow bool // whether the poll picks up new output; f pauses and resumes it + logBusy bool // a poll is in flight + logGen int // bumped to supersede a poll in flight + logNote string // why the pane has no content — empty once it does, or once a read fails with content in place + + metricsBusy bool + metricsGen int + + width, height int + + // send feeds a message back into the program from outside the Update + // loop — the engine's own exit, which its wait goroutine reports. It is + // the tea.Program's Send, safe from any goroutine and a no-op once the + // program has left; nil where the model is driven directly in a test. + send func(tea.Msg) + // stop ends the run: the graceful stop, escalating as a stop does + // elsewhere. It blocks until the engine is down, so it runs in a + // Bubble Tea command, never inside Update. + stop func() + // readMetrics and readLog are the view's two data paths, injected so a + // test drives the model with its own answers. The real ones call the + // in-process daemon and the daemon's own log read — the same functions + // the control API's handlers call, so the view and the API cannot + // report different facts about the same engine. + readMetrics func() (metrics.Stats, error) + readLog func(offset int64, limit int) (daemon.LogsResponse, error) +} + +// serveViewMetricsTickMsg fires on the dashboard's own local cadence. +type serveViewMetricsTickMsg time.Time + +func serveViewMetricsTickCmd() tea.Cmd { + return tea.Tick(dashboardRefreshInterval, func(time.Time) tea.Msg { return serveViewMetricsTickMsg{} }) +} + +// serveViewLogTickMsg fires on the detail view's log cadence, the chain the +// log poll runs on, separately from the metrics tick. +type serveViewLogTickMsg time.Time + +func serveViewLogTickCmd() tea.Cmd { + return tea.Tick(detailLogInterval, func(time.Time) tea.Msg { return serveViewLogTickMsg{} }) +} + +// serveViewMetricsMsg is one completed in-process reading. gen ties it to +// the model that started it, so a reply a superseded read sent is discarded +// rather than painted over a newer one. +type serveViewMetricsMsg struct { + gen int + stats metrics.Stats + err error +} + +// serveViewLogMsg is one completed poll of the engine's log. +type serveViewLogMsg struct { + gen int + reply daemon.LogsResponse + err error +} + +// serveViewStoppedMsg says the operator's stop has landed and the engine is +// down: the view quits on it. +type serveViewStoppedMsg struct{} + +// serveViewEngineExitedMsg says the engine exited on its own, whatever the +// cause: the view quits on it, and the run reports the engine's exit status +// after the program has left. +type serveViewEngineExitedMsg struct{} + +// Init starts both chains at once — the first metrics read and the first +// log poll, which is the backlog, because the offset opens on the tail. +func (m *serveView) Init() tea.Cmd { + return tea.Batch( + serveViewMetricsTickCmd(), m.startMetricsRead(), + serveViewLogTickCmd(), m.startLogPoll(), + ) +} + +func (m *serveView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.clampBehind() + case serveViewMetricsTickMsg: + // One tick, one read, the tick rescheduling itself for the life of + // the view. A read starts only when none is in flight, so a slow + // read stretches to its own time rather than overlapping the next. + return m, tea.Batch(serveViewMetricsTickCmd(), m.startMetricsRead()) + case serveViewMetricsMsg: + m.metricsBusy = false + if msg.gen != m.metricsGen { + return m, nil + } + if msg.err == nil { + // A successful read replaces the one on screen. A failed one + // leaves the last in place: its statsAt stands, and the age + // beside the state line says what it says. + m.stats = msg.stats + m.statsAt = dashNow() + } + case serveViewLogTickMsg: + // The chain keeps ticking whether or not follow is on — pausing + // only skips the round it would have started, so unpausing with f + // needs nothing more than flipping the flag back, and the metrics + // section's own refresh never felt it either. + var cmd tea.Cmd + if m.logFollow { + cmd = m.startLogPoll() + } + return m, tea.Batch(serveViewLogTickCmd(), cmd) + case serveViewLogMsg: + m.logBusy = false + if msg.gen != m.logGen { + return m, nil + } + m.applyLogReply(msg) + case serveViewStoppedMsg, serveViewEngineExitedMsg: + return m, tea.Quit + case tea.KeyMsg: + return m, m.updateKey(msg) + } + return m, nil +} + +// updateKey answers the keys the view reads: the scroll of the log pane, f +// to pause and resume the follow, and q or Ctrl+C to leave — which stops the +// engine. Nothing else drives anything in the view, so nothing else is read. +func (m *serveView) updateKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "q", "ctrl+c": + return m.stopCmd() + case "f": + m.logFollow = !m.logFollow + case "up": + m.logBehind++ + m.clampBehind() + case "down": + m.logBehind-- + m.clampBehind() + case "pgup": + m.logBehind += m.logPaneRows() + m.clampBehind() + case "pgdown": + m.logBehind -= m.logPaneRows() + m.clampBehind() + } + return nil +} + +// stopCmd ends the run. The stop blocks until the engine is down — the +// graceful escalation a stop does elsewhere — and the view quits on the round +// trip, so the screen closes when the engine is actually stopped, not before. +func (m *serveView) stopCmd() tea.Cmd { + return func() tea.Msg { + if m.stop != nil { + m.stop() + } + return serveViewStoppedMsg{} + } +} + +// startMetricsRead takes one in-process reading through the injected read. +// It never starts a second read over one still in flight. +func (m *serveView) startMetricsRead() tea.Cmd { + if m.metricsBusy || m.readMetrics == nil { + return nil + } + m.metricsBusy = true + gen := m.metricsGen + read := m.readMetrics + return func() tea.Msg { + stats, err := read() + return serveViewMetricsMsg{gen: gen, stats: stats, err: err} + } +} + +// startLogPoll reads the engine's log through the daemon's own log read: +// the tail on the first read, the stored offset on every one after, the +// daemon's byte bounds doing the rest. It never starts a second poll over +// one still in flight. +func (m *serveView) startLogPoll() tea.Cmd { + if m.logBusy || m.readLog == nil { + return nil + } + m.logBusy = true + gen := m.logGen + offset, limit := m.logOffset, 0 + if offset == daemon.TailLog { + limit = serveViewLogTailBytes + } + read := m.readLog + return func() tea.Msg { + reply, err := read(offset, limit) + return serveViewLogMsg{gen: gen, reply: reply, err: err} + } +} + +// applyLogReply folds one completed poll into the view. New output is +// appended and trimmed to the line budget, so a long session never grows an +// unbounded buffer — the file on disk keeps the whole record — and a failed +// poll leaves the prior content in place rather than blanking it. The +// window's stick-and-stay rule is arithmetic on logBehind: on the newest +// line new output keeps it there, scrolled away it stays put as the new +// lines land behind it. +func (m *serveView) applyLogReply(msg serveViewLogMsg) { + if msg.err != nil { + if m.logContent == "" { + m.logNote = "log read failed: " + msg.err.Error() + } + return + } + m.logNote = "" + if msg.reply.StaleOffset { + // The file shrank — truncated or replaced. The content in memory is + // from the file it replaced, so it goes, and the cursor resumes from + // the reply's end, the rule the log read defines. + m.logContent = "" + m.logBehind = 0 + m.logOffset = msg.reply.NextOffset + return + } + m.logOffset = msg.reply.NextOffset + if msg.reply.Content != "" { + appended := lineCount(msg.reply.Content) + m.logContent = lastLines(m.logContent+msg.reply.Content, m.logBudget) + if m.logBehind > 0 { + m.logBehind += appended + } + m.clampBehind() + } +} + +// lineCount is how many lines s carries, the way the pane counts them: the +// trailing newline does not start a line. +func lineCount(s string) int { + if s == "" { + return 0 + } + return len(strings.Split(strings.TrimRight(s, "\n"), "\n")) +} + +// clampBehind keeps the window inside the retained content: it never shows +// past the oldest retained line or ahead of the newest, so a press at either +// end leaves it where it is. +func (m *serveView) clampBehind() { + if m.logBehind < 0 { + m.logBehind = 0 + return + } + max := lineCount(m.logContent) - m.logPaneRows() + if max < 0 { + max = 0 + } + if m.logBehind > max { + m.logBehind = max + } +} + +// sectionHeights splits the frame's rows between the metrics section (its +// natural length) and the log section (whatever remains after the header, +// the footer, and the three dividers around the three sections), floored at +// one row each — the detail view's own split. +func (m *serveView) sectionHeights() (metricsH, logH int) { + metricsH = len(m.metricsLines()) + if metricsH < 1 { + metricsH = 1 + } + const fixedRows = 5 // header + divider + divider + divider + footer + logH = m.effHeight() - fixedRows - metricsH + if logH < 1 { + logH = 1 + } + return metricsH, logH +} + +// logPaneRows is how many rows of log the frame shows at once — the page +// size pgup and pgdown move by. +func (m *serveView) logPaneRows() int { + _, logH := m.sectionHeights() + return logH +} + +// effWidth and effHeight report the window, defaulting where none was ever +// reported: Bubble Tea measures the real screen at startup, so a zero can +// only mean not-measured, not a real size. +func (m serveView) effWidth() int { + if m.width < 1 { + return 80 + } + return m.width +} + +func (m serveView) effHeight() int { + if m.height < 1 { + return 24 + } + return m.height +} + +// metricsLines is the metrics section: the same lines the dashboard's detail +// screen and the metrics formats print for the reading — state with uptime, +// what is served, the last-active line, the resource series in both formats +// at once, the token counters — so the view cannot word a number the other +// surfaces would not. +func (m serveView) metricsLines() []string { + if m.statsAt.IsZero() { + return []string{"waiting for the first reading…"} + } + var b strings.Builder + fmt.Fprintf(&b, "%s%s\n", dashStateLine(m.stats.State, m.stats), m.staleSuffix()) + if line := dashTileServingLine(m.stats); line != "" { + fmt.Fprintln(&b, line) + } + renderActiveIndented(&b, m.stats.LastActiveAt, m.stats.IdleSeconds, m.stats.RetainUntil, dashNow()) + // A running reading carries its current figures; a settled one carries + // its retained history, which survives a stop — the detail view's own + // rule for when the series exist at all. + resources := m.stats.State == "running" || len(m.stats.History) > 0 + if resources { + renderStatCombined(&b, m.stats.CPU, m.stats.Memory, m.stats.GPUs, m.stats.History) + renderTokenLines(&b, m.stats.Tokens) + } + lines := strings.Split(b.String(), "\n") + return lines[:len(lines)-1] +} + +// staleSuffix is the "· 3m ago" the state line carries once the reading has +// aged past the dashboard's own stale rule, or "" while it is current: a +// reading the view could not renew is not drawn identically to one just +// read. +func (m serveView) staleSuffix() string { + if m.statsAt.IsZero() { + return "" + } + staleAfter := dashStaleThreshold * dashboardRefreshInterval + age := dashNow().Sub(m.statsAt) + if age < staleAfter { + return "" + } + return " · " + formatDuration(int(age.Seconds())) + " ago" +} + +// View draws the frame: the title bar — the screen, the Spinloop's path, and +// the log's following or paused state to the right — the metrics section, a +// divider, the tailed log filling the remaining rows, a divider, and the +// footer naming the view's keys. +func (m serveView) View() string { + w := m.effWidth() + metricsH, avail := m.sectionHeights() + + logState := "following" + if !m.logFollow { + logState = "paused" + } + header := dashTitleBar("serve", m.spinloopPath+" log: "+logState, w) + divider := strings.Repeat("─", w) + + metrics := m.metricsLines() + for len(metrics) < metricsH { + metrics = append(metrics, "") + } + + logLines := detailLogLines(m.logContent, m.logNote) + if m.logBehind > 0 { + // Scrolled away: the window ends that many lines behind the newest. + end := len(logLines) - m.logBehind + if end < 0 { + end = 0 + } + logLines = logLines[:end] + } + if len(logLines) > avail { + logLines = logLines[len(logLines)-avail:] + } + + parts := make([]string, 0, len(metrics)+len(logLines)+4) + parts = append(parts, header, divider) + for _, line := range metrics { + parts = append(parts, dashClip(line, w)) + } + parts = append(parts, divider) + for _, line := range logLines { + parts = append(parts, dashClip(line, w)) + } + parts = append(parts, divider) + parts = append(parts, dashClip(dashKeyHints(serveViewKeys), w)) + return strings.Join(parts, "\n") +} + +// runServeView is `spinloop serve` on a terminal: the engine runs through +// the shared supervised-foreground construction with its output captured to +// the daemon's state-dir engine log, and the view draws on the alternate +// screen for the life of the run. The engine starts before the view opens, +// so a missing binary fails with its install hint around no view, and the +// run reports the engine's exit status whatever closed the view — the +// operator's q or the engine's own exit. +func runServeView(sel spinloop.Selection, spinloopPath string, engine serveEngine, argv []string, apiOn bool, apiAddr, logLevel string) error { + stateDir, err := daemon.StateDir() + if err != nil { + return err + } + logPath := filepath.Join(stateDir, "engine.log") + run, err := startSupervisedForeground(sel, spinloopPath, engine, argv, apiOn, apiAddr, logLevel, logPath) + if err != nil { + return err + } + m := &serveView{ + spinloopPath: spinloopPath, + logOffset: daemon.TailLog, + logBudget: serveViewLogBudget, + logFollow: true, + stop: run.stop, + readMetrics: func() (metrics.Stats, error) { + ctx, cancel := context.WithTimeout(context.Background(), serveViewMetricsTimeout) + defer cancel() + return run.d.Metrics(ctx), nil + }, + readLog: func(offset int64, limit int) (daemon.LogsResponse, error) { + return daemon.ReadLog(logPath, offset, limit) + }, + } + prog := newServeProgram(m) + // The engine's own exit closes the view, whatever its cause. The send is + // wired before the wait goroutine starts, so it cannot fire into a nil — + // and it is a no-op once the program has left, so the two exit paths + // cannot double-quit. + m.send = prog.Send + go func() { + run.wait() + m.send(serveViewEngineExitedMsg{}) + }() + if _, err := prog.Run(); err != nil { + return err + } + // The view has left and the engine is down: the run reports the + // engine's exit status as a foreground serve always has, a stop on + // request counting as success. + return run.wait() +} diff --git a/cmd/spinloop/serve_view_test.go b/cmd/spinloop/serve_view_test.go new file mode 100644 index 0000000..38c33f8 --- /dev/null +++ b/cmd/spinloop/serve_view_test.go @@ -0,0 +1,508 @@ +package main + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spinloop-ai/spinloop/internal/daemon" + "github.com/spinloop-ai/spinloop/internal/metrics" +) + +func fixDashNow(t *testing.T, at time.Time) { + t.Helper() + dashNow = func() time.Time { return at } + t.Cleanup(func() { dashNow = time.Now }) +} + +// newTestServeView is the model a test drives directly: the window already +// measured at 100x30, the two data paths injected, and nothing else set. +func newTestServeView(readMetrics func() (metrics.Stats, error), readLog func(int64, int) (daemon.LogsResponse, error)) *serveView { + m := &serveView{ + spinloopPath: "Spinloop", + logOffset: daemon.TailLog, + logBudget: serveViewLogBudget, + logFollow: true, + readMetrics: readMetrics, + readLog: readLog, + } + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + return m +} + +func requireQuit(t *testing.T, cmd tea.Cmd) { + t.Helper() + if cmd == nil { + t.Fatal("the message must return tea.Quit, got no command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("the message must return tea.Quit, got a %T", cmd()) + } +} + +// The model opens knowing nothing: the window unmeasured, the first read a +// tail, the follow on, and the frame carrying its two waiting notes until the +// first replies land. +func TestServeViewInitialState(t *testing.T) { + fixDashNow(t, time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)) + m := newTestServeView(nil, nil) + if cmd := m.Init(); cmd == nil { + t.Fatal("Init must schedule the first reading and the first log poll") + } + if m.logOffset != daemon.TailLog || !m.logFollow || m.logBudget != serveViewLogBudget { + t.Errorf("the view must open on the tail, following, with its line budget: %+v", m) + } + v := m.View() + if !strings.Contains(v, "waiting for the first reading…") { + t.Errorf("View before a reading must carry the waiting note:\n%s", v) + } + if !strings.Contains(v, "waiting for the log…") { + t.Errorf("View before a log read must carry the waiting note:\n%s", v) + } +} + +func TestServeViewMetricsReadReplacesPrior(t *testing.T) { + at := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) + fixDashNow(t, at) + first := metrics.Stats{State: "running", UptimeSeconds: 10} + second := metrics.Stats{State: "running", UptimeSeconds: 40} + next := first + m := newTestServeView(func() (metrics.Stats, error) { return next, nil }, nil) + + m.Update(m.startMetricsRead()()) + if m.stats.UptimeSeconds != 10 || !m.statsAt.Equal(at) { + t.Fatalf("the first reading was not stored: %+v at %v", m.stats, m.statsAt) + } + next = second + m.Update(m.startMetricsRead()()) + if m.stats.UptimeSeconds != 40 { + t.Errorf("the second reading must replace the first: %+v", m.stats) + } +} + +func TestServeViewMetricsFailedReadKeepsLast(t *testing.T) { + at := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) + fixDashNow(t, at) + var fail bool + m := newTestServeView(func() (metrics.Stats, error) { + if fail { + return metrics.Stats{}, errors.New("the host probe wedged") + } + return metrics.Stats{State: "running", UptimeSeconds: 10}, nil + }, nil) + m.Update(m.startMetricsRead()()) + if m.stats.UptimeSeconds != 10 || !m.statsAt.Equal(at) { + t.Fatal("the first reading was not stored") + } + + fail = true + m.Update(m.startMetricsRead()()) + if m.stats.UptimeSeconds != 10 || !m.statsAt.Equal(at) { + t.Error("a failed read must leave the last reading in place, its age included") + } +} + +func TestServeViewMetricsSupersededReadDiscarded(t *testing.T) { + fixDashNow(t, time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)) + m := newTestServeView(func() (metrics.Stats, error) { + return metrics.Stats{State: "running"}, nil + }, nil) + cmd := m.startMetricsRead() + m.metricsGen++ + m.Update(cmd()) + if !m.statsAt.IsZero() { + t.Error("a superseded read's reply must be discarded") + } +} + +// A reading the view could not renew is not drawn identically to one just +// read: past the board's own stale rule the state line carries the age. +func TestServeViewStaleReadingCarriesItsAge(t *testing.T) { + at := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) + fixDashNow(t, at) + m := newTestServeView(func() (metrics.Stats, error) { + return metrics.Stats{State: "running"}, nil + }, nil) + m.Update(m.startMetricsRead()()) + if m.staleSuffix() != "" { + t.Errorf("a fresh reading carries no age: %q", m.staleSuffix()) + } + + fixDashNow(t, at.Add(dashStaleThreshold*dashboardRefreshInterval+time.Second)) + if suffix := m.staleSuffix(); !strings.HasSuffix(suffix, "ago") { + t.Errorf("a reading past the stale threshold must carry its age, got %q", suffix) + } +} + +// The first poll reads the tail — the backlog — budgeted the way the detail +// view budgets its tail; every poll after resumes from the stored offset with +// no byte limit, the daemon's own bounds doing the rest. +func TestServeViewLogFirstReadIsBacklog(t *testing.T) { + var gotOffset int64 + var gotLimit int + m := newTestServeView(nil, func(offset int64, limit int) (daemon.LogsResponse, error) { + gotOffset, gotLimit = offset, limit + return daemon.LogsResponse{Content: "one\ntwo\n", NextOffset: 8}, nil + }) + m.Update(m.startLogPoll()()) + if gotOffset != daemon.TailLog { + t.Errorf("the first poll must read the tail, got offset %d", gotOffset) + } + if gotLimit != serveViewLogTailBytes { + t.Errorf("the first poll must budget the backlog, got limit %d", gotLimit) + } + if m.logContent != "one\ntwo\n" || m.logOffset != 8 { + t.Errorf("the backlog was not stored: %q at %d", m.logContent, m.logOffset) + } +} + +func TestServeViewLogPollsAppendWithoutDuplicates(t *testing.T) { + var calls [][2]int64 + m := newTestServeView(nil, func(offset int64, limit int) (daemon.LogsResponse, error) { + calls = append(calls, [2]int64{offset, int64(limit)}) + if offset == daemon.TailLog { + return daemon.LogsResponse{Content: "one\ntwo\n", NextOffset: 8}, nil + } + return daemon.LogsResponse{Content: "three\n", NextOffset: 14}, nil + }) + m.Update(m.startLogPoll()()) + m.Update(m.startLogPoll()()) + if m.logContent != "one\ntwo\nthree\n" { + t.Errorf("the append must keep the backlog and add the new lines once: %q", m.logContent) + } + if len(calls) != 2 || calls[1][0] != 8 || calls[1][1] != 0 { + t.Errorf("the second poll must resume from the stored offset with no byte limit: %v", calls) + } +} + +// A file that shrank — truncated or replaced — drops the content from the +// file it replaced and resumes from the reply's end. +func TestServeViewLogStaleOffsetResumes(t *testing.T) { + m := newTestServeView(nil, func(offset int64, limit int) (daemon.LogsResponse, error) { + if offset == daemon.TailLog { + return daemon.LogsResponse{Content: "one\ntwo\n", NextOffset: 8}, nil + } + return daemon.LogsResponse{NextOffset: 3, Size: 3, StaleOffset: true}, nil + }) + m.Update(m.startLogPoll()()) + m.Update(m.startLogPoll()()) + if m.logContent != "" { + t.Errorf("a stale offset must drop the content from the replaced file: %q", m.logContent) + } + if m.logOffset != 3 { + t.Errorf("the cursor must resume from the reply's end, got %d", m.logOffset) + } + if m.logBehind != 0 { + t.Errorf("the window must be back on the newest line, behind %d", m.logBehind) + } +} + +func TestServeViewLogSupersededPollDiscarded(t *testing.T) { + m := newTestServeView(nil, func(offset int64, limit int) (daemon.LogsResponse, error) { + return daemon.LogsResponse{Content: "one\n", NextOffset: 5}, nil + }) + cmd := m.startLogPoll() + m.logGen++ + m.Update(cmd()) + if m.logContent != "" || m.logOffset != daemon.TailLog { + t.Errorf("a superseded poll's reply must be discarded: %q at %d", m.logContent, m.logOffset) + } +} + +// A long session never grows an unbounded buffer: the tail is trimmed to the +// line budget, the file on disk keeping the whole record. +func TestServeViewLogTrimmedToBudget(t *testing.T) { + m := newTestServeView(nil, func(offset int64, limit int) (daemon.LogsResponse, error) { + var b strings.Builder + for i := 0; i < 10; i++ { + fmt.Fprintf(&b, "line %d\n", i) + } + return daemon.LogsResponse{Content: b.String(), NextOffset: 100}, nil + }) + m.logBudget = 5 + m.Update(m.startLogPoll()()) + if got := lineCount(m.logContent); got != 5 { + t.Errorf("the tail must be trimmed to the budget of 5 lines, got %d", got) + } + if !strings.HasPrefix(m.logContent, "line 5\n") { + t.Errorf("the trim must keep the newest lines: %q", m.logContent) + } +} + +// A failed poll leaves the prior content in place; with no content at all it +// says why the pane is empty rather than drawing nothing. +func TestServeViewLogReadFailure(t *testing.T) { + m := newTestServeView(nil, func(offset int64, limit int) (daemon.LogsResponse, error) { + return daemon.LogsResponse{}, errors.New("the log is gone") + }) + m.Update(m.startLogPoll()()) + if m.logNote != "log read failed: the log is gone" { + t.Errorf("a failed first read must say so, got note %q", m.logNote) + } + if v := m.View(); !strings.Contains(v, "log read failed: the log is gone") { + t.Errorf("the empty pane must carry the failure note:\n%s", v) + } + + m.logContent = "one\n" + m.Update(m.startLogPoll()()) + if m.logContent != "one\n" { + t.Errorf("a failed poll must leave the prior content in place: %q", m.logContent) + } +} + +// The scroll: one line at a time, a page at a time, clamped at both ends — +// a press at either end leaves the window where it is. +func TestServeViewKeysScroll(t *testing.T) { + m := newTestServeView(nil, func(offset int64, limit int) (daemon.LogsResponse, error) { + var b strings.Builder + for i := 0; i < 30; i++ { + fmt.Fprintf(&b, "line %02d\n", i) + } + return daemon.LogsResponse{Content: b.String(), NextOffset: 100}, nil + }) + m.Update(m.startLogPoll()()) + // 30 lines against a 24-row pane: the window can sit at most 6 lines + // behind the newest. + cases := []struct { + key tea.Msg + want int + }{ + {tea.KeyMsg{Type: tea.KeyUp}, 1}, + {tea.KeyMsg{Type: tea.KeyDown}, 0}, + {tea.KeyMsg{Type: tea.KeyDown}, 0}, // on the newest line, down stays put + {tea.KeyMsg{Type: tea.KeyPgUp}, 6}, + {tea.KeyMsg{Type: tea.KeyUp}, 6}, // at the oldest retained line, up stays put + {tea.KeyMsg{Type: tea.KeyPgDown}, 0}, + {tea.KeyMsg{Type: tea.KeyDown}, 0}, + } + for i, tc := range cases { + m.Update(tc.key) + if m.logBehind != tc.want { + t.Errorf("key %d: behind = %d, want %d", i, m.logBehind, tc.want) + } + } +} + +// On the newest line, new output keeps the window there; scrolled away, the +// window stays put as the new lines land behind it. +func TestServeViewWindowSticksAndStays(t *testing.T) { + calls := 0 + m := newTestServeView(nil, func(offset int64, limit int) (daemon.LogsResponse, error) { + calls++ + if calls == 1 { + var b strings.Builder + for i := 0; i < 30; i++ { + fmt.Fprintf(&b, "line %02d\n", i) + } + return daemon.LogsResponse{Content: b.String(), NextOffset: 100}, nil + } + return daemon.LogsResponse{Content: "line 30\n", NextOffset: 110}, nil + }) + m.Update(m.startLogPoll()()) + m.Update(m.startLogPoll()()) + if m.logBehind != 0 { + t.Fatalf("on the newest line the window must stay there, behind %d", m.logBehind) + } + if !strings.HasSuffix(m.logContent, "line 30\n") { + t.Fatalf("the new line must land in the pane: %q", m.logContent) + } + + m.Update(tea.KeyMsg{Type: tea.KeyUp}) // scroll one line away + if m.logBehind != 1 { + t.Fatalf("the up arrow must move the window one line, behind %d", m.logBehind) + } + m.Update(m.startLogPoll()()) + if m.logBehind != 2 { + t.Errorf("scrolled away, new output must stay behind the window, behind %d", m.logBehind) + } + if !strings.HasSuffix(m.logContent, "line 30\n") { + t.Errorf("the new line must be retained while the pane stays put: %q", m.logContent) + } +} + +// f pauses and resumes the follow: paused, the log tick starts no poll while +// the metrics tick keeps its own cadence; resumed, the next tick starts a +// poll again. +func TestServeViewFollowPauseAndResume(t *testing.T) { + calls := 0 + m := newTestServeView( + func() (metrics.Stats, error) { return metrics.Stats{State: "running"}, nil }, + func(offset int64, limit int) (daemon.LogsResponse, error) { + calls++ + return daemon.LogsResponse{Content: "a\n", NextOffset: 2}, nil + }, + ) + m.Update(m.startLogPoll()()) + + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) + if m.logFollow { + t.Error("f must pause the follow") + } + m.Update(serveViewLogTickMsg{}) + if m.logBusy || calls != 1 { + t.Errorf("a paused follow must not start a poll (busy %v, %d calls)", m.logBusy, calls) + } + m.Update(serveViewMetricsTickMsg{}) + if !m.metricsBusy { + t.Error("pausing the log must not pause the metrics refresh") + } + + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) + if !m.logFollow { + t.Fatal("f must resume the follow") + } + m.Update(serveViewLogTickMsg{}) + if !m.logBusy { + t.Error("the next tick after resuming must start a poll") + } +} + +// While the follow is paused the offset holds; the next poll reads from it, so +// whatever the engine wrote in the meantime arrives on the resume. +func TestServeViewPauseLosesNothing(t *testing.T) { + m := newTestServeView(nil, func(offset int64, limit int) (daemon.LogsResponse, error) { + if offset == daemon.TailLog { + return daemon.LogsResponse{Content: "a\n", NextOffset: 2}, nil + } + return daemon.LogsResponse{Content: "b\nc\n", NextOffset: 10}, nil + }) + m.Update(m.startLogPoll()()) + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) + if m.logOffset != 2 { + t.Fatalf("the pause must hold the offset, got %d", m.logOffset) + } + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) + m.Update(m.startLogPoll()()) + if m.logContent != "a\nb\nc\n" { + t.Errorf("nothing written while paused may be lost: %q", m.logContent) + } +} + +// The keys that leave: q and Ctrl+C both stop the engine — outside Update, +// in the command — and the view quits on the round trip. +func TestServeViewQuitStopsTheEngine(t *testing.T) { + for _, key := range []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}, + tea.KeyMsg{Type: tea.KeyCtrlC}, + } { + stopped := 0 + m := newTestServeView(nil, nil) + m.stop = func() { stopped++ } + + _, cmd := m.Update(key) + if cmd == nil { + t.Fatalf("%v must return the stop command", key) + } + if stopped != 0 { + t.Fatalf("the stop must not run inside Update") + } + msg := cmd() + if stopped != 1 { + t.Errorf("the stop command must run the graceful stop") + } + if _, ok := msg.(serveViewStoppedMsg); !ok { + t.Fatalf("the stop command must report back, got a %T", msg) + } + _, quit := m.Update(msg) + requireQuit(t, quit) + } +} + +// The engine's own exit closes the view too, whatever its cause. +func TestServeViewEngineExitedQuits(t *testing.T) { + m := newTestServeView(nil, nil) + _, cmd := m.Update(serveViewEngineExitedMsg{}) + requireQuit(t, cmd) +} + +// The frame: the title bar with the path and the log's state, the metrics +// section in the view's own format — each series its gauge and its bar side +// by side on one line — the tailed log, the dividers, and the footer naming +// exactly the keys the view answers to. +func TestServeViewFrame(t *testing.T) { + fixDashNow(t, time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)) + stats := metrics.Stats{ + State: "running", + UptimeSeconds: 90, + Runner: "llama.cpp", + ModelID: "org/model", + CPU: &metrics.CpuStat{Utilization: 42}, + Memory: &metrics.MemoryStat{Total: 1000, Used: 500}, + History: []metrics.HistorySample{{Time: 1, CPU: f64ptr(10), Mem: f64ptr(40)}}, + Tokens: &metrics.TokenStats{PromptTokens: 10, GenerationTokens: 5, Requests: 2}, + } + m := newTestServeView( + func() (metrics.Stats, error) { return stats, nil }, + func(offset int64, limit int) (daemon.LogsResponse, error) { + return daemon.LogsResponse{Content: "alpha\nbeta\n", NextOffset: 12}, nil + }, + ) + m.Update(m.startMetricsRead()()) + m.Update(m.startLogPoll()()) + + v := m.View() + for _, want := range []string{ + "serve", // the screen + "Spinloop", // the path, right of the screen + "log: following", // the log's state + "running (up 1m 30s)", // state with uptime + "llama.cpp org/model", // what is served + " 42%", // the CPU gauge of the current reading + " 50%", // the RAM gauge + "prompt tokens:", "requests:", // the counters + "alpha", "beta", // the tailed log + "scroll", "follow", "quit", // the footer's keys + } { + if !strings.Contains(v, want) { + t.Errorf("the frame is missing %q:\n%s", want, v) + } + } + // The gauge of each series sits with its bar on the series' own line: + // the label once, and both drawings on it. + if strings.Count(v, "CPU") != 1 { + t.Errorf("the CPU series must draw once, label and all:\n%s", v) + } + if strings.Count(v, "RAM") != 1 { + t.Errorf("the RAM series must draw once, label and all:\n%s", v) + } + for _, line := range strings.Split(v, "\n") { + if strings.Contains(line, "CPU") && (!strings.Contains(line, "█") || !strings.Contains(line, "▁")) { + t.Errorf("the CPU line must carry its gauge and its bar side by side:\n%q", line) + } + } + // Every line fits the window. + for i, line := range strings.Split(v, "\n") { + if w := lipgloss.Width(line); w > 100 { + t.Errorf("line %d is %d columns wide, want at most 100: %q", i, w, line) + } + } +} + +// The same frame, the follow paused: the title bar says so. +func TestServeViewFramePaused(t *testing.T) { + fixDashNow(t, time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)) + m := newTestServeView(nil, nil) + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) + if v := m.View(); !strings.Contains(v, "log: paused") { + t.Errorf("the title bar must name the paused log:\n%s", v) + } +} + +// A window shorter than the frame's fixed rows: the log pane floors at one +// row, and the frame still draws — a negative pane would clip out of range. +func TestServeViewTinyWindowFloorsTheLogPane(t *testing.T) { + m := newTestServeView(nil, nil) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 3}) + if _, logH := m.sectionHeights(); logH != 1 { + t.Errorf("the log pane must floor at one row on a 3-row window, got %d", logH) + } + if m.View() == "" { + t.Error("the frame must still draw on a tiny window") + } +} + +func f64ptr(v float64) *float64 { return &v } diff --git a/docs/commands/serve.md b/docs/commands/serve.md index b6305ba..2378df7 100644 --- a/docs/commands/serve.md +++ b/docs/commands/serve.md @@ -19,6 +19,52 @@ It prints the command before running it, and never touches your agent's config — pair it with [`spinloop apply`](apply.md) to point the agent at the server. +## On a terminal, the serve view + +Run `serve` on a terminal and the engine runs under a full-screen view rather +than forwarding its output: the engine's **metrics** above, its **log** below, +and a footer naming the keys the view answers to. + +- **Metrics** — the same facts the fleet dashboard's node detail screen shows + for the same engine: state and uptime, what is served, last active, and the + resource series — CPU, RAM, and each GPU's utilisation and memory — with + every series drawn in both formats at once, each on one line: a gauge of + the current reading beside the bar of its retained history. Below them, the + token and request counters. The reading comes from the daemon the serve + process runs in-process and refreshes on the dashboard's own local cadence; + a reading the view could not renew is shown with its age. +- **Log** — the engine's own output, tailed and followed, so new lines appear + as they are written. An engine that has written nothing yet shows a waiting + note, not an empty pane. +- **Footer** — the view's keys, and nothing the view cannot do. Starting, + stopping, keeping and aborting are not among them: the engine is serve's + own, and leaving is what stops it. + +| Key | What it does | +| --- | ------------ | +| `↑` / `↓` | Scroll the log one line; a press at either end leaves the window where it is | +| `pgup` / `pgdown` | Scroll the log by a page | +| `f` | Pause and resume the log's follow — the metrics section keeps refreshing either way | +| `q` or `Ctrl+C` | Leave — stops the engine and exits serve | + +While the log's window is on the newest line it sticks to the tail: new +output appears as it is written. Scrolled away from it, the window stays put +and the new lines accrue behind it. Pausing the follow holds the window, and +resuming fetches whatever the engine wrote in the meantime — nothing is lost. + +The engine's own exit closes the view and serve exits with the engine's exit +status, exactly as a foreground serve does. + +Under the view, the engine's stdout and stderr are captured to the same +`daemon/engine.log` [spinloop's daemon](#the-control-api---api-and-spinloop-daemon) +writes, from the engine's first line — so with `--api` the control API's log +endpoint serves the engine's output rather than reporting the log missing. + +Off a terminal — piped or redirected — there is no view: the engine's output +is forwarded to serve's own stdio as before, and the printed command stays on +stdout. On a terminal the view owns stdout, so the command serve prints goes +to stderr there. `--dry-run` never opens the view. + ## The engine comes from `PROVIDER` `PROVIDER` already names the engine, so `serve` needs no keyword of its own — @@ -257,7 +303,9 @@ of you exits. Two related surfaces build on it: - `serve --api` (`-a`) exposes the control API *beside* the foreground engine — status and metrics answer, start fails (the engine is already running), and stop terminates the engine, after which serve exits as it - always has. + always has. The flag changes only whether the API listens: the foreground + behaviour — the view on a terminal, stdio forwarding off one — is the same + with and without it. - `spinloop daemon` is the long-lived agent: it supervises one engine, writes its output to `daemon/engine.log` under [spinloop's config directory](../env-vars.md#config-directory-resolution), @@ -290,7 +338,7 @@ See [HTTP Control API](../http-api.md) for details, or | `POST /v1/start` | Start the engine (optional deploy-config body, optionally carrying the engine's API key; 409 while one runs) | | `POST /v1/stop` | Stop the engine (idempotent; never ends the daemon) | | `GET /v1/metrics` | Engine token counters plus host GPU/CPU/RAM | -| `GET /v1/logs` | A slice of the engine's captured output, by offset | +| `GET /v1/logs` | A slice of the engine's captured output, by offset — where the output is captured: under the daemon, and under the serve view; a plain foreground serve forwards its engine's output to its own stdio, and the endpoint reports the log missing | | `PUT /v1/deploy-config` | Set what the *next* start serves | Requests carry `Authorization: Bearer `. The token comes from one of diff --git a/docs/img/serve_metrics.png b/docs/img/serve_metrics.png new file mode 100644 index 0000000..47db8e0 Binary files /dev/null and b/docs/img/serve_metrics.png differ diff --git a/openspec/changes/archive/2026-09-07-serve-ui/.openspec.yaml b/openspec/changes/archive/2026-09-07-serve-ui/.openspec.yaml new file mode 100644 index 0000000..1a62d62 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-serve-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-06 diff --git a/openspec/changes/archive/2026-09-07-serve-ui/design.md b/openspec/changes/archive/2026-09-07-serve-ui/design.md new file mode 100644 index 0000000..4d5f4ea --- /dev/null +++ b/openspec/changes/archive/2026-09-07-serve-ui/design.md @@ -0,0 +1,200 @@ +## Context + +`spinloop serve` runs its engine in the foreground. Today it forwards the +engine's stdio to the terminal; with `--api` it additionally builds a +`daemon.Daemon` in-process that serves the control API, runs the activity and +history sampler, and starts the engine through a supervisor whose `LogPath` +is empty — so the engine's output still goes to the terminal and the API's +log endpoint answers "missing". + +The fleet dashboard's node detail view (`dashboard_detail.go`) is the +reference screen: three stacked sections (metrics, tailed engine log, key +help), the metrics drawn from the same lines `dashNodeView` produces for a +tile, the log polled by byte offset on a one-shot rescheduled tick with a +generation guard, and a footer naming the keys. It reads its node over the +fleet's HTTP seam; serve's node is its own in-process daemon. + +`daemon.Daemon` already exposes what the view needs without HTTP: +`Status()`, `Metrics(ctx)`, and the log is a plain file read through +`daemon.ReadLog(path, offset, limit)`. The sampler retains a 10-minute, +40-sample history for the bars. The shared metrics renderers +(`metrics_render.go`) already know both formats: `renderStatBars` draws a +sparkline per series from the history, falling back to the gauge per series +where the history holds none; `renderStatGauges` draws the current reading. +`renderGauge` hard-codes its 25-column width; `renderSparkline` takes one. + +bubbletea and lipgloss are existing dependencies; `term.IsTerminal` on +stdout is the terminal gate `fleet dashboard` already uses. See proposal.md +for the motivation; the spec delta in this change is the behaviour contract. + +## Goals / Non-Goals + +**Goals:** + +- The serve view on a terminal: the detail screen's three-section frame, each + resource series drawn in both formats at once, the engine's log tailed with + arrow-key scrolling, and the keys and exit the spec delta names. +- One data path: the view reads the in-process daemon the serve process + already builds for `--api`, so the view works with or without the flag and + no second log mechanism is introduced. +- A non-terminal serve — with or without `--api` — behaves exactly as today. + +**Non-Goals:** + +- No changes to the fleet dashboard (it keeps one format at a time with the + `g` toggle) or to the one-shot `remote metrics` / `fleet metrics` formats. +- No starting, stopping, keeping or aborting from the view; the engine is + serve's own, and leaving stops it. +- No change to the control API surface: the log endpoint's contract is + unchanged; a view run merely gives it a file to serve. +- No log capture for non-terminal runs (their `serve --api` log endpoint + keeps answering "missing", as it does today). + +## Decisions + +**D1 — The terminal gate is stdout, checked before the engine starts.** +`term.IsTerminal(int(os.Stdout.Fd()))`, the dashboard's own check, decides the +view path after the command is built and printed and `--dry-run` has had its +say. A non-terminal run takes the existing exec path untouched. +*Alternative:* also require stdin to be a terminal — rejected; it adds an +edge the dashboard does not have, and bubbletea's own failure mode covers a +hostile terminal. + +**D2 — The view reads the in-process daemon, not its own HTTP API.** +Whenever the view opens — `--api` or not — the serve process runs the engine +through the shared supervised-foreground construction (D8), and the model +calls `d.Status()`, `d.Metrics(ctx)` and `daemon.ReadLog` directly on the +daemon it returns. These are the same functions the API handlers call, so the +view and the API cannot report different facts about the same engine. +*Alternative:* always listen and have the view talk to its own API through +`fleet.daemonNode` — rejected; it adds a socket, a token rule, and a loopback +dependency to a flag that exists to add precisely that, and the dashboard +seam buys serve nothing the in-process calls do not give it. + +**D3 — Engine output is captured to the daemon's state-dir engine log.** +The view run gives the supervisor +`filepath.Join(stateDir, "engine.log")` — the daemon's own file — so the +capture holds the engine's whole output from its first line, the view tails +it by byte offset, the status reports its path, and `serve --api`'s log +endpoint serves it. The file outlives the run, as the daemon's does. +*Alternative:* an in-memory ring buffer as the only log — rejected; it loses +the `/v1/logs` improvement and stands up a second log mechanism beside the +daemon's. + +**D4 — Both formats share one line per series, in the view only.** +A new function in `metrics_render.go` (the bar and gauge formats' +`barSeriesList` supplies the series and their order: CPU, RAM, each GPU's +utilisation and memory) draws one line per series — the gauge of the current +reading and the sparkline of the retained history side by side — with the +current reading as the line's figure, falling back to the bar's latest sample +where the reading carries no current one. A series with no history leaves its +bar half blank; a series with no current reading (the stopped engine) leaves +its gauge half blank, so the lines align and the two halves never draw the +same figure. The halves draw at `serveGaugeW` (20) and `serveBarW` (25) +columns, so a line fits the default 80-column window label and figure +included. `renderGauge` and `renderSparkline` sit on label-less, figure-less +block helpers, so the combined line joins the two halves without duplicating +their drawing, and the one-shot formats keep their own stacked output. +*Alternative:* the gauge stacked above its bar — rejected; two lines per +series at the 40-column width the stacked halves shared, wider than a row of +the dashboard's reference screen. + +**D5 — The log pane scrolls an in-memory tail, clamped to a line budget.** +The model keeps the tailed content (most recent last) and `behind` — how +many lines the window's bottom sits behind the newest; 0 is at the tail. +Each poll (the detail view's 3-second cadence, same one-shot rescheduled +tick and generation guard) reads from the stored byte offset, appends, and +trims to a line budget (a few thousand lines, well past any pane, bounded in +memory; the file on disk keeps the whole record). `up`/`down` move `behind` +by one, `pgup`/`pgdown` by the pane's height, clamped so the window never +shows past the oldest retained line; while `behind` is 0 new lines keep it +at the tail, and while it is not, the window stays put as new lines arrive. +The displayed slice is the last `paneRows` lines ending `behind` from the +end, so the clamp and the stick are arithmetic on two integers, testable +without a terminal. A `StaleOffset` reply (the file shrank) resets the +offset to the reply's `NextOffset`, the rule the log read defines. +*Alternative:* scroll the file by offset — rejected; `ReadLog` is +forward-only with a 256 KiB read bound, so reaching far back means paging +and mid-line bookkeeping, and the detail view already establishes the +in-memory content pattern the budget generalises. + +**D6 — One new file, the frame helpers reused.** +`cmd/spinloop/serve_view.go` holds the model and its `View()`, drawn from +the detail screen's own parts: `dashTitleBar` (screen "serve", the +Spinloop's path and the log's following/paused state to the right), the +three-section frame with dividers, the metrics section's lines +(`dashStateLine`, `dashTileServingLine`, `renderActiveIndented` with no keep +deadline, D4's renderer, `renderTokenLines`), and the footer through the +existing key-hint helpers. The key help names exactly what the view answers +to — `↑↓` scroll, `pgup`/`pgdown` page, `f` follow, `q` quit — and nothing +else, per the cli-ux rule. Stale readings carry their age through the +detail view's own age rule, so a tick that could not renew a reading is not +drawn as current. + +**D7 — Refresh cadence matches the local node.** +One 2-second tick (the dashboard's local cadence) takes an in-process +status-plus-metrics reading and replaces the view's; the log poll chain runs +on its own 3-second cadence, independently, exactly as the detail view keeps +its log poll separate from the grid's refresh. A failed reading leaves the +last one in place with its age. + +**D8 — One shared construction for the supervised foreground engine.** +The daemon-plus-supervisor plumbing `runServeForegroundAPI` already has — +the supervisor, the daemon with its served name, scrape target, engine +endpoint and refusing start stub, the sampler for the run's life, +`MarkActive`, the signal relay, the graceful stop, and the exit-status rule +(a stop on request is success) — is extracted into one helper that the view +path and the `--api` path both call. It is parameterised only by the +supervisor's log target — the state-dir engine log for a view run, empty for +a non-terminal `--api` run, which forwards stdio as today — and whether the +control API listener comes up, which is `--api`'s own say. Nothing else +differs, so the two paths cannot drift: served, scraped and probed the same +way, stopped the same way, exited the same way. `withMetricsArgs`, +`scrapeTargetFor` and `engineEndpointFor` live inside it (an engine with no +metrics dialect gets no added switch and the host's series). The engine +still starts before the view opens, so a missing binary fails with its +install hint around no view; the view's own pieces — the program on the +alternate screen, `m.send = prog.Send` (the dashboard's own door), the +engine-exited message from the `sup.Wait()` goroutine that the model quits +on, and `q`/Ctrl+C and the inherited `SIGINT`/`SIGTERM` handler routing to +the same stop — sit on top of the helper rather than beside it, and the +narration (the command block and the preset/model line) goes to stderr in +the view path because stdout is the view's screen, passed through +`buildServeArgv` rather than printed there. +*Alternative:* the view path assembles its own daemon setup, kept matched to +`--api`'s by review — rejected; two setup blocks for one object drift, and +the second is where the first's later changes stop being applied. Opening +the view before the engine starts, so a key starts it — also rejected; +serve exists to run the engine, and the engine-not-installed scenario must +keep failing the way it does today. + +## Risks / Trade-offs + +- [An interactive user who expected raw logs gets a screen] → the + motivation for the issue; the command and narration still appear (on + stderr, in the scrollback after exit), piped and scripted runs are + byte-for-byte unchanged, and `q` leaves as easily as it entered. +- [Ctrl+C on a terminal changes from a hard SIGINT to the view's quit] → + the quit is a graceful stop with escalation, the behaviour `serve --api` + already has for Ctrl+C; a hard kill remains available as it always was. +- [The in-memory log tail is a second copy of the log] → bounded by the line + budget and trimmed to it; the file on disk is the record, and the copy is + the same pattern the dashboard's detail view already uses. +- [A terminal that is a TTY but cannot do an alternate screen] → the + dashboard has the same exposure; bubbletea restores the terminal on exit + whatever key got there, and the non-terminal path is untouched. +- [Metrics for a view run depend on the engine's endpoint being reachable + from the host] → the scrape target resolution already handles bind, + BASEURL and the engine defaults in that order, and an engine with no + metrics endpoint degrades to the host's series rather than failing the + view. + +## Migration Plan + +One CLI release; no data or config migration. Non-terminal behaviour is +unchanged, so nothing scripted depends on the new path. Rollback is a revert. + +## Open Questions + +None that would change the spec or the task breakdown; the log line budget +and exact pane proportions are tunable during implementation. diff --git a/openspec/changes/archive/2026-09-07-serve-ui/proposal.md b/openspec/changes/archive/2026-09-07-serve-ui/proposal.md new file mode 100644 index 0000000..eabe217 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-serve-ui/proposal.md @@ -0,0 +1,69 @@ +## Why + +`spinloop serve` streams the engine's raw output straight to the terminal: the +operator sitting next to their own engine cannot see how hard it is working — +CPU, RAM, GPU, token counters — and once a log line has scrolled past it is +gone. The fleet dashboard already has a detail screen showing a node's metrics +and tailed log, but the machine where the engine runs in the foreground has +nothing equivalent. + +## What Changes + +- **BREAKING (interactive runs only):** `spinloop serve` on a terminal opens a + full-screen view instead of forwarding the engine's stdio: the engine's + metrics — state, what it serves, last active, the resource series, the token + counters — above the engine's log, in the three-section layout of the fleet + dashboard's node detail screen. A run whose output is not a terminal keeps + today's stdio-forwarded behaviour exactly. +- The view draws every resource series in both formats at once — the gauge of + the current reading and the bar of the retained history — where the + dashboard draws one or the other, toggled by `g`. +- The up and down arrow keys scroll the log pane through older output (page up + and page down by a page); while the pane is on the newest line, new output + appears as it is written. `f` pauses and resumes the log's follow, as in the + detail screen. +- Under the view the engine's stdout and stderr are captured to the daemon's + state-dir engine log — the same file `spinloop daemon` writes — instead of + being forwarded to the terminal. The view tails that file, and with `--api` + the control API's log endpoint serves it, so `serve --api`'s log endpoint + reports the real log rather than "missing". +- `serve --api` shows the same view with the control API listening alongside. +- The view run switches the engine's own metrics endpoint on for an engine + that has one — the same rule a supervised engine follows — and runs the + daemon's activity and history sampling for the life of the view, so the bars + have history to draw. An engine with no metrics endpoint gets the host's + series, as any node does. +- `q` or Ctrl+C stops the engine and exits; when the engine exits on its own + the view closes and serve exits with the engine's exit status, as today. + +## Capabilities + +### New Capabilities + +(none — the view is a surface of `spinloop serve`, which `local-serving` owns, +the same way the dashboard's detail view lives in `fleet-client`) + +### Modified Capabilities + +- `local-serving`: the stdio-forwarded foreground becomes the non-terminal + case; on a terminal serve runs the view. New requirements for the view + itself — its terminal gate and layout, both formats per series, the log's + tail and arrow-key scroll, its keys and exit, and the engine-output capture + it runs on — and the "Serve basics" and "Control API flag" requirements + change with it. + +## Impact + +- `cmd/spinloop/serve.go`, `cmd/spinloop/serve_daemon.go` — the terminal + check, the view's launch, and the supervised-foreground construction pulled + out of `serve --api` into one helper the view path and the `--api` path + share, parameterised by the log target and the API listener. +- A new model/renderer pair in `cmd/spinloop/` for the serve view, reusing the + fleet detail screen's frame helpers, the daemon's in-process + status/metrics/log reads, and the shared metrics renderers. +- `cmd/spinloop/metrics_render.go` — a combined renderer drawing each + series' gauge and bar together, and a width parameter on the gauge. +- `openspec/specs/local-serving/spec.md` — updated by this change's delta. +- `docs/commands/serve.md` — the foreground description and the view's keys. +- No new dependencies: bubbletea and lipgloss are already used by the fleet + dashboard. diff --git a/openspec/changes/archive/2026-09-07-serve-ui/specs/local-serving/spec.md b/openspec/changes/archive/2026-09-07-serve-ui/specs/local-serving/spec.md new file mode 100644 index 0000000..ddccd0a --- /dev/null +++ b/openspec/changes/archive/2026-09-07-serve-ui/specs/local-serving/spec.md @@ -0,0 +1,258 @@ +## MODIFIED Requirements + +### Requirement: Serve basics + +`spinloop serve [path]` SHALL read a Spinloop (default `./Spinloop`, aliases and +directories accepted like every Spinloop command), build the command for the +engine its `PROVIDER` names, print it in copy-pasteable shell form, and run it. +`--dry-run`/`-n` SHALL print the command without launching. A missing binary +SHALL produce an install hint naming **that** engine rather than a raw exec +error. + +A run whose stdout is not a terminal SHALL forward the engine's stdout and +stderr to serve's own, exactly as before the view existed. A run on a terminal +SHALL run the engine under the serve view, whose log section is fed by the +engine's captured output (see The serve view); the command serve prints before +running SHALL be written to stderr there, since stdout is the view's screen. + +#### Scenario: Dry run + +- **WHEN** the user runs `spinloop serve --dry-run` +- **THEN** the resolved command is printed and no server starts + +#### Scenario: Engine not installed + +- **WHEN** the selected engine's binary cannot be found +- **THEN** the error suggests installing that engine, not another one + +#### Scenario: A piped run forwards the engine's output + +- **WHEN** `spinloop serve` runs with its stdout piped or redirected +- **THEN** the engine's stdout and stderr are forwarded to serve's own, no + view opens, and the printed command stays on stdout + +### Requirement: Control API flag + +`spinloop serve` SHALL accept `-a`/`--api` to expose the control API over the +foreground engine, as defined by the `daemon-api` capability. Serve SHALL +remain a foreground command with no daemon flag — long-lived supervision is +`spinloop daemon`'s job. The flag SHALL change only whether the control API +listens: the foreground behaviour — the view on a terminal, stdio forwarding +off one — is the same with and without it. + +#### Scenario: Plain serve is unchanged + +- **WHEN** the user runs `spinloop serve` without `--api`, with output not on + a terminal +- **THEN** the engine runs in the foreground with stdio forwarded, exactly as + before + +#### Scenario: Serve with the API stays foreground + +- **WHEN** the user runs `spinloop serve -a` +- **THEN** the engine runs in the foreground with the control API listening + beside it, and the foreground behaviour is the same as without the flag + +## ADDED Requirements + +### Requirement: The serve view + +`spinloop serve` on a terminal SHALL open a full-screen view of the engine it +is running: the engine's metrics, the engine's log, and a line naming the keys +the view answers to, in the three-section layout of the fleet dashboard's node +detail screen. The view SHALL need a terminal to draw on: a run whose output +is not a terminal SHALL NOT open it, and `--dry-run` SHALL open it neither, +printing the command without launching as it does today. + +The view's metrics section SHALL show the same facts, in the same wording, +that the dashboard's detail screen and the metrics formats show for the same +engine — state, what is served, last active, the resource series, and the +token and request counters — read from the daemon the serve process runs +in-process rather than over the network, refreshed on the dashboard's own +local cadence. A reading the view could not renew SHALL be shown with its age +rather than drawn identically to one just read. + +The view SHALL draw every resource series the reading carries in both formats +at once, each series on one line: its gauge of the current reading and its +bar of the retained history side by side — the same series and labelling the +bar and gauge formats use, with the bar format's no-history rule where a +series has no history to draw, so a series with none carries its gauge +alone, its bar half blank. + +#### Scenario: The view opens on a terminal + +- **WHEN** the user runs `spinloop serve` at an interactive terminal +- **THEN** the view opens showing the engine's metrics above its log, with a + line naming the keys the view answers to + +#### Scenario: A piped run gets no view + +- **WHEN** `spinloop serve` runs with its stdout piped or redirected +- **THEN** no view opens and the engine's output is forwarded to serve's own + +#### Scenario: The metrics match the detail screen + +- **WHEN** the view is open on a running engine +- **THEN** its metrics section shows the same state, serving facts, last + active, resource series and counters the dashboard's detail screen shows + for the same engine, in full rather than clipped + +#### Scenario: Every series is drawn in both formats + +- **WHEN** the reading carries a CPU series with a retained history +- **THEN** the view draws the series on one line: its gauge of the current + reading and its bar of the retained history, side by side + +#### Scenario: A series with no history carries its gauge alone + +- **WHEN** a series has no retained history to draw +- **THEN** the view draws its gauge of the current reading only, its bar + half left blank + +### Requirement: The serve view's log + +The view's log section SHALL show the engine's log, tailing and following it +the same way the dashboard's detail view follows its node's log: new output +appears while the view is open without the operator asking for it. An engine +that has written nothing yet SHALL show a waiting note, not an empty pane. + +The up and down arrow keys SHALL scroll the log pane: up moves the visible +window one line towards the oldest retained line, down one line towards the +newest; a press at either end SHALL leave the window where it is. Page up and +page down SHALL move the window by the pane's height. While the window is on +the newest line, new output SHALL appear in the pane as it is written; while +the window is scrolled away from it, new output SHALL be retained and the pane +SHALL stay where the operator put it until they scroll back to the newest +line, where it sticks again. + +The operator SHALL be able to pause and resume the log's follow from the +keyboard, independently of the rest of the view: while paused, the pane SHALL +stop picking up new output, and the view SHALL show whether the log is +following or paused. Nothing written while paused is lost: resuming SHALL +fetch and show whatever the engine wrote in the meantime, and pausing SHALL +not affect the metrics section's own refresh. + +#### Scenario: The log pane follows new output + +- **WHEN** the engine writes to its log while the view is open and the window + is on the newest line +- **THEN** the new lines appear in the log section without the operator + pressing any key + +#### Scenario: Scrolling the log + +- **WHEN** the operator presses the up arrow +- **THEN** the window moves one line towards the older output, and the down + arrow moves it back, line for line, until it is on the newest line again + and sticks to it + +#### Scenario: Paging the log + +- **WHEN** the operator presses page up +- **THEN** the window moves by the pane's height towards the older output, + clamped at the oldest retained line + +#### Scenario: An engine that has written nothing yet + +- **WHEN** the view is open and the engine has written no log output +- **THEN** the log section shows a waiting note, not an empty pane + +#### Scenario: Pausing the log + +- **WHEN** the operator pauses the log's follow +- **THEN** the pane stops picking up new output and the view shows that the + log is paused, while the metrics section keeps refreshing on its own cadence + +#### Scenario: Resuming the log + +- **WHEN** the operator resumes a paused log +- **THEN** whatever the engine wrote while paused appears in the pane, and + new output continues to appear as it is written + +### Requirement: The serve view's keys and exit + +The view SHALL name its keys on screen and offer only the ones that would do +something in it: the up and down arrows and page up and page down scroll the +log, `f` pauses and resumes the log's follow, and `q` or Ctrl+C leaves. The +view SHALL NOT offer start, stop, keep or abort keys: the engine is serve's +own — starting is serve's job, and stopping it is what leaving does. + +`q` and Ctrl+C SHALL stop the engine — gracefully, escalating as a stop does +elsewhere — and exit serve. When the engine exits on its own, whatever the +cause, the view SHALL close and serve SHALL exit with the engine's exit +status, exactly as a foreground serve does today. + +#### Scenario: The key help names only live keys + +- **WHEN** the view draws its key help line +- **THEN** it names the scroll, follow and quit keys, and nothing the view + cannot do + +#### Scenario: Quitting stops the engine + +- **WHEN** the operator presses q or Ctrl+C +- **THEN** the engine is stopped and serve exits + +#### Scenario: The engine's own exit closes the view + +- **WHEN** the engine process exits while the view is open +- **THEN** the view closes and serve exits with the engine's exit status + +### Requirement: Engine output capture under the serve view + +When serve runs the view, the engine's stdout and stderr SHALL be captured to +the daemon's state-dir engine log — the same file `spinloop daemon` writes — +rather than forwarded to serve's stdio. The capture SHALL hold the engine's +whole output from its first line, so the log the view tails is complete, and +the log's path SHALL be the one the control API's status reports. With +`--api`, the API's log endpoint SHALL serve the captured file rather than +reporting the log missing. + +#### Scenario: The engine's output lands in the log + +- **WHEN** the engine writes to stdout or stderr while the view is open +- **THEN** the output is appended to the engine log file named in the + daemon's status, and appears in the view's log section + +#### Scenario: serve --api's log endpoint serves the capture + +- **WHEN** `spinloop serve --api` runs under the view and a client asks its + log endpoint for the engine log +- **THEN** the reply carries the engine's output, not the missing-log answer + +#### Scenario: Off the terminal nothing is captured + +- **WHEN** `spinloop serve` runs with its output not on a terminal +- **THEN** the engine's output is forwarded to serve's stdio and the run + writes no engine log file + +### Requirement: The view's metrics sampling + +For an engine that exposes a metrics endpoint, a view run SHALL switch that +endpoint on before the engine starts — the same switch a supervised engine +gets — so the counters and the history the bars draw are the engine's own. +The daemon's activity and history sampling SHALL run for the life of the view, +the same sampling a running engine gets under the daemon, so the bars have a +retained history to draw. An engine with no metrics endpoint SHALL run with no +added switch, and its view SHALL draw the host's series — CPU, RAM and the +GPUs — as any node does. + +#### Scenario: A metrics-capable engine is switched on + +- **WHEN** `spinloop serve` opens the view for an engine that has a metrics + endpoint +- **THEN** the engine is launched with its metrics endpoint on, the same way + a supervised engine is + +#### Scenario: History accrues for the bars + +- **WHEN** the engine has been running for several sampler ticks under the + view +- **THEN** the bars draw the retained history of the ticks so far + +#### Scenario: An engine with no metrics endpoint + +- **WHEN** `spinloop serve` opens the view for an engine that exposes no + metrics endpoint +- **THEN** the engine is launched without any added switch, and the view + draws the host's CPU, RAM and GPU series diff --git a/openspec/changes/archive/2026-09-07-serve-ui/tasks.md b/openspec/changes/archive/2026-09-07-serve-ui/tasks.md new file mode 100644 index 0000000..fe1737a --- /dev/null +++ b/openspec/changes/archive/2026-09-07-serve-ui/tasks.md @@ -0,0 +1,31 @@ +## 1. Combined bar-and-gauge metrics rendering + +- [x] 1.1 Give `renderGauge` a width parameter in `cmd/spinloop/metrics_render.go`, updating its existing callers to pass today's 25-column width, and verify the existing metrics render tests pass unchanged +- [x] 1.2 Add the combined renderer (each series from `barSeriesList` on one line: its gauge of the current reading beside its sparkline of the retained history, the line's figure the current reading or, where none, the bar's latest sample, a history-less series gauge-only its bar half blank, a current-less series history-only its gauge half blank) and verify with table-driven tests covering: a series with history (gauge and bar on one line), a series with none (gauge alone), a current-less series (history alone), multi-GPU labelling, and the 80/90 colour thresholds on both halves + +## 2. Serve view model + +- [x] 2.1 Create `cmd/spinloop/serve_view.go` with the model's state (the metrics reading and its time, the tailed log content, its byte offset, its line budget, the scroll position and the follow flag, the window size) and its `Init`/`Update`/`View` shape, and verify the package compiles and an initial-state test passes +- [x] 2.2 Add the metrics tick: a one-shot 2-second tick taking an in-process status-plus-metrics reading through an injected read function, a failed or stale reading leaving the last one in place with its age, and verify with tests that a reading replaces the prior one and a failed read keeps the last +- [x] 2.3 Add the log poll: a one-shot 3-second tick with a generation guard, reading through an injected log read with the daemon's log semantics (tail on first read, resume from the stored offset, `StaleOffset` resetting to the reply's `NextOffset`), appending and trimming to the line budget, and verify with tests for the first read's backlog, no duplicates across polls, a stale-offset resume, and a superseded poll being discarded +- [x] 2.4 Add the keys: `up`/`down` moving the scroll by one line, `pgup`/`pgdown` by the pane's height, clamped at the oldest retained line and the newest, the window sticking to the tail at the newest line and staying put while scrolled away, `f` pausing and resuming the follow, and `q`/Ctrl+C quitting, and verify with table-driven key tests over the scroll positions and the follow flag +- [x] 2.5 Add the engine-exited message delivered through the model's `send` seam (the dashboard's `prog.Send` door), quitting the program on it, and verify a test drives the message and asserts the quit + +## 3. Serve view frame + +- [x] 3.1 Draw the frame in `View()`: the title bar (screen "serve", the Spinloop's path and the log's state to the right), the metrics section (state with uptime, the serving line, the last-active line, the combined renderer, the token lines), the log section with the waiting note where there is no content yet, the dividers, and the footer key help naming exactly the scroll, follow and quit keys, reusing the detail screen's `dashTitleBar`, key-hint and clip helpers, and verify with render tests over a fixed model and terminal size +- [x] 3.2 Show the log's following/paused state in the title bar from the follow flag, and verify a render test for each state + +## 4. `serve` wiring + +- [x] 4.1 Gate the view on `term.IsTerminal` of stdout in `runServe` (never for `--dry-run`, never off the terminal), pass the narration writer through `buildServeArgv` so the command and preset/model lines go to stderr under the view and stay on stdout off it, and verify the existing serve tests pass plus a test that the gate is applied through an injectable check +- [x] 4.2 Pull the supervised-foreground construction out of `runServeForegroundAPI` into one helper — the supervisor, the daemon with its served name, scrape target, engine endpoint and refusing start stub, the metrics switch for an engine that has one, the sampler for the run's life, the signal relay, the graceful stop and the exit-status rule (a stop on request as success) — parameterised only by the supervisor's log target (the state-dir engine log, or empty for stdio forwarding) and whether the control API listener comes up, and point the existing non-terminal `--api` path at it, and verify the existing foreground-serve tests pass unchanged +- [x] 4.3 Build the view run on that helper — log target the state-dir engine log, listener per `--api`, the engine started before the program opens so a missing binary fails with its install hint around no view, and the narration on stderr — and verify with stub-engine tests covering: the metrics switch present for a metrics-capable engine, absent for one with no metrics dialect, the log file holding the engine's output, and the not-found hint +- [x] 4.4 Run the program on the alternate screen with `send` wired, the `sup.Wait()` goroutine delivering the engine-exited message, `q`/Ctrl+C and the inherited `SIGINT`/`SIGTERM` handler routing to the helper's stop, serve exiting with the engine's exit status, and `--api` adding the listener and its shutdown, and verify with the existing foreground-serve test patterns: the stop-on-request success path, the non-zero exit path, and the non-terminal `--api` run answering its API as before +- [x] 4.5 Update `serve`'s long help to describe the view and its keys, and verify `go run ./cmd/spinloop serve --help` shows the new text + +## 5. Docs and verification + +- [x] 5.1 Update `docs/commands/serve.md` for the view: its layout, its keys, the terminal/non-terminal split, and `serve --api`'s log endpoint serving the captured log, and verify the file reads back consistent with the spec delta +- [x] 5.2 Run `gofmt -l .`, `go vet ./...` and `go test ./... -cover`, and verify all are clean with total coverage at or above 80% +- [x] 5.3 Smoke both paths by hand: on a terminal, run `spinloop serve` against a stub engine and confirm the view opens with gauge and bar per series, the log follows, the arrows scroll and stick to the tail, `f` pauses, and `q` stops the engine cleanly; off the terminal, confirm `spinloop serve | cat` forwards the engine's output exactly as before, and verify by the observed behaviour diff --git a/openspec/specs/local-serving/spec.md b/openspec/specs/local-serving/spec.md index 0a327dd..0505961 100644 --- a/openspec/specs/local-serving/spec.md +++ b/openspec/specs/local-serving/spec.md @@ -12,10 +12,16 @@ config. `spinloop serve [path]` SHALL read a Spinloop (default `./Spinloop`, aliases and directories accepted like every Spinloop command), build the command for the -engine its `PROVIDER` names, print it in copy-pasteable shell form, and run it -with stdio forwarded. `--dry-run`/`-n` SHALL print the command without -launching. A missing binary SHALL produce an install hint naming **that** engine -rather than a raw exec error. +engine its `PROVIDER` names, print it in copy-pasteable shell form, and run it. +`--dry-run`/`-n` SHALL print the command without launching. A missing binary +SHALL produce an install hint naming **that** engine rather than a raw exec +error. + +A run whose stdout is not a terminal SHALL forward the engine's stdout and +stderr to serve's own, exactly as before the view existed. A run on a terminal +SHALL run the engine under the serve view, whose log section is fed by the +engine's captured output (see The serve view); the command serve prints before +running SHALL be written to stderr there, since stdout is the view's screen. #### Scenario: Dry run @@ -27,6 +33,12 @@ rather than a raw exec error. - **WHEN** the selected engine's binary cannot be found - **THEN** the error suggests installing that engine, not another one +#### Scenario: A piped run forwards the engine's output + +- **WHEN** `spinloop serve` runs with its stdout piped or redirected +- **THEN** the engine's stdout and stderr are forwarded to serve's own, no + view opens, and the printed command stays on stdout + ### Requirement: Choosing the engine `spinloop serve` SHALL launch the inference engine the Spinloop's `PROVIDER` names, @@ -335,12 +347,14 @@ process. Configuring authentication on the server is the engine's own concern. `spinloop serve` SHALL accept `-a`/`--api` to expose the control API over the foreground engine, as defined by the `daemon-api` capability. Serve SHALL remain a foreground command with no daemon flag — long-lived supervision is -`spinloop daemon`'s job. Without `--api`, serve's foreground stdio-forwarded -behaviour SHALL be unchanged. +`spinloop daemon`'s job. The flag SHALL change only whether the control API +listens: the foreground behaviour — the view on a terminal, stdio forwarding +off one — is the same with and without it. #### Scenario: Plain serve is unchanged -- **WHEN** the user runs `spinloop serve` without `--api` +- **WHEN** the user runs `spinloop serve` without `--api`, with output not on + a terminal - **THEN** the engine runs in the foreground with stdio forwarded, exactly as before @@ -348,5 +362,207 @@ behaviour SHALL be unchanged. - **WHEN** the user runs `spinloop serve -a` - **THEN** the engine runs in the foreground with the control API listening - beside it + beside it, and the foreground behaviour is the same as without the flag + +### Requirement: The serve view + +`spinloop serve` on a terminal SHALL open a full-screen view of the engine it +is running: the engine's metrics, the engine's log, and a line naming the keys +the view answers to, in the three-section layout of the fleet dashboard's node +detail screen. The view SHALL need a terminal to draw on: a run whose output +is not a terminal SHALL NOT open it, and `--dry-run` SHALL open it neither, +printing the command without launching as it does today. + +The view's metrics section SHALL show the same facts, in the same wording, +that the dashboard's detail screen and the metrics formats show for the same +engine — state, what is served, last active, the resource series, and the +token and request counters — read from the daemon the serve process runs +in-process rather than over the network, refreshed on the dashboard's own +local cadence. A reading the view could not renew SHALL be shown with its age +rather than drawn identically to one just read. + +The view SHALL draw every resource series the reading carries in both formats +at once, each series on one line: its gauge of the current reading and its +bar of the retained history side by side — the same series and labelling the +bar and gauge formats use, with the bar format's no-history rule where a +series has no history to draw, so a series with none carries its gauge +alone, its bar half blank. + +#### Scenario: The view opens on a terminal + +- **WHEN** the user runs `spinloop serve` at an interactive terminal +- **THEN** the view opens showing the engine's metrics above its log, with a + line naming the keys the view answers to + +#### Scenario: A piped run gets no view + +- **WHEN** `spinloop serve` runs with its stdout piped or redirected +- **THEN** no view opens and the engine's output is forwarded to serve's own + +#### Scenario: The metrics match the detail screen + +- **WHEN** the view is open on a running engine +- **THEN** its metrics section shows the same state, serving facts, last + active, resource series and counters the dashboard's detail screen shows + for the same engine, in full rather than clipped + +#### Scenario: Every series is drawn in both formats + +- **WHEN** the reading carries a CPU series with a retained history +- **THEN** the view draws the series on one line: its gauge of the current + reading and its bar of the retained history, side by side + +#### Scenario: A series with no history carries its gauge alone + +- **WHEN** a series has no retained history to draw +- **THEN** the view draws its gauge of the current reading only, its bar + half left blank + +### Requirement: The serve view's log + +The view's log section SHALL show the engine's log, tailing and following it +the same way the dashboard's detail view follows its node's log: new output +appears while the view is open without the operator asking for it. An engine +that has written nothing yet SHALL show a waiting note, not an empty pane. + +The up and down arrow keys SHALL scroll the log pane: up moves the visible +window one line towards the oldest retained line, down one line towards the +newest; a press at either end SHALL leave the window where it is. Page up and +page down SHALL move the window by the pane's height. While the window is on +the newest line, new output SHALL appear in the pane as it is written; while +the window is scrolled away from it, new output SHALL be retained and the pane +SHALL stay where the operator put it until they scroll back to the newest +line, where it sticks again. + +The operator SHALL be able to pause and resume the log's follow from the +keyboard, independently of the rest of the view: while paused, the pane SHALL +stop picking up new output, and the view SHALL show whether the log is +following or paused. Nothing written while paused is lost: resuming SHALL +fetch and show whatever the engine wrote in the meantime, and pausing SHALL +not affect the metrics section's own refresh. + +#### Scenario: The log pane follows new output + +- **WHEN** the engine writes to its log while the view is open and the window + is on the newest line +- **THEN** the new lines appear in the log section without the operator + pressing any key + +#### Scenario: Scrolling the log + +- **WHEN** the operator presses the up arrow +- **THEN** the window moves one line towards the older output, and the down + arrow moves it back, line for line, until it is on the newest line again + and sticks to it + +#### Scenario: Paging the log + +- **WHEN** the operator presses page up +- **THEN** the window moves by the pane's height towards the older output, + clamped at the oldest retained line + +#### Scenario: An engine that has written nothing yet + +- **WHEN** the view is open and the engine has written no log output +- **THEN** the log section shows a waiting note, not an empty pane + +#### Scenario: Pausing the log + +- **WHEN** the operator pauses the log's follow +- **THEN** the pane stops picking up new output and the view shows that the + log is paused, while the metrics section keeps refreshing on its own cadence + +#### Scenario: Resuming the log + +- **WHEN** the operator resumes a paused log +- **THEN** whatever the engine wrote while paused appears in the pane, and + new output continues to appear as it is written + +### Requirement: The serve view's keys and exit + +The view SHALL name its keys on screen and offer only the ones that would do +something in it: the up and down arrows and page up and page down scroll the +log, `f` pauses and resumes the log's follow, and `q` or Ctrl+C leaves. The +view SHALL NOT offer start, stop, keep or abort keys: the engine is serve's +own — starting is serve's job, and stopping it is what leaving does. + +`q` and Ctrl+C SHALL stop the engine — gracefully, escalating as a stop does +elsewhere — and exit serve. When the engine exits on its own, whatever the +cause, the view SHALL close and serve SHALL exit with the engine's exit +status, exactly as a foreground serve does today. + +#### Scenario: The key help names only live keys + +- **WHEN** the view draws its key help line +- **THEN** it names the scroll, follow and quit keys, and nothing the view + cannot do + +#### Scenario: Quitting stops the engine + +- **WHEN** the operator presses q or Ctrl+C +- **THEN** the engine is stopped and serve exits + +#### Scenario: The engine's own exit closes the view + +- **WHEN** the engine process exits while the view is open +- **THEN** the view closes and serve exits with the engine's exit status + +### Requirement: Engine output capture under the serve view + +When serve runs the view, the engine's stdout and stderr SHALL be captured to +the daemon's state-dir engine log — the same file `spinloop daemon` writes — +rather than forwarded to serve's stdio. The capture SHALL hold the engine's +whole output from its first line, so the log the view tails is complete, and +the log's path SHALL be the one the control API's status reports. With +`--api`, the API's log endpoint SHALL serve the captured file rather than +reporting the log missing. + +#### Scenario: The engine's output lands in the log + +- **WHEN** the engine writes to stdout or stderr while the view is open +- **THEN** the output is appended to the engine log file named in the + daemon's status, and appears in the view's log section + +#### Scenario: serve --api's log endpoint serves the capture + +- **WHEN** `spinloop serve --api` runs under the view and a client asks its + log endpoint for the engine log +- **THEN** the reply carries the engine's output, not the missing-log answer + +#### Scenario: Off the terminal nothing is captured + +- **WHEN** `spinloop serve` runs with its output not on a terminal +- **THEN** the engine's output is forwarded to serve's stdio and the run + writes no engine log file + +### Requirement: The view's metrics sampling + +For an engine that exposes a metrics endpoint, a view run SHALL switch that +endpoint on before the engine starts — the same switch a supervised engine +gets — so the counters and the history the bars draw are the engine's own. +The daemon's activity and history sampling SHALL run for the life of the view, +the same sampling a running engine gets under the daemon, so the bars have a +retained history to draw. An engine with no metrics endpoint SHALL run with no +added switch, and its view SHALL draw the host's series — CPU, RAM and the +GPUs — as any node does. + +#### Scenario: A metrics-capable engine is switched on + +- **WHEN** `spinloop serve` opens the view for an engine that has a metrics + endpoint +- **THEN** the engine is launched with its metrics endpoint on, the same way + a supervised engine is + +#### Scenario: History accrues for the bars + +- **WHEN** the engine has been running for several sampler ticks under the + view +- **THEN** the bars draw the retained history of the ticks so far + +#### Scenario: An engine with no metrics endpoint + +- **WHEN** `spinloop serve` opens the view for an engine that exposes no + metrics endpoint +- **THEN** the engine is launched without any added switch, and the view + draws the host's CPU, RAM and GPU series