fix(frontend): show wall-clock execution time for workflow-as-code jobs

This commit is contained in:
Diego Imbert
2026-08-03 17:24:00 +02:00
parent 61f2d8dc6a
commit a02ff30970
5 changed files with 102 additions and 10 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { displayDate, msToReadableTime } from '$lib/utils'
import { displayDate, jobDisplayDurationMs, msToReadableTime } from '$lib/utils'
import type { CompletedJob, QueuedJob } from '$lib/gen'
import Badge from './common/badge/Badge.svelte'
import { forLater } from '$lib/forLater'
@@ -20,7 +20,7 @@
{#if job && 'success' in job && job.success}
<Badge {large} color="green">
Successfully ran in {msToReadableTime(job.duration_ms)}
Successfully ran in {msToReadableTime(jobDisplayDurationMs(job))}
{job.is_skipped ? '(Skipped)' : ''}
{#if job.self_wait_time_ms || job.aggregate_wait_time_ms}
<WaitTimeWarning
@@ -34,7 +34,7 @@
<Badge {large} color="orange" title={job.resolution_note}>
<!-- `resolved_by` is absent both for an automatic resolution and for a manual one
outside EE, so the automatic wording comes from `resolved_automatically`. -->
Failed after {msToReadableTime(job.duration_ms)}, resolved{job.resolved_automatically
Failed after {msToReadableTime(jobDisplayDurationMs(job))}, resolved{job.resolved_automatically
? ' automatically'
: job.resolved_by
? ` by ${job.resolved_by}`
@@ -49,7 +49,7 @@
</Badge>
{:else if job && 'success' in job}
<Badge {large} color="red">
Failed after {msToReadableTime(job.duration_ms)}
Failed after {msToReadableTime(jobDisplayDurationMs(job))}
{#if job.self_wait_time_ms || job.aggregate_wait_time_ms}
<WaitTimeWarning
self_wait_time_ms={job.self_wait_time_ms}
@@ -15,6 +15,7 @@
import { Badge } from '../common'
import { forLater } from '$lib/forLater'
import DurationMs from '../DurationMs.svelte'
import { jobDisplayDurationMs } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
@@ -29,6 +30,7 @@
let { id, children, class: clazz }: Props = $props()
let job: Job | undefined = $state(undefined)
let displayDurationMs = $derived(jobDisplayDurationMs(job))
let hovered = $state(false)
let timeout: number | undefined
let result: any = $state()
@@ -155,9 +157,9 @@
<Badge>
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
</Badge>
{#if job?.['duration_ms']}
{#if displayDurationMs}
<DurationMs
duration_ms={job?.['duration_ms']}
duration_ms={displayDurationMs}
self_wait_time_ms={job?.self_wait_time_ms}
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
/>
@@ -1,6 +1,6 @@
import type { Job } from '$lib/gen'
import { triggerIconMap } from '$lib/components/triggers/utils'
import { formatMemory } from '$lib/utils'
import { formatMemory, jobDisplayDurationMs } from '$lib/utils'
import { flowPathToHref } from '$lib/scripts'
import { Calendar, Bot } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
@@ -332,8 +332,8 @@ export const fieldConfigs: Record<JobField, FieldConfig> = {
field: 'duration',
label: 'Duration',
getValue: (job) => {
if ('duration_ms' in job && job.duration_ms) {
const ms = job.duration_ms
const ms = jobDisplayDurationMs(job)
if (ms) {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${(ms / 60000).toFixed(1)}m`
+50 -1
View File
@@ -10,7 +10,8 @@ import {
getQueryStmtCountHeuristic,
isJobResolvable,
parseDbInputFromAssetSyntax,
apiErrorMessage
apiErrorMessage,
jobDisplayDurationMs
} from './utils'
// Mirrors the backend invariant that only `status = 'failure'` rows can carry a
@@ -598,3 +599,51 @@ describe('apiErrorMessage', () => {
expect(apiErrorMessage(new Error('boom'))).toBe('boom')
})
})
// AI-agent jobs populate `workflow_as_code_status` too, so keying off the column
// alone would swap their duration for wall-clock and count queue-free idle time.
describe('jobDisplayDurationMs', () => {
const wac = { workflow_as_code_status: { _checkpoint: { completed_steps: {} } } }
it('reports wall-clock for a workflow-as-code job', () => {
expect(
jobDisplayDurationMs({
...wac,
started_at: '2026-08-03T15:21:04.863Z',
completed_at: '2026-08-03T15:21:19.612Z',
duration_ms: 2058
})
).toBe(14749)
})
it('keeps duration_ms for an AI-agent job and a plain script', () => {
expect(
jobDisplayDurationMs({
workflow_as_code_status: { 'step-1': { name: 'tool' } },
started_at: '2026-08-03T15:21:04.863Z',
completed_at: '2026-08-03T15:21:19.612Z',
duration_ms: 2058
})
).toBe(2058)
expect(
jobDisplayDurationMs({
started_at: '2026-08-03T15:21:00.809Z',
completed_at: '2026-08-03T15:21:04.858Z',
duration_ms: 4046
})
).toBe(4046)
})
it('falls back to duration_ms when the timestamps are missing or inverted', () => {
expect(jobDisplayDurationMs({ ...wac, started_at: '2026-08-03T15:21:04.863Z', duration_ms: 7 }))
.toBe(7)
expect(
jobDisplayDurationMs({
...wac,
started_at: '2026-08-03T15:21:19.612Z',
completed_at: '2026-08-03T15:21:04.863Z',
duration_ms: 7
})
).toBe(7)
})
})
+41
View File
@@ -253,6 +253,47 @@ export function msToReadableTime(ms: number | undefined, maximumFractionDigits?:
}
}
/**
* A job ran through the workflow-as-code (WAC) executor iff its
* `workflow_as_code_status` carries a `_checkpoint`. AI-agent jobs populate the
* same column but never write `_checkpoint`, so they must not match here.
*/
export function isWorkflowAsCodeJob(
job: { workflow_as_code_status?: unknown } | undefined
): boolean {
const wac = job?.workflow_as_code_status as Record<string, unknown> | undefined
return wac != undefined && wac['_checkpoint'] != undefined
}
/**
* Total execution time to display for a job, in ms.
*
* A WAC job suspends while the task jobs it dispatches run, so its `duration_ms`
* covers only the orchestration script's own compute, not the end-to-end run. For
* those, show the wall-clock span (`completed_at - started_at`) — the same total a
* flow reports. `duration_ms` must keep counting compute only: cloud usage
* accounting and workspace fairness consume it as service time.
*/
export function jobDisplayDurationMs(
job:
| {
started_at?: string
completed_at?: string
duration_ms?: number
workflow_as_code_status?: unknown
}
| undefined
): number | undefined {
if (isWorkflowAsCodeJob(job) && job?.started_at && job?.completed_at) {
const start = new Date(job.started_at).getTime()
const end = new Date(job.completed_at).getTime()
if (isFinite(start) && isFinite(end) && end >= start) {
return end - start
}
}
return job?.duration_ms
}
export function msToReadableTimeShort(
ms: number | undefined,
maximumFractionDigits?: number