main reporter_plain.go
Eric Bower  ·  2026-08-16
  1package main
  2
  3import (
  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.
 18func 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}