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.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 (
-
+
);
}
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 (
-
+
);
}
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),