@@ -19,7 +19,8 @@ use std::io::{IsTerminal, Write};
1919
2020use anyhow:: { Context , Result } ;
2121use preloop_gha_protocol:: debug_session:: {
22- ChangeCategory , DebugSession , RevertPolicy , Verdict , VerdictRequest , WorkspaceChange ,
22+ ChangeCategory , DebugSession , RevertPolicy , StepSummary , Verdict , VerdictRequest ,
23+ WorkspaceChange ,
2324} ;
2425
2526/// `preloop debug [session]`.
@@ -176,14 +177,63 @@ pub async fn list_sessions(
176177/// run` reports how many sessions are paused on the server without saying
177178/// whose, and the pauses in the way are frequently an *earlier* run's — so the
178179/// id a user has at hand matches nothing and the listing has to say why.
180+ /// Runner-generated lifecycle and host-hook steps stay in the resolved list for
181+ /// retry fidelity, but they are not workflow steps from a user's perspective.
182+ ///
183+ /// Classification is the runner's `synthetic` flag and nothing else. It is
184+ /// decided there by absence from the job request message's step list, which is
185+ /// authoritative. A prefix check on the context name cannot substitute:
186+ /// `job_builder` passes a user's `id:` through verbatim, so a workflow
187+ /// declaring `id: __post_build` would have its own step hidden from counts and
188+ /// from `--from` selection, and its failure mislabelled as a lifecycle failure.
189+ fn workflow_steps ( session : & DebugSession ) -> impl Iterator < Item = & StepSummary > {
190+ session. job_steps . iter ( ) . filter ( |step| !step. synthetic )
191+ }
192+
193+ /// Return the one-based workflow position for a resolved runner step.
194+ fn workflow_position ( session : & DebugSession , resolved_index : usize ) -> Option < ( usize , usize ) > {
195+ let total = workflow_steps ( session) . count ( ) ;
196+ let position = workflow_steps ( session) . position ( |step| step. index == resolved_index) ?;
197+ Some ( ( position + 1 , total) )
198+ }
199+
200+ /// Explain a step position without exposing synthetic cleanup as a workflow
201+ /// step. If an old server omitted `job_steps`, retain a truthful fallback.
202+ fn step_position_label ( session : & DebugSession , resolved_index : usize ) -> String {
203+ if let Some ( ( position, total) ) = workflow_position ( session, resolved_index) {
204+ return format ! ( "workflow step {position}/{total}" ) ;
205+ }
206+ if session
207+ . job_steps
208+ . iter ( )
209+ . any ( |step| step. index == resolved_index && step. synthetic )
210+ {
211+ return format ! (
212+ "runner lifecycle step {}/{} (not a workflow step)" ,
213+ resolved_index + 1 ,
214+ session. step. total
215+ ) ;
216+ }
217+ format ! (
218+ "resolved runner step {}/{}" ,
219+ resolved_index + 1 ,
220+ session. step. total
221+ )
222+ }
223+
224+ fn workflow_steps_before_failure ( session : & DebugSession ) -> Vec < & StepSummary > {
225+ workflow_steps ( session)
226+ . filter ( |step| step. index <= session. step . index )
227+ . collect ( )
228+ }
229+
179230fn session_line ( session : & DebugSession ) -> String {
180231 format ! (
181- " {} {} run {} step {}/ {} {}" ,
232+ " {} {} run {} {} {}" ,
182233 session. session_id,
183234 session. job_name,
184235 short_run( session. run_id) ,
185- session. step. index + 1 ,
186- session. step. total,
236+ step_position_label( session, session. step. index) ,
187237 session. step. display_name
188238 )
189239}
@@ -306,7 +356,10 @@ pub async fn prompt_at_failure(
306356 None ,
307357 )
308358 . await ?;
309- println ! ( " ⟳ retrying step {}" , session. step. index + 1 ) ;
359+ println ! (
360+ " ⟳ retrying {}" ,
361+ step_position_label( & session, session. step. index)
362+ ) ;
310363 return Ok ( true ) ;
311364 }
312365 "s" => {
@@ -325,7 +378,10 @@ pub async fn prompt_at_failure(
325378 None ,
326379 )
327380 . await ?;
328- println ! ( " ⟳ retrying step {}" , session. step. index + 1 ) ;
381+ println ! (
382+ " ⟳ retrying {}" ,
383+ step_position_label( & session, session. step. index)
384+ ) ;
329385 return Ok ( true ) ;
330386 }
331387 "a" => {
@@ -423,27 +479,55 @@ fn parse_retry_from(
423479 anyhow:: bail!( "use either --from <step> or --from-start, not both" ) ;
424480 }
425481 if from_start {
426- return Ok ( Some ( 0 ) ) ;
482+ if session. job_steps . is_empty ( ) {
483+ // Older sessions did not carry the resolved step summaries.
484+ return Ok ( Some ( 0 ) ) ;
485+ }
486+ // The first workflow step that has actually run. Retries cannot target
487+ // a step after the failure, and when a leading synthetic step fails
488+ // (a `Pre` hook, or a job-start host hook) the first workflow step is
489+ // still ahead of it — the worker rejects that index and silently
490+ // retries the synthetic step instead, executing something other than
491+ // what the accepted command said.
492+ if let Some ( step) = workflow_steps_before_failure ( session) . first ( ) {
493+ return Ok ( Some ( step. index ) ) ;
494+ }
495+ anyhow:: bail!(
496+ "no workflow step has started yet: {} failed before the first one" ,
497+ step_position_label( session, session. step. index)
498+ ) ;
427499 }
428500 let Some ( raw) = from else {
429501 return Ok ( None ) ;
430502 } ;
431503 if raw. is_empty ( ) {
432- anyhow:: bail!( "--from requires a step number or name" ) ;
504+ anyhow:: bail!( "--from requires a workflow step number or name" ) ;
433505 }
434506
435507 if let Ok ( number) = raw. parse :: < usize > ( ) {
436- let max = session. step . index + 1 ;
508+ if session. job_steps . is_empty ( ) {
509+ // Preserve the old resolved-index behavior for sessions created by
510+ // a server that predates job step summaries.
511+ let max = session. step . index + 1 ;
512+ if number == 0 || number > max {
513+ anyhow:: bail!( "step number out of range (1..{max})" ) ;
514+ }
515+ return Ok ( Some ( number - 1 ) ) ;
516+ }
517+
518+ let available = workflow_steps_before_failure ( session) ;
519+ let max = available. len ( ) ;
520+ if max == 0 {
521+ anyhow:: bail!( "no workflow steps are available before the failed step" ) ;
522+ }
437523 if number == 0 || number > max {
438- anyhow:: bail!( "step number out of range (1..{max})" ) ;
524+ anyhow:: bail!( "workflow step number out of range (1..{max})" ) ;
439525 }
440- return Ok ( Some ( number - 1 ) ) ;
526+ return Ok ( Some ( available [ number - 1 ] . index ) ) ;
441527 }
442528
443529 let needle = raw. to_ascii_lowercase ( ) ;
444- let matches: Vec < _ > = session
445- . job_steps
446- . iter ( )
530+ let matches: Vec < _ > = workflow_steps ( session)
447531 . filter ( |step| {
448532 step. display_name . to_ascii_lowercase ( ) . contains ( & needle)
449533 || step. context_name . to_ascii_lowercase ( ) . contains ( & needle)
@@ -456,24 +540,31 @@ fn parse_retry_from(
456540 "no step matching '{raw}' (step names are unavailable in this session)"
457541 ) ;
458542 }
459- let available = session
460- . job_steps
461- . iter ( )
462- . map ( |step| format ! ( "{}. {}" , step. index + 1 , step. display_name) )
543+ // Only steps at or before the failure are accepted, so listing any
544+ // later step would advertise input the retry path rejects.
545+ let available = workflow_steps_before_failure ( session)
546+ . into_iter ( )
547+ . enumerate ( )
548+ . map ( |( index, step) | format ! ( "{}. {}" , index + 1 , step. display_name) )
463549 . collect :: < Vec < _ > > ( )
464550 . join ( ", " ) ;
465- anyhow:: bail!( "no step matching '{raw}'; available steps: {available}" ) ;
551+ anyhow:: bail!( "no workflow step matching '{raw}'; available steps: {available}" ) ;
466552 }
467553 [ step] if step. index <= session. step . index => Ok ( Some ( step. index ) ) ,
468554 [ step] => anyhow:: bail!(
469- "step {} is after the failed step {}" ,
470- step. index + 1 ,
471- session. step. index + 1
555+ "{} is after {}" ,
556+ step_position_label ( session , step. index) ,
557+ step_position_label ( session, session . step. index)
472558 ) ,
473559 _ => {
474560 let names = matches
475561 . iter ( )
476- . map ( |step| format ! ( "{}. {}" , step. index + 1 , step. display_name) )
562+ . map ( |step| {
563+ let position = workflow_position ( session, step. index )
564+ . map ( |( position, _) | position. to_string ( ) )
565+ . unwrap_or_else ( || "?" . to_owned ( ) ) ;
566+ format ! ( "{position}. {}" , step. display_name)
567+ } )
477568 . collect :: < Vec < _ > > ( )
478569 . join ( ", " ) ;
479570 anyhow:: bail!( "'{raw}' is ambiguous: {names}" ) ;
@@ -529,7 +620,7 @@ pub fn render_banner(session: &DebugSession) -> String {
529620 if let Some ( code) = step. exit_code {
530621 out. push_str ( & format ! ( " exit {code}\n " ) ) ;
531622 }
532- out. push_str ( & format ! ( " step {}/{} \n " , step . index + 1 , step. total ) ) ;
623+ out. push_str ( & format ! ( " {} \n " , step_position_label ( session , step. index ) ) ) ;
533624 if let Some ( cwd) = & step. working_directory {
534625 out. push_str ( & format ! ( " cwd {cwd}\n " ) ) ;
535626 }
@@ -690,20 +781,19 @@ async fn repl(ctx: &Api, mut session: DebugSession) -> Result<ReplOutcome> {
690781 if let Some ( from) = retry_from {
691782 let name = session
692783 . job_steps
693- . get ( from)
694- . map ( |s| s. display_name . as_str ( ) )
784+ . iter ( )
785+ . find ( |step| step. index == from)
786+ . map ( |step| step. display_name . as_str ( ) )
695787 . unwrap_or ( "?" ) ;
696788 println ! (
697- " ⟳ retrying from step {}/{} ({})" ,
698- from + 1 ,
699- session. step. total,
789+ " ⟳ retrying from {} ({})" ,
790+ step_position_label( & session, from) ,
700791 name,
701792 ) ;
702793 } else {
703794 println ! (
704- " ⟳ retrying step {}/{}" ,
705- session. step. index + 1 ,
706- session. step. total
795+ " ⟳ retrying {}" ,
796+ step_position_label( & session, session. step. index)
707797 ) ;
708798 }
709799 println ! ( " The job resumes. Watch it with: preloop status" ) ;
@@ -1400,6 +1490,52 @@ mod tests {
14001490 }
14011491 }
14021492
1493+ fn summary (
1494+ index : usize ,
1495+ context_name : & str ,
1496+ display_name : & str ,
1497+ synthetic : bool ,
1498+ ) -> StepSummary {
1499+ StepSummary {
1500+ index,
1501+ context_name : context_name. into ( ) ,
1502+ display_name : display_name. into ( ) ,
1503+ synthetic,
1504+ }
1505+ }
1506+
1507+ fn session_with_lifecycle_steps ( ) -> DebugSession {
1508+ let mut session = session ( ) ;
1509+ session. step . index = 3 ;
1510+ session. step . total = 6 ;
1511+ session. step . context_name = "__run" . into ( ) ;
1512+ session. step . display_name = "Run zizmor" . into ( ) ;
1513+ session. job_steps = vec ! [
1514+ summary(
1515+ 0 ,
1516+ "__pre___actions_checkout" ,
1517+ "Pre Check out repository" ,
1518+ true ,
1519+ ) ,
1520+ summary( 1 , "__actions_checkout" , "Check out repository" , false ) ,
1521+ summary( 2 , "__astral-sh_setup-uv" , "Install uv" , false ) ,
1522+ summary( 3 , "__run" , "Run zizmor" , false ) ,
1523+ summary(
1524+ 4 ,
1525+ "__run_2" ,
1526+ "Verify action pin comments match their SHAs" ,
1527+ false ,
1528+ ) ,
1529+ summary(
1530+ 5 ,
1531+ "__post___actions_checkout" ,
1532+ "Post Check out repository" ,
1533+ true ,
1534+ ) ,
1535+ ] ;
1536+ session
1537+ }
1538+
14031539 #[ test]
14041540 fn banner_prefers_diagnostics_over_the_log_excerpt ( ) {
14051541 let rendered = render_banner ( & session ( ) ) ;
@@ -1467,6 +1603,51 @@ mod tests {
14671603 assert ! ( line. contains( "step 4/6" ) , "1-based step index: {line}" ) ;
14681604 }
14691605
1606+ #[ test]
1607+ fn workflow_positions_hide_runner_lifecycle_steps ( ) {
1608+ let session = session_with_lifecycle_steps ( ) ;
1609+ assert_eq ! ( workflow_position( & session, 3 ) , Some ( ( 3 , 4 ) ) ) ;
1610+ assert_eq ! ( step_position_label( & session, 3 ) , "workflow step 3/4" ) ;
1611+
1612+ let line = session_line ( & session) ;
1613+ assert ! ( line. contains( "workflow step 3/4" ) ) ;
1614+ assert ! ( !line. contains( "3/6" ) ) ;
1615+
1616+ let banner = render_banner ( & session) ;
1617+ assert ! ( banner. contains( "workflow step 3/4" ) ) ;
1618+ assert ! ( !banner. contains( "step 3/6" ) ) ;
1619+ }
1620+
1621+ #[ test]
1622+ fn retry_from_numbers_use_workflow_positions ( ) {
1623+ let session = session_with_lifecycle_steps ( ) ;
1624+ assert_eq ! (
1625+ parse_retry_from( & session, None , true ) . unwrap( ) ,
1626+ Some ( 1 ) ,
1627+ "from-start skips the synthetic pre step"
1628+ ) ;
1629+ assert_eq ! (
1630+ parse_retry_from( & session, Some ( "1" ) , false ) . unwrap( ) ,
1631+ Some ( 1 )
1632+ ) ;
1633+ assert_eq ! (
1634+ parse_retry_from( & session, Some ( "3" ) , false ) . unwrap( ) ,
1635+ Some ( 3 )
1636+ ) ;
1637+ assert_eq ! (
1638+ parse_retry_from( & session, Some ( "Run zizmor" ) , false ) . unwrap( ) ,
1639+ Some ( 3 )
1640+ ) ;
1641+ assert ! (
1642+ parse_retry_from( & session, Some ( "4" ) , false ) . is_err( ) ,
1643+ "future workflow steps cannot be selected"
1644+ ) ;
1645+ assert ! (
1646+ parse_retry_from( & session, Some ( "Post Check out repository" ) , false ) . is_err( ) ,
1647+ "synthetic cleanup is not a workflow step"
1648+ ) ;
1649+ }
1650+
14701651 #[ test]
14711652 fn banner_falls_back_to_the_excerpt_when_there_are_no_diagnostics ( ) {
14721653 let mut session = session ( ) ;
@@ -1545,6 +1726,7 @@ mod tests {
15451726 index,
15461727 context_name : format ! ( "step_{index}" ) ,
15471728 display_name : format ! ( "Step {index}" ) ,
1729+ synthetic : false ,
15481730 } )
15491731 . collect ( ) ;
15501732
@@ -1567,16 +1749,19 @@ mod tests {
15671749 index: 0 ,
15681750 context_name: "prepare" . into( ) ,
15691751 display_name: "Prepare" . into( ) ,
1752+ synthetic: false ,
15701753 } ,
15711754 StepSummary {
15721755 index: 3 ,
15731756 context_name: "build" . into( ) ,
15741757 display_name: "Build" . into( ) ,
1758+ synthetic: false ,
15751759 } ,
15761760 StepSummary {
15771761 index: 4 ,
15781762 context_name: "build_again" . into( ) ,
15791763 display_name: "Build again" . into( ) ,
1764+ synthetic: false ,
15801765 } ,
15811766 ] ;
15821767 assert ! ( parse_retry_from( & paused, Some ( "5" ) , false ) . is_err( ) ) ;
0 commit comments