diff --git a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts index 940eb53..c958548 100644 --- a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts @@ -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) + }) }) diff --git a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts index f81ec6a..c69d6a0 100644 --- a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts +++ b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts @@ -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 { 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 { + 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 { + 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 { + 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), ...(result.nextToken ? {nextToken: result.nextToken} : {})} + } + + async stopExecution(executionArn: string): Promise { + 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' diff --git a/packages/api/src/cloud-spi/workflowExecutions.ts b/packages/api/src/cloud-spi/workflowExecutions.ts new file mode 100644 index 0000000..35d0d5a --- /dev/null +++ b/packages/api/src/cloud-spi/workflowExecutions.ts @@ -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> + nextToken?: string +} diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index c966266..d90bce4 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -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) diff --git a/packages/api/src/routes/workflowExecutions.test.ts b/packages/api/src/routes/workflowExecutions.test.ts new file mode 100644 index 0000000..2bc779b --- /dev/null +++ b/packages/api/src/routes/workflowExecutions.test.ts @@ -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'}) + }) +}) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 8e8aea0..1aa0fd9 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -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 { + return this.workflowAdapter(cloud).listExecutions(stateMachineArn) + } + + async describeWorkflowExecution(cloud: CloudProvider, executionArn: string): Promise { + return this.workflowAdapter(cloud).describeExecution(executionArn) + } + + async getWorkflowExecutionHistory(cloud: CloudProvider, executionArn: string, nextToken?: string): Promise { + return this.workflowAdapter(cloud).getExecutionHistory(executionArn, nextToken) + } + + async stopWorkflowExecution(cloud: CloudProvider, executionArn: string): Promise { + 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}`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index ff9f24f..3ce24d9 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -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, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 22620a6..6cfe543 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -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>; + 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 { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + 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 { + await apiClient.call( + apiEndpointKeys.clouds.resources.stopWorkflowExecution, + requestOptions("aws", "workflows"), + {cloud: "aws", id: stateMachineArn, executionArn}, + ); +} + export async function publishNotification( cloud: CloudProvider, topicArn: string, diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index cd310e7..7dd9718 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -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" && ( + + )} {editingResource && schema.updateFields && (
({ + 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() +} + +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) + }) +}) diff --git a/packages/frontend/src/components/WorkflowExecutionDebugger.tsx b/packages/frontend/src/components/WorkflowExecutionDebugger.tsx new file mode 100644 index 0000000..48343c4 --- /dev/null +++ b/packages/frontend/src/components/WorkflowExecutionDebugger.tsx @@ -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> + 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() + 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

Execution debugging is available for Standard state machines only.

+ + const running = detail.data?.status === 'RUNNING' + return
+

Execution debugger

Run and inspect executions

+
+