fix(server): hide synthetic start node from billing rollup

The billing rollup filtered out the synthetic exit handler but left
start visible, so the billing tab showed an asymmetric pair. Both are
no-op workflow boundary handlers and shouldn't appear as billable
stages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-07 16:09:26 -07:00
parent 094ebe164a
commit 9e54643a7e
No known key found for this signature in database
2 changed files with 13 additions and 14 deletions

View file

@ -144,7 +144,7 @@ fn live_billing_rows(projection: &RunProjection, now: DateTime<Utc>) -> Vec<Live
for (stage_id, stage) in projection.iter_stages() {
let node_id = stage_id.node_id();
if is_exit_stage(projection, node_id) || !stage_has_billing_row(stage) {
if is_boundary_stage(projection, node_id) || !stage_has_billing_row(stage) {
continue;
}
@ -187,9 +187,9 @@ fn stage_has_billing_row(stage: &StageProjection) -> bool {
|| stage.state.is_some()
}
fn is_exit_stage(projection: &RunProjection, node_id: &str) -> bool {
fn is_boundary_stage(projection: &RunProjection, node_id: &str) -> bool {
projection
.spec()
.and_then(|spec| spec.graph().nodes.get(node_id))
.is_some_and(|node| node.handler_type() == Some("exit"))
.is_some_and(|node| matches!(node.handler_type(), Some("start" | "exit")))
}

View file

@ -43,7 +43,7 @@ pub fn billing_rollup_from_projection(projection: &RunProjection) -> ProjectionB
let mut billed_visit_count = 0_usize;
for (stage_id, stage) in projection.iter_stages() {
if is_exit_stage(projection, stage_id.node_id()) {
if is_boundary_stage(projection, stage_id.node_id()) {
continue;
}
if stage.completion.is_none() && stage.duration_ms.is_none() && stage.usage.is_none() {
@ -97,11 +97,11 @@ pub fn billing_rollup_from_projection(projection: &RunProjection) -> ProjectionB
}
}
fn is_exit_stage(projection: &RunProjection, node_id: &str) -> bool {
fn is_boundary_stage(projection: &RunProjection, node_id: &str) -> bool {
projection
.spec()
.and_then(|spec| spec.graph().nodes.get(node_id))
.is_some_and(|node| node.handler_type() == Some("exit"))
.is_some_and(|node| matches!(node.handler_type(), Some("start" | "exit")))
}
fn accumulate_usage(counts: &mut BilledTokenCounts, usage: &BilledModelUsage) {
@ -205,7 +205,7 @@ mod tests {
#[test]
fn rollup_includes_completed_non_llm_stage_rows_with_zero_billing() {
let mut projection = RunProjection::default();
let stage = projection.stage_entry("start", 1, first_event_seq(1));
let stage = projection.stage_entry("build", 1, first_event_seq(1));
stage.duration_ms = Some(25);
stage.completion = Some(StageCompletion {
outcome: StageOutcome::Succeeded,
@ -217,7 +217,7 @@ mod tests {
let rollup = billing_rollup_from_projection(&projection);
assert_eq!(rollup.stages.len(), 1);
assert_eq!(rollup.stages[0].node_id, "start");
assert_eq!(rollup.stages[0].node_id, "build");
assert_eq!(rollup.stages[0].duration_ms, 25);
assert!(rollup.stages[0].model_id.is_none());
assert_eq!(rollup.stages[0].billing.input_tokens, 0);
@ -227,9 +227,9 @@ mod tests {
}
#[test]
fn rollup_excludes_terminal_exit_stage_rows() {
fn rollup_excludes_workflow_boundary_stage_rows() {
let mut projection = RunProjection::default();
projection.spec = Some(run_spec_with_exit_node());
projection.spec = Some(run_spec_with_boundary_nodes());
let start = projection.stage_entry("start", 1, first_event_seq(1));
start.duration_ms = Some(25);
start.completion = Some(StageCompletion {
@ -249,12 +249,11 @@ mod tests {
let rollup = billing_rollup_from_projection(&projection);
assert_eq!(rollup.stages.len(), 1);
assert_eq!(rollup.stages[0].node_id, "start");
assert_eq!(rollup.runtime_ms, 25);
assert_eq!(rollup.stages.len(), 0);
assert_eq!(rollup.runtime_ms, 0);
}
fn run_spec_with_exit_node() -> RunSpec {
fn run_spec_with_boundary_nodes() -> RunSpec {
let mut graph = Graph::new("test");
graph.nodes.insert("start".to_string(), {
let mut node = Node::new("start");