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'
|
||||
|
||||
Reference in New Issue
Block a user