new wf_server: local static workflow server; docs
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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.",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user