This commit is contained in:
lda
2026-05-20 23:29:53 +07:00 Verified
parent bc8dc138c7
commit ecfb64691e
36 changed files with 1191 additions and 1368 deletions
+180 -182
View File
@@ -9,203 +9,201 @@ DemoHandler = Callable[[dict[str, object], RuntimeContext], dict[str, object]]
def build_demo_workflow() -> Workflow: def build_demo_workflow() -> Workflow:
return Workflow.model_validate( return Workflow.model_validate({
{ "name": "drive_summary_demo",
"name": "drive_summary_demo", "input_schema": {
"input_schema": { "type": "object",
"type": "object", "properties": {
"properties": { "folder_id": {"type": "string"},
"folder_id": {"type": "string"}, "should_email": {"type": "boolean"},
"should_email": {"type": "boolean"},
},
"required": ["folder_id", "should_email"],
}, },
"state_schema": { "required": ["folder_id", "should_email"],
"fields": { },
"folder_id": {"type": "string"}, "state_schema": {
"should_email": {"type": "boolean"}, "fields": {
"documents": {"type": "array", "reducer": "wf.std.replace"}, "folder_id": {"type": "string"},
"item_summaries": {"type": "array", "reducer": "wf.std.append"}, "should_email": {"type": "boolean"},
"summary": {"type": "string", "reducer": "wf.std.replace"}, "documents": {"type": "array", "reducer": "wf.std.replace"},
"approved": {"type": "boolean", "reducer": "wf.std.replace"}, "item_summaries": {"type": "array", "reducer": "wf.std.append"},
"approval_comment": { "summary": {"type": "string", "reducer": "wf.std.replace"},
"type": "string", "approved": {"type": "boolean", "reducer": "wf.std.replace"},
"reducer": "wf.std.replace", "approval_comment": {
}, "type": "string",
"email_status": {"type": "string", "reducer": "wf.std.replace"}, "reducer": "wf.std.replace",
} },
"email_status": {"type": "string", "reducer": "wf.std.replace"},
}
},
"output_schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
"email_status": {"type": "string"},
}, },
"output_schema": { "required": ["summary", "email_status"],
"type": "object", },
"properties": { "node_defs": [
"summary": {"type": "string"}, {
"email_status": {"type": "string"}, "name": "drive_list_files",
"input_schema": {
"type": "object",
"properties": {"folder_id": {"type": "string"}},
"required": ["folder_id"],
}, },
"required": ["summary", "email_status"], "output_schema": {
"type": "object",
"properties": {"documents": {"type": "array"}},
"required": ["documents"],
},
"outcomes": ["ok"],
}, },
"node_defs": [ {
{ "name": "summarize_document",
"name": "drive_list_files", "input_schema": {
"input_schema": { "type": "object",
"type": "object", "properties": {"document": {"type": "string"}},
"properties": {"folder_id": {"type": "string"}}, "required": ["document"],
"required": ["folder_id"],
},
"output_schema": {
"type": "object",
"properties": {"documents": {"type": "array"}},
"required": ["documents"],
},
"outcomes": ["ok"],
}, },
{ "output_schema": {
"name": "summarize_document", "type": "object",
"input_schema": { "properties": {"item_summary": {"type": "string"}},
"type": "object", "required": ["item_summary"],
"properties": {"document": {"type": "string"}},
"required": ["document"],
},
"output_schema": {
"type": "object",
"properties": {"item_summary": {"type": "string"}},
"required": ["item_summary"],
},
"outcomes": ["ok"],
}, },
{ "outcomes": ["ok"],
"name": "combine_summaries", },
"input_schema": { {
"type": "object", "name": "combine_summaries",
"properties": {"item_summaries": {"type": "array"}}, "input_schema": {
"required": ["item_summaries"], "type": "object",
}, "properties": {"item_summaries": {"type": "array"}},
"output_schema": { "required": ["item_summaries"],
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
},
"outcomes": ["ok"],
}, },
{ "output_schema": {
"name": "send_email", "type": "object",
"input_schema": { "properties": {"summary": {"type": "string"}},
"type": "object", "required": ["summary"],
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
},
"output_schema": {
"type": "object",
"properties": {"email_status": {"type": "string"}},
"required": ["email_status"],
},
"outcomes": ["sent"],
}, },
{ "outcomes": ["ok"],
"name": "mark_email_skipped", },
"input_schema": { {
"type": "object", "name": "send_email",
"properties": {}, "input_schema": {
}, "type": "object",
"output_schema": { "properties": {"summary": {"type": "string"}},
"type": "object", "required": ["summary"],
"properties": {"email_status": {"type": "string"}},
"required": ["email_status"],
},
"outcomes": ["ok"],
}, },
], "output_schema": {
"start": "list_files", "type": "object",
"nodes": [ "properties": {"email_status": {"type": "string"}},
{ "required": ["email_status"],
"id": "list_files",
"type": "node",
"node": "drive_list_files",
"desc": "List files from a Google Drive folder",
"in_map": {"input.folder_id": "folder_id"},
"out_map": {"documents": "state.documents"},
}, },
{ "outcomes": ["sent"],
"id": "summarize_each", },
"type": "foreach", {
"over": "state.documents", "name": "mark_email_skipped",
"as": "document", "input_schema": {
"mode": "serial", "type": "object",
"on_item_error": "fail", "properties": {},
}, },
{ "output_schema": {
"id": "summarize_one", "type": "object",
"type": "node", "properties": {"email_status": {"type": "string"}},
"node": "summarize_document", "required": ["email_status"],
"desc": "Summarize one document",
"in_map": {"context.document": "document"},
"out_map": {"item_summary": "state.item_summaries"},
}, },
{ "outcomes": ["ok"],
"id": "combine_summaries", },
"type": "node", ],
"node": "combine_summaries", "start": "list_files",
"desc": "Combine item summaries into one final summary", "nodes": [
"in_map": {"state.item_summaries": "item_summaries"}, {
"out_map": {"summary": "state.summary"}, "id": "list_files",
"type": "node",
"node": "drive_list_files",
"desc": "List files from a Google Drive folder",
"in_map": {"input.folder_id": "folder_id"},
"out_map": {"documents": "state.documents"},
},
{
"id": "summarize_each",
"type": "foreach",
"over": "state.documents",
"as": "document",
"mode": "serial",
"on_item_error": "fail",
},
{
"id": "summarize_one",
"type": "node",
"node": "summarize_document",
"desc": "Summarize one document",
"in_map": {"context.document": "document"},
"out_map": {"item_summary": "state.item_summaries"},
},
{
"id": "combine_summaries",
"type": "node",
"node": "combine_summaries",
"desc": "Combine item summaries into one final summary",
"in_map": {"state.item_summaries": "item_summaries"},
"out_map": {"summary": "state.summary"},
},
{
"id": "should_email",
"type": "condition",
"check": {
"op": "eq",
"left": {"path": "state.should_email"},
"right": {"value": True},
}, },
{ },
"id": "should_email", {
"type": "condition", "id": "send_email",
"check": { "type": "node",
"op": "eq", "node": "send_email",
"left": {"path": "state.should_email"}, "desc": "Send the summary by email",
"right": {"value": True}, "in_map": {"state.summary": "summary"},
}, "out_map": {"email_status": "state.email_status"},
},
{
"id": "approve_email",
"type": "interrupt",
"kind": "approval",
"request_map": {
"state.summary": "summary",
"input.folder_id": "folder_id",
}, },
{ "out_map": {
"id": "send_email", "approved": "state.approved",
"type": "node", "comment": "state.approval_comment",
"node": "send_email",
"desc": "Send the summary by email",
"in_map": {"state.summary": "summary"},
"out_map": {"email_status": "state.email_status"},
}, },
{ "outcomes": ["submitted", "cancelled"],
"id": "approve_email", },
"type": "interrupt", {
"kind": "approval", "id": "skip_email",
"request_map": { "type": "node",
"state.summary": "summary", "node": "mark_email_skipped",
"input.folder_id": "folder_id", "desc": "Record that email delivery was skipped",
}, "out_map": {"email_status": "state.email_status"},
"out_map": { },
"approved": "state.approved", ],
"comment": "state.approval_comment", "edges": [
}, {"from": "list_files", "outcome": "ok", "to": "summarize_each"},
"outcomes": ["submitted", "cancelled"], {"from": "summarize_each", "outcome": "loop", "to": "summarize_one"},
}, {
{ "from": "summarize_each",
"id": "skip_email", "outcome": "done",
"type": "node", "to": "combine_summaries",
"node": "mark_email_skipped", },
"desc": "Record that email delivery was skipped", {"from": "summarize_one", "outcome": "ok", "to": END},
"out_map": {"email_status": "state.email_status"}, {"from": "combine_summaries", "outcome": "ok", "to": "should_email"},
}, {"from": "should_email", "outcome": "true", "to": "approve_email"},
], {"from": "should_email", "outcome": "false", "to": "skip_email"},
"edges": [ {"from": "approve_email", "outcome": "submitted", "to": "send_email"},
{"from": "list_files", "outcome": "ok", "to": "summarize_each"}, {"from": "approve_email", "outcome": "cancelled", "to": "skip_email"},
{"from": "summarize_each", "outcome": "loop", "to": "summarize_one"}, {"from": "send_email", "outcome": "sent", "to": END},
{ {"from": "skip_email", "outcome": "ok", "to": END},
"from": "summarize_each", ],
"outcome": "done", })
"to": "combine_summaries",
},
{"from": "summarize_one", "outcome": "ok", "to": END},
{"from": "combine_summaries", "outcome": "ok", "to": "should_email"},
{"from": "should_email", "outcome": "true", "to": "approve_email"},
{"from": "should_email", "outcome": "false", "to": "skip_email"},
{"from": "approve_email", "outcome": "submitted", "to": "send_email"},
{"from": "approve_email", "outcome": "cancelled", "to": "skip_email"},
{"from": "send_email", "outcome": "sent", "to": END},
{"from": "skip_email", "outcome": "ok", "to": END},
],
}
)
def drive_list_files( def drive_list_files(
+37 -39
View File
@@ -34,47 +34,45 @@ async def run_example() -> dict[str, object]:
await service.refresh_connection_catalog("fixture.personal") await service.refresh_connection_catalog("fixture.personal")
plan = RawWorkflowPlan.model_validate( plan = RawWorkflowPlan.model_validate({
{ "name": "mcp_echo_workflow",
"name": "mcp_echo_workflow", "input_schema": {
"input_schema": { "type": "object",
"type": "object", "properties": {"text": {"type": "string"}},
"properties": {"text": {"type": "string"}}, "required": ["text"],
"required": ["text"], },
"state_schema": {
"type": "object",
"properties": {"echoed": {"type": "string"}},
},
"output_schema": {
"type": "object",
"properties": {"echoed": {"type": "string"}},
"required": ["echoed"],
},
"start": "echo",
"nodes": [
{
"id": "echo",
"type": "node",
"node": "fixture.personal.echo_tool",
"in_map": {"input.text": "text"},
"out_map": {"echoed": "state.echoed"},
}, },
"state_schema": { {
"type": "object", "id": "raise_mcp_error",
"properties": {"echoed": {"type": "string"}}, "type": "node",
"node": "wf.std.runtime_error",
"in_map": {"input.text": "message"},
"out_map": {},
}, },
"output_schema": { ],
"type": "object", "edges": [
"properties": {"echoed": {"type": "string"}}, {"from": "echo", "outcome": "ok", "to": END},
"required": ["echoed"], {"from": "echo", "outcome": "error", "to": "raise_mcp_error"},
}, {"from": "raise_mcp_error", "outcome": "ok", "to": END},
"start": "echo", ],
"nodes": [ })
{
"id": "echo",
"type": "node",
"node": "fixture.personal.echo_tool",
"in_map": {"input.text": "text"},
"out_map": {"echoed": "state.echoed"},
},
{
"id": "raise_mcp_error",
"type": "node",
"node": "wf.std.runtime_error",
"in_map": {"input.text": "message"},
"out_map": {},
},
],
"edges": [
{"from": "echo", "outcome": "ok", "to": END},
{"from": "echo", "outcome": "error", "to": "raise_mcp_error"},
{"from": "raise_mcp_error", "outcome": "ok", "to": END},
],
}
)
run = await service.run_workflow_from_plan(plan, {"text": "hello from MCP"}) run = await service.run_workflow_from_plan(plan, {"text": "hello from MCP"})
return { return {
+53 -55
View File
@@ -6,63 +6,61 @@ from wf_core.run_state import RunState
def build_raw_canonical_workflow() -> Workflow: def build_raw_canonical_workflow() -> Workflow:
"""Build a raw core workflow using the canonical post-migration shape.""" """Build a raw core workflow using the canonical post-migration shape."""
return Workflow.model_validate( return Workflow.model_validate({
{ "name": "raw_canonical_echo",
"name": "raw_canonical_echo", "input_schema": {
"input_schema": { "type": "object",
"type": "object", "properties": {"text": {"type": "string"}},
"properties": {"text": {"type": "string"}}, "required": ["text"],
"required": ["text"], },
"state_schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"reducer": "wf.std.replace",
}
}, },
"state_schema": { },
"type": "object", "output_schema": {
"properties": { "type": "object",
"message": { "properties": {"message": {"type": "string"}},
"type": "string", "required": ["message"],
"reducer": "wf.std.replace", },
} "node_defs": [
{
"name": "format_text",
"input_schema": {
"type": "object",
"properties": {
"text": {"type": "string"},
"prefix": {"type": "string"},
},
"required": ["text", "prefix"],
}, },
}, "output_schema": {
"output_schema": { "type": "object",
"type": "object", "properties": {"message": {"type": "string"}},
"properties": {"message": {"type": "string"}}, "required": ["message"],
"required": ["message"], },
}, "outcomes": ["ok"],
"node_defs": [ }
{ ],
"name": "format_text", "start": "format",
"input_schema": { "nodes": [
"type": "object", {
"properties": { "id": "format",
"text": {"type": "string"}, "type": "node",
"prefix": {"type": "string"}, "node": "format_text",
}, "input": [
"required": ["text", "prefix"], {"target": "text", "path": "input.text"},
}, {"target": "prefix", "value": "raw:"},
"output_schema": { ],
"type": "object", "output": [{"source": "message", "target": "state.message"}],
"properties": {"message": {"type": "string"}}, }
"required": ["message"], ],
}, "edges": [{"from": "format", "outcome": "ok", "to": END}],
"outcomes": ["ok"], })
}
],
"start": "format",
"nodes": [
{
"id": "format",
"type": "node",
"node": "format_text",
"input": [
{"target": "text", "path": "input.text"},
{"target": "prefix", "value": "raw:"},
],
"output": [{"source": "message", "target": "state.message"}],
}
],
"edges": [{"from": "format", "outcome": "ok", "to": END}],
}
)
def build_raw_canonical_registry(): def build_raw_canonical_registry():
+1 -1
View File
@@ -23,7 +23,7 @@ dev = [
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-p no:cacheprovider" addopts = "-p no:cacheprovider"
[tool.uv] [tool.uv]
package = true package = true
+4 -6
View File
@@ -38,12 +38,10 @@ def artifact_catalog_entry(
diagnostics: list[DependencyDiagnostic] | tuple[DependencyDiagnostic, ...] = (), diagnostics: list[DependencyDiagnostic] | tuple[DependencyDiagnostic, ...] = (),
) -> WorkflowArtifactCatalogEntry: ) -> WorkflowArtifactCatalogEntry:
"""Project an artifact as a catalog entry without exposing its internal plan.""" """Project an artifact as a catalog entry without exposing its internal plan."""
required_sources = sorted( required_sources = sorted({
{ capability.logical_source
capability.logical_source for capability in artifact.required_capability_map().values()
for capability in artifact.required_capability_map().values() })
}
)
return WorkflowArtifactCatalogEntry( return WorkflowArtifactCatalogEntry(
name=artifact_node_name(artifact), name=artifact_node_name(artifact),
artifact_id=artifact.id, artifact_id=artifact.id,
+9 -3
View File
@@ -7,9 +7,15 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
from wf_core.models.conditions import Condition from wf_core.models.conditions import Condition
JsonObject = dict[str, Any] JsonObject = dict[str, Any]
STEP_KIND_KEYS = frozenset( STEP_KIND_KEYS = frozenset({
{"use", "foreach", "interrupt", "join", "when", "choose", "match"} "use",
) "foreach",
"interrupt",
"join",
"when",
"choose",
"match",
})
class DraftUseStep(BaseModel): class DraftUseStep(BaseModel):
+13 -17
View File
@@ -254,16 +254,14 @@ class WorkflowBuilder:
mode: Literal["serial", "parallel"] = "serial", mode: Literal["serial", "parallel"] = "serial",
on_item_error: Literal["fail", "collect", "skip"] = "fail", on_item_error: Literal["fail", "collect", "skip"] = "fail",
) -> ForeachNode: ) -> ForeachNode:
node = ForeachNode.model_validate( node = ForeachNode.model_validate({
{ "id": id or self._next_step_id(f"foreach_{slug_id(as_)}"),
"id": id or self._next_step_id(f"foreach_{slug_id(as_)}"), "type": "foreach",
"type": "foreach", "over": coerce_path(over),
"over": coerce_path(over), "as": as_,
"as": as_, "mode": mode,
"mode": mode, "on_item_error": on_item_error,
"on_item_error": on_item_error, })
}
)
self.nodes.append(node) self.nodes.append(node)
return node return node
@@ -297,13 +295,11 @@ class WorkflowBuilder:
source = self._resolve_branch_ref(from_) source = self._resolve_branch_ref(from_)
target = self._resolve_branch_ref(to) target = self._resolve_branch_ref(to)
self.edges.append( self.edges.append(
Edge.model_validate( Edge.model_validate({
{ "from": step_id(source),
"from": step_id(source), "outcome": outcome,
"outcome": outcome, "to": step_id(target),
"to": step_id(target), })
}
)
) )
return source, target return source, target
+7 -9
View File
@@ -265,15 +265,13 @@ def _iter_state_field_declarations(
_attach_root_schema_context(validation_schema, root_schema) _attach_root_schema_context(validation_schema, root_schema)
yield ( yield (
path, path,
StateFieldDecl.model_validate( StateFieldDecl.model_validate({
{ "path": StatePath.of(path),
"path": StatePath.of(path), "schema": SchemaRef.model_validate(validation_schema),
"schema": SchemaRef.model_validate(validation_schema), "reducer": reducer,
"reducer": reducer, "trace": trace,
"trace": trace, "default": default,
"default": default, }),
}
),
) )
child_properties = resolved_schema.get("properties") child_properties = resolved_schema.get("properties")
if isinstance(child_properties, Mapping): if isinstance(child_properties, Mapping):
+16 -20
View File
@@ -319,26 +319,22 @@ class WfMcpService:
statuses: list[dict[str, Any]] = [] statuses: list[dict[str, Any]] = []
for connection in self.connections.list_all(): for connection in self.connections.list_all():
snapshot = self.store.load_catalog(connection.id) snapshot = self.store.load_catalog(connection.id)
statuses.append( statuses.append({
{ "connection_id": connection.id,
"connection_id": connection.id, "server": connection.server,
"server": connection.server, "account": connection.account,
"account": connection.account, "enabled": connection.enabled,
"enabled": connection.enabled, "has_snapshot": snapshot is not None,
"has_snapshot": snapshot is not None, "fetched_at_epoch_ms": None
"fetched_at_epoch_ms": None if snapshot is None
if snapshot is None else snapshot.fetched_at_epoch_ms,
else snapshot.fetched_at_epoch_ms, "max_age_seconds": None
"max_age_seconds": None if snapshot is None
if snapshot is None else snapshot.max_age_seconds,
else snapshot.max_age_seconds, "node_count": 0 if snapshot is None else len(snapshot.nodes),
"node_count": 0 if snapshot is None else len(snapshot.nodes), "resource_count": 0 if snapshot is None else len(snapshot.resources),
"resource_count": 0 "prompt_count": 0 if snapshot is None else len(snapshot.prompts),
if snapshot is None })
else len(snapshot.resources),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts),
}
)
return statuses return statuses
def list_resources( def list_resources(
+27 -37
View File
@@ -94,26 +94,20 @@ async def _refresh_all(service, connection_id: str | None) -> list[dict[str, Any
try: try:
await service.refresh_connection_catalog(target_id) await service.refresh_connection_catalog(target_id)
snapshot = service.get_connection_snapshot(target_id) snapshot = service.get_connection_snapshot(target_id)
results.append( results.append({
{ "connection_id": target_id,
"connection_id": target_id, "refreshed": snapshot is not None,
"refreshed": snapshot is not None, "node_count": 0 if snapshot is None else len(snapshot.nodes),
"node_count": 0 if snapshot is None else len(snapshot.nodes), "resource_count": 0 if snapshot is None else len(snapshot.resources),
"resource_count": 0 "prompt_count": 0 if snapshot is None else len(snapshot.prompts),
if snapshot is None })
else len(snapshot.resources),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts),
}
)
except Exception as exc: except Exception as exc:
results.append( results.append({
{ "connection_id": target_id,
"connection_id": target_id, "refreshed": False,
"refreshed": False, "error_type": type(exc).__name__,
"error_type": type(exc).__name__, "error": str(exc),
"error": str(exc), })
}
)
return results return results
@@ -138,18 +132,16 @@ def main(argv: list[str] | None = None) -> int:
service = _service_from_config(args.config) service = _service_from_config(args.config)
if args.command == "connections": if args.command == "connections":
_json_dump( _json_dump([
[ {
{ "id": connection.id,
"id": connection.id, "server": connection.server,
"server": connection.server, "account": connection.account,
"account": connection.account, "enabled": connection.enabled,
"enabled": connection.enabled, "metadata": connection.metadata,
"metadata": connection.metadata, }
} for connection in service.connections.list_all()
for connection in service.connections.list_all() ])
]
)
return 0 return 0
if args.command == "status": if args.command == "status":
@@ -162,12 +154,10 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "refresh": if args.command == "refresh":
results = asyncio.run(_refresh_all(service, args.connection_id)) results = asyncio.run(_refresh_all(service, args.connection_id))
_json_dump( _json_dump({
{ "results": results,
"results": results, "catalog": service.get_catalog().as_payload(),
"catalog": service.get_catalog().as_payload(), })
}
)
if any(not result["refreshed"] for result in results): if any(not result["refreshed"] for result in results):
return 1 return 1
return 0 return 0
+6 -8
View File
@@ -38,12 +38,10 @@ def connection_to_fastmcp_server_config(
def broker_config_to_fastmcp_config(config: BrokerConfig) -> MCPConfig: def broker_config_to_fastmcp_config(config: BrokerConfig) -> MCPConfig:
"""Convert broker config into FastMCP's multi-server config object.""" """Convert broker config into FastMCP's multi-server config object."""
validate_proxy_config(config) validate_proxy_config(config)
return MCPConfig.from_dict( return MCPConfig.from_dict({
{ "mcpServers": {
"mcpServers": { connection.id: connection_to_fastmcp_server_config(connection)
connection.id: connection_to_fastmcp_server_config(connection) for connection in config.connections
for connection in config.connections if connection.enabled
if connection.enabled
}
} }
) })
@@ -46,13 +46,11 @@ class ResourceLinkRewritingTool(Tool):
rewrite_uri: Callable[[str], str], rewrite_uri: Callable[[str], str],
) -> ResourceLinkRewritingTool: ) -> ResourceLinkRewritingTool:
"""Copy one tool's public schema while replacing only execution.""" """Copy one tool's public schema while replacing only execution."""
return cls.model_validate( return cls.model_validate({
{ **tool.model_dump(),
**tool.model_dump(), "parent_tool": tool,
"parent_tool": tool, "rewrite_uri": rewrite_uri,
"rewrite_uri": rewrite_uri, })
}
)
class ResourceLinkNamespace(Transform): class ResourceLinkNamespace(Transform):
+37 -45
View File
@@ -214,21 +214,19 @@ class WorkflowSurfaceHandlers:
query=query, query=query,
): ):
continue continue
rows.append( rows.append({
{ "name": name,
"name": name, "source_id": "workflow",
"source_id": "workflow", "kind": "wrapper_artifact",
"kind": "wrapper_artifact", "artifact_id": artifact.id,
"artifact_id": artifact.id, "version": artifact.version,
"version": artifact.version, "title": artifact.title,
"title": artifact.title, "description": artifact.description,
"description": artifact.description, "outcomes": list(artifact.outcomes),
"outcomes": list(artifact.outcomes), "is_async": True,
"is_async": True, "input_fields": _schema_field_names(artifact.input_schema),
"input_fields": _schema_field_names(artifact.input_schema), "output_fields": _schema_field_names(artifact.output_schema),
"output_fields": _schema_field_names(artifact.output_schema), })
}
)
return rows return rows
def _wrapper_capability_detail( def _wrapper_capability_detail(
@@ -447,12 +445,10 @@ class WorkflowSurfaceHandlers:
}, },
) )
) )
required_sources = sorted( required_sources = sorted({
{ capability.logical_source
capability.logical_source for capability in workflow_artifact.required_capability_map().values()
for capability in workflow_artifact.required_capability_map().values() })
}
)
return { return {
"artifact_id": workflow_artifact.id, "artifact_id": workflow_artifact.id,
"version": workflow_artifact.version, "version": workflow_artifact.version,
@@ -909,16 +905,14 @@ def _available_sources(service: WfMcpService) -> list[AvailableSource]:
if (capability_name := _capability_name(spec.name)) is not None if (capability_name := _capability_name(spec.name)) is not None
if (detail := node_spec_details.get(spec.name)) is not None if (detail := node_spec_details.get(spec.name)) is not None
} }
capabilities.update( capabilities.update({
{ capability_name: AvailableCapability(
capability_name: AvailableCapability( name=capability_name,
name=capability_name, kind="reducer",
kind="reducer", )
) for reducer in source.capabilities.reducers.values()
for reducer in source.capabilities.reducers.values() if (capability_name := _capability_name(reducer.name)) is not None
if (capability_name := _capability_name(reducer.name)) is not None })
}
)
sources.append( sources.append(
AvailableSource( AvailableSource(
id=source.id, id=source.id,
@@ -982,9 +976,9 @@ def _observed_node_specs(service: WfMcpService) -> dict[str, NodeSpecInventory]:
observed: dict[str, NodeSpecInventory] = {} observed: dict[str, NodeSpecInventory] = {}
for source in service.capability_sources.values(): for source in service.capability_sources.values():
inventory = source.as_inventory() inventory = source.as_inventory()
observed.update( observed.update({
{detail.name: detail for detail in inventory.capabilities.node_spec_details} detail.name: detail for detail in inventory.capabilities.node_spec_details
) })
return observed return observed
@@ -1063,17 +1057,15 @@ def _artifact_capability_id(artifact: WorkflowArtifact) -> str:
def _raw_plan_from_artifact(artifact: WorkflowArtifact) -> RawWorkflowPlan: def _raw_plan_from_artifact(artifact: WorkflowArtifact) -> RawWorkflowPlan:
"""Validate the stored plan shape expected by the broker workflow runner.""" """Validate the stored plan shape expected by the broker workflow runner."""
return RawWorkflowPlan.model_validate( return RawWorkflowPlan.model_validate({
{ "name": _plan_field(artifact, "name"),
"name": _plan_field(artifact, "name"), "input_schema": _plan_field(artifact, "input_schema"),
"input_schema": _plan_field(artifact, "input_schema"), "state_schema": _plan_field(artifact, "state_schema"),
"state_schema": _plan_field(artifact, "state_schema"), "output_schema": _plan_field(artifact, "output_schema"),
"output_schema": _plan_field(artifact, "output_schema"), "start": _plan_field(artifact, "start"),
"start": _plan_field(artifact, "start"), "nodes": _plan_field(artifact, "nodes"),
"nodes": _plan_field(artifact, "nodes"), "edges": _plan_field(artifact, "edges"),
"edges": _plan_field(artifact, "edges"), })
}
)
def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any: def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any:
+103 -113
View File
@@ -10,17 +10,15 @@ from wf_core.models.steps import InputValueBinding
def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None: def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate({
{ "name": "echo",
"name": "echo", "input_schema": {},
"input_schema": {}, "state_schema": {"fields": {}},
"state_schema": {"fields": {}}, "output_schema": {},
"output_schema": {}, "start": "echo",
"start": "echo", "steps": {"echo": {"use": "demo.echo"}},
"steps": {"echo": {"use": "demo.echo"}}, "routes": {"echo": {"ok": "__end__"}},
"routes": {"echo": {"ok": "__end__"}}, })
}
)
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
@@ -34,23 +32,21 @@ def test_adapter_lowers_keyed_use_steps_and_routes_through_builder() -> None:
def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None: def test_adapter_lowers_static_inputs_for_constant_like_steps() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate({
{ "name": "constant",
"name": "constant", "input_schema": {},
"input_schema": {}, "state_schema": {"fields": {"message": {"type": "string"}}},
"state_schema": {"fields": {"message": {"type": "string"}}}, "output_schema": {},
"output_schema": {}, "start": "constant",
"start": "constant", "steps": {
"steps": { "constant": {
"constant": { "use": "wf.std.constant",
"use": "wf.std.constant", "with": {"value": "CLICKED"},
"with": {"value": "CLICKED"}, "out": {"value": "state.message"},
"out": {"value": "state.message"}, }
} },
}, "routes": {"constant": {"ok": "__end__"}},
"routes": {"constant": {"ok": "__end__"}}, })
}
)
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
node = workflow.nodes[0] node = workflow.nodes[0]
@@ -92,30 +88,28 @@ def test_invalid_literal_input_map_does_not_fall_through_to_join() -> None:
def test_adapter_lowers_when_step_through_builder() -> None: def test_adapter_lowers_when_step_through_builder() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate({
{ "name": "when_example",
"name": "when_example", "input_schema": {},
"input_schema": {}, "state_schema": {"fields": {}},
"state_schema": {"fields": {}}, "output_schema": {},
"output_schema": {}, "start": "decide",
"start": "decide", "steps": {
"steps": { "decide": {
"decide": { "when": {
"when": { "if": {
"if": { "op": "ge",
"op": "ge", "left": {"path": "state.count"},
"left": {"path": "state.count"}, "right": {"value": 1},
"right": {"value": 1}, },
}, "then": "echo",
"then": "echo", "otherwise": "__end__",
"otherwise": "__end__", }
}
},
"echo": {"use": "demo.echo"},
}, },
"routes": {"echo": {"ok": "__end__"}}, "echo": {"use": "demo.echo"},
} },
) "routes": {"echo": {"ok": "__end__"}},
})
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
condition = workflow.nodes[0] condition = workflow.nodes[0]
@@ -130,45 +124,43 @@ def test_adapter_lowers_when_step_through_builder() -> None:
def test_adapter_lowers_choose_step_through_builder() -> None: def test_adapter_lowers_choose_step_through_builder() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate({
{ "name": "choose_example",
"name": "choose_example", "input_schema": {},
"input_schema": {}, "state_schema": {"fields": {}},
"state_schema": {"fields": {}}, "output_schema": {},
"output_schema": {}, "start": "pick",
"start": "pick", "steps": {
"steps": { "pick": {
"pick": { "choose": {
"choose": { "clauses": [
"clauses": [ {
{ "if": {
"if": { "op": "gt",
"op": "gt", "left": {"path": "state.score"},
"left": {"path": "state.score"}, "right": {"value": 80},
"right": {"value": 80},
},
"then": "high",
}, },
{ "then": "high",
"if": { },
"op": "exists", {
"path": "state.fallback", "if": {
}, "op": "exists",
"then": "fallback", "path": "state.fallback",
}, },
], "then": "fallback",
"default": "__end__", },
} ],
}, "default": "__end__",
"high": {"use": "demo.high"}, }
"fallback": {"use": "demo.fallback"},
}, },
"routes": { "high": {"use": "demo.high"},
"high": {"ok": "__end__"}, "fallback": {"use": "demo.fallback"},
"fallback": {"ok": "__end__"}, },
}, "routes": {
} "high": {"ok": "__end__"},
) "fallback": {"ok": "__end__"},
},
})
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
condition_ids = [ condition_ids = [
@@ -186,33 +178,31 @@ def test_adapter_lowers_choose_step_through_builder() -> None:
def test_adapter_lowers_match_step_through_builder() -> None: def test_adapter_lowers_match_step_through_builder() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate({
{ "name": "match_example",
"name": "match_example", "input_schema": {},
"input_schema": {}, "state_schema": {"fields": {}},
"state_schema": {"fields": {}}, "output_schema": {},
"output_schema": {}, "start": "match_status",
"start": "match_status", "steps": {
"steps": { "match_status": {
"match_status": { "match": {
"match": { "value": "state.status",
"value": "state.status", "cases": [
"cases": [ {"equals": "ready", "then": "ready"},
{"equals": "ready", "then": "ready"}, {"equals": "waiting", "then": "waiting"},
{"equals": "waiting", "then": "waiting"}, ],
], "default": "__end__",
"default": "__end__", }
}
},
"ready": {"use": "demo.ready"},
"waiting": {"use": "demo.waiting"},
}, },
"routes": { "ready": {"use": "demo.ready"},
"ready": {"ok": "__end__"}, "waiting": {"use": "demo.waiting"},
"waiting": {"ok": "__end__"}, },
}, "routes": {
} "ready": {"ok": "__end__"},
) "waiting": {"ok": "__end__"},
},
})
workflow = build_workflow_from_draft(draft) workflow = build_workflow_from_draft(draft)
condition_ids = [ condition_ids = [
+53 -59
View File
@@ -36,78 +36,72 @@ def test_draft_step_requires_exactly_one_kind_key() -> None:
def test_workflow_draft_accepts_when_step() -> None: def test_workflow_draft_accepts_when_step() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate({
{ **_keyed_echo_draft(),
**_keyed_echo_draft(), "start": "decide",
"start": "decide", "steps": {
"steps": { **_keyed_echo_draft()["steps"],
**_keyed_echo_draft()["steps"], "decide": {
"decide": { "when": {
"when": { "if": {
"if": { "op": "ge",
"op": "ge", "left": {"path": "state.count"},
"left": {"path": "state.count"}, "right": {"value": 1},
"right": {"value": 1}, },
}, "then": "echo",
"then": "echo", "otherwise": "__end__",
"otherwise": "__end__", }
}
},
}, },
} },
) })
assert isinstance(draft.steps["decide"], DraftWhenStep) assert isinstance(draft.steps["decide"], DraftWhenStep)
def test_workflow_draft_accepts_choose_step() -> None: def test_workflow_draft_accepts_choose_step() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate({
{ **_keyed_echo_draft(),
**_keyed_echo_draft(), "start": "choose_next",
"start": "choose_next", "steps": {
"steps": { **_keyed_echo_draft()["steps"],
**_keyed_echo_draft()["steps"], "choose_next": {
"choose_next": { "choose": {
"choose": { "clauses": [
"clauses": [ {
{ "if": {
"if": { "op": "exists",
"op": "exists", "path": "state.text",
"path": "state.text", },
}, "then": "echo",
"then": "echo", }
} ],
], "default": "__end__",
"default": "__end__", }
}
},
}, },
} },
) })
assert isinstance(draft.steps["choose_next"], DraftChooseStep) assert isinstance(draft.steps["choose_next"], DraftChooseStep)
def test_workflow_draft_accepts_match_step() -> None: def test_workflow_draft_accepts_match_step() -> None:
draft = WorkflowDraft.model_validate( draft = WorkflowDraft.model_validate({
{ **_keyed_echo_draft(),
**_keyed_echo_draft(), "start": "match_status",
"start": "match_status", "steps": {
"steps": { **_keyed_echo_draft()["steps"],
**_keyed_echo_draft()["steps"], "match_status": {
"match_status": { "match": {
"match": { "value": "state.status",
"value": "state.status", "cases": [
"cases": [ {"equals": "ready", "then": "echo"},
{"equals": "ready", "then": "echo"}, {"equals": "done", "then": "__end__"},
{"equals": "done", "then": "__end__"}, ],
], "default": "__end__",
"default": "__end__", }
}
},
}, },
} },
) })
assert isinstance(draft.steps["match_status"], DraftMatchStep) assert isinstance(draft.steps["match_status"], DraftMatchStep)
+23 -27
View File
@@ -66,25 +66,23 @@ def test_workflow_artifact_can_be_marked_as_wrapper_intent() -> None:
def test_workflow_artifact_accepts_legacy_required_capability_map_and_dumps_list() -> ( def test_workflow_artifact_accepts_legacy_required_capability_map_and_dumps_list() -> (
None None
): ):
artifact = WorkflowArtifact.model_validate( artifact = WorkflowArtifact.model_validate({
{ "id": "legacy_capabilities",
"id": "legacy_capabilities", "version": 1,
"version": 1, "title": "Legacy Capabilities",
"title": "Legacy Capabilities", "input_schema": {"type": "object", "properties": {}},
"input_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}}, "outcomes": ["done"],
"outcomes": ["done"], "plan": {"name": "legacy_capabilities", "nodes": [], "edges": []},
"plan": {"name": "legacy_capabilities", "nodes": [], "edges": []}, "required_capabilities": {
"required_capabilities": { "demo.echo": {
"demo.echo": { "kind": "tool",
"kind": "tool", "input_schema_hash": "sha256:input",
"input_schema_hash": "sha256:input", "output_schema_hash": "sha256:output",
"output_schema_hash": "sha256:output", "observed_concrete_source": "demo.personal",
"observed_concrete_source": "demo.personal", }
} },
}, })
}
)
dumped = artifact.model_dump(mode="json") dumped = artifact.model_dump(mode="json")
required = dumped["required_capabilities"][0] required = dumped["required_capabilities"][0]
@@ -120,14 +118,12 @@ def test_workflow_deployment_binds_logical_sources_to_concrete_sources() -> None
def test_workflow_deployment_accepts_legacy_binding_map_and_dumps_list() -> None: def test_workflow_deployment_accepts_legacy_binding_map_and_dumps_list() -> None:
deployment = WorkflowDeployment.model_validate( deployment = WorkflowDeployment.model_validate({
{ "id": "legacy_bindings.personal",
"id": "legacy_bindings.personal", "artifact_id": "legacy_bindings",
"artifact_id": "legacy_bindings", "artifact_version": 1,
"artifact_version": 1, "bindings": {"demo": "demo.personal"},
"bindings": {"demo": "demo.personal"}, })
}
)
dumped = deployment.model_dump(mode="json") dumped = deployment.model_dump(mode="json")
binding = dumped["bindings"][0] binding = dumped["bindings"][0]
+21 -25
View File
@@ -73,23 +73,21 @@ def test_file_store_loads_legacy_artifact_and_rewrites_canonical_shape(
artifact_dir.mkdir(parents=True) artifact_dir.mkdir(parents=True)
artifact_path = artifact_dir / "1.json" artifact_path = artifact_dir / "1.json"
artifact_path.write_text( artifact_path.write_text(
json.dumps( json.dumps({
{ "id": "legacy_capabilities",
"id": "legacy_capabilities", "version": 1,
"version": 1, "title": "Legacy Capabilities",
"title": "Legacy Capabilities", "input_schema": {"type": "object", "properties": {}},
"input_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}},
"output_schema": {"type": "object", "properties": {}}, "outcomes": ["done"],
"outcomes": ["done"], "plan": {"name": "legacy_capabilities", "nodes": [], "edges": []},
"plan": {"name": "legacy_capabilities", "nodes": [], "edges": []}, "required_capabilities": {
"required_capabilities": { "demo.echo": {
"demo.echo": { "kind": "tool",
"kind": "tool", "input_schema_hash": "sha256:input",
"input_schema_hash": "sha256:input", }
} },
}, }),
}
),
encoding="utf-8", encoding="utf-8",
) )
@@ -110,14 +108,12 @@ def test_file_store_loads_legacy_deployment_and_rewrites_canonical_shape(
store = FileWorkflowArtifactStore(tmp_path) store = FileWorkflowArtifactStore(tmp_path)
deployment_path = store.deployments_dir / "legacy_bindings.personal.json" deployment_path = store.deployments_dir / "legacy_bindings.personal.json"
deployment_path.write_text( deployment_path.write_text(
json.dumps( json.dumps({
{ "id": "legacy_bindings.personal",
"id": "legacy_bindings.personal", "artifact_id": "legacy_bindings",
"artifact_id": "legacy_bindings", "artifact_version": 1,
"artifact_version": 1, "bindings": {"demo": "demo.personal"},
"bindings": {"demo": "demo.personal"}, }),
}
),
encoding="utf-8", encoding="utf-8",
) )
+6 -8
View File
@@ -373,14 +373,12 @@ def test_foreach_stress_with_many_documents() -> None:
assert len(run.state["documents"]) == document_count assert len(run.state["documents"]) == document_count
assert len(run.state["item_summaries"]) == document_count assert len(run.state["item_summaries"]) == document_count
assert ( assert (
len( len([
[ frame
frame for frame in run.frames.values()
for frame in run.frames.values() if frame.kind == "foreach_iteration"
if frame.kind == "foreach_iteration" and frame.status == FrameStatus.COMPLETED
and frame.status == FrameStatus.COMPLETED ])
]
)
== document_count == document_count
) )
assert len([entry for entry in run.trace if entry.step_type == "foreach"]) == ( assert len([entry for entry in run.trace if entry.step_type == "foreach"]) == (
+15 -19
View File
@@ -39,15 +39,13 @@ def _build_first_workflow(use_safe_first: bool = False):
builder = WorkflowBuilder( builder = WorkflowBuilder(
name="first_demo", name="first_demo",
input_schema=SchemaRef(type="object"), input_schema=SchemaRef(type="object"),
state_schema=StateSchema.model_validate( state_schema=StateSchema.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "items": {"type": "array"},
"items": {"type": "array"}, "item": {"type": ["string", "null"]},
"item": {"type": ["string", "null"]}, },
}, }),
}
),
output_schema=SchemaRef(type="object"), output_schema=SchemaRef(type="object"),
start="pick_first", start="pick_first",
) )
@@ -65,16 +63,14 @@ def _build_first_maybe_workflow():
builder = WorkflowBuilder( builder = WorkflowBuilder(
name="first_maybe_demo", name="first_maybe_demo",
input_schema=SchemaRef(type="object"), input_schema=SchemaRef(type="object"),
state_schema=StateSchema.model_validate( state_schema=StateSchema.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "items": {"type": "array"},
"items": {"type": "array"}, "item": {"type": ["string", "null"]},
"item": {"type": ["string", "null"]}, "missing": {"type": "boolean"},
"missing": {"type": "boolean"}, },
}, }),
}
),
output_schema=SchemaRef(type="object"), output_schema=SchemaRef(type="object"),
start="pick_first", start="pick_first",
) )
+19 -25
View File
@@ -126,18 +126,16 @@ def test_output_bindings_validate_exact_state_schema_before_mutation() -> None:
def test_output_bindings_validate_declared_parent_schema_before_mutation() -> None: def test_output_bindings_validate_declared_parent_schema_before_mutation() -> None:
workflow = _workflow_from_state_schema( workflow = _workflow_from_state_schema(
StateSchema.model_validate( StateSchema.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "person": {
"person": { "type": "object",
"type": "object", "properties": {"name": {"type": "string"}},
"properties": {"name": {"type": "string"}}, "additionalProperties": False,
"additionalProperties": False, }
} },
}, })
}
)
) )
state = {"person": {"name": "old"}} state = {"person": {"name": "old"}}
@@ -206,9 +204,9 @@ def _workflow_with_node() -> Workflow:
return Workflow( return Workflow(
name="canonical_output", name="canonical_output",
input_schema=SchemaRef(type="object", properties={}), input_schema=SchemaRef(type="object", properties={}),
state_schema=StateSchema.from_field_map( state_schema=StateSchema.from_field_map({
{"person.name": StateField(type="string")} "person.name": StateField(type="string")
), }),
output_schema=SchemaRef( output_schema=SchemaRef(
type="object", properties={"person": {"type": "object"}} type="object", properties={"person": {"type": "object"}}
), ),
@@ -225,16 +223,12 @@ def _workflow_with_node() -> Workflow:
], ],
start="rename", start="rename",
nodes=[ nodes=[
NodeUse.model_validate( NodeUse.model_validate({
{ "id": "rename",
"id": "rename", "type": "node",
"type": "node", "node": "rename",
"node": "rename", "output": [{"source": "person.name", "target": "state.person.name"}],
"output": [ })
{"source": "person.name", "target": "state.person.name"}
],
}
)
], ],
edges=[Edge.model_validate({"from": "rename", "outcome": "ok", "to": END})], edges=[Edge.model_validate({"from": "rename", "outcome": "ok", "to": END})],
) )
+69 -79
View File
@@ -6,18 +6,16 @@ from wf_core.paths import GraphSourcePath, LocalPath, StatePath
def test_node_use_accepts_canonical_input_and_output_bindings(): def test_node_use_accepts_canonical_input_and_output_bindings():
node = NodeUse.model_validate( node = NodeUse.model_validate({
{ "id": "echo",
"id": "echo", "type": "node",
"type": "node", "node": "echo",
"node": "echo", "input": [
"input": [ {"target": "message", "path": "input.message"},
{"target": "message", "path": "input.message"}, {"target": "mode", "value": None},
{"target": "mode", "value": None}, ],
], "output": [{"source": "echoed", "target": "state.echoed"}],
"output": [{"source": "echoed", "target": "state.echoed"}], })
}
)
path_binding = node.input[0] path_binding = node.input[0]
assert isinstance(path_binding, InputPathBinding) assert isinstance(path_binding, InputPathBinding)
@@ -34,16 +32,14 @@ def test_node_use_accepts_canonical_input_and_output_bindings():
def test_node_use_converts_old_maps_to_canonical_bindings(): def test_node_use_converts_old_maps_to_canonical_bindings():
node = NodeUse.model_validate( node = NodeUse.model_validate({
{ "id": "echo",
"id": "echo", "type": "node",
"type": "node", "node": "echo",
"node": "echo", "in_map": {"input.message": "message"},
"in_map": {"input.message": "message"}, "input_values": {"mode": "fast"},
"input_values": {"mode": "fast"}, "out_map": {"echoed": "state.echoed"},
"out_map": {"echoed": "state.echoed"}, })
}
)
dumped = node.model_dump(mode="json") dumped = node.model_dump(mode="json")
assert "in_map" not in dumped assert "in_map" not in dumped
@@ -58,15 +54,13 @@ def test_node_use_converts_old_maps_to_canonical_bindings():
def test_node_use_serializes_canonical_binding_paths_as_strings_in_all_dump_modes(): def test_node_use_serializes_canonical_binding_paths_as_strings_in_all_dump_modes():
node = NodeUse.model_validate( node = NodeUse.model_validate({
{ "id": "echo",
"id": "echo", "type": "node",
"type": "node", "node": "echo",
"node": "echo", "input": [{"target": "message", "path": "input.message"}],
"input": [{"target": "message", "path": "input.message"}], "output": [{"source": "echoed", "target": "state.echoed"}],
"output": [{"source": "echoed", "target": "state.echoed"}], })
}
)
python_dumped = node.model_dump() python_dumped = node.model_dump()
json_dumped = node.model_dump(mode="json") json_dumped = node.model_dump(mode="json")
@@ -83,39 +77,33 @@ def test_node_use_serializes_canonical_binding_paths_as_strings_in_all_dump_mode
def test_node_use_rejects_mixed_old_and_new_binding_styles(): def test_node_use_rejects_mixed_old_and_new_binding_styles():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NodeUse.model_validate( NodeUse.model_validate({
{ "id": "echo",
"id": "echo", "type": "node",
"type": "node", "node": "echo",
"node": "echo", "input": [{"target": "message", "path": "input.message"}],
"input": [{"target": "message", "path": "input.message"}], "in_map": {"input.other": "other"},
"in_map": {"input.other": "other"}, })
}
)
def test_input_binding_rejects_path_and_value_together(): def test_input_binding_rejects_path_and_value_together():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NodeUse.model_validate( NodeUse.model_validate({
{ "id": "bad",
"id": "bad", "type": "node",
"type": "node", "node": "bad",
"node": "bad", "input": [{"target": "message", "path": "input.message", "value": "x"}],
"input": [{"target": "message", "path": "input.message", "value": "x"}], })
}
)
def test_input_binding_rejects_neither_path_nor_value(): def test_input_binding_rejects_neither_path_nor_value():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NodeUse.model_validate( NodeUse.model_validate({
{ "id": "bad",
"id": "bad", "type": "node",
"type": "node", "node": "bad",
"node": "bad", "input": [{"target": "message"}],
"input": [{"target": "message"}], })
}
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -127,9 +115,12 @@ def test_input_binding_rejects_neither_path_nor_value():
) )
def test_bindings_reject_extra_fields(field: str, binding: dict[str, object]): def test_bindings_reject_extra_fields(field: str, binding: dict[str, object]):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NodeUse.model_validate( NodeUse.model_validate({
{"id": "bad", "type": "node", "node": "bad", field: [binding]} "id": "bad",
) "type": "node",
"node": "bad",
field: [binding],
})
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -142,21 +133,22 @@ def test_bindings_reject_extra_fields(field: str, binding: dict[str, object]):
) )
def test_deprecated_maps_reject_non_mapping_values(field: str, value: object): def test_deprecated_maps_reject_non_mapping_values(field: str, value: object):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
NodeUse.model_validate( NodeUse.model_validate({
{"id": "bad", "type": "node", "node": "bad", field: value} "id": "bad",
) "type": "node",
"node": "bad",
field: value,
})
def test_deprecated_conversion_preserves_input_value_then_in_map_order(): def test_deprecated_conversion_preserves_input_value_then_in_map_order():
node = NodeUse.model_validate( node = NodeUse.model_validate({
{ "id": "ordered",
"id": "ordered", "type": "node",
"type": "node", "node": "ordered",
"node": "ordered", "input_values": {"first": 1, "second": 2},
"input_values": {"first": 1, "second": 2}, "in_map": {"input.third": "third", "state.fourth": "fourth"},
"in_map": {"input.third": "third", "state.fourth": "fourth"}, })
}
)
dumped_input = node.model_dump(mode="json")["input"] dumped_input = node.model_dump(mode="json")["input"]
assert dumped_input[0]["target"] == "first" assert dumped_input[0]["target"] == "first"
@@ -170,14 +162,12 @@ def test_deprecated_conversion_preserves_input_value_then_in_map_order():
def test_deprecated_input_value_preserves_explicit_null(): def test_deprecated_input_value_preserves_explicit_null():
node = NodeUse.model_validate( node = NodeUse.model_validate({
{ "id": "null",
"id": "null", "type": "node",
"type": "node", "node": "null",
"node": "null", "input_values": {"maybe": None},
"input_values": {"maybe": None}, })
}
)
value_binding = node.input[0] value_binding = node.input[0]
assert isinstance(value_binding, InputValueBinding) assert isinstance(value_binding, InputValueBinding)
+12 -9
View File
@@ -178,9 +178,10 @@ def _workflow(
return Workflow( return Workflow(
name="mapping_validation", name="mapping_validation",
input_schema=SchemaRef.model_validate( input_schema=SchemaRef.model_validate({
{"type": "object", "properties": {"person": {"type": "object"}}} "type": "object",
), "properties": {"person": {"type": "object"}},
}),
state_schema=StateSchema.from_field_map( state_schema=StateSchema.from_field_map(
state_fields or {"person": StateField(type="object")} state_fields or {"person": StateField(type="object")}
), ),
@@ -188,12 +189,14 @@ def _workflow(
node_defs=[ node_defs=[
NodeDef( NodeDef(
name="tool", name="tool",
input_schema=SchemaRef.model_validate( input_schema=SchemaRef.model_validate({
{"type": "object", "properties": {"user": {"type": "object"}}} "type": "object",
), "properties": {"user": {"type": "object"}},
output_schema=SchemaRef.model_validate( }),
{"type": "object", "properties": {"user": {"type": "object"}}} output_schema=SchemaRef.model_validate({
), "type": "object",
"properties": {"user": {"type": "object"}},
}),
outcomes=["ok"], outcomes=["ok"],
) )
], ],
+109 -131
View File
@@ -19,54 +19,52 @@ from wf_core import (
def test_canonical_bindings_resolve_input_values_paths_and_explicit_null() -> None: def test_canonical_bindings_resolve_input_values_paths_and_explicit_null() -> None:
workflow = Workflow.model_validate( workflow = Workflow.model_validate({
{ "name": "canonical",
"name": "canonical", "input_schema": {
"input_schema": { "type": "object",
"type": "object", "properties": {"message": {"type": "string"}},
"properties": {"message": {"type": "string"}}, },
}, "state_schema": {"fields": {"echoed": {"type": "string"}}},
"state_schema": {"fields": {"echoed": {"type": "string"}}}, "output_schema": {
"output_schema": { "type": "object",
"type": "object", "properties": {"echoed": {"type": "string"}},
"properties": {"echoed": {"type": "string"}}, },
}, "start": "echo",
"start": "echo", "node_defs": [
"node_defs": [ {
{ "name": "echo",
"name": "echo", "input_schema": {
"input_schema": { "type": "object",
"type": "object", "properties": {
"properties": { "message": {"type": "string"},
"message": {"type": "string"}, "mode": {"type": "string"},
"mode": {"type": "string"}, "maybe": {"type": "null"},
"maybe": {"type": "null"},
},
"required": ["message", "mode", "maybe"],
}, },
"output_schema": { "required": ["message", "mode", "maybe"],
"type": "object", },
"properties": {"echoed": {"type": "string"}}, "output_schema": {
}, "type": "object",
"outcomes": ["ok"], "properties": {"echoed": {"type": "string"}},
} },
], "outcomes": ["ok"],
"nodes": [ }
{ ],
"id": "echo", "nodes": [
"type": "node", {
"node": "echo", "id": "echo",
"input": [ "type": "node",
{"target": "message", "path": "input.message"}, "node": "echo",
{"target": "mode", "value": "fast"}, "input": [
{"target": "maybe", "value": None}, {"target": "message", "path": "input.message"},
], {"target": "mode", "value": "fast"},
"output": [{"source": "echoed", "target": "state.echoed"}], {"target": "maybe", "value": None},
} ],
], "output": [{"source": "echoed", "target": "state.echoed"}],
"edges": [{"from": "echo", "outcome": "ok", "to": END}], }
} ],
) "edges": [{"from": "echo", "outcome": "ok", "to": END}],
})
run = execute_workflow( run = execute_workflow(
workflow, workflow,
{"message": "hi"}, {"message": "hi"},
@@ -130,12 +128,10 @@ def test_missing_nested_node_output_path_fails() -> None:
def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None: def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None:
workflow = Workflow( workflow = Workflow(
name="root_mapping", name="root_mapping",
input_schema=SchemaRef.model_validate( input_schema=SchemaRef.model_validate({
{ "type": "object",
"type": "object", "properties": {"rates": {"type": "object"}},
"properties": {"rates": {"type": "object"}}, }),
}
),
state_schema=StateSchema.from_field_map({"rates": StateField(type="object")}), state_schema=StateSchema.from_field_map({"rates": StateField(type="object")}),
output_schema=SchemaRef(type="object", properties={}), output_schema=SchemaRef(type="object", properties={}),
node_defs=[ node_defs=[
@@ -156,15 +152,13 @@ def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None:
nodes=[ nodes=[
cast( cast(
Any, Any,
NodeUse.model_validate( NodeUse.model_validate({
{ "id": "force",
"id": "force", "type": "node",
"type": "node", "node": "force_rates",
"node": "force_rates", "in_map": {"input.rates": "."},
"in_map": {"input.rates": "."}, "out_map": {".": "state.rates"},
"out_map": {".": "state.rates"}, }),
}
),
) )
], ],
edges=[Edge.model_validate({"from": "force", "outcome": "ok", "to": END})], edges=[Edge.model_validate({"from": "force", "outcome": "ok", "to": END})],
@@ -196,20 +190,16 @@ def test_static_input_values_are_merged_into_node_local_input() -> None:
node_defs=[ node_defs=[
NodeDef( NodeDef(
name="constant", name="constant",
input_schema=SchemaRef.model_validate( input_schema=SchemaRef.model_validate({
{ "type": "object",
"type": "object", "properties": {"value": {"type": "string"}},
"properties": {"value": {"type": "string"}}, "required": ["value"],
"required": ["value"], }),
} output_schema=SchemaRef.model_validate({
), "type": "object",
output_schema=SchemaRef.model_validate( "properties": {"value": {"type": "string"}},
{ "required": ["value"],
"type": "object", }),
"properties": {"value": {"type": "string"}},
"required": ["value"],
}
),
outcomes=["ok"], outcomes=["ok"],
) )
], ],
@@ -217,15 +207,13 @@ def test_static_input_values_are_merged_into_node_local_input() -> None:
nodes=[ nodes=[
cast( cast(
Any, Any,
NodeUse.model_validate( NodeUse.model_validate({
{ "id": "constant",
"id": "constant", "type": "node",
"type": "node", "node": "constant",
"node": "constant", "input_values": {"value": "CLICKED"},
"input_values": {"value": "CLICKED"}, "out_map": {"value": "state.message"},
"out_map": {"value": "state.message"}, }),
}
),
) )
], ],
edges=[Edge.model_validate({"from": "constant", "outcome": "ok", "to": END})], edges=[Edge.model_validate({"from": "constant", "outcome": "ok", "to": END})],
@@ -244,40 +232,32 @@ def test_static_input_values_are_merged_into_node_local_input() -> None:
def _nested_mapping_workflow() -> Workflow: def _nested_mapping_workflow() -> Workflow:
return Workflow( return Workflow(
name="nested_mapping", name="nested_mapping",
input_schema=SchemaRef.model_validate( input_schema=SchemaRef.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "person": {"type": "object"},
"person": {"type": "object"}, "digital": {"type": "object"},
"digital": {"type": "object"}, },
}, }),
} state_schema=StateSchema.from_field_map({
), "person": StateField(type="object"),
state_schema=StateSchema.from_field_map( "experience": StateField(type="object"),
{ }),
"person": StateField(type="object"),
"experience": StateField(type="object"),
}
),
output_schema=SchemaRef(type="object", properties={}), output_schema=SchemaRef(type="object", properties={}),
node_defs=[ node_defs=[
NodeDef( NodeDef(
name="big_tool", name="big_tool",
input_schema=SchemaRef.model_validate( input_schema=SchemaRef.model_validate({
{ "type": "object",
"type": "object", "properties": {"user": {"type": "object"}},
"properties": {"user": {"type": "object"}}, }),
} output_schema=SchemaRef.model_validate({
), "type": "object",
output_schema=SchemaRef.model_validate( "properties": {
{ "user": {"type": "object"},
"type": "object", "job": {"type": "object"},
"properties": { },
"user": {"type": "object"}, }),
"job": {"type": "object"},
},
}
),
outcomes=["ok"], outcomes=["ok"],
) )
], ],
@@ -285,22 +265,20 @@ def _nested_mapping_workflow() -> Workflow:
nodes=[ nodes=[
cast( cast(
Any, Any,
NodeUse.model_validate( NodeUse.model_validate({
{ "id": "big",
"id": "big", "type": "node",
"type": "node", "node": "big_tool",
"node": "big_tool", "in_map": {
"in_map": { "input.person.name": "user.name",
"input.person.name": "user.name", "input.digital.email": "user.email",
"input.digital.email": "user.email", },
}, "out_map": {
"out_map": { "user.age": "state.person.age",
"user.age": "state.person.age", "user.gender": "state.person.gender",
"user.gender": "state.person.gender", "job.years": "state.experience.years",
"job.years": "state.experience.years", },
}, }),
}
),
) )
], ],
edges=[Edge.model_validate({"from": "big", "outcome": "ok", "to": END})], edges=[Edge.model_validate({"from": "big", "outcome": "ok", "to": END})],
+76 -91
View File
@@ -31,18 +31,16 @@ def test_exact_nested_state_path_uses_declared_reducer() -> None:
def test_state_schema_accepts_legacy_field_list_and_dumps_json_schema() -> None: def test_state_schema_accepts_legacy_field_list_and_dumps_json_schema() -> None:
schema = StateSchema.model_validate( schema = StateSchema.model_validate({
{ "fields": [
"fields": [ {"path": "state.person", "type": "object"},
{"path": "state.person", "type": "object"}, {
{ "path": "state.person.name",
"path": "state.person.name", "type": "string",
"type": "string", "reducer": "wf.std.replace",
"reducer": "wf.std.replace", },
}, ]
] })
}
)
assert schema.fields[0].path == StatePath.of("person") assert schema.fields[0].path == StatePath.of("person")
assert schema.field_map()["person.name"].type == "string" assert schema.field_map()["person.name"].type == "string"
@@ -52,24 +50,22 @@ def test_state_schema_accepts_legacy_field_list_and_dumps_json_schema() -> None:
def test_state_schema_uses_json_schema_properties_as_canonical_shape() -> None: def test_state_schema_uses_json_schema_properties_as_canonical_shape() -> None:
schema = StateSchema.model_validate( schema = StateSchema.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "person": {
"person": { "type": "object",
"type": "object", "properties": {
"properties": { "name": {
"name": { "type": "string",
"type": "string", "description": "Display name",
"description": "Display name", "reducer": "wf.std.replace",
"reducer": "wf.std.replace", }
}
},
}, },
"count": {"type": "integer", "reducer": "wf.std.add"},
}, },
} "count": {"type": "integer", "reducer": "wf.std.add"},
) },
})
fields = schema.field_map() fields = schema.field_map()
assert fields["person.name"].validation_schema.type == "string" assert fields["person.name"].validation_schema.type == "string"
@@ -79,14 +75,12 @@ def test_state_schema_uses_json_schema_properties_as_canonical_shape() -> None:
def test_state_schema_rejects_invalid_reducer_extension_keyword() -> None: def test_state_schema_rejects_invalid_reducer_extension_keyword() -> None:
try: try:
StateSchema.model_validate( StateSchema.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "count": {"type": "integer", "reducer": {"bad": True}},
"count": {"type": "integer", "reducer": {"bad": True}}, },
}, })
}
)
except ValueError as exc: except ValueError as exc:
assert "invalid reducer for state field 'count'" in str(exc) assert "invalid reducer for state field 'count'" in str(exc)
else: else:
@@ -94,16 +88,14 @@ def test_state_schema_rejects_invalid_reducer_extension_keyword() -> None:
def test_state_schema_accepts_canonical_schema_field() -> None: def test_state_schema_accepts_canonical_schema_field() -> None:
schema = StateSchema.model_validate( schema = StateSchema.model_validate({
{ "fields": [
"fields": [ {
{ "path": "state.person.name",
"path": "state.person.name", "schema": {"type": "string", "title": "Person Name"},
"schema": {"type": "string", "title": "Person Name"}, }
} ]
] })
}
)
field = schema.field_map()["person.name"] field = schema.field_map()["person.name"]
assert field.validation_schema.type == "string" assert field.validation_schema.type == "string"
@@ -119,15 +111,13 @@ def test_state_schema_accepts_deprecated_dict_shape_and_dumps_list() -> None:
def test_state_schema_accepts_deprecated_dict_value_with_schema_key() -> None: def test_state_schema_accepts_deprecated_dict_value_with_schema_key() -> None:
schema = StateSchema.model_validate( schema = StateSchema.model_validate({
{ "fields": {
"fields": { "person.name": {
"person.name": { "schema": {"type": "string", "description": "Display name"},
"schema": {"type": "string", "description": "Display name"},
}
} }
} }
) })
assert schema.field_map()["person.name"].validation_schema.type == "string" assert schema.field_map()["person.name"].validation_schema.type == "string"
@@ -139,26 +129,27 @@ def test_state_schema_accepts_json_schema_field_without_type() -> None:
def test_state_schema_accepts_deprecated_state_prefixed_dict_keys() -> None: def test_state_schema_accepts_deprecated_state_prefixed_dict_keys() -> None:
schema = StateSchema.model_validate( schema = StateSchema.model_validate({
{"fields": {"state.person.name": {"type": "string"}}} "fields": {"state.person.name": {"type": "string"}}
) })
assert schema.field_map()["person.name"].path == StatePath.of("person.name") assert schema.field_map()["person.name"].path == StatePath.of("person.name")
def test_state_field_decl_model_dump_serializes_path_as_string() -> None: def test_state_field_decl_model_dump_serializes_path_as_string() -> None:
field = StateFieldDecl.model_validate( field = StateFieldDecl.model_validate({
{"path": "state.person.name", "type": "string"} "path": "state.person.name",
) "type": "string",
})
assert field.model_dump()["path"] == "state.person.name" assert field.model_dump()["path"] == "state.person.name"
assert field.model_dump(mode="json")["path"] == "state.person.name" assert field.model_dump(mode="json")["path"] == "state.person.name"
def test_state_schema_model_dump_serializes_paths_as_strings() -> None: def test_state_schema_model_dump_serializes_paths_as_strings() -> None:
schema = StateSchema.model_validate( schema = StateSchema.model_validate({
{"fields": [{"path": "state.person.name", "type": "string"}]} "fields": [{"path": "state.person.name", "type": "string"}]
) })
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
assert dumped["properties"]["person"]["properties"]["name"]["type"] == "string" assert dumped["properties"]["person"]["properties"]["name"]["type"] == "string"
@@ -167,14 +158,12 @@ def test_state_schema_model_dump_serializes_paths_as_strings() -> None:
def test_state_schema_rejects_duplicate_field_paths() -> None: def test_state_schema_rejects_duplicate_field_paths() -> None:
try: try:
StateSchema.model_validate( StateSchema.model_validate({
{ "fields": [
"fields": [ {"path": "state.person.name", "type": "string"},
{"path": "state.person.name", "type": "string"}, {"path": "state.person.name", "type": "string"},
{"path": "state.person.name", "type": "string"}, ]
] })
}
)
except ValueError as exc: except ValueError as exc:
assert "duplicate state field path 'person.name'" in str(exc) assert "duplicate state field path 'person.name'" in str(exc)
else: else:
@@ -183,19 +172,17 @@ def test_state_schema_rejects_duplicate_field_paths() -> None:
def test_exact_nested_state_path_uses_reducer_from_json_schema_property() -> None: def test_exact_nested_state_path_uses_reducer_from_json_schema_property() -> None:
workflow = _workflow_from_state_schema( workflow = _workflow_from_state_schema(
StateSchema.model_validate( StateSchema.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "person": {
"person": { "type": "object",
"type": "object", "properties": {
"properties": { "tags": {"type": "array", "reducer": "wf.std.append"}
"tags": {"type": "array", "reducer": "wf.std.append"} },
}, }
} },
}, })
}
)
) )
state = {"person": {"tags": ["seed"]}} state = {"person": {"tags": ["seed"]}}
@@ -205,14 +192,12 @@ def test_exact_nested_state_path_uses_reducer_from_json_schema_property() -> Non
def test_state_schema_field_map_uses_rootless_keys() -> None: def test_state_schema_field_map_uses_rootless_keys() -> None:
schema = StateSchema.model_validate( schema = StateSchema.model_validate({
{ "fields": [
"fields": [ {"path": "state.person.name", "type": "string"},
{"path": "state.person.name", "type": "string"}, {"path": "state.person.tags", "type": "array"},
{"path": "state.person.tags", "type": "array"}, ]
] })
}
)
fields = schema.field_map() fields = schema.field_map()
assert fields["person.name"].path == StatePath.of("person.name") assert fields["person.name"].path == StatePath.of("person.name")
+25 -31
View File
@@ -121,29 +121,23 @@ def test_pydantic_revalidates_existing_path_objects() -> None:
object.__setattr__(local, "parts", ("items[0]",)) object.__setattr__(local, "parts", ("items[0]",))
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Payload.model_validate( Payload.model_validate({
{ "source": source,
"source": source, "target": StatePath.of("person"),
"target": StatePath.of("person"), "local": LocalPath.root(),
"local": LocalPath.root(), })
}
)
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Payload.model_validate( Payload.model_validate({
{ "source": GraphSourcePath.input("user"),
"source": GraphSourcePath.input("user"), "target": target,
"target": target, "local": LocalPath.root(),
"local": LocalPath.root(), })
}
)
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Payload.model_validate( Payload.model_validate({
{ "source": GraphSourcePath.input("user"),
"source": GraphSourcePath.input("user"), "target": StatePath.of("person"),
"target": StatePath.of("person"), "local": local,
"local": local, })
}
)
def test_pydantic_accepts_path_strings_and_serializes_strings() -> None: def test_pydantic_accepts_path_strings_and_serializes_strings() -> None:
@@ -152,9 +146,11 @@ def test_pydantic_accepts_path_strings_and_serializes_strings() -> None:
target: StatePath target: StatePath
local: LocalPath local: LocalPath
payload = Payload.model_validate( payload = Payload.model_validate({
{"source": "input.user", "target": "state.person", "local": "user"} "source": "input.user",
) "target": "state.person",
"local": "user",
})
assert payload.source == GraphSourcePath.input("user") assert payload.source == GraphSourcePath.input("user")
assert payload.target == StatePath.of("person") assert payload.target == StatePath.of("person")
@@ -184,13 +180,11 @@ def test_pydantic_accepts_existing_path_objects() -> None:
target: StatePath target: StatePath
local: LocalPath local: LocalPath
payload = Payload.model_validate( payload = Payload.model_validate({
{ "source": GraphSourcePath.state("person"),
"source": GraphSourcePath.state("person"), "target": StatePath.of("person.name"),
"target": StatePath.of("person.name"), "local": LocalPath.root(),
"local": LocalPath.root(), })
}
)
assert str(payload.source) == "state.person" assert str(payload.source) == "state.person"
assert str(payload.target) == "state.person.name" assert str(payload.target) == "state.person.name"
+79 -94
View File
@@ -10,16 +10,14 @@ from wf_core.runtime.ops.schemas import validate_payload_against_schema
def test_schema_validation_rejects_wrong_property_type() -> None: def test_schema_validation_rejects_wrong_property_type() -> None:
schema = SchemaRef.model_validate( schema = SchemaRef.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "name": {"type": "string"},
"name": {"type": "string"}, "count": {"type": "integer"},
"count": {"type": "integer"}, },
}, "required": ["name", "count"],
"required": ["name", "count"], })
}
)
with pytest.raises(WorkflowExecutionError, match=r"count.*not of type 'integer'"): with pytest.raises(WorkflowExecutionError, match=r"count.*not of type 'integer'"):
validate_payload_against_schema( validate_payload_against_schema(
@@ -30,19 +28,17 @@ def test_schema_validation_rejects_wrong_property_type() -> None:
def test_schema_validation_rejects_nested_missing_required_field() -> None: def test_schema_validation_rejects_nested_missing_required_field() -> None:
schema = SchemaRef.model_validate( schema = SchemaRef.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "profile": {
"profile": { "type": "object",
"type": "object", "properties": {"email": {"type": "string"}},
"properties": {"email": {"type": "string"}}, "required": ["email"],
"required": ["email"], }
} },
}, "required": ["profile"],
"required": ["profile"], })
}
)
with pytest.raises(WorkflowExecutionError, match=r"profile.*email.*required"): with pytest.raises(WorkflowExecutionError, match=r"profile.*email.*required"):
validate_payload_against_schema( validate_payload_against_schema(
@@ -53,35 +49,31 @@ def test_schema_validation_rejects_nested_missing_required_field() -> None:
def test_schema_validation_accepts_valid_payload() -> None: def test_schema_validation_accepts_valid_payload() -> None:
schema = SchemaRef.model_validate( schema = SchemaRef.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "tags": {"type": "array", "items": {"type": "string"}},
"tags": {"type": "array", "items": {"type": "string"}}, },
}, "required": ["tags"],
"required": ["tags"], })
}
)
validate_payload_against_schema(schema, {"tags": ["a", "b"]}, "node input") validate_payload_against_schema(schema, {"tags": ["a", "b"]}, "node input")
def test_schema_ref_accepts_and_preserves_schema_with_defs_and_ref() -> None: def test_schema_ref_accepts_and_preserves_schema_with_defs_and_ref() -> None:
schema = SchemaRef.model_validate( schema = SchemaRef.model_validate({
{ "$schema": "https://json-schema.org/draft/2020-12/schema",
"$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": {
"$defs": { "tag": {
"tag": { "type": "object",
"type": "object", "properties": {"name": {"type": "string"}},
"properties": {"name": {"type": "string"}}, "required": ["name"],
"required": ["name"], }
} },
}, "type": "object",
"type": "object", "properties": {"tag": {"$ref": "#/$defs/tag"}},
"properties": {"tag": {"$ref": "#/$defs/tag"}}, "required": ["tag"],
"required": ["tag"], })
}
)
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
@@ -97,13 +89,11 @@ def test_schema_ref_rejects_invalid_json_schema_shape() -> None:
def test_schema_ref_defaults_to_draft_2020_12_without_schema_keyword() -> None: def test_schema_ref_defaults_to_draft_2020_12_without_schema_keyword() -> None:
schema = SchemaRef.model_validate( schema = SchemaRef.model_validate({
{ "type": "object",
"type": "object", "properties": {"count": {"type": "integer"}},
"properties": {"count": {"type": "integer"}}, "required": ["count"],
"required": ["count"], })
}
)
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
@@ -113,13 +103,11 @@ def test_schema_ref_defaults_to_draft_2020_12_without_schema_keyword() -> None:
def test_schema_ref_preserves_extra_json_schema_keywords() -> None: def test_schema_ref_preserves_extra_json_schema_keywords() -> None:
schema = SchemaRef.model_validate( schema = SchemaRef.model_validate({
{ "type": "object",
"type": "object", "properties": {"name": {"type": "string"}},
"properties": {"name": {"type": "string"}}, "additionalProperties": False,
"additionalProperties": False, })
}
)
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
@@ -137,9 +125,10 @@ def test_schema_ref_dump_omits_none_fields_and_stays_valid_json_schema() -> None
def test_state_field_decl_dump_omits_nested_schema_none_fields() -> None: def test_state_field_decl_dump_omits_nested_schema_none_fields() -> None:
field = StateFieldDecl.model_validate( field = StateFieldDecl.model_validate({
{"path": "state.person", "schema": {"type": "object"}} "path": "state.person",
) "schema": {"type": "object"},
})
dumped = field.model_dump(mode="json") dumped = field.model_dump(mode="json")
@@ -151,18 +140,16 @@ def test_state_field_decl_dump_omits_nested_schema_none_fields() -> None:
def test_state_schema_dump_is_valid_json_schema_with_reducer_keyword() -> None: def test_state_schema_dump_is_valid_json_schema_with_reducer_keyword() -> None:
from wf_core import StateSchema from wf_core import StateSchema
schema = StateSchema.model_validate( schema = StateSchema.model_validate({
{ "type": "object",
"type": "object", "properties": {
"properties": { "count": {
"count": { "type": "integer",
"type": "integer", "description": "Running count",
"description": "Running count", "reducer": "wf.std.add",
"reducer": "wf.std.add", }
} },
}, })
}
)
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
assert dumped["type"] == "object" assert dumped["type"] == "object"
@@ -174,24 +161,22 @@ def test_state_schema_dump_is_valid_json_schema_with_reducer_keyword() -> None:
def test_state_field_validation_schema_preserves_root_defs_for_local_refs() -> None: def test_state_field_validation_schema_preserves_root_defs_for_local_refs() -> None:
from wf_core import StateSchema from wf_core import StateSchema
schema = StateSchema.model_validate( schema = StateSchema.model_validate({
{ "type": "object",
"type": "object", "$defs": {
"$defs": { "PoolByCategory": {
"PoolByCategory": { "type": "object",
"type": "object", "properties": {"category": {"type": "string"}},
"properties": {"category": {"type": "string"}}, "required": ["category"],
"required": ["category"], }
} },
}, "properties": {
"properties": { "current_pools": {
"current_pools": { "type": "array",
"type": "array", "items": {"$ref": "#/$defs/PoolByCategory"},
"items": {"$ref": "#/$defs/PoolByCategory"}, }
} },
}, })
}
)
field_schema = schema.field_map()["current_pools"].validation_schema field_schema = schema.field_map()["current_pools"].validation_schema
+6 -8
View File
@@ -26,14 +26,12 @@ async def echo_tool(
async def resource_link_tool() -> list[mcp_types.ResourceLink]: async def resource_link_tool() -> list[mcp_types.ResourceLink]:
"""Return a link to a fixture resource so proxy URI rewriting is testable.""" """Return a link to a fixture resource so proxy URI rewriting is testable."""
return [ return [
mcp_types.ResourceLink.model_validate( mcp_types.ResourceLink.model_validate({
{ "type": "resource_link",
"type": "resource_link", "name": "resource.welcome",
"name": "resource.welcome", "uri": "fixture://docs/welcome",
"uri": "fixture://docs/welcome", "mimeType": "text/plain",
"mimeType": "text/plain", })
}
)
] ]
+4 -6
View File
@@ -33,12 +33,10 @@ def test_platform_refs_validate_and_serialize_through_pydantic() -> None:
source: SourceRef source: SourceRef
capability: CapabilityRef capability: CapabilityRef
payload = Payload.model_validate( payload = Payload.model_validate({
{ "source": "demo.personal",
"source": "demo.personal", "capability": "demo.personal.echo_tool",
"capability": "demo.personal.echo_tool", })
}
)
assert payload.source == SourceRef.parse("demo.personal") assert payload.source == SourceRef.parse("demo.personal")
assert payload.capability == CapabilityRef.parse("demo.personal.echo_tool") assert payload.capability == CapabilityRef.parse("demo.personal.echo_tool")
+41 -53
View File
@@ -109,37 +109,33 @@ class RateChange:
@node(name="force 6* rating") @node(name="force 6* rating")
@staticmethod @staticmethod
def r80(r: Rates) -> Rates: def r80(r: Rates) -> Rates:
return Rates.model_validate( return Rates.model_validate({
{ "rates": {
"rates": { "r_1": 0,
"r_1": 0, "r_10": 0,
"r_10": 0, "r_80": r.rates["r_80"],
"r_80": r.rates["r_80"], "r_240": r.rates["r_240"],
"r_240": r.rates["r_240"],
}
} }
) })
@node(name="force banner rating") @node(name="force banner rating")
@staticmethod @staticmethod
def r240(_: Nothing) -> Rates: def r240(_: Nothing) -> Rates:
return Rates.model_validate( return Rates.model_validate({
{"rates": {"r_1": 0, "r_10": 0, "r_80": 0, "r_240": 1}} "rates": {"r_1": 0, "r_10": 0, "r_80": 0, "r_240": 1}
) })
@node(name="force 5*+ rating") @node(name="force 5*+ rating")
@staticmethod @staticmethod
def r10(r: Rates) -> Rates: def r10(r: Rates) -> Rates:
return Rates.model_validate( return Rates.model_validate({
{ "rates": {
"rates": { "r_1": 0,
"r_1": 0, "r_10": r.rates["r_10"],
"r_10": r.rates["r_10"], "r_80": r.rates["r_80"],
"r_80": r.rates["r_80"], "r_240": r.rates["r_240"],
"r_240": r.rates["r_240"],
}
} }
) })
@node(name="buff 6* rating") @node(name="buff 6* rating")
@staticmethod @staticmethod
@@ -157,16 +153,14 @@ class RateChange:
r80 = br["r_80"] * (1 + rpn) r80 = br["r_80"] * (1 + rpn)
r10 = br["r_10"] # use initial rates because i dont know how this works r10 = br["r_10"] # use initial rates because i dont know how this works
r1 = 1 - r240 - r80 - r10 r1 = 1 - r240 - r80 - r10
return Rates.model_validate( return Rates.model_validate({
{ "rates": {
"rates": { "r_1": r1,
"r_1": r1, "r_10": r10,
"r_10": r10, "r_80": r80,
"r_80": r80, "r_240": r240,
"r_240": r240,
}
} }
) })
@node(name="reset rating") @node(name="reset rating")
@staticmethod @staticmethod
@@ -178,28 +172,24 @@ class CounterUp:
@node(name="counter 6* reset") @node(name="counter 6* reset")
@staticmethod @staticmethod
def c80(_: Nothing) -> Counters: def c80(_: Nothing) -> Counters:
return Counters.model_validate( return Counters.model_validate({
{ "counter": {
"counter": { "c_80": 0,
"c_80": 0, "c_10": 0,
"c_10": 0, },
}, "simple_counter": 0,
"simple_counter": 0, # this is influenced by the add reducer.
# this is influenced by the add reducer. # its top level. it doesnt reset. its a miracle. i hate this.
# its top level. it doesnt reset. its a miracle. i hate this. })
}
)
@node(name="counter 5* reset") @node(name="counter 5* reset")
@staticmethod @staticmethod
def c10(c: Counters) -> Counters: def c10(c: Counters) -> Counters:
c80 = c.counter["c_80"] c80 = c.counter["c_80"]
return Counters.model_validate( return Counters.model_validate({
{ "counter": {"c_10": 0, "c_80": c80}, # merge with or_!
"counter": {"c_10": 0, "c_80": c80}, # merge with or_! "simple_counter": 0,
"simple_counter": 0, })
}
)
@node(name="counting up") @node(name="counting up")
@staticmethod @staticmethod
@@ -238,12 +228,10 @@ def roll(state: CurrentPools) -> ThisStorage:
r = state.current_pools r = state.current_pools
(t,) = random.choices(r, weights=[*map(lambda p: p["rates"], r)]) (t,) = random.choices(r, weights=[*map(lambda p: p["rates"], r)])
this = Entity(category=t["category"], name=random.choice(t["pool"])) this = Entity(category=t["category"], name=random.choice(t["pool"]))
return ThisStorage.model_validate( return ThisStorage.model_validate({
{ "this": this,
"this": this, "storage": [this], # I NEED MERGE
"storage": [this], # I NEED MERGE })
}
)
@node(outcomes=("240", "80", "10", "1")) # missed this! good job. @node(outcomes=("240", "80", "10", "1")) # missed this! good job.
+9 -11
View File
@@ -72,17 +72,15 @@ def test():
assert d.status == RunStatus.COMPLETED, "oops" assert d.status == RunStatus.COMPLETED, "oops"
state = State.model_validate(d.state) state = State.model_validate(d.state)
pprint(state.storage) pprint(state.storage)
pprint( pprint([
[ t
t for t in d.trace
for t in d.trace if t.node_id
if t.node_id in (
in ( "counter_up",
"counter_up", "tick",
"tick", )
) ])
]
)
pprint(d.state) pprint(d.state)
assert any( assert any(
i["name"] in context["pool"]["n_240"] i["name"] in context["pool"]["n_240"]
+7 -9
View File
@@ -84,15 +84,13 @@ class FakeManager:
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
enabled: bool = True, enabled: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
self.added.append( self.added.append({
{ "connection_id": connection_id,
"connection_id": connection_id, "server": server,
"server": server, "account": account,
"account": account, "metadata": metadata,
"metadata": metadata, "enabled": enabled,
"enabled": enabled, })
}
)
return {"action": "add_connection", "ok": True} return {"action": "add_connection", "ok": True}
def update_connection( def update_connection(
+10 -12
View File
@@ -32,18 +32,16 @@ def test_load_broker_config_resolves_relative_store_root() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps( json.dumps({
{ "store_root": ".broker-store",
"store_root": ".broker-store", "connections": [
"connections": [ {
{ "id": "demo.personal",
"id": "demo.personal", "server": "demo",
"server": "demo", "account": "personal",
"account": "personal", }
} ],
], }),
}
),
encoding="utf-8", encoding="utf-8",
) )
+64 -72
View File
@@ -14,27 +14,29 @@ from .test_support import local_temp_root
def _write_config(path: Path) -> None: def _write_config(path: Path) -> None:
path.write_text( path.write_text(
json.dumps( json.dumps({
{ "store_root": ".wf_mcp_store",
"store_root": ".wf_mcp_store", "connections": [
"connections": [ {
{ "id": "demo.personal",
"id": "demo.personal", "server": "demo",
"server": "demo", "account": "personal",
"account": "personal", }
} ],
], }),
}
),
encoding="utf-8", encoding="utf-8",
) )
def test_build_parser_accepts_serve_transport() -> None: def test_build_parser_accepts_serve_transport() -> None:
parser = build_parser() parser = build_parser()
args = parser.parse_args( args = parser.parse_args([
["--config", "wf_mcp.config.json", "serve", "--transport", "streamable_http"] "--config",
) "wf_mcp.config.json",
"serve",
"--transport",
"streamable_http",
])
assert args.command == "serve" assert args.command == "serve"
assert args.transport == "streamable_http" assert args.transport == "streamable_http"
@@ -45,17 +47,15 @@ def test_build_parser_accepts_serve_transport() -> None:
def test_build_parser_accepts_proxy_compatibility_flags() -> None: def test_build_parser_accepts_proxy_compatibility_flags() -> None:
parser = build_parser() parser = build_parser()
args = parser.parse_args( args = parser.parse_args([
[ "--config",
"--config", "wf_mcp.config.json",
"wf_mcp.config.json", "serve",
"serve", "--resources-as-tools",
"--resources-as-tools", "--prompts-as-tools",
"--prompts-as-tools", "--search-tools",
"--search-tools", "--safe-tool-names",
"--safe-tool-names", ])
]
)
assert args.command == "serve" assert args.command == "serve"
assert args.resources_as_tools is True assert args.resources_as_tools is True
@@ -68,27 +68,23 @@ def test_build_parser_rejects_legacy_mode_flag() -> None:
parser = build_parser() parser = build_parser()
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
parser.parse_args( parser.parse_args([
[ "--config",
"--config", "wf_mcp.config.json",
"wf_mcp.config.json", "serve",
"serve", "--mode",
"--mode", "unified",
"unified", ])
]
)
def test_build_parser_accepts_no_admin_tools_flag() -> None: def test_build_parser_accepts_no_admin_tools_flag() -> None:
parser = build_parser() parser = build_parser()
args = parser.parse_args( args = parser.parse_args([
[ "--config",
"--config", "wf_mcp.config.json",
"wf_mcp.config.json", "serve",
"serve", "--no-admin-tools",
"--no-admin-tools", ])
]
)
assert args.command == "serve" assert args.command == "serve"
assert args.admin_tools is False assert args.admin_tools is False
@@ -158,23 +154,21 @@ def test_load_broker_config_normalizes_typed_stdio_metadata() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps( json.dumps({
{ "store_root": ".wf_mcp_store",
"store_root": ".wf_mcp_store", "connections": [
"connections": [ {
{ "id": "demo.personal",
"id": "demo.personal", "server": "demo",
"server": "demo", "account": "personal",
"account": "personal", "metadata": {
"metadata": { "command": "python",
"command": "python", "args": ["server.py"],
"args": ["server.py"], "env": {"TOKEN": "secret"},
"env": {"TOKEN": "secret"}, },
}, }
} ],
], }),
}
),
encoding="utf-8", encoding="utf-8",
) )
@@ -194,18 +188,16 @@ def test_load_broker_config_rejects_bad_metadata_shape() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps( json.dumps({
{ "connections": [
"connections": [ {
{ "id": "demo.personal",
"id": "demo.personal", "server": "demo",
"server": "demo", "account": "personal",
"account": "personal", "metadata": {"transport": "stdio", "args": "server.py"},
"metadata": {"transport": "stdio", "args": "server.py"}, }
} ],
], }),
}
),
encoding="utf-8", encoding="utf-8",
) )
+23 -31
View File
@@ -389,19 +389,17 @@ def test_proxy_admin_tools_mutate_config_file() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps( json.dumps({
{ "store_root": ".wf_mcp_store",
"store_root": ".wf_mcp_store", "connections": [
"connections": [ {
{ "id": "fixture.personal",
"id": "fixture.personal", "server": "fixture",
"server": "fixture", "account": "personal",
"account": "personal", "enabled": False,
"enabled": False, }
} ],
], }),
}
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)
@@ -486,12 +484,10 @@ def test_proxy_admin_reload_remounts_connections() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps( json.dumps({
{ "store_root": ".wf_mcp_store",
"store_root": ".wf_mcp_store", "connections": [],
"connections": [], }),
}
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)
@@ -548,12 +544,10 @@ def test_proxy_admin_reload_sends_list_changed_notifications() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps( json.dumps({
{ "store_root": ".wf_mcp_store",
"store_root": ".wf_mcp_store", "connections": [],
"connections": [], }),
}
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)
@@ -582,12 +576,10 @@ def test_proxy_config_mutation_does_not_notify_before_reload() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps( json.dumps({
{ "store_root": ".wf_mcp_store",
"store_root": ".wf_mcp_store", "connections": [],
"connections": [], }),
}
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)
+6 -8
View File
@@ -65,11 +65,9 @@ def test_rewrites_resource_links_inside_call_tool_result() -> None:
def _resource_link(uri: str) -> mcp_types.ResourceLink: def _resource_link(uri: str) -> mcp_types.ResourceLink:
"""Build ResourceLink through validation because Pydantic accepts URI strings.""" """Build ResourceLink through validation because Pydantic accepts URI strings."""
return mcp_types.ResourceLink.model_validate( return mcp_types.ResourceLink.model_validate({
{ "type": "resource_link",
"type": "resource_link", "name": "dynamic-text",
"name": "dynamic-text", "uri": uri,
"uri": uri, "mimeType": "text/plain",
"mimeType": "text/plain", })
}
)
+16 -18
View File
@@ -526,24 +526,22 @@ def test_server_reload_syncs_service_connection_source_enabled_state() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps( json.dumps({
{ "store_root": ".wf_mcp_store",
"store_root": ".wf_mcp_store", "connections": [
"connections": [ {
{ "id": "fixture.personal",
"id": "fixture.personal", "server": "fixture",
"server": "fixture", "account": "personal",
"account": "personal", "enabled": False,
"enabled": False, "metadata": {
"metadata": { "transport": "stdio",
"transport": "stdio", "command": sys.executable,
"command": sys.executable, "args": [fixture_server_path()],
"args": [fixture_server_path()], },
}, }
} ],
], }),
}
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)