feat: decode composite step inputs
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user