diff --git a/apps/fabro-web/app/components/inline-markdown.tsx b/apps/fabro-web/app/components/inline-markdown.tsx index 9ff7a8212..907430577 100644 --- a/apps/fabro-web/app/components/inline-markdown.tsx +++ b/apps/fabro-web/app/components/inline-markdown.tsx @@ -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 {renderTokens(tokens)}; + const children = useMemo(() => renderTokens(Lexer.lexInline(content)), [content]); + return {children}; } diff --git a/apps/fabro-web/app/components/pull-request-chip.tsx b/apps/fabro-web/app/components/pull-request-chip.tsx new file mode 100644 index 000000000..8b561f6c7 --- /dev/null +++ b/apps/fabro-web/app/components/pull-request-chip.tsx @@ -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 = ( + <> + + #{number} + {children} + + ); + + if (url == null) { + return {content}; + } + + return ( + + {content} + + ); +} diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index fb9844d7c..a7b8b66bd 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -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 } }) { )} {run.number != null && run.pullRequestUrl && ( - - - #{run.number} - + )} diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 032394035..452796320 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -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 ( - +
- + {pr.repo} - + {lifecycleLabel != null && ( {lifecycleLabel} )} {pr.number != null && ( - pr.pullRequestUrl ? ( - event.stopPropagation()} - className={`ml-auto inline-flex items-center gap-1 font-mono text-xs ${iconColor} hover:text-fg`} - > - - #{pr.number} - - ) : ( - - - #{pr.number} - - ) + )}
-

{pr.title}

+ +

{pr.title}

+ {(pr.resources != null || pr.comments != null || pr.elapsed != null) && (
@@ -387,7 +378,7 @@ function PrCard({ )}
)} - +
); } @@ -594,7 +585,8 @@ function RunRow({ run }: { run: RunWithStatus }) { const statusDisplay = columnStatusDisplay[run.status]; return ( - +
+ + {run.number != null && ( - run.pullRequestUrl ? ( - event.stopPropagation()} - className="inline-flex items-center gap-1.5 hover:text-fg" - > - - #{run.number} - {run.checks != null && } - - ) : ( - <> - - #{run.number} - {run.checks != null && } - - ) + + {run.checks != null && } + )} - +
); } diff --git a/apps/fabro-web/app/routes/workflow-runs.tsx b/apps/fabro-web/app/routes/workflow-runs.tsx index 0811f4320..75741abcb 100644 --- a/apps/fabro-web/app/routes/workflow-runs.tsx +++ b/apps/fabro-web/app/routes/workflow-runs.tsx @@ -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 ( - +
+ {run.statusLabel} @@ -52,31 +53,16 @@ function RunRow({ run }: { run: RunWithStatus }) { {run.additions != null && +{run.additions.toLocaleString()}} {run.deletions != null && -{run.deletions.toLocaleString()}} + {run.number != null && ( - run.pullRequestUrl ? ( - event.stopPropagation()} - className="inline-flex items-center gap-1.5 hover:text-fg" - > - - #{run.number} - {run.checks != null && } - - ) : ( - <> - - #{run.number} - {run.checks != null && } - - ) + + {run.checks != null && } + )} - +
); } diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index c0ee9a87b..d333b1750 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -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, diff --git a/lib/crates/fabro-sandbox/src/docker.rs b/lib/crates/fabro-sandbox/src/docker.rs index b1e743d9d..8ae8d8dc7 100644 --- a/lib/crates/fabro-sandbox/src/docker.rs +++ b/lib/crates/fabro-sandbox/src/docker.rs @@ -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::>) .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, diff --git a/lib/crates/fabro-sandbox/src/local.rs b/lib/crates/fabro-sandbox/src/local.rs index d7709bace..9b5091a61 100644 --- a/lib/crates/fabro-sandbox/src/local.rs +++ b/lib/crates/fabro-sandbox/src/local.rs @@ -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))??; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index b5c898221..102179da1 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -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 - { - 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()) diff --git a/lib/crates/fabro-server/src/server/handler/billing.rs b/lib/crates/fabro-server/src/server/handler/billing.rs index ec4cd99f0..73ff31d1b 100644 --- a/lib/crates/fabro-server/src/server/handler/billing.rs +++ b/lib/crates/fabro-server/src/server/handler/billing.rs @@ -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(), diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index ceb7a462c..b196ab1c2 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -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 { diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index 10d1d90a8..4d88a45ea 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -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>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub handler: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub duration_ms: Option, /// 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) { + pub fn begin_attempt(&mut self, started_at: DateTime, handler: StageHandler) { *self = Self::new(self.first_event_seq); self.started_at = Some(started_at); + self.handler = Some(handler); self.state = Some(StageState::Running); } } diff --git a/lib/crates/fabro-types/src/status.rs b/lib/crates/fabro-types/src/status.rs index 398be2770..f6ee5f233 100644 --- a/lib/crates/fabro-types/src/status.rs +++ b/lib/crates/fabro-types/src/status.rs @@ -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 { match self { Self::Blocked { blocked_reason } => Some(blocked_reason),