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
@@ -3,7 +3,12 @@ import {
CreateStateMachineCommand, CreateStateMachineCommand,
DeleteStateMachineCommand, DeleteStateMachineCommand,
DescribeStateMachineCommand, DescribeStateMachineCommand,
DescribeExecutionCommand,
GetExecutionHistoryCommand,
ListExecutionsCommand,
ListStateMachinesCommand, ListStateMachinesCommand,
StartExecutionCommand,
StopExecutionCommand,
type SFNClient, type SFNClient,
} from '@aws-sdk/client-sfn' } from '@aws-sdk/client-sfn'
import {AwsStepFunctionsAdapter} from './AwsStepFunctionsAdapter' import {AwsStepFunctionsAdapter} from './AwsStepFunctionsAdapter'
@@ -221,4 +226,39 @@ describe('AwsStepFunctionsAdapter', () => {
expect((sent[0] as DeleteStateMachineCommand).input.stateMachineArn).toBe(ARN) 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, CreateStateMachineCommand,
DeleteStateMachineCommand, DeleteStateMachineCommand,
DescribeStateMachineCommand, DescribeStateMachineCommand,
DescribeExecutionCommand,
GetExecutionHistoryCommand,
ListExecutionsCommand,
ListStateMachinesCommand, ListStateMachinesCommand,
StartExecutionCommand,
StopExecutionCommand,
type SFNClient, type SFNClient,
type StateMachineListItem, type StateMachineListItem,
} from '@aws-sdk/client-sfn' } from '@aws-sdk/client-sfn'
import {RuntimeError, ValidationError} from '../cloud-spi/errors' import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {STATE_MACHINE_NAME_PATTERN, awsWorkflowsSchema} from '../cloud-spi/workflowsSchema' import {STATE_MACHINE_NAME_PATTERN, awsWorkflowsSchema} from '../cloud-spi/workflowsSchema'
import type {StartWorkflowExecutionInput, WorkflowExecutionDetail, WorkflowExecutionHistoryPage, WorkflowExecutionSummary} from '../cloud-spi/workflowExecutions'
import type { import type {
CloudResource, CloudResource,
CloudServiceAdapter, CloudServiceAdapter,
@@ -104,6 +110,65 @@ export class AwsStepFunctionsAdapter implements CloudServiceAdapter {
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await this.sfn.send(new DeleteStateMachineCommand({stateMachineArn: id})) 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 { function toResource(machine: StateMachineListItem | DescribedMachine): CloudResource {
@@ -172,6 +237,16 @@ function requiredDefinition(value: unknown): string {
return raw 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 { function isMissing(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false if (typeof error !== 'object' || error === null) return false
return (error as {name?: string}).name === 'StateMachineDoesNotExist' 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
}
+38
View File
@@ -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) => { app.get('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) 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' } from '../cloud-spi/childCollections'
import {NotSupportedError} from '../cloud-spi/errors' import {NotSupportedError} from '../cloud-spi/errors'
import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' 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 {SERVICE_CATALOG_ENTRIES, displayNameFor, routeFor} from '../cloud-spi/serviceCatalog'
import {toHttpError} from '../cloud-spi/errors' import {toHttpError} from '../cloud-spi/errors'
import {mapAwsSdkError} from '../adapter-aws/awsErrors' import {mapAwsSdkError} from '../adapter-aws/awsErrors'
@@ -761,6 +762,34 @@ export class CloudProxyService {
return this.appConfigAdapter(cloud).getAppConfigDeployment!(applicationId, environmentId, deploymentNumber) 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) { private requireAdapter(cloud: CloudProvider, service: CloudServiceType) {
const adapter = this.registry.get(cloud, service) const adapter = this.registry.get(cloud, service)
if (!adapter) throw new NotSupportedError(`No adapter registered for ${cloud}/${service}`) if (!adapter) throw new NotSupportedError(`No adapter registered for ${cloud}/${service}`)
+25
View File
@@ -50,6 +50,11 @@ export const apiEndpointKeys = {
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create", createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list", 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: { storage: {
objects: { objects: {
@@ -530,6 +535,26 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "serverless" }, 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, apiEndpointKeys.clouds.resources.get,
{ {
@@ -234,6 +234,27 @@ export interface PublishNotificationResult {
messageId: string | null; 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 { export interface CreateApiGatewayLambdaRouteInput {
path: string; path: string;
method: string; method: string;
@@ -768,6 +789,53 @@ export async function invokeCloudResource(
return res.data; 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( export async function publishNotification(
cloud: CloudProvider, cloud: CloudProvider,
topicArn: string, topicArn: string,
@@ -58,6 +58,7 @@ import { EcrImageBrowser } from "@/components/EcrImageBrowser";
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel"; import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel"; import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel"; import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
interface DynamicResourceViewProps { interface DynamicResourceViewProps {
cloud: CloudProvider; cloud: CloudProvider;
@@ -947,6 +948,9 @@ export function DynamicResourceView({
runtimeReachable={canUseRuntime} runtimeReachable={canUseRuntime}
/> />
)} )}
{service === "workflows" && cloud === "aws" && (
<WorkflowExecutionDebugger resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{editingResource && schema.updateFields && ( {editingResource && schema.updateFields && (
<div <div
className="modal-overlay" 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
}