feat: add workflow config validation command
This commit is contained in:
@@ -85,12 +85,13 @@ auth admin are implemented. The next work is polish, not new broad surfaces.
|
||||
- New source families should follow the generic runtime source lifecycle rather
|
||||
than being forced through MCP `ConnectionConfig`:
|
||||
[`runtime source lifecycle`](superpowers/specs/2026-06-09-runtime-source-lifecycle.md).
|
||||
- Active implementation plan: static config Python sources for trusted
|
||||
project-local `NodeSpec` registries:
|
||||
[`static Python sources`](superpowers/plans/2026-06-11-static-python-sources.md).
|
||||
- Completed: static config `kind: "python"` sources can load trusted local
|
||||
`NodeSpec` registries and expose them through WorkflowServer. Implementation:
|
||||
[`static Python sources`](historical/superpowers/plans/2026-06-11-static-python-sources.md).
|
||||
- Completed: `wf config validate` preflights neutral workflow config files,
|
||||
including config-relative path resolution and trusted static Python source
|
||||
imports. MCP sources are shape-validated only; live upstream checks remain a
|
||||
server/status concern.
|
||||
- Completed: server startup policy moved to `wf_server.cli`; JSON-RPC HTTP
|
||||
remains in `wf_transport_rpc_http`:
|
||||
[`server CLI and transport boundary`](superpowers/specs/2026-06-10-server-cli-transport-boundary.md).
|
||||
|
||||
@@ -122,6 +122,9 @@ Implemented:
|
||||
- `wf_sources_python` loads trusted in-process `NodeSpec` registries from
|
||||
`path` plus `module:registry`.
|
||||
- `wf_server.config` composes Python sources into local/static servers.
|
||||
- `wf config validate` imports configured trusted Python sources and reports
|
||||
missing modules, missing registries, invalid registry shapes, and duplicate
|
||||
specs before server startup.
|
||||
- Capability listing/calling works over JSON-RPC.
|
||||
|
||||
Still deferred:
|
||||
|
||||
@@ -49,6 +49,18 @@ Convert a legacy broker config into the neutral config shape:
|
||||
wf config migrate-mcp wf_mcp.config.json --output wf.json
|
||||
```
|
||||
|
||||
Validate a neutral workflow config before starting a server:
|
||||
|
||||
```bash
|
||||
wf config validate wf.json
|
||||
```
|
||||
|
||||
`validate` checks JSON/model shape, resolves config-relative paths, and imports
|
||||
trusted static Python sources so missing modules or registries fail before
|
||||
server startup. MCP sources are shape-validated only; use `wf status`,
|
||||
`wf source list`, or `wf deploy validate --live` against a running server for
|
||||
live upstream checks.
|
||||
|
||||
The old `store_root` field maps to
|
||||
`server.store: {"kind": "filesystem", "root": ...}`; old `connections[]` map to
|
||||
`server.sources[]` entries with `kind: "mcp"`.
|
||||
|
||||
@@ -6,7 +6,10 @@ from typing import Annotated
|
||||
import typer
|
||||
|
||||
from wf_cli.io import emit_json
|
||||
from wf_config import McpSourceConfig, PythonSourceConfig, StdlibSourceConfig
|
||||
from wf_config.loader import load_workflow_config
|
||||
from wf_mcp.broker.config import migrate_broker_config_file
|
||||
from wf_sources_python import load_python_source
|
||||
|
||||
app = typer.Typer(
|
||||
name="config",
|
||||
@@ -37,3 +40,53 @@ def migrate_mcp_config(
|
||||
encoding="utf-8",
|
||||
)
|
||||
emit_json({"status": "written", "path": str(output_path)})
|
||||
|
||||
|
||||
@app.command("validate")
|
||||
def validate_config(
|
||||
config_path: Annotated[
|
||||
Path,
|
||||
typer.Argument(help="Neutral workflow config JSON path."),
|
||||
],
|
||||
) -> None:
|
||||
"""Validate config shape and trusted static source imports."""
|
||||
try:
|
||||
config = load_workflow_config(config_path)
|
||||
sources = [_validate_source(source) for source in config.server.sources]
|
||||
except Exception as exc:
|
||||
raise typer.BadParameter(f"invalid workflow config: {exc}") from exc
|
||||
emit_json(
|
||||
{
|
||||
"valid": True,
|
||||
"path": str(config_path),
|
||||
"sources": sources,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_source(
|
||||
source: StdlibSourceConfig | PythonSourceConfig | McpSourceConfig,
|
||||
) -> dict[str, object]:
|
||||
"""Return a compact source validation summary without live network probes."""
|
||||
if isinstance(source, PythonSourceConfig):
|
||||
try:
|
||||
loaded = load_python_source(
|
||||
source_id=source.id,
|
||||
path=source.path,
|
||||
module=source.module,
|
||||
registry=source.registry,
|
||||
enabled=source.enabled,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"source {source.id!r}: {exc}") from exc
|
||||
return {
|
||||
"id": source.id,
|
||||
"kind": source.kind,
|
||||
"status": "ok",
|
||||
"capability_count": len(loaded.capabilities.node_specs),
|
||||
}
|
||||
return {
|
||||
"id": source.id,
|
||||
"kind": source.kind,
|
||||
"status": "ok",
|
||||
}
|
||||
|
||||
@@ -79,3 +79,97 @@ def test_wf_config_migrate_mcp_writes_output_file(tmp_path: Path) -> None:
|
||||
assert status["status"] == "written"
|
||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert payload["server"]["sources"][0]["transport"]["kind"] == "http"
|
||||
|
||||
|
||||
def test_wf_config_validate_loads_python_source(tmp_path: Path) -> None:
|
||||
source_root = tmp_path / "sources"
|
||||
source_root.mkdir()
|
||||
(source_root / "ops.py").write_text(
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
from wf_authoring import node
|
||||
|
||||
|
||||
class EchoInput(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@node(name="echo")
|
||||
def echo(input: EchoInput) -> EchoOutput:
|
||||
return EchoOutput(text=input.text)
|
||||
|
||||
|
||||
registry = [echo]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = tmp_path / "wf.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"server": {
|
||||
"store": {"kind": "filesystem", "root": "store"},
|
||||
"sources": [
|
||||
{
|
||||
"kind": "python",
|
||||
"id": "local.ops",
|
||||
"path": "sources",
|
||||
"module": "ops",
|
||||
"registry": "registry",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(app, ["config", "validate", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["valid"] is True
|
||||
assert payload["sources"] == [
|
||||
{
|
||||
"id": "local.ops",
|
||||
"kind": "python",
|
||||
"status": "ok",
|
||||
"capability_count": 1,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_wf_config_validate_reports_python_source_import_failure(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "wf.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"server": {
|
||||
"store": {"kind": "filesystem", "root": "store"},
|
||||
"sources": [
|
||||
{
|
||||
"kind": "python",
|
||||
"id": "local.ops",
|
||||
"path": ".",
|
||||
"module": "missing_ops",
|
||||
"registry": "registry",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(app, ["config", "validate", str(config_path)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "invalid workflow config" in result.output
|
||||
assert "local.ops" in result.output
|
||||
assert "missing_ops" in result.output
|
||||
|
||||
Reference in New Issue
Block a user