331 lines
11 KiB
Python
331 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
import typer
|
|
from pydantic import ValidationError
|
|
|
|
if TYPE_CHECKING:
|
|
from wf_api import (
|
|
WorkflowAdminSurface,
|
|
WorkflowApi,
|
|
WorkflowApiSurface,
|
|
WorkflowSourceAdminSurface,
|
|
WorkflowSourceRegistrySurface,
|
|
)
|
|
from wf_config import WorkflowConfigFile
|
|
from wf_mcp.broker.service import WfMcpService
|
|
from wf_server.context import WorkflowServer
|
|
from wf_transport_rpc_http import RpcWorkflowApiClient
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CliContext:
|
|
"""Protocol-neutral CLI handle over local or remote workflow operations."""
|
|
|
|
config_path: Path
|
|
service: WfMcpService | None
|
|
handlers: WorkflowApiSurface
|
|
source_admin: WorkflowSourceAdminSurface
|
|
admin: WorkflowAdminSurface
|
|
source_registry_admin: WorkflowSourceRegistrySurface | None = None
|
|
verbose: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LocalCliContext:
|
|
"""CLI context for commands that still require same-process WorkflowApi."""
|
|
|
|
config_path: Path
|
|
service: WfMcpService | None
|
|
handlers: WorkflowApi
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CliTyperState:
|
|
"""Typed boundary for Typer's untyped `Context.obj` payload.
|
|
|
|
Typer/Click do not make `Context.obj` generic, so every command should read
|
|
root CLI options through this adapter instead of spelling dict keys locally.
|
|
"""
|
|
|
|
config_path: str = "wf.config.json"
|
|
force_local: bool = False
|
|
rpc_url: str | None = None
|
|
rpc_timeout_seconds: float | None = None
|
|
verbose: bool = False
|
|
|
|
@classmethod
|
|
def from_context(cls, ctx: typer.Context) -> CliTyperState:
|
|
obj = ctx.obj
|
|
if isinstance(obj, cls):
|
|
return obj
|
|
if isinstance(obj, Mapping):
|
|
return cls.from_mapping(obj)
|
|
return cls()
|
|
|
|
@classmethod
|
|
def from_mapping(cls, obj: Mapping[object, object]) -> CliTyperState:
|
|
config_path = obj.get("config_path", cls.config_path)
|
|
rpc_url = obj.get("rpc_url")
|
|
timeout = obj.get("rpc_timeout_seconds")
|
|
return cls(
|
|
config_path=(
|
|
config_path if isinstance(config_path, str) else cls.config_path
|
|
),
|
|
force_local=bool(obj.get("force_local", cls.force_local)),
|
|
rpc_url=rpc_url if isinstance(rpc_url, str) else None,
|
|
rpc_timeout_seconds=(
|
|
float(timeout) if isinstance(timeout, float | int) else None
|
|
),
|
|
verbose=bool(obj.get("verbose", cls.verbose)),
|
|
)
|
|
|
|
|
|
def config_path_from_context(ctx: typer.Context) -> str:
|
|
"""Return the root --config path captured by the Typer callback."""
|
|
return CliTyperState.from_context(ctx).config_path
|
|
|
|
|
|
def build_workflow_server_from_workflow_config(
|
|
config: WorkflowConfigFile,
|
|
) -> WorkflowServer:
|
|
"""Build the local server without importing the server runtime at CLI startup."""
|
|
from wf_server.config import build_workflow_server_from_workflow_config as build
|
|
|
|
# Local CLI exposes the full draft command group, so it is an explicit
|
|
# draft-bearing composition even though the neutral server default is not.
|
|
return build(config, drafts=True)
|
|
|
|
|
|
def load_cli_context(
|
|
config_path: str | Path,
|
|
*,
|
|
force_local: bool = False,
|
|
rpc_url: str | None = None,
|
|
rpc_timeout_seconds: float | None = None,
|
|
verbose: bool = False,
|
|
) -> CliContext:
|
|
"""Load config and build workflow-surface handlers for CLI commands."""
|
|
resolved_config_path = Path(config_path)
|
|
if force_local and rpc_url is not None:
|
|
raise ValueError("--local and --url are mutually exclusive")
|
|
|
|
if rpc_url is not None:
|
|
# Keep transport construction out of `wf --help`; only commands that
|
|
# contact a remote target need the RPC client and its runtime stack.
|
|
_validate_rpc_url(rpc_url)
|
|
client = rpc_client_from_target(
|
|
url=rpc_url,
|
|
timeout_seconds=_rpc_timeout_from_optional_config(
|
|
resolved_config_path,
|
|
override=rpc_timeout_seconds,
|
|
),
|
|
)
|
|
return CliContext(
|
|
config_path=resolved_config_path,
|
|
service=None,
|
|
handlers=client,
|
|
source_admin=client,
|
|
admin=client,
|
|
source_registry_admin=client,
|
|
verbose=verbose,
|
|
)
|
|
|
|
if _is_legacy_mcp_config(resolved_config_path):
|
|
from wf_api import (
|
|
WorkflowAdminApi,
|
|
WorkflowApi,
|
|
WorkflowSourceAdminApi,
|
|
)
|
|
from wf_mcp.broker import build_service_from_config, load_broker_config
|
|
from wf_mcp.broker.service.workflow_operation_context import (
|
|
context_from_service,
|
|
)
|
|
|
|
config = load_broker_config(resolved_config_path)
|
|
service = build_service_from_config(config)
|
|
return CliContext(
|
|
config_path=resolved_config_path,
|
|
service=service,
|
|
# Legacy MCP CLI commands include draft authoring operations.
|
|
handlers=WorkflowApi(context_from_service(service), drafts=True),
|
|
source_admin=WorkflowSourceAdminApi(context_from_service(service)),
|
|
admin=WorkflowAdminApi(
|
|
connections=service.connection_service,
|
|
events=service.events,
|
|
),
|
|
verbose=verbose,
|
|
)
|
|
|
|
from wf_api import WorkflowApi
|
|
from wf_config import (
|
|
FilesystemStoreConfig,
|
|
LocalTargetConfig,
|
|
RpcHttpTargetConfig,
|
|
load_workflow_config,
|
|
)
|
|
|
|
config = load_workflow_config(resolved_config_path)
|
|
target = config.client.target
|
|
if force_local or isinstance(target, LocalTargetConfig):
|
|
store = config.server.workflow_store
|
|
if not isinstance(store, FilesystemStoreConfig):
|
|
raise ValueError("local CLI target currently requires filesystem store")
|
|
# Local means "same-process WorkflowServer", not "local-only sources".
|
|
# Configured MCP HTTP/stdio sources may still open external transports.
|
|
server = build_workflow_server_from_workflow_config(config)
|
|
return CliContext(
|
|
config_path=resolved_config_path,
|
|
service=None,
|
|
handlers=server.api,
|
|
source_admin=server.source_admin,
|
|
admin=server.admin,
|
|
verbose=verbose,
|
|
)
|
|
if isinstance(target, RpcHttpTargetConfig):
|
|
client = rpc_client_from_target(
|
|
url=str(target.url),
|
|
timeout_seconds=(
|
|
rpc_timeout_seconds
|
|
if rpc_timeout_seconds is not None
|
|
else target.timeout_seconds
|
|
),
|
|
)
|
|
return CliContext(
|
|
config_path=resolved_config_path,
|
|
service=None,
|
|
handlers=client,
|
|
source_admin=client,
|
|
admin=client,
|
|
source_registry_admin=client,
|
|
verbose=verbose,
|
|
)
|
|
raise ValueError(f"unsupported workflow target {target!r}")
|
|
|
|
|
|
def rpc_client_from_target(
|
|
*,
|
|
url: str,
|
|
timeout_seconds: float,
|
|
) -> RpcWorkflowApiClient:
|
|
"""Build the remote workflow surface for a resolved RPC target.
|
|
|
|
CLI tests patch this project-owned seam instead of monkeypatching `httpx2`
|
|
internals. The production path still keeps HTTP construction inside the
|
|
transport package.
|
|
"""
|
|
|
|
# Importing the client here keeps HTTP/RPC server dependencies out of the
|
|
# CLI's command registration and help path.
|
|
from wf_transport_rpc_http import RpcWorkflowApiClient
|
|
|
|
return RpcWorkflowApiClient(url=url, timeout_seconds=timeout_seconds)
|
|
|
|
|
|
def load_local_cli_context(
|
|
config_path: str | Path,
|
|
*,
|
|
force_local: bool = False,
|
|
rpc_url: str | None = None,
|
|
rpc_timeout_seconds: float | None = None,
|
|
) -> LocalCliContext:
|
|
"""Load a local WorkflowApi context for commands not remote-enabled yet."""
|
|
context = load_cli_context(
|
|
config_path,
|
|
force_local=force_local,
|
|
rpc_url=rpc_url,
|
|
rpc_timeout_seconds=rpc_timeout_seconds,
|
|
)
|
|
from wf_api import WorkflowApi
|
|
|
|
if not isinstance(context.handlers, WorkflowApi):
|
|
raise ValueError(
|
|
"this CLI command is not available for rpc_http targets yet; "
|
|
"use --local or run a cap/run command"
|
|
)
|
|
return LocalCliContext(
|
|
config_path=context.config_path,
|
|
service=context.service,
|
|
handlers=context.handlers,
|
|
)
|
|
|
|
|
|
def force_local_from_context(ctx: typer.Context) -> bool:
|
|
return CliTyperState.from_context(ctx).force_local
|
|
|
|
|
|
def rpc_url_from_context(ctx: typer.Context) -> str | None:
|
|
return CliTyperState.from_context(ctx).rpc_url
|
|
|
|
|
|
def rpc_timeout_from_context(ctx: typer.Context) -> float | None:
|
|
return CliTyperState.from_context(ctx).rpc_timeout_seconds
|
|
|
|
|
|
def load_cli_context_from_typer(ctx: typer.Context) -> CliContext:
|
|
try:
|
|
return load_cli_context(
|
|
config_path_from_context(ctx),
|
|
force_local=force_local_from_context(ctx),
|
|
rpc_url=rpc_url_from_context(ctx),
|
|
rpc_timeout_seconds=rpc_timeout_from_context(ctx),
|
|
verbose=CliTyperState.from_context(ctx).verbose,
|
|
)
|
|
except ValueError as exc:
|
|
raise typer.BadParameter(str(exc)) from exc
|
|
|
|
|
|
def load_local_cli_context_from_typer(ctx: typer.Context) -> LocalCliContext:
|
|
try:
|
|
return load_local_cli_context(
|
|
config_path_from_context(ctx),
|
|
force_local=force_local_from_context(ctx),
|
|
rpc_url=rpc_url_from_context(ctx),
|
|
rpc_timeout_seconds=rpc_timeout_from_context(ctx),
|
|
)
|
|
except ValueError as exc:
|
|
raise typer.BadParameter(str(exc)) from exc
|
|
|
|
|
|
def _is_legacy_mcp_config(path: Path) -> bool:
|
|
"""Detect legacy broker config by content, not filename.
|
|
|
|
This keeps `wf_mcp.config.json` compatibility without making the filename a
|
|
load-bearing part of the neutral workflow config migration.
|
|
"""
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
return False
|
|
if any(key in data for key in ("version", "client", "server")):
|
|
return False
|
|
return any(key in data for key in ("store_root", "connections"))
|
|
|
|
|
|
def _rpc_timeout_from_optional_config(
|
|
path: Path,
|
|
*,
|
|
override: float | None,
|
|
) -> float:
|
|
from wf_config import RpcHttpTargetConfig, load_workflow_config
|
|
|
|
if override is not None:
|
|
return override
|
|
try:
|
|
config = load_workflow_config(path)
|
|
except FileNotFoundError, json.JSONDecodeError, ValidationError:
|
|
return 30.0
|
|
target = config.client.target
|
|
if isinstance(target, RpcHttpTargetConfig):
|
|
return target.timeout_seconds
|
|
return 30.0
|
|
|
|
|
|
def _validate_rpc_url(url: str) -> None:
|
|
if not url.startswith(("http://", "https://")):
|
|
raise ValueError("rpc url must start with http:// or https://")
|