feat: inspect node runtime context
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core import END, Edge, ForeachNode, NodeUse, SchemaRef, StateSchema, Workflow
|
||||
from wf_core.analysis.context_scopes import (
|
||||
context_analysis_warnings,
|
||||
context_fields_by_node,
|
||||
)
|
||||
from wf_core.context_contracts import STANDARD_CONTEXT_FIELDS, foreach_context_fields
|
||||
from wf_core.run_state import ExecutionFrame
|
||||
from wf_core.runtime.ops.frames import frame_context_values
|
||||
|
||||
|
||||
def _node(node_id: str) -> NodeUse:
|
||||
return NodeUse(id=node_id, type="node", node="noop")
|
||||
|
||||
|
||||
def _foreach(
|
||||
node_id: str,
|
||||
*,
|
||||
alias: str,
|
||||
mode: str = "serial",
|
||||
over: str = "state.items",
|
||||
) -> ForeachNode:
|
||||
data: dict[str, object] = {
|
||||
"id": node_id,
|
||||
"type": "foreach",
|
||||
"over": over,
|
||||
"as": alias,
|
||||
"mode": mode,
|
||||
}
|
||||
if mode == "concurrent":
|
||||
data["concurrent"] = {"max_active": 2, "max_outstanding": 2}
|
||||
return ForeachNode.model_validate(data)
|
||||
|
||||
|
||||
def _workflow(
|
||||
*,
|
||||
start: str,
|
||||
nodes: list[object],
|
||||
edges: list[dict[str, str]],
|
||||
state_schema: dict[str, object] | None = None,
|
||||
) -> Workflow:
|
||||
return Workflow(
|
||||
name="context-analysis",
|
||||
input_schema=SchemaRef(type="object"),
|
||||
state_schema=StateSchema.model_validate(
|
||||
state_schema
|
||||
or {
|
||||
"type": "object",
|
||||
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
|
||||
}
|
||||
),
|
||||
output_schema=SchemaRef(type="object"),
|
||||
start=start,
|
||||
nodes=nodes,
|
||||
edges=[Edge.model_validate(edge) for edge in edges],
|
||||
)
|
||||
|
||||
|
||||
def _field_map(workflow: Workflow, node_id: str) -> dict[str, object]:
|
||||
return {
|
||||
field.contract.name: field
|
||||
for field in context_fields_by_node(workflow)[node_id]
|
||||
}
|
||||
|
||||
|
||||
def test_frame_context_values_uses_standard_and_foreach_contract_keys() -> None:
|
||||
ordinary = frame_context_values(
|
||||
ExecutionFrame(
|
||||
id="root",
|
||||
kind="root",
|
||||
node_id="plain",
|
||||
prior_outcome="ok",
|
||||
activated_incoming_edge="start",
|
||||
)
|
||||
)
|
||||
assert ordinary["prior_outcome"] == "ok"
|
||||
assert ordinary["activated_incoming_edge"] == "start"
|
||||
assert ordinary["scope_id"] == "root"
|
||||
assert ordinary["lineage_id"] == "root"
|
||||
assert ordinary["parent_lineage_id"] is None
|
||||
assert "loop_item" not in ordinary
|
||||
|
||||
iteration = ExecutionFrame(
|
||||
id="root:each:0",
|
||||
kind="foreach_iteration",
|
||||
node_id="body",
|
||||
metadata={"loop_item": "a", "loop_index": 0, "loop_alias": "item"},
|
||||
)
|
||||
context = frame_context_values(iteration)
|
||||
assert context["loop_item"] == "a"
|
||||
assert context["loop_index"] == 0
|
||||
assert context["item"] == "a"
|
||||
|
||||
|
||||
def test_context_contracts_deduplicate_aliases_that_are_standard_loop_keys() -> None:
|
||||
assert STANDARD_CONTEXT_FIELDS[0].schema == {"type": ["string", "null"]}
|
||||
assert [field.name for field in foreach_context_fields("loop_item", {})] == [
|
||||
"loop_item",
|
||||
"loop_index",
|
||||
]
|
||||
|
||||
|
||||
def test_serial_and_concurrent_foreach_expose_the_same_scoped_context() -> None:
|
||||
for mode in ("serial", "concurrent"):
|
||||
workflow = _workflow(
|
||||
start="each",
|
||||
nodes=[
|
||||
_foreach("each", alias="item", mode=mode),
|
||||
_node("body"),
|
||||
_node("tail"),
|
||||
],
|
||||
edges=[
|
||||
{"from": "each", "outcome": "loop", "to": "body"},
|
||||
{"from": "each", "outcome": "done", "to": "tail"},
|
||||
{"from": "body", "outcome": "ok", "to": END},
|
||||
{"from": "tail", "outcome": "ok", "to": END},
|
||||
],
|
||||
)
|
||||
|
||||
body = _field_map(workflow, "body")
|
||||
assert body["loop_item"].availability == "available"
|
||||
assert body["loop_index"].availability == "available"
|
||||
assert body["item"].availability == "available"
|
||||
assert "item" not in _field_map(workflow, "tail")
|
||||
|
||||
|
||||
def test_foreach_item_schema_and_configured_alias_are_reported() -> None:
|
||||
workflow = _workflow(
|
||||
start="each",
|
||||
nodes=[_foreach("each", alias="record"), _node("body")],
|
||||
edges=[
|
||||
{"from": "each", "outcome": "loop", "to": "body"},
|
||||
{"from": "body", "outcome": "ok", "to": END},
|
||||
{"from": "each", "outcome": "done", "to": END},
|
||||
],
|
||||
)
|
||||
|
||||
fields = _field_map(workflow, "body")
|
||||
assert fields["record"].contract.schema == {"type": "string"}
|
||||
assert fields["loop_item"].contract.schema == {"type": "string"}
|
||||
assert fields["loop_index"].contract.schema == {"type": "integer"}
|
||||
|
||||
|
||||
def test_only_foreach_reachable_node_has_available_context() -> None:
|
||||
workflow = _workflow(
|
||||
start="start",
|
||||
nodes=[_node("start"), _foreach("each", alias="item"), _node("body")],
|
||||
edges=[
|
||||
{"from": "start", "outcome": "ok", "to": "body"},
|
||||
{"from": "start", "outcome": "loop", "to": "each"},
|
||||
{"from": "each", "outcome": "loop", "to": "body"},
|
||||
{"from": "each", "outcome": "done", "to": END},
|
||||
{"from": "body", "outcome": "ok", "to": END},
|
||||
],
|
||||
)
|
||||
|
||||
assert _field_map(workflow, "body")["item"].availability == "conditional"
|
||||
assert _field_map(workflow, "body")["item"].reason
|
||||
|
||||
|
||||
def test_nested_foreach_replaces_inner_scope_and_restores_outer_scope() -> None:
|
||||
workflow = _workflow(
|
||||
start="outer",
|
||||
nodes=[
|
||||
_foreach("outer", alias="outer_item"),
|
||||
_foreach("inner", alias="inner_item", over="state.inner_items"),
|
||||
_node("inner_body"),
|
||||
_node("after_inner"),
|
||||
],
|
||||
edges=[
|
||||
{"from": "outer", "outcome": "loop", "to": "inner"},
|
||||
{"from": "inner", "outcome": "loop", "to": "inner_body"},
|
||||
{"from": "inner", "outcome": "done", "to": "after_inner"},
|
||||
{"from": "inner_body", "outcome": "ok", "to": END},
|
||||
{"from": "after_inner", "outcome": "ok", "to": END},
|
||||
{"from": "outer", "outcome": "done", "to": END},
|
||||
],
|
||||
state_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"type": "string"}},
|
||||
"inner_items": {"type": "array", "items": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
inner = _field_map(workflow, "inner_body")
|
||||
after_inner = _field_map(workflow, "after_inner")
|
||||
assert "outer_item" not in inner
|
||||
assert inner["inner_item"].availability == "available"
|
||||
assert after_inner["outer_item"].availability == "available"
|
||||
assert "inner_item" not in after_inner
|
||||
|
||||
|
||||
def test_malformed_routes_warn_without_granting_a_scoped_alias() -> None:
|
||||
workflow = _workflow(
|
||||
start="each",
|
||||
nodes=[_foreach("each", alias="item"), _node("body")],
|
||||
edges=[
|
||||
{"from": "each", "outcome": "done", "to": "missing"},
|
||||
{"from": "each", "outcome": "ok", "to": "body"},
|
||||
{"from": "body", "outcome": "ok", "to": END},
|
||||
],
|
||||
)
|
||||
|
||||
assert "item" not in _field_map(workflow, "body")
|
||||
warnings = context_analysis_warnings(workflow)
|
||||
assert any("missing" in warning for warning in warnings)
|
||||
assert any("loop" in warning for warning in warnings)
|
||||
|
||||
|
||||
def test_cyclic_graph_analysis_memoizes_node_and_frame_scope() -> None:
|
||||
workflow = _workflow(
|
||||
start="a",
|
||||
nodes=[_node("a"), _node("b")],
|
||||
edges=[
|
||||
{"from": "a", "outcome": "ok", "to": "b"},
|
||||
{"from": "b", "outcome": "ok", "to": "a"},
|
||||
],
|
||||
)
|
||||
|
||||
fields = context_fields_by_node(workflow)
|
||||
assert set(fields) == {"a", "b"}
|
||||
assert fields["a"]
|
||||
assert fields["b"]
|
||||
@@ -7,6 +7,7 @@ from wf_core.models.schemas import SchemaRef, StateSchema
|
||||
from wf_core.models.workflow import Workflow
|
||||
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
|
||||
from wf_core.runtime.ops.flow import advance_frame
|
||||
from wf_core.runtime.ops.frames import frame_context_values
|
||||
from wf_core.runtime.ops.runs import create_run_state
|
||||
from wf_core.runtime.scheduler import (
|
||||
add_frame,
|
||||
@@ -207,3 +208,22 @@ def test_deadlock_error_includes_ready_queue_and_frame_summary() -> None:
|
||||
assert "deadlocked" in message
|
||||
assert "ready_frame_ids=[]" in message
|
||||
assert "parent:blocked@foreach" in message
|
||||
|
||||
|
||||
def test_frame_context_values_exposes_configured_foreach_alias() -> None:
|
||||
context = frame_context_values(
|
||||
ExecutionFrame(
|
||||
id="child",
|
||||
kind="foreach_iteration",
|
||||
node_id="body",
|
||||
metadata={
|
||||
"loop_item": {"id": "a"},
|
||||
"loop_index": 2,
|
||||
"loop_alias": "record",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert context["loop_item"] == {"id": "a"}
|
||||
assert context["loop_index"] == 2
|
||||
assert context["record"] == {"id": "a"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_api.authoring_contracts import (
|
||||
context_path_options,
|
||||
project_authoring_contract_inventory,
|
||||
schema_path_options,
|
||||
)
|
||||
@@ -328,3 +329,48 @@ def test_project_authoring_contract_inventory_composes_pure_inputs() -> None:
|
||||
assert inventory["entry_steps"] == [entry_step]
|
||||
assert inventory["workflow_outcomes"] == ["ok", "error"]
|
||||
assert inventory["warnings"] == ["selected step has conditional context"]
|
||||
|
||||
|
||||
def test_context_path_options_are_step_input_only() -> None:
|
||||
options = context_path_options(
|
||||
[
|
||||
{
|
||||
"name": "loop_item",
|
||||
"schema": {"type": "string"},
|
||||
"description": "Current foreach item",
|
||||
"availability": "conditional",
|
||||
"reason": "Only available inside the foreach body.",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert options[0]["path"] == "context.loop_item"
|
||||
assert options[0]["origin"] == "runtime_context"
|
||||
assert options[0]["uses"] == ["step_input"]
|
||||
assert options[0]["availability"] == "conditional"
|
||||
assert options[0]["reason"] == "Only available inside the foreach body."
|
||||
|
||||
|
||||
def test_project_inventory_does_not_offer_context_for_workflow_output() -> None:
|
||||
context_entry = {
|
||||
"path": "context.item",
|
||||
"label": "Item",
|
||||
"origin": "runtime_context",
|
||||
"schema": {},
|
||||
"required": False,
|
||||
"availability": "available",
|
||||
"uses": ["step_input", "workflow_output"],
|
||||
}
|
||||
|
||||
inventory = project_authoring_contract_inventory(
|
||||
workspace_id="workspace-1",
|
||||
revision=1,
|
||||
selected_step_id=None,
|
||||
input_schema={"type": "object"},
|
||||
state_schema={"type": "object"},
|
||||
output_schema={"type": "object"},
|
||||
context_entries=[context_entry],
|
||||
)
|
||||
|
||||
assert inventory["readable_sources"][0]["path"] == "context.item"
|
||||
assert inventory["readable_sources"][0]["uses"] == ["step_input"]
|
||||
|
||||
Reference in New Issue
Block a user