+
+ {#if $isCloud}
+ Billable Actions
+
+ {:else}
+ State Transitions
+
+ {/if}
+
diff --git a/src/lib/components/standalone-activities/activity-input-and-outcome.svelte b/src/lib/components/standalone-activities/activity-input-and-outcome.svelte
index 8c6ffc8ed6..a2085c9dcf 100644
--- a/src/lib/components/standalone-activities/activity-input-and-outcome.svelte
+++ b/src/lib/components/standalone-activities/activity-input-and-outcome.svelte
@@ -1,40 +1,39 @@
-
-
Input
-
- {#snippet children(decodedValue)}
-
- {/snippet}
-
-
-
-
Result
- {#if has(outcome, 'failure')}
-
- {:else if has(outcome, 'result')}
-
- {#snippet children(decodedValue)}
-
- {/snippet}
-
- {:else}
-
- {/if}
-
+
+ {#if has(outcome, 'failure')}
+
+ {:else if has(outcome, 'result')}
+
+ {:else}
+
+ {/if}
diff --git a/src/lib/components/standalone-activities/start-standalone-activity-form/types.ts b/src/lib/components/standalone-activities/start-standalone-activity-form/types.ts
index 4f6887d3a5..d356d0f4b1 100644
--- a/src/lib/components/standalone-activities/start-standalone-activity-form/types.ts
+++ b/src/lib/components/standalone-activities/start-standalone-activity-form/types.ts
@@ -10,6 +10,7 @@ export interface StandaloneActivityFormData {
startToCloseTimeout: string;
scheduleToCloseTimeout: string;
scheduleToStartTimeout: string;
+ heartbeatTimeout: string;
input: string;
encoding: PayloadInputEncoding;
messageType: string;
diff --git a/src/lib/pages/standalone-activity-details.svelte b/src/lib/pages/standalone-activity-details.svelte
index 089302c39d..beb40e2aa1 100644
--- a/src/lib/pages/standalone-activity-details.svelte
+++ b/src/lib/pages/standalone-activity-details.svelte
@@ -1,246 +1,106 @@
-{#snippet activityExecutionAttemptsBadge(
- attempt: number,
- maximumAttempts: number | undefined,
- lastFailure: Failure,
-)}
- {@const failed = attempt > 1 && !!lastFailure}
- {@const badgeType = failed ? 'danger' : 'default'}
+{#if activity}
+
+
+
+
-
Attempt
-
-
-
- {attempt} of {formatMaximumAttempts(maximumAttempts)}
-
-
- {#if maximumAttempts && !isClosed}
-
- {formatAttemptsLeft(maximumAttempts, attempt)} remaining
-
- {/if}
-
-{/snippet}
-
-{#if $activityExecution}
-
-
- {$activityExecution.info.activityType.name}
-
-
- {#if !isClosed}
-
Current State
-
- Run State
-
- {@render activityExecutionAttemptsBadge(
- $activityExecution.info.attempt,
- $activityExecution.info.retryPolicy?.maximumAttempts,
- $activityExecution.info.lastFailure,
- )}
-
- {/if}
-
Timing and Progress
-
+
+
+
+ {#if activity.running}
+
+
- Schedule Time
-
- Last Started Time
-
- {#if isClosed}
- Execution Duration
-
- Close Time
-
- {/if}
-
- {#if !isClosed}
- Health
-
- Last Heartbeat
-
- Heartbeat Timeout
-
-
- {/if}
- {#if isRetrying}
- Retry State
-
- Current Retry Interval
-
-
- Last Attempted Complete Time
-
-
- Next Attempt Scheduled Time
-
-
- {/if}
+
+
+
+
+
+
+
-
Timeout Configuration
-
- Schedule to Start Timeout
-
- Schedule to Close Timeout
-
- Start to Close Timeout
-
-
-
Worker
-
- Task Queue
-
+
+
-
Last Worker Identity
-
-
-
-
- {#if $activityExecution.info.lastFailure}
-
- {/if}
- {#if $activityExecution.info.retryPolicy}
-
- {/if}
- {#if $activityExecution.info.heartbeatDetails}
-
-
Heartbeat Details
-
- {#snippet children(content)}
-
- {/snippet}
-
-
- {/if}
- {#if $activityExecution.info.header}
-
-
Header
-
- {#snippet children(content)}
-
- {/snippet}
-
-
- {/if}
- {#if $activityExecution.info.priority}
-
- {/if}
+
-
-
+ {:else}
+
+ {/if}
+
{/if}
diff --git a/src/lib/pages/standalone-activity.svelte.ts b/src/lib/pages/standalone-activity.svelte.ts
new file mode 100644
index 0000000000..2fb744f09a
--- /dev/null
+++ b/src/lib/pages/standalone-activity.svelte.ts
@@ -0,0 +1,231 @@
+import { SvelteDate } from 'svelte/reactivity';
+
+import type {
+ ActivityExecution,
+ ActivityExecutionRunState,
+} from '$lib/types/activity-execution';
+import {
+ formatSecondsAbbreviated,
+ fromDurationToNumber,
+} from '$lib/utilities/format-time';
+
+interface ScheduleEntry {
+ attempt: number;
+ waitSeconds: number;
+ runAt: Date;
+}
+
+export const MAX_ATTEMPTS = 50;
+
+export class StandaloneActivity {
+ private activityExecution: ActivityExecution | undefined = $state();
+
+ public initialInterval: number | undefined = $state();
+ public maximumInterval: number | undefined = $state();
+ public maximumAttempts: number | undefined = $state();
+ public backoffCoefficient: number | undefined = $state();
+ public currentInterval: string = $state('-');
+ public currentAttempt: number | undefined = $state();
+ public scheduleTime: string | undefined = $state();
+ public lastAttemptCompletedTime: string | undefined = $state();
+ public nextAttemptScheduleTime: string | undefined = $state();
+ public startToCloseTimeout: string = $state();
+ public scheduleToCloseTimeout: string = $state();
+ public scheduleToStartTimeout: string = $state();
+ public heartbeatTimeout: string = $state();
+ public closeTime: string | undefined = $state();
+ public runState: ActivityExecutionRunState | undefined = $state();
+ public now = $state(SvelteDate.now());
+
+ private scheduleToCloseTimeoutSeconds: number = $derived(
+ parseInt(fromDurationToNumber(this.scheduleToCloseTimeout), 10),
+ );
+
+ private startToCloseTimeoutSeconds: number = $derived(
+ parseInt(fromDurationToNumber(this.startToCloseTimeout), 10),
+ );
+
+ public running = $derived(
+ this.activityExecution?.info?.status ===
+ 'ACTIVITY_EXECUTION_STATUS_RUNNING',
+ );
+
+ public schedule = $derived(
+ this.buildSchedule(this.activityExecution?.info?.scheduleTime),
+ );
+
+ public upcomingAttempts = $derived(
+ this.running
+ ? this.buildUpcomingAttempts(
+ this.activityExecution?.info?.lastAttemptCompleteTime,
+ this.currentAttempt,
+ )
+ : [],
+ );
+
+ public attemptsRemaining = $derived(
+ this.maximumAttempts - this.currentAttempt,
+ );
+
+ public secondsRemaining = $derived.by(() => {
+ if (this.maximumAttempts) {
+ let total = 0;
+ for (let a = this.currentAttempt; a < this.maximumAttempts; a++) {
+ total += this.intervalForAttempt(a);
+
+ total += Math.max(
+ this.scheduleToCloseTimeoutSeconds,
+ this.startToCloseTimeoutSeconds,
+ );
+ }
+
+ return total;
+ }
+ });
+
+ public nextRetrySecondsLeft = $derived.by(() => {
+ if (!this.nextAttemptScheduleTime) return 0;
+
+ return formatSecondsAbbreviated(
+ Math.max(
+ 0,
+ (new SvelteDate(this.nextAttemptScheduleTime).getTime() - this.now) /
+ 1000,
+ ),
+ false,
+ );
+ });
+
+ public deadlineTime = $derived.by(() => {
+ if (!this.secondsRemaining) return undefined;
+
+ return new SvelteDate(
+ new SvelteDate(this.lastAttemptCompletedTime).getTime() +
+ this.secondsRemaining * 1000,
+ );
+ });
+
+ public startToCloseSecondsLeft = $derived.by(() => {
+ if (this.startToCloseTimeoutSeconds === 0) return;
+
+ const startToCloseTimeoutMillis = this.startToCloseTimeoutSeconds * 1000;
+
+ const endTime = new SvelteDate(
+ new SvelteDate(this.activityExecution.info.lastStartedTime).getTime() +
+ startToCloseTimeoutMillis,
+ );
+
+ return Math.max(0, (new SvelteDate(endTime).getTime() - this.now) / 1000);
+ });
+
+ public scheduleToCloseSecondsLeft = $derived.by(() => {
+ if (this.scheduleToCloseTimeoutSeconds === 0) return;
+
+ const scheduleToCloseTimeoutMillis =
+ this.scheduleToCloseTimeoutSeconds * 1000;
+
+ const endTime = new SvelteDate(
+ new SvelteDate(this.activityExecution.info.scheduleTime).getTime() +
+ scheduleToCloseTimeoutMillis,
+ );
+
+ return Math.max(0, (new SvelteDate(endTime).getTime() - this.now) / 1000);
+ });
+
+ constructor(activityExecution: ActivityExecution | undefined) {
+ this.activityExecution = activityExecution;
+ this.currentAttempt = activityExecution?.info?.attempt;
+ this.initialInterval = parseInt(
+ fromDurationToNumber(
+ activityExecution?.info?.retryPolicy.initialInterval,
+ ),
+ 10,
+ );
+ this.maximumInterval = parseInt(
+ fromDurationToNumber(
+ activityExecution?.info?.retryPolicy.maximumInterval,
+ ),
+ 10,
+ );
+ this.maximumAttempts = activityExecution?.info?.retryPolicy.maximumAttempts;
+ this.backoffCoefficient =
+ activityExecution?.info?.retryPolicy.backoffCoefficient;
+ this.scheduleTime = activityExecution?.info?.scheduleTime;
+ this.currentInterval = formatSecondsAbbreviated(
+ fromDurationToNumber(activityExecution?.info?.currentRetryInterval),
+ false,
+ );
+ this.lastAttemptCompletedTime =
+ activityExecution?.info?.lastAttemptCompleteTime;
+ this.nextAttemptScheduleTime =
+ activityExecution?.info?.nextAttemptScheduleTime;
+ this.closeTime = activityExecution?.info?.closeTime;
+ this.scheduleToCloseTimeout =
+ activityExecution?.info.scheduleToCloseTimeout;
+ this.startToCloseTimeout = activityExecution?.info?.startToCloseTimeout;
+ this.scheduleToStartTimeout =
+ activityExecution?.info?.scheduleToStartTimeout;
+ this.heartbeatTimeout = activityExecution?.info?.heartbeatTimeout;
+ this.runState = activityExecution?.info?.runState;
+ }
+
+ public intervalForAttempt(attempt: number): number {
+ return Math.min(
+ this.initialInterval * Math.pow(this.backoffCoefficient, attempt - 1),
+ this.maximumInterval,
+ );
+ }
+
+ private buildUpcomingAttempts(
+ lastAttemptCompletedTime: string | undefined,
+ currentAttempt: number,
+ ): ScheduleEntry[] {
+ if (!lastAttemptCompletedTime) return [];
+ const entries: ScheduleEntry[] = [];
+ let offset = 0;
+
+ const totalAttempts = this.maximumAttempts ?? MAX_ATTEMPTS + currentAttempt;
+
+ for (let attempt = currentAttempt; attempt <= totalAttempts; attempt += 1) {
+ const runAt = new SvelteDate(lastAttemptCompletedTime);
+ const waitSeconds = this.intervalForAttempt(attempt);
+ runAt.setSeconds(runAt.getSeconds() + offset);
+
+ entries.push({
+ attempt,
+ waitSeconds,
+ runAt,
+ });
+
+ offset += waitSeconds;
+ }
+
+ return entries;
+ }
+
+ private buildSchedule(scheduleTime: string | undefined): ScheduleEntry[] {
+ if (!scheduleTime) return [];
+ const entries: ScheduleEntry[] = [];
+ let offset = 0;
+ const totalAttempts = this.maximumAttempts ?? MAX_ATTEMPTS;
+
+ for (let attempt = 1; attempt <= totalAttempts; attempt += 1) {
+ const runAt = new SvelteDate(scheduleTime);
+ const waitSeconds = this.intervalForAttempt(attempt);
+
+ runAt.setSeconds(runAt.getSeconds() + offset);
+
+ if (attempt <= MAX_ATTEMPTS) {
+ entries.push({
+ attempt,
+ waitSeconds,
+ runAt,
+ });
+ }
+
+ offset += waitSeconds;
+ }
+
+ return entries;
+ }
+}
diff --git a/src/lib/pages/standalone-activity.test.ts b/src/lib/pages/standalone-activity.test.ts
new file mode 100644
index 0000000000..9cce80d7f7
--- /dev/null
+++ b/src/lib/pages/standalone-activity.test.ts
@@ -0,0 +1,112 @@
+import * as SvelteReactivity from 'svelte/reactivity';
+
+import { describe, expect, test, vi } from 'vitest';
+
+import type { ActivityExecution } from '$lib/types/activity-execution';
+
+import { StandaloneActivity } from './standalone-activity.svelte';
+
+const MOCK_RETRYING_ACTIVITY: ActivityExecution = {
+ runId: '019d2ad0-26a1-72f9-8295-d4d82cb56c22',
+ info: {
+ activityId: '91246338-45c8-4f5b-9f8e-dd5aa9c8e450',
+ runId: '019d2ad0-26a1-72f9-8295-d4d82cb56c22',
+ activityType: {
+ name: 'LongRunningActivity',
+ },
+ status: 'ACTIVITY_EXECUTION_STATUS_RUNNING',
+ runState: 'PENDING_ACTIVITY_STATE_SCHEDULED',
+ taskQueue: 'session-failure',
+ scheduleToCloseTimeout: '0s',
+ scheduleToStartTimeout: '0s',
+ startToCloseTimeout: '10s',
+ heartbeatTimeout: '0s',
+ retryPolicy: {
+ initialInterval: '1s',
+ backoffCoefficient: 2,
+ maximumInterval: '100s',
+ maximumAttempts: 60,
+ },
+ lastStartedTime: '2026-01-01T00:01:00.000000Z',
+ attempt: 10,
+ scheduleTime: '2026-01-01T00:00:00.000000Z',
+ lastFailure: {},
+ lastWorkerIdentity: '34096@MacBookPro.localdomain@',
+ currentRetryInterval: '100s',
+ lastAttemptCompleteTime: '2026-01-01T00:01:05.000000Z',
+ nextAttemptScheduleTime: '2026-01-01T00:01:40.000000Z',
+ stateTransitionCount: '33',
+ searchAttributes: {},
+ userMetadata: {},
+ },
+ longPollToken: '',
+};
+
+vi.mock('svelte/reactivity', async (importOriginal) => {
+ const original = (await importOriginal()) as typeof SvelteReactivity;
+ const date = new Date(2026, 0, 1, 0, 0, 0, 0);
+
+ class MockSvelteDate {
+ static now() {
+ return date.getTime();
+ }
+
+ getSeconds() {
+ return date.getSeconds();
+ }
+
+ setSeconds(seconds: number) {
+ date.setSeconds(seconds);
+ }
+
+ getTime() {
+ return date.getTime();
+ }
+
+ constructor(...args: ConstructorParameters
) {
+ return new Date(...args);
+ }
+ }
+
+ return {
+ ...original,
+ SvelteDate: MockSvelteDate,
+ };
+});
+
+describe('Standalone Activity Page Class', () => {
+ test('derived properties are set for a retrying activity', () => {
+ const activity = new StandaloneActivity(MOCK_RETRYING_ACTIVITY);
+
+ expect(activity.attemptsRemaining).toEqual(50);
+ expect(activity.running).toBe(true);
+ expect(activity.upcomingAttempts.length).toBe(51); // includes current attempt
+ expect(activity.secondsRemaining).toBe(5500);
+ expect(activity.nextRetrySecondsLeft).toBe('1m 40s');
+ expect(activity.deadlineTime.toISOString()).toBe(
+ '2026-01-01T01:32:45.000Z',
+ );
+ expect(activity.startToCloseSecondsLeft).toBe(70);
+ expect(activity.scheduleToCloseSecondsLeft).toBe(undefined);
+ });
+
+ const cases = [
+ [1, 1],
+ [2, 2],
+ [3, 4],
+ [4, 8],
+ [5, 16],
+ [6, 32],
+ [7, 64],
+ [8, 100],
+ ];
+
+ test.each(cases)(
+ 'intervalForAttempt %i is %i seconds',
+ (attempt, interval) => {
+ const activity = new StandaloneActivity(MOCK_RETRYING_ACTIVITY);
+
+ expect(activity.intervalForAttempt(attempt)).toBe(interval);
+ },
+ );
+});
diff --git a/src/lib/services/standalone-activities.ts b/src/lib/services/standalone-activities.ts
index fe40d39d4d..70598d53db 100644
--- a/src/lib/services/standalone-activities.ts
+++ b/src/lib/services/standalone-activities.ts
@@ -149,6 +149,9 @@ const toStartActivityExecutionRequest = async (
...(activityFormData.scheduleToStartTimeout && {
scheduleToStartTimeout: activityFormData.scheduleToStartTimeout,
}),
+ ...(activityFormData.heartbeatTimeout && {
+ heartbeatTimeout: activityFormData.heartbeatTimeout,
+ }),
retryPolicy: {
...(activityFormData.initialInterval && {
initialInterval: activityFormData.initialInterval,
diff --git a/src/lib/theme/plugin.ts b/src/lib/theme/plugin.ts
index 9097c73524..e232498a16 100644
--- a/src/lib/theme/plugin.ts
+++ b/src/lib/theme/plugin.ts
@@ -243,6 +243,13 @@ const temporal = plugin(
danger: css('--color-text-danger'),
}),
+ fill: ({ theme }) => ({
+ ...theme('colors'),
+
+ secondary: css('--color-surface-secondary'),
+ brand: css('--color-surface-brand'),
+ subtle: css('--color-surface-subtle'),
+ }),
extend: {
transitionProperty: {
width: 'width',
diff --git a/src/lib/types/activity-execution.ts b/src/lib/types/activity-execution.ts
index 25433c54cb..100fc049c0 100644
--- a/src/lib/types/activity-execution.ts
+++ b/src/lib/types/activity-execution.ts
@@ -21,6 +21,14 @@ export type ActivityExecutionStatus =
| 'ACTIVITY_EXECUTION_STATUS_TERMINATED'
| 'ACTIVITY_EXECUTION_STATUS_TIMED_OUT';
+export type ActivityExecutionRunState =
+ | 'PENDING_ACTIVITY_STATE_UNSPECIFIED'
+ | 'PENDING_ACTIVITY_STATE_SCHEDULED'
+ | 'PENDING_ACTIVITY_STATE_STARTED'
+ | 'PENDING_ACTIVITY_STATE_CANCEL_REQUESTED'
+ | 'PENDING_ACTIVITY_STATE_PAUSED'
+ | 'PENDING_ACTIVITY_STATE_PAUSE_REQUESTED';
+
export const ACTIVITY_ID_REUSE_POLICIES = [
'ACTIVITY_ID_REUSE_POLICY_UNSPECIFIED',
'ACTIVITY_ID_REUSE_POLICY_ALLOW_DUPLICATE',
@@ -54,7 +62,7 @@ export type ActivityExecutionOutcome =
failure: Failure;
};
-interface RetryPolicy {
+export interface RetryPolicy {
initialInterval: string;
backoffCoefficient: number;
maximumInterval: string;
@@ -66,12 +74,12 @@ export interface ActivityExecutionInfo {
runId: string;
activityType: ActivityType;
status: ActivityExecutionStatus;
- runState?: string; // only for running activities
+ runState?: ActivityExecutionRunState; // only for running activities
taskQueue: string;
scheduleToCloseTimeout: string;
scheduleToStartTimeout: string;
startToCloseTimeout: string;
- lastHeartbeatTime: string;
+ lastHeartbeatTime?: string;
heartbeatDetails?: Payloads;
heartbeatTimeout: string;
retryPolicy: RetryPolicy;
@@ -79,7 +87,7 @@ export interface ActivityExecutionInfo {
attempt: number;
executionDuration?: string;
scheduleTime: string;
- closeTime: string;
+ closeTime?: string;
lastWorkerIdentity: string;
lastAttemptCompleteTime: string;
nextAttemptScheduleTime: string;
@@ -110,6 +118,7 @@ export interface StartActivityExecutionRequest {
startToCloseTimeout: string;
scheduleToCloseTimeout: string;
scheduleToStartTimeout: string;
+ heartbeatTimeout?: string;
input?: Payloads;
userMetadata?: UserMetadata;
retryPolicy?: RetryPolicy;
diff --git a/src/lib/utilities/format-time.ts b/src/lib/utilities/format-time.ts
index 6ad3e5ba9e..b81415af35 100644
--- a/src/lib/utilities/format-time.ts
+++ b/src/lib/utilities/format-time.ts
@@ -250,10 +250,13 @@ export const getTimestampDifference = (
return Math.abs(parse1 - parse2);
};
-export const formatSecondsAbbreviated = (seconds: number | string): string => {
+export const formatSecondsAbbreviated = (
+ seconds: number | string,
+ includeMilliseconds = true,
+): string => {
const start = new Date();
const end = new Date(start.getTime() + Number(seconds) * 1000);
- return formatDistanceAbbreviated({ start, end, includeMilliseconds: true });
+ return formatDistanceAbbreviated({ start, end, includeMilliseconds });
};
export const fromDurationToNumber = (duration: string): string => {
@@ -261,10 +264,10 @@ export const fromDurationToNumber = (duration: string): string => {
return '';
}
- return duration?.replace('s', '');
+ return duration.replace('s', '');
};
-export const fromNumberToDuration = (duration: string): string => {
+export const fromNumberToDuration = (duration: string | number): string => {
if (!duration) return undefined;
- return duration + 's';
+ return `${duration}s`;
};
diff --git a/src/lib/utilities/to-duration.ts b/src/lib/utilities/to-duration.ts
index 297dc84cb1..46250416ff 100644
--- a/src/lib/utilities/to-duration.ts
+++ b/src/lib/utilities/to-duration.ts
@@ -6,6 +6,7 @@ import {
sub,
} from 'date-fns';
+import { fromNumberToDuration } from './format-time';
import { isObject, isString } from './is';
import { pluralize } from './pluralize';
@@ -128,15 +129,18 @@ export const fromDate = (
};
export const fromSeconds = (
- seconds: string,
+ seconds: string | number,
{ delimiter = ', ' } = {},
): string => {
if (!seconds) return '';
- const parsedSeconds = parseInt(seconds);
- const parsedDecimal = parseFloat(`.${seconds.split('.')[1] ?? 0}`);
+ const parsedSeconds =
+ typeof seconds === 'number' ? seconds : parseInt(seconds);
+ const stringSeconds =
+ typeof seconds === 'number' ? fromNumberToDuration(seconds) : seconds;
+ const parsedDecimal = parseFloat(`.${stringSeconds.split('.')[1] ?? 0}`);
- if (!seconds.endsWith('s')) return '';
+ if (!stringSeconds.endsWith('s')) return '';
if (isNaN(parsedSeconds) || isNaN(parsedDecimal)) return '';
const start = new Date(Date.UTC(0, 0, 0, 0, 0, 0));
diff --git a/src/routes/(app)/namespaces/[namespace]/activities/[activityId]/[runId]/details/+page.svelte b/src/routes/(app)/namespaces/[namespace]/activities/[activityId]/[runId]/details/+page.svelte
index e807d38b01..521072f8f7 100644
--- a/src/routes/(app)/namespaces/[namespace]/activities/[activityId]/[runId]/details/+page.svelte
+++ b/src/routes/(app)/namespaces/[namespace]/activities/[activityId]/[runId]/details/+page.svelte
@@ -4,7 +4,6 @@
import PageTitle from '$lib/components/page-title.svelte';
import StandaloneActivityDetails from '$lib/pages/standalone-activity-details.svelte';
- const namespace = $derived(page.params.namespace);
const activityId = $derived(page.params.activityId);
@@ -13,4 +12,4 @@
url={page.url.href}
/>
-
+