Commit 1a7066e

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