wf explain

This commit is contained in:
lda
2026-06-01 03:51:03 +07:00 Verified
parent d4ab0e6912
commit a15b1035d3
11 changed files with 1563 additions and 12 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ app.add_typer(deployments.app, name="deploy")
app.add_typer(runs.app, name="run")
app.add_typer(docs.app, name="docs")
app.add_typer(schema.app, name="schema")
app.add_typer(explain.app, name="explain")
app.command("explain")(explain.explain_command)
def main() -> None:
+151 -4
View File
@@ -1,9 +1,156 @@
from __future__ import annotations
from enum import StrEnum
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer(
name="explain",
help="Explain workflow diagnostic and CLI error codes.",
no_args_is_help=True,
from wf_cli.explain import (
DEFAULT_EXPLAIN_REGISTRY,
ExplainCard,
ExplainInputError,
ExplainSummary,
UnknownExplainCode,
parse_explain_input,
)
from wf_cli.io import emit_json
class ExplainFormat(StrEnum):
"""Output formats supported by `wf explain`."""
JSON = "json"
MARKDOWN = "markdown"
COMPACT = "compact"
def explain_command(
code: Annotated[
str | None,
typer.Argument(help="Diagnostic/error code, or JSON payload containing codes."),
] = None,
input_file: Annotated[
Path | None,
typer.Option("--input-file", help="Read diagnostic/error JSON from a file."),
] = None,
read_stdin: Annotated[
bool,
typer.Option("--stdin", help="Read diagnostic/error JSON from standard input."),
] = False,
list_entries: Annotated[
bool,
typer.Option("--list", help="List known explanation codes."),
] = False,
output_format: Annotated[
ExplainFormat,
typer.Option("--format", help="Output format."),
] = ExplainFormat.JSON,
) -> None:
"""Explain exact workflow diagnostic codes without generated prose."""
try:
if list_entries:
if code is not None or input_file is not None or read_stdin:
raise ExplainInputError(
"--list cannot be combined with code, --input-file, or --stdin"
)
_emit_summaries(DEFAULT_EXPLAIN_REGISTRY.list_entries(), output_format)
return
codes = _read_codes(code=code, input_file=input_file, read_stdin=read_stdin)
cards = [DEFAULT_EXPLAIN_REGISTRY.get(item) for item in codes]
except (ExplainInputError, UnknownExplainCode) as exc:
raise typer.BadParameter(_error_message(exc)) from exc
if len(cards) == 1 and input_file is None and not read_stdin:
_emit_card(cards[0], output_format)
else:
_emit_cards(cards, output_format)
def _read_codes(
*,
code: str | None,
input_file: Path | None,
read_stdin: bool,
) -> list[str]:
"""Resolve the mutually exclusive input modes supported by `wf explain`."""
selected = sum(value is not None for value in (code, input_file)) + int(read_stdin)
if selected == 0:
raise ExplainInputError("provide a code, --input-file, --stdin, or --list")
if selected > 1:
raise ExplainInputError(
"code, --input-file, and --stdin are mutually exclusive"
)
if code is not None:
return parse_explain_input(code)
if input_file is not None:
try:
return parse_explain_input(input_file.read_text(encoding="utf-8"))
except OSError as exc:
message = f"could not read input file {input_file!s}: {exc}"
raise ExplainInputError(message) from exc
return parse_explain_input(typer.get_text_stream("stdin").read())
def _emit_card(card: ExplainCard, output_format: ExplainFormat) -> None:
if output_format is ExplainFormat.JSON:
emit_json(card.model_dump(mode="json"))
return
if output_format is ExplainFormat.MARKDOWN:
print(_card_markdown(card))
return
print(_card_compact(card))
def _emit_cards(cards: list[ExplainCard], output_format: ExplainFormat) -> None:
if output_format is ExplainFormat.JSON:
emit_json({"entries": [card.model_dump(mode="json") for card in cards]})
return
if output_format is ExplainFormat.MARKDOWN:
print("\n\n".join(_card_markdown(card) for card in cards))
return
print("\n".join(_card_compact(card) for card in cards))
def _emit_summaries(
summaries: list[ExplainSummary],
output_format: ExplainFormat,
) -> None:
if output_format is ExplainFormat.JSON:
emit_json(
{"entries": [summary.model_dump(mode="json") for summary in summaries]}
)
return
if output_format is ExplainFormat.MARKDOWN:
print("\n".join(f"- `{item.code}`: {item.summary}" for item in summaries))
return
print("\n".join(f"{item.code}: {item.summary}" for item in summaries))
def _card_markdown(card: ExplainCard) -> str:
lines = [
f"# {card.code}",
"",
card.summary,
"",
"## Why It Happens",
*[f"- {item}" for item in card.why_it_happens],
"",
"## How To Fix",
*[f"- {item}" for item in card.how_to_fix],
]
if card.related_docs:
lines.extend(
["", "## Related Docs", *[f"- {item}" for item in card.related_docs]]
)
return "\n".join(lines)
def _card_compact(card: ExplainCard) -> str:
return f"{card.code}: {card.summary}"
def _error_message(exc: Exception) -> str:
if isinstance(exc, UnknownExplainCode):
return f"unknown explain code: {exc.args[0]}"
return str(exc)
+16
View File
@@ -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",
]
+121
View File
@@ -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",
],
),
)
+27
View File
@@ -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)
+70
View File
@@ -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
+34
View File
@@ -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()