feat: decode composite step inputs
This commit is contained in:
@@ -6,6 +6,10 @@ import {
|
||||
workflowRpcOperationNames,
|
||||
workflowOperationNames,
|
||||
type OperationName,
|
||||
type InputExpression,
|
||||
type InputExpressionBinding,
|
||||
type InputPathBinding,
|
||||
type InputValueBinding,
|
||||
type WorkflowOperationParams,
|
||||
type WorkflowOperationResult,
|
||||
} from "../index.js";
|
||||
@@ -75,4 +79,30 @@ describe("generated workflow contract", () => {
|
||||
store_root: string;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("exposes recursive node-local inputs without widening workflow outputs", () => {
|
||||
const expressionBinding = {
|
||||
target: "request",
|
||||
expression: {
|
||||
kind: "object",
|
||||
fields: {
|
||||
value: { kind: "path", path: "state.foo" },
|
||||
label: { kind: "literal", value: "wowcool" },
|
||||
},
|
||||
},
|
||||
} satisfies InputExpressionBinding;
|
||||
const expression: InputExpression = expressionBinding.expression;
|
||||
|
||||
expect(expression.kind).toBe("object");
|
||||
expectTypeOf<
|
||||
WorkflowOperationParams<"workflow.draft_workspaces.set_step_input_bindings">
|
||||
>().toMatchTypeOf<{
|
||||
bindings: Array<InputPathBinding | InputValueBinding | InputExpressionBinding>;
|
||||
}>();
|
||||
expectTypeOf<
|
||||
WorkflowOperationParams<"workflow.draft_workspaces.set_step_output_bindings">
|
||||
>().toMatchTypeOf<{
|
||||
bindings: Array<{ source: string; target: string }>;
|
||||
}>();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,6 +173,11 @@ export type JsonValue =
|
||||
[k: string]: JsonValue;
|
||||
}
|
||||
| null;
|
||||
/**
|
||||
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||
* via the `definition` "InputExpression".
|
||||
*/
|
||||
export type InputExpression = LiteralExpression | PathExpression | ArrayExpression | ObjectExpression;
|
||||
/**
|
||||
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||
* via the `definition` "CompileDraftWorkspaceResult".
|
||||
@@ -444,7 +449,7 @@ export interface WorkflowContractMap {
|
||||
input_map?: {
|
||||
[k: string]: string;
|
||||
} | null;
|
||||
input_bindings?: (InputPathBinding | InputValueBinding)[] | null;
|
||||
input_bindings?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[] | null;
|
||||
bind_outputs?: {
|
||||
[k: string]: string;
|
||||
};
|
||||
@@ -535,8 +540,8 @@ export interface WorkflowContractMap {
|
||||
output_schema?: {
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
input?: unknown[] | null;
|
||||
output?: unknown[] | null;
|
||||
input?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[] | null;
|
||||
output?: OutputBinding[] | null;
|
||||
input_map?: {
|
||||
[k: string]: string;
|
||||
} | null;
|
||||
@@ -688,7 +693,7 @@ export interface WorkflowContractMap {
|
||||
workspace_id: string;
|
||||
revision: number;
|
||||
step_id: string;
|
||||
bindings: (InputPathBinding | InputValueBinding)[];
|
||||
bindings: (InputPathBinding | InputValueBinding | InputExpressionBinding)[];
|
||||
};
|
||||
result: DraftWorkspaceResult;
|
||||
};
|
||||
@@ -774,8 +779,8 @@ export interface WorkflowContractMap {
|
||||
output_schema?: {
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
input?: unknown[] | null;
|
||||
output?: unknown[] | null;
|
||||
input?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[] | null;
|
||||
output?: OutputBinding[] | null;
|
||||
input_map?: {
|
||||
[k: string]: string;
|
||||
} | null;
|
||||
@@ -1493,7 +1498,7 @@ export interface DraftUseStep {
|
||||
/**
|
||||
* Canonical input bindings for this capability. Use path bindings for graph-to-local input and value bindings for literals.
|
||||
*/
|
||||
input?: (InputPathBinding | InputValueBinding)[];
|
||||
input?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[];
|
||||
/**
|
||||
* Canonical output bindings from node-local output paths to workflow state destinations.
|
||||
*/
|
||||
@@ -1566,6 +1571,80 @@ export interface InputValueBinding {
|
||||
}
|
||||
| null;
|
||||
}
|
||||
/**
|
||||
* Assign one recursively composed expression to a node-local target.
|
||||
*
|
||||
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||
* via the `definition` "InputExpressionBinding".
|
||||
*/
|
||||
export interface InputExpressionBinding {
|
||||
expression: InputExpression;
|
||||
/**
|
||||
* Node-local path. Use the root marker `.` for the whole payload.
|
||||
*/
|
||||
target:
|
||||
| string
|
||||
| {
|
||||
/**
|
||||
* @minItems 0
|
||||
*/
|
||||
parts: string[];
|
||||
root: "local";
|
||||
};
|
||||
}
|
||||
/**
|
||||
* A strict JSON literal embedded in a node-local input expression.
|
||||
*
|
||||
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||
* via the `definition` "LiteralExpression".
|
||||
*/
|
||||
export interface LiteralExpression {
|
||||
kind: "literal";
|
||||
value: JsonValue;
|
||||
}
|
||||
/**
|
||||
* Read one graph source path while resolving a composite input.
|
||||
*
|
||||
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||
* via the `definition` "PathExpression".
|
||||
*/
|
||||
export interface PathExpression {
|
||||
kind: "path";
|
||||
/**
|
||||
* Readable graph path rooted at input, state, or context.
|
||||
*/
|
||||
path:
|
||||
| string
|
||||
| {
|
||||
/**
|
||||
* @minItems 0
|
||||
*/
|
||||
parts: string[];
|
||||
root: "input" | "state" | "context";
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Resolve ordered child expressions into one JSON array.
|
||||
*
|
||||
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||
* via the `definition` "ArrayExpression".
|
||||
*/
|
||||
export interface ArrayExpression {
|
||||
items: InputExpression[];
|
||||
kind: "array";
|
||||
}
|
||||
/**
|
||||
* Resolve named child expressions into one JSON object.
|
||||
*
|
||||
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||
* via the `definition` "ObjectExpression".
|
||||
*/
|
||||
export interface ObjectExpression {
|
||||
fields: {
|
||||
[k: string]: InputExpression;
|
||||
};
|
||||
kind: "object";
|
||||
}
|
||||
/**
|
||||
* Map one node-local output path into one workflow state path.
|
||||
*
|
||||
@@ -1689,7 +1768,7 @@ export interface DraftInterruptStep {
|
||||
export interface DraftInterruptPayload {
|
||||
kind: string;
|
||||
outcomes?: string[];
|
||||
request?: (InputPathBinding | InputValueBinding)[];
|
||||
request?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[];
|
||||
request_schema?: SchemaRef | null;
|
||||
resume?: OutputBinding[];
|
||||
resume_schema?: SchemaRef | null;
|
||||
@@ -1936,7 +2015,7 @@ export interface DraftSubgraphStep {
|
||||
*/
|
||||
export interface DraftSubgraphPayload {
|
||||
desc?: string | null;
|
||||
input?: (InputPathBinding | InputValueBinding)[];
|
||||
input?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[];
|
||||
input_schema?: SchemaRef;
|
||||
/**
|
||||
* @minItems 1
|
||||
@@ -2135,7 +2214,7 @@ export interface ListDraftWorkspacesResult {
|
||||
*/
|
||||
export interface CapabilityStepUpdate {
|
||||
desc?: string | null;
|
||||
input?: (InputPathBinding | InputValueBinding)[] | null;
|
||||
input?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[] | null;
|
||||
retry?: number | null;
|
||||
timeout_seconds?: number | null;
|
||||
}
|
||||
@@ -2592,6 +2671,27 @@ export type WorkflowOperationResult<Name extends WorkflowOperationName> =
|
||||
// Runtime JSON Schema is limited to parity-verified authored RPCs.
|
||||
export const workflowRuntimeContract = {
|
||||
"components": {
|
||||
"ArrayExpression": {
|
||||
"additionalProperties": false,
|
||||
"description": "Resolve ordered child expressions into one JSON array.",
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InputExpression"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"kind": {
|
||||
"const": "array",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"items"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ArtifactCatalogEntryPayload": {
|
||||
"description": "Compact artifact row returned by discovery operations.",
|
||||
"properties": {
|
||||
@@ -2776,9 +2876,11 @@ export const workflowRuntimeContract = {
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputValueBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputExpressionBinding"
|
||||
}
|
||||
],
|
||||
"description": "Canonical node input binding. Use either a path binding with `path`, or a literal binding with `value`; do not provide both."
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
@@ -3085,6 +3187,77 @@ export const workflowRuntimeContract = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"InputExpression": {
|
||||
"discriminator": {
|
||||
"mapping": {
|
||||
"array": "#/components/schemas/ArrayExpression",
|
||||
"literal": "#/components/schemas/LiteralExpression",
|
||||
"object": "#/components/schemas/ObjectExpression",
|
||||
"path": "#/components/schemas/PathExpression"
|
||||
},
|
||||
"propertyName": "kind"
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/LiteralExpression"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/PathExpression"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ArrayExpression"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ObjectExpression"
|
||||
}
|
||||
]
|
||||
},
|
||||
"InputExpressionBinding": {
|
||||
"additionalProperties": false,
|
||||
"description": "Assign one recursively composed expression to a node-local target.",
|
||||
"properties": {
|
||||
"expression": {
|
||||
"$ref": "#/components/schemas/InputExpression"
|
||||
},
|
||||
"target": {
|
||||
"description": "Node-local path. Use the root marker `.` for the whole payload.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Canonical TOML-key path string.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Structural path object accepted as input.",
|
||||
"properties": {
|
||||
"parts": {
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 0,
|
||||
"type": "array"
|
||||
},
|
||||
"root": {
|
||||
"const": "local",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"root",
|
||||
"parts"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"target",
|
||||
"expression"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"InputPathBinding": {
|
||||
"additionalProperties": false,
|
||||
"description": "Map one workflow graph source path into one node-local input path.",
|
||||
@@ -3522,6 +3695,24 @@ export const workflowRuntimeContract = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LiteralExpression": {
|
||||
"additionalProperties": false,
|
||||
"description": "A strict JSON literal embedded in a node-local input expression.",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "literal",
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NextActionPatchExamplePayload": {
|
||||
"description": "Concrete follow-up operation suggested to an API caller.",
|
||||
"properties": {
|
||||
@@ -3710,6 +3901,27 @@ export const workflowRuntimeContract = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ObjectExpression": {
|
||||
"additionalProperties": false,
|
||||
"description": "Resolve named child expressions into one JSON object.",
|
||||
"properties": {
|
||||
"fields": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/InputExpression"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"kind": {
|
||||
"const": "object",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"fields"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OutputBinding": {
|
||||
"additionalProperties": false,
|
||||
"description": "Map one node-local output path into one workflow state path.",
|
||||
@@ -3785,6 +3997,57 @@ export const workflowRuntimeContract = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PathExpression": {
|
||||
"additionalProperties": false,
|
||||
"description": "Read one graph source path while resolving a composite input.",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "path",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "Readable graph path rooted at input, state, or context.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Canonical TOML-key path string.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Structural path object accepted as input.",
|
||||
"properties": {
|
||||
"parts": {
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 0,
|
||||
"type": "array"
|
||||
},
|
||||
"root": {
|
||||
"enum": [
|
||||
"input",
|
||||
"state",
|
||||
"context"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"root",
|
||||
"parts"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"path"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RequiredCapabilityPayload": {
|
||||
"description": "Saved dependency contract for one artifact capability reference.",
|
||||
"properties": {
|
||||
@@ -5206,9 +5469,11 @@ export const workflowRuntimeContract = {
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputValueBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputExpressionBinding"
|
||||
}
|
||||
],
|
||||
"description": "Canonical node input binding. Use either a path binding with `path`, or a literal binding with `value`; do not provide both."
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
@@ -5422,7 +5687,19 @@ export const workflowRuntimeContract = {
|
||||
"input": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {},
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InputPathBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputValueBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputExpressionBinding"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
@@ -5434,7 +5711,9 @@ export const workflowRuntimeContract = {
|
||||
"output": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/OutputBinding"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
@@ -5586,9 +5865,11 @@ export const workflowRuntimeContract = {
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputValueBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputExpressionBinding"
|
||||
}
|
||||
],
|
||||
"description": "Canonical node input binding. Use either a path binding with `path`, or a literal binding with `value`; do not provide both."
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|
||||
@@ -295,10 +295,84 @@ const InputValueBindingSchema = Schema.Struct({
|
||||
value: JsonValueSchema,
|
||||
});
|
||||
|
||||
type GraphSourcePath =
|
||||
| string
|
||||
| {
|
||||
readonly root: "input" | "state" | "context";
|
||||
readonly parts: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
type InputExpression =
|
||||
| { readonly kind: "literal"; readonly value: JsonValue }
|
||||
| { readonly kind: "path"; readonly path: GraphSourcePath }
|
||||
| { readonly kind: "array"; readonly items: ReadonlyArray<InputExpression> }
|
||||
| { readonly kind: "object"; readonly fields: Readonly<Record<string, InputExpression>> };
|
||||
|
||||
const GraphSourcePathSchema = Schema.Union(
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
parts: StructuralPathPartsSchema,
|
||||
root: Schema.Literal("input", "state", "context"),
|
||||
}),
|
||||
);
|
||||
|
||||
const LiteralExpressionSchema = Schema.Struct({
|
||||
kind: Schema.Literal("literal"),
|
||||
value: JsonValueSchema,
|
||||
});
|
||||
const PathExpressionSchema = Schema.Struct({
|
||||
kind: Schema.Literal("path"),
|
||||
path: GraphSourcePathSchema,
|
||||
});
|
||||
const ArrayExpressionSchema = Schema.Struct({
|
||||
kind: Schema.Literal("array"),
|
||||
items: Schema.Array(
|
||||
Schema.suspend(
|
||||
(): Schema.Schema<InputExpression, unknown, never> => InputExpressionSchema,
|
||||
),
|
||||
),
|
||||
});
|
||||
const ObjectExpressionSchema = Schema.Struct({
|
||||
kind: Schema.Literal("object"),
|
||||
fields: Schema.Record({
|
||||
key: Schema.String,
|
||||
value: Schema.suspend(
|
||||
(): Schema.Schema<InputExpression, unknown, never> => InputExpressionSchema,
|
||||
),
|
||||
}),
|
||||
});
|
||||
// Keep the recursive authored decoder separate from the generated contract so
|
||||
// parity tests can catch drift in either direction.
|
||||
const InputExpressionSchema: Schema.Schema<InputExpression, unknown, never> =
|
||||
Schema.suspend(
|
||||
(): Schema.Schema<InputExpression, unknown, never> =>
|
||||
Schema.Union(
|
||||
LiteralExpressionSchema,
|
||||
PathExpressionSchema,
|
||||
ArrayExpressionSchema,
|
||||
ObjectExpressionSchema,
|
||||
),
|
||||
);
|
||||
const InputExpressionBindingSchema = Schema.Struct({
|
||||
target: Schema.Union(
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
parts: StructuralPathPartsSchema,
|
||||
root: Schema.Literal("local"),
|
||||
}),
|
||||
),
|
||||
expression: InputExpressionSchema,
|
||||
});
|
||||
|
||||
const InputBindingSchema = Schema.Union(
|
||||
InputPathBindingSchema,
|
||||
InputValueBindingSchema,
|
||||
);
|
||||
const StepInputBindingSchema = Schema.Union(
|
||||
InputPathBindingSchema,
|
||||
InputValueBindingSchema,
|
||||
InputExpressionBindingSchema,
|
||||
);
|
||||
|
||||
const OutputBindingSchema = Schema.Struct({
|
||||
source: Schema.Union(
|
||||
@@ -319,7 +393,7 @@ const OutputBindingSchema = Schema.Struct({
|
||||
|
||||
const CapabilityStepUpdateSchema = Schema.Struct({
|
||||
desc: Schema.optional(Schema.NullOr(Schema.String.pipe(Schema.minLength(1)))),
|
||||
input: Schema.optional(Schema.NullOr(Schema.Array(InputBindingSchema))),
|
||||
input: Schema.optional(Schema.NullOr(Schema.Array(StepInputBindingSchema))),
|
||||
retry: Schema.optional(Schema.NullOr(NonNegativeIntegerSchema)),
|
||||
timeout_seconds: Schema.optional(Schema.NullOr(PositiveIntegerSchema)),
|
||||
});
|
||||
@@ -446,7 +520,7 @@ export const authoredRpcSchemas = {
|
||||
Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })),
|
||||
),
|
||||
input_bindings: Schema.optional(
|
||||
Schema.NullOr(Schema.Array(InputBindingSchema)),
|
||||
Schema.NullOr(Schema.Array(StepInputBindingSchema)),
|
||||
),
|
||||
bind_outputs: Schema.optional(
|
||||
Schema.Record({ key: Schema.String, value: Schema.String }),
|
||||
@@ -481,7 +555,7 @@ export const authoredRpcSchemas = {
|
||||
workspace_id: Schema.String.pipe(Schema.minLength(1)),
|
||||
revision: PositiveIntegerSchema,
|
||||
step_id: Schema.String.pipe(Schema.minLength(1)),
|
||||
bindings: Schema.Array(InputBindingSchema),
|
||||
bindings: Schema.Array(StepInputBindingSchema),
|
||||
}),
|
||||
success: DraftWorkspaceSchema,
|
||||
},
|
||||
|
||||
@@ -492,8 +492,8 @@ const parityCases: ReadonlyArray<ParityCase> = [
|
||||
input_schema: { type: "object" },
|
||||
state_schema: { type: "object" },
|
||||
output_schema: { type: "object" },
|
||||
input: [{ text: "hello" }],
|
||||
output: [{ text: "state.text" }],
|
||||
input: [{ path: "input.text", target: "text" }],
|
||||
output: [{ source: "text", target: "state.text" }],
|
||||
input_map: { "input.text": "text" },
|
||||
output_map: { text: "state.text" },
|
||||
error_message_source: "state.error_message",
|
||||
@@ -1096,6 +1096,58 @@ describe("authored RPC and manifest schema parity", () => {
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts nested authored expressions and rejects over-specified variants", () => {
|
||||
const basePayload = {
|
||||
workspace_id: "console.demo",
|
||||
revision: 3,
|
||||
step_id: "concat",
|
||||
};
|
||||
const expression = {
|
||||
kind: "object",
|
||||
fields: {
|
||||
items: {
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "path", path: "state.foo" },
|
||||
{ kind: "literal", value: "wowcool" },
|
||||
],
|
||||
},
|
||||
separator: { kind: "literal", value: " " },
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
accepts(WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, {
|
||||
...basePayload,
|
||||
bindings: [{ target: "request", expression }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
accepts(authoredRpcSchemas["workflow.draft_workspaces.set_step_input_bindings"].payload, {
|
||||
...basePayload,
|
||||
bindings: [{ target: "request", expression }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
accepts(WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, {
|
||||
...basePayload,
|
||||
bindings: [{ target: "request", expression: { ...expression, extra: true } }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
accepts(authoredRpcSchemas["workflow.draft_workspaces.set_step_input_bindings"].payload, {
|
||||
...basePayload,
|
||||
bindings: [{ target: "request", expression: { ...expression, extra: true } }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
accepts(authoredRpcSchemas["workflow.draft_workspaces.set_step_output_bindings"].payload, {
|
||||
...basePayload,
|
||||
bindings: [{ target: "state.output", expression }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("catalogs every authored RPC exactly once", () => {
|
||||
const expectedMethods = [
|
||||
"workflow.health",
|
||||
@@ -1143,6 +1195,7 @@ describe("authored RPC and manifest schema parity", () => {
|
||||
|
||||
it("reports the exact remaining translator blockers", () => {
|
||||
expect(parityReport().blockers).toEqual([
|
||||
"workflow.draft_workspaces.create_from_capability:payload:oneOf@#/components/schemas/InputPathBinding.properties.path",
|
||||
"workflow.draft_workspaces.add_step_from_capability:payload:oneOf@#/components/schemas/InputPathBinding.properties.path",
|
||||
"workflow.draft_workspaces.update_capability_step:payload:oneOf@#/components/schemas/InputPathBinding.properties.path",
|
||||
"workflow.draft_workspaces.set_step_input_bindings:payload:oneOf@#/components/schemas/InputPathBinding.properties.path",
|
||||
|
||||
@@ -165,6 +165,87 @@ describe("translateJsonSchema", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("translates a recursive discriminated oneOf contract", () => {
|
||||
const components = {
|
||||
InputExpression: {
|
||||
discriminator: {
|
||||
mapping: {
|
||||
array: "#/components/schemas/ArrayExpression",
|
||||
literal: "#/components/schemas/LiteralExpression",
|
||||
object: "#/components/schemas/ObjectExpression",
|
||||
path: "#/components/schemas/PathExpression",
|
||||
},
|
||||
propertyName: "kind",
|
||||
},
|
||||
oneOf: [
|
||||
{ $ref: "#/components/schemas/LiteralExpression" },
|
||||
{ $ref: "#/components/schemas/PathExpression" },
|
||||
{ $ref: "#/components/schemas/ArrayExpression" },
|
||||
{ $ref: "#/components/schemas/ObjectExpression" },
|
||||
],
|
||||
},
|
||||
LiteralExpression: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { const: "literal", type: "string" },
|
||||
value: { type: "string" },
|
||||
},
|
||||
required: ["kind", "value"],
|
||||
type: "object",
|
||||
},
|
||||
PathExpression: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { const: "path", type: "string" },
|
||||
path: { type: "string" },
|
||||
},
|
||||
required: ["kind", "path"],
|
||||
type: "object",
|
||||
},
|
||||
ArrayExpression: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { const: "array", type: "string" },
|
||||
items: {
|
||||
items: { $ref: "#/components/schemas/InputExpression" },
|
||||
type: "array",
|
||||
},
|
||||
},
|
||||
required: ["kind", "items"],
|
||||
type: "object",
|
||||
},
|
||||
ObjectExpression: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
fields: {
|
||||
additionalProperties: { $ref: "#/components/schemas/InputExpression" },
|
||||
type: "object",
|
||||
},
|
||||
kind: { const: "object", type: "string" },
|
||||
},
|
||||
required: ["kind", "fields"],
|
||||
type: "object",
|
||||
},
|
||||
};
|
||||
const schema = translatedSchema(
|
||||
{ $ref: "#/components/schemas/InputExpression" },
|
||||
components,
|
||||
);
|
||||
|
||||
expect(accepts(schema, { kind: "literal", value: "hello" })).toBe(true);
|
||||
expect(
|
||||
accepts(schema, {
|
||||
kind: "object",
|
||||
fields: {
|
||||
nested: { kind: "array", items: [{ kind: "path", path: "state.foo" }] },
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(accepts(schema, { kind: "literal", value: "hello", extra: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unproductive component reference cycles", () => {
|
||||
const components = {
|
||||
Loop: { $ref: "#/components/schemas/Loop" },
|
||||
|
||||
@@ -270,7 +270,7 @@ class Translator {
|
||||
return failure(path, "JSON Schema must be a boolean or object");
|
||||
}
|
||||
|
||||
for (const keyword of ["allOf", "if", "not", "oneOf", "then", "else"]) {
|
||||
for (const keyword of ["allOf", "if", "not", "then", "else"]) {
|
||||
if (keyword in value) {
|
||||
return failure(
|
||||
path,
|
||||
@@ -283,6 +283,9 @@ class Translator {
|
||||
if ("$ref" in value) {
|
||||
return this.#translateRef(value, path, structuralDepth);
|
||||
}
|
||||
if ("oneOf" in value) {
|
||||
return this.#translateDiscriminatedOneOf(value, path, structuralDepth);
|
||||
}
|
||||
if ("anyOf" in value) {
|
||||
return this.#translateAnyOf(value, path, structuralDepth);
|
||||
}
|
||||
@@ -398,6 +401,62 @@ class Translator {
|
||||
return Either.right(Schema.Union(...members));
|
||||
}
|
||||
|
||||
#translateDiscriminatedOneOf(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
path: string,
|
||||
structuralDepth: number,
|
||||
): Either.Either<Schema.Schema.AnyNoContext, JsonSchemaTranslationError> {
|
||||
const unsupported = unsupportedKeyword(
|
||||
value,
|
||||
new Set(["discriminator", "oneOf"]),
|
||||
path,
|
||||
);
|
||||
if (unsupported !== null) return Either.left(unsupported);
|
||||
if (!Array.isArray(value.oneOf) || value.oneOf.length === 0) {
|
||||
return failure(path, "oneOf must be a non-empty array", "oneOf");
|
||||
}
|
||||
if (!isRecord(value.discriminator)) {
|
||||
return failure(
|
||||
path,
|
||||
"oneOf is supported only with a discriminator",
|
||||
"oneOf",
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.discriminator.propertyName !== "string" ||
|
||||
value.discriminator.propertyName.length === 0
|
||||
) {
|
||||
return failure(
|
||||
path,
|
||||
"discriminator.propertyName must be a non-empty string",
|
||||
"discriminator",
|
||||
);
|
||||
}
|
||||
const mapping = value.discriminator.mapping;
|
||||
if (!isRecord(mapping) || Object.values(mapping).some((ref) => typeof ref !== "string")) {
|
||||
return failure(
|
||||
path,
|
||||
"discriminator.mapping must map tags to local references",
|
||||
"discriminator",
|
||||
);
|
||||
}
|
||||
|
||||
const members: Schema.Schema.AnyNoContext[] = [];
|
||||
for (const [index, member] of value.oneOf.entries()) {
|
||||
const translated = this.translate(
|
||||
member,
|
||||
`${path}.oneOf[${index}]`,
|
||||
structuralDepth,
|
||||
);
|
||||
if (Either.isLeft(translated)) return translated;
|
||||
members.push(translated.right);
|
||||
}
|
||||
// The generated InputExpression branches are disjoint by their tag, so a
|
||||
// runtime union preserves the discriminated contract without weakening
|
||||
// arbitrary oneOf schemas into an overlapping union.
|
||||
return Either.right(Schema.Union(...members));
|
||||
}
|
||||
|
||||
#translateConst(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
path: string,
|
||||
|
||||
@@ -522,6 +522,43 @@ describe("run operation registry", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not flatten composite bindings into a fake inline CLI value", async () => {
|
||||
const { getOperationMeta } = await import("./method-registry.js");
|
||||
const operation = getOperationMeta(
|
||||
"workflow.draft_workspaces.set_step_input_bindings",
|
||||
);
|
||||
if (operation === undefined) throw new Error("missing set-input operation");
|
||||
|
||||
const cli = operation.equivalentCli({
|
||||
workspace_id: "console.demo",
|
||||
revision: 3,
|
||||
step_id: "concat",
|
||||
bindings: [
|
||||
{
|
||||
target: "request",
|
||||
expression: {
|
||||
kind: "object",
|
||||
fields: {
|
||||
items: {
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "path", path: "state.foo" },
|
||||
{ kind: "literal", value: "wowcool" },
|
||||
],
|
||||
},
|
||||
separator: { kind: "literal", value: " " },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(cli).toContain(
|
||||
"[non-equivalent: unavailable CLI representation for input_bindings (use --bindings-file)]",
|
||||
);
|
||||
expect(cli).not.toContain("--value");
|
||||
});
|
||||
|
||||
it("explains unavailable inline schema and capability fields", async () => {
|
||||
const { getOperationMeta } = await import("./method-registry.js");
|
||||
const createEmpty = getOperationMeta(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Schema } from "effect";
|
||||
import type {
|
||||
NodeSpecCapabilitySummary,
|
||||
InputExpressionBinding,
|
||||
InputPathBinding,
|
||||
InputValueBinding,
|
||||
WorkflowOperationName,
|
||||
WrapperArtifactCapabilitySummary,
|
||||
} from "./generated/workflow-contract.js";
|
||||
@@ -54,6 +57,11 @@ import {
|
||||
WorkflowRunsTraceResultSchema,
|
||||
} from "./rpcs.js";
|
||||
|
||||
type StepInputBinding =
|
||||
| InputPathBinding
|
||||
| InputValueBinding
|
||||
| InputExpressionBinding;
|
||||
|
||||
export type OperationMeta = {
|
||||
readonly method: WorkflowOperationName;
|
||||
readonly label: string;
|
||||
@@ -174,17 +182,21 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const inputBindingCliArgs = (
|
||||
bindings: readonly unknown[],
|
||||
bindings: readonly StepInputBinding[],
|
||||
pathFlag: "--input" | "--map" = "--input",
|
||||
): { readonly args: readonly string[]; readonly unavailable: readonly string[] } => {
|
||||
const args: string[] = [];
|
||||
const unavailable: string[] = [];
|
||||
for (const binding of bindings) {
|
||||
if (!isRecord(binding) || typeof binding.target !== "string") {
|
||||
if (typeof binding.target !== "string") {
|
||||
unavailable.push("input_bindings (use --bindings-file)");
|
||||
continue;
|
||||
}
|
||||
if (typeof binding.path === "string") {
|
||||
if ("path" in binding) {
|
||||
if (typeof binding.path !== "string") {
|
||||
unavailable.push("input_bindings (use --bindings-file)");
|
||||
continue;
|
||||
}
|
||||
args.push(pathFlag, shellArg(`${binding.path}=${binding.target}`));
|
||||
continue;
|
||||
}
|
||||
@@ -192,9 +204,18 @@ const inputBindingCliArgs = (
|
||||
const serialized = JSON.stringify(binding.value);
|
||||
if (serialized !== undefined) {
|
||||
args.push("--value", shellArg(`${binding.target}=${serialized}`));
|
||||
continue;
|
||||
} else {
|
||||
unavailable.push("input_bindings (use --bindings-file)");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ("expression" in binding) {
|
||||
// Composite expressions have no lossless inline CLI equivalent.
|
||||
unavailable.push("input_bindings (use --bindings-file)");
|
||||
continue;
|
||||
}
|
||||
const exhaustive: never = binding;
|
||||
void exhaustive;
|
||||
unavailable.push("input_bindings (use --bindings-file)");
|
||||
}
|
||||
return { args, unavailable };
|
||||
|
||||
Reference in New Issue
Block a user