Implement saved native subgraph resolution through deployments
This commit is contained in:
+14
-9
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
)
|
||||||
@@ -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']}"}
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user