Skip to content

Commit 4ddcbce

Browse files
committed
* fix JSONata pass output not arguments
* fix JSONata no path fields * wire up awsSdk method
1 parent 5780f74 commit 4ddcbce

File tree

7 files changed

+309
-30
lines changed

7 files changed

+309
-30
lines changed

examples/starters/cdk/test/advanced-workflow-stack.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ describe('AdvancedWorkflowStack', () => {
189189
test('has a Pass state with intrinsic or JSONata expression for UUID/Format', () => {
190190
const passes = getStatesByType(definition, 'Pass');
191191
const hasIntrinsic = passes.some(([, s]) => {
192-
const params = JSON.stringify(s.Parameters || s.Arguments || s.Result || {});
192+
const params = JSON.stringify(s.Parameters || s.Arguments || s.Output || s.Result || {});
193193
return params.includes('States.UUID()') || params.includes('States.Format')
194194
|| params.includes('$uuid()') || params.includes('&');
195195
});

packages/core/src/asl/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ export interface TaskState extends StateBase, TerminalFields {
5353
readonly Assign?: Readonly<Record<string, unknown>>;
5454
readonly Output?: unknown;
5555
readonly ResultSelector?: Readonly<Record<string, unknown>>;
56-
readonly TimeoutSeconds?: number;
56+
readonly TimeoutSeconds?: number | string;
5757
readonly TimeoutSecondsPath?: string;
58-
readonly HeartbeatSeconds?: number;
58+
readonly HeartbeatSeconds?: number | string;
5959
readonly HeartbeatSecondsPath?: string;
6060
readonly Retry?: readonly RetryRule[];
6161
readonly Catch?: readonly CatchRule[];
@@ -79,7 +79,7 @@ export interface ChoiceState extends StateBase {
7979

8080
export interface WaitState extends StateBase, TerminalFields {
8181
readonly Type: 'Wait';
82-
readonly Seconds?: number;
82+
readonly Seconds?: number | string;
8383
readonly Timestamp?: string;
8484
readonly SecondsPath?: string;
8585
readonly TimestampPath?: string;

packages/core/src/compiler/diagnosticCodes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export class ErrorCodes {
6464
static readonly EmptyStateMachine = error('SS600', 'generation');
6565
static readonly UnknownServiceMethod = error('SS610', 'generation');
6666
static readonly MissingResourceArn = error('SS611', 'generation');
67+
static readonly AwsSdkRequiresLiteral = error('SS612', 'generation');
6768
};
6869

6970
// ── Helper function inlining (SS8xx) ─────────────────────────────────

packages/core/src/compiler/generation/pathDialect.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,34 @@ export interface PathDialect {
131131
*/
132132
emitReturnPath(path: string): Record<string, unknown>;
133133

134+
/**
135+
* Emit the output field for a Pass state (object literal or intrinsic result).
136+
* JSONPath: `{ Parameters: params }`
137+
* JSONata: `{ Output: params }` (Pass states use Output, not Arguments)
138+
*/
139+
emitPassOutput(params: Record<string, unknown>): Record<string, unknown>;
140+
141+
/**
142+
* Emit a dynamic (path-based) wait field.
143+
* JSONPath: `{ SecondsPath: path }` or `{ TimestampPath: path }`
144+
* JSONata: `{ Seconds: "{% path %}" }` or `{ Timestamp: "{% path %}" }`
145+
*/
146+
emitDynamicWaitField(field: 'Seconds' | 'Timestamp', path: string): Record<string, unknown>;
147+
148+
/**
149+
* Emit a dynamic task timeout or heartbeat field.
150+
* JSONPath: `{ TimeoutSecondsPath: path }` or `{ HeartbeatSecondsPath: path }`
151+
* JSONata: `{ TimeoutSeconds: "{% path %}" }` or `{ HeartbeatSeconds: "{% path %}" }`
152+
*/
153+
emitDynamicTimeout(field: 'TimeoutSeconds' | 'HeartbeatSeconds', path: string): Record<string, unknown>;
154+
155+
/**
156+
* Emit a dynamic fail cause field.
157+
* JSONPath: `{ CausePath: path }`
158+
* JSONata: `{ Cause: "{% path %}" }`
159+
*/
160+
emitDynamicFailCause(path: string): Record<string, unknown>;
161+
134162
// ── Choice rule support ───────────────────────────────────────────
135163

136164
/** Whether this dialect uses JSONata Condition expressions for Choice rules. */
@@ -241,6 +269,22 @@ export class JsonPathDialect implements PathDialect {
241269
return { InputPath: path };
242270
}
243271

272+
emitPassOutput(params: Record<string, unknown>): Record<string, unknown> {
273+
return { Parameters: params };
274+
}
275+
276+
emitDynamicWaitField(field: 'Seconds' | 'Timestamp', path: string): Record<string, unknown> {
277+
return { [`${field}Path`]: path };
278+
}
279+
280+
emitDynamicTimeout(field: 'TimeoutSeconds' | 'HeartbeatSeconds', path: string): Record<string, unknown> {
281+
return { [`${field}Path`]: path };
282+
}
283+
284+
emitDynamicFailCause(path: string): Record<string, unknown> {
285+
return { CausePath: path };
286+
}
287+
244288
isJsonata(): boolean {
245289
return false;
246290
}
@@ -352,6 +396,22 @@ export class JsonataDialect implements PathDialect {
352396
return { Output: `{% ${path} %}` };
353397
}
354398

399+
emitPassOutput(params: Record<string, unknown>): Record<string, unknown> {
400+
return { Output: params };
401+
}
402+
403+
emitDynamicWaitField(field: 'Seconds' | 'Timestamp', path: string): Record<string, unknown> {
404+
return { [field]: `{% ${path} %}` };
405+
}
406+
407+
emitDynamicTimeout(field: 'TimeoutSeconds' | 'HeartbeatSeconds', path: string): Record<string, unknown> {
408+
return { [field]: `{% ${path} %}` };
409+
}
410+
411+
emitDynamicFailCause(path: string): Record<string, unknown> {
412+
return { Cause: `{% ${path} %}` };
413+
}
414+
355415
isJsonata(): boolean {
356416
return true;
357417
}

packages/core/src/compiler/generation/stateBuilder.ts

Lines changed: 99 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { StepVariableType } from '../analysis/types.js';
1515
import { buildParameters, buildChoiceRule } from './expressionMapper.js';
1616
import { type PathDialect, JSON_PATH_DIALECT } from './pathDialect.js';
1717
import { STEP_ERROR_NAMES } from '../../runtime/index.js';
18-
import { SDK_PARAM_SHAPE, SDK_RESOURCE_INJECT } from '../../runtime/services/metadata.js';
18+
import { SDK_PARAM_SHAPE, SDK_RESOURCE_INJECT, SERVICE_SDK_IDS } from '../../runtime/services/metadata.js';
1919
import type {
2020
State,
2121
StateMachineDefinition,
@@ -313,7 +313,7 @@ function processAwaitAssignment(
313313

314314
if (!ts.isCallExpression(callExpr)) return null;
315315

316-
const serviceCall = extractServiceCall(ctx, callExpr);
316+
const serviceCall = extractServiceCall(ctx, callExpr) ?? extractStepsAwsSdk(ctx, callExpr);
317317
if (!serviceCall) return null;
318318

319319
// Get the variable name for ResultPath
@@ -346,9 +346,9 @@ function processAwaitAssignment(
346346
...(varName ? ctx.dialect.emitResultAssignment(varName) : ctx.dialect.emitResultDiscard()),
347347
...(serviceCall.retry && { Retry: serviceCall.retry }),
348348
...(serviceCall.timeoutSeconds != null && { TimeoutSeconds: serviceCall.timeoutSeconds }),
349-
...(serviceCall.timeoutSecondsPath && { TimeoutSecondsPath: serviceCall.timeoutSecondsPath }),
349+
...(serviceCall.timeoutSecondsPath && ctx.dialect.emitDynamicTimeout('TimeoutSeconds', serviceCall.timeoutSecondsPath)),
350350
...(serviceCall.heartbeatSeconds != null && { HeartbeatSeconds: serviceCall.heartbeatSeconds }),
351-
...(serviceCall.heartbeatSecondsPath && { HeartbeatSecondsPath: serviceCall.heartbeatSecondsPath }),
351+
...(serviceCall.heartbeatSecondsPath && ctx.dialect.emitDynamicTimeout('HeartbeatSeconds', serviceCall.heartbeatSecondsPath)),
352352
...buildCatchRules(ctx),
353353
};
354354

@@ -372,7 +372,7 @@ function processAwaitFireAndForget(
372372

373373
if (!ts.isCallExpression(callExpr)) return null;
374374

375-
const serviceCall = extractServiceCall(ctx, callExpr);
375+
const serviceCall = extractServiceCall(ctx, callExpr) ?? extractStepsAwsSdk(ctx, callExpr);
376376
if (!serviceCall) return null;
377377

378378
const stateName = generateStateName(
@@ -387,9 +387,9 @@ function processAwaitFireAndForget(
387387
...ctx.dialect.emitResultDiscard(),
388388
...(serviceCall.retry && { Retry: serviceCall.retry }),
389389
...(serviceCall.timeoutSeconds != null && { TimeoutSeconds: serviceCall.timeoutSeconds }),
390-
...(serviceCall.timeoutSecondsPath && { TimeoutSecondsPath: serviceCall.timeoutSecondsPath }),
390+
...(serviceCall.timeoutSecondsPath && ctx.dialect.emitDynamicTimeout('TimeoutSeconds', serviceCall.timeoutSecondsPath)),
391391
...(serviceCall.heartbeatSeconds != null && { HeartbeatSeconds: serviceCall.heartbeatSeconds }),
392-
...(serviceCall.heartbeatSecondsPath && { HeartbeatSecondsPath: serviceCall.heartbeatSecondsPath }),
392+
...(serviceCall.heartbeatSecondsPath && ctx.dialect.emitDynamicTimeout('HeartbeatSeconds', serviceCall.heartbeatSecondsPath)),
393393
...buildCatchRules(ctx),
394394
};
395395

@@ -405,7 +405,7 @@ function processAwaitReassignment(
405405
const callExpr = awaitExpr.expression;
406406
if (!ts.isCallExpression(callExpr)) return null;
407407

408-
const serviceCall = extractServiceCall(ctx, callExpr);
408+
const serviceCall = extractServiceCall(ctx, callExpr) ?? extractStepsAwsSdk(ctx, callExpr);
409409
if (!serviceCall) return null;
410410

411411
// Resolve the LHS identifier to get its variable name for result assignment
@@ -435,9 +435,9 @@ function processAwaitReassignment(
435435
...resultAssignment,
436436
...(serviceCall.retry && { Retry: serviceCall.retry }),
437437
...(serviceCall.timeoutSeconds != null && { TimeoutSeconds: serviceCall.timeoutSeconds }),
438-
...(serviceCall.timeoutSecondsPath && { TimeoutSecondsPath: serviceCall.timeoutSecondsPath }),
438+
...(serviceCall.timeoutSecondsPath && ctx.dialect.emitDynamicTimeout('TimeoutSeconds', serviceCall.timeoutSecondsPath)),
439439
...(serviceCall.heartbeatSeconds != null && { HeartbeatSeconds: serviceCall.heartbeatSeconds }),
440-
...(serviceCall.heartbeatSecondsPath && { HeartbeatSecondsPath: serviceCall.heartbeatSecondsPath }),
440+
...(serviceCall.heartbeatSecondsPath && ctx.dialect.emitDynamicTimeout('HeartbeatSeconds', serviceCall.heartbeatSecondsPath)),
441441
...buildCatchRules(ctx),
442442
};
443443

@@ -473,21 +473,21 @@ function processStepsDelay(
473473
if (resolved.kind === 'literal' && typeof resolved.value === 'number') {
474474
(waitState as any).Seconds = resolved.value;
475475
} else if (resolved.kind === 'jsonpath') {
476-
(waitState as any).SecondsPath = resolved.path;
476+
Object.assign(waitState, ctx.dialect.emitDynamicWaitField('Seconds', resolved.path!));
477477
}
478478
} else if (key === 'timestamp') {
479479
if (resolved.kind === 'literal' && typeof resolved.value === 'string') {
480480
(waitState as any).Timestamp = resolved.value;
481481
} else if (resolved.kind === 'jsonpath') {
482-
(waitState as any).TimestampPath = resolved.path;
482+
Object.assign(waitState, ctx.dialect.emitDynamicWaitField('Timestamp', resolved.path!));
483483
}
484484
} else if (key === 'secondsPath') {
485485
if (resolved.kind === 'literal' && typeof resolved.value === 'string') {
486-
(waitState as any).SecondsPath = resolved.value;
486+
Object.assign(waitState, ctx.dialect.emitDynamicWaitField('Seconds', resolved.value as string));
487487
}
488488
} else if (key === 'timestampPath') {
489489
if (resolved.kind === 'literal' && typeof resolved.value === 'string') {
490-
(waitState as any).TimestampPath = resolved.value;
490+
Object.assign(waitState, ctx.dialect.emitDynamicWaitField('Timestamp', resolved.value as string));
491491
}
492492
}
493493
}
@@ -1093,7 +1093,7 @@ function processBranchExpression(
10931093

10941094
if (!ts.isCallExpression(callExpr)) return null;
10951095

1096-
const serviceCall = extractServiceCall(ctx, callExpr as ts.CallExpression);
1096+
const serviceCall = extractServiceCall(ctx, callExpr as ts.CallExpression) ?? extractStepsAwsSdk(ctx, callExpr as ts.CallExpression);
10971097
if (!serviceCall) return null;
10981098

10991099
const stateName = generateStateName(
@@ -1107,9 +1107,9 @@ function processBranchExpression(
11071107
...(serviceCall.parameters && ctx.dialect.emitParameters(serviceCall.parameters)),
11081108
...(serviceCall.retry && { Retry: serviceCall.retry }),
11091109
...(serviceCall.timeoutSeconds != null && { TimeoutSeconds: serviceCall.timeoutSeconds }),
1110-
...(serviceCall.timeoutSecondsPath && { TimeoutSecondsPath: serviceCall.timeoutSecondsPath }),
1110+
...(serviceCall.timeoutSecondsPath && ctx.dialect.emitDynamicTimeout('TimeoutSeconds', serviceCall.timeoutSecondsPath)),
11111111
...(serviceCall.heartbeatSeconds != null && { HeartbeatSeconds: serviceCall.heartbeatSeconds }),
1112-
...(serviceCall.heartbeatSecondsPath && { HeartbeatSecondsPath: serviceCall.heartbeatSecondsPath }),
1112+
...(serviceCall.heartbeatSecondsPath && ctx.dialect.emitDynamicTimeout('HeartbeatSeconds', serviceCall.heartbeatSecondsPath)),
11131113
End: true,
11141114
};
11151115

@@ -1132,7 +1132,7 @@ function processReturnTerminator(
11321132

11331133
// return await svc.call(...) → Task state with End: true
11341134
if (ts.isAwaitExpression(expression) && ts.isCallExpression(expression.expression)) {
1135-
const serviceCall = extractServiceCall(ctx, expression.expression);
1135+
const serviceCall = extractServiceCall(ctx, expression.expression) ?? extractStepsAwsSdk(ctx, expression.expression);
11361136
if (serviceCall) {
11371137
const stateName = generateStateName(
11381138
`Invoke_${serviceCall.serviceVarName}`,
@@ -1144,9 +1144,9 @@ function processReturnTerminator(
11441144
...(serviceCall.parameters && ctx.dialect.emitParameters(serviceCall.parameters)),
11451145
...(serviceCall.retry && { Retry: serviceCall.retry }),
11461146
...(serviceCall.timeoutSeconds != null && { TimeoutSeconds: serviceCall.timeoutSeconds }),
1147-
...(serviceCall.timeoutSecondsPath && { TimeoutSecondsPath: serviceCall.timeoutSecondsPath }),
1147+
...(serviceCall.timeoutSecondsPath && ctx.dialect.emitDynamicTimeout('TimeoutSeconds', serviceCall.timeoutSecondsPath)),
11481148
...(serviceCall.heartbeatSeconds != null && { HeartbeatSeconds: serviceCall.heartbeatSeconds }),
1149-
...(serviceCall.heartbeatSecondsPath && { HeartbeatSecondsPath: serviceCall.heartbeatSecondsPath }),
1149+
...(serviceCall.heartbeatSecondsPath && ctx.dialect.emitDynamicTimeout('HeartbeatSeconds', serviceCall.heartbeatSecondsPath)),
11501150
...buildCatchRules(ctx),
11511151
End: true,
11521152
};
@@ -1170,7 +1170,7 @@ function processReturnTerminator(
11701170
const stateName = generateStateName('Return_Result', ctx.usedNames);
11711171
const passState: PassState = {
11721172
Type: 'Pass',
1173-
...ctx.dialect.emitParameters(params),
1173+
...ctx.dialect.emitPassOutput(params),
11741174
End: true,
11751175
};
11761176

@@ -1228,7 +1228,7 @@ function processReturnTerminator(
12281228
const stateName = generateStateName('Return_Result', ctx.usedNames);
12291229
const passState: PassState = {
12301230
Type: 'Pass',
1231-
...ctx.dialect.emitParameters(ctx.dialect.wrapIntrinsicResult('result', resolved.path!)),
1231+
...ctx.dialect.emitPassOutput(ctx.dialect.wrapIntrinsicResult('result', resolved.path!)),
12321232
End: true,
12331233
};
12341234
ctx.states.set(stateName, passState);
@@ -1292,7 +1292,7 @@ function processThrowTerminator(
12921292
if (resolved.kind === 'literal' && typeof resolved.value === 'string') {
12931293
failState = { Type: 'Fail', Error: errorName, Cause: resolved.value };
12941294
} else if (resolved.kind === 'jsonpath') {
1295-
failState = { Type: 'Fail', Error: errorName, CausePath: resolved.path };
1295+
failState = { Type: 'Fail', Error: errorName, ...ctx.dialect.emitDynamicFailCause(resolved.path!) };
12961296
} else {
12971297
failState = { Type: 'Fail', Error: errorName };
12981298
}
@@ -1428,19 +1428,92 @@ function extractServiceCall(
14281428
};
14291429
}
14301430

1431+
// ---------------------------------------------------------------------------
1432+
// Steps.awsSdk() → Task state (generic AWS SDK escape hatch)
1433+
// ---------------------------------------------------------------------------
1434+
1435+
/**
1436+
* Recognize `Steps.awsSdk(service, action, params, ?options)` call sites
1437+
* and extract them as a service call that compiles to:
1438+
* Resource: "arn:aws:states:::aws-sdk:{sdkId}:{action}"
1439+
*/
1440+
function extractStepsAwsSdk(
1441+
ctx: BuildContext,
1442+
callExpr: ts.CallExpression,
1443+
): ExtractedServiceCall | null {
1444+
// Check callee is Steps.awsSdk
1445+
if (!ts.isPropertyAccessExpression(callExpr.expression)) return null;
1446+
const propAccess = callExpr.expression;
1447+
if (!ts.isIdentifier(propAccess.expression) || propAccess.expression.text !== 'Steps') return null;
1448+
if (propAccess.name.text !== 'awsSdk') return null;
1449+
1450+
// First arg: service name (must be string literal)
1451+
const serviceArg = callExpr.arguments[0];
1452+
if (!serviceArg || !ts.isStringLiteral(serviceArg)) {
1453+
ctx.compilerContext.addError(
1454+
callExpr,
1455+
'Steps.awsSdk() requires a string literal service name as the first argument',
1456+
ErrorCodes.Gen.AwsSdkRequiresLiteral.code,
1457+
);
1458+
return null;
1459+
}
1460+
const serviceName = serviceArg.text;
1461+
1462+
// Second arg: action name (must be string literal)
1463+
const actionArg = callExpr.arguments[1];
1464+
if (!actionArg || !ts.isStringLiteral(actionArg)) {
1465+
ctx.compilerContext.addError(
1466+
callExpr,
1467+
'Steps.awsSdk() requires a string literal action name as the second argument',
1468+
ErrorCodes.Gen.AwsSdkRequiresLiteral.code,
1469+
);
1470+
return null;
1471+
}
1472+
const actionName = actionArg.text;
1473+
1474+
// Look up SDK ID for service name (PascalCase → lowercase SDK ID)
1475+
const sdkId = SERVICE_SDK_IDS[serviceName] ?? serviceName.toLowerCase();
1476+
const resource = `arn:aws:states:::aws-sdk:${sdkId}:${actionName}`;
1477+
1478+
// Third arg: parameters (optional object literal)
1479+
let parameters: Record<string, unknown> | undefined;
1480+
const paramsArg = callExpr.arguments[2];
1481+
if (paramsArg && ts.isObjectLiteralExpression(paramsArg)) {
1482+
parameters = buildParameters(
1483+
ctx.compilerContext,
1484+
paramsArg,
1485+
ctx.variables.toResolution(),
1486+
ctx.dialect,
1487+
);
1488+
}
1489+
1490+
// Fourth arg: task options (retry, timeout, heartbeat)
1491+
const taskOptions = extractTaskOptions(ctx, callExpr, 3);
1492+
1493+
return {
1494+
serviceVarName: `${serviceName}_${actionName}`,
1495+
resource,
1496+
parameters,
1497+
methodInfo: { integration: 'sdk', hasOutput: true, methodName: actionName } as ServiceMethodInfo,
1498+
...taskOptions,
1499+
};
1500+
}
1501+
14311502
// ---------------------------------------------------------------------------
14321503
// Task options extraction (retry, timeout, heartbeat)
14331504
// ---------------------------------------------------------------------------
14341505

14351506
/**
1436-
* Extract task-level options from the 2nd argument of a service call.
1507+
* Extract task-level options from the specified argument of a service call.
14371508
* Supports: retry, timeoutSeconds, heartbeatSeconds.
1509+
* @param optionsArgIndex — index of the options argument (default 1 for service.call(input, opts))
14381510
*/
14391511
function extractTaskOptions(
14401512
ctx: BuildContext,
14411513
callExpr: ts.CallExpression,
1514+
optionsArgIndex: number = 1,
14421515
): Pick<ExtractedServiceCall, 'retry' | 'timeoutSeconds' | 'timeoutSecondsPath' | 'heartbeatSeconds' | 'heartbeatSecondsPath'> {
1443-
const optionsArg = callExpr.arguments[1];
1516+
const optionsArg = callExpr.arguments[optionsArgIndex];
14441517
if (!optionsArg || !ts.isObjectLiteralExpression(optionsArg)) return {};
14451518

14461519
const result: {
@@ -1774,7 +1847,7 @@ function buildTernaryBranchPass(
17741847
} else if (resolved.kind === 'jsonpath' && resolved.path) {
17751848
Object.assign(passState, ctx.dialect.emitReturnPath(resolved.path));
17761849
} else if (resolved.kind === 'intrinsic' && resolved.path) {
1777-
Object.assign(passState, ctx.dialect.emitParameters(ctx.dialect.wrapIntrinsicResult('value', resolved.path!)));
1850+
Object.assign(passState, ctx.dialect.emitPassOutput(ctx.dialect.wrapIntrinsicResult('value', resolved.path!)));
17781851
}
17791852

17801853
if (nextState) {

0 commit comments

Comments
 (0)