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