fix: address composite input contract review

This commit is contained in:
lda
2026-08-13 21:44:29 +07:00 Verified
parent 1bb684bfcd
commit cca1be891f
18 changed files with 455 additions and 172 deletions
@@ -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 -8
View File
@@ -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;