fix: harden workflow config rpc target routing
This commit is contained in:
@@ -5,7 +5,7 @@ from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
|
||||
from wf_cli.context import config_path_from_context, load_cli_context
|
||||
from wf_cli.context import load_local_cli_context_from_typer as load_cli_context
|
||||
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
||||
from wf_cli.io import emit_json
|
||||
|
||||
@@ -37,7 +37,7 @@ def list_artifacts(
|
||||
] = ListOutputFormat.JSON,
|
||||
) -> None:
|
||||
"""List compact saved artifact summaries."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
payload = asyncio.run(
|
||||
context.handlers.list_artifacts(
|
||||
query=query,
|
||||
@@ -62,7 +62,7 @@ def inspect_artifact(
|
||||
version: Annotated[int, typer.Argument(min=1, help="Artifact version.")],
|
||||
) -> None:
|
||||
"""Inspect one saved artifact version."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
asyncio.run(
|
||||
context.handlers.inspect_artifact(artifact_id=artifact_id, version=version)
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from wf_cli.context import config_path_from_context, load_cli_context
|
||||
from wf_cli.context import load_local_cli_context_from_typer as load_cli_context
|
||||
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
||||
from wf_cli.io import CliInputError, emit_json, parse_bindings, parse_json_input
|
||||
|
||||
@@ -30,7 +30,7 @@ def validate_deployment(
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Validate one saved workflow deployment."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
payload = asyncio.run(
|
||||
context.handlers.validate_deployment(
|
||||
deployment_id=deployment_id,
|
||||
@@ -48,7 +48,7 @@ def list_deployments(
|
||||
] = ListOutputFormat.JSON,
|
||||
) -> None:
|
||||
"""List saved workflow deployments."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
payload = asyncio.run(context.handlers.list_deployments())
|
||||
emit_list_payload(
|
||||
payload,
|
||||
@@ -65,7 +65,7 @@ def inspect_deployment(
|
||||
deployment_id: Annotated[str, typer.Argument(help="Deployment id.")],
|
||||
) -> None:
|
||||
"""Inspect one saved deployment."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
asyncio.run(context.handlers.inspect_deployment(deployment_id=deployment_id))
|
||||
)
|
||||
@@ -106,7 +106,7 @@ def save_deployment(
|
||||
)
|
||||
except CliInputError as exc:
|
||||
raise typer.BadParameter(str(exc)) from exc
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(asyncio.run(context.handlers.save_deployment(payload)))
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ def delete_deployment(
|
||||
deployment_id: Annotated[str, typer.Argument(help="Deployment id.")],
|
||||
) -> None:
|
||||
"""Delete one saved deployment."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
asyncio.run(context.handlers.delete_deployment(deployment_id=deployment_id))
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
|
||||
from wf_cli.context import config_path_from_context, load_cli_context
|
||||
from wf_cli.context import load_local_cli_context_from_typer as load_cli_context
|
||||
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
||||
from wf_cli.io import CliInputError, emit_json, parse_bindings, parse_json_value
|
||||
|
||||
@@ -25,7 +25,7 @@ def list_drafts(
|
||||
] = ListOutputFormat.JSON,
|
||||
) -> None:
|
||||
"""List stored draft workspaces."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
payload = asyncio.run(context.handlers.list_draft_workspaces())
|
||||
emit_list_payload(
|
||||
payload,
|
||||
@@ -45,7 +45,7 @@ def inspect_draft(
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Inspect one draft workspace."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
asyncio.run(
|
||||
context.handlers.get_draft_workspace(
|
||||
@@ -69,7 +69,7 @@ def create_from_capability(
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Bootstrap a draft workspace from inspect_capability wrapper hints."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
asyncio.run(
|
||||
context.handlers.create_draft_workspace_from_capability(
|
||||
@@ -103,7 +103,7 @@ def patch_draft(
|
||||
raise typer.BadParameter(str(exc)) from exc
|
||||
if not isinstance(patch, list):
|
||||
raise typer.BadParameter("draft patch input must be a JSON array")
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
asyncio.run(
|
||||
context.handlers.patch_draft_workspace(
|
||||
@@ -121,7 +121,7 @@ def validate_draft(
|
||||
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
|
||||
) -> None:
|
||||
"""Validate one stored draft workspace."""
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
emit_json(
|
||||
asyncio.run(
|
||||
context.handlers.validate_draft_workspace(workspace_id=workspace_id)
|
||||
@@ -156,7 +156,7 @@ def save_draft(
|
||||
source_bindings = parse_bindings(binding or [])
|
||||
except CliInputError as exc:
|
||||
raise typer.BadParameter(str(exc)) from exc
|
||||
context = load_cli_context(config_path_from_context(ctx))
|
||||
context = load_cli_context(ctx)
|
||||
if kind == "wrapper":
|
||||
payload = asyncio.run(
|
||||
context.handlers.create_wrapper_from_workspace(
|
||||
|
||||
+115
-17
@@ -2,19 +2,21 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
import typer
|
||||
from pydantic import ValidationError
|
||||
|
||||
from wf_api import WorkflowApi
|
||||
from wf_mcp.broker import build_service_from_config, load_broker_config
|
||||
from wf_mcp.broker.service import WfMcpService
|
||||
from wf_mcp.broker.service.workflow_operation_context import context_from_service
|
||||
from wf_config import (
|
||||
FilesystemStoreConfig,
|
||||
LocalTargetConfig,
|
||||
RpcHttpTargetConfig,
|
||||
load_workflow_config,
|
||||
)
|
||||
from wf_mcp.broker import build_service_from_config, load_broker_config
|
||||
from wf_mcp.broker.service import WfMcpService
|
||||
from wf_mcp.broker.service.workflow_operation_context import context_from_service
|
||||
from wf_server import build_local_static_workflow_server
|
||||
from wf_transport_rpc_http import RpcWorkflowApiClient
|
||||
|
||||
@@ -28,6 +30,15 @@ class CliContext:
|
||||
handlers: "WorkflowApi | RpcWorkflowApiClient"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalCliContext:
|
||||
"""CLI context for commands that still require same-process WorkflowApi."""
|
||||
|
||||
config_path: Path
|
||||
service: WfMcpService | None
|
||||
handlers: WorkflowApi
|
||||
|
||||
|
||||
def config_path_from_context(ctx: typer.Context) -> str:
|
||||
"""Return the root --config path captured by the Typer callback."""
|
||||
obj = ctx.obj if isinstance(ctx.obj, dict) else {}
|
||||
@@ -44,7 +55,24 @@ def load_cli_context(
|
||||
) -> CliContext:
|
||||
"""Load config and build workflow-surface handlers for CLI commands."""
|
||||
resolved_config_path = Path(config_path)
|
||||
if resolved_config_path.name == "wf_mcp.config.json":
|
||||
if force_local and rpc_url is not None:
|
||||
raise ValueError("--local and --url are mutually exclusive")
|
||||
|
||||
if rpc_url is not None:
|
||||
_validate_rpc_url(rpc_url)
|
||||
return CliContext(
|
||||
config_path=resolved_config_path,
|
||||
service=None,
|
||||
handlers=RpcWorkflowApiClient(
|
||||
url=rpc_url,
|
||||
timeout_seconds=_rpc_timeout_from_optional_config(
|
||||
resolved_config_path,
|
||||
override=rpc_timeout_seconds,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if _is_legacy_mcp_config(resolved_config_path):
|
||||
config = load_broker_config(resolved_config_path)
|
||||
service = build_service_from_config(config)
|
||||
return CliContext(
|
||||
@@ -55,13 +83,6 @@ def load_cli_context(
|
||||
|
||||
config = load_workflow_config(resolved_config_path)
|
||||
target = config.client.target
|
||||
if rpc_url is not None:
|
||||
timeout = rpc_timeout_seconds if rpc_timeout_seconds is not None else 30.0
|
||||
return CliContext(
|
||||
config_path=resolved_config_path,
|
||||
service=None,
|
||||
handlers=RpcWorkflowApiClient(url=rpc_url, timeout_seconds=timeout),
|
||||
)
|
||||
if force_local or isinstance(target, LocalTargetConfig):
|
||||
store = config.server.store
|
||||
if not isinstance(store, FilesystemStoreConfig):
|
||||
@@ -88,6 +109,32 @@ def load_cli_context(
|
||||
raise ValueError(f"unsupported workflow target {target!r}")
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
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:
|
||||
obj = ctx.obj if isinstance(ctx.obj, dict) else {}
|
||||
return bool(obj.get("force_local", False))
|
||||
@@ -106,9 +153,60 @@ def rpc_timeout_from_context(ctx: typer.Context) -> float | None:
|
||||
|
||||
|
||||
def load_cli_context_from_typer(ctx: typer.Context) -> CliContext:
|
||||
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),
|
||||
)
|
||||
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),
|
||||
)
|
||||
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:
|
||||
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://")
|
||||
|
||||
Reference in New Issue
Block a user