feat: add workflow config validation command

This commit is contained in:
lda
2026-06-12 09:29:53 +07:00 Verified
parent a0bc27b051
commit 03c388d654
5 changed files with 166 additions and 3 deletions
+4 -3
View File
@@ -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:
+12
View File
@@ -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"`.
+53
View File
@@ -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",
}
+94
View File
@@ -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