feat: Step Function + hardning

This commit is contained in:
Zakaria
2026-09-27 23:26:00 -04:00
parent ce8400757c
commit 0d613ae256
11 changed files with 672 additions and 1 deletions
+26 -1
View File
@@ -49,7 +49,12 @@ export const apiEndpointKeys = {
deleteEventBridgeRule: "clouds.services.resources.events.rule.delete",
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list",
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
startWorkflowExecution: "clouds.services.resources.workflows.execution.start",
listWorkflowExecutions: "clouds.services.resources.workflows.executions.list",
describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe",
getWorkflowExecutionHistory: "clouds.services.resources.workflows.execution.history.get",
stopWorkflowExecution: "clouds.services.resources.workflows.execution.stop",
},
storage: {
objects: {
@@ -530,6 +535,26 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.startWorkflowExecution,
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions", method: "POST", telemetry: { service: "workflows" } },
],
[
apiEndpointKeys.clouds.resources.listWorkflowExecutions,
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions", method: "GET", telemetry: { service: "workflows" } },
],
[
apiEndpointKeys.clouds.resources.describeWorkflowExecution,
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn", method: "GET", telemetry: { service: "workflows" } },
],
[
apiEndpointKeys.clouds.resources.getWorkflowExecutionHistory,
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn/history", method: "GET", telemetry: { service: "workflows" } },
],
[
apiEndpointKeys.clouds.resources.stopWorkflowExecution,
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn/stop", method: "POST", telemetry: { service: "workflows" } },
],
[
apiEndpointKeys.clouds.resources.get,
{
@@ -234,6 +234,27 @@ export interface PublishNotificationResult {
messageId: string | null;
}
export interface WorkflowExecutionSummary {
executionArn: string;
name: string;
status: string;
startDate: string;
stopDate?: string;
}
export interface WorkflowExecutionDetail extends WorkflowExecutionSummary {
stateMachineArn: string;
input?: string;
output?: string;
error?: string;
cause?: string;
}
export interface WorkflowExecutionHistoryPage {
events: Array<Record<string, unknown>>;
nextToken?: string;
}
export interface CreateApiGatewayLambdaRouteInput {
path: string;
method: string;
@@ -768,6 +789,53 @@ export async function invokeCloudResource(
return res.data;
}
export async function startWorkflowExecution(
stateMachineArn: string,
input: {input: string; name?: string},
): Promise<{executionArn: string; startDate: string}> {
const res = await apiClient.call<{executionArn: string; startDate: string}, {input: string; name?: string}>(
apiEndpointKeys.clouds.resources.startWorkflowExecution,
requestOptions("aws", "workflows", {body: input}),
{cloud: "aws", id: stateMachineArn},
);
return res.data;
}
export async function listWorkflowExecutions(stateMachineArn: string, signal?: AbortSignal): Promise<WorkflowExecutionSummary[]> {
const res = await apiClient.call<WorkflowExecutionSummary[]>(
apiEndpointKeys.clouds.resources.listWorkflowExecutions,
requestOptions("aws", "workflows", {signal}),
{cloud: "aws", id: stateMachineArn},
);
return res.data;
}
export async function describeWorkflowExecution(stateMachineArn: string, executionArn: string, signal?: AbortSignal): Promise<WorkflowExecutionDetail> {
const res = await apiClient.call<WorkflowExecutionDetail>(
apiEndpointKeys.clouds.resources.describeWorkflowExecution,
requestOptions("aws", "workflows", {signal}),
{cloud: "aws", id: stateMachineArn, executionArn},
);
return res.data;
}
export async function getWorkflowExecutionHistory(stateMachineArn: string, executionArn: string, nextToken?: string, signal?: AbortSignal): Promise<WorkflowExecutionHistoryPage> {
const res = await apiClient.call<WorkflowExecutionHistoryPage>(
apiEndpointKeys.clouds.resources.getWorkflowExecutionHistory,
requestOptions("aws", "workflows", {signal, params: {nextToken}}),
{cloud: "aws", id: stateMachineArn, executionArn},
);
return res.data;
}
export async function stopWorkflowExecution(stateMachineArn: string, executionArn: string): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.stopWorkflowExecution,
requestOptions("aws", "workflows"),
{cloud: "aws", id: stateMachineArn, executionArn},
);
}
export async function publishNotification(
cloud: CloudProvider,
topicArn: string,
@@ -58,6 +58,7 @@ import { EcrImageBrowser } from "@/components/EcrImageBrowser";
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
interface DynamicResourceViewProps {
cloud: CloudProvider;
@@ -947,6 +948,9 @@ export function DynamicResourceView({
runtimeReachable={canUseRuntime}
/>
)}
{service === "workflows" && cloud === "aws" && (
<WorkflowExecutionDebugger resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{editingResource && schema.updateFields && (
<div
className="modal-overlay"
@@ -0,0 +1,113 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {afterEach, describe, expect, test, vi} from 'vitest'
import {WorkflowExecutionDebugger} from '@/components/WorkflowExecutionDebugger'
import {describeWorkflowExecution, getWorkflowExecutionHistory, listWorkflowExecutions, startWorkflowExecution, stopWorkflowExecution} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
vi.mock('@/api/cloudProxyClient', () => ({
describeWorkflowExecution: vi.fn(),
getWorkflowExecutionHistory: vi.fn(),
listWorkflowExecutions: vi.fn(),
startWorkflowExecution: vi.fn(),
stopWorkflowExecution: vi.fn(),
}))
const machine: CloudResource = {id: 'arn:aws:states:us-east-1:000000000000:stateMachine:orders', name: 'orders', cloud: 'aws', service: 'workflows', type: 'state-machine', region: 'us-east-1', createdAt: null, metadata: {machineType: 'STANDARD'}}
const executionArn = 'arn:aws:states:us-east-1:000000000000:execution:orders:run-1'
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><WorkflowExecutionDebugger resource={machine} runtimeReachable={true}/></QueryClientProvider>)
}
afterEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
describe('WorkflowExecutionDebugger', () => {
test('starts an execution and automatically aggregates paged history in backend order', async () => {
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z'}])
vi.mocked(startWorkflowExecution).mockResolvedValue({executionArn, startDate: '2026-07-28T10:01:00.000Z'})
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id, input: '{}', output: '{"ok":true}'})
vi.mocked(getWorkflowExecutionHistory).mockResolvedValueOnce({events: [{id: 1, type: 'ExecutionStarted', timestamp: '2026-07-28T10:01:00.000Z'}], nextToken: 'next'}).mockResolvedValueOnce({events: [{id: 1, type: 'ExecutionStarted', timestamp: '2026-07-28T10:01:00.000Z'}, {id: 2, type: 'ExecutionSucceeded'}]})
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByText('run-1'))
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toBeInTheDocument()
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionSucceeded') === true)).toBeInTheDocument()
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(1, machine.id, executionArn, undefined, expect.any(AbortSignal))
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(2, machine.id, executionArn, 'next', expect.any(AbortSignal))
expect(screen.getAllByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toHaveLength(1)
expect(screen.queryByRole('button', {name: 'Load more history'})).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('JSON input'), {target: {value: '{"orderId":"1"}'}})
await user.click(screen.getByRole('button', {name: 'Start execution'}))
await waitFor(() => expect(startWorkflowExecution).toHaveBeenCalledWith(machine.id, {input: '{"orderId":"1"}'}))
})
test('confirms stopping only a running execution', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z'}])
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id})
vi.mocked(getWorkflowExecutionHistory).mockResolvedValue({events: []})
vi.mocked(stopWorkflowExecution).mockResolvedValue()
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByText('run-1'))
await user.click(await screen.findByRole('button', {name: 'Stop execution'}))
await waitFor(() => expect(stopWorkflowExecution).toHaveBeenCalledWith(machine.id, executionArn))
})
test('keeps loaded events visible when a later history page cannot load', async () => {
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z'}])
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id})
vi.mocked(getWorkflowExecutionHistory).mockResolvedValueOnce({events: [{id: 1, type: 'ExecutionStarted'}], nextToken: 'next'}).mockRejectedValueOnce(new Error('Unable to load the next history page'))
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByText('run-1'))
expect(await screen.findByText('Unable to load the next history page')).toBeInTheDocument()
expect(screen.getByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('ExecutionStarted') === true)).toBeInTheDocument()
})
test('polls only the trailing history page for a running execution and appends new events', async () => {
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z'}])
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id})
vi.mocked(getWorkflowExecutionHistory)
.mockResolvedValueOnce({events: [{id: 1, type: 'ExecutionStarted'}], nextToken: 'page-2'})
.mockResolvedValueOnce({events: [{id: 2, type: 'TaskStateEntered'}]})
.mockResolvedValueOnce({events: [{id: 2, type: 'TaskStateEntered'}, {id: 3, type: 'TaskSucceeded'}]})
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByText('run-1'))
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('TaskStateEntered') === true)).toBeInTheDocument()
expect(await screen.findByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('TaskSucceeded') === true, {}, {timeout: 3_000})).toBeInTheDocument()
expect(getWorkflowExecutionHistory).toHaveBeenNthCalledWith(3, machine.id, executionArn, 'page-2', expect.any(AbortSignal))
expect(getWorkflowExecutionHistory).toHaveBeenCalledTimes(3)
})
test('marks history incomplete at the explicit page ceiling while retaining loaded progress', async () => {
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z'}])
vi.mocked(describeWorkflowExecution).mockResolvedValue({executionArn, name: 'run-1', status: 'SUCCEEDED', startDate: '2026-07-28T10:01:00.000Z', stateMachineArn: machine.id})
let page = 0
vi.mocked(getWorkflowExecutionHistory).mockImplementation(async () => {
page++
return {events: [{id: page, type: 'Event'}], nextToken: 'more'}
})
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByText('run-1'))
expect(await screen.findByText('History incomplete: reached the 10-page limit.')).toBeInTheDocument()
expect(getWorkflowExecutionHistory).toHaveBeenCalledTimes(10)
expect(screen.getAllByText((_, element) => element?.tagName === 'SUMMARY' && element.textContent?.includes('Event') === true)).toHaveLength(10)
})
})
@@ -0,0 +1,195 @@
import {useEffect, useState} from 'react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {Play, RefreshCw, Square} from 'lucide-react'
import {describeWorkflowExecution, getWorkflowExecutionHistory, listWorkflowExecutions, startWorkflowExecution, stopWorkflowExecution, type WorkflowExecutionDetail} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
interface WorkflowExecutionDebuggerProps {
resource?: CloudResource
runtimeReachable: boolean
}
const POLL_MS = 2_000
const MAX_HISTORY_PAGES = 10
const MAX_HISTORY_EVENTS = 10_000
interface ExecutionHistory {
events: Array<Record<string, unknown>>
loading: boolean
incomplete?: string
error?: unknown
}
export function WorkflowExecutionDebugger({resource, runtimeReachable}: WorkflowExecutionDebuggerProps) {
const queryClient = useQueryClient()
const [input, setInput] = useState('{}')
const [name, setName] = useState('')
const [selectedExecutionArn, setSelectedExecutionArn] = useState<string>()
const isStandard = resource?.metadata.machineType === 'STANDARD'
const enabled = Boolean(resource && runtimeReachable && isStandard)
const executionKey = ['workflow-executions', resource?.id]
useEffect(() => {
setSelectedExecutionArn(undefined)
}, [resource?.id])
const executions = useQuery({
queryKey: executionKey,
queryFn: ({signal}) => listWorkflowExecutions(resource!.id, signal),
enabled,
refetchInterval: (query) => query.state.data?.some((execution) => execution.status === 'RUNNING') ? POLL_MS : false,
})
const detail = useQuery({
queryKey: ['workflow-execution', resource?.id, selectedExecutionArn],
queryFn: ({signal}) => describeWorkflowExecution(resource!.id, selectedExecutionArn!, signal),
enabled: enabled && Boolean(selectedExecutionArn),
refetchInterval: (query) => query.state.data?.status === 'RUNNING' ? POLL_MS : false,
})
const history = useExecutionHistory(resource?.id, selectedExecutionArn, enabled && Boolean(detail.data), detail.data?.status === 'RUNNING')
const start = useMutation({
mutationFn: () => startWorkflowExecution(resource!.id, {input, ...(name.trim() ? {name: name.trim()} : {})}),
onSuccess: (execution) => {
setSelectedExecutionArn(execution.executionArn)
void queryClient.invalidateQueries({queryKey: executionKey})
},
})
const stop = useMutation({
mutationFn: () => stopWorkflowExecution(resource!.id, selectedExecutionArn!),
onSuccess: () => {
void queryClient.invalidateQueries({queryKey: executionKey})
void queryClient.invalidateQueries({queryKey: ['workflow-execution', resource?.id, selectedExecutionArn]})
},
})
if (!resource) return null
if (!isStandard) return <section className="table-panel"><p className="muted compact-text">Execution debugging is available for Standard state machines only.</p></section>
const running = detail.data?.status === 'RUNNING'
return <section className="table-panel">
<div className="dynamic-stage-header"><div><p className="eyebrow">Execution debugger</p><h3>Run and inspect executions</h3></div><button className="button" type="button" disabled={!enabled || executions.isFetching} onClick={() => executions.refetch()}><RefreshCw size={13}/>{executions.isFetching ? 'Refreshing' : 'Refresh'}</button></div>
<div className="input-row" style={{alignItems: 'end'}}>
<label className="field"><span>JSON input</span><textarea className="input" aria-label="JSON input" rows={4} value={input} onChange={(event) => setInput(event.target.value)}/></label>
<label className="field"><span>Execution name (optional)</span><input className="input" aria-label="Execution name (optional)" value={name} onChange={(event) => setName(event.target.value)}/></label>
<button className="button" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Start execution'}</button>
</div>
<p className="muted compact-text">Execution input, output, and history can contain sensitive data.</p>
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start execution.')}</p>}
{executions.isError && <p className="error-text compact-text">{message(executions.error, 'Unable to load executions.')}</p>}
<table className="table"><thead><tr><th>Name</th><th>Status</th><th>Started</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.executionArn} onClick={() => setSelectedExecutionArn(execution.executionArn)} style={{cursor: 'pointer'}}><td><code>{execution.name}</code></td><td>{execution.status}</td><td>{execution.startDate}</td></tr>)}</tbody></table>
{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}
{selectedExecutionArn && <ExecutionDetail detail={detail.data} loading={detail.isLoading} error={detail.error} running={running} stopping={stop.isPending} onStop={() => { if (window.confirm('Stop this running execution?')) stop.mutate() }} stopError={stop.error} events={history.events} loadingHistory={history.loading} historyError={history.error} historyIncomplete={history.incomplete}/>}
</section>
}
function ExecutionDetail({detail, loading, error, running, stopping, onStop, stopError, events, loadingHistory, historyError, historyIncomplete}: {detail?: WorkflowExecutionDetail; loading: boolean; error: unknown; running: boolean; stopping: boolean; onStop: () => void; stopError: unknown; events: Array<Record<string, unknown>>; loadingHistory: boolean; historyError: unknown; historyIncomplete?: string}) {
if (loading) return <p className="muted compact-text">Loading execution...</p>
if (error) return <p className="error-text compact-text">{message(error, 'Unable to load execution.')}</p>
if (!detail) return null
return <div style={{marginTop: 16}}><div className="dynamic-stage-header"><div><h3>{detail.name} <span className="muted">{detail.status}</span></h3></div>{running && <button className="button danger" type="button" disabled={stopping} onClick={onStop}><Square size={13}/>{stopping ? 'Stopping' : 'Stop execution'}</button>}</div>
{Boolean(stopError) && <p className="error-text compact-text">{message(stopError, 'Unable to stop execution.')}</p>}
<ExecutionData label="Input" value={detail.input}/><ExecutionData label="Output" value={detail.output}/>{detail.error && <ExecutionData label="Error" value={detail.error}/>} {detail.cause && <ExecutionData label="Cause" value={detail.cause}/>}
<h4>Timeline</h4>{loadingHistory && <p className="muted compact-text">Loading history...</p>}{historyIncomplete && <p className="muted compact-text">History incomplete: {historyIncomplete}</p>}{Boolean(historyError) && <p className="error-text compact-text">{message(historyError, 'Unable to load complete execution history.')}</p>}
{events.map((event, index) => <details key={`${String(event.id ?? index)}-${index}`}><summary>{String(event.timestamp ?? '')} {String(event.type ?? 'Event')}</summary><pre>{JSON.stringify(event, null, 2)}</pre></details>)}
</div>
}
function useExecutionHistory(stateMachineArn: string | undefined, executionArn: string | undefined, enabled: boolean, running: boolean): ExecutionHistory {
const [history, setHistory] = useState<ExecutionHistory>({events: [], loading: false})
useEffect(() => {
if (!enabled || !stateMachineArn || !executionArn) {
setHistory({events: [], loading: false})
return
}
const controller = new AbortController()
const events: Array<Record<string, unknown>> = []
const eventKeys = new Set<string>()
let pages = 0
let trailingPageToken: string | undefined
let pollTimer: ReturnType<typeof setTimeout> | undefined
const publish = (loading: boolean, incomplete?: string, error?: unknown) => {
if (!controller.signal.aborted) setHistory({events: [...events], loading, ...(incomplete ? {incomplete} : {}), ...(error ? {error} : {})})
}
const addPage = (page: {events: Array<Record<string, unknown>>; nextToken?: string}): boolean => {
for (const event of page.events) {
const key = event.id === undefined ? JSON.stringify(event) : `id:${String(event.id)}`
if (!eventKeys.has(key)) {
if (events.length === MAX_HISTORY_EVENTS) return false
eventKeys.add(key)
events.push(event)
}
}
return true
}
const limitMessage = (pageLimitReached: boolean, eventLimitReached: boolean) => {
if (pageLimitReached && eventLimitReached) return `reached the ${MAX_HISTORY_PAGES}-page and ${MAX_HISTORY_EVENTS}-event limits.`
if (pageLimitReached) return `reached the ${MAX_HISTORY_PAGES}-page limit.`
return `reached the ${MAX_HISTORY_EVENTS}-event limit.`
}
const loadNewPages = async (nextToken: string | undefined, poll: boolean): Promise<boolean> => {
let token = nextToken
do {
if (!poll && pages === MAX_HISTORY_PAGES) {
publish(false, limitMessage())
return false
}
const page = await getWorkflowExecutionHistory(stateMachineArn, executionArn, token, controller.signal)
if (controller.signal.aborted) return false
if (!poll) pages++
trailingPageToken = token
const withinEventLimit = addPage(page)
const pageLimitReached = pages === MAX_HISTORY_PAGES && Boolean(page.nextToken)
const eventLimitReached = !withinEventLimit || (events.length === MAX_HISTORY_EVENTS && Boolean(page.nextToken))
if (pageLimitReached || eventLimitReached) {
publish(false, limitMessage(pageLimitReached, eventLimitReached))
return false
}
publish(true)
token = page.nextToken
poll = false
} while (token)
return true
}
const load = async () => {
try {
publish(true)
if (!await loadNewPages(undefined, false)) return
publish(false)
if (running) {
pollTimer = setTimeout(() => void poll(), POLL_MS)
}
} catch (error) {
if (!controller.signal.aborted) publish(false, undefined, error)
}
}
const poll = async () => {
try {
publish(true)
if (!await loadNewPages(trailingPageToken, true)) return
publish(false)
pollTimer = setTimeout(() => void poll(), POLL_MS)
} catch (error) {
if (!controller.signal.aborted) publish(false, undefined, error)
}
}
void load()
return () => {
controller.abort()
if (pollTimer) clearTimeout(pollTimer)
}
}, [enabled, executionArn, running, stateMachineArn])
return history
}
function ExecutionData({label, value}: {label: string; value?: string}) {
if (value === undefined) return null
return <div><h4>{label}</h4><pre>{value}</pre></div>
}
function message(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback
}