Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
formatRetryExpiration,
} from '$lib/utilities/format-event-attributes';
import { formatDuration, getDuration } from '$lib/utilities/format-time';
import { getPendingActivityElapsed } from '$lib/utilities/pending-activity-elapsed';
import {
getPendingActivityTimeouts,
hasPopulatedActivityOptions,
} from '$lib/utilities/pending-activity-timeouts';
import { toTimeDifference } from '$lib/utilities/to-time-difference';

let {
Expand All @@ -49,6 +54,17 @@
),
);

const timeouts = $derived(
getPendingActivityTimeouts(
hasPopulatedActivityOptions(activity.activityOptions)
? activity.activityOptions
: scheduledEvent?.activityTaskScheduledEventAttributes,
),
);
const elapsed = $derived(
getPendingActivityElapsed(activity, scheduledEvent?.timestamp),
);

let coreUser = coreUserStore();
let showActivityCommands = $derived(
activityCommandsEnabled(
Expand Down Expand Up @@ -90,6 +106,21 @@
)}
{/if}
{@render detail(translate('workflows.attempt'), attempts)}
{#each timeouts as timeout (timeout.field)}
{@render detail(
translate(timeout.label),
timeout.formatted || translate('workflows.timeout-not-set'),
)}
{/each}
{#if elapsed.currentAttempt}
{@render detail(
translate('workflows.current-attempt-elapsed'),
elapsed.currentAttempt,
)}
{/if}
{#if elapsed.total}
{@render detail(translate('workflows.total-elapsed'), elapsed.total)}
{/if}
{#if activity.scheduledTime}
{@const timeDifference = toTimeDifference({
date: activity.scheduledTime,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/i18n/locales/en/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ export const Strings = {
attempt: 'Attempt',
'attempts-left': 'Attempts Left',
'last-attempt-completed-time': 'Last Attempt Completed Time',
'current-attempt-elapsed': 'Current Attempt Elapsed',
'total-elapsed': 'Total Elapsed',
retry: 'Retry',
'next-retry': 'Next Retry',
'retry-interval': 'Retry Interval',
Expand Down Expand Up @@ -355,6 +357,7 @@ export const Strings = {
'schedule-to-close-timeout': 'Schedule to Close Timeout',
'schedule-to-start-timeout': 'Schedule to Start Timeout',
'start-to-close-timeout': 'Start to Close Timeout',
'timeout-not-set': 'Not set / Unlimited',
'related-events': 'Related Events',
priority: 'Priority',
fairness: 'Fairness',
Expand Down
90 changes: 90 additions & 0 deletions src/lib/utilities/pending-activity-elapsed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';

import { getPendingActivityElapsed } from './pending-activity-elapsed';

const now = new Date('2026-10-25T12:00:00.000Z');
const hoursAgo = (hours: number) =>
new Date(now.getTime() - hours * 3_600_000).toISOString();
const minutesAgo = (minutes: number) =>
new Date(now.getTime() - minutes * 60_000).toISOString();

describe('getPendingActivityElapsed', () => {
it('splits the current attempt elapsed from the total elapsed for a started activity', () => {
const elapsed = getPendingActivityElapsed(
{
state: 'Started',
lastStartedTime: minutesAgo(30),
lastAttemptCompleteTime: minutesAgo(45),
scheduledTime: hoursAgo(3),
},
hoursAgo(2),
now,
);

expect(elapsed.currentAttempt).toBe('30 minutes');
expect(elapsed.total).toBe('2 hours');
});

it('measures a waiting attempt from when the previous attempt completed', () => {
const elapsed = getPendingActivityElapsed(
{
state: 'Scheduled',
lastStartedTime: hoursAgo(1),
lastAttemptCompleteTime: minutesAgo(10),
scheduledTime: hoursAgo(3),
},
hoursAgo(2),
now,
);

expect(elapsed.currentAttempt).toBe('10 minutes');
expect(elapsed.total).toBe('2 hours');
});

it('falls back to the scheduled time for a first attempt that has not started', () => {
const elapsed = getPendingActivityElapsed(
{
state: 'Scheduled',
lastStartedTime: null,
lastAttemptCompleteTime: null,
scheduledTime: minutesAgo(5),
},
minutesAgo(5),
now,
);

expect(elapsed.currentAttempt).toBe('5 minutes');
expect(elapsed.total).toBe('5 minutes');
});

it('falls back to the pending activity scheduledTime when the scheduled event is unavailable', () => {
const elapsed = getPendingActivityElapsed(
{
state: 'Started',
lastStartedTime: minutesAgo(20),
lastAttemptCompleteTime: null,
scheduledTime: hoursAgo(1),
},
undefined,
now,
);

expect(elapsed.currentAttempt).toBe('20 minutes');
expect(elapsed.total).toBe('1 hour');
});

it('returns empty strings when no time anchors exist', () => {
const elapsed = getPendingActivityElapsed(
{
state: 'Scheduled',
lastStartedTime: null,
lastAttemptCompleteTime: null,
scheduledTime: null,
},
undefined,
now,
);

expect(elapsed).toEqual({ currentAttempt: '', total: '' });
});
});
52 changes: 52 additions & 0 deletions src/lib/utilities/pending-activity-elapsed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { PendingActivity } from '$lib/types/events';

import { formatDistance, type ValidTime } from './format-time';

export type PendingActivityElapsed = {
/** Time since the current attempt started (or, while it waits, since the previous attempt completed). */
currentAttempt: string;
/** Time since the activity was first scheduled. */
total: string;
};

type ElapsedActivity = {
state: PendingActivity['state'];
lastStartedTime?: ValidTime | null;
lastAttemptCompleteTime?: ValidTime | null;
scheduledTime?: ValidTime | null;
};

const distance = (
start: ValidTime | undefined | null,
now: ValidTime,
): string => (start ? formatDistance({ start, end: now }) : '');

/**
* Elapsed times for the Pending Activity view, split into the current
* attempt's elapsed time and the total elapsed time across all attempts.
* Previously only the total was visible, and users mistook it for the
* current attempt's time when judging StartToClose.
*
* `firstScheduledTime` is the ActivityTaskScheduled event's timestamp: the
* moment the activity was first scheduled. It falls back to the pending
* activity's own scheduledTime when the event is unavailable.
*/
export const getPendingActivityElapsed = (
activity: ElapsedActivity,
firstScheduledTime?: ValidTime | null,
now: ValidTime = new Date(),
): PendingActivityElapsed => {
const totalStart = firstScheduledTime ?? activity.scheduledTime ?? undefined;

const attemptStarted =
activity.state === 'Started' || activity.state === 'CancelRequested'
? activity.lastStartedTime
: undefined;
const currentAttemptStart =
attemptStarted ?? activity.lastAttemptCompleteTime ?? totalStart;

return {
currentAttempt: distance(currentAttemptStart, now),
total: distance(totalStart, now),
};
};
75 changes: 75 additions & 0 deletions src/lib/utilities/pending-activity-timeouts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';

import {
getPendingActivityTimeouts,
hasPopulatedActivityOptions,
} from './pending-activity-timeouts';

describe('getPendingActivityTimeouts', () => {
it('returns no timeouts without a source', () => {
expect(getPendingActivityTimeouts(undefined)).toEqual([]);
expect(getPendingActivityTimeouts(null)).toEqual([]);
});

it('formats all four timeouts in issue order', () => {
const timeouts = getPendingActivityTimeouts({
scheduleToCloseTimeout: '3600s',
scheduleToStartTimeout: '60s',
startToCloseTimeout: '600s',
heartbeatTimeout: '10s',
});

expect(timeouts.map((t) => t.field)).toEqual([
'scheduleToCloseTimeout',
'scheduleToStartTimeout',
'startToCloseTimeout',
'heartbeatTimeout',
]);
expect(timeouts.map((t) => t.formatted)).toEqual([
'1 hour',
'1 minute',
'10 minutes',
'10 seconds',
]);
});

it('leaves unset timeouts unformatted so the view can label them', () => {
const timeouts = getPendingActivityTimeouts({
scheduleToCloseTimeout: '0s',
startToCloseTimeout: '30s',
});

expect(timeouts.map((t) => t.formatted)).toEqual([
'',
'',
'30 seconds',
'',
]);
});

it('accepts proto object durations as well as seconds strings', () => {
const timeouts = getPendingActivityTimeouts({
scheduleToCloseTimeout: { seconds: '90', nanos: 0 },
heartbeatTimeout: { seconds: 45, nanos: 500_000_000 },
});

expect(timeouts[0].formatted).toBe('1 minute, 30 seconds');
expect(timeouts[3].formatted).toBe('45 seconds, 500 milliseconds');
});
});

describe('hasPopulatedActivityOptions', () => {
it('is false for missing or stub options', () => {
expect(hasPopulatedActivityOptions(undefined)).toBe(false);
expect(hasPopulatedActivityOptions(null)).toBe(false);
expect(hasPopulatedActivityOptions({})).toBe(false);
expect(hasPopulatedActivityOptions({ taskQueue: {} })).toBe(false);
});

it('is true when a task queue name is present', () => {
expect(hasPopulatedActivityOptions({ taskQueue: 'demo' })).toBe(true);
expect(hasPopulatedActivityOptions({ taskQueue: { name: 'demo' } })).toBe(
true,
);
});
});
Loading
Loading