Skip to content

Commit 0f8d1b0

Browse files
committed
fix(debug): report workflow step positions without lifecycle steps (#210)
* fix(debug): hide runner lifecycle steps from workflow counts * fix(debug): classify lifecycle steps by declared-step membership Replaces the prefix/marker heuristic with the one rule that is actually authoritative: a resolved step is synthetic iff its id is absent from the job request message's step list. The worker already has that list — main_steps is 1:1 with the message — so it is captured on JobContext before lifecycle expansion adds anything. This closes the misclassification the reviewers found. job_builder passes a user's `id:` through verbatim as the context name, so a workflow that declares `id: __post_build` had its own step hidden from workflow counts and from --from selection, and its failure mislabelled as a lifecycle failure. Membership cannot make that mistake, and it needs no per-shape handling for raw.__pre / raw.__post / raw.isPre / raw.isPost or for GUID-identified lifecycle steps. Also fixes two retry-selection bounds bugs: - --from-start selected the first workflow step even when it was after the failure. With a leading synthetic failure (a Pre hook or a job-started host hook) the worker rejected that index and silently retried the synthetic step, running something other than the accepted command. It now selects the first workflow step at or before the failure and explains itself when none has started. - an unmatched --from listed steps after the failure as available, which the retry path rejects. The list now comes from the same bounded set. Renames the wire field is_internal -> synthetic to match the server's StepKind::Synthetic for the same concept, and drops the legacy prefix fallback in the CLI: greenfield, so there are no older sessions to read.
1 parent 3fe19e3 commit 0f8d1b0

7 files changed

Lines changed: 365 additions & 41 deletions

File tree

‎crates/preloop-cli/src/debug_session.rs‎

Lines changed: 217 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ use std::io::{IsTerminal, Write};
1919

2020
use anyhow::{Context, Result};
2121
use 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+
179230
fn 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

Comments
 (0)