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). [ADR 0002](./adr/0002-concurrent-foreach-policy-and-barrier-commits.md).
- Native subgraph design spec: - Native subgraph design spec:
[2026-05-24 native subgraphs](./superpowers/specs/2026-05-24-native-subgraphs-design.md). [2026-05-24 native subgraphs](./superpowers/specs/2026-05-24-native-subgraphs-design.md).
- **Native subgraphs / graph-as-node**: next major runtime feature. The - **Native subgraphs / graph-as-node**: core has `SubgraphNode`, structural
scaffolding slice is complete: core has `SubgraphNode`, structural
`WorkflowRef`, workflow-level outcomes plus explicit `EndNode` termination, `WorkflowRef`, workflow-level outcomes plus explicit `EndNode` termination,
authoring helpers (`subgraph_ref` / `WorkflowBuilder.subgraph`), and artifact authoring helpers (`subgraph_ref` / `WorkflowBuilder.subgraph`), and artifact
reference conversion helpers. Core can now execute a prepared local child 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 map child output through the boundary, and route by the child's terminal
outcome. Prepared child interrupts now bubble through a typed internal route outcome. Prepared child interrupts now bubble through a typed internal route
and resume inside child scope while the public request identifies the parent and resume inside child scope while the public request identifies the parent
subgraph boundary. Saved/deployed child resolution remains next. Wrapper subgraph boundary. The workflow platform now resolves non-interrupting saved
helpers currently run child workflows as ordinary nodes; native 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. `SubgraphNode` is now the graph-as-node path for prepared children.
`WorkflowBuilder.prepare_subgraph()` and `WorkflowBuilder.resume()` make the `WorkflowBuilder.prepare_subgraph()` and `WorkflowBuilder.resume()` make the
local runnable/resumable path available without core-runtime plumbing. 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, - **Concurrent foreach**: implemented in core with explicit scheduling,
reducer/merge semantics, item error policy, async handler batching, and reducer/merge semantics, item error policy, async handler batching, and
quiescent interrupt behavior. Remaining work is polish and future reuse of 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 ## Why This Order
The MCP workflow authoring path is now usable enough for real testing. The next The MCP workflow authoring path is now usable enough for real testing. The next
bottleneck is runtime/platform correctness: resumable child execution, bottleneck is runtime/platform correctness: persisted resume for saved
saved/deployed child resolution, persistent run history, and protocol-native interrupting children, optional per-use-site child deployment overrides,
progress reporting. Concurrent foreach and prepared native child execution now persistent run history, and protocol-native progress reporting. Concurrent
supply scheduler/lineage precedent. Those remaining pieces should come before foreach and native saved child execution now supply scheduler/lineage
adding more high-level authoring sugar. precedent. Those remaining pieces should come before adding more high-level
authoring sugar.
@@ -13,6 +13,7 @@
### Task 1: Child Interrupt Contract ### Task 1: Child Interrupt Contract
**Files:** **Files:**
- Modify: `src/wf_core/run_state.py` - Modify: `src/wf_core/run_state.py`
- Modify: `tests/core/test_subgraph_step.py` - Modify: `tests/core/test_subgraph_step.py`
@@ -54,6 +55,7 @@ to use `route=None`.
### Task 2: Scope-Aware Interrupt Creation and Resume ### Task 2: Scope-Aware Interrupt Creation and Resume
**Files:** **Files:**
- Modify: `src/wf_core/runtime/ops/handlers.py` - Modify: `src/wf_core/runtime/ops/handlers.py`
- Modify: `src/wf_core/runtime/ops/interrupts.py` - Modify: `src/wf_core/runtime/ops/interrupts.py`
- Modify: `src/wf_core/runtime/preparation.py` - Modify: `src/wf_core/runtime/preparation.py`
@@ -83,6 +85,7 @@ output into parent state.
### Task 3: Verification and Documentation ### Task 3: Verification and Documentation
**Files:** **Files:**
- Modify: `docs/wf_core_architecture.md` - Modify: `docs/wf_core_architecture.md`
- Modify: `docs/current_roadmap.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 # 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 Native subgraphs should make a workflow usable as a workflow step without
collapsing the child run into one opaque Python node call. The current 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`. interrupt lifecycle from `wf_core`.
This design defines the core runtime shape. The boundary model, prepared-child This design defines the core runtime shape. The boundary model, prepared-child
execution, and routed child interrupt resume are implemented; saved-workflow execution, and routed child interrupt resume are implemented. The remaining
resolution remains planned. 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 ## Goals
@@ -333,9 +336,10 @@ child = parent.subgraph(
``` ```
This copies the compiled child workflow contract into a core `SubgraphNode`, This copies the compiled child workflow contract into a core `SubgraphNode`,
appends it to the builder, and returns the step for normal routing. Runtime appends it to the builder, and returns the step for normal routing. For a local
execution requires the child graph and its handlers to be supplied as a child builder, `parent.prepare_subgraph(child_builder)` registers the compiled
`PreparedSubgraph`; higher layers still need dependency resolution before 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(...)` saved/deployed workflow refs can run. The lower-level `subgraph_ref(...)`
helper exists for code that wants only the core step object. 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 This keeps auth, source availability, deployment binding, and MCP account
selection out of `wf_core`. 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 ## Implementation Slices
### Completed Scaffold: Typed Native Boundary ### Completed Scaffold: Typed Native Boundary
@@ -424,9 +459,13 @@ selection out of `wf_core`.
- Structural saved-workflow references and conversion helpers already exist; - Structural saved-workflow references and conversion helpers already exist;
this slice is execution resolution, not a new identity shape. this slice is execution resolution, not a new identity shape.
- Add platform-level resolution for saved workflow artifacts. - Add platform-level resolution for saved workflow artifacts.
- Validate dependencies and source bindings before execution. - Apply the parent deployment binding environment transitively to each exact
- Tests: saved child workflow runs through a deployment binding, missing child saved child artifact version.
artifact reports an unrunnable dependency. - 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 ### Slice 4: Optional Policy Expansion
@@ -440,11 +479,8 @@ selection out of `wf_core`.
## Risks ## Risks
- Trace shape can become confusing if child entries are flattened too early. - 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 - Storing nested run state directly may bloat persisted runs unless inspection
APIs paginate trace/state detail. APIs paginate trace/state detail.
- Recursive saved workflows need explicit cycle detection.
- Multiple child workflow dependency registries can make runtime dependencies - Multiple child workflow dependency registries can make runtime dependencies
complex; keep the boundary typed early. complex; keep the boundary typed early.
@@ -454,15 +490,15 @@ selection out of `wf_core`.
frame metadata plus shared parent `RunState.frames`? frame metadata plus shared parent `RunState.frames`?
- Should `TraceEntry` gain explicit `scope_id`, `lineage_id`, and parent-trace - Should `TraceEntry` gain explicit `scope_id`, `lineage_id`, and parent-trace
fields, or should child traces live in a separate inspectable structure? 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 ## Recommendation
The typed boundary scaffold and non-interrupting prepared-child runtime are The typed boundary scaffold, prepared-child runtime, and routed interrupt
complete. Do not delete the wrapper-node helpers yet; use them as compatibility resume are complete. Implement Slice 3 in the platform layer: prepare saved
and examples while native subgraphs mature. 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 Do not broaden saved interrupt support through `run_deployment` until the
graphs. Saved workflows without nested interrupt support would look reusable platform has a public persisted resume path. Do not delete wrapper-node
but break at exactly the moment users need persistence and resume. 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 dependencies. A prepared local child executes through a child runtime scope
and lineage; child output commits only through declared boundary bindings and and lineage; child output commits only through declared boundary bindings and
the parent routes by the child's terminal workflow outcome. Saved/deployed the parent routes by the child's terminal workflow outcome. Saved/deployed
workflow resolution remains outside core and is not implemented at this workflow resolution remains outside core; the workflow platform can now
boundary yet. For local authoring, `WorkflowBuilder.prepare_subgraph()` 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 registers a child builder and `WorkflowBuilder.resume()` continues a paused
prepared-child interrupt without requiring direct core-runtime calls. prepared-child interrupt without requiring direct core-runtime calls.
- The current `wf_authoring` wrapper helpers still run child workflows as - 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 `examples/authoring_workflow_as_node.py` for the compatibility wrapper shape
and `examples/authoring_native_subgraph.py` plus and `examples/authoring_native_subgraph.py` plus
`examples/authoring_native_subgraph_interrupt.py` for the native path. `examples/authoring_native_subgraph_interrupt.py` for the native path.
- Saved workflow-as-node execution with interrupts still requires platform - Saved workflow-as-node execution with interrupts still requires a persisted
resolution of artifact/deployment references into prepared child platform resume surface. The current one-shot deployment tool rejects those
dependencies before core execution begins. artifacts before execution even though core prepared children can resume.
- Frames are no longer only a serial execution stack: the runtime has a ready - Frames are no longer only a serial execution stack: the runtime has a ready
queue, `BLOCKED` frame state, lineage isolation, barrier merge semantics, and queue, `BLOCKED` frame state, lineage isolation, barrier merge semantics, and
pending child results for concurrent foreach. Native prepared subgraphs now pending child results for concurrent foreach. Native prepared subgraphs now
use child-scope execution and typed routed child interruption; saved/deployed use child-scope execution and typed routed child interruption; the platform
child resolution remains outstanding. resolves non-interrupting saved/deployed child artifacts before core starts.
Concurrent foreach is the primary current use case for async concurrent node Concurrent foreach is the primary current use case for async concurrent node
handler execution. handler execution.
- Runtime errors are still ordinary exceptions plus failed run status. A richer - 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 return compact status, output, diagnostics, and `trace_count`; optional ranged
trace detail is for debugging only. 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 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 run store behind it. A `run_id` without persisted state, trace paging, and
status lookup would be misleading. The likely shape is: status lookup would be misleading. The likely shape is:
+9 -7
View File
@@ -96,15 +96,17 @@ def resolve_prepared_subgraph(
ref: WorkflowRef, ref: WorkflowRef,
subgraphs: Mapping[str, PreparedSubgraph[HandlerT]] | None, subgraphs: Mapping[str, PreparedSubgraph[HandlerT]] | None,
) -> PreparedSubgraph[HandlerT]: ) -> PreparedSubgraph[HandlerT]:
"""Resolve a prepared local child; artifact loading is not a core concern.""" """Resolve a caller-prepared child; artifact loading is not a core concern.
if ref.name is None:
raise WorkflowExecutionError( Local refs use their registry name. Saved refs use their structural display
f"saved child workflow reference {ref.display!r} is not prepared for core execution" key only as an already-prepared dependency lookup key; loading immutable
) artifacts and resolving deployment bindings remains platform work.
prepared = None if subgraphs is None else subgraphs.get(ref.name) """
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: if prepared is None:
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"no prepared child workflow registered for {ref.name!r}" f"no prepared child workflow registered for {ref.display!r}"
) )
return prepared return prepared
+17
View File
@@ -48,6 +48,10 @@ from ...shared.names import RESERVED_CONNECTION_IDS
from ...storage import Store from ...storage import Store
from ...workflow.wrappers import _model_from_schema from ...workflow.wrappers import _model_from_schema
from ...workflow_surface.runtime_dependencies import resolve_runtime_dependencies 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 ..admin_capabilities import admin_source
from ..catalog import CombinedCatalog, snapshot_from_specs from ..catalog import CombinedCatalog, snapshot_from_specs
from ..discovery import discover_connection_capabilities, specs_from_discovered_tools from ..discovery import discover_connection_capabilities, specs_from_discovered_tools
@@ -698,12 +702,25 @@ class WfMcpService:
sources=self.capability_sources, sources=self.capability_sources,
plan_node_names=plan_node_names, 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) workflow = self.compile_plan(plan, dependencies.node_name_bindings)
run = await execute_workflow_async( run = await execute_workflow_async(
workflow, workflow,
workflow_input, workflow_input,
dependencies.node_registry, dependencies.node_registry,
reducers=dependencies.reducers, reducers=dependencies.reducers,
subgraphs=prepared_subgraphs,
) )
self._record_event( self._record_event(
make_event( make_event(
+23 -27
View File
@@ -9,7 +9,6 @@ from wf_artifacts import (
AvailableCapability, AvailableCapability,
AvailableSource, AvailableSource,
DependencyDiagnostic, DependencyDiagnostic,
DiagnosticSeverity,
DraftWorkspaceStore, DraftWorkspaceStore,
RequiredCapability, RequiredCapability,
WorkflowArtifact, WorkflowArtifact,
@@ -52,6 +51,11 @@ from .constants import (
) )
from .models import TraceRange from .models import TraceRange
from .refs import parse_workflow_surface_capability_id 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 from .wrapper_hints import wrapper_hints_for_capability
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -279,13 +283,13 @@ class WorkflowSurfaceHandlers:
deployment_id: str | None, deployment_id: str | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Execute a saved wrapper artifact through the workflow runner.""" """Execute a saved wrapper artifact through the workflow runner."""
unsupported = _unsupported_interrupt_diagnostic(artifact) unsupported = interrupting_artifact_diagnostic(artifact)
if unsupported is not None: if unsupported is not None:
raise ValueError(unsupported.message) raise ValueError(unsupported.message)
# For now only wrapper artifacts are honest node capabilities here. # Direct capability calls remain wrapper-only. Full saved workflows run
# Full saved workflows stay on `run_deployment` until core supports # through deployments, where native subgraph dependencies and bindings
# graph-as-node semantics instead of us faking subgraphs at this layer. # are prepared before core execution.
plan = _raw_plan_from_artifact(artifact) plan = _raw_plan_from_artifact(artifact)
deployment = None deployment = None
if deployment_id is not None: if deployment_id is not None:
@@ -882,7 +886,7 @@ class WorkflowSurfaceHandlers:
diagnostics=diagnostics, diagnostics=diagnostics,
) )
unsupported = _unsupported_interrupt_diagnostic(artifact) unsupported = interrupting_artifact_diagnostic(artifact)
if unsupported is not None: if unsupported is not None:
return _run_payload( return _run_payload(
deployment=deployment, deployment=deployment,
@@ -933,10 +937,22 @@ class WorkflowSurfaceHandlers:
deployment.artifact_id, deployment.artifact_id,
deployment.artifact_version, deployment.artifact_version,
) )
available_sources = _available_sources(self.service)
diagnostics = validate_deployment_dependencies( diagnostics = validate_deployment_dependencies(
artifact=artifact, artifact=artifact,
deployment=deployment, 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 return deployment, artifact, diagnostics
@@ -1212,26 +1228,6 @@ def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any:
) from exc ) 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]]: def _plan_nodes(artifact: WorkflowArtifact) -> list[dict[str, Any]]:
nodes = artifact.plan.get("nodes", []) nodes = artifact.plan.get("nodes", [])
return [node for node in nodes if isinstance(node, dict)] 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" 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: def test_subgraph_step_executes_prepared_async_child() -> None:
async def answer(payload: dict[str, object], _ctx: object) -> dict[str, object]: async def answer(payload: dict[str, object], _ctx: object) -> dict[str, object]:
return {"answer": f"async:{payload['text']}"} 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)