This commit is contained in:
lda
2026-04-28 13:52:01 +07:00 Verified
parent 8061d0c41d
commit dc38b87ddb
6 changed files with 486 additions and 286 deletions
+17 -266
View File
@@ -1,282 +1,30 @@
import json
import sys
from wf_core import (
END,
RuntimeContext,
Workflow,
execute_workflow,
resume_workflow,
)
from wf_core import RuntimeContext, execute_workflow, resume_workflow
from wf_core.demo_workflow import build_demo_registry, build_demo_workflow
workflow = 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"],
},
"state_schema": {
"fields": {
"folder_id": {"type": "string"},
"should_email": {"type": "boolean"},
"documents": {"type": "array", "merge_strategy": "replace"},
"item_summaries": {"type": "array", "merge_strategy": "append"},
"summary": {"type": "string", "merge_strategy": "replace"},
"approved": {"type": "boolean", "merge_strategy": "replace"},
"approval_comment": {"type": "string", "merge_strategy": "replace"},
"email_status": {"type": "string", "merge_strategy": "replace"},
}
},
"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"],
},
"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"],
},
"output_schema": {
"type": "object",
"properties": {"item_summary": {"type": "string"}},
"required": ["item_summary"],
},
"outcomes": ["ok"],
},
{
"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"],
},
{
"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"],
},
{
"name": "mark_email_skipped",
"input_schema": {
"type": "object",
"properties": {},
},
"output_schema": {
"type": "object",
"properties": {"email_status": {"type": "string"}},
"required": ["email_status"],
},
"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": "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",
},
"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},
],
}
)
def print_run(label: str, run: object) -> None:
print(f"{label}:")
print(json.dumps(run.to_dict(), indent=2))
def main() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
report = workflow.validate_structure()
report.raise_for_errors()
print("Workflow is structurally valid.", file=sys.stderr)
def drive_list_files(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
folder_id = payload["folder_id"]
return {
"outcome": "ok",
"output": {
"documents": [
f"{folder_id}/meeting-notes.md",
f"{folder_id}/weekly-report.md",
]
},
}
def summarize_documents(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
document = payload["document"]
return {
"outcome": "ok",
"output": {"item_summary": f"Summary of {document}"},
}
def combine_summaries(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
item_summaries = payload["item_summaries"]
return {
"outcome": "ok",
"output": {"summary": " | ".join(item_summaries)},
}
def send_email(payload: dict[str, object], ctx: RuntimeContext) -> dict[str, object]:
return {
"outcome": "sent",
"output": {"email_status": f"sent: {payload['summary']}"},
}
def mark_email_skipped(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
return {
"outcome": "ok",
"output": {"email_status": "skipped"},
}
registry = {
"drive_list_files": drive_list_files,
"summarize_document": summarize_documents,
"combine_summaries": combine_summaries,
"send_email": send_email,
"mark_email_skipped": mark_email_skipped,
}
workflow.validate_structure().raise_for_errors()
print("Interrupting run:")
interrupted_run = execute_workflow(
workflow,
{"folder_id": "demo-folder", "should_email": True},
registry,
)
print(json.dumps(interrupted_run.to_dict(), indent=2))
print_run("Interrupting run", interrupted_run)
print("Resumed run:")
resumed_run = resume_workflow(
workflow,
interrupted_run,
@@ -284,12 +32,15 @@ resumed_run = resume_workflow(
resume_payload={"approved": True, "comment": "Looks good to send."},
resume_outcome="submitted",
)
print(json.dumps(resumed_run.to_dict(), indent=2))
print_run("Resumed run", resumed_run)
print("Non-interrupt run:")
non_interrupt_run = execute_workflow(
workflow,
{"folder_id": "demo-folder", "should_email": False},
registry,
)
print(json.dumps(non_interrupt_run.to_dict(), indent=2))
print_run("Non-interrupt run", non_interrupt_run)
if __name__ == "__main__":
main()
+5
View File
@@ -8,3 +8,8 @@ dependencies = [
"mcp[cli,rich]>=1.27.0",
"pydantic>=2.13.3",
]
[dependency-groups]
dev = [
"pytest>=8.4.0",
]
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from wf_core import (
END,
FrameStatus,
RunStatus,
execute_workflow,
resume_workflow,
step_workflow,
)
from wf_core.demo_workflow import build_demo_registry, build_demo_workflow
from wf_core.run_factory import create_run_state
def test_interrupt_then_resume_to_send_email() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
interrupted_run = execute_workflow(
workflow,
{"folder_id": "demo-folder", "should_email": True},
registry,
)
assert interrupted_run.status == RunStatus.INTERRUPTED
assert interrupted_run.current_node_id == "approve_email"
assert interrupted_run.interrupt is not None
assert interrupted_run.interrupt.kind == "approval"
assert interrupted_run.state["summary"].startswith("Summary of demo-folder/")
resumed_run = resume_workflow(
workflow,
interrupted_run,
registry,
resume_payload={"approved": True, "comment": "Looks good to send."},
resume_outcome="submitted",
)
assert resumed_run.status == RunStatus.COMPLETED
assert resumed_run.current_node_id == END
assert resumed_run.output["email_status"].startswith("sent:")
assert resumed_run.state["approved"] is True
assert resumed_run.state["approval_comment"] == "Looks good to send."
def test_non_interrupt_path_skips_email() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
run = execute_workflow(
workflow,
{"folder_id": "demo-folder", "should_email": False},
registry,
)
assert run.status == RunStatus.COMPLETED
assert run.output["email_status"] == "skipped"
assert run.interrupt is None
def test_stepwise_execution_reaches_interrupt() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
run = create_run_state(
workflow,
{"folder_id": "demo-folder", "should_email": True},
)
workflow.validate_structure().raise_for_errors()
while run.status not in {RunStatus.INTERRUPTED, RunStatus.COMPLETED}:
step_workflow(workflow, run, registry)
assert run.status == RunStatus.INTERRUPTED
assert run.current_node_id == "approve_email"
assert any(entry.step_type == "foreach" for entry in run.trace)
assert len(run.trace) == 9
def test_foreach_stress_with_many_documents() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
document_count = 25
def many_files(payload: dict[str, object], ctx: object) -> dict[str, object]:
folder_id = payload["folder_id"]
return {
"outcome": "ok",
"output": {
"documents": [
f"{folder_id}/doc-{index:02d}.md" for index in range(document_count)
]
},
}
registry["drive_list_files"] = many_files
run = execute_workflow(
workflow,
{"folder_id": "bulk-folder", "should_email": False},
registry,
)
assert run.status == RunStatus.COMPLETED
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
]
) == document_count
assert len([entry for entry in run.trace if entry.step_type == "foreach"]) == (
document_count + 1
)
Generated
+51
View File
@@ -212,6 +212,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -248,12 +257,20 @@ dependencies = [
{ name = "pydantic" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "mcp", extras = ["cli", "rich"], specifier = ">=1.27.0" },
{ name = "pydantic", specifier = ">=2.13.3" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.4.0" }]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -309,6 +326,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -411,6 +446,22 @@ crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
+266
View File
@@ -0,0 +1,266 @@
from __future__ import annotations
from collections.abc import Callable
from .model import Workflow
from .run_state import RuntimeContext
from .tokens import END
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"],
},
"state_schema": {
"fields": {
"folder_id": {"type": "string"},
"should_email": {"type": "boolean"},
"documents": {"type": "array", "merge_strategy": "replace"},
"item_summaries": {"type": "array", "merge_strategy": "append"},
"summary": {"type": "string", "merge_strategy": "replace"},
"approved": {"type": "boolean", "merge_strategy": "replace"},
"approval_comment": {
"type": "string",
"merge_strategy": "replace",
},
"email_status": {"type": "string", "merge_strategy": "replace"},
}
},
"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"],
},
"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"],
},
"output_schema": {
"type": "object",
"properties": {"item_summary": {"type": "string"}},
"required": ["item_summary"],
},
"outcomes": ["ok"],
},
{
"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"],
},
{
"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"],
},
{
"name": "mark_email_skipped",
"input_schema": {
"type": "object",
"properties": {},
},
"output_schema": {
"type": "object",
"properties": {"email_status": {"type": "string"}},
"required": ["email_status"],
},
"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": "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",
},
"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},
],
}
)
def drive_list_files(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
folder_id = payload["folder_id"]
return {
"outcome": "ok",
"output": {
"documents": [
f"{folder_id}/meeting-notes.md",
f"{folder_id}/weekly-report.md",
]
},
}
def summarize_documents(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
document = payload["document"]
return {
"outcome": "ok",
"output": {"item_summary": f"Summary of {document}"},
}
def combine_summaries(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
item_summaries = payload["item_summaries"]
return {
"outcome": "ok",
"output": {"summary": " | ".join(item_summaries)},
}
def send_email(payload: dict[str, object], ctx: RuntimeContext) -> dict[str, object]:
return {
"outcome": "sent",
"output": {"email_status": f"sent: {payload['summary']}"},
}
def mark_email_skipped(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
return {
"outcome": "ok",
"output": {"email_status": "skipped"},
}
def build_demo_registry() -> dict[str, DemoHandler]:
return {
"drive_list_files": drive_list_files,
"summarize_document": summarize_documents,
"combine_summaries": combine_summaries,
"send_email": send_email,
"mark_email_skipped": mark_email_skipped,
}