fix: address composite input contract review
This commit is contained in:
@@ -8,8 +8,7 @@ import {
|
||||
type OperationName,
|
||||
type InputExpression,
|
||||
type InputExpressionBinding,
|
||||
type InputPathBinding,
|
||||
type InputValueBinding,
|
||||
type StepInputBinding,
|
||||
type WorkflowOperationParams,
|
||||
type WorkflowOperationResult,
|
||||
} from "../index.js";
|
||||
@@ -97,7 +96,7 @@ describe("generated workflow contract", () => {
|
||||
expectTypeOf<
|
||||
WorkflowOperationParams<"workflow.draft_workspaces.set_step_input_bindings">
|
||||
>().toMatchTypeOf<{
|
||||
bindings: Array<InputPathBinding | InputValueBinding | InputExpressionBinding>;
|
||||
bindings: Array<StepInputBinding>;
|
||||
}>();
|
||||
expectTypeOf<
|
||||
WorkflowOperationParams<"workflow.draft_workspaces.set_step_output_bindings">
|
||||
|
||||
@@ -160,6 +160,11 @@ export type CapabilityKindPayload = "tool" | "resource" | "prompt" | "node_spec"
|
||||
* via the `definition` "InspectCapabilityResult".
|
||||
*/
|
||||
export type InspectCapabilityResult = NodeSpecCapabilityDetail | WrapperArtifactCapabilityDetail;
|
||||
/**
|
||||
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||
* via the `definition` "StepInputBinding".
|
||||
*/
|
||||
export type StepInputBinding = InputPathBinding | InputValueBinding | InputExpressionBinding;
|
||||
/**
|
||||
* This interface was referenced by `WorkflowContractMap`'s JSON-Schema
|
||||
* via the `definition` "JsonValue".
|
||||
@@ -449,7 +454,7 @@ export interface WorkflowContractMap {
|
||||
input_map?: {
|
||||
[k: string]: string;
|
||||
} | null;
|
||||
input_bindings?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[] | null;
|
||||
input_bindings?: StepInputBinding[] | null;
|
||||
bind_outputs?: {
|
||||
[k: string]: string;
|
||||
};
|
||||
@@ -540,7 +545,7 @@ export interface WorkflowContractMap {
|
||||
output_schema?: {
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
input?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[] | null;
|
||||
input?: StepInputBinding[] | null;
|
||||
output?: OutputBinding[] | null;
|
||||
input_map?: {
|
||||
[k: string]: string;
|
||||
@@ -693,7 +698,7 @@ export interface WorkflowContractMap {
|
||||
workspace_id: string;
|
||||
revision: number;
|
||||
step_id: string;
|
||||
bindings: (InputPathBinding | InputValueBinding | InputExpressionBinding)[];
|
||||
bindings: StepInputBinding[];
|
||||
};
|
||||
result: DraftWorkspaceResult;
|
||||
};
|
||||
@@ -779,7 +784,7 @@ export interface WorkflowContractMap {
|
||||
output_schema?: {
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
input?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[] | null;
|
||||
input?: StepInputBinding[] | null;
|
||||
output?: OutputBinding[] | null;
|
||||
input_map?: {
|
||||
[k: string]: string;
|
||||
@@ -1498,7 +1503,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 | InputExpressionBinding)[];
|
||||
input?: StepInputBinding[];
|
||||
/**
|
||||
* Canonical output bindings from node-local output paths to workflow state destinations.
|
||||
*/
|
||||
@@ -1768,7 +1773,7 @@ export interface DraftInterruptStep {
|
||||
export interface DraftInterruptPayload {
|
||||
kind: string;
|
||||
outcomes?: string[];
|
||||
request?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[];
|
||||
request?: StepInputBinding[];
|
||||
request_schema?: SchemaRef | null;
|
||||
resume?: OutputBinding[];
|
||||
resume_schema?: SchemaRef | null;
|
||||
@@ -2015,7 +2020,7 @@ export interface DraftSubgraphStep {
|
||||
*/
|
||||
export interface DraftSubgraphPayload {
|
||||
desc?: string | null;
|
||||
input?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[];
|
||||
input?: StepInputBinding[];
|
||||
input_schema?: SchemaRef;
|
||||
/**
|
||||
* @minItems 1
|
||||
@@ -2214,7 +2219,7 @@ export interface ListDraftWorkspacesResult {
|
||||
*/
|
||||
export interface CapabilityStepUpdate {
|
||||
desc?: string | null;
|
||||
input?: (InputPathBinding | InputValueBinding | InputExpressionBinding)[] | null;
|
||||
input?: StepInputBinding[] | null;
|
||||
retry?: number | null;
|
||||
timeout_seconds?: number | null;
|
||||
}
|
||||
@@ -2870,17 +2875,7 @@ export const workflowRuntimeContract = {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InputPathBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputValueBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputExpressionBinding"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/StepInputBinding"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
@@ -4646,6 +4641,19 @@ export const workflowRuntimeContract = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"StepInputBinding": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InputPathBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputValueBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputExpressionBinding"
|
||||
}
|
||||
]
|
||||
},
|
||||
"TraceEntryPayload": {
|
||||
"properties": {
|
||||
"frame_id": {
|
||||
@@ -5463,17 +5471,7 @@ export const workflowRuntimeContract = {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InputPathBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputValueBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputExpressionBinding"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/StepInputBinding"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
@@ -5688,17 +5686,7 @@ export const workflowRuntimeContract = {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InputPathBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputValueBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputExpressionBinding"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/StepInputBinding"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
@@ -5859,17 +5847,7 @@ export const workflowRuntimeContract = {
|
||||
},
|
||||
"bindings": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InputPathBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputValueBinding"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InputExpressionBinding"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/StepInputBinding"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Schema } from "effect";
|
||||
import { hasBoundedInputExpressionNodeBudget } from "./input-expression-limits.js";
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { readonly [key: string]: JsonValue };
|
||||
|
||||
@@ -353,6 +354,11 @@ const InputExpressionSchema: Schema.Schema<InputExpression, unknown, never> =
|
||||
ObjectExpressionSchema,
|
||||
),
|
||||
);
|
||||
const BoundedInputExpressionSchema = InputExpressionSchema.pipe(
|
||||
Schema.filter((value) => hasBoundedInputExpressionNodeBudget(value), {
|
||||
message: () => "input expression exceeds the 1024-node budget",
|
||||
}),
|
||||
);
|
||||
const InputExpressionBindingSchema = Schema.Struct({
|
||||
target: Schema.Union(
|
||||
Schema.String,
|
||||
@@ -361,7 +367,7 @@ const InputExpressionBindingSchema = Schema.Struct({
|
||||
root: Schema.Literal("local"),
|
||||
}),
|
||||
),
|
||||
expression: InputExpressionSchema,
|
||||
expression: BoundedInputExpressionSchema,
|
||||
});
|
||||
|
||||
const InputBindingSchema = Schema.Union(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export const MAX_INPUT_EXPRESSION_NODES = 1024;
|
||||
|
||||
const expressionKinds = new Set(["literal", "path", "array", "object"]);
|
||||
|
||||
/** Count expression nodes and containers nested inside literal values. */
|
||||
export const hasBoundedInputExpressionNodeBudget = (
|
||||
input: unknown,
|
||||
maxNodes: number = MAX_INPUT_EXPRESSION_NODES,
|
||||
): boolean => {
|
||||
let nodes = 0;
|
||||
const active = new WeakSet<object>();
|
||||
|
||||
const visitNode = (value: object): boolean => {
|
||||
if (active.has(value)) return false;
|
||||
active.add(value);
|
||||
nodes += 1;
|
||||
if (nodes > maxNodes) {
|
||||
active.delete(value);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const visitJson = (value: unknown): boolean => {
|
||||
if (typeof value !== "object" || value === null) return true;
|
||||
if (!visitNode(value)) return false;
|
||||
const valid = Array.isArray(value)
|
||||
? value.every(visitJson)
|
||||
: Object.values(value).every(visitJson);
|
||||
active.delete(value);
|
||||
return valid;
|
||||
};
|
||||
|
||||
const visitExpression = (value: unknown): boolean => {
|
||||
if (!isRecord(value) || typeof value.kind !== "string") return false;
|
||||
if (!expressionKinds.has(value.kind) || !visitNode(value)) return false;
|
||||
|
||||
let valid = true;
|
||||
switch (value.kind) {
|
||||
case "literal":
|
||||
valid = visitJson(value.value);
|
||||
break;
|
||||
case "path":
|
||||
break;
|
||||
case "array":
|
||||
valid = Array.isArray(value.items) && value.items.every(visitExpression);
|
||||
break;
|
||||
case "object":
|
||||
valid = isRecord(value.fields) && Object.values(value.fields).every(visitExpression);
|
||||
break;
|
||||
}
|
||||
active.delete(value);
|
||||
return valid;
|
||||
};
|
||||
|
||||
return visitExpression(input);
|
||||
};
|
||||
|
||||
/** Find and bound generated expression-shaped values anywhere in an RPC value. */
|
||||
export const hasBoundedInputExpressionPayload = (
|
||||
input: unknown,
|
||||
maxNodes: number = MAX_INPUT_EXPRESSION_NODES,
|
||||
): boolean => {
|
||||
const active = new WeakSet<object>();
|
||||
const visit = (value: unknown): boolean => {
|
||||
if (typeof value !== "object" || value === null) return true;
|
||||
if (active.has(value)) return false;
|
||||
active.add(value);
|
||||
const valid = Array.isArray(value)
|
||||
? value.every(visit)
|
||||
: isRecord(value) &&
|
||||
(typeof value.kind === "string" &&
|
||||
expressionKinds.has(value.kind) &&
|
||||
((value.kind === "literal" && "value" in value) ||
|
||||
(value.kind === "path" && "path" in value) ||
|
||||
(value.kind === "array" && Array.isArray(value.items)) ||
|
||||
(value.kind === "object" && isRecord(value.fields)))
|
||||
? hasBoundedInputExpressionNodeBudget(value, maxNodes)
|
||||
: Object.values(value).every(visit));
|
||||
active.delete(value);
|
||||
return valid;
|
||||
};
|
||||
|
||||
return visit(input);
|
||||
};
|
||||
@@ -1148,6 +1148,25 @@ describe("authored RPC and manifest schema parity", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects authored expressions over 1024 nodes including literal containers", () => {
|
||||
const basePayload = {
|
||||
workspace_id: "console.demo",
|
||||
revision: 3,
|
||||
step_id: "concat",
|
||||
};
|
||||
const expression = {
|
||||
kind: "literal",
|
||||
value: Array.from({ length: 1023 }, () => ({})),
|
||||
};
|
||||
|
||||
expect(
|
||||
accepts(authoredRpcSchemas["workflow.draft_workspaces.set_step_input_bindings"].payload, {
|
||||
...basePayload,
|
||||
bindings: [{ target: "request", expression }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("catalogs every authored RPC exactly once", () => {
|
||||
const expectedMethods = [
|
||||
"workflow.health",
|
||||
|
||||
@@ -88,6 +88,47 @@ describe("runtimeSchemasFor", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an input expression over the canonical 1024-node budget", () => {
|
||||
const schemas = runtimeSchemasFor(
|
||||
"workflow.draft_workspaces.set_step_input_bindings",
|
||||
);
|
||||
const expression = {
|
||||
kind: "array",
|
||||
items: Array.from({ length: 1025 }, () => ({
|
||||
kind: "literal",
|
||||
value: "child",
|
||||
})),
|
||||
};
|
||||
|
||||
expect(
|
||||
accepts(schemas.payload, {
|
||||
workspace_id: "console.demo",
|
||||
revision: 3,
|
||||
step_id: "render",
|
||||
bindings: [{ target: "request", expression }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("counts nested literal array and object containers in the input budget", () => {
|
||||
const schemas = runtimeSchemasFor(
|
||||
"workflow.draft_workspaces.set_step_input_bindings",
|
||||
);
|
||||
const expression = {
|
||||
kind: "literal",
|
||||
value: Array.from({ length: 1023 }, () => ({})),
|
||||
};
|
||||
|
||||
expect(
|
||||
accepts(schemas.payload, {
|
||||
workspace_id: "console.demo",
|
||||
revision: 3,
|
||||
step_id: "render",
|
||||
bindings: [{ target: "request", expression }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns typed payload and result schemas for a generated operation", () => {
|
||||
const schemas = runtimeSchemasFor("workflow.health");
|
||||
const payload: WorkflowOperationParams<"workflow.health"> =
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type WorkflowOperationResult,
|
||||
} from "../generated/workflow-contract.js";
|
||||
import { translateJsonSchema } from "./translator.js";
|
||||
import { hasBoundedInputExpressionPayload } from "./input-expression-limits.js";
|
||||
|
||||
type RuntimeOperationName = keyof typeof workflowRuntimeContract.operations;
|
||||
const MAX_RUNTIME_VALUE_DEPTH = 64;
|
||||
@@ -47,6 +48,10 @@ const BoundedRuntimeValueSchema = Schema.Unknown.pipe(
|
||||
message: () =>
|
||||
`runtime value exceeds ${MAX_RUNTIME_VALUE_DEPTH} nested containers`,
|
||||
}),
|
||||
Schema.filter((value) => hasBoundedInputExpressionPayload(value), {
|
||||
message: () =>
|
||||
"runtime value contains an input expression over the 1024-node budget",
|
||||
}),
|
||||
);
|
||||
|
||||
const translatedAst = (schema: unknown): AST.AST => {
|
||||
|
||||
@@ -246,6 +246,54 @@ describe("translateJsonSchema", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a decorative discriminator on inline overlapping oneOf branches", () => {
|
||||
const error = rejected({
|
||||
discriminator: {
|
||||
mapping: { text: "#/components/schemas/Text", short: "#/components/schemas/Short" },
|
||||
propertyName: "kind",
|
||||
},
|
||||
oneOf: [{ type: "string" }, { minLength: 1, type: "string" }],
|
||||
}, {
|
||||
Short: { minLength: 1, type: "string" },
|
||||
Text: { type: "string" },
|
||||
});
|
||||
|
||||
expect(error.keyword).toBe("oneOf");
|
||||
expect(error.message).toMatch(/generated tagged object union/i);
|
||||
});
|
||||
|
||||
it("rejects discriminated branches without distinct discriminator constants", () => {
|
||||
const error = rejected({
|
||||
discriminator: {
|
||||
mapping: {
|
||||
first: "#/components/schemas/First",
|
||||
second: "#/components/schemas/Second",
|
||||
},
|
||||
propertyName: "kind",
|
||||
},
|
||||
oneOf: [
|
||||
{ $ref: "#/components/schemas/First" },
|
||||
{ $ref: "#/components/schemas/Second" },
|
||||
],
|
||||
}, {
|
||||
First: {
|
||||
additionalProperties: false,
|
||||
properties: { kind: { type: "string" }, value: { type: "string" } },
|
||||
required: ["kind", "value"],
|
||||
type: "object",
|
||||
},
|
||||
Second: {
|
||||
additionalProperties: false,
|
||||
properties: { kind: { type: "string" }, value: { type: "string" } },
|
||||
required: ["kind", "value"],
|
||||
type: "object",
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.keyword).toBe("oneOf");
|
||||
expect(error.message).toMatch(/generated tagged object union/i);
|
||||
});
|
||||
|
||||
it("rejects unproductive component reference cycles", () => {
|
||||
const components = {
|
||||
Loop: { $ref: "#/components/schemas/Loop" },
|
||||
|
||||
@@ -441,6 +441,14 @@ class Translator {
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.#isGeneratedDiscriminatedUnion(value.oneOf, value.discriminator.propertyName, mapping)) {
|
||||
return failure(
|
||||
path,
|
||||
"discriminated oneOf is supported only for generated tagged object unions",
|
||||
"oneOf",
|
||||
);
|
||||
}
|
||||
|
||||
const members: Schema.Schema.AnyNoContext[] = [];
|
||||
for (const [index, member] of value.oneOf.entries()) {
|
||||
const translated = this.translate(
|
||||
@@ -457,6 +465,55 @@ class Translator {
|
||||
return Either.right(Schema.Union(...members));
|
||||
}
|
||||
|
||||
#isGeneratedDiscriminatedUnion(
|
||||
branches: readonly unknown[],
|
||||
propertyName: string,
|
||||
mapping: Record<string, unknown>,
|
||||
): boolean {
|
||||
const mappedReferences = Object.entries(mapping);
|
||||
if (mappedReferences.length !== branches.length) return false;
|
||||
|
||||
const references = new Set<string>();
|
||||
for (const branch of branches) {
|
||||
if (!isRecord(branch) || typeof branch.$ref !== "string") return false;
|
||||
if (references.has(branch.$ref)) return false;
|
||||
references.add(branch.$ref);
|
||||
}
|
||||
|
||||
const mappedReferenceSet = new Set<string>();
|
||||
for (const [tag, reference] of mappedReferences) {
|
||||
if (typeof reference !== "string") return false;
|
||||
if (mappedReferenceSet.has(reference) || !references.has(reference)) return false;
|
||||
mappedReferenceSet.add(reference);
|
||||
const prefix = "#/components/schemas/";
|
||||
if (!reference.startsWith(prefix)) return false;
|
||||
const component = this.#components[reference.slice(prefix.length)];
|
||||
if (!isRecord(component)) return false;
|
||||
if (component.type !== "object" || component.additionalProperties !== false) {
|
||||
return false;
|
||||
}
|
||||
const required = component.required;
|
||||
const properties = component.properties;
|
||||
if (
|
||||
!Array.isArray(required) ||
|
||||
!required.includes(propertyName) ||
|
||||
!isRecord(properties) ||
|
||||
!isRecord(properties[propertyName])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const discriminator = properties[propertyName];
|
||||
if (
|
||||
discriminator.type !== "string" ||
|
||||
discriminator.const !== tag
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return mappedReferenceSet.size === references.size;
|
||||
}
|
||||
|
||||
#translateConst(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
path: string,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Schema } from "effect";
|
||||
import type {
|
||||
NodeSpecCapabilitySummary,
|
||||
InputExpressionBinding,
|
||||
InputPathBinding,
|
||||
InputValueBinding,
|
||||
StepInputBinding,
|
||||
WorkflowOperationName,
|
||||
WrapperArtifactCapabilitySummary,
|
||||
} from "./generated/workflow-contract.js";
|
||||
@@ -57,11 +55,6 @@ import {
|
||||
WorkflowRunsTraceResultSchema,
|
||||
} from "./rpcs.js";
|
||||
|
||||
type StepInputBinding =
|
||||
| InputPathBinding
|
||||
| InputValueBinding
|
||||
| InputExpressionBinding;
|
||||
|
||||
export type OperationMeta = {
|
||||
readonly method: WorkflowOperationName;
|
||||
readonly label: string;
|
||||
|
||||
Reference in New Issue
Block a user