feat: inspect node runtime context
This commit is contained in:
@@ -4,6 +4,13 @@ from collections.abc import Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from wf_core.analysis.context_scopes import (
|
||||
ContextFieldAvailability,
|
||||
context_analysis_warnings,
|
||||
context_fields_by_node,
|
||||
)
|
||||
from wf_core.models.workflow import Workflow
|
||||
|
||||
from .models.authoring_contracts import (
|
||||
AuthoringContractInventoryPayload,
|
||||
AuthoringPathOptionPayload,
|
||||
@@ -73,6 +80,7 @@ def project_authoring_contract_inventory(
|
||||
entry_steps: Sequence[AuthoringStepContractPayload] = (),
|
||||
workflow_outcomes: Sequence[str] = (),
|
||||
warnings: Sequence[str] = (),
|
||||
workflow: Workflow | None = None,
|
||||
) -> AuthoringContractInventoryPayload:
|
||||
"""Compose an inventory from caller-provided schemas and graph facts.
|
||||
|
||||
@@ -80,6 +88,10 @@ def project_authoring_contract_inventory(
|
||||
service layer supplies the selected-step and runtime-context projections;
|
||||
this function only derives schema choices and copies those projections.
|
||||
"""
|
||||
if workflow is not None and selected_step_id is not None and not context_entries:
|
||||
context_entries = context_path_options_for_node(workflow, selected_step_id)
|
||||
warnings = [*warnings, *context_analysis_warnings(workflow)]
|
||||
|
||||
input_sources = schema_path_options(
|
||||
input_schema,
|
||||
root="input",
|
||||
@@ -107,7 +119,7 @@ def project_authoring_contract_inventory(
|
||||
"selected_step_id": selected_step_id,
|
||||
"readable_sources": [
|
||||
*input_sources,
|
||||
*deepcopy(list(context_entries)),
|
||||
*_context_entries_for_inventory(context_entries),
|
||||
*state_sources,
|
||||
],
|
||||
"step_input_targets": deepcopy(list(step_input_targets)),
|
||||
@@ -120,6 +132,76 @@ def project_authoring_contract_inventory(
|
||||
}
|
||||
|
||||
|
||||
def context_path_options(
|
||||
fields: Sequence[ContextFieldAvailability | Mapping[str, Any]],
|
||||
) -> list[AuthoringPathOptionPayload]:
|
||||
"""Project analyzed runtime context fields into Task 1 path payloads."""
|
||||
options: list[AuthoringPathOptionPayload] = []
|
||||
for field in fields:
|
||||
if isinstance(field, ContextFieldAvailability):
|
||||
name = field.name
|
||||
schema = field.schema
|
||||
description = field.description
|
||||
availability = field.availability
|
||||
reason = field.reason
|
||||
else:
|
||||
raw_name = field.get("name")
|
||||
if not isinstance(raw_name, str) or not raw_name:
|
||||
continue
|
||||
name = raw_name
|
||||
raw_schema = field.get("schema")
|
||||
schema = raw_schema if isinstance(raw_schema, Mapping) else {}
|
||||
raw_description = field.get("description")
|
||||
description = raw_description if isinstance(raw_description, str) else name
|
||||
raw_availability = field.get("availability")
|
||||
availability = (
|
||||
raw_availability
|
||||
if raw_availability in {"available", "conditional"}
|
||||
else "available"
|
||||
)
|
||||
raw_reason = field.get("reason")
|
||||
reason = raw_reason if isinstance(raw_reason, str) else None
|
||||
|
||||
option: AuthoringPathOptionPayload = {
|
||||
"path": f"context.{name}",
|
||||
"label": name.replace("_", " ").replace("-", " ").title(),
|
||||
"origin": "runtime_context",
|
||||
"schema": deepcopy(dict(schema)),
|
||||
"required": False,
|
||||
"availability": availability,
|
||||
"uses": ["step_input"],
|
||||
}
|
||||
if description:
|
||||
option["description"] = description
|
||||
if reason is not None:
|
||||
option["reason"] = reason
|
||||
options.append(option)
|
||||
return options
|
||||
|
||||
|
||||
def context_path_options_for_node(
|
||||
workflow: Workflow,
|
||||
node_id: str,
|
||||
) -> list[AuthoringPathOptionPayload]:
|
||||
"""Project the runtime context available at one workflow node."""
|
||||
return context_path_options(context_fields_by_node(workflow).get(node_id, ()))
|
||||
|
||||
|
||||
def _context_entries_for_inventory(
|
||||
entries: Sequence[AuthoringPathOptionPayload],
|
||||
) -> list[AuthoringPathOptionPayload]:
|
||||
"""Keep runtime context readable only where execution has frame context."""
|
||||
result: list[AuthoringPathOptionPayload] = []
|
||||
for entry in entries:
|
||||
copied = deepcopy(entry)
|
||||
if copied["path"].startswith("context."):
|
||||
copied["uses"] = [use for use in copied["uses"] if use == "step_input"]
|
||||
if not copied["uses"]:
|
||||
continue
|
||||
result.append(copied)
|
||||
return result
|
||||
|
||||
|
||||
def _append_schema_options(
|
||||
schema: JsonObject,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Static analyses over workflow graph contracts."""
|
||||
|
||||
from .context_scopes import (
|
||||
ContextFieldAvailability,
|
||||
context_analysis_warnings,
|
||||
context_fields_by_node,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ContextFieldAvailability",
|
||||
"context_analysis_warnings",
|
||||
"context_fields_by_node",
|
||||
]
|
||||
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from wf_core.context_contracts import (
|
||||
STANDARD_CONTEXT_FIELDS,
|
||||
ContextFieldContract,
|
||||
ContextSchema,
|
||||
foreach_context_fields,
|
||||
)
|
||||
from wf_core.models.steps import ForeachNode
|
||||
from wf_core.models.workflow import Edge, Workflow
|
||||
from wf_core.tokens import END
|
||||
|
||||
type ContextAvailability = Literal["available", "conditional"]
|
||||
type FrameScope = str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContextFieldAvailability:
|
||||
"""One context contract plus whether it is guaranteed at a graph node."""
|
||||
|
||||
contract: ContextFieldContract
|
||||
availability: ContextAvailability
|
||||
reason: str | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.contract.name
|
||||
|
||||
@property
|
||||
def schema(self) -> ContextSchema:
|
||||
return self.contract.schema
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.contract.description
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ContextAnalysis:
|
||||
fields_by_node: dict[str, tuple[ContextFieldAvailability, ...]]
|
||||
warnings: tuple[str, ...]
|
||||
|
||||
|
||||
class _Warnings:
|
||||
def __init__(self) -> None:
|
||||
self.values: list[str] = []
|
||||
self.seen: set[str] = set()
|
||||
|
||||
def add(self, value: str) -> None:
|
||||
if value not in self.seen:
|
||||
self.seen.add(value)
|
||||
self.values.append(value)
|
||||
|
||||
|
||||
def context_fields_by_node(
|
||||
workflow: Workflow,
|
||||
) -> dict[str, tuple[ContextFieldAvailability, ...]]:
|
||||
"""Return runtime context contracts for every reachable graph node.
|
||||
|
||||
This is an abstract execution-frame analysis rather than ordinary graph
|
||||
reachability: the same node can execute in the root frame and in a
|
||||
foreach child frame, and those frames expose different context keys.
|
||||
The traversal memoizes both node id and active frame scope so cyclic
|
||||
graphs terminate without granting aliases from an impossible scope.
|
||||
"""
|
||||
return _analyze(workflow).fields_by_node
|
||||
|
||||
|
||||
def context_analysis_warnings(workflow: Workflow) -> tuple[str, ...]:
|
||||
"""Return bounded warnings found while analyzing workflow frame scopes."""
|
||||
return _analyze(workflow).warnings
|
||||
|
||||
|
||||
def _analyze(workflow: Workflow) -> _ContextAnalysis:
|
||||
nodes = {node.id: node for node in workflow.nodes}
|
||||
foreach_nodes = {
|
||||
node.id: node for node in workflow.nodes if isinstance(node, ForeachNode)
|
||||
}
|
||||
edges_by_node: dict[str, list[Edge]] = {}
|
||||
warnings = _Warnings()
|
||||
|
||||
for edge in workflow.edges:
|
||||
edges_by_node.setdefault(edge.from_, []).append(edge)
|
||||
if edge.from_ not in nodes:
|
||||
warnings.add(f"edge source {edge.from_!r} is not a workflow node")
|
||||
if edge.to != END and edge.to not in nodes:
|
||||
warnings.add(f"edge from {edge.from_!r} targets missing node {edge.to!r}")
|
||||
|
||||
for foreach in foreach_nodes.values():
|
||||
if not any(
|
||||
edge.outcome == "loop" for edge in edges_by_node.get(foreach.id, [])
|
||||
):
|
||||
warnings.add(
|
||||
f"foreach node {foreach.id!r} has no loop route; "
|
||||
"no scoped alias is guaranteed"
|
||||
)
|
||||
|
||||
if workflow.start not in nodes:
|
||||
warnings.add(f"workflow start targets missing node {workflow.start!r}")
|
||||
return _ContextAnalysis({}, tuple(warnings.values))
|
||||
|
||||
scopes_by_node: dict[str, set[FrameScope]] = {}
|
||||
pending: deque[tuple[str, FrameScope]] = deque([(workflow.start, None)])
|
||||
visited: set[tuple[str, FrameScope]] = set()
|
||||
while pending:
|
||||
node_id, active_scope = pending.popleft()
|
||||
state = (node_id, active_scope)
|
||||
if state in visited:
|
||||
continue
|
||||
visited.add(state)
|
||||
node = nodes.get(node_id)
|
||||
if node is None:
|
||||
continue
|
||||
scopes_by_node.setdefault(node_id, set()).add(active_scope)
|
||||
|
||||
for edge in edges_by_node.get(node_id, []):
|
||||
if edge.to == END or edge.to not in nodes:
|
||||
continue
|
||||
next_scope = active_scope
|
||||
if isinstance(node, ForeachNode) and edge.outcome == "loop":
|
||||
next_scope = node.id
|
||||
pending.append((edge.to, next_scope))
|
||||
|
||||
fields_by_node: dict[str, tuple[ContextFieldAvailability, ...]] = {}
|
||||
for node_id, scopes in scopes_by_node.items():
|
||||
fields_by_node[node_id] = _available_fields(
|
||||
workflow,
|
||||
foreach_nodes,
|
||||
node_id,
|
||||
scopes,
|
||||
)
|
||||
return _ContextAnalysis(fields_by_node, tuple(warnings.values))
|
||||
|
||||
|
||||
def _available_fields(
|
||||
workflow: Workflow,
|
||||
foreach_nodes: Mapping[str, ForeachNode],
|
||||
node_id: str,
|
||||
scopes: set[FrameScope],
|
||||
) -> tuple[ContextFieldAvailability, ...]:
|
||||
del node_id
|
||||
fields_by_name: dict[str, ContextFieldContract] = {}
|
||||
scopes_by_field: dict[str, set[FrameScope]] = {}
|
||||
for scope in sorted(scopes, key=lambda value: value or ""):
|
||||
contracts = STANDARD_CONTEXT_FIELDS
|
||||
if scope is not None:
|
||||
foreach = foreach_nodes.get(scope)
|
||||
if foreach is not None:
|
||||
contracts = (
|
||||
*contracts,
|
||||
*foreach_context_fields(
|
||||
foreach.as_,
|
||||
_foreach_item_schema(workflow, foreach, scope, foreach_nodes),
|
||||
),
|
||||
)
|
||||
for contract in contracts:
|
||||
fields_by_name.setdefault(
|
||||
contract.name,
|
||||
ContextFieldContract(
|
||||
contract.name,
|
||||
deepcopy(contract.schema),
|
||||
contract.description,
|
||||
),
|
||||
)
|
||||
scopes_by_field.setdefault(contract.name, set()).add(scope)
|
||||
|
||||
field_count = len(scopes)
|
||||
result: list[ContextFieldAvailability] = []
|
||||
for contract in fields_by_name.values():
|
||||
field_scopes = scopes_by_field[contract.name]
|
||||
availability: ContextAvailability = (
|
||||
"available" if len(field_scopes) == field_count else "conditional"
|
||||
)
|
||||
reason = None
|
||||
if availability == "conditional":
|
||||
reason = "Available only in some reachable execution frames."
|
||||
result.append(
|
||||
ContextFieldAvailability(
|
||||
contract=contract,
|
||||
availability=availability,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _foreach_item_schema(
|
||||
workflow: Workflow,
|
||||
foreach: ForeachNode,
|
||||
active_scope: FrameScope,
|
||||
foreach_nodes: Mapping[str, ForeachNode],
|
||||
) -> ContextSchema:
|
||||
source_schema = _schema_at_path(
|
||||
workflow, foreach.over.root, foreach.over.parts, active_scope, foreach_nodes
|
||||
)
|
||||
if not isinstance(source_schema, Mapping):
|
||||
return {}
|
||||
source_type = source_schema.get("type")
|
||||
is_array = source_type == "array" or (
|
||||
isinstance(source_type, list) and "array" in source_type
|
||||
)
|
||||
items = source_schema.get("items")
|
||||
return deepcopy(dict(items)) if is_array and isinstance(items, Mapping) else {}
|
||||
|
||||
|
||||
def _schema_at_path(
|
||||
workflow: Workflow,
|
||||
root: str,
|
||||
parts: tuple[str, ...],
|
||||
active_scope: FrameScope,
|
||||
foreach_nodes: Mapping[str, ForeachNode],
|
||||
) -> Mapping[str, object] | None:
|
||||
if root == "input":
|
||||
current: object = workflow.input_schema.model_dump(
|
||||
mode="json", exclude_none=True
|
||||
)
|
||||
elif root == "state":
|
||||
current = workflow.state_schema.model_dump(mode="json", exclude_none=True)
|
||||
elif root == "context":
|
||||
current = {field.name: field.schema for field in STANDARD_CONTEXT_FIELDS}
|
||||
if active_scope is not None:
|
||||
foreach = foreach_nodes.get(active_scope)
|
||||
if foreach is not None:
|
||||
current.update(
|
||||
{
|
||||
field.name: field.schema
|
||||
for field in foreach_context_fields(
|
||||
foreach.as_,
|
||||
_foreach_item_schema(
|
||||
workflow,
|
||||
foreach,
|
||||
None,
|
||||
foreach_nodes,
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
for part in parts:
|
||||
if not isinstance(current, Mapping):
|
||||
return None
|
||||
properties = current.get("properties")
|
||||
if not isinstance(properties, Mapping):
|
||||
return None
|
||||
current = properties.get(part)
|
||||
return current if isinstance(current, Mapping) else None
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
type ContextSchema = dict[str, Any]
|
||||
|
||||
PRIOR_OUTCOME_CONTEXT_KEY = "prior_outcome"
|
||||
ACTIVATED_INCOMING_EDGE_CONTEXT_KEY = "activated_incoming_edge"
|
||||
SCOPE_ID_CONTEXT_KEY = "scope_id"
|
||||
LINEAGE_ID_CONTEXT_KEY = "lineage_id"
|
||||
PARENT_LINEAGE_ID_CONTEXT_KEY = "parent_lineage_id"
|
||||
LOOP_ITEM_CONTEXT_KEY = "loop_item"
|
||||
LOOP_INDEX_CONTEXT_KEY = "loop_index"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContextFieldContract:
|
||||
"""Semantic contract for one key exposed by a runtime execution frame."""
|
||||
|
||||
name: str
|
||||
schema: ContextSchema
|
||||
description: str
|
||||
|
||||
|
||||
STANDARD_CONTEXT_FIELDS = (
|
||||
ContextFieldContract(
|
||||
PRIOR_OUTCOME_CONTEXT_KEY,
|
||||
{"type": ["string", "null"]},
|
||||
"Prior route outcome",
|
||||
),
|
||||
ContextFieldContract(
|
||||
ACTIVATED_INCOMING_EDGE_CONTEXT_KEY,
|
||||
{"type": ["string", "null"]},
|
||||
"Incoming step id",
|
||||
),
|
||||
ContextFieldContract(
|
||||
SCOPE_ID_CONTEXT_KEY,
|
||||
{"type": "string"},
|
||||
"Execution scope id",
|
||||
),
|
||||
ContextFieldContract(
|
||||
LINEAGE_ID_CONTEXT_KEY,
|
||||
{"type": "string"},
|
||||
"Execution lineage id",
|
||||
),
|
||||
ContextFieldContract(
|
||||
PARENT_LINEAGE_ID_CONTEXT_KEY,
|
||||
{"type": ["string", "null"]},
|
||||
"Parent lineage id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def foreach_context_fields(
|
||||
alias: str,
|
||||
item_schema: ContextSchema,
|
||||
) -> tuple[ContextFieldContract, ...]:
|
||||
"""Return the iteration keys, including a configured item alias once."""
|
||||
item_contract = ContextFieldContract(
|
||||
LOOP_ITEM_CONTEXT_KEY,
|
||||
deepcopy(item_schema),
|
||||
"Current foreach item",
|
||||
)
|
||||
index_contract = ContextFieldContract(
|
||||
LOOP_INDEX_CONTEXT_KEY,
|
||||
{"type": "integer"},
|
||||
"Current foreach item index",
|
||||
)
|
||||
fields = [item_contract, index_contract]
|
||||
if alias and alias not in {LOOP_ITEM_CONTEXT_KEY, LOOP_INDEX_CONTEXT_KEY}:
|
||||
fields.append(
|
||||
ContextFieldContract(
|
||||
alias,
|
||||
deepcopy(item_schema),
|
||||
"Current foreach item",
|
||||
)
|
||||
)
|
||||
return tuple(fields)
|
||||
@@ -1,22 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.context_contracts import (
|
||||
ACTIVATED_INCOMING_EDGE_CONTEXT_KEY,
|
||||
LINEAGE_ID_CONTEXT_KEY,
|
||||
LOOP_INDEX_CONTEXT_KEY,
|
||||
LOOP_ITEM_CONTEXT_KEY,
|
||||
PARENT_LINEAGE_ID_CONTEXT_KEY,
|
||||
PRIOR_OUTCOME_CONTEXT_KEY,
|
||||
SCOPE_ID_CONTEXT_KEY,
|
||||
)
|
||||
from wf_core.run_state import ExecutionFrame
|
||||
|
||||
|
||||
def frame_context_values(frame: ExecutionFrame) -> dict[str, object | None]:
|
||||
context: dict[str, object | None] = {
|
||||
"prior_outcome": frame.prior_outcome,
|
||||
"activated_incoming_edge": frame.activated_incoming_edge,
|
||||
"scope_id": frame.scope_id,
|
||||
"lineage_id": frame.lineage_id,
|
||||
"parent_lineage_id": frame.parent_lineage_id,
|
||||
PRIOR_OUTCOME_CONTEXT_KEY: frame.prior_outcome,
|
||||
ACTIVATED_INCOMING_EDGE_CONTEXT_KEY: frame.activated_incoming_edge,
|
||||
SCOPE_ID_CONTEXT_KEY: frame.scope_id,
|
||||
LINEAGE_ID_CONTEXT_KEY: frame.lineage_id,
|
||||
PARENT_LINEAGE_ID_CONTEXT_KEY: frame.parent_lineage_id,
|
||||
}
|
||||
if frame.kind == "foreach_iteration":
|
||||
loop_item = frame.metadata.get("loop_item")
|
||||
loop_index = frame.metadata.get("loop_index")
|
||||
loop_alias = frame.metadata.get("loop_alias")
|
||||
context["loop_item"] = loop_item
|
||||
context["loop_index"] = loop_index
|
||||
context[LOOP_ITEM_CONTEXT_KEY] = loop_item
|
||||
context[LOOP_INDEX_CONTEXT_KEY] = loop_index
|
||||
if isinstance(loop_alias, str) and loop_alias:
|
||||
context[loop_alias] = loop_item
|
||||
return context
|
||||
|
||||
Reference in New Issue
Block a user