5ab8877ba5
* feat(durabletask): surface HITL respond-URL addressing to workflow executors
Let a workflow notify a human reviewer (e.g. email an approval link) from inside the
graph, without the caller threading the instanceId/requestId by hand.
- durabletask: the orchestrator injects host_context {instance_id, workflow_name,
request_path_prefix} into each activity input; CapturingRunnerContext surfaces it as
host_metadata. No new core API.
- azurefunctions: WorkflowHitlContext.from_context(ctx) builds the canonical
respond/status URLs (returns None in-process so callers degrade gracefully).
Re-exported through the agent_framework.azure lazy namespace.
- Nested sub-workflows: the address context (root instance + workflow name +
accumulated {executor}~{ordinal}~ prefix) propagates down call_sub_orchestrator via a
new SUBWORKFLOW_ADDRESS_KEY marker, so an executor at any depth builds a URL that
targets the addressable top-level instance with a qualified request id. The per-child
ordinal matches the read-side enumerate() index used by the status/respond endpoints.
The marker is stripped from untrusted input alongside SUBWORKFLOW_INPUT_KEY
(confused-deputy / info-leak guard).
- Samples 12 and 13 reworked into the retry-safe two-step notify pattern: the emitter
generates an explicit request id and a downstream NotifyExecutor builds the URL and
notifies, so failed upstream retries never produce a dead link.
Tests: unit coverage for the metadata round-trip, address/ordinal agreement (fan-out at
depth and nested prefix accumulation), marker stripping, and URL building; integration
tests assert the helper-built URL equals the server respondUrl and resumes the run, for
both the flat (12) and nested (13) samples.
* refactor(durabletask): read back request_info id instead of generating one in samples
Add WorkflowHitlContext.pending_request_id(ctx), an async helper that returns the
id request_info just generated (read from the runner context's pending request-info
events). This works on any host via the core RunnerContext protocol method, so it
needs no core change.
Samples 12 and 13 now call request_info() and read the id back to forward to the
NotifyExecutor, instead of minting a uuid by hand and passing request_id=. The
read-back happens in the same activity execution that generated the id, so the
pending request event and the notify message still commit together with the same id
(retry-safe; failed upstream retries notify no one).
* docs(azurefunctions): document request_info id read-back and notify safety
Tighten pending_request_id docstring to require calling it immediately after request_info, and explain why that is safe on the durable host (each executor runs in its own activity with its own runner context, so the pending set only holds this executor's requests and the newest is the one just emitted). Document the two-step notify pattern in the 12 and 13 sample READMEs, including the downstream-notifier retry safety and the nested address-prefix propagation.
* fix(python): resolve ty typing error and address PR review comments
- test_subworkflow_orchestration: replace the mypy-only type:ignore[arg-type] with a cast so the ty checker passes too (the other four checkers already honored the ignore).
- samples 12/13 README: guard the notify snippet against None before build_respond_url to match the documented graceful-degradation behavior.
- integration tests 12/13: reword comments that implied request_info now generates an explicit uuid4; it generates the id internally by default.
* fix(python): honor configurable Functions route prefix and address HITL PR review
- Resolve the route prefix from host.json (extensions.http.routePrefix, default api) in a new azurefunctions _routes module, used by both the server endpoints and WorkflowHitlContext, so a custom or empty routePrefix no longer 404s respond/status URLs (was hardcoded /api/ in four places).
- Extract respond/status URL construction into one shared builder called from _app.py and _hitl_context.py, removing the sync-by-test duplication.
- Broaden loopback detection (localhost, 127.0.0.0/8, 0.0.0.0, ::1, [::1]) via a _is_loopback helper so local links use http.
- Pin the host_context key names as shared constants in durabletask so producer and azurefunctions consumer cannot drift.
- Reword a stale base_url comment to reference WEBSITE_HOSTNAME.
- Add unit tests for the route module and loopback handling.
* refactor(azurefunctions): derive server-side HITL URLs from the request URL
The run and status endpoints now derive the base URL and route prefix from the incoming request URL (the value the host actually routed) via split_request_url, so the caller-visible respond/status URLs no longer depend on reading host.json on the server. The in-workflow helper keeps reading host.json since it has no request context. Replaces strip_route_prefix and updates its tests.
* test(durabletask): enforce sub-workflow ordinal and read-index agreement
Extract the read-side subworkflows grouping into a shared _index_subworkflows helper (used by the orchestrator) and add test_readside_index_matches_dispatch_ordinal, which round-trips a fan-out through that helper and asserts subworkflows[executor][ordinal] resolves to the child the dispatch stamped that ordinal onto. Turns the previously comment-only write-ordinal / read-index invariant into a shared, CI-enforced one.
* fix(azurefunctions): suppress bandit B104 on loopback host set
194 lines
7.6 KiB
Python
194 lines
7.6 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
"""Unit tests for execute_workflow_activity (shared non-agent executor activity body).
|
|
|
|
These tests exercise the host-agnostic activity execution shared by the Azure
|
|
Functions and standalone durabletask workflow hosts. In particular they protect
|
|
the state snapshot/diff semantics: the snapshot must be a *deep* copy so that
|
|
in-place mutations to nested objects (dicts, lists) are correctly detected as
|
|
updates (regression guard for the shallow-copy bug, #4500).
|
|
"""
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
from agent_framework_durabletask import execute_workflow_activity
|
|
from agent_framework_durabletask._workflows.orchestrator import SOURCE_HITL_RESPONSE, SOURCE_ORCHESTRATOR
|
|
from agent_framework_durabletask._workflows.serialization import serialize_value
|
|
|
|
|
|
@dataclass
|
|
class ApprovalRequest:
|
|
"""Typed request used to select a HITL response handler."""
|
|
|
|
prompt: str
|
|
|
|
|
|
def _make_executor(executor_id: str, mutate: Any) -> Mock:
|
|
"""Build a mock non-agent executor whose execute() mutates shared state."""
|
|
executor = Mock()
|
|
executor.id = executor_id
|
|
executor.execute = AsyncMock(side_effect=mutate)
|
|
return executor
|
|
|
|
|
|
def _run(executor: Mock, snapshot: dict[str, Any]) -> dict[str, Any]:
|
|
"""Invoke execute_workflow_activity and return the parsed result dict."""
|
|
input_data = json.dumps({
|
|
"message": "test",
|
|
"shared_state_snapshot": snapshot,
|
|
"source_executor_ids": [SOURCE_ORCHESTRATOR],
|
|
})
|
|
return json.loads(execute_workflow_activity(executor, input_data))
|
|
|
|
|
|
class TestExecuteWorkflowActivityStateDiff:
|
|
"""State snapshot/diff behavior of the shared workflow activity body."""
|
|
|
|
def test_nested_dict_mutation_detected(self) -> None:
|
|
"""In-place mutation of a nested dict is reported as an update."""
|
|
|
|
async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None:
|
|
config = state.get("Local.config")
|
|
config["code"] = "SOMECODEXXX"
|
|
config["enabled"] = True
|
|
state.commit()
|
|
|
|
executor = _make_executor("test-exec", mutate)
|
|
result = _run(executor, {"Local.config": {"code": "", "enabled": False}, "simple_key": "simple_value"})
|
|
|
|
updates = result["shared_state_updates"]
|
|
assert "Local.config" in updates, "nested mutation not detected — snapshot may be a shallow copy"
|
|
assert updates["Local.config"]["code"] == "SOMECODEXXX"
|
|
assert updates["Local.config"]["enabled"] is True
|
|
|
|
def test_new_key_in_nested_dict_detected(self) -> None:
|
|
"""Adding a key to a nested dict is reported as an update."""
|
|
|
|
async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None:
|
|
state.get("Local.data")["code"] = "NEW_CODE"
|
|
state.commit()
|
|
|
|
executor = _make_executor("test-exec", mutate)
|
|
result = _run(executor, {"Local.data": {"existing": "value"}})
|
|
|
|
assert result["shared_state_updates"]["Local.data"]["code"] == "NEW_CODE"
|
|
|
|
def test_nested_list_mutation_detected(self) -> None:
|
|
"""Appending to a nested list is reported as an update."""
|
|
|
|
async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None:
|
|
state.get("Local.items").append(4)
|
|
state.commit()
|
|
|
|
executor = _make_executor("test-exec", mutate)
|
|
result = _run(executor, {"Local.items": [1, 2, 3]})
|
|
|
|
assert result["shared_state_updates"]["Local.items"] == [1, 2, 3, 4]
|
|
|
|
def test_new_top_level_key_detected(self) -> None:
|
|
"""Setting a new top-level key is reported as an update."""
|
|
|
|
async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None:
|
|
state.set("Local.code", "SOMECODEXXX")
|
|
state.commit()
|
|
|
|
executor = _make_executor("test-exec", mutate)
|
|
result = _run(executor, {"existing": "value"})
|
|
|
|
assert result["shared_state_updates"]["Local.code"] == "SOMECODEXXX"
|
|
|
|
def test_unchanged_state_produces_empty_diff(self) -> None:
|
|
"""Unmodified state produces no updates."""
|
|
|
|
async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None:
|
|
# No mutations performed.
|
|
state.commit()
|
|
|
|
executor = _make_executor("test-exec", mutate)
|
|
result = _run(executor, {"Local.config": {"code": "existing", "enabled": True}, "simple_key": "v"})
|
|
|
|
assert result["shared_state_updates"] == {}
|
|
|
|
def test_deleted_key_reported(self) -> None:
|
|
"""A key removed during execution is reported as a delete."""
|
|
|
|
async def mutate(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None:
|
|
state.delete("to_remove")
|
|
state.commit()
|
|
|
|
executor = _make_executor("test-exec", mutate)
|
|
result = _run(executor, {"to_remove": "value", "keep": "value"})
|
|
|
|
assert "to_remove" in result["shared_state_deletes"]
|
|
assert "keep" not in result["shared_state_deletes"]
|
|
|
|
|
|
def test_hitl_response_handler_receives_typed_original_request() -> None:
|
|
"""Already-serialized HITL requests are decoded before response handler dispatch."""
|
|
original_request = ApprovalRequest(prompt="Approve this?")
|
|
hitl_message = {
|
|
"original_request": serialize_value(original_request),
|
|
"response": "approved",
|
|
"response_type": None,
|
|
}
|
|
input_data = json.dumps({
|
|
"message": serialize_value(hitl_message),
|
|
"shared_state_snapshot": {},
|
|
"source_executor_ids": [f"{SOURCE_HITL_RESPONSE}_request-1"],
|
|
})
|
|
|
|
handler = AsyncMock()
|
|
executor = Mock()
|
|
executor.id = "review-gate"
|
|
executor._find_response_handler.return_value = handler
|
|
|
|
execute_workflow_activity(executor, input_data)
|
|
|
|
executor._find_response_handler.assert_called_once_with(original_request, "approved")
|
|
handler.assert_awaited_once()
|
|
|
|
|
|
class TestExecuteWorkflowActivityHostMetadata:
|
|
"""Orchestration metadata is surfaced to executors via the runner context."""
|
|
|
|
def test_host_context_surfaced_on_runner_context(self) -> None:
|
|
"""``host_context`` in the activity input is exposed as ``runner_context.host_metadata``."""
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def capture(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None:
|
|
captured["metadata"] = runner_context.host_metadata
|
|
state.commit()
|
|
|
|
executor = _make_executor("test-exec", capture)
|
|
input_data = json.dumps({
|
|
"message": "test",
|
|
"shared_state_snapshot": {},
|
|
"source_executor_ids": [SOURCE_ORCHESTRATOR],
|
|
"host_context": {"instance_id": "abc123", "workflow_name": "content_moderation"},
|
|
})
|
|
json.loads(execute_workflow_activity(executor, input_data))
|
|
|
|
assert captured["metadata"] == {"instance_id": "abc123", "workflow_name": "content_moderation"}
|
|
|
|
def test_absent_host_context_yields_none(self) -> None:
|
|
"""When the input omits ``host_context``, ``host_metadata`` is ``None`` (in-process parity)."""
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def capture(message: Any, source_executor_ids: Any, state: Any, runner_context: Any) -> None:
|
|
captured["metadata"] = runner_context.host_metadata
|
|
state.commit()
|
|
|
|
executor = _make_executor("test-exec", capture)
|
|
_run(executor, {})
|
|
|
|
assert captured["metadata"] is None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import pytest
|
|
|
|
pytest.main([__file__, "-v", "--tb=short"])
|