diff --git a/plugins/nemo-evaluator/src/nemo_evaluator/jobs/publication.py b/plugins/nemo-evaluator/src/nemo_evaluator/jobs/publication.py index a43a524377..777c6cb67f 100644 --- a/plugins/nemo-evaluator/src/nemo_evaluator/jobs/publication.py +++ b/plugins/nemo-evaluator/src/nemo_evaluator/jobs/publication.py @@ -19,7 +19,8 @@ from __future__ import annotations import logging -from datetime import datetime +import time +from datetime import UTC, datetime from nemo_evaluator.intake.publish import PublishError, PublishReport, publish_to_intake from nemo_evaluator.intake.row_adapter import RowIdentityError, row_result_to_agent_eval_result @@ -33,11 +34,18 @@ from nemo_evaluator_sdk.values.results import EvaluationResult from nemo_platform import AsyncNeMoPlatform from nemo_platform._exceptions import NeMoPlatformError, NotFoundError +from nemo_platform.types.evaluations import EvaluationResponse from nemo_platform_plugin.jobs.schemas import PlatformJobStatus from pydantic import BaseModel, ConfigDict, Field logger = logging.getLogger(__name__) +#: Evaluation metadata keys carrying how long the run took and how long publishing it took, both as +#: string-encoded seconds (the API takes ``dict[str, str]``). Two keys rather than one ``duration`` +#: because a reader cannot tell which of the two a single key means. +EVAL_DURATION_KEY = "eval_duration_sec" +PUBLISH_DURATION_KEY = "publish_duration_sec" + class PublicationOutcome(BaseModel): """What publication did, as reported in the job output. @@ -99,6 +107,40 @@ def _failed(evaluation_id: str, error: str, report: PublishReport | None) -> Pub ) +def _eval_duration_sec(result: AgentEvalResult) -> float | None: + """Wall-clock seconds the run itself took, or ``None`` when it cannot be known.""" + if result.metadata.duration_sec is not None: + return result.metadata.duration_sec + if result.metadata.started_at is not None: + # A row-eval result carries only a start time (see ``intake.row_adapter``), so its length is + # measured here instead. Call this before publishing, not after, or the run duration swallows + # the publish it is meant to be reported alongside. It still slightly overcounts: it covers + # persisting the bundle between the run finishing and this call. + return (datetime.now(UTC) - result.metadata.started_at).total_seconds() + return None + + +async def _record_durations( + platform: AsyncNeMoPlatform, + *, + evaluation: EvaluationResponse, + spec: IntakePublicationSpec, + workspace: str, + eval_duration_sec: float | None, + publish_duration_sec: float, +) -> None: + """Stamp the two durations onto the Evaluation's metadata. + + PATCH replaces the metadata dict wholesale, so the merge with what is already there is + mandatory — a blind write would drop producer keys such as ``eval_config_fileset``. + """ + metadata = dict(evaluation.metadata or {}) + if eval_duration_sec is not None: + metadata[EVAL_DURATION_KEY] = f"{eval_duration_sec:.1f}" + metadata[PUBLISH_DURATION_KEY] = f"{publish_duration_sec:.1f}" + await platform.evaluations.patch(spec.evaluation_id, workspace=workspace, metadata=metadata) + + async def _publish( result: AgentEvalResult, *, @@ -108,13 +150,17 @@ async def _publish( agent_name: str, model_name: str | None, ) -> PublishReport: - """Check the Evaluation exists, then publish under it.""" + """Check the Evaluation exists, publish under it, then record how long both took.""" + # Measured before the publish so the two durations stay disjoint — for a result that carries only + # a start time, reading this afterwards would fold the whole publish into the run's own length. + eval_duration_sec = _eval_duration_sec(result) # The Evaluation must pre-exist — ATIF ingest rejects an unknown one per trial, so without this # a typo would surface as N failed writes after a partial publish instead of one clear stop. # This reads the entity store, so it says nothing about whether Intake's span storage is up; # that surfaces on the first ingest below, and re-publish is idempotent, so it needs no probe. - await platform.evaluations.retrieve(spec.evaluation_id, workspace=workspace) - return await publish_to_intake( + evaluation = await platform.evaluations.retrieve(spec.evaluation_id, workspace=workspace) + publish_started = time.monotonic() + report = await publish_to_intake( result, platform=platform, experiment_id=spec.evaluation_id, @@ -123,6 +169,26 @@ async def _publish( agent_version=spec.agent_version, model_name=model_name, ) + publish_duration_sec = time.monotonic() - publish_started + try: + await _record_durations( + platform, + evaluation=evaluation, + spec=spec, + workspace=workspace, + eval_duration_sec=eval_duration_sec, + publish_duration_sec=publish_duration_sec, + ) + except Exception: + # The durations are informational, and the publish already succeeded. Every caller-side + # handler turns an exception here into a failed job (and a raise when `required`), so letting + # one escape would report a successful publish as a failure. + logger.warning( + "Published to Intake but could not record durations on evaluation %r", + spec.evaluation_id, + exc_info=True, + ) + return report def publish_agent_eval_result( diff --git a/plugins/nemo-evaluator/tests/jobs/test_publication.py b/plugins/nemo-evaluator/tests/jobs/test_publication.py index d2cebeb07d..6c469cc8a9 100644 --- a/plugins/nemo-evaluator/tests/jobs/test_publication.py +++ b/plugins/nemo-evaluator/tests/jobs/test_publication.py @@ -32,7 +32,12 @@ target_agent_identity, ) from nemo_evaluator.jobs.evaluate import EvaluateInputSpec, EvaluateJob, EvaluateSpec -from nemo_evaluator.jobs.publication import PublicationFailedError, publish_agent_eval_result +from nemo_evaluator.jobs.publication import ( + EVAL_DURATION_KEY, + PUBLISH_DURATION_KEY, + PublicationFailedError, + publish_agent_eval_result, +) from nemo_evaluator.jobs.publication_spec import ( IntakePublicationSpec, PublicationSpec, @@ -96,10 +101,19 @@ async def _gen() -> AsyncIterator[object]: class _FakeEvaluations: - def __init__(self, *, missing: bool = False, error: Exception | None = None) -> None: + def __init__( + self, + *, + missing: bool = False, + error: Exception | None = None, + patch_error: Exception | None = None, + ) -> None: self.missing = missing self.error = error + self.patch_error = patch_error self.retrieved: _SessionIds = [] + self.patched: list[dict[str, Any]] = [] + self.metadata: dict[str, str] = {"eval_config_fileset": "fs-1"} async def retrieve(self, name: str, *, workspace: str | None = None) -> object: self.retrieved.append(name) @@ -107,17 +121,26 @@ async def retrieve(self, name: str, *, workspace: str | None = None) -> object: raise self.error if self.missing: raise NotFoundError("not found", response=_response(404), body=None) + return SimpleNamespace(name=name, metadata=dict(self.metadata)) + + async def patch(self, name: str, *, workspace: str | None = None, **kwargs: Any) -> object: + if self.patch_error is not None: + raise self.patch_error + self.patched.append({"name": name, **kwargs}) return SimpleNamespace(name=name) class _FakeIngest: - def __init__(self, calls: list[dict[str, Any]], *, error: Exception | None = None) -> None: + def __init__(self, calls: list[dict[str, Any]], *, error: Exception | None = None, delay_sec: float = 0.0) -> None: self._calls = calls self._error = error + self._delay_sec = delay_sec self.loop: asyncio.AbstractEventLoop | None = None async def create(self, **kwargs: Any) -> None: self.loop = asyncio.get_running_loop() + if self._delay_sec: + await asyncio.sleep(self._delay_sec) if self._error is not None: raise self._error self._calls.append(kwargs) @@ -132,15 +155,17 @@ def __init__( missing_evaluation: bool = False, ingest_error: Exception | None = None, preflight_error: Exception | None = None, + patch_error: Exception | None = None, + ingest_delay_sec: float = 0.0, ) -> None: self.workspace = "default" self.atif_calls: list[dict[str, Any]] = [] self.eval_result_calls: list[dict[str, Any]] = [] self.trace_calls: _SessionIds = [] self.copy_calls = 0 - self.evaluations = _FakeEvaluations(missing=missing_evaluation, error=preflight_error) + self.evaluations = _FakeEvaluations(missing=missing_evaluation, error=preflight_error, patch_error=patch_error) self.intake = SimpleNamespace( - ingest=SimpleNamespace(atif=_FakeIngest(self.atif_calls, error=ingest_error)), + ingest=SimpleNamespace(atif=_FakeIngest(self.atif_calls, error=ingest_error, delay_sec=ingest_delay_sec)), evaluator_results=_FakeIngest(self.eval_result_calls), traces=_FakeTraces(self.trace_calls), ) @@ -317,6 +342,52 @@ def test_publishes_and_reports_what_landed() -> None: assert client.evaluations.retrieved == ["eval-1"] +def test_durations_are_stamped_without_dropping_existing_metadata() -> None: + client = _FakeClient() + _publish(cast(AsyncNeMoPlatform, client)) + + (patched,) = client.evaluations.patched + metadata = patched["metadata"] + # PATCH replaces the metadata dict wholesale, so stamping the durations has to merge with what + # the producer already wrote. A blind write would silently drop `eval_config_fileset`. + assert metadata["eval_config_fileset"] == "fs-1" + assert float(metadata[EVAL_DURATION_KEY]) > 0 + assert float(metadata[PUBLISH_DURATION_KEY]) >= 0 + + +def test_a_started_at_only_result_does_not_count_publish_time_as_run_time() -> None: + # A row-eval result carries no `duration_sec`, so the run's length is derived from `started_at`. + # Deriving it after the publish instead of before would fold the publish into the run. + publish_sec = 0.3 + client = _FakeClient(ingest_delay_sec=publish_sec) + result = _result() + result.metadata.started_at = datetime.now(UTC) + + publish_agent_eval_result( + result, + spec=IntakePublicationSpec(evaluation_id="eval-1", agent_name="a", required=True), + target=None, + workspace="default", + async_sdk=cast(AsyncNeMoPlatform, client), + ) + + (patched,) = client.evaluations.patched + metadata = patched["metadata"] + assert float(metadata[PUBLISH_DURATION_KEY]) >= publish_sec + assert float(metadata[EVAL_DURATION_KEY]) < publish_sec / 2 + + +def test_a_failed_duration_stamp_does_not_fail_the_publish() -> None: + client = _FakeClient(patch_error=APIConnectionError(request=httpx.Request("PATCH", "http://x"))) + outcome = _publish(cast(AsyncNeMoPlatform, client), required=True) + + # The durations are informational and the trials already landed, so losing them must not turn a + # successful publish into a failed job — which is what every caller-side handler would do. + assert outcome.status == PlatformJobStatus.COMPLETED + assert outcome.trial_count == 1 + assert outcome.error is None + + def test_outcome_does_not_leak_experiment_id() -> None: outcome = _publish(_client()) assert "experiment_id" not in outcome.model_dump() diff --git a/web/packages/studio/src/api/evaluation/utils.ts b/web/packages/studio/src/api/evaluation/utils.ts index 0c6a858780..509af2996c 100644 --- a/web/packages/studio/src/api/evaluation/utils.ts +++ b/web/packages/studio/src/api/evaluation/utils.ts @@ -74,6 +74,20 @@ export const EVAL_JOB_KIND_LABEL: Record = { dataset: 'Dataset-Driven', }; +/** Metadata key the evaluator stamps the run's wall-clock seconds under, at publish time. */ +export const EVAL_DURATION_METADATA_KEY = 'eval_duration_sec'; + +/** How long the published run took, in milliseconds, or undefined when it was never recorded. + * Metadata values are strings server-side, so a non-numeric one reads the same as an absent one. */ +export const evalDurationMs = (metadata?: Record | null): number | undefined => { + const raw = metadata?.[EVAL_DURATION_METADATA_KEY]; + // Metadata is free-form and hand-editable, so treat anything that is not a non-negative number as + // absent. `Number('')` is 0, which would otherwise render as a confident "0ms". + if (raw == null || raw.trim() === '') return undefined; + const seconds = Number(raw); + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined; +}; + export const publishedEvaluationName = (job: PlatformJobResponse): string | null => { const intake = asRecord(asRecord(specOf(job).publication)?.intake); return asNonEmptyString(intake?.evaluation_id) ?? null; diff --git a/web/packages/studio/src/components/dataViews/ExperimentDataView/index.tsx b/web/packages/studio/src/components/dataViews/ExperimentDataView/index.tsx index 6392616b7c..8be2a260a7 100644 --- a/web/packages/studio/src/components/dataViews/ExperimentDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/ExperimentDataView/index.tsx @@ -21,6 +21,7 @@ import { formatDurationMs } from '@nemo/common/src/utils/date'; import { formatEvaluatorScore, snakeCaseToTitleCase } from '@nemo/common/src/utils/formatters'; import type { EvaluationFilter, ExperimentResponse } from '@nemo/sdk/generated/platform/schema'; import { Button, Text, Tooltip } from '@nvidia/foundations-react-core'; +import { EVAL_DURATION_METADATA_KEY, evalDurationMs } from '@studio/api/evaluation/utils'; import { ChangesetBadge } from '@studio/components/ChangesetBadge'; import { ExperimentParetoChart } from '@studio/components/charts/ExperimentParetoChart'; import { AddToGroupModal } from '@studio/components/dataViews/ExperimentDataView/AddToGroupModal'; @@ -230,13 +231,17 @@ export const ExperimentDataView: FC = ({ group, paretoV // One column per metadata key: keys are lowercased so case variants (e.g. "status" // and "Status") collapse into one column rather than producing duplicate headers. + // The run duration is excluded because the Duration column below already renders it, formatted; + // publish duration is deliberately left in, which is what surfaces publish latency at all. const metadataKeys = useMemo( () => [ ...new Set( orderedData.flatMap((e) => Object.keys(e.metadata ?? {}).map((k) => k.toLowerCase())) ), - ].sort(), + ] + .filter((key) => key !== EVAL_DURATION_METADATA_KEY) + .sort(), [orderedData] ); @@ -481,6 +486,15 @@ export const ExperimentDataView: FC = ({ group, paretoV enableSorting: true, cell: ({ row }) => {String(row.original.test_case_count ?? 0)}, }), + // Written as metadata at publish time, so it is a string and only present for runs that + // published successfully. Not sortable: the API sorts metadata lexically, which would order + // "9" after "10", and the list pages at 100 so a client-side sort would lie across pages. + accessor((original) => evalDurationMs(original.metadata), { + id: 'eval_duration', + header: 'Duration', + enableSorting: false, + cell: ({ getValue }) => {formatDurationMs(getValue())}, + }), accessor('created_at', { header: 'Created', size: 200, diff --git a/web/packages/studio/src/components/evaluation/Jobs/DetailsPanel.tsx b/web/packages/studio/src/components/evaluation/Jobs/DetailsPanel.tsx index 966ceaf292..610936e63d 100644 --- a/web/packages/studio/src/components/evaluation/Jobs/DetailsPanel.tsx +++ b/web/packages/studio/src/components/evaluation/Jobs/DetailsPanel.tsx @@ -9,18 +9,16 @@ import { StatusBadge } from '@nemo/common/src/components/StatusBadge'; import { PlatformJobTerminalStatuses } from '@nemo/common/src/constants/query'; import { useLiveSeconds } from '@nemo/common/src/hooks/useLiveSeconds'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; -import { - formatTimeInSeconds, - getDifferenceInMilliseconds, - utcToLocalDate, -} from '@nemo/common/src/utils/date'; +import { formatDurationMs, formatTimeInSeconds, utcToLocalDate } from '@nemo/common/src/utils/date'; import { logger } from '@nemo/common/src/utils/logger'; import { getEvaluatorGetEvaluateJobQueryKey, useEvaluatorCancelEvaluateJob, } from '@nemo/sdk/generated/evaluator/api'; import type { EvaluateJob } from '@nemo/sdk/generated/evaluator/schema'; +import { useGetEvaluation } from '@nemo/sdk/generated/platform/api'; import { Banner, Button, Flex, Modal, Panel, Stack, Text } from '@nvidia/foundations-react-core'; +import { evalDurationMs } from '@studio/api/evaluation/utils'; import { ButtonLaunchEvaluation } from '@studio/components/evaluation/ButtonLaunchEvaluation'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getFilesetRoute } from '@studio/routes/utils'; @@ -64,13 +62,6 @@ export const DetailsPanel = ({ evaluationJob, error }: DetailsPanelProps) => { } }; - const differenceInMilliseconds = getDifferenceInMilliseconds( - evaluationJob?.created_at, - evaluationJob?.updated_at - ); - const elapsedSeconds = differenceInMilliseconds - ? Math.floor(differenceInMilliseconds / 1000) - : undefined; const isTerminalStatus = evaluationJob?.status && PlatformJobTerminalStatuses.includes(evaluationJob.status); const canCancelJob = evaluationJob?.status && !isTerminalStatus; @@ -78,6 +69,14 @@ export const DetailsPanel = ({ evaluationJob, error }: DetailsPanelProps) => { startDate: !isTerminalStatus ? utcToLocalDate(evaluationJob?.created_at) : undefined, }); + // A finished job has no end time of its own — `updated_at` is stamped at create and on rerun, + // never on a status change — so the run's length comes from the evaluation it published under. + const publishedEvaluation = evaluationJob?.spec.publication?.intake?.evaluation_id; + const { data: evaluation } = useGetEvaluation(workspace, publishedEvaluation ?? '', { + query: { enabled: !!workspace && !!publishedEvaluation && !!isTerminalStatus }, + }); + const durationMs = evalDurationMs(evaluation?.metadata); + if (error || !evaluationJob) { return ( { status ? ( - {formatTimeInSeconds( - PlatformJobTerminalStatuses.includes(status) ? elapsedSeconds : liveSeconds - )} + {PlatformJobTerminalStatuses.includes(status) + ? durationMs !== undefined && formatDurationMs(durationMs) + : formatTimeInSeconds(liveSeconds)} ) : ( Detail not available diff --git a/web/packages/studio/src/routes/agents/AgentDetailRoute/evaluations/JobsTable.tsx b/web/packages/studio/src/routes/agents/AgentDetailRoute/evaluations/JobsTable.tsx index 8f9f43c84c..12291b8eb4 100644 --- a/web/packages/studio/src/routes/agents/AgentDetailRoute/evaluations/JobsTable.tsx +++ b/web/packages/studio/src/routes/agents/AgentDetailRoute/evaluations/JobsTable.tsx @@ -5,11 +5,15 @@ import { StudioDataView } from '@nemo/common/src/components/DataView/StudioDataV import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; import { StatusBadge } from '@nemo/common/src/components/StatusBadge'; import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState'; +import { PlatformJobTerminalStatuses } from '@nemo/common/src/constants/query'; +import { useLiveSeconds } from '@nemo/common/src/hooks/useLiveSeconds'; import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; +import { formatDurationMs, formatTimeInSeconds, utcToLocalDate } from '@nemo/common/src/utils/date'; import { Text } from '@nvidia/foundations-react-core'; import { EVAL_JOB_KIND_LABEL, type EvalJobRow, + evalDurationMs, evalJobDetailRoute, } from '@studio/api/evaluation/utils'; import type { AgentEvaluationRow } from '@studio/routes/agents/AgentDetailRoute/useAgentDetails'; @@ -18,6 +22,24 @@ import { ListChecks } from 'lucide-react'; import { type ComponentProps, type FC, useCallback } from 'react'; import { useNavigate } from 'react-router'; +/** Elapsed time for one job row: a live counter while it runs, the published run's recorded + * duration once it finished, and an em dash for a job that ended without publishing. + * + * A completed job's own `updated_at` is not an end time — the job row is written at create and on + * rerun only, never on a status transition — so the duration has to come from the evaluation. */ +const DurationCell: FC<{ row: EvalJobRow; durationMs?: number }> = ({ row, durationMs }) => { + const isTerminal = PlatformJobTerminalStatuses.some((status) => status === row.status); + // `enabled` is what actually stops the timer: the hook's interval effect keys off its *locked* + // start date, so clearing `startDate` alone leaves a row that finished mid-poll ticking (and + // re-rendering the table) once a second forever. + const liveSeconds = useLiveSeconds({ + startDate: isTerminal ? undefined : utcToLocalDate(row.created_at), + enabled: !isTerminal, + }); + if (!isTerminal) return {formatTimeInSeconds(liveSeconds)}; + return {formatDurationMs(durationMs)}; +}; + interface JobsTableProps { workspace: string; jobs: EvalJobRow[]; @@ -46,6 +68,16 @@ export const JobsTable: FC = ({ workspace, jobs, evaluations }) [workspace, evaluations] ); + const durationMsFor = useCallback( + (row: EvalJobRow): number | undefined => { + const published = row.evaluationName + ? evaluations.find((evaluation) => evaluation.name === row.evaluationName) + : undefined; + return evalDurationMs(published?.metadata); + }, + [evaluations] + ); + const makeColumns: ComponentProps>['makeColumns'] = useCallback( ({ accessor }) => [ accessor('name', { @@ -73,8 +105,18 @@ export const JobsTable: FC = ({ workspace, jobs, evaluations }) cell: ({ row }) => row.original.created_at ? : '—', }), + // A just-finished run stays on '—' here for up to a minute: the evaluations it reads are + // filtered by `agent_name`, which Intake denormalizes on an interval after publish. + accessor(durationMsFor, { + id: 'duration', + header: 'Duration', + enableSorting: false, + cell: ({ row, getValue }) => ( + ()} /> + ), + }), ], - [] + [durationMsFor] ); return ( diff --git a/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/AgentEvaluationDetailRoute.tsx b/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/AgentEvaluationDetailRoute.tsx index d85ac7e518..5cd97ac626 100644 --- a/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/AgentEvaluationDetailRoute.tsx +++ b/web/packages/studio/src/routes/agents/AgentEvaluationsRoute/AgentEvaluationDetailRoute.tsx @@ -9,8 +9,9 @@ import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; import { StatusBadge } from '@nemo/common/src/components/StatusBadge'; import { useLiveSeconds } from '@nemo/common/src/hooks/useLiveSeconds'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; -import { formatTimeInSeconds, utcToLocalDate } from '@nemo/common/src/utils/date'; +import { formatDurationMs, formatTimeInSeconds, utcToLocalDate } from '@nemo/common/src/utils/date'; import { evaluatorCancelAgentEvaluateJob } from '@nemo/sdk/generated/evaluator/api'; +import { useGetEvaluation } from '@nemo/sdk/generated/platform/api'; import type { PlatformJobStatus } from '@nemo/sdk/generated/platform/schema'; import { Block, @@ -35,6 +36,7 @@ import { joinBundleByTask, parseBundleRef, } from '@studio/api/evaluation/agent-evaluations'; +import { evalDurationMs } from '@studio/api/evaluation/utils'; import { AgentEvalTaskResultsPanel } from '@studio/components/evaluation/AgentEvalTaskResultsPanel'; import { EvalAggregateScoresTable } from '@studio/components/evaluation/EvalAggregateScoresTable'; import { StatusLogsContent } from '@studio/components/evaluation/Jobs/StatusLogsContent'; @@ -96,6 +98,15 @@ export const AgentEvaluationDetailRoute: FC = () => { startDate: !isJobTerminal ? utcToLocalDate(job?.created_at) : undefined, }); + // How long the run took, once it finished. The job row itself cannot answer this — it is written + // at create and on rerun, never on a status change — so the duration comes from the evaluation the + // run published under. Fetched by name, so it lands as soon as the publish does. + const publishedEvaluation = job?.spec.publication?.intake?.evaluation_id; + const { data: evaluation } = useGetEvaluation(workspace, publishedEvaluation ?? '', { + query: { enabled: !!workspace && !!publishedEvaluation && isJobTerminal }, + }); + const durationMs = evalDurationMs(evaluation?.metadata); + const handleCancelJob = async () => { if (!jobName) return; setIsCancelling(true); @@ -210,6 +221,9 @@ export const AgentEvaluationDetailRoute: FC = () => { {!isJobTerminal && liveSeconds !== undefined && ( {formatTimeInSeconds(liveSeconds)} )} + {isJobTerminal && durationMs !== undefined && ( + {formatDurationMs(durationMs)} + )} } loading={isLoadingJob} @@ -238,11 +252,6 @@ export const AgentEvaluationDetailRoute: FC = () => { value={job.created_at ? : ''} loading={isLoadingJob} /> - : ''} - loading={isLoadingJob} - /> {artifactsFileset && (