From 594643e25408de4828a4dbb5117474fda79a6841 Mon Sep 17 00:00:00 2001 From: Luca Fondo Date: Sat, 24 Jan 2026 02:22:18 +0100 Subject: [PATCH 1/3] feat(#56): propagate cache hit status from telemetry to UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add visual indicator (⚡ + dimmed style) for cached tasks: - Extend MsgTaskComplete with Cached bool field - Extract same.cached span attribute in TUIBridge.OnEnd - Update Renderer port interface signature - Set TaskNode.Cached in Model.Update handler - Add unit tests for attribute extraction and state update - Update linear renderer to display cached tasks with ⚡ icon --- cli/internal/adapters/linear/renderer.go | 11 +++++-- cli/internal/adapters/linear/renderer_test.go | 16 +++++----- cli/internal/adapters/telemetry/bridge.go | 10 +++++++ .../adapters/telemetry/bridge_test.go | 30 +++++++++++++++++-- cli/internal/adapters/telemetry/messages.go | 1 + cli/internal/adapters/telemetry/mock_test.go | 2 +- cli/internal/adapters/tui/model.go | 1 + cli/internal/adapters/tui/model_test.go | 25 ++++++++++++++-- cli/internal/adapters/tui/renderer.go | 3 +- cli/internal/adapters/tui/renderer_test.go | 4 +-- cli/internal/core/ports/renderer.go | 3 +- 11 files changed, 86 insertions(+), 20 deletions(-) diff --git a/cli/internal/adapters/linear/renderer.go b/cli/internal/adapters/linear/renderer.go index 01f2702..d454cfc 100644 --- a/cli/internal/adapters/linear/renderer.go +++ b/cli/internal/adapters/linear/renderer.go @@ -166,7 +166,7 @@ func (r *Renderer) OnTaskLog(spanID string, data []byte) { } // OnTaskComplete flushes remaining buffer and prints completion status. -func (r *Renderer) OnTaskComplete(spanID string, endTime time.Time, err error) { +func (r *Renderer) OnTaskComplete(spanID string, endTime time.Time, err error, cached bool) { r.mu.Lock() defer r.mu.Unlock() @@ -182,11 +182,16 @@ func (r *Renderer) OnTaskComplete(spanID string, endTime time.Time, err error) { duration := endTime.Sub(task.startTime) coloredPrefix := r.output.String(fmt.Sprintf("[%s]", task.name)).Foreground(task.color).String() - if err != nil { + switch { + case err != nil: symbol := r.output.String("✗").Foreground(termenv.ANSIRed).String() _, _ = fmt.Fprintf(r.stderr, "%s %s Failed after %v: %v\n", coloredPrefix, symbol, duration, err) - } else { + case cached: + symbol := r.output.String("⚡").Foreground(termenv.ANSIYellow).String() + _, _ = fmt.Fprintf(r.stderr, "%s %s Cached (skipped in %v)\n", + coloredPrefix, symbol, duration) + default: symbol := r.output.String("✓").Foreground(termenv.ANSIGreen).String() _, _ = fmt.Fprintf(r.stderr, "%s %s Completed in %v\n", coloredPrefix, symbol, duration) diff --git a/cli/internal/adapters/linear/renderer_test.go b/cli/internal/adapters/linear/renderer_test.go index fa3db8f..7053be2 100644 --- a/cli/internal/adapters/linear/renderer_test.go +++ b/cli/internal/adapters/linear/renderer_test.go @@ -52,7 +52,7 @@ func TestRenderer_TaskLifecycle(t *testing.T) { // Task complete endTime := startTime.Add(100 * time.Millisecond) - r.OnTaskComplete("span1", endTime, nil) + r.OnTaskComplete("span1", endTime, nil, false) if !strings.Contains(stderr.String(), "Completed") { t.Errorf("Expected completion message, got: %s", stderr.String()) @@ -86,7 +86,7 @@ func TestRenderer_PartialLines(t *testing.T) { // Flush on complete r.OnTaskLog("span1", []byte("unflushed")) endTime := startTime.Add(50 * time.Millisecond) - r.OnTaskComplete("span1", endTime, nil) + r.OnTaskComplete("span1", endTime, nil, false) if !strings.Contains(stdout.String(), "task1") || !strings.Contains(stdout.String(), "unflushed") { t.Errorf("Expected flushed partial line on complete, got: %s", stdout.String()) @@ -104,7 +104,7 @@ func TestRenderer_TaskError(t *testing.T) { endTime := startTime.Add(50 * time.Millisecond) err := zerr.New("task failed") - r.OnTaskComplete("span1", endTime, err) + r.OnTaskComplete("span1", endTime, err, false) stderrStr := stderr.String() if !strings.Contains(stderrStr, "Failed") { @@ -153,8 +153,8 @@ func TestRenderer_ConcurrentTasks(t *testing.T) { } endTime := startTime.Add(100 * time.Millisecond) - r.OnTaskComplete("span1", endTime, nil) - r.OnTaskComplete("span2", endTime, nil) + r.OnTaskComplete("span1", endTime, nil, false) + r.OnTaskComplete("span2", endTime, nil, false) } func TestRenderer_NoColor(t *testing.T) { @@ -172,7 +172,7 @@ func TestRenderer_NoColor(t *testing.T) { r.OnTaskStart("span1", "", "task1", startTime) endTime := startTime.Add(50 * time.Millisecond) - r.OnTaskComplete("span1", endTime, nil) + r.OnTaskComplete("span1", endTime, nil, false) // With NO_COLOR, output should not contain ANSI escape codes stderrStr := stderr.String() @@ -242,7 +242,7 @@ func TestRenderer_OnTaskCompleteUnknownSpan(t *testing.T) { var stdout, stderr bytes.Buffer r := linear.NewRenderer(&stdout, &stderr) - r.OnTaskComplete("unknown-span", time.Now(), nil) + r.OnTaskComplete("unknown-span", time.Now(), nil, false) if stderr.Len() != 0 { t.Errorf("Expected no output for unknown span completion, got: %s", stderr.String()) @@ -303,5 +303,5 @@ func TestRenderer_NilStdout(_ *testing.T) { startTime := time.Now() r.OnTaskStart("span1", "", "task1", startTime) r.OnTaskLog("span1", []byte("test\n")) - r.OnTaskComplete("span1", startTime.Add(time.Second), nil) + r.OnTaskComplete("span1", startTime.Add(time.Second), nil, false) } diff --git a/cli/internal/adapters/telemetry/bridge.go b/cli/internal/adapters/telemetry/bridge.go index db56771..edae463 100644 --- a/cli/internal/adapters/telemetry/bridge.go +++ b/cli/internal/adapters/telemetry/bridge.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace" @@ -66,10 +67,19 @@ func (b *Bridge) OnEnd(s sdktrace.ReadOnlySpan) { err = errors.New(desc) } + cached := false + for _, attr := range s.Attributes() { + if string(attr.Key) == "same.cached" && attr.Value.Type() == attribute.BOOL { + cached = attr.Value.AsBool() + break + } + } + b.renderer.OnTaskComplete( sc.SpanID().String(), s.EndTime(), err, + cached, ) } diff --git a/cli/internal/adapters/telemetry/bridge_test.go b/cli/internal/adapters/telemetry/bridge_test.go index becbff6..eb6b32d 100644 --- a/cli/internal/adapters/telemetry/bridge_test.go +++ b/cli/internal/adapters/telemetry/bridge_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.trai.ch/same/internal/adapters/telemetry" @@ -50,7 +51,7 @@ func TestBridge_OnEnd(t *testing.T) { mockRenderer := mocks.NewMockRenderer(ctrl) bridge := telemetry.NewBridge(mockRenderer) - mockRenderer.EXPECT().OnTaskComplete(gomock.Any(), gomock.Any(), nil).Times(1) + mockRenderer.EXPECT().OnTaskComplete(gomock.Any(), gomock.Any(), nil, false).Times(1) tp := sdktrace.NewTracerProvider() tracer := tp.Tracer("test") @@ -69,7 +70,7 @@ func TestBridge_OnEndWithError(t *testing.T) { mockRenderer := mocks.NewMockRenderer(ctrl) bridge := telemetry.NewBridge(mockRenderer) - mockRenderer.EXPECT().OnTaskComplete(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + mockRenderer.EXPECT().OnTaskComplete(gomock.Any(), gomock.Any(), gomock.Any(), false).Times(1) tp := sdktrace.NewTracerProvider() tracer := tp.Tracer("test") @@ -118,3 +119,28 @@ func TestBridge_Shutdown(t *testing.T) { t.Errorf("Shutdown() should not return error, got: %v", err) } } + +func TestBridge_OnEndWithCachedAttribute(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRenderer := mocks.NewMockRenderer(ctrl) + bridge := telemetry.NewBridge(mockRenderer) + + mockRenderer.EXPECT().OnTaskComplete( + gomock.Any(), + gomock.Any(), + nil, + true, + ).Times(1) + + tp := sdktrace.NewTracerProvider() + tracer := tp.Tracer("test") + _, span := tracer.Start(context.Background(), "test-span") + span.SetAttributes(attribute.Bool("same.cached", true)) + span.End() + + if roSpan, ok := span.(sdktrace.ReadOnlySpan); ok { + bridge.OnEnd(roSpan) + } +} diff --git a/cli/internal/adapters/telemetry/messages.go b/cli/internal/adapters/telemetry/messages.go index 1b13f0a..c644f56 100644 --- a/cli/internal/adapters/telemetry/messages.go +++ b/cli/internal/adapters/telemetry/messages.go @@ -23,6 +23,7 @@ type MsgTaskComplete struct { SpanID string EndTime time.Time Err error + Cached bool } // MsgTaskLog carries a chunk of log output for a specific task. diff --git a/cli/internal/adapters/telemetry/mock_test.go b/cli/internal/adapters/telemetry/mock_test.go index 01afb39..25e06ac 100644 --- a/cli/internal/adapters/telemetry/mock_test.go +++ b/cli/internal/adapters/telemetry/mock_test.go @@ -39,7 +39,7 @@ func (m *mockRenderer) OnTaskLog(_ string, data []byte) { m.logs = append(m.logs, data) } -func (m *mockRenderer) OnTaskComplete(_ string, _ time.Time, _ error) { +func (m *mockRenderer) OnTaskComplete(_ string, _ time.Time, _ error, _ bool) { m.mu.Lock() defer m.mu.Unlock() m.completeCalls++ diff --git a/cli/internal/adapters/tui/model.go b/cli/internal/adapters/tui/model.go index 08d8cc7..70734e5 100644 --- a/cli/internal/adapters/tui/model.go +++ b/cli/internal/adapters/tui/model.go @@ -308,6 +308,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case telemetry.MsgTaskComplete: if node, ok := m.SpanMap[msg.SpanID]; ok { node.EndTime = msg.EndTime + node.Cached = msg.Cached if msg.Err != nil { node.Status = StatusError } else { diff --git a/cli/internal/adapters/tui/model_test.go b/cli/internal/adapters/tui/model_test.go index 4686dcc..e3ae780 100644 --- a/cli/internal/adapters/tui/model_test.go +++ b/cli/internal/adapters/tui/model_test.go @@ -131,14 +131,14 @@ func TestModel_Update_Telemetry(t *testing.T) { assert.Contains(t, output, "hello log") // 4. Complete Task (Success) - msgComplete := telemetry.MsgTaskComplete{SpanID: spanID, Err: nil} + msgComplete := telemetry.MsgTaskComplete{SpanID: spanID, Err: nil, Cached: false} m.Update(msgComplete) assert.Equal(t, tui.StatusDone, m.TaskMap["task1"].Status) // 5. Complete Task (Error) // Reset status for test m.TaskMap["task1"].Status = tui.StatusRunning - msgError := telemetry.MsgTaskComplete{SpanID: spanID, Err: assert.AnError} + msgError := telemetry.MsgTaskComplete{SpanID: spanID, Err: assert.AnError, Cached: false} m.Update(msgError) assert.Equal(t, tui.StatusError, m.TaskMap["task1"].Status) } @@ -372,6 +372,27 @@ func TestModel_Update_TaskExecStart(t *testing.T) { assert.False(t, task.ExecStartTime.IsZero()) } +func TestModel_Update_TaskCompleteCached(t *testing.T) { + t.Parallel() + + task := &tui.TaskNode{Name: "task1", Term: tui.NewVterm()} + m := &tui.Model{ + TaskMap: map[string]*tui.TaskNode{"task1": task}, + SpanMap: map[string]*tui.TaskNode{"span-cached": task}, + } + + msgComplete := telemetry.MsgTaskComplete{ + SpanID: "span-cached", + EndTime: time.Now(), + Err: nil, + Cached: true, + } + m.Update(msgComplete) + + assert.Equal(t, tui.StatusDone, task.Status) + assert.True(t, task.Cached, "Cached flag should be set to true") +} + func TestModel_getSelectedTask_OutOfBounds(t *testing.T) { t.Parallel() diff --git a/cli/internal/adapters/tui/renderer.go b/cli/internal/adapters/tui/renderer.go index 360371e..4744da6 100644 --- a/cli/internal/adapters/tui/renderer.go +++ b/cli/internal/adapters/tui/renderer.go @@ -73,11 +73,12 @@ func (r *Renderer) OnTaskLog(spanID string, data []byte) { } // OnTaskComplete forwards task completion events to the TUI. -func (r *Renderer) OnTaskComplete(spanID string, endTime time.Time, err error) { +func (r *Renderer) OnTaskComplete(spanID string, endTime time.Time, err error, cached bool) { r.program.Send(telemetry.MsgTaskComplete{ SpanID: spanID, EndTime: endTime, Err: err, + Cached: cached, }) } diff --git a/cli/internal/adapters/tui/renderer_test.go b/cli/internal/adapters/tui/renderer_test.go index 4fe4cc8..872818c 100644 --- a/cli/internal/adapters/tui/renderer_test.go +++ b/cli/internal/adapters/tui/renderer_test.go @@ -139,7 +139,7 @@ func TestRenderer_OnTaskComplete(t *testing.T) { startTime := time.Now() renderer.OnTaskStart("span1", "", "task1", startTime) endTime := startTime.Add(100 * time.Millisecond) - renderer.OnTaskComplete("span1", endTime, nil) + renderer.OnTaskComplete("span1", endTime, nil, false) time.Sleep(10 * time.Millisecond) } @@ -166,7 +166,7 @@ func TestRenderer_OnTaskCompleteWithError(t *testing.T) { startTime := time.Now() renderer.OnTaskStart("span1", "", "task1", startTime) endTime := startTime.Add(100 * time.Millisecond) - renderer.OnTaskComplete("span1", endTime, zerr.New("task failed")) + renderer.OnTaskComplete("span1", endTime, zerr.New("task failed"), false) time.Sleep(10 * time.Millisecond) } diff --git a/cli/internal/core/ports/renderer.go b/cli/internal/core/ports/renderer.go index 3508b22..fcaa8cf 100644 --- a/cli/internal/core/ports/renderer.go +++ b/cli/internal/core/ports/renderer.go @@ -45,5 +45,6 @@ type Renderer interface { // spanID: identifier for the task // endTime: when the task completed // err: nil if successful, error otherwise - OnTaskComplete(spanID string, endTime time.Time, err error) + // cached: true if the task was retrieved from cache + OnTaskComplete(spanID string, endTime time.Time, err error, cached bool) } From 3ceac421bebbfa0e4af5748321f96e71890b51ab Mon Sep 17 00:00:00 2001 From: Luca Fondo Date: Sat, 24 Jan 2026 11:29:37 +0100 Subject: [PATCH 2/3] test(#56): add tests for cached task completion - Add TestRenderer_TaskCached to cover cached task completion path - Add TestRenderer_StopWithCompletedTask to test buffer cleanup edge case - Improves coverage from 96.2% to 98.8% - OnTaskComplete function now has 100% coverage (was 88.2%) - All tests pass with race detector - Zero linting issues --- cli/internal/adapters/linear/renderer_test.go | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/cli/internal/adapters/linear/renderer_test.go b/cli/internal/adapters/linear/renderer_test.go index 7053be2..4df976b 100644 --- a/cli/internal/adapters/linear/renderer_test.go +++ b/cli/internal/adapters/linear/renderer_test.go @@ -115,6 +115,27 @@ func TestRenderer_TaskError(t *testing.T) { } } +func TestRenderer_TaskCached(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + startTime := time.Now() + r.OnTaskStart("span1", "", "cached-task", startTime) + + r.OnTaskLog("span1", []byte("cache check\n")) + + endTime := startTime.Add(10 * time.Millisecond) + r.OnTaskComplete("span1", endTime, nil, true) + + stderrStr := stderr.String() + if !strings.Contains(stderrStr, "Cached") { + t.Errorf("Expected cached message, got: %s", stderrStr) + } + if !strings.Contains(stderrStr, "skipped") { + t.Errorf("Expected 'skipped' in cached message, got: %s", stderrStr) + } +} + func TestRenderer_ConcurrentTasks(t *testing.T) { var stdout, stderr bytes.Buffer r := linear.NewRenderer(&stdout, &stderr) @@ -288,6 +309,32 @@ func TestRenderer_StopFlushesBuffers(t *testing.T) { } } +func TestRenderer_StopWithCompletedTask(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + startTime := time.Now() + r.OnTaskStart("span1", "", "task1", startTime) + r.OnTaskStart("span2", "", "task2", startTime) + + r.OnTaskLog("span1", []byte("partial1")) + r.OnTaskLog("span2", []byte("partial2")) + + // Complete span1, leaving span2 with buffer + endTime := startTime.Add(50 * time.Millisecond) + r.OnTaskComplete("span1", endTime, nil, false) + + // Stop should flush span2's buffer and handle span1's missing task gracefully + if err := r.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + stdoutStr := stdout.String() + if !strings.Contains(stdoutStr, "partial2") { + t.Errorf("Expected flushed partial2, got: %s", stdoutStr) + } +} + func TestRenderer_Wait(t *testing.T) { var stdout, stderr bytes.Buffer r := linear.NewRenderer(&stdout, &stderr) From 739af5b43775fb9e6ee874c8652e407d69309524 Mon Sep 17 00:00:00 2001 From: Luca Fondo Date: Sat, 24 Jan 2026 11:53:06 +0100 Subject: [PATCH 3/3] feat(#56): format task durations with 2 decimal precision and appropriate units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add formatDuration helper to display durations with µs/ms/s units - Use float division to preserve decimal precision - Cap all duration displays at 2 decimal places --- cli/internal/adapters/linear/renderer.go | 25 ++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/cli/internal/adapters/linear/renderer.go b/cli/internal/adapters/linear/renderer.go index d454cfc..35d12fd 100644 --- a/cli/internal/adapters/linear/renderer.go +++ b/cli/internal/adapters/linear/renderer.go @@ -180,21 +180,22 @@ func (r *Renderer) OnTaskComplete(spanID string, endTime time.Time, err error, c // Print completion message duration := endTime.Sub(task.startTime) + durationStr := formatDuration(duration) coloredPrefix := r.output.String(fmt.Sprintf("[%s]", task.name)).Foreground(task.color).String() switch { case err != nil: symbol := r.output.String("✗").Foreground(termenv.ANSIRed).String() - _, _ = fmt.Fprintf(r.stderr, "%s %s Failed after %v: %v\n", - coloredPrefix, symbol, duration, err) + _, _ = fmt.Fprintf(r.stderr, "%s %s Failed after %s: %v\n", + coloredPrefix, symbol, durationStr, err) case cached: symbol := r.output.String("⚡").Foreground(termenv.ANSIYellow).String() - _, _ = fmt.Fprintf(r.stderr, "%s %s Cached (skipped in %v)\n", - coloredPrefix, symbol, duration) + _, _ = fmt.Fprintf(r.stderr, "%s %s Cached (skipped in %s)\n", + coloredPrefix, symbol, durationStr) default: symbol := r.output.String("✓").Foreground(termenv.ANSIGreen).String() - _, _ = fmt.Fprintf(r.stderr, "%s %s Completed in %v\n", - coloredPrefix, symbol, duration) + _, _ = fmt.Fprintf(r.stderr, "%s %s Completed in %s\n", + coloredPrefix, symbol, durationStr) } // Cleanup @@ -232,3 +233,15 @@ func (r *Renderer) printLineLocked(taskName string, color termenv.Color, line [] prefix := r.output.String(fmt.Sprintf("[%s]", taskName)).Foreground(color).String() _, _ = fmt.Fprintf(r.stdout, "%s %s\n", prefix, string(line)) } + +// formatDuration formats a duration with appropriate units and 2 decimal precision. +func formatDuration(d time.Duration) string { + switch { + case d < time.Millisecond: + return fmt.Sprintf("%.2fµs", float64(d)/float64(time.Microsecond)) + case d < time.Second: + return fmt.Sprintf("%.2fms", float64(d)/float64(time.Millisecond)) + default: + return fmt.Sprintf("%.2fs", d.Seconds()) + } +}