@@ -17,6 +17,11 @@ type PhaseCompletion struct {
1717// boundary and on every heartbeat tick. The same struct is embedded on the
1818// final telemetry Payload so a stored telemetry record is self-describing
1919// without joining to the run-status table.
20+ //
21+ // In-flight sub-progress (set via PhaseTracker.UpdateDetail) is folded into
22+ // CurrentPhase as "<phase> (<detail>)" rather than carried in a separate
23+ // field, so older backends that don't know about phase-detail still surface
24+ // the string verbatim. Completed phases keep their base name only.
2025type RunStatusInfo struct {
2126 PhasesCompleted []PhaseCompletion `json:"phases_completed,omitempty"`
2227 CurrentPhase string `json:"current_phase,omitempty"`
@@ -28,12 +33,13 @@ type RunStatusInfo struct {
2833// concurrently — Snapshot returns a defensive copy so the caller never
2934// observes a torn slice while a phase is appended.
3035type PhaseTracker struct {
31- mu sync.Mutex
32- startedAt time.Time
33- phaseStartedAt time.Time
34- currentPhase string
35- completed []PhaseCompletion
36- now func () time.Time // overridable for tests
36+ mu sync.Mutex
37+ startedAt time.Time
38+ phaseStartedAt time.Time
39+ currentPhase string
40+ currentPhaseDetail string
41+ completed []PhaseCompletion
42+ now func () time.Time // overridable for tests
3743}
3844
3945// NewPhaseTracker constructs a tracker anchored at the current time.
@@ -51,7 +57,8 @@ func newPhaseTrackerWithClock(now func() time.Time) *PhaseTracker {
5157// Start records the beginning of a new phase. Calling Start while another
5258// phase is already in flight implicitly finishes the previous one — this
5359// keeps call sites tidy when phases run back-to-back without a Finish in
54- // between.
60+ // between. Detail from the previous phase is cleared so it never leaks
61+ // into the new one.
5562func (t * PhaseTracker ) Start (phase string ) {
5663 t .mu .Lock ()
5764 defer t .mu .Unlock ()
@@ -60,6 +67,7 @@ func (t *PhaseTracker) Start(phase string) {
6067 t .finishLocked ()
6168 }
6269 t .currentPhase = phase
70+ t .currentPhaseDetail = ""
6371 t .phaseStartedAt = t .now ()
6472}
6573
@@ -82,17 +90,42 @@ func (t *PhaseTracker) finishLocked() {
8290 DurationMs : finishedAt .Sub (t .phaseStartedAt ).Milliseconds (),
8391 })
8492 t .currentPhase = ""
93+ t .currentPhaseDetail = ""
94+ }
95+
96+ // UpdateDetail sets a free-form sub-progress string for the current
97+ // phase ("project 12 of 47", "scanning pip3", ...). No-op when no phase
98+ // is in flight — keeps call sites tidy when a scanner reports progress
99+ // from inside a goroutine that may outlive its enclosing Start/Finish.
100+ func (t * PhaseTracker ) UpdateDetail (detail string ) {
101+ t .mu .Lock ()
102+ defer t .mu .Unlock ()
103+ if t .currentPhase == "" {
104+ return
105+ }
106+ t .currentPhaseDetail = detail
85107}
86108
87109// Snapshot returns a copy of the tracker state safe for marshalling on
88110// another goroutine. The returned slice is independent of the tracker's
89111// internal buffer.
112+ //
113+ // When the in-flight phase has a detail set, it's folded into CurrentPhase
114+ // as "<phase> (<detail>)" so the wire format stays flat — older backends
115+ // without a dedicated detail field still render the progress verbatim.
116+ // PhasesCompleted entries keep their base name; detail is per-tick state,
117+ // not a permanent label.
90118func (t * PhaseTracker ) Snapshot () RunStatusInfo {
91119 t .mu .Lock ()
92120 defer t .mu .Unlock ()
93121
122+ current := t .currentPhase
123+ if current != "" && t .currentPhaseDetail != "" {
124+ current = current + " (" + t .currentPhaseDetail + ")"
125+ }
126+
94127 out := RunStatusInfo {
95- CurrentPhase : t . currentPhase ,
128+ CurrentPhase : current ,
96129 ElapsedMs : t .now ().Sub (t .startedAt ).Milliseconds (),
97130 }
98131 if len (t .completed ) > 0 {
0 commit comments