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
|
- New source families should follow the generic runtime source lifecycle rather
|
||||||
than being forced through MCP `ConnectionConfig`:
|
than being forced through MCP `ConnectionConfig`:
|
||||||
[`runtime source lifecycle`](superpowers/specs/2026-06-09-runtime-source-lifecycle.md).
|
[`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
|
- Completed: static config `kind: "python"` sources can load trusted local
|
||||||
`NodeSpec` registries and expose them through WorkflowServer. Implementation:
|
`NodeSpec` registries and expose them through WorkflowServer. Implementation:
|
||||||
[`static Python sources`](historical/superpowers/plans/2026-06-11-static-python-sources.md).
|
[`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
|
- Completed: server startup policy moved to `wf_server.cli`; JSON-RPC HTTP
|
||||||
remains in `wf_transport_rpc_http`:
|
remains in `wf_transport_rpc_http`:
|
||||||
[`server CLI and transport boundary`](superpowers/specs/2026-06-10-server-cli-transport-boundary.md).
|
[`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
|
- `wf_sources_python` loads trusted in-process `NodeSpec` registries from
|
||||||
`path` plus `module:registry`.
|
`path` plus `module:registry`.
|
||||||
- `wf_server.config` composes Python sources into local/static servers.
|
- `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.
|
- Capability listing/calling works over JSON-RPC.
|
||||||
|
|
||||||
Still deferred:
|
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
|
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
|
The old `store_root` field maps to
|
||||||
`server.store: {"kind": "filesystem", "root": ...}`; old `connections[]` map to
|
`server.store: {"kind": "filesystem", "root": ...}`; old `connections[]` map to
|
||||||
`server.sources[]` entries with `kind: "mcp"`.
|
`server.sources[]` entries with `kind: "mcp"`.
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from typing import Annotated
|
|||||||
import typer
|
import typer
|
||||||
|
|
||||||
from wf_cli.io import emit_json
|
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_mcp.broker.config import migrate_broker_config_file
|
||||||
|
from wf_sources_python import load_python_source
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="config",
|
name="config",
|
||||||
@@ -37,3 +40,53 @@ def migrate_mcp_config(
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
emit_json({"status": "written", "path": str(output_path)})
|
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"
|
assert status["status"] == "written"
|
||||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||||
assert payload["server"]["sources"][0]["transport"]["kind"] == "http"
|
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