feat: Step Function + hardning
This commit is contained in:
@@ -3,7 +3,12 @@ import {
|
||||
CreateStateMachineCommand,
|
||||
DeleteStateMachineCommand,
|
||||
DescribeStateMachineCommand,
|
||||
DescribeExecutionCommand,
|
||||
GetExecutionHistoryCommand,
|
||||
ListExecutionsCommand,
|
||||
ListStateMachinesCommand,
|
||||
StartExecutionCommand,
|
||||
StopExecutionCommand,
|
||||
type SFNClient,
|
||||
} from '@aws-sdk/client-sfn'
|
||||
import {AwsStepFunctionsAdapter} from './AwsStepFunctionsAdapter'
|
||||
@@ -221,4 +226,39 @@ describe('AwsStepFunctionsAdapter', () => {
|
||||
|
||||
expect((sent[0] as DeleteStateMachineCommand).input.stateMachineArn).toBe(ARN)
|
||||
})
|
||||
|
||||
test('uses AWS execution commands with input data and history pagination', async () => {
|
||||
const executionArn = 'arn:aws:states:us-east-1:000000000000:execution:orders:run-1'
|
||||
const {client, sent} = stubSfn((command) => {
|
||||
if (command instanceof StartExecutionCommand) return {executionArn, startDate: new Date('2026-07-28T10:01:00.000Z')}
|
||||
if (command instanceof ListExecutionsCommand) return {executions: [{executionArn, name: 'run-1', status: 'RUNNING', startDate: new Date('2026-07-28T10:01:00.000Z')}]}
|
||||
if (command instanceof DescribeExecutionCommand) return {executionArn, name: 'run-1', status: 'FAILED', startDate: new Date('2026-07-28T10:01:00.000Z'), stateMachineArn: ARN, input: '{}', error: 'States.TaskFailed', cause: 'boom'}
|
||||
if (command instanceof GetExecutionHistoryCommand) return {events: [{id: 1, type: 'ExecutionStarted', timestamp: new Date()}], nextToken: 'page-2'}
|
||||
return {}
|
||||
})
|
||||
const adapter = new AwsStepFunctionsAdapter(client)
|
||||
|
||||
await adapter.startExecution(ARN, {input: '{"orderId":"1"}', name: 'run-1'})
|
||||
await adapter.listExecutions(ARN)
|
||||
const detail = await adapter.describeExecution(executionArn)
|
||||
const history = await adapter.getExecutionHistory(executionArn, 'previous-page')
|
||||
await adapter.stopExecution(executionArn)
|
||||
|
||||
expect((sent[0] as StartExecutionCommand).input).toMatchObject({stateMachineArn: ARN, input: '{"orderId":"1"}', name: 'run-1'})
|
||||
expect((sent[1] as ListExecutionsCommand).input.stateMachineArn).toBe(ARN)
|
||||
expect((sent[2] as DescribeExecutionCommand).input.executionArn).toBe(executionArn)
|
||||
expect((sent[3] as GetExecutionHistoryCommand).input).toMatchObject({executionArn, includeExecutionData: true, maxResults: 1_000, nextToken: 'previous-page'})
|
||||
expect((sent[4] as StopExecutionCommand).input.executionArn).toBe(executionArn)
|
||||
expect(detail).toMatchObject({input: '{}', error: 'States.TaskFailed', cause: 'boom'})
|
||||
expect(history.nextToken).toBe('page-2')
|
||||
})
|
||||
|
||||
test('rejects invalid execution JSON before calling AWS', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
|
||||
await expect(new AwsStepFunctionsAdapter(client).startExecution(ARN, {input: 'not json'})).rejects.toThrow(
|
||||
new ValidationError('input must be valid JSON'),
|
||||
)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,18 @@ import {
|
||||
CreateStateMachineCommand,
|
||||
DeleteStateMachineCommand,
|
||||
DescribeStateMachineCommand,
|
||||
DescribeExecutionCommand,
|
||||
GetExecutionHistoryCommand,
|
||||
ListExecutionsCommand,
|
||||
ListStateMachinesCommand,
|
||||
StartExecutionCommand,
|
||||
StopExecutionCommand,
|
||||
type SFNClient,
|
||||
type StateMachineListItem,
|
||||
} from '@aws-sdk/client-sfn'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {STATE_MACHINE_NAME_PATTERN, awsWorkflowsSchema} from '../cloud-spi/workflowsSchema'
|
||||
import type {StartWorkflowExecutionInput, WorkflowExecutionDetail, WorkflowExecutionHistoryPage, WorkflowExecutionSummary} from '../cloud-spi/workflowExecutions'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
@@ -104,6 +110,65 @@ export class AwsStepFunctionsAdapter implements CloudServiceAdapter {
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.sfn.send(new DeleteStateMachineCommand({stateMachineArn: id}))
|
||||
}
|
||||
|
||||
async startExecution(stateMachineArn: string, input: StartWorkflowExecutionInput): Promise<{executionArn: string; startDate: Date}> {
|
||||
const result = await this.sfn.send(new StartExecutionCommand({
|
||||
stateMachineArn,
|
||||
input: validExecutionInput(input.input),
|
||||
...(input.name ? {name: input.name} : {}),
|
||||
}))
|
||||
if (!result.executionArn || !result.startDate) throw new RuntimeError('Step Functions did not return an execution ARN and start date')
|
||||
return {executionArn: result.executionArn, startDate: result.startDate}
|
||||
}
|
||||
|
||||
async listExecutions(stateMachineArn: string): Promise<WorkflowExecutionSummary[]> {
|
||||
const executions: WorkflowExecutionSummary[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const result = await this.sfn.send(new ListExecutionsCommand({stateMachineArn, ...(nextToken ? {nextToken} : {})}))
|
||||
for (const execution of result.executions ?? []) {
|
||||
if (execution.executionArn && execution.name && execution.status && execution.startDate) {
|
||||
executions.push({
|
||||
executionArn: execution.executionArn,
|
||||
name: execution.name,
|
||||
status: execution.status,
|
||||
startDate: execution.startDate,
|
||||
...(execution.stopDate ? {stopDate: execution.stopDate} : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
nextToken = result.nextToken
|
||||
} while (nextToken)
|
||||
return executions
|
||||
}
|
||||
|
||||
async describeExecution(executionArn: string): Promise<WorkflowExecutionDetail> {
|
||||
const result = await this.sfn.send(new DescribeExecutionCommand({executionArn}))
|
||||
if (!result.executionArn || !result.name || !result.status || !result.startDate || !result.stateMachineArn) {
|
||||
throw new RuntimeError('Step Functions returned an incomplete execution description')
|
||||
}
|
||||
return {
|
||||
executionArn: result.executionArn,
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
startDate: result.startDate,
|
||||
stateMachineArn: result.stateMachineArn,
|
||||
...(result.stopDate ? {stopDate: result.stopDate} : {}),
|
||||
...(result.input !== undefined ? {input: result.input} : {}),
|
||||
...(result.output !== undefined ? {output: result.output} : {}),
|
||||
...(result.error !== undefined ? {error: result.error} : {}),
|
||||
...(result.cause !== undefined ? {cause: result.cause} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async getExecutionHistory(executionArn: string, nextToken?: string): Promise<WorkflowExecutionHistoryPage> {
|
||||
const result = await this.sfn.send(new GetExecutionHistoryCommand({executionArn, includeExecutionData: true, maxResults: 1_000, ...(nextToken ? {nextToken} : {})}))
|
||||
return {events: (result.events ?? []).map((event) => event as unknown as Record<string, unknown>), ...(result.nextToken ? {nextToken: result.nextToken} : {})}
|
||||
}
|
||||
|
||||
async stopExecution(executionArn: string): Promise<void> {
|
||||
await this.sfn.send(new StopExecutionCommand({executionArn}))
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(machine: StateMachineListItem | DescribedMachine): CloudResource {
|
||||
@@ -172,6 +237,16 @@ function requiredDefinition(value: unknown): string {
|
||||
return raw
|
||||
}
|
||||
|
||||
function validExecutionInput(value: string): string {
|
||||
const input = requiredString(value, 'input')
|
||||
try {
|
||||
JSON.parse(input)
|
||||
} catch {
|
||||
throw new ValidationError('input must be valid JSON')
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
return (error as {name?: string}).name === 'StateMachineDoesNotExist'
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface StartWorkflowExecutionInput {
|
||||
input: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface WorkflowExecutionSummary {
|
||||
executionArn: string
|
||||
name: string
|
||||
status: string
|
||||
startDate: Date
|
||||
stopDate?: Date
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -729,6 +729,44 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/workflows/resources/:id/executions', async (c) => {
|
||||
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const body = await jsonBody<{input?: unknown; name?: unknown}>(c)
|
||||
if (typeof body.input !== 'string') throw new ValidationError('input must be a JSON string')
|
||||
try {
|
||||
JSON.parse(body.input)
|
||||
} catch {
|
||||
throw new ValidationError('input must be valid JSON')
|
||||
}
|
||||
if (body.name !== undefined && (typeof body.name !== 'string' || !body.name.trim())) throw new ValidationError('name must be a non-empty string')
|
||||
return c.json(await svc(c).startWorkflowExecution('aws', c.req.param('id'), {input: body.input, ...(typeof body.name === 'string' ? {name: body.name} : {})}), 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/workflows/resources/:id/executions', async (c) => {
|
||||
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => c.json(await svc(c).listWorkflowExecutions('aws', c.req.param('id'))))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/workflows/resources/:id/executions/:executionArn', async (c) => {
|
||||
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => c.json(await svc(c).describeWorkflowExecution('aws', c.req.param('executionArn'))))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/workflows/resources/:id/executions/:executionArn/history', async (c) => {
|
||||
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => c.json(await svc(c).getWorkflowExecutionHistory('aws', c.req.param('executionArn'), c.req.query('nextToken'))))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/workflows/resources/:id/executions/:executionArn/stop', async (c) => {
|
||||
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).stopWorkflowExecution('aws', c.req.param('executionArn'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {createCloudRoutes} from './clouds'
|
||||
import type {CloudProxyService} from '../service/CloudProxyService'
|
||||
|
||||
const machineArn = 'arn:aws:states:us-east-1:000000000000:stateMachine:orders'
|
||||
const executionArn = 'arn:aws:states:us-east-1:000000000000:execution:orders:run-1'
|
||||
|
||||
describe('workflow execution routes', () => {
|
||||
test('routes execution debugging through the AWS workflow contract', async () => {
|
||||
const calls: Array<[string, ...unknown[]]> = []
|
||||
const service = {
|
||||
async startWorkflowExecution(...args: unknown[]) {
|
||||
calls.push(['start', ...args])
|
||||
return {executionArn, startDate: new Date('2026-07-28T10:01:00.000Z')}
|
||||
},
|
||||
async listWorkflowExecutions(...args: unknown[]) {
|
||||
calls.push(['list', ...args])
|
||||
return [{executionArn, name: 'run-1', status: 'RUNNING', startDate: new Date('2026-07-28T10:01:00.000Z')}]
|
||||
},
|
||||
async describeWorkflowExecution(...args: unknown[]) {
|
||||
calls.push(['describe', ...args])
|
||||
return {executionArn, name: 'run-1', status: 'RUNNING', startDate: new Date(), stateMachineArn: machineArn, input: '{}'}
|
||||
},
|
||||
async getWorkflowExecutionHistory(...args: unknown[]) {
|
||||
calls.push(['history', ...args])
|
||||
return {events: [{id: 1, type: 'ExecutionStarted'}], nextToken: 'next'}
|
||||
},
|
||||
async stopWorkflowExecution(...args: unknown[]) {
|
||||
calls.push(['stop', ...args])
|
||||
},
|
||||
} as unknown as CloudProxyService
|
||||
const app = createCloudRoutes(service)
|
||||
const resource = encodeURIComponent(machineArn)
|
||||
const execution = encodeURIComponent(executionArn)
|
||||
|
||||
const started = await app.request(`/aws/services/workflows/resources/${resource}/executions`, {method: 'POST', body: JSON.stringify({input: '{}', name: 'run-1'}), headers: {'content-type': 'application/json'}})
|
||||
const listed = await app.request(`/aws/services/workflows/resources/${resource}/executions`)
|
||||
const described = await app.request(`/aws/services/workflows/resources/${resource}/executions/${execution}`)
|
||||
const history = await app.request(`/aws/services/workflows/resources/${resource}/executions/${execution}/history?nextToken=previous`)
|
||||
const stopped = await app.request(`/aws/services/workflows/resources/${resource}/executions/${execution}/stop`, {method: 'POST'})
|
||||
|
||||
expect([started.status, listed.status, described.status, history.status, stopped.status]).toEqual([201, 200, 200, 200, 200])
|
||||
expect(calls).toEqual([
|
||||
['start', 'aws', machineArn, {input: '{}', name: 'run-1'}],
|
||||
['list', 'aws', machineArn],
|
||||
['describe', 'aws', executionArn],
|
||||
['history', 'aws', executionArn, 'previous'],
|
||||
['stop', 'aws', executionArn],
|
||||
])
|
||||
})
|
||||
|
||||
test('rejects invalid execution input before calling the adapter', async () => {
|
||||
const app = createCloudRoutes({} as CloudProxyService)
|
||||
const response = await app.request(`/aws/services/workflows/resources/${encodeURIComponent(machineArn)}/executions`, {method: 'POST', body: JSON.stringify({input: 'not json'}), headers: {'content-type': 'application/json'}})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
await expect(response.json()).resolves.toMatchObject({error: 'input must be valid JSON'})
|
||||
})
|
||||
})
|
||||
@@ -75,6 +75,7 @@ import type {
|
||||
} from '../cloud-spi/childCollections'
|
||||
import {NotSupportedError} from '../cloud-spi/errors'
|
||||
import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry'
|
||||
import type {StartWorkflowExecutionInput, WorkflowExecutionDetail, WorkflowExecutionHistoryPage, WorkflowExecutionSummary} from '../cloud-spi/workflowExecutions'
|
||||
import {SERVICE_CATALOG_ENTRIES, displayNameFor, routeFor} from '../cloud-spi/serviceCatalog'
|
||||
import {toHttpError} from '../cloud-spi/errors'
|
||||
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
||||
@@ -761,6 +762,34 @@ export class CloudProxyService {
|
||||
return this.appConfigAdapter(cloud).getAppConfigDeployment!(applicationId, environmentId, deploymentNumber)
|
||||
}
|
||||
|
||||
async startWorkflowExecution(cloud: CloudProvider, stateMachineArn: string, input: StartWorkflowExecutionInput): Promise<{executionArn: string; startDate: Date}> {
|
||||
return this.workflowAdapter(cloud).startExecution(stateMachineArn, input)
|
||||
}
|
||||
|
||||
async listWorkflowExecutions(cloud: CloudProvider, stateMachineArn: string): Promise<WorkflowExecutionSummary[]> {
|
||||
return this.workflowAdapter(cloud).listExecutions(stateMachineArn)
|
||||
}
|
||||
|
||||
async describeWorkflowExecution(cloud: CloudProvider, executionArn: string): Promise<WorkflowExecutionDetail> {
|
||||
return this.workflowAdapter(cloud).describeExecution(executionArn)
|
||||
}
|
||||
|
||||
async getWorkflowExecutionHistory(cloud: CloudProvider, executionArn: string, nextToken?: string): Promise<WorkflowExecutionHistoryPage> {
|
||||
return this.workflowAdapter(cloud).getExecutionHistory(executionArn, nextToken)
|
||||
}
|
||||
|
||||
async stopWorkflowExecution(cloud: CloudProvider, executionArn: string): Promise<void> {
|
||||
await this.workflowAdapter(cloud).stopExecution(executionArn)
|
||||
}
|
||||
|
||||
private workflowAdapter(cloud: CloudProvider) {
|
||||
const adapter = this.requireAdapter(cloud, 'workflows')
|
||||
if (!('startExecution' in adapter) || !('listExecutions' in adapter) || !('describeExecution' in adapter) || !('getExecutionHistory' in adapter) || !('stopExecution' in adapter)) {
|
||||
throw new NotSupportedError(`Workflow executions are not supported for ${cloud}`)
|
||||
}
|
||||
return adapter as import('../adapter-aws/AwsStepFunctionsAdapter').AwsStepFunctionsAdapter
|
||||
}
|
||||
|
||||
private requireAdapter(cloud: CloudProvider, service: CloudServiceType) {
|
||||
const adapter = this.registry.get(cloud, service)
|
||||
if (!adapter) throw new NotSupportedError(`No adapter registered for ${cloud}/${service}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user