Commit 85f918b

Eric Bower  ·  2026-05-07 11:26:22 -0400 EDT
parent 9b7abfc
chore: cleanup
3 files changed,  +134, -33
M go.mod
M go.sum
M go.mod
+0, -2
......@@ -1,5 +1,3 @@
11 module github.com/picosh/pici
22
33 go 1.26.2
4-
5-require github.com/golang-cz/devslog v0.0.15
M go.sum
+0, -2
......@@ -1,2 +0,0 @@
1-github.com/golang-cz/devslog v0.0.15 h1:ejoBLTCwJHWGbAmDf2fyTJJQO3AkzcPjw8SC9LaOQMI=
2-github.com/golang-cz/devslog v0.0.15/go.mod h1:bSe5bm0A7Nyfqtijf1OMNgVJHlWEuVSXnkuASiE1vV8=
+134, -29
......@@ -19,7 +19,7 @@ import (
1919 "syscall"
2020 "time"
2121
22- "github.com/golang-cz/devslog"
22+
2323 )
2424
2525 type WorkspaceFactory func(cfg *Cfg, logger *slog.Logger, source string) Workspace
......@@ -61,14 +61,12 @@ func NewCfg() (*Cfg, string, bool) {
6161 var keyLoc, certLoc, artifactDir, event string
6262 var monitorInterval time.Duration
6363 var logLevel string
64- var structured bool
6564 flag.StringVar(&keyLoc, "pk", "", "ssh private key used to authenticate with pico services")
6665 flag.StringVar(&certLoc, "ck", "", "ssh certificate public key used to authenticate with pico services (only required if using ssh certificates)")
6766 flag.StringVar(&artifactDir, "artifact-dir", "/tmp/pici-artifacts", "local directory to stage artifacts")
6867 flag.StringVar(&event, "event", "", "event JSON to run (alternative to reading from stdin)")
6968 flag.DurationVar(&monitorInterval, "monitor-interval", 5*time.Second, "interval for monitoring zmx sessions")
7069 flag.StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
71- flag.BoolVar(&structured, "structured", false, "use structured key=value log output")
7270 var statusFilter string
7371 var human bool
7472 var wait bool
......@@ -81,7 +79,7 @@ func NewCfg() (*Cfg, string, bool) {
8179 flags, cmd, wantHelp := splitCommand(os.Args[1:])
8280 flag.CommandLine.Parse(flags)
8381
84- logger := newLogger("ci", logLevel, structured)
82+ logger := newLogger("ci", logLevel)
8583 ctx, cancel := context.WithCancel(context.Background())
8684 return &Cfg{
8785 NewWorkspace: defaultWorkspaceFactory,
......@@ -131,17 +129,10 @@ func parseLogLevel(s string) slog.Level {
131129 }
132130 }
133131
134-func newLogger(space string, levelStr string, structured bool) *slog.Logger {
132+func newLogger(space string, levelStr string) *slog.Logger {
135133 lvl := parseLogLevel(levelStr)
136- if structured {
137- return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
138- Level: lvl,
139- })).With("service", space)
140- }
141- return slog.New(devslog.NewHandler(os.Stderr, &devslog.Options{
142- HandlerOptions: &slog.HandlerOptions{
143- Level: lvl,
144- },
134+ return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
135+ Level: lvl,
145136 })).With("service", space)
146137 }
147138
......@@ -528,12 +519,6 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID string) error {
528519 fmt.Fprint(os.Stdout, "⏳ waiting for completion...\n")
529520
530521 // Track state for each session
531- type sessionState struct {
532- status string // "running", "success", "failed"
533- exitCode string
534- duration string
535- printed bool // true once final state (success/failed) is printed
536- }
537522 known := make(map[string]*sessionState)
538523 var jobSessions []SessionInfo
539524 var sessionOrder []string // track insertion order for deterministic output
......@@ -702,6 +687,23 @@ func cleanSessionShort(name, prefix, repoName, jobID string) string {
702687 // pici monitor | ssh pipe.pico.sh "pub build.status -b=false"
703688 // pici monitor | while read -r line; do curl -sd"$line" $WEBHOOK; done
704689 // pici monitor > status.jsonl
690+//
691+// sessionState tracks the display state of a single session.
692+type sessionState struct {
693+ status string // "running", "success", "failed"
694+ exitCode string
695+ duration string
696+ printed bool // true once final state (success/failed) is printed
697+}
698+
699+// monitorJobState tracks display state for a single job across ticks.
700+type monitorJobState struct {
701+ sessionOrder []string // insertion order for deterministic output
702+ sessions map[string]*sessionState
703+ liveLines []string // last set of status lines printed (for overwrite)
704+ published bool // final status already printed
705+}
706+
705707 func runMonitor(cfg *Cfg) error {
706708 log := cfg.Logger.With("cmd", "monitor")
707709
......@@ -713,6 +715,9 @@ func runMonitor(cfg *Cfg) error {
713715 ticker := time.NewTicker(cfg.MonitorInterval)
714716 defer ticker.Stop()
715717
718+ // Track per-job display state across ticks (for human output)
719+ jobStates := make(map[string]*monitorJobState) // key: "name/jobID"
720+
716721 log.Debug("monitor started", "interval", cfg.MonitorInterval, "artifact_dir", cfg.ArtifactDir)
717722 log.Debug("monitoring ci.* sessions for job status, writing status to stdout")
718723
......@@ -724,14 +729,114 @@ func runMonitor(cfg *Cfg) error {
724729 case <-ticker.C:
725730 }
726731
727- if err := monitorTick(cfg, log, output); err != nil {
732+ if err := monitorTick(cfg, log, output, jobStates); err != nil {
728733 log.Error("monitor tick", "err", err)
729734 }
730735 }
731736 }
732737
733738 // monitorTick performs a single monitoring pass over all ci.* sessions.
734-func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer) error {
739+// renderJobRunning prints per-session status for a running job, using ANSI
740+// cursor control to overwrite previous lines.
741+func renderJobRunning(output io.Writer, name, jobID string, group []SessionInfo, duration string, jobStates map[string]*monitorJobState) {
742+ key := name + "/" + jobID
743+ state, ok := jobStates[key]
744+ if !ok {
745+ state = &monitorJobState{sessions: make(map[string]*sessionState)}
746+ jobStates[key] = state
747+ }
748+
749+ // Track new sessions
750+ for _, s := range group {
751+ if _, ok := state.sessions[s.Short]; !ok {
752+ state.sessions[s.Short] = &sessionState{}
753+ state.sessionOrder = append(state.sessionOrder, s.Short)
754+ }
755+ }
756+
757+ // Update session states
758+ for _, s := range group {
759+ ss := state.sessions[s.Short]
760+ if s.Ended == "" {
761+ ss.status = "running"
762+ created, _ := strconv.ParseInt(s.Created, 10, 64)
763+ ss.duration = fmtDurationTs(created, time.Now().Unix())
764+ } else {
765+ if s.ExitCode == "0" {
766+ ss.status = "success"
767+ } else {
768+ ss.status = "failed"
769+ ss.exitCode = s.ExitCode
770+ }
771+ ss.duration = fmtDuration(s.Created, s.Ended)
772+ }
773+ }
774+
775+ // Build status lines
776+ lines := make([]string, 0, len(state.sessionOrder))
777+ for _, short := range state.sessionOrder {
778+ ss := state.sessions[short]
779+ icon := map[string]string{"running": "🚀", "success": "✅", "failed": "❌"}[ss.status]
780+ detail := ""
781+ if ss.status == "failed" && ss.exitCode != "" {
782+ detail = fmt.Sprintf(", exit %s", ss.exitCode)
783+ }
784+ if ss.duration != "" && ss.duration != "—" {
785+ detail += fmt.Sprintf(" (%s)", ss.duration)
786+ }
787+ lines = append(lines, fmt.Sprintf(" %-12s %s %s%s", short, icon, ss.status, detail))
788+ }
789+
790+ // Overwrite previous lines or print fresh
791+ if len(state.liveLines) > 0 {
792+ for range len(state.liveLines) {
793+ fmt.Fprint(output, "\033[A")
794+ }
795+ for i, line := range lines {
796+ if i > 0 {
797+ fmt.Fprint(output, "\n")
798+ }
799+ fmt.Fprint(output, line+"\033[K")
800+ }
801+ fmt.Fprint(output, "\n")
802+ } else {
803+ fmt.Fprintf(output, " %-12s %s %s\n", name, "🚀", "running")
804+ for _, line := range lines {
805+ fmt.Fprintln(output, line)
806+ }
807+ }
808+ state.liveLines = lines
809+}
810+
811+// renderJobFinal prints the final status for a completed job.
812+func renderJobFinal(output io.Writer, name, jobID string, group []SessionInfo, duration, status string, success bool, workspace, artifactDir, artifactURL string) {
813+ icon := map[string]string{"success": "✅", "failed": "❌"}[status]
814+ fmt.Fprintf(output, " %-12s %s %s (%s)\n", name, icon, status, duration)
815+
816+ // Per-session summary
817+ for _, s := range group {
818+ icon := "✅"
819+ if s.ExitCode != "0" {
820+ icon = "❌"
821+ }
822+ dur := fmtDuration(s.Created, s.Ended)
823+ fmt.Fprintf(output, " %-12s %s done (%s)\n", s.Short, icon, dur)
824+ }
825+
826+ // Context info
827+ fmt.Fprint(output, "\n")
828+ if workspace != "" {
829+ fmt.Fprintf(output, " workspace: %s\n", workspace)
830+ }
831+ artifactPath := filepath.Join(artifactDir, name, jobID)
832+ fmt.Fprintf(output, " artifacts: %s\n", artifactPath)
833+ if artifactURL != "" {
834+ fmt.Fprintf(output, " url: %s\n", artifactURL)
835+ }
836+ fmt.Fprint(output, "\n")
837+}
838+
839+func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[string]*monitorJobState) error {
735840 // a. zmx list → filter ci.* sessions
736841 listOutput, err := exec.Command("zmx", "list").CombinedOutput()
737842 if err != nil {
......@@ -816,9 +921,7 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer) error {
816921 log.Info("job finished", "status", status, "exit_code", exitCode)
817922
818923 if cfg.HumanOutput {
819- icon := map[string]string{"success": "✅", "failed": "❌"}[status]
820- fmt.Fprintf(output, "[%d/%d] %s %s: %s (%s)\n",
821- countCompleted(group), len(group), icon, status, name, duration)
924+ renderJobFinal(output, name, jobID, group, duration, status, exitCode == 0, eventData.Workspace, cfg.ArtifactDir, artifactURL)
822925 } else {
823926 payload := StatusPayload{
824927 Name: name,
......@@ -845,9 +948,7 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer) error {
845948 // Publish running status only when filter is "all"
846949 if cfg.StatusFilter != "terminal" {
847950 if cfg.HumanOutput {
848- completed := countCompleted(group)
849- fmt.Fprintf(output, "[%d/%d] 🚀 running: %s (%s)\n",
850- completed, len(group), name, duration)
951+ renderJobRunning(output, name, jobID, group, duration, jobStates)
851952 } else {
852953 payload := StatusPayload{
853954 Name: name,
......@@ -869,6 +970,9 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer) error {
869970 }
870971
871972 // d. Sync artifacts once per tick
973+ if cfg.HumanOutput {
974+ fmt.Fprint(output, " 📦 syncing artifacts...\n")
975+ }
872976 if err := syncArtifacts(cfg, log); err != nil {
873977 log.Error("sync artifacts", "err", err)
874978 }
......@@ -966,7 +1070,8 @@ func groupSessionsByJob(sessions []SessionInfo) map[string][]SessionInfo {
9661070 continue
9671071 }
9681072 // Set the Short name
969- s.Short = strings.TrimPrefix(s.Name, prefix)
1073+ name, jobID := parseJobPrefix(prefix)
1074+ s.Short = cleanSessionShort(s.Name, prefix, name, jobID)
9701075 groups[prefix] = append(groups[prefix], s)
9711076 }
9721077 return groups