feat: add interrupt schema contract fields
This commit is contained in:
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Annotated, Literal, Self
|
from typing import Annotated, Literal, Self
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator, SchemaError, validators
|
||||||
|
|
||||||
from wf_core.models.conditions import Condition
|
from wf_core.models.conditions import Condition
|
||||||
from wf_core.models.schemas import SchemaRef
|
from wf_core.models.schemas import SchemaRef
|
||||||
@@ -332,6 +334,31 @@ class EndNode(BaseModel):
|
|||||||
outcome: str = Field(default="ok", min_length=1)
|
outcome: str = Field(default="ok", min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _object_schema() -> dict[str, object]:
|
||||||
|
"""Default legacy interrupt contract: any JSON object payload is accepted."""
|
||||||
|
return {"type": "object", "additionalProperties": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_json_schema(value: object, *, field_name: str) -> dict[str, object]:
|
||||||
|
"""Validate one interrupt contract schema with jsonschema."""
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError(f"{field_name} must be a JSON Schema object")
|
||||||
|
schema = dict(value)
|
||||||
|
validator_cls = (
|
||||||
|
validators.validator_for(schema)
|
||||||
|
if "$schema" in schema
|
||||||
|
else Draft202012Validator
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
validator_cls.check_schema(schema)
|
||||||
|
except SchemaError as exc:
|
||||||
|
raise ValueError(f"invalid JSON Schema: {exc.message}") from exc
|
||||||
|
schema_type = schema.get("type")
|
||||||
|
if schema_type != "object":
|
||||||
|
raise ValueError(f"{field_name} must describe a JSON object")
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
class InterruptNode(BaseModel):
|
class InterruptNode(BaseModel):
|
||||||
"""Control-flow step that pauses a run and waits for resume input."""
|
"""Control-flow step that pauses a run and waits for resume input."""
|
||||||
|
|
||||||
@@ -351,6 +378,15 @@ class InterruptNode(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
outcomes: list[str] = Field(default_factory=lambda: ["submitted"])
|
outcomes: list[str] = Field(default_factory=lambda: ["submitted"])
|
||||||
|
request_schema: dict[str, object] = Field(default_factory=_object_schema)
|
||||||
|
resume_schema: dict[str, object] = Field(default_factory=_object_schema)
|
||||||
|
has_explicit_contract: bool = Field(default=False, exclude=True)
|
||||||
|
|
||||||
|
@field_validator("request_schema", "resume_schema", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _validate_interrupt_schema(cls, value: object, info: object) -> object:
|
||||||
|
field_name = getattr(info, "field_name", "interrupt schema")
|
||||||
|
return _validate_json_schema(value, field_name=field_name)
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -388,6 +424,9 @@ class InterruptNode(BaseModel):
|
|||||||
|
|
||||||
normalized["request"] = request_bindings
|
normalized["request"] = request_bindings
|
||||||
normalized["resume"] = resume_bindings
|
normalized["resume"] = resume_bindings
|
||||||
|
normalized["has_explicit_contract"] = (
|
||||||
|
"request_schema" in normalized or "resume_schema" in normalized
|
||||||
|
)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,62 @@ def test_interrupt_node_rejects_mixed_old_and_new_binding_styles():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupt_node_defaults_to_untyped_object_contract():
|
||||||
|
node = InterruptNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "approval",
|
||||||
|
"type": "interrupt",
|
||||||
|
"kind": "approval",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert node.request_schema == {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": True,
|
||||||
|
}
|
||||||
|
assert node.resume_schema == {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": True,
|
||||||
|
}
|
||||||
|
assert node.has_explicit_contract is False
|
||||||
|
|
||||||
|
dumped = node.model_dump(mode="json")
|
||||||
|
assert dumped["request_schema"] == {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": True,
|
||||||
|
}
|
||||||
|
assert dumped["resume_schema"] == {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupt_node_accepts_explicit_request_and_resume_schemas():
|
||||||
|
node = InterruptNode.model_validate(
|
||||||
|
{
|
||||||
|
"id": "approval",
|
||||||
|
"type": "interrupt",
|
||||||
|
"kind": "approval",
|
||||||
|
"request_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"message": {"type": "string"}},
|
||||||
|
"required": ["message"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
"resume_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"approved": {"type": "boolean"}},
|
||||||
|
"required": ["approved"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert node.has_explicit_contract is True
|
||||||
|
assert node.request_schema["required"] == ["message"]
|
||||||
|
assert node.resume_schema["required"] == ["approved"]
|
||||||
|
|
||||||
|
|
||||||
def test_foreach_node_serializes_over_path_as_canonical_string():
|
def test_foreach_node_serializes_over_path_as_canonical_string():
|
||||||
node = ForeachNode.model_validate(
|
node = ForeachNode.model_validate(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user