new wf_server: local static workflow server; docs

This commit is contained in:
lda
2026-06-03 06:29:56 +07:00 Verified
parent e1d71d7d37
commit f13f099949
10 changed files with 808 additions and 155 deletions
+5 -3
View File
@@ -85,9 +85,11 @@ implementation state.
[2026-06-03 long-lived workflow API boundary](./superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md): [2026-06-03 long-lived workflow API boundary](./superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md):
first slice should prove a lightweight local/static server that constructs first slice should prove a lightweight local/static server that constructs
`WorkflowApi` without `WfMcpService`; later slices add remote CLI targeting, `WorkflowApi` without `WfMcpService`; later slices add remote CLI targeting,
swappable HTTP/JSON-RPC/WebSocket/MCP transport adapters, source providers, JSON-RPC-over-HTTP first transport, remote CLI targeting, WebSocket/MCP
auth, streaming/progress, transactional storage, and live upstream MCP transport siblings, source providers, auth, streaming/progress,
sources. transactional storage, and live upstream MCP sources.
- First slice implemented: `wf_server` can construct a local/static durable
`WorkflowApi` without `WfMcpService`. Transport adapters remain future work.
5. **CLI/API alignment** 5. **CLI/API alignment**
- Let the CLI target either local process-backed stores/runtime or the future - Let the CLI target either local process-backed stores/runtime or the future
@@ -1142,9 +1142,13 @@ Verification:
These are intentionally not part of this implementation plan: These are intentionally not part of this implementation plan:
1. **Transport adapter plan** 1. **Transport adapter plan**
- Add `wf_transport_http` or equivalent. - Add JSON-RPC 2.0 over HTTP, likely `wf_transport_rpc_http`.
- Start with health, run deployment, inspect run, read trace, resume run. - Prefer `fastapi-jsonrpc` + `uvicorn` for server dispatch/docs.
- Keep routes thin over `WorkflowApi`. - Start with health, list/inspect capabilities, run deployment, inspect run,
read trace, and resume run.
- Use stable dotted method names such as `workflow.runs.start`.
- Keep method handlers thin over `WorkflowApi`.
- Do not dynamically register saved workflows as JSON-RPC methods.
2. **CLI remote target plan** 2. **CLI remote target plan**
- Let `wf_cli` choose local server composition or remote transport client. - Let `wf_cli` choose local server composition or remote transport client.
@@ -34,15 +34,11 @@ wf_server
source/catalog/runtime/event implementation wiring source/catalog/runtime/event implementation wiring
durable WorkflowOperationContext construction durable WorkflowOperationContext construction
wf_transport_http wf_transport_rpc_http
HTTP routes/controllers only JSON-RPC 2.0 over HTTP endpoint/controller
request/response framework models request/response envelope translation
auth/session/streaming transport policy auth/session/streaming transport policy
wf_transport_rpc
optional JSON-RPC over HTTP/WebSocket adapter
request/response envelope translation only
wf_api wf_api
protocol-neutral workflow application operations protocol-neutral workflow application operations
no HTTP imports no HTTP imports
@@ -52,8 +48,8 @@ wf_mcp
MCP transport and upstream MCP integration MCP transport and upstream MCP integration
``` ```
`wf_transport_http` should call `WorkflowApi` through the server composition. It `wf_transport_rpc_http` should call `WorkflowApi` through the server
should not call `WfMcpService`. composition. It should not call `WfMcpService`.
HTTP is one transport adapter, not "the API." JSON-RPC over HTTP, JSON-RPC over HTTP is one transport adapter, not "the API." JSON-RPC over HTTP, JSON-RPC over
WebSocket, and possibly MCP can become sibling transports around the same WebSocket, and possibly MCP can become sibling transports around the same
@@ -94,6 +90,15 @@ It should prove:
- deployment run/inspect/trace/resume semantics match local behavior - deployment run/inspect/trace/resume semantics match local behavior
- no direct dependency on `WfMcpService` - no direct dependency on `WfMcpService`
Implementation status:
- `wf_server.build_local_static_workflow_server()` constructs a durable
`WorkflowApi` with required file-backed stores, local `wf.std`/`wf.recipes`
sources, and a local runtime runner.
- This first slice has no transport adapter. Clients still call the in-process
`WorkflowApi` in tests; HTTP/JSON-RPC/WebSocket/MCP transport adapters are
later slices.
First slice should not include: First slice should not include:
- live upstream MCP source management - live upstream MCP source management
@@ -231,11 +236,35 @@ Do not move large service sets in the first slice.
## Later Slice Pointers ## Later Slice Pointers
### Slice 2: HTTP Transport Adapter ### Slice 2: JSON-RPC HTTP Transport Adapter
Add the first transport package, likely `wf_transport_http`. Add the first transport package as JSON-RPC 2.0 over HTTP, likely
`wf_transport_rpc_http`.
This slice should expose a small route set over the existing server composition: This is preferred over REST for the first remote CLI/server path because CLI and
agent clients want stable operation names more than resource-shaped URLs. The
method names should be dotted strings, matching the mental model already used by
MCP/admin tool names without inheriting MCP's dynamic tool-list behavior.
Proposed initial method names:
```text
workflow.capabilities.list
workflow.capabilities.inspect
workflow.drafts.create_from_capability
workflow.drafts.patch
workflow.drafts.validate
workflow.artifacts.save
workflow.deployments.save
workflow.deployments.validate
workflow.runs.start
workflow.runs.inspect
workflow.runs.trace
workflow.runs.resume
```
This slice should expose a small method set over the existing server
composition:
- health/status - health/status
- list/inspect capabilities - list/inspect capabilities
@@ -246,6 +275,32 @@ This slice should expose a small route set over the existing server composition:
It should not implement source provider management yet. It should not implement source provider management yet.
Preferred implementation dependency:
```bash
uv add fastapi-jsonrpc uvicorn
```
`fastapi-jsonrpc` is the recommended server library for this slice because it
keeps JSON-RPC 2.0 dispatch, errors, and docs near FastAPI/Pydantic instead of
requiring a local hand-rolled dispatcher.
Client-side CLI code can use `httpx` directly for now. A typed client wrapper
can be added when the method set stabilizes.
Guardrails:
- Do not dynamically register saved workflows as JSON-RPC methods.
- Do not add a JSON-RPC equivalent of MCP `tools/list` as the primary execution
surface.
- Do not require client session state to run or resume workflows.
- Keep durable state addressed by explicit ids: `artifact_id`, `deployment_id`,
`run_id`.
- Keep trace reads bounded by explicit range.
- Define request/response models explicitly with Pydantic; do not pass raw
arbitrary dicts through the transport layer when a stable request shape is
known.
### Slice 3: CLI Remote Target ### Slice 3: CLI Remote Target
Allow `wf_cli` to target either: Allow `wf_cli` to target either:
@@ -255,14 +310,13 @@ Allow `wf_cli` to target either:
The CLI command surface should stay stable. Only context construction changes. The CLI command surface should stay stable. Only context construction changes.
### Slice 4: JSON-RPC / WebSocket Transport ### Slice 4: WebSocket Transport
If HTTP REST starts becoming awkward for agents or streaming, add JSON-RPC as a If JSON-RPC over HTTP starts becoming awkward for streaming/progress, add a
transport sibling rather than changing `WorkflowApi`. WebSocket transport sibling rather than changing `WorkflowApi`.
Possible shapes: Possible shapes:
- JSON-RPC over HTTP
- JSON-RPC over WebSocket - JSON-RPC over WebSocket
- future MCP transport over the same server-owned `WorkflowApi` - future MCP transport over the same server-owned `WorkflowApi`
@@ -326,10 +380,9 @@ This needs explicit policies for:
1. Package name: `wf_server` is the recommended process-composition package, 1. Package name: `wf_server` is the recommended process-composition package,
but the exact name can change before implementation. but the exact name can change before implementation.
2. HTTP framework: likely FastAPI for `wf_transport_http`, but the first server 2. HTTP framework: likely FastAPI under `fastapi-jsonrpc`.
slice does not require choosing until route implementation starts. 3. RPC framework: use `fastapi-jsonrpc` for the first JSON-RPC-over-HTTP slice
3. RPC framework: undecided. JSON-RPC should be added only if it is clearly unless evaluation finds a blocking issue.
better for agent clients or streaming/progress.
4. Source-provider extraction: some reusable code currently lives in 4. Source-provider extraction: some reusable code currently lives in
`wf_mcp.broker.service`; first slice should avoid moving it unless a small `wf_mcp.broker.service`; first slice should avoid moving it unless a small
dependency-free helper is obviously needed. dependency-free helper is obviously needed.
+4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from .listing import matches_query, paged_list_payload from .listing import matches_query, paged_list_payload
from .artifacts import WorkflowArtifactApi from .artifacts import WorkflowArtifactApi
from .local_sources import builtin_sources, get_qualified_spec, qualify_spec
from .models import RawWorkflowPlan, TraceRange from .models import RawWorkflowPlan, TraceRange
from .capabilities import WorkflowCapabilityApi from .capabilities import WorkflowCapabilityApi
from .constants import ( from .constants import (
@@ -43,8 +44,11 @@ from .durable_context import durable_workflow_api, require_workflow_stores
__all__ = [ __all__ = [
"DEFAULT_CALL_STEP_ID", "DEFAULT_CALL_STEP_ID",
"builtin_sources",
"get_qualified_spec",
"matches_query", "matches_query",
"paged_list_payload", "paged_list_payload",
"qualify_spec",
"DEFAULT_ERROR_OUTCOME", "DEFAULT_ERROR_OUTCOME",
"DEFAULT_ERROR_STEP_ID", "DEFAULT_ERROR_STEP_ID",
"DEFAULT_OK_OUTCOME", "DEFAULT_OK_OUTCOME",
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from wf_authoring import NodeSpec, coalesce, concat, constant, default_if_none
from wf_authoring import extract_field, filter_items, filter_items_present, first_item
from wf_authoring import first_item_maybe, first_item_or_none, is_empty, last_item
from wf_authoring import last_item_or_none, length, node, pick_key, pick_path
from wf_authoring import project_fields, rename_fields, runtime_error, truthy
from wf_authoring import extract_text_content
from wf_core.runtime.ops.merges import DEFAULT_REDUCER_DEFINITIONS
from wf_platform import (
CapabilityBuckets,
CapabilitySource,
SourcePermissions,
SourceVisibility,
)
if TYPE_CHECKING:
from wf_core import ReducerSpec
BUILTIN_SOURCE_ID = "wf.std"
"""Internal source id for workflow standard-library node specs."""
BUILTIN_CONNECTION_ID = BUILTIN_SOURCE_ID
"""Compatibility alias for older MCP broker code."""
MCP_SOURCE_ID = "wf.mcp"
"""Reserved source id for future workflow-safe MCP utility node specs."""
RECIPE_SOURCE_ID = "wf.recipes"
"""Internal source id for first-party composed workflow recipes."""
AUTHORING_STD_SPECS: tuple[NodeSpec[Any, Any], ...] = (
coalesce,
default_if_none,
constant,
pick_key,
pick_path,
project_fields,
rename_fields,
truthy,
runtime_error,
first_item,
first_item_or_none,
first_item_maybe,
last_item,
last_item_or_none,
length,
is_empty,
filter_items,
filter_items_present,
extract_field,
concat,
)
"""Existing authoring ops exposed through the workflow stdlib."""
RECIPE_SPECS: tuple[NodeSpec[Any, Any], ...] = (extract_text_content,)
"""Composed first-party recipes exposed as workflow-facing capabilities."""
def qualify_node_name(source_id: str, local_name: str) -> str:
"""Return one source-qualified node name without assuming MCP connections."""
if not source_id:
raise ValueError("source_id must not be empty")
if not local_name:
raise ValueError("local node name must not be empty")
return f"{source_id}.{local_name}"
def qualify_spec(source_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any, Any]:
"""Return a copy of a spec with its node name scoped to a source."""
return NodeSpec(
name=qualify_node_name(source_id, spec.name),
input_model=spec.input_model,
output_model=spec.output_model,
outcomes=spec.outcomes,
fn=spec.fn,
description=spec.description,
is_async=spec.is_async,
accepts_context=spec.accepts_context,
input_schema_contract=spec.input_schema_contract,
output_schema_contract=spec.output_schema_contract,
)
def get_qualified_spec(
sources: Mapping[str, CapabilitySource],
qualified_name: str,
) -> NodeSpec[Any, Any]:
"""Resolve a namespaced node spec from enabled planner-visible sources."""
for source in sources.values():
if not source.enabled or not source.visibility.planner:
continue
spec = source.capabilities.node_specs.get(qualified_name)
if spec is not None:
return spec
raise KeyError(f"unknown qualified node {qualified_name!r}")
def _qualified_specs(
source_id: str,
specs: tuple[NodeSpec[Any, Any], ...],
) -> dict[str, NodeSpec[Any, Any]]:
"""Return specs with authoring names rewritten under one source id."""
local_specs = [
node(spec, name=spec.name.removeprefix("authoring.")) for spec in specs
]
qualified_specs = [qualify_spec(source_id, spec) for spec in local_specs]
return {spec.name: spec for spec in qualified_specs}
def builtin_specs() -> dict[str, NodeSpec[Any, Any]]:
"""Return primitive built-in NodeSpecs available to raw workflow plans."""
return _qualified_specs(BUILTIN_SOURCE_ID, AUTHORING_STD_SPECS)
def recipe_specs() -> dict[str, NodeSpec[Any, Any]]:
"""Return composed first-party recipe specs."""
return _qualified_specs(RECIPE_SOURCE_ID, RECIPE_SPECS)
def builtin_reducers() -> dict[str, ReducerSpec]:
"""Return built-in reducers owned by the workflow standard library."""
return {
definition.spec.name: definition.spec
for definition in DEFAULT_REDUCER_DEFINITIONS.values()
}
def builtin_reducer_definitions():
"""Return executable built-in reducers for trusted runtime dependency wiring."""
return dict(DEFAULT_REDUCER_DEFINITIONS)
def builtin_sources() -> dict[str, CapabilitySource]:
"""Return all local workflow-facing capability sources."""
return {
BUILTIN_SOURCE_ID: CapabilitySource(
id=BUILTIN_SOURCE_ID,
kind="system",
capabilities=CapabilityBuckets(
node_specs=builtin_specs(),
reducers=builtin_reducers(),
reducer_definitions=builtin_reducer_definitions(),
),
visibility=SourceVisibility(
planner=True,
mcp_client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(safe_for_workflow=True),
description="Workflow standard-library nodes.",
),
RECIPE_SOURCE_ID: CapabilitySource(
id=RECIPE_SOURCE_ID,
kind="system",
capabilities=CapabilityBuckets(node_specs=recipe_specs()),
visibility=SourceVisibility(
planner=True,
mcp_client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(safe_for_workflow=True),
description="First-party workflow recipes composed from standard nodes.",
),
}
__all__ = [
"AUTHORING_STD_SPECS",
"BUILTIN_CONNECTION_ID",
"BUILTIN_SOURCE_ID",
"MCP_SOURCE_ID",
"RECIPE_SOURCE_ID",
"RECIPE_SPECS",
"builtin_reducer_definitions",
"builtin_reducers",
"builtin_sources",
"builtin_specs",
"get_qualified_spec",
"qualify_node_name",
"qualify_spec",
"recipe_specs",
]
+36 -128
View File
@@ -1,133 +1,41 @@
"""Compatibility exports for workflow local sources.
Canonical local workflow source helpers live in `wf_api.local_sources` so
non-MCP process hosts can construct `wf.std` without importing broker internals.
"""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from wf_api.local_sources import (
AUTHORING_STD_SPECS,
from wf_authoring import NodeSpec, coalesce, concat, constant, default_if_none BUILTIN_CONNECTION_ID,
from wf_authoring import extract_field, filter_items, filter_items_present, first_item BUILTIN_SOURCE_ID,
from wf_authoring import first_item_maybe, first_item_or_none, is_empty, last_item MCP_SOURCE_ID,
from wf_authoring import last_item_or_none, length, node, pick_key, pick_path RECIPE_SOURCE_ID,
from wf_authoring import project_fields, rename_fields, runtime_error, truthy RECIPE_SPECS,
from wf_authoring import extract_text_content builtin_reducer_definitions,
from wf_core.runtime.ops.merges import DEFAULT_REDUCER_DEFINITIONS builtin_reducers,
builtin_sources,
from wf_platform import ( builtin_specs,
CapabilityBuckets, get_qualified_spec,
CapabilitySource, qualify_node_name,
SourcePermissions, qualify_spec,
SourceVisibility, recipe_specs,
) )
from wf_mcp.broker.service.specs import qualify_spec
if TYPE_CHECKING: __all__ = [
from wf_core import ReducerSpec "AUTHORING_STD_SPECS",
"BUILTIN_CONNECTION_ID",
BUILTIN_CONNECTION_ID = "wf.std" "BUILTIN_SOURCE_ID",
"""Internal source id for workflow standard-library node specs.""" "MCP_SOURCE_ID",
"RECIPE_SOURCE_ID",
MCP_SOURCE_ID = "wf.mcp" "RECIPE_SPECS",
"""Reserved source id for future workflow-safe MCP utility node specs.""" "builtin_reducer_definitions",
"builtin_reducers",
RECIPE_SOURCE_ID = "wf.recipes" "builtin_sources",
"""Internal source id for first-party composed workflow recipes.""" "builtin_specs",
"get_qualified_spec",
"qualify_node_name",
AUTHORING_STD_SPECS: tuple[NodeSpec[Any, Any], ...] = ( "qualify_spec",
coalesce, "recipe_specs",
default_if_none,
constant,
pick_key,
pick_path,
project_fields,
rename_fields,
truthy,
runtime_error,
first_item,
first_item_or_none,
first_item_maybe,
last_item,
last_item_or_none,
length,
is_empty,
filter_items,
filter_items_present,
extract_field,
concat,
)
"""Existing authoring ops that are also exposed through the workflow stdlib."""
RECIPE_SPECS: tuple[NodeSpec[Any, Any], ...] = (extract_text_content,)
"""Composed first-party recipes exposed as capabilities."""
def _qualified_specs(
source_id: str,
specs: tuple[NodeSpec[Any, Any], ...],
) -> dict[str, NodeSpec[Any, Any]]:
"""Return specs with authoring names rewritten under one source id."""
local_specs = [
node(spec, name=spec.name.removeprefix("authoring.")) for spec in specs
] ]
qualified_specs = [qualify_spec(source_id, spec) for spec in local_specs]
return {spec.name: spec for spec in qualified_specs}
def builtin_specs() -> dict[str, NodeSpec[Any, Any]]:
"""Return primitive built-in NodeSpecs available to raw broker workflow plans."""
return _qualified_specs(BUILTIN_CONNECTION_ID, AUTHORING_STD_SPECS)
def recipe_specs() -> dict[str, NodeSpec[Any, Any]]:
"""Return composed first-party recipe specs.
Recipes are wrapper-node subgraphs today. They are useful workflow-facing
capabilities, but parent runs do not yet see their child graph frames.
"""
return _qualified_specs(RECIPE_SOURCE_ID, RECIPE_SPECS)
def builtin_reducers() -> dict[str, ReducerSpec]:
"""Return built-in reducers owned by the workflow standard library."""
return {
definition.spec.name: definition.spec
for definition in DEFAULT_REDUCER_DEFINITIONS.values()
}
def builtin_reducer_definitions():
"""Return executable built-in reducers for trusted runtime dependency wiring."""
return dict(DEFAULT_REDUCER_DEFINITIONS)
def builtin_sources() -> dict[str, CapabilitySource]:
"""Return all broker-local capability sources."""
return {
BUILTIN_CONNECTION_ID: CapabilitySource(
id=BUILTIN_CONNECTION_ID,
kind="system",
capabilities=CapabilityBuckets(
node_specs=builtin_specs(),
reducers=builtin_reducers(),
reducer_definitions=builtin_reducer_definitions(),
),
visibility=SourceVisibility(
planner=True,
mcp_client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(safe_for_workflow=True),
description="Workflow standard-library nodes.",
),
RECIPE_SOURCE_ID: CapabilitySource(
id=RECIPE_SOURCE_ID,
kind="system",
capabilities=CapabilityBuckets(node_specs=recipe_specs()),
visibility=SourceVisibility(
planner=True,
mcp_client=True,
admin_dashboard=True,
),
permissions=SourcePermissions(safe_for_workflow=True),
description="First-party workflow recipes composed from standard nodes.",
),
}
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from .context import (
InMemoryWorkflowEventRecorder,
LocalWorkflowRuntimeRunner,
StaticWorkflowSpecProvider,
WorkflowServer,
WorkflowServerConfig,
build_local_static_workflow_server,
)
__all__ = [
"InMemoryWorkflowEventRecorder",
"LocalWorkflowRuntimeRunner",
"StaticWorkflowSpecProvider",
"WorkflowServer",
"WorkflowServerConfig",
"build_local_static_workflow_server",
]
+273
View File
@@ -0,0 +1,273 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from wf_api import WorkflowApi, durable_workflow_api
from wf_api.local_sources import builtin_sources, get_qualified_spec
from wf_api.models import RawWorkflowPlan, TraceRange
from wf_api.operation_context import (
WorkflowEventRecorder,
WorkflowOperationContext,
WorkflowRuntimeRunner,
WorkflowSpecProvider,
)
from wf_api.runtime_dependencies import resolve_runtime_dependencies
from wf_api.saved_subgraphs import (
SavedSubgraphTree,
prepare_saved_subgraphs,
resolve_saved_subgraph_tree,
)
from wf_api.stores import WorkflowStores, file_workflow_stores
from wf_artifacts import WorkflowArtifact, WorkflowDeployment
from wf_authoring import NodeSpec
from wf_core import (
NodeUse,
RunState,
Workflow,
execute_workflow_result_async,
resume_workflow_result_async,
)
from wf_platform import CapabilitySource
@dataclass(frozen=True, slots=True)
class WorkflowServerConfig:
"""Configuration for the first local/static workflow server slice."""
store_root: Path
@dataclass(slots=True)
class InMemoryWorkflowEventRecorder(WorkflowEventRecorder):
"""Small process-local event sink for server composition tests."""
events: list[dict[str, Any]] = field(default_factory=list)
def record_event(self, event: object) -> None:
self.events.append({"kind": "adapter_event", "event": event})
def record_workflow_event(
self,
event_type: str,
*,
capability_id: str,
payload: dict[str, Any],
) -> None:
self.events.append(
{
"kind": event_type,
"capability_id": capability_id,
"payload": payload,
}
)
@dataclass(frozen=True, slots=True)
class StaticWorkflowSpecProvider(WorkflowSpecProvider):
"""Source provider for local/static server capabilities."""
sources: Mapping[str, CapabilitySource]
@property
def capability_sources(self) -> dict[str, CapabilitySource]:
return dict(self.sources)
def get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
return get_qualified_spec(self.sources, qualified_name)
@dataclass(slots=True)
class LocalWorkflowRuntimeRunner(WorkflowRuntimeRunner):
"""Run workflow plans against local/static source catalogs."""
specs: StaticWorkflowSpecProvider
artifact_store: Any
def compile_plan(
self,
plan: RawWorkflowPlan,
node_name_bindings: dict[str, str] | None = None,
) -> Workflow:
node_defs: dict[str, Any] = {}
bindings = node_name_bindings or {}
for step in plan.nodes:
if not isinstance(step, NodeUse):
continue
qualified_name = bindings.get(step.node, step.node)
spec = self.specs.get_qualified_spec(qualified_name)
node_defs[qualified_name] = spec.to_node_def()
nodes = []
for node in plan.nodes:
node_payload = node.model_dump(by_alias=True)
if isinstance(node, NodeUse):
node_payload["node"] = bindings.get(node.node, node.node)
nodes.append(node_payload)
return Workflow.model_validate(
{
"name": plan.name,
"input_schema": plan.input_schema,
"state_schema": plan.state_schema,
"output_schema": plan.output_schema,
"output": [binding.model_dump(mode="json") for binding in plan.output],
"outcomes": plan.outcomes,
"start": plan.start,
"node_defs": [node.model_dump() for node in node_defs.values()],
"nodes": nodes,
"edges": [edge.model_dump(by_alias=True) for edge in plan.edges],
}
)
def prepare_workflow_runtime(
self,
plan: RawWorkflowPlan,
*,
deployment: WorkflowDeployment | None,
artifact: WorkflowArtifact | None,
saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> tuple[Workflow, dict[str, Any], dict[str, Any], dict[str, Any]]:
plan_node_names = [
node.node for node in plan.nodes if isinstance(node, NodeUse)
]
runtime_artifact = artifact or WorkflowArtifact(
id=plan.name,
version=1,
title=plan.name,
input_schema=plan.input_schema,
output_schema=plan.output_schema,
outcomes=("completed",),
plan=plan.model_dump(mode="json", by_alias=True),
)
dependencies = resolve_runtime_dependencies(
artifact=runtime_artifact,
deployment=deployment,
sources=self.specs.capability_sources,
plan_node_names=plan_node_names,
)
prepared_subgraphs = {}
if saved_subgraph_tree is not None:
prepared_subgraphs = prepare_saved_subgraphs(
tree=saved_subgraph_tree,
deployment=deployment,
sources=self.specs.capability_sources,
compile_plan=self.compile_plan,
)
elif 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.specs.capability_sources,
compile_plan=self.compile_plan,
)
workflow = self.compile_plan(plan, dependencies.node_name_bindings)
return (
workflow,
dependencies.node_registry,
dependencies.reducers,
prepared_subgraphs,
)
async def run_workflow_from_plan(
self,
plan: RawWorkflowPlan,
workflow_input: dict[str, Any],
deployment: WorkflowDeployment | None = None,
artifact: WorkflowArtifact | None = None,
saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> RunState:
workflow, registry, reducers, prepared_subgraphs = (
self.prepare_workflow_runtime(
plan,
deployment=deployment,
artifact=artifact,
saved_subgraph_tree=saved_subgraph_tree,
)
)
return await execute_workflow_result_async(
workflow,
workflow_input,
registry,
reducers=reducers,
subgraphs=prepared_subgraphs,
)
async def resume_workflow_from_plan(
self,
plan: RawWorkflowPlan,
run: RunState,
*,
resume_payload: dict[str, Any],
resume_outcome: str,
deployment: WorkflowDeployment | None = None,
artifact: WorkflowArtifact | None = None,
saved_subgraph_tree: SavedSubgraphTree | None = None,
) -> RunState:
workflow, registry, reducers, prepared_subgraphs = (
self.prepare_workflow_runtime(
plan,
deployment=deployment,
artifact=artifact,
saved_subgraph_tree=saved_subgraph_tree,
)
)
return await resume_workflow_result_async(
workflow,
run,
registry,
resume_payload=resume_payload,
resume_outcome=resume_outcome,
reducers=reducers,
subgraphs=prepared_subgraphs,
)
@dataclass(frozen=True, slots=True)
class WorkflowServer:
"""First-slice long-lived server composition without transport concerns."""
config: WorkflowServerConfig
stores: WorkflowStores
context: WorkflowOperationContext
api: WorkflowApi
events: InMemoryWorkflowEventRecorder
@staticmethod
def trace_range(*, start: int, limit: int) -> TraceRange:
return TraceRange(start=start, limit=limit)
def build_local_static_workflow_server(root: str | Path) -> WorkflowServer:
"""Build a durable local/static workflow server composition."""
config = WorkflowServerConfig(store_root=Path(root))
stores = file_workflow_stores(config.store_root)
events = InMemoryWorkflowEventRecorder()
specs = StaticWorkflowSpecProvider(builtin_sources())
runtime = LocalWorkflowRuntimeRunner(
specs=specs,
artifact_store=stores.artifact_store,
)
context = WorkflowOperationContext(
artifact_store=stores.artifact_store,
draft_workspace_store=stores.draft_workspace_store,
run_store=stores.run_store,
events=events,
specs=specs,
runtime=runtime,
live_sources=None,
)
api = durable_workflow_api(context)
return WorkflowServer(
config=config,
stores=stores,
context=context,
api=api,
events=events,
)
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from wf_api.local_sources import (
BUILTIN_SOURCE_ID,
RECIPE_SOURCE_ID,
builtin_sources,
get_qualified_spec,
qualify_spec,
)
from wf_authoring import constant
def test_builtin_sources_expose_workflow_stdlib() -> None:
sources = builtin_sources()
assert BUILTIN_SOURCE_ID == "wf.std"
assert RECIPE_SOURCE_ID == "wf.recipes"
assert "wf.std" in sources
assert "wf.std.constant" in sources["wf.std"].capabilities.node_specs
assert "wf.std.replace" in sources["wf.std"].capabilities.reducers
def test_get_qualified_spec_resolves_planner_visible_spec() -> None:
sources = builtin_sources()
spec = get_qualified_spec(sources, "wf.std.constant")
assert spec.name == "wf.std.constant"
assert spec.outcomes == ("ok",)
def test_qualify_spec_scopes_authoring_node_name() -> None:
qualified = qualify_spec("custom.local", constant)
assert qualified.name == "custom.local.authoring.constant"
assert qualified.input_model is constant.input_model
assert qualified.output_model is constant.output_model
def test_mcp_builtin_module_reexports_canonical_helpers() -> None:
from wf_mcp.broker.service import builtins as mcp_builtins
assert mcp_builtins.BUILTIN_CONNECTION_ID == BUILTIN_SOURCE_ID
assert mcp_builtins.BUILTIN_SOURCE_ID == BUILTIN_SOURCE_ID
assert mcp_builtins.builtin_sources is builtin_sources
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import ast
import asyncio
from pathlib import Path
from wf_api.models import RawWorkflowPlan
from wf_core import END
from wf_server import build_local_static_workflow_server
def _constant_plan() -> RawWorkflowPlan:
return RawWorkflowPlan.model_validate(
{
"name": "server_constant",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {
"type": "object",
"properties": {
"result": {"type": "string", "reducer": "wf.std.replace"}
},
},
"output_schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
"required": ["result"],
},
"outcomes": ["ok"],
"start": "constant",
"nodes": [
{
"id": "constant",
"type": "node",
"node": "wf.std.constant",
"input": [
{
"value": "hello from server",
"target": {"root": "local", "parts": ["value"]},
}
],
"output": [
{
"source": {"root": "local", "parts": ["value"]},
"target": {"root": "state", "parts": ["result"]},
}
],
}
],
"edges": [{"from": "constant", "outcome": "ok", "to": END}],
"output": [
{
"path": {"root": "state", "parts": ["result"]},
"target": {"root": "local", "parts": ["result"]},
}
],
}
)
def test_wf_server_context_imports_no_wfmcp_service() -> None:
path = Path("src/wf_server/context.py")
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
violations: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module is not None:
if node.module == "wf_mcp.broker" or node.module.endswith(".core"):
violations.append(f"{node.lineno}: from {node.module} import ...")
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name in {"wf_mcp.broker", "wf_mcp.broker.service.core"}:
violations.append(f"{node.lineno}: import {alias.name}")
assert violations == []
def test_local_static_server_runs_deployment_and_persists_run(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
api = server.api
plan = _constant_plan()
artifact_result = asyncio.run(
api.create_artifact_from_plan(
artifact_id="server_constant",
version=1,
title="Server Constant",
plan=plan,
outcomes=["ok"],
source_bindings={"wf.std": "wf.std"},
)
)
deployment_result = asyncio.run(
api.save_deployment(
{
"id": "server_constant.default",
"artifact_id": "server_constant",
"artifact_version": 1,
"bindings": [{"logical_source": "wf.std", "concrete_source": "wf.std"}],
}
)
)
run_result = asyncio.run(
api.run_deployment(
deployment_id="server_constant.default",
workflow_input={},
)
)
assert artifact_result["artifact_id"] == "server_constant"
assert deployment_result["deployment_id"] == "server_constant.default"
assert run_result["status"] == "completed"
assert run_result["output"]["result"] == "hello from server"
assert isinstance(run_result["run_id"], str)
assert (
server.stores.run_store.get_run(run_result["run_id"]).id == run_result["run_id"]
)
def test_local_static_server_inspects_and_reads_bounded_trace(tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
api = server.api
plan = _constant_plan()
asyncio.run(
api.create_artifact_from_plan(
artifact_id="server_trace",
version=1,
title="Server Trace",
plan=plan.model_copy(update={"name": "server_trace"}),
outcomes=["ok"],
source_bindings={"wf.std": "wf.std"},
)
)
asyncio.run(
api.save_deployment(
{
"id": "server_trace.default",
"artifact_id": "server_trace",
"artifact_version": 1,
"bindings": [{"logical_source": "wf.std", "concrete_source": "wf.std"}],
}
)
)
run_result = asyncio.run(
api.run_deployment(deployment_id="server_trace.default", workflow_input={})
)
summary = asyncio.run(api.inspect_run(run_id=run_result["run_id"]))
trace = asyncio.run(
api.read_run_trace(
run_id=run_result["run_id"],
trace_range=server.trace_range(start=0, limit=1),
)
)
assert "trace" not in summary
assert summary["trace_count"] >= 1
assert trace["trace_start"] == 0
assert trace["trace_limit"] == 1
assert len(trace["trace"]) == 1