refactor: simplify reviewed run cleanup

Reuse shared PR chip rendering, persist runtime stage handlers from events, and remove duplicated lifecycle/delete helpers found during review.
This commit is contained in:
Bryan Helmkamp 2026-05-08 23:29:08 -04:00
parent 1a89974f65
commit 6719a3cdac
No known key found for this signature in database
13 changed files with 134 additions and 154 deletions

View file

@ -1,4 +1,4 @@
import { Fragment, type ReactNode } from "react";
import { Fragment, useMemo, type ReactNode } from "react";
import { Lexer, type Token } from "marked";
const CODE_CLASSNAME =
@ -46,6 +46,6 @@ export function InlineMarkdown({
content: string;
className?: string;
}) {
const tokens = Lexer.lexInline(content);
return <span className={className}>{renderTokens(tokens)}</span>;
const children = useMemo(() => renderTokens(Lexer.lexInline(content)), [content]);
return <span className={className}>{children}</span>;
}

View file

@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { GitPullRequestIcon } from "./icons";
export function PullRequestChip({
number,
url,
className = "inline-flex items-center gap-1.5 font-mono text-xs text-fg-muted",
iconClassName = "size-3",
children,
}: {
number: number;
url?: string;
className?: string;
iconClassName?: string;
children?: ReactNode;
}) {
const content = (
<>
<GitPullRequestIcon className={iconClassName} />
#{number}
{children}
</>
);
if (url == null) {
return <span className={className}>{content}</span>;
}
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className={`${className} hover:text-fg`}
>
{content}
</a>
);
}

View file

@ -18,7 +18,7 @@ import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { InlineMarkdown } from "../components/inline-markdown";
import { InterviewDock } from "../components/interview-dock";
import { GitPullRequestIcon } from "../components/icons";
import { PullRequestChip } from "../components/pull-request-chip";
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
@ -328,15 +328,11 @@ export default function RunDetail({ params }: { params: { id: string } }) {
</Tooltip>
)}
{run.number != null && run.pullRequestUrl && (
<a
href={run.pullRequestUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 font-mono text-xs text-fg-muted hover:text-fg"
>
<GitPullRequestIcon className="size-3.5" />
#{run.number}
</a>
<PullRequestChip
number={run.number}
url={run.pullRequestUrl}
iconClassName="size-3.5"
/>
)}
</div>
</div>

View file

@ -25,8 +25,8 @@ import { ciConfig, columnStatusDisplay, columnStatuses, deriveCiStatus, mapRunLi
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
import { formatRelativeTime } from "../lib/format";
import { EmptyState } from "../components/state";
import { GitPullRequestIcon } from "../components/icons";
import { InlineMarkdown } from "../components/inline-markdown";
import { PullRequestChip } from "../components/pull-request-chip";
import { useToast } from "../components/toast";
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
import { useAuthConfig, useBoardsRuns, useSystemInfo } from "../lib/queries";
@ -276,38 +276,29 @@ function PrCard({
const lifecycleLabel = boardLifecycleStatusLabel(pr);
return (
<Link to={`/runs/${pr.id}`} className="group block rounded-md border border-line bg-panel p-4 transition-all duration-200 hover:border-line-strong hover:shadow-lg hover:shadow-black/20">
<div className="group rounded-md border border-line bg-panel p-4 transition-all duration-200 hover:border-line-strong hover:shadow-lg hover:shadow-black/20">
<div className="mb-2 flex items-center gap-1.5">
<span className="font-mono text-xs font-medium text-teal-500">
<Link to={`/runs/${pr.id}`} className="font-mono text-xs font-medium text-teal-500">
{pr.repo}
</span>
</Link>
{lifecycleLabel != null && (
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[11px] uppercase tracking-wide text-fg-muted">
{lifecycleLabel}
</span>
)}
{pr.number != null && (
pr.pullRequestUrl ? (
<a
href={pr.pullRequestUrl}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
className={`ml-auto inline-flex items-center gap-1 font-mono text-xs ${iconColor} hover:text-fg`}
>
<GitPullRequestIcon className="size-3.5 shrink-0" />
#{pr.number}
</a>
) : (
<span className={`ml-auto inline-flex items-center gap-1 font-mono text-xs ${iconColor}`}>
<GitPullRequestIcon className="size-3.5 shrink-0" />
#{pr.number}
</span>
)
<PullRequestChip
number={pr.number}
url={pr.pullRequestUrl}
className={`ml-auto inline-flex items-center gap-1 font-mono text-xs ${iconColor}`}
iconClassName="size-3.5 shrink-0"
/>
)}
</div>
<p className="text-sm leading-snug text-fg-2">{pr.title}</p>
<Link to={`/runs/${pr.id}`} className="block">
<p className="text-sm leading-snug text-fg-2">{pr.title}</p>
</Link>
{(pr.resources != null || pr.comments != null || pr.elapsed != null) && (
<div className="mt-3 flex items-center gap-3 font-mono text-xs">
@ -387,7 +378,7 @@ function PrCard({
)}
</div>
)}
</Link>
</div>
);
}
@ -594,7 +585,8 @@ function RunRow({ run }: { run: RunWithStatus }) {
const statusDisplay = columnStatusDisplay[run.status];
return (
<Link to={`/runs/${run.id}`} className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<div className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<Link to={`/runs/${run.id}`} className="contents">
<span className="flex items-center gap-2 pr-2">
<span className={`size-1.5 shrink-0 rounded-full ${statusDisplay.dot}`} aria-hidden="true" />
<span className={`font-mono text-xs ${statusDisplay.text}`}>{run.statusLabel}</span>
@ -636,31 +628,16 @@ function RunRow({ run }: { run: RunWithStatus }) {
{run.additions != null && <span className="text-mint">+{run.additions.toLocaleString()}</span>}
{run.deletions != null && <span className="text-coral">-{run.deletions.toLocaleString()}</span>}
</span>
</Link>
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-fg-muted">
{run.number != null && (
run.pullRequestUrl ? (
<a
href={run.pullRequestUrl}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
className="inline-flex items-center gap-1.5 hover:text-fg"
>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</a>
) : (
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</>
)
<PullRequestChip number={run.number} url={run.pullRequestUrl}>
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</PullRequestChip>
)}
</span>
</Link>
</div>
);
}

View file

@ -1,8 +1,8 @@
import { useState } from "react";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { Link, useParams } from "react-router";
import { GitPullRequestIcon } from "../components/icons";
import { InlineMarkdown } from "../components/inline-markdown";
import { PullRequestChip } from "../components/pull-request-chip";
import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs";
import type { ColumnStatus, RunWithStatus } from "../data/runs";
import { useWorkflowRuns } from "../lib/queries";
@ -26,7 +26,8 @@ function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithSt
function RunRow({ run }: { run: RunWithStatus }) {
const colors = columnStatusDisplay[run.status];
return (
<Link to={`/runs/${run.id}`} className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<div className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<Link to={`/runs/${run.id}`} className="contents">
<span className="flex items-center gap-2 pr-2">
<span className={`size-2 shrink-0 rounded-full ${colors.dot}`} />
<span className={`text-xs font-medium ${colors.text}`}>{run.statusLabel}</span>
@ -52,31 +53,16 @@ function RunRow({ run }: { run: RunWithStatus }) {
{run.additions != null && <span className="text-mint">+{run.additions.toLocaleString()}</span>}
{run.deletions != null && <span className="text-coral">-{run.deletions.toLocaleString()}</span>}
</span>
</Link>
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-fg-muted">
{run.number != null && (
run.pullRequestUrl ? (
<a
href={run.pullRequestUrl}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
className="inline-flex items-center gap-1.5 hover:text-fg"
>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</a>
) : (
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</>
)
<PullRequestChip number={run.number} url={run.pullRequestUrl}>
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</PullRequestChip>
)}
</span>
</Link>
</div>
);
}

View file

@ -918,7 +918,7 @@ impl Sandbox for DaytonaSandbox {
});
return Err(err);
}
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
self.emit(SandboxEvent::StartCompleted {
provider: "daytona".into(),
duration_ms,
@ -941,7 +941,7 @@ impl Sandbox for DaytonaSandbox {
});
return Err(err);
}
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
self.emit(SandboxEvent::StopCompleted {
provider: "daytona".into(),
duration_ms,
@ -966,7 +966,7 @@ impl Sandbox for DaytonaSandbox {
return Err(err);
}
}
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
self.emit(SandboxEvent::DeleteCompleted {
provider: "daytona".into(),
duration_ms,

View file

@ -17,6 +17,7 @@ use bollard::image::CreateImageOptions;
use bollard::models::HostConfig;
use fabro_github::GitHubCredentials;
use fabro_types::{CommandOutputStream, CommandTermination, RunId};
use fabro_util::time::elapsed_ms;
use futures::StreamExt;
use tokio::sync::OnceCell;
use tokio::{fs, time};
@ -873,7 +874,7 @@ fn docker_not_found(error: &DockerError) -> bool {
})
}
fn docker_already_stopped(error: &DockerError) -> bool {
fn docker_not_modified(error: &DockerError) -> bool {
matches!(error, DockerError::DockerResponseServerError {
status_code: 304,
..
@ -1148,7 +1149,7 @@ impl Sandbox for DockerSandbox {
.start_container(&container_id, None::<StartContainerOptions<String>>)
.await
{
if !docker_already_stopped(&e) {
if !docker_not_modified(&e) {
return self.start_error(crate::Error::context(
format!(
"Failed to start Docker container '{container_id}' with labels {labels:?}"
@ -1170,7 +1171,7 @@ impl Sandbox for DockerSandbox {
)));
}
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
self.emit(SandboxEvent::StartCompleted {
provider: "docker".into(),
duration_ms,
@ -1185,7 +1186,7 @@ impl Sandbox for DockerSandbox {
let start = Instant::now();
let Some(container_id) = self.container_id.get().cloned() else {
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
self.emit(SandboxEvent::StopCompleted {
provider: "docker".into(),
duration_ms,
@ -1207,7 +1208,7 @@ impl Sandbox for DockerSandbox {
.stop_container(&container_id, Some(stop_opts))
.await
{
if !docker_not_found(&e) && !docker_already_stopped(&e) {
if !docker_not_found(&e) && !docker_not_modified(&e) {
return self.stop_error(crate::Error::context(
format!(
"Failed to stop Docker container '{container_id}' with labels {labels:?}"
@ -1217,7 +1218,7 @@ impl Sandbox for DockerSandbox {
}
}
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
self.emit(SandboxEvent::StopCompleted {
provider: "docker".into(),
duration_ms,
@ -1233,7 +1234,7 @@ impl Sandbox for DockerSandbox {
let start = Instant::now();
let Some(container_id) = self.container_id.get().cloned() else {
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
self.emit(SandboxEvent::DeleteCompleted {
provider: "docker".into(),
duration_ms,
@ -1268,7 +1269,7 @@ impl Sandbox for DockerSandbox {
}
}
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
self.emit(SandboxEvent::DeleteCompleted {
provider: "docker".into(),
duration_ms,

View file

@ -4,6 +4,7 @@ use std::time::Instant;
use async_trait::async_trait;
use fabro_static::EnvVars;
use fabro_types::{CommandOutputStream, CommandTermination};
use fabro_util::time::elapsed_ms;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::{Child, Command};
use tokio::task::spawn_blocking;
@ -314,7 +315,7 @@ impl Sandbox for LocalSandbox {
}
};
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
let stdout_str = stdout_task.await.unwrap_or_default();
let stderr_str = stderr_task.await.unwrap_or_default();
@ -402,7 +403,7 @@ impl Sandbox for LocalSandbox {
}
};
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let duration_ms = elapsed_ms(start);
let stdout_bytes = stdout_task
.await
.map_err(|e| crate::Error::context("stdout stream task failed", e))??;

View file

@ -26,17 +26,17 @@ pub use fabro_api::types::{
ArtifactEntry, ArtifactListResponse, BillingByModel, BillingStageRef,
CloseRunPullRequestResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole,
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
CreateRunPullRequestRequest, CreateSecretRequest, DeleteSecretRequest, DiskUsageResponse,
DiskUsageRunRow, DiskUsageSummaryRow, ForkRequest, ForkResponse, MergeRunPullRequestRequest,
MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList,
PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, PruneRunEntry,
PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection, RenderWorkflowGraphRequest,
RewindRequest, RewindResponse, RunArtifactEntry, RunArtifactListResponse, RunBilling,
RunBillingStage, RunBillingTotals, RunError, RunManifest, RunStage, RunStatusResponse,
SandboxFileEntry, SandboxFileListResponse, SshAccessRequest, SshAccessResponse, StageHandler,
StageState, StartRunRequest, SubmitAnswerRequest, SystemFeatures, SystemInfoResponse,
SystemRepairRunIssue, SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse,
WriteBlobResponse,
CreateRunPullRequestRequest, CreateSecretRequest, DeleteRunResponse, DeleteRunSandbox,
DeleteSecretRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, ForkRequest,
ForkResponse, MergeRunPullRequestRequest, MergeRunPullRequestResponse, ModelReference,
PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest,
PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse,
RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RewindRequest, RewindResponse,
RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals,
RunError, RunManifest, RunStage, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse,
SshAccessRequest, SshAccessResponse, StageHandler, StageState, StartRunRequest,
SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRepairRunIssue,
SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, WriteBlobResponse,
};
use fabro_auth::{
CredentialSource, VaultCredentialSource, auth_issue_message, parse_credential_secret,
@ -96,7 +96,6 @@ use fabro_workflow::run_lookup::{
};
use fabro_workflow::run_status::{FailureReason, RunStatus, SuccessReason};
use fabro_workflow::{Error as WorkflowError, operations, pull_request};
use serde::Serialize;
use sha2::{Digest, Sha256};
use tempfile::NamedTempFile;
use tokio::fs;
@ -1591,19 +1590,6 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
const MAX_PAGE_OFFSET: u32 = 1_000_000;
#[derive(Serialize)]
struct DeleteRunResponse {
deleted: bool,
sandbox_preserved: bool,
sandbox: DeleteRunSandboxResponse,
}
#[derive(Serialize)]
struct DeleteRunSandboxResponse {
provider: String,
identifier: String,
}
enum DeleteRunOutcome {
NoContent,
Preserved(DeleteRunResponse),
@ -1723,7 +1709,7 @@ async fn delete_run_sandbox_resource(
return Ok(DeleteRunOutcome::Preserved(DeleteRunResponse {
deleted: true,
sandbox_preserved: true,
sandbox: DeleteRunSandboxResponse {
sandbox: DeleteRunSandbox {
provider: record.provider,
identifier,
},
@ -1771,15 +1757,7 @@ async fn reject_active_delete_without_force(
.ok()
.and_then(|runs| runs.get(run_id).map(|managed_run| managed_run.status));
if let Some(status) = managed_status {
if matches!(
status,
RunStatus::Submitted
| RunStatus::Queued
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. }
) {
if status.requires_force_to_delete() {
return Err(ApiError::new(
StatusCode::CONFLICT,
active_run_delete_message(*run_id, status),
@ -1790,23 +1768,11 @@ async fn reject_active_delete_without_force(
}
match state.store.runs().find(run_id).await {
Ok(Some(summary))
if matches!(
summary.status,
RunStatus::Submitted
| RunStatus::Queued
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. }
) =>
{
Err(ApiError::new(
StatusCode::CONFLICT,
active_run_delete_message(*run_id, summary.status),
)
.into_response())
}
Ok(Some(summary)) if summary.status.requires_force_to_delete() => Err(ApiError::new(
StatusCode::CONFLICT,
active_run_delete_message(*run_id, summary.status),
)
.into_response()),
Ok(_) => Ok(()),
Err(err) => {
Err(ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())

View file

@ -43,11 +43,13 @@ async fn list_run_stages(
let stages = projection
.iter_stages()
.map(|(stage_id, stage)| {
let handler = StageHandler::from_handler_type(
graph
.and_then(|g| g.nodes.get(stage_id.node_id()))
.and_then(|n| n.handler_type()),
);
let handler = stage.handler.unwrap_or_else(|| {
StageHandler::from_handler_type(
graph
.and_then(|g| g.nodes.get(stage_id.node_id()))
.and_then(|n| n.handler_type()),
)
});
run_stage_from_stage_id(
stage_id,
stage_id.node_id().to_string(),

View file

@ -10,7 +10,7 @@ use fabro_types::{
BilledModelUsage, Checkpoint, CommandTermination, Conclusion, EventBody, FailureSignature,
InterviewQuestionRecord, Outcome, PendingInterviewRecord, PullRequestRecord, RunControlAction,
RunEvent, RunId, RunProjection, RunSpec, RunStatus, RunSummary, SandboxRecord, StageCompletion,
StageId, StageOutcome, StageProjection, StageState, StartRecord, TerminalStatus,
StageHandler, StageId, StageOutcome, StageProjection, StageState, StartRecord, TerminalStatus,
first_event_seq,
};
use fabro_util::error::render_with_causes;
@ -292,7 +292,7 @@ impl RunProjectionReducer for RunProjection {
EventBody::InterviewInterrupted(props) if !props.question_id.is_empty() => {
self.pending_interviews.remove(&props.question_id);
}
EventBody::StageStarted(_) => {
EventBody::StageStarted(props) => {
let Some(stage_id) = stored.stage_id.as_ref() else {
return Ok(());
};
@ -301,7 +301,10 @@ impl RunProjectionReducer for RunProjection {
stage_id.visit(),
first_event_seq(event.seq),
);
stage.begin_attempt(ts);
stage.begin_attempt(
ts,
StageHandler::from_handler_type(Some(&props.handler_type)),
);
}
EventBody::StageRetrying(_) => {
let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else {

View file

@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
use crate::{
BilledModelUsage, Checkpoint, Conclusion, DiffSummary, InterviewQuestionRecord,
InvalidTransition, PullRequestRecord, Retro, RunControlAction, RunId, RunSpec, RunStatus,
SandboxRecord, StageCompletion, StageId, StageState, StartRecord,
SandboxRecord, StageCompletion, StageHandler, StageId, StageState, StartRecord,
};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
@ -62,6 +62,8 @@ pub struct StageProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handler: Option<StageHandler>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
/// Server-internal billing usage for the latest attempt; not part of the
/// wire contract because `BilledModelUsage` is not modeled in OpenAPI.
@ -99,6 +101,7 @@ impl StageProjection {
live_streaming: None,
termination: None,
started_at: None,
handler: None,
state: None,
}
}
@ -142,9 +145,10 @@ impl StageProjection {
/// per-attempt field so prior-attempt data does not leak, then record
/// `started_at` and `state = Running`. Preserves `first_event_seq`
/// (identity / sort key).
pub fn begin_attempt(&mut self, started_at: DateTime<Utc>) {
pub fn begin_attempt(&mut self, started_at: DateTime<Utc>, handler: StageHandler) {
*self = Self::new(self.first_event_seq);
self.started_at = Some(started_at);
self.handler = Some(handler);
self.state = Some(StageState::Running);
}
}

View file

@ -55,6 +55,10 @@ impl RunStatus {
)
}
pub fn requires_force_to_delete(self) -> bool {
self.is_active() && !matches!(self, Self::Removing)
}
pub fn blocked_reason(self) -> Option<BlockedReason> {
match self {
Self::Blocked { blocked_reason } => Some(blocked_reason),