Commit 1a7066e
Eric Bower
·
2026-08-16 09:45:42 -0400 EDT
parent 50d7d59
feat: reporter ui and plain
7 files changed,
+1704,
-211
M
main.go
+10,
-211
1@@ -16,13 +16,11 @@ import (
2 "log/slog"
3 "os"
4 "os/exec"
5- "os/signal"
6 "path/filepath"
7 "runtime"
8 "sort"
9 "strconv"
10 "strings"
11- "syscall"
12 "time"
13 )
14
15@@ -1027,7 +1025,7 @@ See: https://github.com/picosh/pici
16 fmt.Fprintln(os.Stdout, "ā
job launched") //nolint:errcheck
17
18 if cfg.Wait {
19- if err := waitAndReport(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
20+ if err := waitAndReportPlain(cfg, log, eventData.Name, jobID, eventData.Type); err != nil {
21 runErr = err
22 return fmt.Errorf("wait: %w", err)
23 }
24@@ -1043,210 +1041,12 @@ See: https://github.com/picosh/pici
25 }
26
27 // waitAndReport polls the job's sessions until all complete, prints live
28-// progress to stdout, then dumps session history and a final summary.
29+// interactive progress to stdout, updates stats.json, and outputs a final summary.
30 func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error {
31- domain := getDomain(eventType)
32- prefix := domain + "." + name + "." + jobID + "."
33- interval := cfg.MonitorInterval
34- if interval <= 0 {
35- interval = 5 * time.Second
36- }
37- ticker := time.NewTicker(interval)
38- defer ticker.Stop()
39-
40- // Handle ^C and termination signals gracefully
41- sigCh := make(chan os.Signal, 1)
42- signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
43- defer signal.Stop(sigCh)
44-
45- fmt.Fprintln(os.Stdout) //nolint:errcheck
46- fmt.Fprint(os.Stdout, "ā³ waiting for completion...\n") //nolint:errcheck
47-
48- // Track state for each session
49- known := make(map[string]*sessionState)
50- var jobSessions []SessionInfo
51- var sessionOrder []string // track insertion order for deterministic output
52- var liveLines []string // last set of status lines printed (for overwrite)
53-
54- var done <-chan struct{}
55- if cfg.Ctx != nil {
56- done = cfg.Ctx.Done()
57- }
58-
59- for {
60- select {
61- case <-done:
62- cancelJobSessions(prefix)
63- fmt.Fprintln(os.Stdout, "\nā¹ job cancelled") //nolint:errcheck
64- return cfg.Ctx.Err()
65- case <-sigCh:
66- cancelJobSessions(prefix)
67- fmt.Fprintln(os.Stdout, "\nā¹ job cancelled") //nolint:errcheck
68- return fmt.Errorf("job cancelled by signal")
69- case <-ticker.C:
70- }
71-
72- // Fetch current session list
73- listOutput, err := exec.Command("zmx", "list").CombinedOutput()
74- if err != nil {
75- log.Error("zmx list", "err", err)
76- continue
77- }
78-
79- sessions := parseZMXList(string(listOutput))
80- jobSessions = nil
81- for _, s := range sessions {
82- if strings.HasPrefix(s.Name, prefix) {
83- s.Short = cleanSessionShort(s.Name, prefix, name, jobID)
84- jobSessions = append(jobSessions, s)
85- }
86- }
87-
88- if len(jobSessions) == 0 {
89- continue // runner session may not have appeared yet
90- }
91-
92- // Update state for each session
93- for _, s := range jobSessions {
94- state, ok := known[s.Short]
95- if !ok {
96- state = &sessionState{}
97- known[s.Short] = state
98- sessionOrder = append(sessionOrder, s.Short)
99- }
100-
101- if s.Ended == "" {
102- state.status = "running"
103- created, _ := strconv.ParseInt(s.Created, 10, 64)
104- state.duration = fmtDurationTs(created, time.Now().Unix())
105- } else {
106- if s.ExitCode == "0" {
107- state.status = "success"
108- } else {
109- state.status = "failed"
110- state.exitCode = s.ExitCode
111- }
112- state.duration = fmtDuration(s.Created, s.Ended)
113- state.printed = true // lock final state
114- }
115- }
116-
117- // Build current status lines
118- lines := make([]string, 0, len(sessionOrder))
119- for _, short := range sessionOrder {
120- state := known[short]
121- icon := map[string]string{"running": "š", "success": "ā
", "failed": "ā"}[state.status]
122- detail := ""
123- if state.status == "failed" && state.exitCode != "" {
124- detail = fmt.Sprintf(", exit %s", state.exitCode)
125- }
126- if state.duration != "" && state.duration != "ā" {
127- detail += fmt.Sprintf(" (%s)", state.duration)
128- }
129- lines = append(lines, fmt.Sprintf(" %-12s %s %s%s", short, icon, state.status, detail))
130- }
131-
132- // Overwrite previous lines with cursor-up, or print fresh
133- if len(liveLines) > 0 {
134- // Move cursor up to overwrite previous lines //nolint:errcheck
135- for range len(liveLines) {
136- fmt.Fprint(os.Stdout, "\033[A") //nolint:errcheck
137- }
138- // Clear each line
139- for i, line := range lines { //nolint:errcheck
140- if i > 0 {
141- fmt.Fprint(os.Stdout, "\n") //nolint:errcheck
142- }
143- fmt.Fprint(os.Stdout, line+"\033[K") //nolint:errcheck
144- }
145- fmt.Fprint(os.Stdout, "\n") //nolint:errcheck
146- } else { //nolint:errcheck
147- for _, line := range lines {
148- fmt.Fprintln(os.Stdout, line) //nolint:errcheck
149- }
150- }
151- liveLines = lines
152-
153- // Stage local artifacts on tick so progress and HTML reports update live
154- _ = stageLocalArtifacts(cfg, log, name, jobID, eventType)
155-
156- // Check if all sessions are done and the runner session has finished
157- if isJobComplete(jobSessions) {
158- break
159- }
160- }
161-
162- // Print last 25 lines of history for failed sessions only
163- fmt.Fprintln(os.Stdout) //nolint:errcheck
164- for _, s := range jobSessions {
165- state := known[s.Short]
166- if state == nil || state.status != "failed" {
167- continue
168- }
169-
170- separator := strings.Repeat("\u2500", 50)
171- fmt.Fprintln(os.Stdout, separator) //nolint:errcheck
172- fmt.Fprintf(os.Stdout, "Session: %s (exit %s)\n", s.Short, state.exitCode) //nolint:errcheck
173- fmt.Fprintln(os.Stdout, separator) //nolint:errcheck
174- fmt.Fprintln(os.Stdout) //nolint:errcheck
175-
176- history, err := fetchHistoryPlain(s.Name)
177- if err != nil {
178- fmt.Fprintf(os.Stdout, " (history unavailable: %v)\n", err) //nolint:errcheck
179- } else {
180- lines := strings.Split(history, "\n")
181- // Show last 25 lines
182- if len(lines) > 25 {
183- fmt.Fprintf(os.Stdout, " ... (%d lines omitted)\n", len(lines)-25) //nolint:errcheck
184- lines = lines[len(lines)-25:]
185- }
186- for _, line := range lines {
187- if line != "" {
188- fmt.Fprintf(os.Stdout, " %s\n", line) //nolint:errcheck
189- }
190- }
191- }
192- fmt.Fprintln(os.Stdout) //nolint:errcheck
193- }
194-
195- // Final summary
196- exitCode, status := resolveJobExitCode(jobSessions)
197- _, _, duration := computeJobTiming(jobSessions)
198- icon := map[string]string{"success": "ā
", "failed": "ā"}[status]
199- if exitCode == 0 {
200- fmt.Fprintf(os.Stdout, "%s job finished: %s (%s)\n", icon, status, duration) //nolint:errcheck
201- } else {
202- fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration) //nolint:errcheck
203- }
204-
205- topic := fmt.Sprintf("%s.%s.%s", domain, name, jobID)
206- if exitCode != 0 && cfg.DebugOnFail {
207- cwd, _ := os.Getwd()
208- debugCfg := DebugBridgeConfig{
209- Topic: topic,
210- KeyLocation: cfg.KeyLocation,
211- CertLocation: cfg.CertificateLocation,
212- WorkDir: cwd,
213- Timeout: cfg.DebugTimeout,
214- Logger: log,
215- Stdout: os.Stdout,
216- }
217- if err := startDebugBridge(cfg.Ctx, debugCfg); err != nil {
218- log.Error("debug bridge failed", "err", err)
219- }
220- }
221-
222- if cfg.SummaryFile != "" {
223- if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known, cfg.DebugOnFail && exitCode != 0, topic); err != nil {
224- log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
225- }
226- }
227-
228- if exitCode != 0 {
229- return &JobFailedError{ExitCode: exitCode}
230- }
231-
232- return nil
233+ cwd, _ := os.Getwd()
234+ branch := detectGitBranch(cwd)
235+ commit := detectGitCommit(cwd)
236+ return waitAndReportUI(cfg, log, name, jobID, eventType, branch, commit)
237 }
238
239 func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState, debugActive bool, debugTopic string) error {
240@@ -1351,7 +1151,6 @@ type sessionState struct {
241 status string // "running", "success", "failed"
242 exitCode string
243 duration string
244- printed bool // true once final state (success/failed) is printed
245 }
246
247 // monitorJobState tracks display state for a single job across ticks.
248@@ -2630,14 +2429,14 @@ func runLocal(cfg *Cfg, dest string) error {
249 }
250 }()
251
252- fmt.Fprintf(os.Stdout, "š starting local job local.%s.%s\n", eventData.Name, jobID) //nolint:errcheck
253+ log.Info("starting local job", "job_id", jobID)
254 if err := eng.Setup(); err != nil {
255 return fmt.Errorf("workspace setup: %w", err)
256 }
257 if cfg.InPlace {
258- fmt.Fprintf(os.Stdout, "š using in-place workspace directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
259+ log.Debug("using in-place workspace directory", "dir", eng.Wk.GetDir())
260 } else {
261- fmt.Fprintf(os.Stdout, "š¦ syncing workspace to temp directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
262+ log.Debug("syncing workspace to temp directory", "dir", eng.Wk.GetDir())
263 }
264 log.Debug("workspace directory", "dir", eng.Wk.GetDir())
265
266@@ -2674,7 +2473,7 @@ func runLocal(cfg *Cfg, dest string) error {
267 return err
268 }
269
270- fmt.Fprintln(os.Stdout, "š launching sessions...") //nolint:errcheck
271+ log.Debug("launching sessions")
272 if err := eng.Run(manifest); err != nil {
273 return fmt.Errorf("run: %w", err)
274 }
+145,
-0
1@@ -0,0 +1,145 @@
2+package main
3+
4+import (
5+ "fmt"
6+ "log/slog"
7+ "os"
8+ "os/exec"
9+ "os/signal"
10+ "path/filepath"
11+ "strconv"
12+ "strings"
13+ "syscall"
14+ "time"
15+)
16+
17+// waitAndReportPlain provides clean, linear append-only output for background services
18+// like pici-runner.service, bypassing all TTY cursor overwrites and spinner loops.
19+func waitAndReportPlain(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) error {
20+ domain := getDomain(eventType)
21+ prefix := fmt.Sprintf("%s.%s.%s.", domain, name, jobID)
22+ interval := cfg.MonitorInterval
23+ if interval <= 0 {
24+ interval = 1 * time.Second
25+ }
26+ ticker := time.NewTicker(interval)
27+ defer ticker.Stop()
28+
29+ sigCh := make(chan os.Signal, 1)
30+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
31+ defer signal.Stop(sigCh)
32+
33+ startedSessions := make(map[string]bool)
34+ completedSessions := make(map[string]bool)
35+ var finalSessions []SessionInfo
36+
37+ var done <-chan struct{}
38+ if cfg.Ctx != nil {
39+ done = cfg.Ctx.Done()
40+ }
41+
42+ for {
43+ select {
44+ case <-done:
45+ cancelJobSessions(prefix)
46+ fmt.Fprintf(os.Stdout, "[%s] ā¹ runner cancelled\n", time.Now().Format("15:04:05")) //nolint:errcheck
47+ return cfg.Ctx.Err()
48+ case <-sigCh:
49+ cancelJobSessions(prefix)
50+ fmt.Fprintf(os.Stdout, "\n[%s] ā¹ runner terminated by signal\n", time.Now().Format("15:04:05")) //nolint:errcheck
51+ return fmt.Errorf("job cancelled by signal")
52+ case <-ticker.C:
53+ }
54+
55+ listOutput, err := exec.Command("zmx", "list").CombinedOutput()
56+ if err != nil {
57+ if log != nil {
58+ log.Error("zmx list", "err", err)
59+ }
60+ continue
61+ }
62+
63+ sessions := parseZMXList(string(listOutput))
64+ var jobSessions []SessionInfo
65+ for _, s := range sessions {
66+ if strings.HasPrefix(s.Name, prefix) {
67+ s.Short = cleanSessionShort(s.Name, prefix, name, jobID)
68+ jobSessions = append(jobSessions, s)
69+ }
70+ }
71+
72+ finalSessions = jobSessions
73+
74+ for _, s := range jobSessions {
75+ if s.Short == "runner" || s.Short == "" {
76+ continue
77+ }
78+
79+ if !startedSessions[s.Short] {
80+ startedSessions[s.Short] = true
81+ fmt.Fprintf(os.Stdout, "[%s] ā¶ [%s] started\n", time.Now().Format("15:04:05"), s.Short) //nolint:errcheck
82+ }
83+
84+ if s.Ended != "" && !completedSessions[s.Short] {
85+ completedSessions[s.Short] = true
86+ created, _ := strconv.ParseInt(s.Created, 10, 64)
87+ ended, _ := strconv.ParseInt(s.Ended, 10, 64)
88+ dur := time.Duration(ended-created) * time.Second
89+ if s.ExitCode == "0" {
90+ fmt.Fprintf(os.Stdout, "[%s] ā [%s] succeeded (%s)\n", time.Now().Format("15:04:05"), s.Short, formatShortDuration(dur)) //nolint:errcheck
91+ } else {
92+ fmt.Fprintf(os.Stdout, "[%s] ā [%s] failed with exit %s (%s)\n", time.Now().Format("15:04:05"), s.Short, s.ExitCode, formatShortDuration(dur)) //nolint:errcheck
93+ hist, err := fetchHistoryPlain(s.Name)
94+ if err == nil && hist != "" {
95+ errPreview := extractErrorPreview(hist, 5)
96+ for _, line := range errPreview {
97+ fmt.Fprintf(os.Stdout, " ā %s\n", line) //nolint:errcheck
98+ }
99+ }
100+ }
101+ }
102+ }
103+
104+ // Stage local artifacts
105+ _ = stageLocalArtifacts(cfg, log, name, jobID, eventType)
106+
107+ if isJobComplete(jobSessions) {
108+ break
109+ }
110+ }
111+
112+ exitCode, status := resolveJobExitCode(finalSessions)
113+ _, _, duration := computeJobTiming(finalSessions)
114+
115+ reportPath := filepath.Join(cfg.ArtifactDir, name, jobID, "index.html")
116+ if exitCode == 0 {
117+ fmt.Fprintf(os.Stdout, "[%s] ā job finished: %s (%s)\n", time.Now().Format("15:04:05"), status, duration) //nolint:errcheck
118+ } else {
119+ fmt.Fprintf(os.Stdout, "[%s] ā job failed: exit %d (%s)\n", time.Now().Format("15:04:05"), exitCode, duration) //nolint:errcheck
120+ }
121+ fmt.Fprintf(os.Stdout, "Artifacts: %s\n", reportPath) //nolint:errcheck
122+
123+ if cfg.SummaryFile != "" {
124+ knownStates := make(map[string]*sessionState)
125+ for _, s := range finalSessions {
126+ st := &sessionState{
127+ status: "failed",
128+ exitCode: s.ExitCode,
129+ }
130+ if s.ExitCode == "0" {
131+ st.status = "success"
132+ }
133+ knownStates[s.Short] = st
134+ }
135+ topic := fmt.Sprintf("%s.%s.%s", domain, name, jobID)
136+ if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, finalSessions, knownStates, cfg.DebugOnFail && exitCode != 0, topic); err != nil && log != nil {
137+ log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
138+ }
139+ }
140+
141+ if exitCode != 0 {
142+ return &JobFailedError{ExitCode: exitCode}
143+ }
144+
145+ return nil
146+}
+49,
-0
1@@ -0,0 +1,49 @@
2+package main
3+
4+import (
5+ "context"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "os/exec"
10+ "testing"
11+ "time"
12+)
13+
14+func TestWaitAndReportPlain_Cancellation(t *testing.T) {
15+ if _, err := exec.LookPath("zmx"); err != nil {
16+ t.Skip("zmx not found, skipping integration test")
17+ }
18+
19+ ctx, cancel := context.WithCancel(context.Background())
20+ cfg := &Cfg{
21+ Ctx: ctx,
22+ Cancel: cancel,
23+ MonitorInterval: 50 * time.Millisecond,
24+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
25+ ArtifactDir: t.TempDir(),
26+ }
27+
28+ jobID := fmt.Sprintf("plain-cancel-%d", time.Now().UnixNano())
29+ prefix := "local.testrepo." + jobID + "."
30+ runnerSession := prefix + "runner"
31+
32+ t.Cleanup(func() {
33+ _ = exec.Command("zmx", "kill", "-f", runnerSession).Run()
34+ })
35+
36+ cmd := exec.Command("zmx", "run", runnerSession, "-d", "sleep", "30")
37+ if err := cmd.Run(); err != nil {
38+ t.Fatalf("failed to start zmx session: %v", err)
39+ }
40+
41+ go func() {
42+ time.Sleep(100 * time.Millisecond)
43+ cancel()
44+ }()
45+
46+ err := waitAndReportPlain(cfg, nil, "testrepo", jobID, "local")
47+ if err == nil {
48+ t.Error("expected error when waitAndReportPlain is cancelled, got nil")
49+ }
50+}
+863,
-0
1@@ -0,0 +1,863 @@
2+package main
3+
4+import (
5+ "context"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "os"
10+ "os/exec"
11+ "os/signal"
12+ "path/filepath"
13+ "regexp"
14+ "sort"
15+ "strconv"
16+ "strings"
17+ "sync"
18+ "syscall"
19+ "time"
20+
21+ "golang.org/x/sys/unix"
22+)
23+
24+// ---------------------------------------------------------
25+// UI State Data Model
26+// ---------------------------------------------------------
27+
28+type TaskUIState struct {
29+ Name string
30+ Status string // "expected", "running", "success", "failed", "cancelled"
31+ Created int64
32+ Ended int64
33+ Duration time.Duration
34+ ExitCode string
35+ LastOutput string // Last non-empty line for running peek
36+ ErrorPreview []string // Captured error lines on failure
37+ PredictedDur time.Duration
38+ PrevStatus string
39+ PrevExitCode int
40+ FailureStreak int
41+ Transition string
42+}
43+
44+type PipelineUIState struct {
45+ Repo string
46+ Branch string
47+ Commit string
48+ JobID string
49+ StartedAt time.Time
50+ Elapsed time.Duration
51+ Tasks map[string]*TaskUIState
52+ TaskOrder []string
53+ PredictedTotal time.Duration
54+ IsComplete bool
55+ Cancelled bool
56+ Stats *RepoStats
57+ mu sync.Mutex
58+}
59+
60+var spinnerFrames = []rune{'ā ', 'ā ', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ', 'ā '}
61+
62+func newPipelineUIState(repo, branch, commit, jobID string, stats *RepoStats) *PipelineUIState {
63+ ui := &PipelineUIState{
64+ Repo: repo,
65+ Branch: branch,
66+ Commit: commit,
67+ JobID: jobID,
68+ StartedAt: time.Now(),
69+ Tasks: make(map[string]*TaskUIState),
70+ Stats: stats,
71+ }
72+
73+ if stats != nil {
74+ ui.PredictedTotal = time.Duration(stats.AvgWallDurationMs) * time.Millisecond
75+
76+ // Pre-populate expected tasks in deterministic order
77+ var names []string
78+ for name := range stats.Tasks {
79+ names = append(names, name)
80+ }
81+ sort.Strings(names)
82+
83+ for _, name := range names {
84+ ts := stats.Tasks[name]
85+ ui.Tasks[name] = &TaskUIState{
86+ Name: name,
87+ Status: "expected",
88+ PredictedDur: time.Duration(ts.AvgDurationMs) * time.Millisecond,
89+ PrevStatus: ts.LastStatus,
90+ PrevExitCode: ts.LastExitCode,
91+ FailureStreak: ts.FailureStreak,
92+ }
93+ ui.TaskOrder = append(ui.TaskOrder, name)
94+ }
95+ }
96+
97+ return ui
98+}
99+
100+// ---------------------------------------------------------
101+// UI Helpers & Formatters
102+// ---------------------------------------------------------
103+
104+var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\([a-zA-Z]`)
105+
106+func stripANSI(s string) string {
107+ return ansiRegex.ReplaceAllString(s, "")
108+}
109+
110+func getTerminalWidth() int {
111+ ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
112+ if err == nil && ws.Col > 0 {
113+ return int(ws.Col)
114+ }
115+ if cols := os.Getenv("COLUMNS"); cols != "" {
116+ if n, err := strconv.Atoi(cols); err == nil && n > 0 {
117+ return n
118+ }
119+ }
120+ return 80
121+}
122+
123+func truncateLine(s string, maxWidth int) string {
124+ if maxWidth <= 0 {
125+ maxWidth = 80
126+ }
127+ clean := stripANSI(s)
128+ runes := []rune(clean)
129+ if len(runes) <= maxWidth {
130+ return s
131+ }
132+ if maxWidth > 3 {
133+ return string(runes[:maxWidth-3]) + "..."
134+ }
135+ return string(runes[:maxWidth])
136+}
137+
138+func extractLastOutputLine(raw string) string {
139+ clean := stripANSI(raw)
140+ lines := strings.Split(clean, "\n")
141+ for i := len(lines) - 1; i >= 0; i-- {
142+ line := strings.TrimSpace(lines[i])
143+ if line != "" && !strings.HasPrefix(line, "step starting") && !strings.HasPrefix(line, "step completed") {
144+ return line
145+ }
146+ }
147+ return ""
148+}
149+
150+func extractErrorPreview(raw string, maxLines int) []string {
151+ clean := stripANSI(raw)
152+ lines := strings.Split(clean, "\n")
153+ var nonEmpties []string
154+ for _, l := range lines {
155+ trimmed := strings.TrimRight(l, "\r\n ")
156+ if trimmed != "" {
157+ nonEmpties = append(nonEmpties, trimmed)
158+ }
159+ }
160+ if len(nonEmpties) == 0 {
161+ return nil
162+ }
163+ if len(nonEmpties) > maxLines {
164+ nonEmpties = nonEmpties[len(nonEmpties)-maxLines:]
165+ }
166+ return nonEmpties
167+}
168+
169+func renderProgressBar(elapsed, predicted time.Duration, width int) (string, int) {
170+ if width <= 0 {
171+ width = 10
172+ }
173+ if predicted <= 0 {
174+ return "[" + strings.Repeat("ā", width) + "]", 100
175+ }
176+
177+ pct := int((float64(elapsed) / float64(predicted)) * 100)
178+ displayPct := pct
179+ if displayPct > 100 {
180+ displayPct = 100
181+ }
182+ if displayPct < 0 {
183+ displayPct = 0
184+ }
185+
186+ filled := (displayPct * width) / 100
187+ unfilled := width - filled
188+ if unfilled < 0 {
189+ unfilled = 0
190+ }
191+ bar := "[" + strings.Repeat("ā", filled) + strings.Repeat("ā", unfilled) + "]"
192+ return bar, displayPct
193+}
194+
195+func renderErrorPreviewBox(lines []string, width int) string {
196+ if len(lines) == 0 {
197+ return ""
198+ }
199+ if width < 30 {
200+ width = 60
201+ }
202+
203+ var sb strings.Builder
204+ headerTitle := "āā [error preview] "
205+ headerLine := headerTitle + strings.Repeat("ā", max(0, width-len(headerTitle)))
206+ sb.WriteString(" " + headerLine + "\n")
207+
208+ for _, line := range lines {
209+ if len(line) > width-4 {
210+ line = line[:width-7] + "..."
211+ }
212+ fmt.Fprintf(&sb, " ā %s\n", line)
213+ }
214+
215+ footer := "ā" + strings.Repeat("ā", max(0, width-1))
216+ sb.WriteString(" " + footer)
217+ return sb.String()
218+}
219+
220+func formatShortDuration(d time.Duration) string {
221+ secs := d.Seconds()
222+ if secs < 0 {
223+ secs = 0
224+ }
225+ if secs >= 60 {
226+ m := int(secs) / 60
227+ s := int(secs) % 60
228+ return fmt.Sprintf("%dm%ds", m, s)
229+ }
230+ if secs >= 10 {
231+ return fmt.Sprintf("%.1fs", secs)
232+ }
233+ return fmt.Sprintf("%.1fs", secs)
234+}
235+
236+// ---------------------------------------------------------
237+// Full Interactive UI State Rendering
238+// ---------------------------------------------------------
239+
240+func (ui *PipelineUIState) Render(frame int) string {
241+ ui.mu.Lock()
242+ defer ui.mu.Unlock()
243+
244+ var sb strings.Builder
245+ commitShort := ui.Commit
246+ if len(commitShort) > 7 {
247+ commitShort = commitShort[:7]
248+ }
249+ if commitShort == "" {
250+ commitShort = "HEAD"
251+ }
252+ branch := ui.Branch
253+ if branch == "" {
254+ branch = "local"
255+ }
256+
257+ // Header line 1: repo info
258+ fmt.Fprintf(&sb, "š pici: %s (branch: %s ⢠%s)\n", ui.Repo, branch, commitShort)
259+
260+ // Calculate counts
261+ totalTasks := len(ui.TaskOrder)
262+ completedCount := 0
263+ failedCount := 0
264+ for _, name := range ui.TaskOrder {
265+ task := ui.Tasks[name]
266+ if task.Status == "success" || task.Status == "failed" {
267+ completedCount++
268+ }
269+ if task.Status == "failed" {
270+ failedCount++
271+ }
272+ }
273+
274+ elapsed := ui.Elapsed
275+ if elapsed == 0 {
276+ elapsed = time.Since(ui.StartedAt)
277+ }
278+
279+ // Header line 2: timing & status
280+ fmt.Fprintf(&sb, "ā± Running for %s [ ", formatShortDuration(elapsed))
281+ if failedCount > 0 {
282+ fmt.Fprintf(&sb, "%d/%d completed ⢠%d failed ]\n", completedCount, totalTasks, failedCount)
283+ } else if completedCount == 0 {
284+ estTotal := "~" + formatShortDuration(ui.PredictedTotal)
285+ if ui.PredictedTotal == 0 {
286+ estTotal = "estimating"
287+ }
288+ fmt.Fprintf(&sb, "0/%d tasks ⢠est. %s ]\n", totalTasks, estTotal)
289+ } else {
290+ rem := ui.PredictedTotal - elapsed
291+ if rem < 0 {
292+ rem = 0
293+ }
294+ fmt.Fprintf(&sb, "%d/%d completed ⢠est. ~%s remaining ]\n", completedCount, totalTasks, formatShortDuration(rem))
295+ }
296+
297+ sb.WriteString("\n")
298+
299+ spinnerRune := spinnerFrames[frame%len(spinnerFrames)]
300+
301+ // Render task list
302+ for _, name := range ui.TaskOrder {
303+ task := ui.Tasks[name]
304+ switch task.Status {
305+ case "expected":
306+ pred := ""
307+ if task.PredictedDur > 0 {
308+ pred = fmt.Sprintf("~%s", formatShortDuration(task.PredictedDur))
309+ }
310+ notes := ""
311+ if task.PrevStatus == "failed" {
312+ notes = " ⢠ā ļø failed last run"
313+ }
314+ detail := ""
315+ if pred != "" || notes != "" {
316+ detail = fmt.Sprintf("(expected %s%s)", pred, notes)
317+ }
318+ fmt.Fprintf(&sb, " ā %-8s %s\n", task.Name, detail)
319+
320+ case "running":
321+ taskDur := task.Duration
322+ if taskDur == 0 && task.Created > 0 {
323+ taskDur = time.Duration(time.Now().Unix()-task.Created) * time.Second
324+ }
325+ predStr := ""
326+ var bar string
327+ var pct int
328+ if task.PredictedDur > 0 {
329+ predStr = fmt.Sprintf("/ ~%s", formatShortDuration(task.PredictedDur))
330+ bar, pct = renderProgressBar(taskDur, task.PredictedDur, 10)
331+ }
332+ extraNote := ""
333+ if task.PrevStatus == "failed" {
334+ extraNote = " (retesting previous failure)"
335+ }
336+
337+ barPart := ""
338+ if bar != "" {
339+ barPart = fmt.Sprintf(" %s %d%%%s", bar, pct, extraNote)
340+ }
341+
342+ fmt.Fprintf(&sb, " %c %-8s %s %s%s\n", spinnerRune, task.Name, formatShortDuration(taskDur), predStr, barPart)
343+ if task.LastOutput != "" {
344+ fmt.Fprintf(&sb, " āā %s\n", task.LastOutput)
345+ }
346+
347+ case "success":
348+ transitionNote := ""
349+ if task.Transition == "fixed" {
350+ transitionNote = " ⢠š fixed!"
351+ }
352+ durStr := formatShortDuration(task.Duration)
353+ if transitionNote != "" {
354+ fmt.Fprintf(&sb, " ā %-8s %s (%s)\n", task.Name, durStr, strings.TrimPrefix(transitionNote, " ⢠"))
355+ } else {
356+ fmt.Fprintf(&sb, " ā %-8s %s\n", task.Name, durStr)
357+ }
358+
359+ case "failed":
360+ notes := ""
361+ switch task.Transition {
362+ case "still_failing":
363+ notes = " ⢠ā ļø still failing"
364+ case "new_failure":
365+ notes = " ⢠ā new failure"
366+ }
367+ exitStr := "exit 1"
368+ if task.ExitCode != "" {
369+ exitStr = "exit " + task.ExitCode
370+ }
371+ fmt.Fprintf(&sb, " ā %-8s %s (%s%s)\n", task.Name, formatShortDuration(task.Duration), exitStr, notes)
372+ if len(task.ErrorPreview) > 0 {
373+ sb.WriteString(renderErrorPreviewBox(task.ErrorPreview, 65))
374+ sb.WriteString("\n")
375+ }
376+
377+ case "cancelled":
378+ fmt.Fprintf(&sb, " ā %-8s cancelled\n", task.Name)
379+ }
380+ }
381+
382+ return sb.String()
383+}
384+
385+// RenderSummary renders the post-run completion summary.
386+func (ui *PipelineUIState) RenderSummary(reportPath string, exitCode int) string {
387+ ui.mu.Lock()
388+ defer ui.mu.Unlock()
389+
390+ var sb strings.Builder
391+ totalTasks := len(ui.TaskOrder)
392+ failedCount := 0
393+ var failedTasks []*TaskUIState
394+
395+ for _, name := range ui.TaskOrder {
396+ task := ui.Tasks[name]
397+ if task.Status == "failed" {
398+ failedCount++
399+ failedTasks = append(failedTasks, task)
400+ }
401+ }
402+
403+ if exitCode != 0 && failedCount == 0 {
404+ failedCount = 1
405+ }
406+
407+ elapsed := ui.Elapsed
408+ if elapsed == 0 {
409+ elapsed = time.Since(ui.StartedAt)
410+ }
411+ wallStr := formatShortDuration(elapsed)
412+
413+ if exitCode == 0 && failedCount == 0 {
414+ fmt.Fprintf(&sb, "ā All %d tasks succeeded (total time: %s)\n\n", totalTasks, wallStr)
415+ for _, name := range ui.TaskOrder {
416+ task := ui.Tasks[name]
417+ avgStr := ""
418+ if task.PredictedDur > 0 {
419+ avgStr = fmt.Sprintf("(avg %s", formatShortDuration(task.PredictedDur))
420+ if task.Transition == "fixed" {
421+ avgStr += " ⢠š fixed!)"
422+ } else {
423+ avgStr += ")"
424+ }
425+ }
426+ fmt.Fprintf(&sb, " ā %-9s %6s %s\n", task.Name, formatShortDuration(task.Duration), avgStr)
427+ }
428+ } else {
429+ taskWord := "task"
430+ if failedCount > 1 {
431+ taskWord = "tasks"
432+ }
433+ fmt.Fprintf(&sb, "ā %d %s failed (total time: %s)\n\n", failedCount, taskWord, wallStr)
434+ for _, name := range ui.TaskOrder {
435+ task := ui.Tasks[name]
436+ switch task.Status {
437+ case "success":
438+ fmt.Fprintf(&sb, " ā %-9s %6s\n", task.Name, formatShortDuration(task.Duration))
439+ case "failed":
440+ exitStr := "exit " + task.ExitCode
441+ if task.ExitCode == "" {
442+ exitStr = "exit 1"
443+ }
444+ fmt.Fprintf(&sb, " ā %-9s %6s %s (see error preview above)\n", task.Name, formatShortDuration(task.Duration), exitStr)
445+ default:
446+ fmt.Fprintf(&sb, " ā %-9s cancelled\n", task.Name)
447+ }
448+ }
449+ }
450+
451+ sb.WriteString("\nArtifacts:\n")
452+ if reportPath != "" {
453+ fmt.Fprintf(&sb, " š Report: %s\n", reportPath)
454+ }
455+ if failedCount > 0 {
456+ if len(failedTasks) > 0 {
457+ for _, ft := range failedTasks {
458+ fmt.Fprintf(&sb, " š Debug: zmx attach local.%s.%s.step.%s\n", ui.Repo, ui.JobID, ft.Name)
459+ }
460+ } else {
461+ fmt.Fprintf(&sb, " š Debug: zmx attach local.%s.%s.runner\n", ui.Repo, ui.JobID)
462+ }
463+ }
464+
465+ return sb.String()
466+}
467+
468+// ---------------------------------------------------------
469+// Non-TTY Plain Stream Reporter
470+// ---------------------------------------------------------
471+
472+type plainStreamReporter struct {
473+ out io.Writer
474+ ui *PipelineUIState
475+ log *slog.Logger
476+}
477+
478+func (p *plainStreamReporter) OnTaskStart(name string) {
479+ fmt.Fprintf(p.out, "ā¶ starting %s\n", name) //nolint:errcheck
480+}
481+
482+func (p *plainStreamReporter) OnTaskOutput(name, line string) {
483+ if line != "" {
484+ fmt.Fprintf(p.out, " [%s] %s\n", name, line) //nolint:errcheck
485+ }
486+}
487+
488+func (p *plainStreamReporter) OnTaskComplete(name string, exitCode int, dur time.Duration, errorLines []string) {
489+ durStr := formatShortDuration(dur)
490+ if exitCode == 0 {
491+ fmt.Fprintf(p.out, "ā %s succeeded (%s)\n", name, durStr) //nolint:errcheck
492+ } else {
493+ fmt.Fprintf(p.out, "ā %s failed with exit %d (%s)\n", name, exitCode, durStr) //nolint:errcheck
494+ if len(errorLines) > 0 {
495+ for _, line := range errorLines {
496+ fmt.Fprintf(p.out, " ā %s\n", line) //nolint:errcheck
497+ }
498+ }
499+ }
500+}
501+
502+// ---------------------------------------------------------
503+// Interactive Controller Loop
504+// ---------------------------------------------------------
505+
506+type interactiveController struct {
507+ ctx context.Context
508+ out io.Writer
509+ ui *PipelineUIState
510+ isTTY bool
511+ rendered int // number of lines rendered in previous frame for cursor overwrite
512+ streamer *plainStreamReporter
513+}
514+
515+func newInteractiveController(ctx context.Context, out io.Writer, repo, branch, commit, jobID string, stats *RepoStats, isTTY bool) *interactiveController {
516+ if ctx == nil {
517+ ctx = context.Background()
518+ }
519+ ui := newPipelineUIState(repo, branch, commit, jobID, stats)
520+ return &interactiveController{
521+ ctx: ctx,
522+ out: out,
523+ ui: ui,
524+ isTTY: isTTY,
525+ streamer: &plainStreamReporter{out: out, ui: ui},
526+ }
527+}
528+
529+func (c *interactiveController) WaitUntilDone(fetchSessions func() ([]SessionInfo, bool)) error {
530+ sigCh := make(chan os.Signal, 1)
531+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
532+ defer signal.Stop(sigCh)
533+
534+ ticker := time.NewTicker(100 * time.Millisecond)
535+ defer ticker.Stop()
536+
537+ frame := 0
538+
539+ // Initial render
540+ c.redraw(c.ui.Render(frame))
541+
542+ prevStatus := make(map[string]string)
543+ prevOutput := make(map[string]string)
544+
545+ for {
546+ select {
547+ case <-c.ctx.Done():
548+ c.ui.Cancelled = true
549+ c.clear()
550+ fmt.Fprintln(c.out, "\nā¹ cancelling job and terminating active sessions...") //nolint:errcheck
551+ return c.ctx.Err()
552+
553+ case <-sigCh:
554+ c.ui.Cancelled = true
555+ c.clear()
556+ fmt.Fprintln(c.out, "\n^C") //nolint:errcheck
557+ fmt.Fprintln(c.out, "ā¹ cancelling job and terminating active sessions...") //nolint:errcheck
558+ return fmt.Errorf("job cancelled by signal")
559+
560+ case <-ticker.C:
561+ frame++
562+ sessions, isComplete := fetchSessions()
563+
564+ // Update UI state with current sessions
565+ c.ui.mu.Lock()
566+ for _, s := range sessions {
567+ name := s.Short
568+ if name == "runner" || name == "" {
569+ continue
570+ }
571+
572+ task, exists := c.ui.Tasks[name]
573+ if !exists {
574+ task = &TaskUIState{
575+ Name: name,
576+ Status: "running",
577+ }
578+ c.ui.Tasks[name] = task
579+ c.ui.TaskOrder = append(c.ui.TaskOrder, name)
580+ }
581+
582+ created, _ := strconv.ParseInt(s.Created, 10, 64)
583+ ended, _ := strconv.ParseInt(s.Ended, 10, 64)
584+ task.Created = created
585+ task.Ended = ended
586+
587+ oldState := prevStatus[name]
588+ if s.Ended == "" {
589+ task.Status = "running"
590+ if created > 0 {
591+ task.Duration = time.Duration(time.Now().Unix()-created) * time.Second
592+ }
593+ if !c.isTTY && (oldState == "" || oldState == "expected") {
594+ c.streamer.OnTaskStart(name)
595+ }
596+ if !c.isTTY && task.LastOutput != "" && task.LastOutput != prevOutput[name] {
597+ c.streamer.OnTaskOutput(name, task.LastOutput)
598+ prevOutput[name] = task.LastOutput
599+ }
600+ } else {
601+ task.ExitCode = s.ExitCode
602+ exitCodeInt, _ := strconv.Atoi(s.ExitCode)
603+ if s.ExitCode == "0" {
604+ task.Status = "success"
605+ } else {
606+ task.Status = "failed"
607+ }
608+ if created > 0 && ended > 0 {
609+ task.Duration = time.Duration(ended-created) * time.Second
610+ }
611+ if task.Transition == "" && c.ui.Stats != nil {
612+ task.Transition = computeOutcomeTransition(c.ui.Stats.Tasks[name], exitCodeInt)
613+ }
614+ if !c.isTTY && oldState != task.Status {
615+ c.streamer.OnTaskComplete(name, exitCodeInt, task.Duration, task.ErrorPreview)
616+ }
617+ }
618+ prevStatus[name] = task.Status
619+ }
620+ c.ui.mu.Unlock()
621+
622+ c.redraw(c.ui.Render(frame))
623+
624+ if isComplete {
625+ c.ui.Elapsed = time.Since(c.ui.StartedAt)
626+ c.ui.IsComplete = true
627+ c.clear()
628+ return nil
629+ }
630+ }
631+ }
632+}
633+
634+func (c *interactiveController) redraw(outStr string) {
635+ if !c.isTTY {
636+ return
637+ }
638+ trimmed := strings.TrimRight(outStr, "\n")
639+ rawLines := strings.Split(trimmed, "\n")
640+ if len(rawLines) == 0 || (len(rawLines) == 1 && rawLines[0] == "") {
641+ return
642+ }
643+
644+ termWidth := getTerminalWidth()
645+ lines := make([]string, len(rawLines))
646+ for i, l := range rawLines {
647+ lines[i] = truncateLine(l, termWidth-1)
648+ }
649+
650+ var b strings.Builder
651+
652+ // Move cursor up from the bottom of previous render block to the top line
653+ if c.rendered > 0 {
654+ fmt.Fprintf(&b, "\r\033[%dA", c.rendered)
655+ }
656+
657+ // Print each line, clearing to the end of the line and resetting carriage
658+ for i, line := range lines {
659+ b.WriteString("\r\033[2K") // clear entire line
660+ b.WriteString(line)
661+ if i < len(lines)-1 {
662+ b.WriteString("\n")
663+ }
664+ }
665+
666+ // If the previous frame had more lines, clear the leftover trailing lines
667+ if c.rendered > len(lines)-1 {
668+ extra := c.rendered - (len(lines) - 1)
669+ for i := 0; i < extra; i++ {
670+ b.WriteString("\n\r\033[2K")
671+ }
672+ fmt.Fprintf(&b, "\r\033[%dA", extra)
673+ }
674+
675+ c.rendered = len(lines) - 1
676+ fmt.Fprint(c.out, b.String()) //nolint:errcheck
677+}
678+
679+func (c *interactiveController) clear() {
680+ if !c.isTTY || c.rendered < 0 {
681+ return
682+ }
683+ var b strings.Builder
684+ if c.rendered > 0 {
685+ fmt.Fprintf(&b, "\r\033[%dA", c.rendered)
686+ }
687+ for i := 0; i <= c.rendered; i++ {
688+ b.WriteString("\r\033[2K")
689+ if i < c.rendered {
690+ b.WriteString("\n")
691+ }
692+ }
693+ if c.rendered > 0 {
694+ fmt.Fprintf(&b, "\r\033[%dA", c.rendered)
695+ }
696+ c.rendered = 0
697+ fmt.Fprint(c.out, b.String()) //nolint:errcheck
698+}
699+
700+// ---------------------------------------------------------
701+// Interactive waitAndReport Entrypoint
702+// ---------------------------------------------------------
703+
704+func isTerminal(w io.Writer) bool {
705+ if os.Getenv("CI") != "" || os.Getenv("TERM") == "dumb" {
706+ return false
707+ }
708+ if f, ok := w.(*os.File); ok {
709+ stat, err := f.Stat()
710+ if err == nil && (stat.Mode()&os.ModeCharDevice) != 0 {
711+ return true
712+ }
713+ }
714+ return false
715+}
716+
717+// waitAndReportUI executes the rich interactive reporting loop, updating stats.json.
718+func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, eventType, branch, commit string) error {
719+ domain := getDomain(eventType)
720+ prefix := fmt.Sprintf("%s.%s.%s.", domain, repoName, jobID)
721+
722+ stats, _ := loadRepoStats(repoName)
723+ isTTY := isTerminal(os.Stdout)
724+
725+ ctrl := newInteractiveController(cfg.Ctx, os.Stdout, repoName, branch, commit, jobID, stats, isTTY)
726+
727+ lastStage := time.Now()
728+
729+ // Session poller & peek fetcher
730+ fetchSessions := func() ([]SessionInfo, bool) {
731+ listOutput, err := exec.Command("zmx", "list").CombinedOutput()
732+ if err != nil {
733+ if log != nil {
734+ log.Error("zmx list", "err", err)
735+ }
736+ return nil, false
737+ }
738+
739+ sessions := parseZMXList(string(listOutput))
740+ var jobSessions []SessionInfo
741+ for _, s := range sessions {
742+ if strings.HasPrefix(s.Name, prefix) {
743+ s.Short = cleanSessionShort(s.Name, prefix, repoName, jobID)
744+ jobSessions = append(jobSessions, s)
745+ }
746+ }
747+
748+ // Update tail history for live activity and error peeks
749+ for _, s := range jobSessions {
750+ if s.Short == "runner" || s.Short == "" {
751+ continue
752+ }
753+ hist, err := fetchHistoryPlain(s.Name)
754+ if err == nil && hist != "" {
755+ ctrl.ui.mu.Lock()
756+ if task, ok := ctrl.ui.Tasks[s.Short]; ok {
757+ if s.Ended == "" {
758+ task.LastOutput = extractLastOutputLine(hist)
759+ } else if s.ExitCode != "0" && len(task.ErrorPreview) == 0 {
760+ task.ErrorPreview = extractErrorPreview(hist, 5)
761+ }
762+ }
763+ ctrl.ui.mu.Unlock()
764+ }
765+ }
766+
767+ // Stage local artifacts periodically (every 2 seconds) so reports stay updated live without disk trashing
768+ if time.Since(lastStage) >= 2*time.Second {
769+ _ = stageLocalArtifacts(cfg, log, repoName, jobID, eventType)
770+ lastStage = time.Now()
771+ }
772+
773+ return jobSessions, isJobComplete(jobSessions)
774+ }
775+
776+ waitErr := ctrl.WaitUntilDone(fetchSessions)
777+ if waitErr != nil {
778+ cancelJobSessions(prefix)
779+ if ctrl.ui.Cancelled {
780+ for _, task := range ctrl.ui.Tasks {
781+ if task.Status == "running" {
782+ task.Status = "cancelled"
783+ }
784+ }
785+ fmt.Fprintln(os.Stdout, "ā¹ job cancelled") //nolint:errcheck
786+ }
787+ return waitErr
788+ }
789+
790+ // Fetch final session list
791+ var finalSessions []SessionInfo
792+ if listOutput, err := exec.Command("zmx", "list").CombinedOutput(); err == nil {
793+ for _, s := range parseZMXList(string(listOutput)) {
794+ if strings.HasPrefix(s.Name, prefix) {
795+ s.Short = cleanSessionShort(s.Name, prefix, repoName, jobID)
796+ finalSessions = append(finalSessions, s)
797+ }
798+ }
799+ }
800+
801+ exitCode, overallStatus := resolveJobExitCode(finalSessions)
802+
803+ // Update historical stats.json
804+ if stats != nil {
805+ for _, s := range finalSessions {
806+ if s.Short == "runner" || s.Short == "" {
807+ continue
808+ }
809+ created, _ := strconv.ParseInt(s.Created, 10, 64)
810+ ended, _ := strconv.ParseInt(s.Ended, 10, 64)
811+ dur := time.Duration(ended-created) * time.Second
812+ code, _ := strconv.Atoi(s.ExitCode)
813+ updateTaskStats(stats, s.Short, dur, code)
814+ }
815+ updateRepoStats(stats, ctrl.ui.Elapsed, overallStatus)
816+ _ = saveRepoStats(stats)
817+ }
818+
819+ // Output summary
820+ reportPath := filepath.Join(cfg.ArtifactDir, repoName, jobID, "index.html")
821+ fmt.Fprintln(os.Stdout) //nolint:errcheck
822+ fmt.Fprint(os.Stdout, ctrl.ui.RenderSummary(reportPath, exitCode)) //nolint:errcheck
823+
824+ // Debug bridge if failed
825+ topic := fmt.Sprintf("%s.%s.%s", domain, repoName, jobID)
826+ if exitCode != 0 && cfg.DebugOnFail {
827+ cwd, _ := os.Getwd()
828+ debugCfg := DebugBridgeConfig{
829+ Topic: topic,
830+ KeyLocation: cfg.KeyLocation,
831+ CertLocation: cfg.CertificateLocation,
832+ WorkDir: cwd,
833+ Timeout: cfg.DebugTimeout,
834+ Logger: log,
835+ Stdout: os.Stdout,
836+ }
837+ if err := startDebugBridge(cfg.Ctx, debugCfg); err != nil && log != nil {
838+ log.Error("debug bridge failed", "err", err)
839+ }
840+ }
841+
842+ if cfg.SummaryFile != "" {
843+ knownStates := make(map[string]*sessionState)
844+ for _, s := range finalSessions {
845+ st := &sessionState{
846+ status: "failed",
847+ exitCode: s.ExitCode,
848+ }
849+ if s.ExitCode == "0" {
850+ st.status = "success"
851+ }
852+ knownStates[s.Short] = st
853+ }
854+ if err := writeSummaryMarkdown(cfg.SummaryFile, repoName, jobID, finalSessions, knownStates, cfg.DebugOnFail && exitCode != 0, topic); err != nil && log != nil {
855+ log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
856+ }
857+ }
858+
859+ if exitCode != 0 {
860+ return &JobFailedError{ExitCode: exitCode}
861+ }
862+
863+ return nil
864+}
+334,
-0
1@@ -0,0 +1,334 @@
2+package main
3+
4+import (
5+ "bytes"
6+ "context"
7+ "strings"
8+ "testing"
9+ "time"
10+)
11+
12+func TestUI_ForecastStateInitialization(t *testing.T) {
13+ stats := &RepoStats{
14+ Repo: "my-repo",
15+ AvgWallDurationMs: 14200,
16+ Tasks: map[string]*TaskStats{
17+ "fmt": {
18+ AvgDurationMs: 850,
19+ LastStatus: "success",
20+ LastExitCode: 0,
21+ },
22+ "lint": {
23+ AvgDurationMs: 4200,
24+ LastStatus: "failed",
25+ LastExitCode: 1,
26+ FailureStreak: 1,
27+ },
28+ "test": {
29+ AvgDurationMs: 13500,
30+ LastStatus: "success",
31+ LastExitCode: 0,
32+ },
33+ },
34+ }
35+
36+ ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
37+
38+ if len(ui.Tasks) != 3 {
39+ t.Fatalf("expected 3 tasks, got %d", len(ui.Tasks))
40+ }
41+ if ui.Tasks["fmt"].Status != "expected" {
42+ t.Errorf("expected fmt status to be expected, got %s", ui.Tasks["fmt"].Status)
43+ }
44+ if ui.Tasks["lint"].PrevStatus != "failed" {
45+ t.Errorf("expected lint prev status failed, got %s", ui.Tasks["lint"].PrevStatus)
46+ }
47+ if ui.Tasks["lint"].PredictedDur != 4200*time.Millisecond {
48+ t.Errorf("expected predicted duration 4.2s, got %v", ui.Tasks["lint"].PredictedDur)
49+ }
50+}
51+
52+func TestUI_RenderProgressBar(t *testing.T) {
53+ tests := []struct {
54+ elapsed time.Duration
55+ predicted time.Duration
56+ width int
57+ wantBar string
58+ wantPct int
59+ }{
60+ {0, 10 * time.Second, 10, "[āāāāāāāāāā]", 0},
61+ {5 * time.Second, 10 * time.Second, 10, "[āāāāāāāāāā]", 50},
62+ {10 * time.Second, 10 * time.Second, 10, "[āāāāāāāāāā]", 100},
63+ {15 * time.Second, 10 * time.Second, 10, "[āāāāāāāāāā]", 100}, // clamped to 100%
64+ {1 * time.Second, 0, 10, "[āāāāāāāāāā]", 100}, // zero predicted fallback
65+ }
66+
67+ for _, tt := range tests {
68+ bar, pct := renderProgressBar(tt.elapsed, tt.predicted, tt.width)
69+ if bar != tt.wantBar || pct != tt.wantPct {
70+ t.Errorf("renderProgressBar(%v, %v, %d) = (%s, %d), want (%s, %d)",
71+ tt.elapsed, tt.predicted, tt.width, bar, pct, tt.wantBar, tt.wantPct)
72+ }
73+ }
74+}
75+
76+func TestUI_EarlyFailurePeekBox(t *testing.T) {
77+ lines := []string{
78+ "cmd/server/main.go:58:2: errcheck: unchecked error",
79+ "api/v1/routes.go:19:1: revive: exported function missing doc",
80+ }
81+
82+ box := renderErrorPreviewBox(lines, 60)
83+ if !strings.Contains(box, "āā [error preview]") {
84+ t.Errorf("expected box header, got:\n%s", box)
85+ }
86+ if !strings.Contains(box, "ā cmd/server/main.go:58:2: errcheck: unchecked error") {
87+ t.Errorf("expected line 1 in box, got:\n%s", box)
88+ }
89+ if !strings.Contains(box, "ā") {
90+ t.Errorf("expected box footer, got:\n%s", box)
91+ }
92+}
93+
94+func TestUI_LiveActivityPeekExtraction(t *testing.T) {
95+ rawHistory := "step starting\n\n \x1b[32mrunning golangci-lint on 28 packages...\x1b[0m\n\n"
96+ peek := extractLastOutputLine(rawHistory)
97+ if peek != "running golangci-lint on 28 packages..." {
98+ t.Errorf("expected clean peek line, got '%s'", peek)
99+ }
100+
101+ emptyHistory := " \n\n\x1b[0m\n"
102+ peekEmpty := extractLastOutputLine(emptyHistory)
103+ if peekEmpty != "" {
104+ t.Errorf("expected empty peek, got '%s'", peekEmpty)
105+ }
106+}
107+
108+func TestUI_RenderStateTransitions(t *testing.T) {
109+ stats := &RepoStats{
110+ Repo: "my-repo",
111+ AvgWallDurationMs: 14000,
112+ Tasks: map[string]*TaskStats{
113+ "fmt": {AvgDurationMs: 850, LastStatus: "success", LastExitCode: 0},
114+ "lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1},
115+ "test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0},
116+ },
117+ }
118+
119+ ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
120+
121+ // State 1: Forecast
122+ render1 := ui.Render(0)
123+ if !strings.Contains(render1, "ā lint") || !strings.Contains(render1, "failed last run") {
124+ t.Errorf("expected State 1 forecast output, got:\n%s", render1)
125+ }
126+
127+ // State 2: Running fmt and lint
128+ ui.Tasks["fmt"].Status = "running"
129+ ui.Tasks["fmt"].Duration = 400 * time.Millisecond
130+ ui.Tasks["lint"].Status = "running"
131+ ui.Tasks["lint"].Duration = 5400 * time.Millisecond
132+ ui.Tasks["lint"].LastOutput = "running golangci-lint on 28 packages..."
133+
134+ render2 := ui.Render(0)
135+ if !strings.Contains(render2, "fmt") || !strings.Contains(render2, "lint") {
136+ t.Errorf("expected State 2 running tasks, got:\n%s", render2)
137+ }
138+ if !strings.Contains(render2, "āā running golangci-lint on 28 packages...") {
139+ t.Errorf("expected live activity peek under lint, got:\n%s", render2)
140+ }
141+
142+ // State 3: Failure with error preview
143+ ui.Tasks["fmt"].Status = "success"
144+ ui.Tasks["fmt"].Duration = 800 * time.Millisecond
145+ ui.Tasks["lint"].Status = "failed"
146+ ui.Tasks["lint"].ExitCode = "1"
147+ ui.Tasks["lint"].Duration = 4200 * time.Millisecond
148+ ui.Tasks["lint"].Transition = "still_failing"
149+ ui.Tasks["lint"].ErrorPreview = []string{
150+ "cmd/server/main.go:58:2: errcheck: unchecked error",
151+ "api/v1/routes.go:19:1: revive: exported function missing doc",
152+ }
153+
154+ render3 := ui.Render(0)
155+ if !strings.Contains(render3, "lint") || !strings.Contains(render3, "still failing") {
156+ t.Errorf("expected State 3 failure indicator, got:\n%s", render3)
157+ }
158+ if !strings.Contains(render3, "cmd/server/main.go:58:2") {
159+ t.Errorf("expected inline error preview box, got:\n%s", render3)
160+ }
161+
162+ // State 5: Completion summary (success vs failure)
163+ summary := ui.RenderSummary("/tmp/pici-artifacts/my-repo/job123/index.html", 1)
164+ if !strings.Contains(summary, "1 task failed") {
165+ t.Errorf("expected failure summary, got:\n%s", summary)
166+ }
167+ if !strings.Contains(summary, "Report: /tmp/pici-artifacts/my-repo/job123/index.html") {
168+ t.Errorf("expected report path in summary, got:\n%s", summary)
169+ }
170+}
171+
172+func TestUI_RenderSummary_SuccessWithFixed(t *testing.T) {
173+ stats := &RepoStats{
174+ Repo: "my-repo",
175+ Tasks: map[string]*TaskStats{
176+ "fmt": {AvgDurationMs: 900, LastStatus: "success", LastExitCode: 0},
177+ "lint": {AvgDurationMs: 4200, LastStatus: "failed", LastExitCode: 1, FailureStreak: 1},
178+ "test": {AvgDurationMs: 13500, LastStatus: "success", LastExitCode: 0},
179+ },
180+ }
181+
182+ ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats)
183+ ui.Tasks["fmt"].Status = "success"
184+ ui.Tasks["fmt"].Duration = 800 * time.Millisecond
185+ ui.Tasks["lint"].Status = "success"
186+ ui.Tasks["lint"].Duration = 4100 * time.Millisecond
187+ ui.Tasks["lint"].Transition = "fixed"
188+ ui.Tasks["test"].Status = "success"
189+ ui.Tasks["test"].Duration = 14100 * time.Millisecond
190+
191+ summary := ui.RenderSummary("/tmp/report.html", 0)
192+ if !strings.Contains(summary, "All 3 tasks succeeded") {
193+ t.Errorf("expected success summary, got:\n%s", summary)
194+ }
195+ if !strings.Contains(summary, "š fixed!") {
196+ t.Errorf("expected fixed badge in summary, got:\n%s", summary)
197+ }
198+ if !strings.Contains(summary, "Report: /tmp/report.html") {
199+ t.Errorf("expected report path, got:\n%s", summary)
200+ }
201+}
202+
203+func TestUI_NonTTYStreaming(t *testing.T) {
204+ var buf bytes.Buffer
205+ logger := newLogger("ci", "error")
206+
207+ stats := &RepoStats{Repo: "test"}
208+ ui := newPipelineUIState("test", "main", "abc", "job1", stats)
209+
210+ streamer := &plainStreamReporter{
211+ out: &buf,
212+ ui: ui,
213+ log: logger,
214+ }
215+
216+ streamer.OnTaskStart("fmt")
217+ streamer.OnTaskOutput("fmt", "formatting...")
218+ streamer.OnTaskComplete("fmt", 0, 800*time.Millisecond, nil)
219+
220+ out := buf.String()
221+ if !strings.Contains(out, "starting fmt") {
222+ t.Errorf("expected non-TTY start event, got: %s", out)
223+ }
224+ if !strings.Contains(out, "fmt succeeded (0.8s)") {
225+ t.Errorf("expected non-TTY complete event, got: %s", out)
226+ }
227+}
228+
229+func TestInteractiveController_Cancellation(t *testing.T) {
230+ ctx, cancel := context.WithCancel(context.Background())
231+ var out bytes.Buffer
232+
233+ ctrl := newInteractiveController(ctx, &out, "testrepo", "main", "head", "job1", nil, false)
234+ go func() {
235+ time.Sleep(50 * time.Millisecond)
236+ cancel()
237+ }()
238+
239+ err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) {
240+ return []SessionInfo{
241+ {Name: "local.testrepo.job1.step.test", Short: "test"},
242+ }, false
243+ })
244+
245+ if err == nil {
246+ t.Fatal("expected error on cancellation, got nil")
247+ }
248+ if !strings.Contains(out.String(), "cancelling job and terminating active sessions") && !ctrl.ui.Cancelled {
249+ t.Errorf("expected cancellation message, got: %s", out.String())
250+ }
251+}
252+
253+func TestInteractiveController_FullRunSimulation(t *testing.T) {
254+ var out bytes.Buffer
255+ ctx := context.Background()
256+
257+ stats := &RepoStats{
258+ Repo: "my-repo",
259+ Tasks: map[string]*TaskStats{
260+ "test": {AvgDurationMs: 1000, LastStatus: "success"},
261+ },
262+ }
263+
264+ ctrl := newInteractiveController(ctx, &out, "my-repo", "main", "abcd123", "job99", stats, true)
265+
266+ step := 0
267+ err := ctrl.WaitUntilDone(func() ([]SessionInfo, bool) {
268+ step++
269+ if step == 1 {
270+ // Step running
271+ return []SessionInfo{
272+ {Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: ""},
273+ }, false
274+ }
275+ // Step completed
276+ return []SessionInfo{
277+ {Name: "local.my-repo.job99.step.test", Short: "test", Created: "1000", Ended: "1002", ExitCode: "0"},
278+ {Name: "local.my-repo.job99.runner", Short: "runner", Created: "1000", Ended: "1002", ExitCode: "0"},
279+ }, true
280+ })
281+
282+ if err != nil {
283+ t.Fatalf("expected nil error, got %v", err)
284+ }
285+ if !ctrl.ui.IsComplete {
286+ t.Errorf("expected UI to be marked complete")
287+ }
288+ if ctrl.ui.Tasks["test"].Status != "success" {
289+ t.Errorf("expected test status success, got %s", ctrl.ui.Tasks["test"].Status)
290+ }
291+}
292+
293+func TestController_RedrawNoDrift(t *testing.T) {
294+ var buf bytes.Buffer
295+ ctrl := &interactiveController{
296+ out: &buf,
297+ isTTY: true,
298+ }
299+
300+ // Frame 1: 3 lines
301+ ctrl.redraw("line1\nline2\nline3\n")
302+ if ctrl.rendered != 2 {
303+ t.Errorf("expected rendered=2, got %d", ctrl.rendered)
304+ }
305+
306+ // Frame 2: same 3 lines (should move up 2 lines, not drift)
307+ buf.Reset()
308+ ctrl.redraw("line1\nline2\nline3\n")
309+ out := buf.String()
310+ if !strings.HasPrefix(out, "\r\033[2A") {
311+ t.Errorf("expected move up 2 lines '\\r\\033[2A', got %q", out)
312+ }
313+
314+ // Frame 3: expanded to 5 lines
315+ buf.Reset()
316+ ctrl.redraw("line1\nline2\nline3\nline4\nline5\n")
317+ out = buf.String()
318+ if !strings.HasPrefix(out, "\r\033[2A") {
319+ t.Errorf("expected move up 2 lines before expanding, got %q", out)
320+ }
321+ if ctrl.rendered != 4 {
322+ t.Errorf("expected rendered=4, got %d", ctrl.rendered)
323+ }
324+
325+ // Frame 4: shrink back to 3 lines
326+ buf.Reset()
327+ ctrl.redraw("line1\nline2\nline3\n")
328+ out = buf.String()
329+ if !strings.HasPrefix(out, "\r\033[4A") {
330+ t.Errorf("expected move up 4 lines before shrinking, got %q", out)
331+ }
332+ if ctrl.rendered != 2 {
333+ t.Errorf("expected rendered=2, got %d", ctrl.rendered)
334+ }
335+}
A
stats.go
+146,
-0
1@@ -0,0 +1,146 @@
2+package main
3+
4+import (
5+ "encoding/json"
6+ "math"
7+ "os"
8+ "path/filepath"
9+ "time"
10+)
11+
12+// RepoStats tracks historical run durations and outcomes across runs.
13+type RepoStats struct {
14+ Repo string `json:"repo"`
15+ LastRunAt string `json:"last_run_at"`
16+ LastOverallStatus string `json:"last_overall_status"`
17+ RunsCount int `json:"runs_count"`
18+ AvgWallDurationMs int64 `json:"avg_wall_duration_ms"`
19+ Tasks map[string]*TaskStats `json:"tasks"`
20+}
21+
22+// TaskStats tracks duration averages, exit codes, and failure streaks per task.
23+type TaskStats struct {
24+ AvgDurationMs int64 `json:"avg_duration_ms"`
25+ LastDurationMs int64 `json:"last_duration_ms"`
26+ LastStatus string `json:"last_status"`
27+ LastExitCode int `json:"last_exit_code"`
28+ FailureStreak int `json:"failure_streak,omitempty"`
29+ SeenCount int `json:"seen_count"`
30+}
31+
32+// getStatsPath returns the path to stats.json for the specified repository.
33+func getStatsPath(repo string) string {
34+ cacheDir := os.Getenv("XDG_CACHE_HOME")
35+ if cacheDir == "" {
36+ home, err := os.UserHomeDir()
37+ if err != nil {
38+ cacheDir = "/tmp"
39+ } else {
40+ cacheDir = filepath.Join(home, ".cache")
41+ }
42+ }
43+ return filepath.Join(cacheDir, "pici", repo, "stats.json")
44+}
45+
46+// loadRepoStats loads historical stats for a repo, or returns an initialized empty struct.
47+func loadRepoStats(repo string) (*RepoStats, error) {
48+ path := getStatsPath(repo)
49+ data, err := os.ReadFile(path)
50+ if err != nil {
51+ if os.IsNotExist(err) {
52+ return &RepoStats{
53+ Repo: repo,
54+ Tasks: make(map[string]*TaskStats),
55+ }, nil
56+ }
57+ return nil, err
58+ }
59+
60+ var stats RepoStats
61+ if err := json.Unmarshal(data, &stats); err != nil {
62+ return &RepoStats{
63+ Repo: repo,
64+ Tasks: make(map[string]*TaskStats),
65+ }, nil
66+ }
67+ if stats.Tasks == nil {
68+ stats.Tasks = make(map[string]*TaskStats)
69+ }
70+ return &stats, nil
71+}
72+
73+// saveRepoStats writes repo stats to stats.json.
74+func saveRepoStats(stats *RepoStats) error {
75+ if stats == nil || stats.Repo == "" {
76+ return nil
77+ }
78+ path := getStatsPath(stats.Repo)
79+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
80+ return err
81+ }
82+ data, err := json.MarshalIndent(stats, "", " ")
83+ if err != nil {
84+ return err
85+ }
86+ return os.WriteFile(path, data, 0644)
87+}
88+
89+// updateTaskStats updates single task statistics using EMA duration (alpha = 0.3).
90+func updateTaskStats(stats *RepoStats, name string, actualDur time.Duration, exitCode int) {
91+ if stats.Tasks == nil {
92+ stats.Tasks = make(map[string]*TaskStats)
93+ }
94+ task, ok := stats.Tasks[name]
95+ if !ok {
96+ task = &TaskStats{}
97+ stats.Tasks[name] = task
98+ }
99+
100+ actualMs := actualDur.Milliseconds()
101+ if task.SeenCount == 0 || task.AvgDurationMs == 0 {
102+ task.AvgDurationMs = actualMs
103+ } else {
104+ // EMA: alpha = 0.3
105+ task.AvgDurationMs = int64(math.Round(0.3*float64(actualMs) + 0.7*float64(task.AvgDurationMs)))
106+ }
107+
108+ task.LastDurationMs = actualMs
109+ task.LastExitCode = exitCode
110+ task.SeenCount++
111+
112+ if exitCode == 0 {
113+ task.LastStatus = "success"
114+ task.FailureStreak = 0
115+ } else {
116+ task.LastStatus = "failed"
117+ task.FailureStreak++
118+ }
119+}
120+
121+// updateRepoStats updates overall repository stats after a run.
122+func updateRepoStats(stats *RepoStats, wallDur time.Duration, overallStatus string) {
123+ stats.RunsCount++
124+ stats.LastRunAt = time.Now().UTC().Format(time.RFC3339)
125+ stats.LastOverallStatus = overallStatus
126+
127+ wallMs := wallDur.Milliseconds()
128+ if stats.RunsCount == 1 || stats.AvgWallDurationMs == 0 {
129+ stats.AvgWallDurationMs = wallMs
130+ } else {
131+ stats.AvgWallDurationMs = int64(math.Round(0.3*float64(wallMs) + 0.7*float64(stats.AvgWallDurationMs)))
132+ }
133+}
134+
135+// computeOutcomeTransition returns "fixed", "still_failing", "new_failure", or "".
136+func computeOutcomeTransition(prev *TaskStats, currentExitCode int) string {
137+ if prev != nil && (prev.LastStatus == "failed" || prev.LastExitCode != 0) {
138+ if currentExitCode == 0 {
139+ return "fixed"
140+ }
141+ return "still_failing"
142+ }
143+ if currentExitCode != 0 {
144+ return "new_failure"
145+ }
146+ return ""
147+}
+157,
-0
1@@ -0,0 +1,157 @@
2+package main
3+
4+import (
5+ "path/filepath"
6+ "testing"
7+ "time"
8+)
9+
10+func TestStats_EMACalculation(t *testing.T) {
11+ // Formula: EMA_new = 0.3 * actual + 0.7 * EMA_prev
12+ // If EMA_prev == 0, EMA_new = actual
13+ stats := &RepoStats{
14+ Repo: "testrepo",
15+ Tasks: map[string]*TaskStats{
16+ "fmt": {
17+ AvgDurationMs: 1000,
18+ LastDurationMs: 1000,
19+ LastStatus: "success",
20+ LastExitCode: 0,
21+ SeenCount: 1,
22+ },
23+ },
24+ }
25+
26+ // Update with a new run of 2000ms
27+ updateTaskStats(stats, "fmt", 2000*time.Millisecond, 0)
28+ task := stats.Tasks["fmt"]
29+ if task.SeenCount != 2 {
30+ t.Errorf("expected seen count 2, got %d", task.SeenCount)
31+ }
32+ // 0.3 * 2000 + 0.7 * 1000 = 600 + 700 = 1300
33+ if task.AvgDurationMs != 1300 {
34+ t.Errorf("expected AvgDurationMs 1300, got %d", task.AvgDurationMs)
35+ }
36+ if task.LastDurationMs != 2000 {
37+ t.Errorf("expected LastDurationMs 2000, got %d", task.LastDurationMs)
38+ }
39+ if task.LastStatus != "success" {
40+ t.Errorf("expected LastStatus success, got %s", task.LastStatus)
41+ }
42+ if task.FailureStreak != 0 {
43+ t.Errorf("expected FailureStreak 0, got %d", task.FailureStreak)
44+ }
45+
46+ // Update with new task (not seen before)
47+ updateTaskStats(stats, "lint", 4000*time.Millisecond, 1)
48+ lintTask := stats.Tasks["lint"]
49+ if lintTask.AvgDurationMs != 4000 {
50+ t.Errorf("expected initial AvgDurationMs 4000, got %d", lintTask.AvgDurationMs)
51+ }
52+ if lintTask.LastStatus != "failed" {
53+ t.Errorf("expected LastStatus failed, got %s", lintTask.LastStatus)
54+ }
55+ if lintTask.FailureStreak != 1 {
56+ t.Errorf("expected FailureStreak 1, got %d", lintTask.FailureStreak)
57+ }
58+
59+ // Fail again -> streak increments
60+ updateTaskStats(stats, "lint", 4100*time.Millisecond, 1)
61+ lintTask = stats.Tasks["lint"]
62+ if lintTask.FailureStreak != 2 {
63+ t.Errorf("expected FailureStreak 2, got %d", lintTask.FailureStreak)
64+ }
65+
66+ // Succeed -> streak resets to 0
67+ updateTaskStats(stats, "lint", 3900*time.Millisecond, 0)
68+ lintTask = stats.Tasks["lint"]
69+ if lintTask.FailureStreak != 0 {
70+ t.Errorf("expected FailureStreak reset to 0, got %d", lintTask.FailureStreak)
71+ }
72+ if lintTask.LastStatus != "success" {
73+ t.Errorf("expected LastStatus success, got %s", lintTask.LastStatus)
74+ }
75+}
76+
77+func TestStats_StorageAndPath(t *testing.T) {
78+ tempCache := t.TempDir()
79+ t.Setenv("XDG_CACHE_HOME", tempCache)
80+
81+ repo := "my-project"
82+ expectedPath := filepath.Join(tempCache, "pici", repo, "stats.json")
83+ path := getStatsPath(repo)
84+ if path != expectedPath {
85+ t.Errorf("expected stats path %s, got %s", expectedPath, path)
86+ }
87+
88+ // Test Save & Load
89+ stats := &RepoStats{
90+ Repo: repo,
91+ LastRunAt: time.Now().UTC().Format(time.RFC3339),
92+ LastOverallStatus: "success",
93+ RunsCount: 5,
94+ AvgWallDurationMs: 12000,
95+ Tasks: map[string]*TaskStats{
96+ "build": {
97+ AvgDurationMs: 6000,
98+ LastDurationMs: 6100,
99+ LastStatus: "success",
100+ LastExitCode: 0,
101+ SeenCount: 5,
102+ },
103+ },
104+ }
105+
106+ if err := saveRepoStats(stats); err != nil {
107+ t.Fatalf("failed to save stats: %v", err)
108+ }
109+
110+ loaded, err := loadRepoStats(repo)
111+ if err != nil {
112+ t.Fatalf("failed to load stats: %v", err)
113+ }
114+ if loaded.Repo != repo || loaded.RunsCount != 5 || loaded.AvgWallDurationMs != 12000 {
115+ t.Errorf("loaded stats mismatch: %+v", loaded)
116+ }
117+ if task, ok := loaded.Tasks["build"]; !ok || task.AvgDurationMs != 6000 {
118+ t.Errorf("loaded task stats mismatch: %+v", task)
119+ }
120+}
121+
122+func TestStats_OutcomeTransitions(t *testing.T) {
123+ // 1. Fixed transition (failed in prev run, succeeded in current run)
124+ t1 := &TaskStats{
125+ LastStatus: "failed",
126+ LastExitCode: 1,
127+ }
128+ if trans := computeOutcomeTransition(t1, 0); trans != "fixed" {
129+ t.Errorf("expected 'fixed', got '%s'", trans)
130+ }
131+
132+ // 2. Still failing transition (failed in prev run, failed in current run)
133+ if trans := computeOutcomeTransition(t1, 1); trans != "still_failing" {
134+ t.Errorf("expected 'still_failing', got '%s'", trans)
135+ }
136+
137+ // 3. New failure transition (succeeded in prev run, failed in current run)
138+ t2 := &TaskStats{
139+ LastStatus: "success",
140+ LastExitCode: 0,
141+ }
142+ if trans := computeOutcomeTransition(t2, 2); trans != "new_failure" {
143+ t.Errorf("expected 'new_failure', got '%s'", trans)
144+ }
145+
146+ // 4. Clean success (succeeded in prev run, succeeded in current run)
147+ if trans := computeOutcomeTransition(t2, 0); trans != "" {
148+ t.Errorf("expected '', got '%s'", trans)
149+ }
150+
151+ // 5. First time seen (nil prev)
152+ if trans := computeOutcomeTransition(nil, 0); trans != "" {
153+ t.Errorf("expected '', got '%s'", trans)
154+ }
155+ if trans := computeOutcomeTransition(nil, 1); trans != "new_failure" {
156+ t.Errorf("expected 'new_failure', got '%s'", trans)
157+ }
158+}