feat: decode composite step inputs

This commit is contained in:
lda
2026-08-13 19:49:39 +07:00 Verified
parent 938864b4d2
commit 1bb684bfcd
22 changed files with 1339 additions and 119 deletions
@@ -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" },
+60 -1
View File
@@ -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,