docs: record workflow config target slice

This commit is contained in:
lda
2026-06-03 09:08:28 +07:00 Verified
parent 14183a9eb6
commit 8a1d627f06
12 changed files with 137 additions and 89 deletions
+3
View File
@@ -93,6 +93,9 @@ implementation state.
- Completed: the first JSON-RPC-over-HTTP transport can expose the local/static
`WorkflowServer` through fixed dotted methods. Remote CLI targeting remains
the next transport-facing slice.
- Completed: workflow config now distinguishes client targets from server
hosting config, and selected `wf` commands can target JSON-RPC HTTP with
explicit CLI overrides.
5. **CLI/API alignment**
- Let the CLI target either local process-backed stores/runtime or the future
@@ -452,6 +452,26 @@ Relative paths should resolve relative to the config file directory.
- Do not derive identity by splitting dotted source ids.
- Do not use MCP as the first-class `wf` CLI target.
## Implementation Status
First slice implemented:
- neutral `wf_config` models and loader
- filesystem server store config
- stdlib source bootstrap config
- local and JSON-RPC client targets
- `wf` root overrides for `--local`, `--url`, and `--timeout`
- remote JSON-RPC client support for capability and run CLI commands
- `wf-rpc-server --config` support for server store and RPC HTTP transport
Still future:
- store-backed mutable source registry
- MCP/OpenAPI source config
- `/mcp` hosting from neutral server config
- remote draft/artifact/deployment CLI commands
- auth and SQL stores
## Next Implementation Slice
First config implementation should be small:
+7 -2
View File
@@ -9,7 +9,12 @@ 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_config import (
FilesystemStoreConfig,
LocalTargetConfig,
RpcHttpTargetConfig,
load_workflow_config,
)
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient
@@ -20,7 +25,7 @@ class CliContext:
config_path: Path
service: WfMcpService | None
handlers: WorkflowApi | RpcWorkflowApiClient
handlers: "WorkflowApi | RpcWorkflowApiClient"
def config_path_from_context(ctx: typer.Context) -> str:
+12 -4
View File
@@ -2,10 +2,14 @@ from __future__ import annotations
from pathlib import Path
from pathlib import Path
import typer
import uvicorn
from wf_config import FilesystemStoreConfig, RpcHttpTransportConfig, load_workflow_config
from wf_config import (
FilesystemStoreConfig,
RpcHttpTransportConfig,
load_workflow_config,
)
from wf_server import build_local_static_workflow_server
@@ -37,7 +41,9 @@ def serve(
workflow_config = load_workflow_config(config)
store = workflow_config.server.store
if not isinstance(store, FilesystemStoreConfig):
raise typer.BadParameter("wf-rpc-server currently requires filesystem store")
raise typer.BadParameter(
"wf-rpc-server currently requires filesystem store"
)
resolved_store_root = resolved_store_root or store.root
rpc_transport = next(
(
@@ -51,7 +57,9 @@ def serve(
resolved_host = host or rpc_transport.host
resolved_port = port or rpc_transport.port
if resolved_store_root is None:
raise typer.BadParameter("--store-root is required when --config is not supplied")
raise typer.BadParameter(
"--store-root is required when --config is not supplied"
)
server = build_local_static_workflow_server(resolved_store_root)
rpc_app = create_rpc_app(server)
+15 -15
View File
@@ -55,20 +55,16 @@ def test_context_from_service_exposes_existing_store_objects(tmp_path: Path) ->
encoding="utf-8",
)
cli_context = load_cli_context(config_path)
service = cli_context.service
assert service is not None
operation_context = context_from_service(cli_context.service)
operation_context = context_from_service(service)
assert isinstance(operation_context, WorkflowOperationContext)
assert operation_context.artifact_store is cli_context.service.artifact_store
assert (
operation_context.draft_workspace_store
is cli_context.service.draft_workspace_store
)
assert operation_context.run_store is cli_context.service.run_store
assert (
operation_context.specs.capability_sources
is cli_context.service.capability_sources
)
assert operation_context.artifact_store is service.artifact_store
assert operation_context.draft_workspace_store is service.draft_workspace_store
assert operation_context.run_store is service.run_store
assert operation_context.specs.capability_sources is service.capability_sources
def test_context_from_service_delegates_specs_and_events(tmp_path: Path) -> None:
@@ -78,12 +74,14 @@ def test_context_from_service_delegates_specs_and_events(tmp_path: Path) -> None
encoding="utf-8",
)
cli_context = load_cli_context(config_path)
operation_context = context_from_service(cli_context.service)
service = cli_context.service
assert service is not None
operation_context = context_from_service(service)
event = object()
operation_context.events.record_event(event)
assert cli_context.service.list_events()[-1] is event
assert service.list_events()[-1] is event
def test_context_from_service_record_workflow_event(tmp_path: Path) -> None:
@@ -93,7 +91,9 @@ def test_context_from_service_record_workflow_event(tmp_path: Path) -> None:
encoding="utf-8",
)
cli_context = load_cli_context(config_path)
operation_context = context_from_service(cli_context.service)
service = cli_context.service
assert service is not None
operation_context = context_from_service(service)
operation_context.events.record_workflow_event(
"workflow_artifact_saved",
@@ -101,7 +101,7 @@ def test_context_from_service_record_workflow_event(tmp_path: Path) -> None:
payload={"artifact_id": "demo", "version": 1},
)
recorded = cli_context.service.events.list_events()[-1]
recorded = service.events.list_events()[-1]
assert recorded.kind == "workflow_artifact_saved"
assert recorded.capability_id == "workflow.demo.v1"
assert recorded.payload["artifact_id"] == "demo"
+6 -5
View File
@@ -27,12 +27,13 @@ def test_load_cli_context_builds_service_and_handlers(tmp_path: Path) -> None:
)
context = load_cli_context(config_path)
service = context.service
assert service is not None
assert context.config_path == config_path
assert context.service.connections.list_all()[0].id == "demo.personal"
assert context.handlers.context.artifact_store is context.service.artifact_store
assert service.connections.list_all()[0].id == "demo.personal"
assert context.handlers.context.artifact_store is service.artifact_store
assert (
context.handlers.context.draft_workspace_store
is context.service.draft_workspace_store
context.handlers.context.draft_workspace_store is service.draft_workspace_store
)
assert context.handlers.context.run_store is context.service.run_store
assert context.handlers.context.run_store is service.run_store
+24 -22
View File
@@ -9,7 +9,9 @@ from typer.testing import CliRunner
from wf_artifacts import FileWorkflowArtifactStore, WorkflowDeployment
from wf_cli.app import app
from wf_cli.context import CliContext, load_cli_context
from typer import Context as TyperContext
from wf_cli.context import CliContext, config_path_from_context, load_cli_context
from wf_cli.formats import ListOutputFormat, render_list_payload
from wf_cli.io import CliInputError, parse_bindings, parse_json_value
@@ -17,6 +19,18 @@ from tests.wf_mcp.test_support import echo_tool, local_temp_root
from tests.wf_mcp.workflow_surface.conftest import echo_artifact
def _load_cli_context_with_specs(ctx: TyperContext | str | Path) -> CliContext:
if isinstance(ctx, (str, Path)):
config_path = ctx
else:
config_path = config_path_from_context(ctx)
context = load_cli_context(config_path)
service = context.service
assert service is not None
service.register_specs("demo.personal", echo_tool)
return context
def test_render_list_payload_ids_uses_requested_id_field() -> None:
payload = {"capabilities": [{"name": "wf.std.truthy"}, {"name": "wf.std.add"}]}
@@ -132,30 +146,14 @@ def _write_cli_config(root: Path) -> Path:
return config_path
from wf_cli.context import load_cli_context_from_typer, config_path_from_context, load_cli_context
def _load_cli_context_with_specs(ctx: typer.Context | str | Path) -> CliContext:
if isinstance(ctx, (str, Path)):
config_path = ctx
else:
config_path = config_path_from_context(ctx)
"""Seed executable demo specs for CLI tests only.
Config loading registers connections and stores; it does not register
in-memory test NodeSpecs. Production source registration remains outside
this CLI slice.
"""
context = load_cli_context(config_path)
context.service.register_specs("demo.personal", echo_tool)
return context
def test_wf_cap_list_outputs_json() -> None:
root = local_temp_root() / "wf_cli_cap_list"
root.mkdir(parents=True, exist_ok=True)
config_path = _write_cli_config(root)
with patch("wf_cli.commands.caps.load_cli_context_from_typer", _load_cli_context_with_specs):
with patch(
"wf_cli.commands.caps.load_cli_context_from_typer", _load_cli_context_with_specs
):
result = runner.invoke(
app,
["--config", str(config_path), "cap", "list", "--source", "demo.personal"],
@@ -172,7 +170,9 @@ def test_wf_cap_list_ids_format() -> None:
root.mkdir(parents=True, exist_ok=True)
config_path = _write_cli_config(root)
with patch("wf_cli.commands.caps.load_cli_context_from_typer", _load_cli_context_with_specs):
with patch(
"wf_cli.commands.caps.load_cli_context_from_typer", _load_cli_context_with_specs
):
result = runner.invoke(
app,
[
@@ -196,7 +196,9 @@ def test_wf_cap_inspect_outputs_detail() -> None:
root.mkdir(parents=True, exist_ok=True)
config_path = _write_cli_config(root)
with patch("wf_cli.commands.caps.load_cli_context_from_typer", _load_cli_context_with_specs):
with patch(
"wf_cli.commands.caps.load_cli_context_from_typer", _load_cli_context_with_specs
):
result = runner.invoke(
app,
["--config", str(config_path), "cap", "inspect", "demo.personal.echo_tool"],
+11 -12
View File
@@ -2,8 +2,15 @@ from __future__ import annotations
import json
import httpx
from typer.testing import CliRunner
from wf_api.models import RawWorkflowPlan
from wf_cli.app import app
from wf_cli.context import load_cli_context
from wf_transport_rpc_http import RpcWorkflowApiClient
from wf_core import END
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None:
@@ -57,16 +64,6 @@ def test_load_cli_context_local_override_beats_rpc_config(tmp_path) -> None:
assert not isinstance(context.handlers, RpcWorkflowApiClient)
assert context.service is None
assert context.config_path == config_path
import asyncio
import httpx
from typer.testing import CliRunner
from wf_api.models import RawWorkflowPlan
from wf_cli.app import app
from wf_core import END
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import create_rpc_app
def _constant_plan() -> RawWorkflowPlan:
@@ -124,7 +121,9 @@ def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
original_client = httpx.AsyncClient
monkeypatch.setattr(
"wf_transport_rpc_http.client.httpx.AsyncClient",
lambda *args, **kwargs: original_client(transport=transport, base_url="http://test"),
lambda *args, **kwargs: original_client(
transport=transport, base_url="http://test"
),
)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
+34 -24
View File
@@ -8,7 +8,9 @@ from typer.testing import CliRunner
from wf_artifacts import FileWorkflowArtifactStore, WorkflowDeployment
from wf_cli.app import app
from wf_cli.context import CliContext, load_cli_context
from typer import Context as TyperContext
from wf_cli.context import CliContext, config_path_from_context, load_cli_context
from tests.wf_mcp.test_support import echo_tool, local_temp_root
from tests.wf_mcp.workflow_surface.conftest import echo_artifact
@@ -17,6 +19,25 @@ from tests.wf_mcp.workflow_surface.conftest import echo_artifact
runner = CliRunner()
def _load_cli_context_with_specs(ctx: TyperContext | str | Path) -> CliContext:
"""Test-only hook: seed executable demo specs for CLI integration tests.
``build_service_from_config`` registers connections, adapters, and
file-backed stores, but not in-memory node specs. Production code does
not auto-register specs; this helper exists solely so CLI tests can
exercise the full command path with a runnable deployment.
"""
if isinstance(ctx, (str, Path)):
config_path = ctx
else:
config_path = config_path_from_context(ctx)
context = load_cli_context(config_path)
service = context.service
assert service is not None
service.register_specs("demo.personal", echo_tool)
return context
def _write_config(root: Path) -> Path:
config_path = root / "wf_mcp.config.json"
config_path.write_text(
@@ -53,25 +74,6 @@ def _seed_echo_deployment(root: Path) -> Path:
return config_path
from wf_cli.context import load_cli_context_from_typer, config_path_from_context, load_cli_context
def _load_cli_context_with_specs(ctx: typer.Context | str | Path) -> CliContext:
if isinstance(ctx, (str, Path)):
config_path = ctx
else:
config_path = config_path_from_context(ctx)
"""Test-only hook: seed executable demo specs for CLI integration tests.
``build_service_from_config`` registers connections, adapters, and
file-backed stores, but not in-memory node specs. Production code does
not auto-register specs; this helper exists solely so CLI tests can
exercise the full command path with a runnable deployment.
"""
context = load_cli_context(config_path)
context.service.register_specs("demo.personal", echo_tool)
return context
def test_wf_deploy_validate_outputs_json() -> None:
root = local_temp_root() / "wf_cli_deploy_validate"
root.mkdir(parents=True, exist_ok=True)
@@ -99,7 +101,9 @@ def test_wf_run_start_accepts_inline_json_input() -> None:
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_deployment(root)
with patch("wf_cli.commands.runs.load_cli_context_from_typer", _load_cli_context_with_specs):
with patch(
"wf_cli.commands.runs.load_cli_context_from_typer", _load_cli_context_with_specs
):
result = runner.invoke(
app,
[
@@ -128,7 +132,9 @@ def test_wf_run_start_accepts_input_file() -> None:
input_path = root / "input.json"
input_path.write_text('{"text": "from file"}', encoding="utf-8")
with patch("wf_cli.commands.runs.load_cli_context_from_typer", _load_cli_context_with_specs):
with patch(
"wf_cli.commands.runs.load_cli_context_from_typer", _load_cli_context_with_specs
):
result = runner.invoke(
app,
[
@@ -153,7 +159,9 @@ def test_wf_run_inspect_and_trace_existing_run() -> None:
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_deployment(root)
with patch("wf_cli.commands.runs.load_cli_context_from_typer", _load_cli_context_with_specs):
with patch(
"wf_cli.commands.runs.load_cli_context_from_typer", _load_cli_context_with_specs
):
start = runner.invoke(
app,
[
@@ -206,7 +214,9 @@ def test_wf_run_start_reports_bad_json() -> None:
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_deployment(root)
with patch("wf_cli.commands.runs.load_cli_context_from_typer", _load_cli_context_with_specs):
with patch(
"wf_cli.commands.runs.load_cli_context_from_typer", _load_cli_context_with_specs
):
result = runner.invoke(
app,
[
@@ -1,5 +1,7 @@
from __future__ import annotations
import json
import pytest
from pydantic import ValidationError
@@ -10,6 +12,7 @@ from wf_config import (
RpcHttpTransportConfig,
StdlibSourceConfig,
WorkflowConfigFile,
load_workflow_config,
)
@@ -87,9 +90,6 @@ def test_workflow_config_rejects_unknown_target_kind() -> None:
"client": {"target": {"kind": "mcp"}},
}
)
import json
from wf_config import load_workflow_config
def test_load_workflow_config_resolves_filesystem_store_relative_to_config(
+2 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import json
from typer.testing import CliRunner
from wf_transport_rpc_http.cli import app
@@ -12,7 +14,6 @@ def test_rpc_server_cli_help_mentions_store_root() -> None:
assert "--store-root" in result.output
assert "--host" in result.output
assert "--port" in result.output
import json
def test_rpc_server_cli_accepts_config_file(tmp_path) -> None:
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
from typing import Any
import httpx