Implement saved native subgraph resolution through deployments

This commit is contained in:
lda
2026-05-25 18:04:07 +07:00 Verified
parent 6e3a6cac48
commit 1a5efe6cda
12 changed files with 1099 additions and 70 deletions
+14 -9
View File
@@ -45,8 +45,7 @@ implementation state.
[ADR 0002](./adr/0002-concurrent-foreach-policy-and-barrier-commits.md).
- Native subgraph design spec:
[2026-05-24 native subgraphs](./superpowers/specs/2026-05-24-native-subgraphs-design.md).
- **Native subgraphs / graph-as-node**: next major runtime feature. The
scaffolding slice is complete: core has `SubgraphNode`, structural
- **Native subgraphs / graph-as-node**: core has `SubgraphNode`, structural
`WorkflowRef`, workflow-level outcomes plus explicit `EndNode` termination,
authoring helpers (`subgraph_ref` / `WorkflowBuilder.subgraph`), and artifact
reference conversion helpers. Core can now execute a prepared local child
@@ -54,11 +53,16 @@ implementation state.
map child output through the boundary, and route by the child's terminal
outcome. Prepared child interrupts now bubble through a typed internal route
and resume inside child scope while the public request identifies the parent
subgraph boundary. Saved/deployed child resolution remains next. Wrapper
helpers currently run child workflows as ordinary nodes; native
subgraph boundary. The workflow platform now resolves non-interrupting saved
child artifact refs into native prepared dependencies; descendant logical
capabilities inherit the root deployment binding environment, and missing or
cyclic saved children fail validation before a run starts. Wrapper helpers
currently run child workflows as ordinary nodes; native
`SubgraphNode` is now the graph-as-node path for prepared children.
`WorkflowBuilder.prepare_subgraph()` and `WorkflowBuilder.resume()` make the
local runnable/resumable path available without core-runtime plumbing.
Saved interrupting artifacts remain unrunnable through one-shot
`run_deployment` until the platform exposes persisted resume.
- **Concurrent foreach**: implemented in core with explicit scheduling,
reducer/merge semantics, item error policy, async handler batching, and
quiescent interrupt behavior. Remaining work is polish and future reuse of
@@ -100,8 +104,9 @@ Frame stress points remaining for native subgraphs and future fork/gather:
## Why This Order
The MCP workflow authoring path is now usable enough for real testing. The next
bottleneck is runtime/platform correctness: resumable child execution,
saved/deployed child resolution, persistent run history, and protocol-native
progress reporting. Concurrent foreach and prepared native child execution now
supply scheduler/lineage precedent. Those remaining pieces should come before
adding more high-level authoring sugar.
bottleneck is runtime/platform correctness: persisted resume for saved
interrupting children, optional per-use-site child deployment overrides,
persistent run history, and protocol-native progress reporting. Concurrent
foreach and native saved child execution now supply scheduler/lineage
precedent. Those remaining pieces should come before adding more high-level
authoring sugar.
@@ -13,6 +13,7 @@
### Task 1: Child Interrupt Contract
**Files:**
- Modify: `src/wf_core/run_state.py`
- Modify: `tests/core/test_subgraph_step.py`
@@ -54,6 +55,7 @@ to use `route=None`.
### Task 2: Scope-Aware Interrupt Creation and Resume
**Files:**
- Modify: `src/wf_core/runtime/ops/handlers.py`
- Modify: `src/wf_core/runtime/ops/interrupts.py`
- Modify: `src/wf_core/runtime/preparation.py`
@@ -83,6 +85,7 @@ output into parent state.
### Task 3: Verification and Documentation
**Files:**
- Modify: `docs/wf_core_architecture.md`
- Modify: `docs/current_roadmap.md`
@@ -0,0 +1,397 @@
# Saved Subgraph Platform Resolution Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Execute non-interrupting saved child workflow artifacts natively from a parent deployment while resolving all descendant dependencies through the parent deployment binding environment.
**Architecture:** `wf_core` remains unaware of artifact storage: it only looks up caller-prepared `PreparedSubgraph` dependencies by structural workflow-ref display key. A new focused workflow-surface resolver loads exact child artifact versions, traverses descendants with cycle detection, validates descendant capabilities and existing interrupt limitations, and prepares child workflows for `WfMcpService` execution. Future child deployment overrides remain outside this slice and must be keyed by subgraph use site, not artifact identity.
**Tech Stack:** Python 3.14, Pydantic v2, `wf_core` native subgraphs, `wf_artifacts` stores/deployment diagnostics, `wf_mcp` workflow surface, pytest, ruff, basedpyright.
---
## File Structure
- Modify `src/wf_core/runtime/subgraphs.py`: accept a caller-prepared structural saved `WorkflowRef`; do not load artifacts.
- Create `src/wf_mcp/workflow_surface/saved_subgraphs.py`: own saved-child traversal, diagnostic production, and preparation of executable child dependencies.
- Modify `src/wf_mcp/workflow_surface/handlers.py`: include descendant dependency/interrupt diagnostics in deployment validation.
- Modify `src/wf_mcp/broker/service/core.py`: supply prepared saved children to `execute_workflow_async`.
- Modify `tests/core/test_subgraph_step.py`: cover core execution of an already-prepared saved ref.
- Create `tests/wf_mcp/test_saved_subgraphs.py`: cover deployment-bound saved child execution and unrunnable descendant cases.
- Modify `docs/current_roadmap.md` and `docs/workflow_artifacts.md`: record the new runnable saved-child path and remaining persisted-resume limitation.
### Task 1: Core Accepts Prepared Saved References
**Files:**
- Modify: `src/wf_core/runtime/subgraphs.py`
- Test: `tests/core/test_subgraph_step.py`
- [ ] **Step 1: Write the failing core test**
Add a test that constructs a parent `SubgraphNode` with:
```python
workflow=WorkflowRef(artifact_id="child", version=1)
```
and supplies:
```python
subgraphs={
"workflow.child.v1": PreparedSubgraph(
workflow=child,
registry={"echo": echo_handler},
reducers={},
)
}
```
Assert the parent run completes and maps the child output into parent state.
- [ ] **Step 2: Run the core test to verify it fails**
Run:
```bash
uv run pytest -q tests/core/test_subgraph_step.py
```
Expected: FAIL because `resolve_prepared_subgraph()` currently rejects a saved structural ref before checking supplied prepared dependencies.
- [ ] **Step 3: Make prepared dependency lookup structural**
Update `resolve_prepared_subgraph()`:
```python
def resolve_prepared_subgraph(
ref: WorkflowRef,
subgraphs: Mapping[str, PreparedSubgraph[HandlerT]] | None,
) -> PreparedSubgraph[HandlerT]:
"""Resolve a caller-prepared child; artifact loading is not a core concern."""
key = ref.name if ref.name is not None else ref.display
prepared = None if subgraphs is None else subgraphs.get(key)
if prepared is None:
raise WorkflowExecutionError(
f"no prepared child workflow registered for {ref.display!r}"
)
return prepared
```
- [ ] **Step 4: Run the core test to verify it passes**
Run:
```bash
uv run pytest -q tests/core/test_subgraph_step.py
```
Expected: PASS.
### Task 2: Traverse and Prepare Saved Child Artifacts
**Files:**
- Create: `src/wf_mcp/workflow_surface/saved_subgraphs.py`
- Test: `tests/wf_mcp/test_saved_subgraphs.py`
- [ ] **Step 1: Write failing traversal tests**
Add focused tests for a helper that receives a root artifact plan containing a
structural `SubgraphNode` ref and a `FileWorkflowArtifactStore`:
```python
resolution = resolve_saved_subgraph_tree(
root_artifact=parent,
artifact_store=artifact_store,
)
assert resolution.artifacts_by_ref["workflow.child.v1"].id == "child"
assert resolution.diagnostics == []
```
Add tests asserting:
```python
assert resolution.diagnostics[0].code == "workflow_dependency_missing"
assert resolution.diagnostics[0].code == "workflow_dependency_cycle"
```
for a missing child and a parent/child cycle respectively.
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
uv run pytest -q tests/wf_mcp/test_saved_subgraphs.py
```
Expected: FAIL because `saved_subgraphs.py` and its resolver do not exist.
- [ ] **Step 3: Implement saved-child tree discovery**
Create a typed resolution object:
```python
@dataclass(frozen=True, slots=True)
class SavedSubgraphTree:
"""Saved descendant artifacts keyed by structural workflow-ref display."""
artifacts_by_ref: dict[str, WorkflowArtifact]
diagnostics: list[DependencyDiagnostic]
```
Implement:
```python
def resolve_saved_subgraph_tree(
*,
root_artifact: WorkflowArtifact,
artifact_store: WorkflowArtifactStore,
) -> SavedSubgraphTree:
"""Load exact saved descendants and report missing refs or cycles."""
```
Parse each artifact plan as `RawWorkflowPlan`, visit only `SubgraphNode`
instances whose `workflow.artifact_id` and `.version` are present, load the
exact artifact, and recurse. Keep an active artifact stack of
`(artifact_id, version)` values so only recursion cycles fail; repeated reuse
of the same child in separate branches is allowed.
Construct direct diagnostics without inventing a fake capability:
```python
DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="workflow_dependency_missing",
logical_ref=ref.display,
message=f"Saved child workflow {ref.display!r} is unavailable.",
repair_hint="Save the referenced artifact version or update the parent graph.",
)
```
Use analogous text for `workflow_dependency_cycle`.
- [ ] **Step 4: Run traversal tests to verify they pass**
Run:
```bash
uv run pytest -q tests/wf_mcp/test_saved_subgraphs.py
```
Expected: traversal tests PASS.
### Task 3: Validate Descendants Under One Deployment Environment
**Files:**
- Modify: `src/wf_mcp/workflow_surface/saved_subgraphs.py`
- Modify: `src/wf_mcp/workflow_surface/handlers.py`
- Test: `tests/wf_mcp/test_saved_subgraphs.py`
- [ ] **Step 1: Write failing public-validation tests**
Add tests that save a parent artifact referencing a child artifact whose plan
uses logical node `demo.echo_tool`. Save one parent deployment:
```python
WorkflowDeployment(
id="parent.personal",
artifact_id="parent",
artifact_version=1,
bindings={"demo": "demo.personal"},
)
```
Assert:
```python
result = asyncio.run(handlers.validate_deployment(deployment_id="parent.personal"))
assert result["status"] == "runnable"
```
Add descendant failure assertions:
```python
assert result["diagnostics"][0]["code"] == "binding_missing"
assert result["diagnostics"][0]["logical_ref"] == "demo.echo_tool"
```
and for an interrupting saved child:
```python
assert result["status"] == "unrunnable"
assert result["diagnostics"][0]["code"] == "unsupported_interrupt"
```
The interrupt diagnostic must be reported before execution because
`run_deployment` is still one-shot.
- [ ] **Step 2: Run validation tests to verify they fail**
Run:
```bash
uv run pytest -q tests/wf_mcp/test_saved_subgraphs.py
```
Expected: FAIL because `_deployment_validation()` validates only the root artifact.
- [ ] **Step 3: Add descendant validation composition**
Add a helper in `saved_subgraphs.py`:
```python
def validate_saved_subgraph_tree(
*,
tree: SavedSubgraphTree,
deployment: WorkflowDeployment,
sources: list[AvailableSource],
unsupported_interrupt: Callable[[WorkflowArtifact], DependencyDiagnostic | None],
) -> list[DependencyDiagnostic]:
"""Validate descendants in the root deployment environment."""
```
It should begin with tree discovery diagnostics, then for each loaded child
call `validate_deployment_dependencies(...)`, and finally append the existing
unsupported-interrupt diagnostic for that child when present.
Update `WorkflowSurfaceHandlers._deployment_validation()` to discover the
saved tree and extend the root diagnostic list with descendant diagnostics.
Preserve the root artifact interrupt check in `run_deployment()`; it remains
the existing surface behavior.
- [ ] **Step 4: Run validation tests to verify they pass**
Run:
```bash
uv run pytest -q tests/wf_mcp/test_saved_subgraphs.py
```
Expected: descendant validation tests PASS.
### Task 4: Execute Prepared Saved Children
**Files:**
- Modify: `src/wf_mcp/workflow_surface/saved_subgraphs.py`
- Modify: `src/wf_mcp/broker/service/core.py`
- Test: `tests/wf_mcp/test_saved_subgraphs.py`
- [ ] **Step 1: Write failing end-to-end execution tests**
Use the parent deployment and child artifact from Task 3. Assert:
```python
payload = asyncio.run(
handlers.run_deployment(
deployment_id="parent.personal",
workflow_input={"text": "hello"},
)
)
assert payload["status"] == "completed"
assert payload["output"]["echoed"] == "hello"
```
Add a nested parent -> middle -> child test where only the parent deployment
contains `{"demo": "demo.personal"}`, and assert the grandchild node executes
through the inherited binding.
- [ ] **Step 2: Run execution tests to verify they fail**
Run:
```bash
uv run pytest -q tests/wf_mcp/test_saved_subgraphs.py
```
Expected: FAIL because the service does not provide prepared saved children to core.
- [ ] **Step 3: Prepare executable children and supply them to core**
Add:
```python
def prepare_saved_subgraphs(
*,
tree: SavedSubgraphTree,
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
compile_plan: Callable[[RawWorkflowPlan, dict[str, str] | None], Workflow],
) -> dict[str, PreparedSubgraph[AsyncRegistryHandler]]:
"""Compile loaded descendants with the parent deployment bindings."""
```
For each child artifact, parse its plan, resolve its node/reducer runtime
dependencies with `resolve_runtime_dependencies(...)`, compile it, and return
the dependency under its structural ref display key:
```python
prepared[workflow_ref_display] = PreparedSubgraph(
workflow=compile_plan(plan, dependencies.node_name_bindings),
registry=dependencies.node_registry,
reducers=dependencies.reducers,
)
```
Update `WfMcpService.run_workflow_from_plan()` to resolve the saved tree for
`runtime_artifact` when an artifact store exists, prepare loaded children, and
pass:
```python
subgraphs=prepared_subgraphs
```
to `execute_workflow_async(...)`.
- [ ] **Step 4: Run saved-child tests to verify they pass**
Run:
```bash
uv run pytest -q tests/wf_mcp/test_saved_subgraphs.py
```
Expected: all saved-subgraph tests PASS.
### Task 5: Documentation and Full Verification
**Files:**
- Modify: `docs/current_roadmap.md`
- Modify: `docs/workflow_artifacts.md`
- [ ] **Step 1: Document current support**
Record:
- Non-interrupting saved child artifacts now run natively through deployments.
- Descendant logical dependencies inherit the root deployment binding environment.
- Missing/cyclic/interrupting saved descendants are reported as unrunnable.
- Explicit per-child deployment overrides and persisted saved-interrupt resume remain future work.
- [ ] **Step 2: Run focused and full verification**
Run:
```bash
uv run pytest -q tests/core/test_subgraph_step.py tests/wf_mcp/test_saved_subgraphs.py
uv run pytest -q
uvx ruff check src/wf_core src/wf_mcp tests/core/test_subgraph_step.py tests/wf_mcp/test_saved_subgraphs.py
uvx ruff format --check src/wf_core/runtime/subgraphs.py src/wf_mcp/workflow_surface/saved_subgraphs.py src/wf_mcp/workflow_surface/handlers.py src/wf_mcp/broker/service/core.py tests/core/test_subgraph_step.py tests/wf_mcp/test_saved_subgraphs.py
uv run basedpyright --level error src/wf_core src/wf_mcp tests/core/test_subgraph_step.py tests/wf_mcp/test_saved_subgraphs.py
```
Expected: all commands pass, with the repository's intentionally skipped live
integration test remaining skipped unless its environment is provided.
## Self-Review
- Spec coverage: the plan covers exact artifact loading, inherited bindings,
cycle/missing diagnostics, preserved interrupt rejection, and native execution.
- Boundary check: artifact traversal and dependency preparation stay in
`wf_mcp`; `wf_core` accepts only caller-prepared structural refs.
- Future compatibility: no child deployment field is added; per-use-site
override remains an additive future platform feature.
@@ -1,6 +1,7 @@
# Native Subgraphs Design
Status: prepared-child execution and interrupt resume implemented; artifact resolution planned
Status: prepared-child execution and interrupt resume implemented; saved-artifact
resolution scoped for platform implementation
Native subgraphs should make a workflow usable as a workflow step without
collapsing the child run into one opaque Python node call. The current
@@ -9,8 +10,10 @@ compatibility wrappers, but they hide the child trace, child frames, and child
interrupt lifecycle from `wf_core`.
This design defines the core runtime shape. The boundary model, prepared-child
execution, and routed child interrupt resume are implemented; saved-workflow
resolution remains planned.
execution, and routed child interrupt resume are implemented. The remaining
saved-workflow work is platform preparation: load immutable child artifact
versions, resolve their runtime dependencies, and supply prepared children to
the already-implemented core runtime.
## Goals
@@ -333,9 +336,10 @@ child = parent.subgraph(
```
This copies the compiled child workflow contract into a core `SubgraphNode`,
appends it to the builder, and returns the step for normal routing. Runtime
execution requires the child graph and its handlers to be supplied as a
`PreparedSubgraph`; higher layers still need dependency resolution before
appends it to the builder, and returns the step for normal routing. For a local
child builder, `parent.prepare_subgraph(child_builder)` registers the compiled
graph, handlers, and reducers required by `parent.execute(...)` and
`parent.resume(...)`. Higher layers still need dependency resolution before
saved/deployed workflow refs can run. The lower-level `subgraph_ref(...)`
helper exists for code that wants only the core step object.
@@ -382,6 +386,37 @@ The platform layer should:
This keeps auth, source availability, deployment binding, and MCP account
selection out of `wf_core`.
### Saved Child Deployment Environment
For the first saved-subgraph slice, one deployment defines the runnable
environment for the full graph tree. A parent deployment that loads a
structural child ref such as `{"artifact_id": "child", "version": 2}` must:
1. load exactly that immutable child artifact version
2. resolve the child's logical node and reducer dependencies using the same
deployment bindings as the parent
3. recursively prepare non-interrupting descendant child artifacts using that
same environment
4. detect missing artifacts, missing bound capabilities, and saved-child
reference cycles before execution
This intentionally does not add a child deployment id to `WorkflowRef`.
Supporting an explicit per-child deployment override later remains compatible
with the structural child reference and preparation boundary. Such an override
must be keyed by the parent subgraph dependency/use site, not only by child
artifact id: one parent graph may intentionally invoke the same immutable
child artifact twice against different accounts or capability bindings.
### Saved Child Interrupt Limitation
Native prepared children can interrupt and resume in core. The current
workflow-surface `run_deployment` entrypoint is still a one-shot execution
call, however, and does not expose persisted platform resume for a saved run.
Therefore this slice preserves the existing unrunnable behavior for saved
artifacts containing interrupts, including interrupting descendant artifacts.
The platform must report that diagnostic before execution rather than starting
a run it cannot resume through its public surface.
## Implementation Slices
### Completed Scaffold: Typed Native Boundary
@@ -424,9 +459,13 @@ selection out of `wf_core`.
- Structural saved-workflow references and conversion helpers already exist;
this slice is execution resolution, not a new identity shape.
- Add platform-level resolution for saved workflow artifacts.
- Validate dependencies and source bindings before execution.
- Tests: saved child workflow runs through a deployment binding, missing child
artifact reports an unrunnable dependency.
- Apply the parent deployment binding environment transitively to each exact
saved child artifact version.
- Validate dependencies, missing artifacts, saved-child cycles, and existing
unsupported interrupt diagnostics before execution.
- Tests: saved child workflow runs through a deployment binding, nested saved
child dependencies use the same binding environment, and missing,
cyclic, or interrupting child artifacts report an unrunnable dependency.
### Slice 4: Optional Policy Expansion
@@ -440,11 +479,8 @@ selection out of `wf_core`.
## Risks
- Trace shape can become confusing if child entries are flattened too early.
- Interrupt resume can become string-parsing-heavy if `InterruptRequest` is not
extended structurally.
- Storing nested run state directly may bloat persisted runs unless inspection
APIs paginate trace/state detail.
- Recursive saved workflows need explicit cycle detection.
- Multiple child workflow dependency registries can make runtime dependencies
complex; keep the boundary typed early.
@@ -454,15 +490,15 @@ selection out of `wf_core`.
frame metadata plus shared parent `RunState.frames`?
- Should `TraceEntry` gain explicit `scope_id`, `lineage_id`, and parent-trace
fields, or should child traces live in a separate inspectable structure?
- Is v1 allowed to reference only inline/compiled child workflows, or should it
immediately accept artifact refs resolved by the platform?
## Recommendation
The typed boundary scaffold and non-interrupting prepared-child runtime are
complete. Do not delete the wrapper-node helpers yet; use them as compatibility
and examples while native subgraphs mature.
The typed boundary scaffold, prepared-child runtime, and routed interrupt
resume are complete. Implement Slice 3 in the platform layer: prepare saved
non-interrupting child artifacts recursively under one deployment environment
and pass those prepared dependencies into core execution.
Implement Slice 2 before exposing saved workflows as broadly reusable child
graphs. Saved workflows without nested interrupt support would look reusable
but break at exactly the moment users need persistence and resume.
Do not broaden saved interrupt support through `run_deployment` until the
platform has a public persisted resume path. Do not delete wrapper-node
helpers yet; they remain compatibility APIs while saved native execution
matures.
+9 -7
View File
@@ -139,8 +139,10 @@ limits and intended adapter seam.
dependencies. A prepared local child executes through a child runtime scope
and lineage; child output commits only through declared boundary bindings and
the parent routes by the child's terminal workflow outcome. Saved/deployed
workflow resolution remains outside core and is not implemented at this
boundary yet. For local authoring, `WorkflowBuilder.prepare_subgraph()`
workflow resolution remains outside core; the workflow platform can now
supply non-interrupting saved child artifacts as prepared dependencies using
one inherited deployment binding environment. For local authoring,
`WorkflowBuilder.prepare_subgraph()`
registers a child builder and `WorkflowBuilder.resume()` continues a paused
prepared-child interrupt without requiring direct core-runtime calls.
- The current `wf_authoring` wrapper helpers still run child workflows as
@@ -151,14 +153,14 @@ limits and intended adapter seam.
`examples/authoring_workflow_as_node.py` for the compatibility wrapper shape
and `examples/authoring_native_subgraph.py` plus
`examples/authoring_native_subgraph_interrupt.py` for the native path.
- Saved workflow-as-node execution with interrupts still requires platform
resolution of artifact/deployment references into prepared child
dependencies before core execution begins.
- Saved workflow-as-node execution with interrupts still requires a persisted
platform resume surface. The current one-shot deployment tool rejects those
artifacts before execution even though core prepared children can resume.
- Frames are no longer only a serial execution stack: the runtime has a ready
queue, `BLOCKED` frame state, lineage isolation, barrier merge semantics, and
pending child results for concurrent foreach. Native prepared subgraphs now
use child-scope execution and typed routed child interruption; saved/deployed
child resolution remains outstanding.
use child-scope execution and typed routed child interruption; the platform
resolves non-interrupting saved/deployed child artifacts before core starts.
Concurrent foreach is the primary current use case for async concurrent node
handler execution.
- Runtime errors are still ordinary exceptions plus failed run status. A richer
+11
View File
@@ -73,6 +73,17 @@ Current `run_deployment` calls are synchronous request/response executions. They
return compact status, output, diagnostics, and `trace_count`; optional ranged
trace detail is for debugging only.
Non-interrupting saved workflow children can now execute natively through this
deployment surface. A parent deployment resolves its saved descendants by exact
artifact version, and the parent binding environment supplies logical source
bindings for the whole child tree. This is intentionally one configured graph
environment; future per-child deployment overrides, if added, must be keyed by
the subgraph use site rather than only the child artifact id.
Interrupting saved artifacts remain unrunnable through `run_deployment`.
Although core prepared children can interrupt and resume, this one-shot public
surface does not yet persist a run for a later resume request.
Future run history should introduce a stable `run_id` only when there is a real
run store behind it. A `run_id` without persisted state, trace paging, and
status lookup would be misleading. The likely shape is:
+9 -7
View File
@@ -96,15 +96,17 @@ def resolve_prepared_subgraph(
ref: WorkflowRef,
subgraphs: Mapping[str, PreparedSubgraph[HandlerT]] | None,
) -> PreparedSubgraph[HandlerT]:
"""Resolve a prepared local child; artifact loading is not a core concern."""
if ref.name is None:
raise WorkflowExecutionError(
f"saved child workflow reference {ref.display!r} is not prepared for core execution"
)
prepared = None if subgraphs is None else subgraphs.get(ref.name)
"""Resolve a caller-prepared child; artifact loading is not a core concern.
Local refs use their registry name. Saved refs use their structural display
key only as an already-prepared dependency lookup key; loading immutable
artifacts and resolving deployment bindings remains platform work.
"""
key = ref.name if ref.name is not None else ref.display
prepared = None if subgraphs is None else subgraphs.get(key)
if prepared is None:
raise WorkflowExecutionError(
f"no prepared child workflow registered for {ref.name!r}"
f"no prepared child workflow registered for {ref.display!r}"
)
return prepared
+17
View File
@@ -48,6 +48,10 @@ from ...shared.names import RESERVED_CONNECTION_IDS
from ...storage import Store
from ...workflow.wrappers import _model_from_schema
from ...workflow_surface.runtime_dependencies import resolve_runtime_dependencies
from ...workflow_surface.saved_subgraphs import (
prepare_saved_subgraphs,
resolve_saved_subgraph_tree,
)
from ..admin_capabilities import admin_source
from ..catalog import CombinedCatalog, snapshot_from_specs
from ..discovery import discover_connection_capabilities, specs_from_discovered_tools
@@ -698,12 +702,25 @@ class WfMcpService:
sources=self.capability_sources,
plan_node_names=plan_node_names,
)
prepared_subgraphs = {}
if artifact is not None and self.artifact_store is not None:
tree = resolve_saved_subgraph_tree(
root_artifact=artifact,
artifact_store=self.artifact_store,
)
prepared_subgraphs = prepare_saved_subgraphs(
tree=tree,
deployment=deployment,
sources=self.capability_sources,
compile_plan=self.compile_plan,
)
workflow = self.compile_plan(plan, dependencies.node_name_bindings)
run = await execute_workflow_async(
workflow,
workflow_input,
dependencies.node_registry,
reducers=dependencies.reducers,
subgraphs=prepared_subgraphs,
)
self._record_event(
make_event(
+23 -27
View File
@@ -9,7 +9,6 @@ from wf_artifacts import (
AvailableCapability,
AvailableSource,
DependencyDiagnostic,
DiagnosticSeverity,
DraftWorkspaceStore,
RequiredCapability,
WorkflowArtifact,
@@ -52,6 +51,11 @@ from .constants import (
)
from .models import TraceRange
from .refs import parse_workflow_surface_capability_id
from .saved_subgraphs import (
interrupting_artifact_diagnostic,
resolve_saved_subgraph_tree,
validate_saved_subgraph_tree,
)
from .wrapper_hints import wrapper_hints_for_capability
if TYPE_CHECKING:
@@ -279,13 +283,13 @@ class WorkflowSurfaceHandlers:
deployment_id: str | None,
) -> dict[str, Any]:
"""Execute a saved wrapper artifact through the workflow runner."""
unsupported = _unsupported_interrupt_diagnostic(artifact)
unsupported = interrupting_artifact_diagnostic(artifact)
if unsupported is not None:
raise ValueError(unsupported.message)
# For now only wrapper artifacts are honest node capabilities here.
# Full saved workflows stay on `run_deployment` until core supports
# graph-as-node semantics instead of us faking subgraphs at this layer.
# Direct capability calls remain wrapper-only. Full saved workflows run
# through deployments, where native subgraph dependencies and bindings
# are prepared before core execution.
plan = _raw_plan_from_artifact(artifact)
deployment = None
if deployment_id is not None:
@@ -882,7 +886,7 @@ class WorkflowSurfaceHandlers:
diagnostics=diagnostics,
)
unsupported = _unsupported_interrupt_diagnostic(artifact)
unsupported = interrupting_artifact_diagnostic(artifact)
if unsupported is not None:
return _run_payload(
deployment=deployment,
@@ -933,10 +937,22 @@ class WorkflowSurfaceHandlers:
deployment.artifact_id,
deployment.artifact_version,
)
available_sources = _available_sources(self.service)
diagnostics = validate_deployment_dependencies(
artifact=artifact,
deployment=deployment,
sources=_available_sources(self.service),
sources=available_sources,
)
tree = resolve_saved_subgraph_tree(
root_artifact=artifact,
artifact_store=self.service.artifact_store,
)
diagnostics.extend(
validate_saved_subgraph_tree(
tree=tree,
deployment=deployment,
sources=available_sources,
)
)
return deployment, artifact, diagnostics
@@ -1212,26 +1228,6 @@ def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any:
) from exc
def _unsupported_interrupt_diagnostic(
artifact: WorkflowArtifact,
) -> DependencyDiagnostic | None:
if not any(node.get("type") == "interrupt" for node in _plan_nodes(artifact)):
return None
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="interrupting_artifact_unsupported",
logical_ref=f"workflow.{artifact.id}.v{artifact.version}",
message=(
"Running saved workflow artifacts with interrupt nodes is unsupported "
"until nested run-state resume is implemented."
),
repair_hint=(
"Run this workflow as a top-level core workflow or remove interrupt "
"nodes before saving it as a runnable deployment."
),
)
def _plan_nodes(artifact: WorkflowArtifact) -> list[dict[str, Any]]:
nodes = artifact.plan.get("nodes", [])
return [node for node in nodes if isinstance(node, dict)]
@@ -0,0 +1,219 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pydantic import TypeAdapter
from wf_artifacts import (
AvailableSource,
DependencyDiagnostic,
DiagnosticSeverity,
WorkflowArtifact,
WorkflowArtifactStore,
WorkflowDeployment,
validate_deployment_dependencies,
)
from wf_core import (
AsyncNodeHandler,
InterruptNode,
NodeUse,
PreparedSubgraph,
SubgraphNode,
Workflow,
)
from wf_core.models.steps import Step
from wf_core.models.workflow_refs import WorkflowRef
from wf_platform import CapabilitySource
from ..models import RawWorkflowPlan
from .runtime_dependencies import resolve_runtime_dependencies
_STEPS_ADAPTER = TypeAdapter(list[Step])
@dataclass(frozen=True, slots=True)
class SavedSubgraphTree:
"""Saved descendant artifacts prepared from one root artifact boundary."""
artifacts_by_ref: dict[str, WorkflowArtifact]
diagnostics: list[DependencyDiagnostic]
def resolve_saved_subgraph_tree(
*,
root_artifact: WorkflowArtifact,
artifact_store: WorkflowArtifactStore,
) -> SavedSubgraphTree:
"""Load exact saved descendants and report missing refs or recursion cycles.
A saved subgraph ref identifies an immutable artifact version. This loader
intentionally does not resolve capabilities or deployment bindings; it
identifies the artifact tree that later platform validation/preparation
will operate on.
"""
artifacts_by_ref: dict[str, WorkflowArtifact] = {}
diagnostics: list[DependencyDiagnostic] = []
_visit_saved_children(
artifact=root_artifact,
artifact_store=artifact_store,
active={(root_artifact.id, root_artifact.version)},
artifacts_by_ref=artifacts_by_ref,
diagnostics=diagnostics,
)
return SavedSubgraphTree(
artifacts_by_ref=artifacts_by_ref,
diagnostics=diagnostics,
)
def validate_saved_subgraph_tree(
*,
tree: SavedSubgraphTree,
deployment: WorkflowDeployment,
sources: list[AvailableSource],
) -> list[DependencyDiagnostic]:
"""Validate saved descendants under the parent deployment environment."""
diagnostics = list(tree.diagnostics)
for child in tree.artifacts_by_ref.values():
diagnostics.extend(
validate_deployment_dependencies(
artifact=child,
deployment=deployment,
sources=sources,
)
)
interrupt_diagnostic = interrupting_artifact_diagnostic(child)
if interrupt_diagnostic is not None:
diagnostics.append(interrupt_diagnostic)
return diagnostics
def prepare_saved_subgraphs(
*,
tree: SavedSubgraphTree,
deployment: WorkflowDeployment | None,
sources: dict[str, CapabilitySource],
compile_plan: Callable[[RawWorkflowPlan, dict[str, str] | None], Workflow],
) -> dict[str, PreparedSubgraph[AsyncNodeHandler]]:
"""Compile saved descendants using one inherited deployment environment.
The tree has already fixed exact artifact versions. Binding resolution is
deliberately shared with the root deployment; per-use-site deployment
overrides are a future platform feature rather than an implicit fallback.
"""
if tree.diagnostics:
messages = "; ".join(diagnostic.message for diagnostic in tree.diagnostics)
raise ValueError(f"cannot prepare invalid saved subgraph tree: {messages}")
prepared: dict[str, PreparedSubgraph[AsyncNodeHandler]] = {}
for ref_display, child in tree.artifacts_by_ref.items():
plan = RawWorkflowPlan.model_validate(child.plan)
dependencies = resolve_runtime_dependencies(
artifact=child,
deployment=deployment,
sources=sources,
plan_node_names=[
node.node for node in plan.nodes if isinstance(node, NodeUse)
],
)
prepared[ref_display] = PreparedSubgraph(
workflow=compile_plan(plan, dependencies.node_name_bindings),
registry=dependencies.node_registry,
reducers=dependencies.reducers,
)
return prepared
def interrupting_artifact_diagnostic(
artifact: WorkflowArtifact,
) -> DependencyDiagnostic | None:
"""Reject saved interrupt workflows until the platform exposes resume."""
if not any(isinstance(node, InterruptNode) for node in _artifact_steps(artifact)):
return None
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="interrupting_artifact_unsupported",
logical_ref=f"workflow.{artifact.id}.v{artifact.version}",
message=(
"Running saved workflow artifacts with interrupt nodes is unsupported "
"until nested run-state resume is implemented."
),
repair_hint=(
"Run this workflow as a top-level core workflow or remove interrupt "
"nodes before saving it as a runnable deployment."
),
)
def _visit_saved_children(
*,
artifact: WorkflowArtifact,
artifact_store: WorkflowArtifactStore,
active: set[tuple[str, int]],
artifacts_by_ref: dict[str, WorkflowArtifact],
diagnostics: list[DependencyDiagnostic],
) -> None:
for ref in _saved_child_refs(artifact):
identity = _saved_identity(ref)
if identity in active:
diagnostics.append(_cycle_diagnostic(ref))
continue
if ref.display in artifacts_by_ref:
continue
try:
child = artifact_store.get_artifact(*identity)
except KeyError:
diagnostics.append(_missing_diagnostic(ref))
continue
artifacts_by_ref[ref.display] = child
_visit_saved_children(
artifact=child,
artifact_store=artifact_store,
active=active | {identity},
artifacts_by_ref=artifacts_by_ref,
diagnostics=diagnostics,
)
def _saved_child_refs(artifact: WorkflowArtifact) -> list[WorkflowRef]:
return [
node.workflow
for node in _artifact_steps(artifact)
if isinstance(node, SubgraphNode) and node.workflow.artifact_id is not None
]
def _artifact_steps(artifact: WorkflowArtifact) -> list[Step]:
"""Validate only step payloads needed for dependency discovery."""
raw_nodes = artifact.plan.get("nodes", [])
return _STEPS_ADAPTER.validate_python(raw_nodes)
def _saved_identity(ref: WorkflowRef) -> tuple[str, int]:
"""Return the required saved-ref fields after structural model validation."""
if ref.artifact_id is None or ref.version is None:
raise ValueError(f"workflow ref {ref.display!r} is not a saved artifact ref")
return ref.artifact_id, ref.version
def _missing_diagnostic(ref: WorkflowRef) -> DependencyDiagnostic:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="workflow_dependency_missing",
logical_ref=ref.display,
message=f"Saved child workflow {ref.display!r} is unavailable.",
repair_hint=(
"Save the referenced artifact version or update the parent graph."
),
)
def _cycle_diagnostic(ref: WorkflowRef) -> DependencyDiagnostic:
return DependencyDiagnostic(
severity=DiagnosticSeverity.ERROR,
code="workflow_dependency_cycle",
logical_ref=ref.display,
message=f"Saved child workflow {ref.display!r} creates a dependency cycle.",
repair_hint="Remove the recursive saved subgraph reference.",
)
+29
View File
@@ -129,6 +129,35 @@ def test_subgraph_step_executes_prepared_child_in_isolated_scope() -> None:
assert run.trace[-1].step_type == "subgraph"
def test_subgraph_step_executes_caller_prepared_saved_child_ref() -> None:
payload = _subgraph_node().model_dump(mode="json")
payload["workflow"] = {"artifact_id": "child", "version": 1}
workflow = _workflow(
node=SubgraphNode.model_validate(payload),
output_schema=_schema({"answer": {"type": "string"}}),
)
run = execute_workflow(
workflow,
{"text": "hello"},
{},
subgraphs={
"workflow.child.v1": PreparedSubgraph(
workflow=_child_workflow(),
registry={
"answer": lambda child_input, _ctx: {
"answer": f"saved:{child_input['text']}"
}
},
)
},
)
assert run.output["answer"] == "saved:hello"
assert run.trace[-1].node_id == "child"
assert run.trace[-1].step_type == "subgraph"
def test_subgraph_step_executes_prepared_async_child() -> None:
async def answer(payload: dict[str, object], _ctx: object) -> dict[str, object]:
return {"answer": f"async:{payload['text']}"}
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
import asyncio
from typing import Any
from wf_artifacts import (
FileWorkflowArtifactStore,
RequiredCapability,
WorkflowArtifact,
WorkflowDeployment,
)
from wf_mcp.broker import WfMcpService
from wf_mcp.models import ConnectionConfig
from wf_mcp.storage import FileStore
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
from wf_mcp.workflow_surface.saved_subgraphs import resolve_saved_subgraph_tree
from .test_support import echo_tool, input_binding, local_temp_root, output_binding
def test_saved_subgraph_tree_loads_exact_child_artifact_version() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_tree")
parent = _parent_artifact()
store.save_artifact(_leaf_artifact())
resolution = resolve_saved_subgraph_tree(
root_artifact=parent,
artifact_store=store,
)
assert resolution.diagnostics == []
assert resolution.artifacts_by_ref["workflow.child.v1"].id == "child"
assert resolution.artifacts_by_ref["workflow.child.v1"].version == 1
def test_saved_subgraph_tree_reports_missing_child_artifact() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_missing")
resolution = resolve_saved_subgraph_tree(
root_artifact=_parent_artifact(),
artifact_store=store,
)
assert len(resolution.diagnostics) == 1
assert resolution.diagnostics[0].code == "workflow_dependency_missing"
assert resolution.diagnostics[0].logical_ref == "workflow.child.v1"
def test_saved_subgraph_tree_reports_recursive_child_cycle() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_cycle")
parent = _parent_artifact()
child = _parent_artifact(
artifact_id="child",
title="Child",
child_artifact_id="parent",
)
store.save_artifact(parent)
store.save_artifact(child)
resolution = resolve_saved_subgraph_tree(
root_artifact=parent,
artifact_store=store,
)
assert len(resolution.diagnostics) == 1
assert resolution.diagnostics[0].code == "workflow_dependency_cycle"
assert resolution.diagnostics[0].logical_ref == "workflow.parent.v1"
def test_saved_child_uses_parent_deployment_binding_for_validation() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_validate")
store.save_artifact(_parent_artifact())
store.save_artifact(_leaf_artifact())
store.save_deployment(_deployment())
handlers = _handlers(store)
result = asyncio.run(handlers.validate_deployment(deployment_id="parent.personal"))
assert result["status"] == "runnable"
assert result["diagnostics"] == []
def test_saved_child_missing_parent_binding_is_unrunnable() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_unbound")
store.save_artifact(_parent_artifact())
store.save_artifact(_leaf_artifact())
store.save_deployment(_deployment(bindings={}))
handlers = _handlers(store)
result = asyncio.run(handlers.validate_deployment(deployment_id="parent.personal"))
assert result["status"] == "unrunnable"
assert result["diagnostics"][0]["code"] == "binding_missing"
assert result["diagnostics"][0]["logical_ref"] == "demo.echo_tool"
def test_interrupting_saved_child_remains_unrunnable_on_deployment_surface() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_interrupt")
store.save_artifact(_parent_artifact())
store.save_artifact(_interrupting_child_artifact())
store.save_deployment(_deployment())
handlers = _handlers(store)
result = asyncio.run(handlers.validate_deployment(deployment_id="parent.personal"))
assert result["status"] == "unrunnable"
assert result["diagnostics"][0]["code"] == "interrupting_artifact_unsupported"
assert result["diagnostics"][0]["logical_ref"] == "workflow.child.v1"
def test_missing_saved_child_is_unrunnable_on_deployment_surface() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_missing_run")
store.save_artifact(_parent_artifact())
store.save_deployment(_deployment())
handlers = _handlers(store)
result = asyncio.run(handlers.validate_deployment(deployment_id="parent.personal"))
assert result["status"] == "unrunnable"
assert result["diagnostics"][0]["code"] == "workflow_dependency_missing"
assert result["diagnostics"][0]["logical_ref"] == "workflow.child.v1"
def test_cyclic_saved_child_is_unrunnable_on_deployment_surface() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_cycle_run")
store.save_artifact(_parent_artifact())
store.save_artifact(
_parent_artifact(
artifact_id="child",
title="Child",
child_artifact_id="parent",
)
)
store.save_deployment(_deployment())
handlers = _handlers(store)
result = asyncio.run(handlers.validate_deployment(deployment_id="parent.personal"))
assert result["status"] == "unrunnable"
assert result["diagnostics"][0]["code"] == "workflow_dependency_cycle"
assert result["diagnostics"][0]["logical_ref"] == "workflow.parent.v1"
def test_saved_child_runs_natively_with_parent_deployment_binding() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_run")
store.save_artifact(_parent_artifact())
store.save_artifact(_leaf_artifact())
store.save_deployment(_deployment())
handlers = _handlers(store)
result = asyncio.run(
handlers.run_deployment(
deployment_id="parent.personal",
workflow_input={"text": "hello"},
)
)
assert result["status"] == "completed"
assert result["output"]["echoed"] == "hello"
assert result["diagnostics"] == []
def test_nested_saved_child_inherits_root_deployment_binding() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "saved_subgraph_nested_run")
store.save_artifact(_parent_artifact(child_artifact_id="middle"))
store.save_artifact(
_parent_artifact(
artifact_id="middle",
title="Middle",
child_artifact_id="child",
)
)
store.save_artifact(_leaf_artifact())
store.save_deployment(_deployment())
handlers = _handlers(store)
result = asyncio.run(
handlers.run_deployment(
deployment_id="parent.personal",
workflow_input={"text": "hello"},
)
)
assert result["status"] == "completed"
assert result["output"]["echoed"] == "hello"
assert result["diagnostics"] == []
def _leaf_artifact() -> WorkflowArtifact:
plan: dict[str, Any] = {
"name": "child",
"input_schema": _io_schema("text"),
"state_schema": {"fields": {"echoed": {"type": "string"}}},
"output_schema": _io_schema("echoed"),
"start": "echo",
"nodes": [
{
"id": "echo",
"type": "node",
"node": "demo.echo_tool",
"input": [input_binding("input.text", "text")],
"output": [output_binding("echoed", "state.echoed")],
}
],
"edges": [{"from": "echo", "outcome": "ok", "to": "__end__"}],
}
return WorkflowArtifact(
id="child",
version=1,
title="Child",
input_schema=plan["input_schema"],
output_schema=plan["output_schema"],
outcomes=("completed",),
plan=plan,
required_capabilities=[
RequiredCapability(ref="demo.echo_tool", kind="node_spec")
],
)
def _parent_artifact(
*,
artifact_id: str = "parent",
title: str = "Parent",
child_artifact_id: str = "child",
) -> WorkflowArtifact:
plan: dict[str, Any] = {
"name": artifact_id,
"input_schema": _io_schema("text"),
"state_schema": {"fields": {"echoed": {"type": "string"}}},
"output_schema": _io_schema("echoed"),
"start": "child_step",
"nodes": [
{
"id": "child_step",
"type": "subgraph",
"workflow": {"artifact_id": child_artifact_id, "version": 1},
"input_schema": _io_schema("text"),
"output_schema": _io_schema("echoed"),
"input": [input_binding("input.text", "text")],
"output": [output_binding("echoed", "state.echoed")],
}
],
"edges": [{"from": "child_step", "outcome": "ok", "to": "__end__"}],
}
return WorkflowArtifact(
id=artifact_id,
version=1,
title=title,
input_schema=plan["input_schema"],
output_schema=plan["output_schema"],
outcomes=("completed",),
plan=plan,
)
def _io_schema(field: str) -> dict[str, Any]:
return {
"type": "object",
"properties": {field: {"type": "string"}},
"required": [field],
}
def _interrupting_child_artifact() -> WorkflowArtifact:
plan: dict[str, Any] = {
"name": "child",
"input_schema": _io_schema("text"),
"state_schema": {"fields": {"echoed": {"type": "string"}}},
"output_schema": _io_schema("echoed"),
"start": "ask",
"nodes": [
{
"id": "ask",
"type": "interrupt",
"kind": "input",
"request": [input_binding("input.text", "question")],
"resume": [output_binding("answer", "state.echoed")],
}
],
"edges": [{"from": "ask", "outcome": "submitted", "to": "__end__"}],
}
return WorkflowArtifact(
id="child",
version=1,
title="Interrupting Child",
input_schema=plan["input_schema"],
output_schema=plan["output_schema"],
outcomes=("completed",),
plan=plan,
)
def _deployment(*, bindings: dict[str, str] | None = None) -> WorkflowDeployment:
return WorkflowDeployment(
id="parent.personal",
artifact_id="parent",
artifact_version=1,
bindings={"demo": "demo.personal"} if bindings is None else bindings,
)
def _handlers(artifact_store: FileWorkflowArtifactStore) -> WorkflowSurfaceHandlers:
service = WfMcpService(
store=FileStore(local_temp_root() / f"{artifact_store.root.name}_mcp"),
artifact_store=artifact_store,
)
service.register_connection(
ConnectionConfig(id="demo.personal", server="demo", account="personal")
)
service.register_specs("demo.personal", echo_tool)
return WorkflowSurfaceHandlers(service)