fix: address structured context final-review findings
This commit is contained in:
@@ -26,7 +26,6 @@ from wf_core.models.workflow import Edge, Workflow
|
||||
from wf_core.tokens import END
|
||||
|
||||
type ContextAvailability = Literal["available", "conditional"]
|
||||
type FrameScope = str | None
|
||||
|
||||
_MAX_LOCAL_SCHEMA_REFERENCE_DEPTH = 32
|
||||
|
||||
@@ -377,6 +376,7 @@ def _foreach_item_schema(
|
||||
_schema_document(
|
||||
workflow,
|
||||
foreach.over.root,
|
||||
stack=controller_stack,
|
||||
foreach_nodes=foreach_nodes,
|
||||
owner_stack_by_node=owner_stack_by_node,
|
||||
),
|
||||
@@ -424,7 +424,6 @@ def _schema_document(
|
||||
root: str,
|
||||
*,
|
||||
stack: ForeachOwnerStack | None = None,
|
||||
active_scope: FrameScope = None,
|
||||
foreach_nodes: Mapping[str, ForeachNode] | None = None,
|
||||
owner_stack_by_node: Mapping[str, ForeachOwnerStack] | None = None,
|
||||
) -> Mapping[str, object]:
|
||||
@@ -433,13 +432,7 @@ def _schema_document(
|
||||
if root == "state":
|
||||
return workflow.state_schema.model_dump(mode="json", exclude_none=True)
|
||||
if root == "context":
|
||||
# Prefer the full owner stack when available; fall back to the legacy
|
||||
# single active scope for callers that have not migrated yet.
|
||||
resolved_stack: ForeachOwnerStack = ()
|
||||
if stack is not None:
|
||||
resolved_stack = stack
|
||||
elif active_scope is not None:
|
||||
resolved_stack = (active_scope,)
|
||||
resolved_stack: ForeachOwnerStack = stack or ()
|
||||
current: dict[str, object] = {
|
||||
field.name: field.schema for field in STANDARD_CONTEXT_FIELDS
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.models.workflow import Workflow
|
||||
from wf_core.run_state import (
|
||||
ROOT_FRAME_ID,
|
||||
ExecutionFrame,
|
||||
FrameStatus,
|
||||
RunState,
|
||||
@@ -12,6 +13,7 @@ from wf_core.run_state import (
|
||||
StepExecutionResult,
|
||||
TraceEntry,
|
||||
)
|
||||
from wf_core.runtime.ops.frames import frame_context_view
|
||||
from wf_core.runtime.ops.schemas import validate_payload_against_schema
|
||||
from wf_core.runtime.ops.state import project_output
|
||||
from wf_core.runtime.scheduler import (
|
||||
@@ -163,9 +165,6 @@ def finalize_run(workflow: Workflow, run: RunState) -> RunState:
|
||||
run.outcome = "ok"
|
||||
# Root workflow output keeps standard root facts consistent by projecting
|
||||
# against the root frame's derived context rather than an empty mapping.
|
||||
from wf_core.run_state import ROOT_FRAME_ID
|
||||
from wf_core.runtime.ops.frames import frame_context_view
|
||||
|
||||
root_frame = run.frames.get(ROOT_FRAME_ID)
|
||||
root_context: dict[str, Any] = (
|
||||
dict(frame_context_view(run, root_frame).graph)
|
||||
|
||||
@@ -74,7 +74,9 @@ class ForeachIterationMetadata:
|
||||
raise WorkflowExecutionError(
|
||||
f"malformed foreach activation id for frame {frame.id!r}"
|
||||
)
|
||||
if not isinstance(loop_index, int):
|
||||
# `bool` is an `int` subclass; an index of True/False is corrupt
|
||||
# persisted metadata, not item 1/0.
|
||||
if not isinstance(loop_index, int) or isinstance(loop_index, bool):
|
||||
raise WorkflowExecutionError(
|
||||
f"malformed foreach loop index for frame {frame.id!r}"
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ def validate_context_paths(
|
||||
*,
|
||||
context_schemas: Mapping[str, ContextSchema],
|
||||
report: ValidationReport,
|
||||
control_regions: ControlRegionAnalysis | None = None,
|
||||
) -> None:
|
||||
"""Validate every ``context.*`` path against its consuming location schema.
|
||||
|
||||
@@ -49,10 +50,14 @@ def validate_context_paths(
|
||||
only if every literal segment is a declared object property in the
|
||||
consuming node's generated schema. The whole ``context`` object and the
|
||||
``context.foreach`` map remain readable; unknown dynamic keys do not.
|
||||
The shared control-region analysis is threaded through so validation runs
|
||||
it once; alias ownership never triggers a second traversal.
|
||||
"""
|
||||
nodes_by_index = list(workflow.nodes)
|
||||
node_index_by_id = {node.id: idx for idx, node in enumerate(nodes_by_index)}
|
||||
_validate_alias_ownership(workflow, node_index_by_id, report)
|
||||
_validate_alias_ownership(
|
||||
workflow, node_index_by_id, report, control_regions=control_regions
|
||||
)
|
||||
for idx, node in enumerate(nodes_by_index):
|
||||
schema = context_schemas.get(node.id)
|
||||
if isinstance(node, NodeUse):
|
||||
@@ -177,14 +182,50 @@ def _validate_one_context_path(
|
||||
"no context schema for this program location",
|
||||
)
|
||||
return
|
||||
if not _path_in_schema(schema, path.parts):
|
||||
failing, available = _failing_segment(schema, path.parts)
|
||||
if failing is not None:
|
||||
listed = f" (available: {available})" if available else ""
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_CONTEXT_PATH,
|
||||
location,
|
||||
f"invalid context path {str(path)!r} at {node_id or location!r}",
|
||||
f"invalid context path {str(path)!r} at {node_id or location!r}: "
|
||||
f"unknown segment {failing!r}{listed}",
|
||||
)
|
||||
|
||||
|
||||
def _failing_segment(
|
||||
schema: Mapping[str, Any], parts: tuple[str, ...]
|
||||
) -> tuple[str | None, str]:
|
||||
"""Return the first unknown segment plus the keys available there.
|
||||
|
||||
Returns ``(None, "")`` when the path walks declared properties (or
|
||||
permissive unconstrained schemas). Diagnostics only; validity follows
|
||||
the same walk as :func:`_path_in_schema`.
|
||||
"""
|
||||
if not parts:
|
||||
return None, ""
|
||||
current: Any = schema
|
||||
for part in parts:
|
||||
if not isinstance(current, Mapping):
|
||||
return part, ""
|
||||
while isinstance(current.get("$ref"), str):
|
||||
return part, ""
|
||||
properties = current.get("properties")
|
||||
if not isinstance(properties, Mapping):
|
||||
if current == {}:
|
||||
return None, ""
|
||||
if (
|
||||
current.get("type") == "object"
|
||||
and current.get("additionalProperties", True) is not False
|
||||
):
|
||||
return None, ""
|
||||
return part, ""
|
||||
if part not in properties:
|
||||
return part, ",".join(sorted(str(key) for key in properties))
|
||||
current = properties[part]
|
||||
return None, ""
|
||||
|
||||
|
||||
def _path_in_schema(schema: Mapping[str, Any], parts: tuple[str, ...]) -> bool:
|
||||
"""Return whether literal parts walk declared object properties.
|
||||
|
||||
@@ -251,6 +292,8 @@ def _validate_alias_ownership(
|
||||
workflow: Workflow,
|
||||
node_index_by_id: dict[str, int],
|
||||
report: ValidationReport,
|
||||
*,
|
||||
control_regions: ControlRegionAnalysis | None = None,
|
||||
) -> None:
|
||||
"""Reject reserved or colliding active foreach aliases.
|
||||
|
||||
@@ -258,11 +301,14 @@ def _validate_alias_ownership(
|
||||
``loop_item``, and ``loop_index`` (that is, ``RESERVED_CONTEXT_KEYS``).
|
||||
Siblings in separate control regions may reuse an alias because they are
|
||||
never active together; only aliases active in the same owner stack
|
||||
collide. Failures point at the inner foreach's ``as`` field.
|
||||
collide. Failures point at the inner foreach's ``as`` field. The shared
|
||||
control-region analysis is reused; this helper never traverses alone.
|
||||
"""
|
||||
from wf_core.analysis.control_regions import analyze_control_regions
|
||||
if control_regions is None:
|
||||
from wf_core.analysis.control_regions import analyze_control_regions
|
||||
|
||||
analysis: ControlRegionAnalysis = analyze_control_regions(workflow)
|
||||
control_regions = analyze_control_regions(workflow)
|
||||
analysis = control_regions
|
||||
foreach_by_id = {
|
||||
node.id: node for node in workflow.nodes if isinstance(node, ForeachNode)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,12 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
|
||||
issue.message,
|
||||
)
|
||||
context_schemas = context_schemas_by_node(workflow, control_regions=analysis)
|
||||
validate_context_paths(workflow, context_schemas=context_schemas, report=report)
|
||||
validate_context_paths(
|
||||
workflow,
|
||||
context_schemas=context_schemas,
|
||||
report=report,
|
||||
control_regions=analysis,
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
@@ -329,16 +329,17 @@ def validate_foreach_node(
|
||||
input_root_fields: set[str],
|
||||
workflow: Workflow,
|
||||
) -> None:
|
||||
# Interim permissive gate: context-rooted `over` paths reach runtime, where
|
||||
# structured ancestry resolution handles them. Task 5 replaces this with
|
||||
# location-aware validation against the consuming node's context schema.
|
||||
# Context-rooted `over` paths pass this coarse gate and reach the
|
||||
# location-aware `validate_context_paths` pass, which checks them against
|
||||
# the consuming controller's structured context schema.
|
||||
if not is_valid_source_path(
|
||||
node.over, state_root_fields, input_root_fields, allow_context=True
|
||||
):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_FOREACH_SOURCE,
|
||||
f"nodes[{index}].over",
|
||||
"foreach source path must start with input. or state. and reference a declared root field",
|
||||
"foreach source path must start with input., state., or context. "
|
||||
"and reference a declared root field when applicable",
|
||||
)
|
||||
if node.item_error.action != "collect":
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user