chore: drop dead context compat, retire plan boxes, fix spec wording

This commit is contained in:
lda
2026-09-05 00:35:00 +07:00 Verified
parent 6e6a4f943d
commit 688fccecc8
6 changed files with 112 additions and 149 deletions
@@ -20,7 +20,7 @@ sugar.
JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2. JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
**Spec:** **Spec:**
[`docs/superpowers/specs/2026-09-04-structured-runtime-context-design.md`](../specs/2026-09-04-structured-runtime-context-design.md) [`docs/superpowers/specs/2026-09-04-structured-runtime-context-design.md`](../../superpowers/specs/2026-09-04-structured-runtime-context-design.md)
## Global Constraints ## Global Constraints
@@ -100,7 +100,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
- `FrameContextView.graph` exposes a JSON-compatible `foreach` mapping, all - `FrameContextView.graph` exposes a JSON-compatible `foreach` mapping, all
unique active aliases, and innermost `loop_item`/`loop_index` values. unique active aliases, and innermost `loop_item`/`loop_index` values.
- [ ] **Step 1: Write failing model and ancestry tests** - [x] **Step 1: Write failing model and ancestry tests**
Add focused helpers that construct a `RunState` with explicit frames, then Add focused helpers that construct a `RunState` with explicit frames, then
add tests named: add tests named:
@@ -133,7 +133,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
For the scope-boundary case, give the child root a scheduling parent in the For the scope-boundary case, give the child root a scheduling parent in the
caller's foreach frame and assert that the child context remains `{}`. caller's foreach frame and assert that the child context remains `{}`.
- [ ] **Step 2: Run the focused tests and confirm the missing API fails** - [x] **Step 2: Run the focused tests and confirm the missing API fails**
Run: Run:
@@ -144,7 +144,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: collection or assertions fail because `ForeachContext` and the Expected: collection or assertions fail because `ForeachContext` and the
ancestry-aware helper do not exist. ancestry-aware helper do not exist.
- [ ] **Step 3: Add the typed context value and validate frame metadata once** - [x] **Step 3: Add the typed context value and validate frame metadata once**
Add `ForeachContext` beside `RuntimeContext` and export it through Add `ForeachContext` beside `RuntimeContext` and export it through
`wf_core.__init__`. Keep `ForeachIterationMetadata` as the typed decoder for `wf_core.__init__`. Keep `ForeachIterationMetadata` as the typed decoder for
@@ -164,7 +164,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
) )
``` ```
- [ ] **Step 4: Implement fail-closed same-scope ancestry traversal** - [x] **Step 4: Implement fail-closed same-scope ancestry traversal**
Walk from the selected frame through `parent_frame_id` while scope ids match. Walk from the selected frame through `parent_frame_id` while scope ids match.
Validate the full chain before returning materialized values: Validate the full chain before returning materialized values:
@@ -226,7 +226,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
graph[LOOP_INDEX_CONTEXT_KEY] = innermost.index graph[LOOP_INDEX_CONTEXT_KEY] = innermost.index
``` ```
- [ ] **Step 5: Add corruption and collision regressions** - [x] **Step 5: Add corruption and collision regressions**
Add: Add:
@@ -241,7 +241,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
`run.to_dict()` before the read-only test and assert it remains equal after `run.to_dict()` before the read-only test and assert it remains equal after
deriving context. deriving context.
- [ ] **Step 6: Run and commit the focused model slice** - [x] **Step 6: Run and commit the focused model slice**
Run: Run:
@@ -285,7 +285,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
- `RuntimeContext.metadata` remains a defensive copy of the selected frame's - `RuntimeContext.metadata` remains a defensive copy of the selected frame's
metadata; `RuntimeContext.foreach` is the canonical typed view. metadata; `RuntimeContext.foreach` is the canonical typed view.
- [ ] **Step 1: Write failing end-to-end runtime tests** - [x] **Step 1: Write failing end-to-end runtime tests**
Extend `test_structured_runtime_context.py` with: Extend `test_structured_runtime_context.py` with:
@@ -317,7 +317,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Build the binding test with canonical `InputPathBinding` values for Build the binding test with canonical `InputPathBinding` values for
`context.foreach.customers.item` and `context.foreach.orders.item`. `context.foreach.customers.item` and `context.foreach.orders.item`.
- [ ] **Step 2: Run the end-to-end tests and observe innermost-only behavior** - [x] **Step 2: Run the end-to-end tests and observe innermost-only behavior**
Run: Run:
@@ -329,7 +329,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: the new end-to-end tests fail because runtime consumers still use Expected: the new end-to-end tests fail because runtime consumers still use
innermost frame-only context and handlers do not receive `.foreach`. innermost frame-only context and handlers do not receive `.foreach`.
- [ ] **Step 3: Update all graph-visible context call sites together** - [x] **Step 3: Update all graph-visible context call sites together**
Replace every old call and verify with search: Replace every old call and verify with search:
@@ -349,7 +349,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Root workflow output receives `frame_context_view(run, root_frame).graph` Root workflow output receives `frame_context_view(run, root_frame).graph`
rather than an omitted context so standard root facts remain consistent. rather than an omitted context so standard root facts remain consistent.
- [ ] **Step 4: Give Python handlers the same typed projection** - [x] **Step 4: Give Python handlers the same typed projection**
In `_resolve_node_execution`, materialize the view once and pass its two In `_resolve_node_execution`, materialize the view once and pass its two
projections to their consumers: projections to their consumers:
@@ -374,14 +374,14 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Resolve graph bindings against `context_view.graph`. Do not reconstruct Resolve graph bindings against `context_view.graph`. Do not reconstruct
`ForeachContext` from the graph-visible dictionary. `ForeachContext` from the graph-visible dictionary.
- [ ] **Step 5: Update old focused tests to pass `RunState` explicitly** - [x] **Step 5: Update old focused tests to pass `RunState` explicitly**
Replace direct frame-only context calls in scheduler/context tests with a run Replace direct frame-only context calls in scheduler/context tests with a run
containing that frame. Keep assertions for existing standard and containing that frame. Keep assertions for existing standard and
compatibility keys; add structured assertions rather than deleting old compatibility keys; add structured assertions rather than deleting old
coverage. coverage.
- [ ] **Step 6: Run and commit the runtime integration slice** - [x] **Step 6: Run and commit the runtime integration slice**
Run: Run:
@@ -433,7 +433,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
`GraphSourcePath.context(self.id)` because that helper parses dots as path `GraphSourcePath.context(self.id)` because that helper parses dots as path
separators. separators.
- [ ] **Step 1: Write failing path and serialization tests** - [x] **Step 1: Write failing path and serialization tests**
Add: Add:
@@ -455,7 +455,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
assert "index" not in each.model_dump(mode="json") assert "index" not in each.model_dump(mode="json")
``` ```
- [ ] **Step 2: Write failing node and subgraph binding tests** - [x] **Step 2: Write failing node and subgraph binding tests**
Use the computed ref directly in both authoring boundaries: Use the computed ref directly in both authoring boundaries:
@@ -473,12 +473,12 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Assert both compiled bindings serialize their path as Assert both compiled bindings serialize their path as
`context.foreach.orders.item`. `context.foreach.orders.item`.
- [ ] **Step 3: Implement only the two computed properties** - [x] **Step 3: Implement only the two computed properties**
Add the properties directly to `ForeachNode`. Do not create `ForeachRef`, a Add the properties directly to `ForeachNode`. Do not create `ForeachRef`, a
node-address type, or field-selection sugar beneath `.item`. node-address type, or field-selection sugar beneath `.item`.
- [ ] **Step 4: Run and commit the authoring slice** - [x] **Step 4: Run and commit the authoring slice**
Run: Run:
@@ -596,7 +596,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Keep entry schemas inline unless a measured schema-size problem requires Keep entry schemas inline unless a measured schema-size problem requires
`$defs`; the observable field types and required paths are contractual. `$defs`; the observable field types and required paths are contractual.
- [ ] **Step 1: Replace innermost-only tests with full-stack expectations** - [x] **Step 1: Replace innermost-only tests with full-stack expectations**
Update the existing nested test instead of adding contradictory coverage: Update the existing nested test instead of adding contradictory coverage:
@@ -617,7 +617,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Add `test_inner_completion_schema_restores_outer_structured_entry` and assert Add `test_inner_completion_schema_restores_outer_structured_entry` and assert
`after_inner` contains only the outer structured entry. `after_inner` contains only the outer structured entry.
- [ ] **Step 2: Run static analysis tests and confirm the old projection fails** - [x] **Step 2: Run static analysis tests and confirm the old projection fails**
Run: Run:
@@ -628,7 +628,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: nested assertions fail because `_available_fields` uses only Expected: nested assertions fail because `_available_fields` uses only
`stack[-1]`. `stack[-1]`.
- [ ] **Step 3: Build contracts from the whole owner stack** - [x] **Step 3: Build contracts from the whole owner stack**
Change `_available_fields` to accept the complete `ForeachOwnerStack`. For Change `_available_fields` to accept the complete `ForeachOwnerStack`. For
every owner id, infer its item schema in the controller's own outer region, every owner id, infer its item schema in the controller's own outer region,
@@ -643,7 +643,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
resolve `over=context.foreach.outer.item.children` without claiming its own resolve `over=context.foreach.outer.item.children` without claiming its own
not-yet-active entry. not-yet-active entry.
- [ ] **Step 4: Keep context schema construction reusable and bounded** - [x] **Step 4: Keep context schema construction reusable and bounded**
Implement `context_schemas_by_node` by composing the returned contracts, not Implement `context_schemas_by_node` by composing the returned contracts, not
by running a second graph traversal. `context_schema_for_node` is the by running a second graph traversal. `context_schema_for_node` is the
@@ -652,7 +652,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
generated per-node schema; callers treat that absence as invalid, not as generated per-node schema; callers treat that absence as invalid, not as
root context. root context.
- [ ] **Step 5: Expose structured paths through authoring inventory** - [x] **Step 5: Expose structured paths through authoring inventory**
Add tests showing `context_path_options_for_node(workflow, "inner_body")` Add tests showing `context_path_options_for_node(workflow, "inner_body")`
includes at least: includes at least:
@@ -671,7 +671,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
quoting. Do not hand-concatenate a dotted foreach id; format literal segments quoting. Do not hand-concatenate a dotted foreach id; format literal segments
through `GraphSourcePath`. through `GraphSourcePath`.
- [ ] **Step 6: Run and commit the static projection slice** - [x] **Step 6: Run and commit the static projection slice**
Run: Run:
@@ -730,7 +730,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
- Ordinary input/state validation remains where it is. The new pass owns the - Ordinary input/state validation remains where it is. The new pass owns the
stronger, program-location-aware meaning of `context.*`. stronger, program-location-aware meaning of `context.*`.
- [ ] **Step 1: Write failing context-path validation tests** - [x] **Step 1: Write failing context-path validation tests**
Add: Add:
@@ -755,7 +755,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
assert "work" in issue.message assert "work" in issue.message
``` ```
- [ ] **Step 2: Cover every model surface that can contain a graph path** - [x] **Step 2: Cover every model surface that can contain a graph path**
Parameterize invalid `context.foreach.missing.item` references through: Parameterize invalid `context.foreach.missing.item` references through:
@@ -770,7 +770,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
prevents a future path-bearing model from accidentally retaining the current prevents a future path-bearing model from accidentally retaining the current
permissive `allow_context=True` behavior. permissive `allow_context=True` behavior.
- [ ] **Step 3: Add one bounded structural path walker** - [x] **Step 3: Add one bounded structural path walker**
In `validation/context_paths.py`, use small typed walkers for conditions and In `validation/context_paths.py`, use small typed walkers for conditions and
input expressions: input expressions:
@@ -797,7 +797,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
for every step kind; do not duplicate context validation in each existing for every step kind; do not duplicate context validation in each existing
step validator. step validator.
- [ ] **Step 4: Validate context paths against the consuming location** - [x] **Step 4: Validate context paths against the consuming location**
For paths whose root is `context`, walk their literal `parts` through the For paths whose root is `context`, walk their literal `parts` through the
consuming node's generated schema. A path is valid only if every segment is consuming node's generated schema. A path is valid only if every segment is
@@ -811,7 +811,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Change `validate_foreach_node` so context-rooted `over` paths reach this pass Change `validate_foreach_node` so context-rooted `over` paths reach this pass
instead of being rejected by the old input/state-only check. instead of being rejected by the old input/state-only check.
- [ ] **Step 5: Write failing alias-collision tests** - [x] **Step 5: Write failing alias-collision tests**
Add: Add:
@@ -823,7 +823,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
and `loop_index`. The nested failure points to the inner foreach's `as` and `loop_index`. The nested failure points to the inner foreach's `as`
field. Siblings in separate control regions may reuse an alias. field. Siblings in separate control regions may reuse an alias.
- [ ] **Step 6: Share control-region analysis during validation** - [x] **Step 6: Share control-region analysis during validation**
In `validate_workflow`, run `analyze_control_regions(workflow)` once. Feed the In `validate_workflow`, run `analyze_control_regions(workflow)` once. Feed the
result to context schema construction, translate its issues as today, then result to context schema construction, translate its issues as today, then
@@ -831,7 +831,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
`context_fields_by_node`; add an optional internal `control_regions=` input if `context_fields_by_node`; add an optional internal `control_regions=` input if
necessary while keeping the existing public call form valid. necessary while keeping the existing public call form valid.
- [ ] **Step 7: Run and commit the validation slice** - [x] **Step 7: Run and commit the validation slice**
Run: Run:
@@ -879,7 +879,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
`foreach_ref.item`, while documenting `RuntimeContext.foreach` as the advanced `foreach_ref.item`, while documenting `RuntimeContext.foreach` as the advanced
handler escape hatch. handler escape hatch.
- [ ] **Step 1: Write the interrupt-resume identity regression** - [x] **Step 1: Write the interrupt-resume identity regression**
Build `outer.loop -> inner.loop -> ask -> inner -> outer`, interrupt one Build `outer.loop -> inner.loop -> ask -> inner -> outer`, interrupt one
inner item, serialize it with `dump_run_state`, restore it with inner item, serialize it with `dump_run_state`, restore it with
@@ -899,7 +899,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Do not reuse the original in-memory `RunState`; the loaded value is the Do not reuse the original in-memory `RunState`; the loaded value is the
resume input so reconstruction is genuinely tested. resume input so reconstruction is genuinely tested.
- [ ] **Step 2: Write the complete subgraph scope-boundary test** - [x] **Step 2: Write the complete subgraph scope-boundary test**
Build a parent foreach and map `each.item` into a saved child subgraph's Build a parent foreach and map `each.item` into a saved child subgraph's
declared input. Give both parent and child a foreach node with id `orders`. declared input. Give both parent and child a foreach node with id `orders`.
@@ -916,7 +916,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
`child_ctx.foreach == {}`. This proves the caller entry was not inherited and `child_ctx.foreach == {}`. This proves the caller entry was not inherited and
the reused static id does not collide. the reused static id does not collide.
- [ ] **Step 3: Run the persistence and scope pressure tests** - [x] **Step 3: Run the persistence and scope pressure tests**
Run: Run:
@@ -927,7 +927,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: all commands pass. Expected: all commands pass.
- [ ] **Step 4: Document the preferred authoring and advanced Python forms** - [x] **Step 4: Document the preferred authoring and advanced Python forms**
Add this shape to `docs/wf_authoring_control_flow.md` and the Python skill: Add this shape to `docs/wf_authoring_control_flow.md` and the Python skill:
@@ -953,7 +953,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
- child workflows do not inherit caller context and must receive input; - child workflows do not inherit caller context and must receive input;
- `loop_item`, `loop_index`, and aliases are migration conveniences. - `loop_item`, `loop_index`, and aliases are migration conveniences.
- [ ] **Step 5: Mark the implementation current and retire the live plan** - [x] **Step 5: Mark the implementation current and retire the live plan**
Change the spec status from approved to implemented, replace the roadmap's Change the spec status from approved to implemented, replace the roadmap's
proposed wording with a completed current-runtime statement, and move this proposed wording with a completed current-runtime statement, and move this
@@ -965,7 +965,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
docs skills docs skills
``` ```
- [ ] **Step 6: Run full verification** - [x] **Step 6: Run full verification**
Run: Run:
@@ -988,7 +988,7 @@ JSON Schema, pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
remains the only failure, record its exact failing test and verify it also remains the only failure, record its exact failing test and verify it also
fails at the plan's starting commit before treating it as baseline. fails at the plan's starting commit before treating it as baseline.
- [ ] **Step 7: Commit the integration and documentation slice** - [x] **Step 7: Commit the integration and documentation slice**
Stage exact paths so the user's `docs/AGENTS.md` edit remains untouched: Stage exact paths so the user's `docs/AGENTS.md` edit remains untouched:
@@ -414,7 +414,8 @@ compatibility promise.
- `loop_item` and `loop_index` select the innermost entry. - `loop_item` and `loop_index` select the innermost entry.
- Unique outer and inner aliases remain available together. - Unique outer and inner aliases remain available together.
- Inner completion restores the outer entry and removes the inner entry. - Inner completion restores the outer entry and removes the inner entry.
- Concurrent item frames receive distinct activation/frame/lineage values. - Concurrent item frames share one visit activation but receive distinct
frame/lineage values.
- Interrupt resume recreates the same structured entries from persisted state. - Interrupt resume recreates the same structured entries from persisted state.
- Malformed persisted foreach metadata fails closed. - Malformed persisted foreach metadata fails closed.
-23
View File
@@ -18,29 +18,6 @@ from wf_core.errors import WorkflowExecutionError
from wf_core.run_state import ExecutionFrame, ForeachContext, RunState from wf_core.run_state import ExecutionFrame, ForeachContext, RunState
def frame_context_values(frame: ExecutionFrame) -> dict[str, object | None]:
context: dict[str, object | None] = {
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_CONTEXT_KEY] = loop_item
context[LOOP_INDEX_CONTEXT_KEY] = loop_index
if (
isinstance(loop_alias, str)
and loop_alias
and loop_alias not in RESERVED_CONTEXT_KEYS
):
context[loop_alias] = loop_item
return context
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class FrameContextView: class FrameContextView:
"""Typed handler context and graph values from one ancestry walk.""" """Typed handler context and graph values from one ancestry walk."""
+3 -42
View File
@@ -199,8 +199,9 @@ def _failing_segment(
"""Return the first unknown segment plus the keys available there. """Return the first unknown segment plus the keys available there.
Returns ``(None, "")`` when the path walks declared properties (or Returns ``(None, "")`` when the path walks declared properties (or
permissive unconstrained schemas). Diagnostics only; validity follows permissive unconstrained schemas). A bare ``$ref`` fails closed: generated
the same walk as :func:`_path_in_schema`. per-node schemas are inline except for cyclic shapes, which cannot be
proven valid statically.
""" """
if not parts: if not parts:
return None, "" return None, ""
@@ -226,46 +227,6 @@ def _failing_segment(
return None, "" return None, ""
def _path_in_schema(schema: Mapping[str, Any], parts: tuple[str, ...]) -> bool:
"""Return whether literal parts walk declared object properties.
The whole ``context`` object (no parts) and the ``foreach`` map itself
are readable. Unknown segments fail closed, except beneath an
unconstrained item schema (``{}`` or an open object with no declared
properties): a generic ``array`` collection infers ``{}``, and runtime
values may carry fields the static schema cannot see, so such subpaths
stay permissive. Closed maps with ``additionalProperties: False`` (like
the ``foreach`` map itself) still reject unknown keys.
"""
if not parts:
return True
current: Any = schema
for part in parts:
if not isinstance(current, Mapping):
return False
# Resolve local $ref if present (defensive; generated schemas are inline).
while isinstance(current.get("$ref"), str):
# Generated context schemas keep entry schemas inline, so an
# unresolvable ref here means the path cannot be proven valid.
return False
properties = current.get("properties")
if not isinstance(properties, Mapping):
# No declared properties: unconstrained `{}` or an open object
# allows subpaths; scalar or closed schemas do not.
if current == {}:
return True
if (
current.get("type") == "object"
and current.get("additionalProperties", True) is not False
):
return True
return False
if part not in properties:
return False
current = properties[part]
return True
def _validate_workflow_output(workflow: Workflow, report: ValidationReport) -> None: def _validate_workflow_output(workflow: Workflow, report: ValidationReport) -> None:
schema = root_context_schema() schema = root_context_schema()
for output_index, binding in enumerate(workflow.output): for output_index, binding in enumerate(workflow.output):
+54 -33
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import pytest
from wf_core import END, Edge, ForeachNode, NodeUse, SchemaRef, StateSchema, Workflow from wf_core import END, Edge, ForeachNode, NodeUse, SchemaRef, StateSchema, Workflow
from wf_core.analysis.context_scopes import ( from wf_core.analysis.context_scopes import (
ContextFieldAvailability, ContextFieldAvailability,
@@ -7,9 +9,10 @@ from wf_core.analysis.context_scopes import (
context_fields_by_node, context_fields_by_node,
) )
from wf_core.context_contracts import STANDARD_CONTEXT_FIELDS, foreach_context_fields from wf_core.context_contracts import STANDARD_CONTEXT_FIELDS, foreach_context_fields
from wf_core.errors import WorkflowExecutionError
from wf_core.models.steps import Step from wf_core.models.steps import Step
from wf_core.run_state import ExecutionFrame from wf_core.run_state import ExecutionFrame, RunState, RunStatus
from wf_core.runtime.ops.frames import frame_context_values from wf_core.runtime.ops.frames import frame_context_view
def _node(node_id: str) -> NodeUse: def _node(node_id: str) -> NodeUse:
@@ -66,30 +69,48 @@ def _field_map(workflow: Workflow, node_id: str) -> dict[str, ContextFieldAvaila
} }
def test_frame_context_values_uses_standard_and_foreach_contract_keys() -> None: def _run_with(*frames: ExecutionFrame) -> RunState:
ordinary = frame_context_values( run = RunState(
ExecutionFrame( workflow_name="demo",
id="root", status=RunStatus.RUNNING,
kind="root", workflow_input={},
node_id="plain", state={},
prior_outcome="ok",
activated_incoming_edge="start",
)
) )
for frame in frames:
run.frames[frame.id] = frame
return run
def test_frame_context_view_uses_standard_and_foreach_contract_keys() -> None:
root = ExecutionFrame(
id="root",
kind="root",
node_id="plain",
prior_outcome="ok",
activated_incoming_edge="start",
)
ordinary = frame_context_view(_run_with(root), root).graph
assert ordinary["prior_outcome"] == "ok" assert ordinary["prior_outcome"] == "ok"
assert ordinary["activated_incoming_edge"] == "start" assert ordinary["activated_incoming_edge"] == "start"
assert ordinary["scope_id"] == "root" assert ordinary["scope_id"] == "root"
assert ordinary["lineage_id"] == "root" assert ordinary["lineage_id"] == "root"
assert ordinary["parent_lineage_id"] is None assert ordinary["parent_lineage_id"] is None
assert ordinary["foreach"] == {}
assert "loop_item" not in ordinary assert "loop_item" not in ordinary
iteration = ExecutionFrame( iteration = ExecutionFrame(
id="root:each:0", id="root:each:0",
kind="foreach_iteration", kind="foreach_iteration",
node_id="body", node_id="body",
metadata={"loop_item": "a", "loop_index": 0, "loop_alias": "item"}, metadata={
"foreach_node_id": "each",
"activation_id": "act-1",
"loop_item": "a",
"loop_index": 0,
"loop_alias": "item",
},
) )
context = frame_context_values(iteration) context = frame_context_view(_run_with(iteration), iteration).graph
assert context["loop_item"] == "a" assert context["loop_item"] == "a"
assert context["loop_index"] == 0 assert context["loop_index"] == 0
assert context["item"] == "a" assert context["item"] == "a"
@@ -104,13 +125,6 @@ def test_context_contracts_deduplicate_aliases_that_are_standard_loop_keys() ->
def test_all_standard_context_names_are_reserved_from_foreach_aliases() -> None: def test_all_standard_context_names_are_reserved_from_foreach_aliases() -> None:
expected_values = {
"prior_outcome": "ok",
"activated_incoming_edge": "start",
"scope_id": "scope",
"lineage_id": "lineage",
"parent_lineage_id": "parent",
}
standard_names = {field.name for field in STANDARD_CONTEXT_FIELDS} standard_names = {field.name for field in STANDARD_CONTEXT_FIELDS}
for name in standard_names: for name in standard_names:
@@ -118,20 +132,27 @@ def test_all_standard_context_names_are_reserved_from_foreach_aliases() -> None:
field.name for field in foreach_context_fields(name, {"type": "string"}) field.name for field in foreach_context_fields(name, {"type": "string"})
} }
assert name not in foreach_names assert name not in foreach_names
context = frame_context_values( # A reserved alias can never back a runtime entry: the derived view
ExecutionFrame( # fails closed instead of silently shadowing the standard key.
id="child", frame = ExecutionFrame(
kind="foreach_iteration", id="child",
node_id="body", kind="foreach_iteration",
scope_id="scope", node_id="body",
lineage_id="lineage", scope_id="scope",
parent_lineage_id="parent", lineage_id="lineage",
prior_outcome="ok", parent_lineage_id="parent",
activated_incoming_edge="start", prior_outcome="ok",
metadata={"loop_item": "item", "loop_index": 0, "loop_alias": name}, activated_incoming_edge="start",
) metadata={
"foreach_node_id": "each",
"activation_id": "act-1",
"loop_item": "item",
"loop_index": 0,
"loop_alias": name,
},
) )
assert context[name] == expected_values[name] with pytest.raises(WorkflowExecutionError, match="reserved context keys"):
frame_context_view(_run_with(frame), frame)
def test_serial_and_concurrent_foreach_expose_the_same_scoped_context() -> None: def test_serial_and_concurrent_foreach_expose_the_same_scoped_context() -> None:
+16 -13
View File
@@ -7,7 +7,7 @@ from wf_core.models.schemas import SchemaRef, StateSchema
from wf_core.models.workflow import Workflow from wf_core.models.workflow import Workflow
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
from wf_core.runtime.ops.flow import advance_frame from wf_core.runtime.ops.flow import advance_frame
from wf_core.runtime.ops.frames import frame_context_values from wf_core.runtime.ops.frames import frame_context_view
from wf_core.runtime.ops.runs import create_run_state from wf_core.runtime.ops.runs import create_run_state
from wf_core.runtime.scheduler import ( from wf_core.runtime.scheduler import (
add_frame, add_frame,
@@ -232,19 +232,22 @@ def test_deadlock_error_includes_ready_queue_and_frame_summary() -> None:
assert "parent:blocked@foreach" in message assert "parent:blocked@foreach" in message
def test_frame_context_values_exposes_configured_foreach_alias() -> None: def test_frame_context_view_exposes_configured_foreach_alias() -> None:
context = frame_context_values( run = _run()
ExecutionFrame( frame = ExecutionFrame(
id="child", id="child",
kind="foreach_iteration", kind="foreach_iteration",
node_id="body", node_id="body",
metadata={ metadata={
"loop_item": {"id": "a"}, "foreach_node_id": "each",
"loop_index": 2, "activation_id": "act-1",
"loop_alias": "record", "loop_item": {"id": "a"},
}, "loop_index": 2,
) "loop_alias": "record",
},
) )
run.frames[frame.id] = frame
context = frame_context_view(run, frame).graph
assert context["loop_item"] == {"id": "a"} assert context["loop_item"] == {"id": "a"}
assert context["loop_index"] == 2 assert context["loop_index"] == 2