wf explain
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""Docs-backed explanation registry for workflow CLI diagnostics."""
|
||||
|
||||
from .models import ExplainCard, ExplainSummary
|
||||
from .parser import ExplainInputError, extract_explain_codes, parse_explain_input
|
||||
from .registry import DEFAULT_EXPLAIN_REGISTRY, ExplainRegistry, UnknownExplainCode
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_EXPLAIN_REGISTRY",
|
||||
"ExplainCard",
|
||||
"ExplainInputError",
|
||||
"ExplainRegistry",
|
||||
"ExplainSummary",
|
||||
"UnknownExplainCode",
|
||||
"extract_explain_codes",
|
||||
"parse_explain_input",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import ExplainCard
|
||||
|
||||
|
||||
EXPLAIN_CARDS: tuple[ExplainCard, ...] = (
|
||||
ExplainCard(
|
||||
code="source_missing",
|
||||
summary="A required logical source is not available or not bound.",
|
||||
why_it_happens=[
|
||||
"The artifact requires a logical source that the deployment did not bind.",
|
||||
"The concrete source was removed, renamed, disabled, or never registered.",
|
||||
"A saved wrapper or workflow depends on a source that is absent in this config.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Run `wf deploy inspect <deployment_id>` and check the bindings.",
|
||||
"Run `wf cap list` to confirm the concrete source is available.",
|
||||
"Save the deployment again with the missing logical source bound.",
|
||||
"Run `wf deploy validate <deployment_id> --live` after changing bindings.",
|
||||
],
|
||||
related_docs=[
|
||||
"docs/wf_cli_usage.md#deployment-validation",
|
||||
"docs/workflow_capabilities.md",
|
||||
],
|
||||
),
|
||||
ExplainCard(
|
||||
code="source_unreachable",
|
||||
summary="A concrete source exists in config but could not be reached.",
|
||||
why_it_happens=[
|
||||
"The upstream MCP server or local process failed during liveness checks.",
|
||||
"The source command, URL, authentication, or environment is invalid.",
|
||||
"The source is slow or hung and exceeded the bounded liveness timeout.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Check the source command or URL in the active config.",
|
||||
"Start or restart the upstream server.",
|
||||
"Run validation without `--live` if you only need static deployment checks.",
|
||||
"Run `wf deploy validate <deployment_id> --live` again after fixing the source.",
|
||||
],
|
||||
related_docs=[
|
||||
"docs/wf_cli_usage.md#deployment-validation",
|
||||
"docs/wf_mcp_unified_proxy_plan.md",
|
||||
],
|
||||
),
|
||||
ExplainCard(
|
||||
code="binding_missing",
|
||||
summary="A deployment is missing a required logical-to-concrete source binding.",
|
||||
why_it_happens=[
|
||||
"The artifact was saved with required capabilities under a logical source.",
|
||||
"The deployment was saved without a binding for that logical source.",
|
||||
"A binding field was misspelled or placed under the wrong payload key.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Inspect the artifact requirements.",
|
||||
"Inspect the deployment bindings.",
|
||||
"Save the deployment with `bindings` entries that map each logical source.",
|
||||
"Use `wf deploy validate <deployment_id>` to confirm the binding set.",
|
||||
],
|
||||
related_docs=[
|
||||
"docs/wf_cli_usage.md#save-and-validate-a-deployment",
|
||||
"docs/workflow_capabilities.md#sources",
|
||||
],
|
||||
),
|
||||
ExplainCard(
|
||||
code="capability_missing",
|
||||
summary="A required capability is not present on the bound source.",
|
||||
why_it_happens=[
|
||||
"The upstream source no longer exposes the tool or node spec.",
|
||||
"The workflow was bound to the wrong account/profile/source.",
|
||||
"The capability was renamed after the artifact was saved.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Run `wf cap list` and search for the expected capability.",
|
||||
"Inspect the deployment bindings for the affected logical source.",
|
||||
"Rebind to a concrete source that exposes the capability.",
|
||||
"Rebuild or patch the artifact if the capability was intentionally renamed.",
|
||||
],
|
||||
related_docs=[
|
||||
"docs/workflow_capabilities.md",
|
||||
"docs/wf_cli_usage.md#capability-discovery",
|
||||
],
|
||||
),
|
||||
ExplainCard(
|
||||
code="schema_changed",
|
||||
summary="A saved dependency schema no longer matches the live capability.",
|
||||
why_it_happens=[
|
||||
"The upstream tool or node spec changed its input/output schema.",
|
||||
"The deployment is bound to a different source profile than the one used before.",
|
||||
"A wrapper assumes fields that the live capability no longer declares.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Inspect the live capability.",
|
||||
"Compare it with the saved artifact dependency summary.",
|
||||
"Patch the draft or wrapper to match the new schema.",
|
||||
"Save a new artifact version and deployment after validating the change.",
|
||||
],
|
||||
related_docs=[
|
||||
"docs/workflow_capabilities.md#dependency-validation",
|
||||
"docs/schema_validation.md",
|
||||
],
|
||||
),
|
||||
ExplainCard(
|
||||
code="deployment_unrunnable",
|
||||
summary="The deployment failed validation and should not be run yet.",
|
||||
why_it_happens=[
|
||||
"One or more required sources, capabilities, schemas, or bindings are invalid.",
|
||||
"The deployment points at an artifact version that cannot be resolved.",
|
||||
"Live validation found an upstream source or capability problem.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Run `wf deploy validate <deployment_id>` and read the diagnostics.",
|
||||
"Run `wf explain --input-file <validation-output.json>` for diagnostic details.",
|
||||
"Fix source bindings or rebuild the artifact version.",
|
||||
"Re-run validation before starting the deployment.",
|
||||
],
|
||||
related_docs=[
|
||||
"docs/wf_cli_usage.md#deployment-validation",
|
||||
"docs/current_roadmap.md",
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExplainCard(BaseModel):
|
||||
"""Human-curated help for one stable workflow diagnostic/error code."""
|
||||
|
||||
code: str = Field(min_length=1, description="Stable diagnostic or CLI error code.")
|
||||
summary: str = Field(min_length=1, description="One-sentence explanation.")
|
||||
why_it_happens: list[str] = Field(
|
||||
description="Common causes, ordered from most likely to least likely."
|
||||
)
|
||||
how_to_fix: list[str] = Field(
|
||||
description="Concrete next steps an agent or user can try."
|
||||
)
|
||||
related_docs: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Documentation resource IDs or file references.",
|
||||
)
|
||||
|
||||
|
||||
class ExplainSummary(BaseModel):
|
||||
"""Lean index entry for `wf explain --list`."""
|
||||
|
||||
code: str = Field(min_length=1)
|
||||
summary: str = Field(min_length=1)
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ExplainInputError(ValueError):
|
||||
"""Raised when `wf explain` input cannot be reduced to stable codes."""
|
||||
|
||||
|
||||
def parse_explain_input(raw: str) -> list[str]:
|
||||
"""Parse a direct code or JSON payload into first-seen unique codes."""
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
raise ExplainInputError("explain input is empty")
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
try:
|
||||
value = json.loads(stripped)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ExplainInputError(f"invalid JSON explain input: {exc.msg}") from exc
|
||||
return extract_explain_codes(value)
|
||||
return [stripped]
|
||||
|
||||
|
||||
def extract_explain_codes(value: Any) -> list[str]:
|
||||
"""Extract known diagnostic-code shapes without guessing or fuzzy matching."""
|
||||
codes: list[str] = []
|
||||
_collect_codes(value, codes)
|
||||
deduped = _dedupe(codes)
|
||||
if not deduped:
|
||||
raise ExplainInputError("no explainable code found in input")
|
||||
return deduped
|
||||
|
||||
|
||||
def _collect_codes(value: Any, codes: list[str]) -> None:
|
||||
if isinstance(value, str):
|
||||
codes.append(value)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
_collect_codes(item, codes)
|
||||
return
|
||||
if not isinstance(value, dict):
|
||||
return
|
||||
|
||||
code = value.get("code")
|
||||
if isinstance(code, str):
|
||||
codes.append(code)
|
||||
|
||||
error = value.get("error")
|
||||
if isinstance(error, dict):
|
||||
error_code = error.get("code")
|
||||
if isinstance(error_code, str):
|
||||
codes.append(error_code)
|
||||
|
||||
diagnostics = value.get("diagnostics")
|
||||
if isinstance(diagnostics, list):
|
||||
for diagnostic in diagnostics:
|
||||
_collect_codes(diagnostic, codes)
|
||||
|
||||
|
||||
def _dedupe(codes: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for code in codes:
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
result.append(code)
|
||||
return result
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from .entries import EXPLAIN_CARDS
|
||||
from .models import ExplainCard, ExplainSummary
|
||||
|
||||
|
||||
class UnknownExplainCode(KeyError):
|
||||
"""Raised when a diagnostic code is not present in the curated registry."""
|
||||
|
||||
|
||||
class ExplainRegistry:
|
||||
"""Exact-match registry for docs-backed explanation cards."""
|
||||
|
||||
def __init__(self, entries: Iterable[ExplainCard] = EXPLAIN_CARDS) -> None:
|
||||
self._entries = {entry.code: entry for entry in entries}
|
||||
|
||||
def get(self, code: str) -> ExplainCard:
|
||||
"""Return a full explanation card for one stable code."""
|
||||
try:
|
||||
return self._entries[code]
|
||||
except KeyError as exc:
|
||||
raise UnknownExplainCode(code) from exc
|
||||
|
||||
def list_entries(self) -> list[ExplainSummary]:
|
||||
"""Return lean summaries for discovery output."""
|
||||
return [
|
||||
ExplainSummary(code=entry.code, summary=entry.summary)
|
||||
for entry in self._entries.values()
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_EXPLAIN_REGISTRY = ExplainRegistry()
|
||||
Reference in New Issue
Block a user