* docs(plan-04): spec + plan for pipeline error transparency (#131) speckit spec/plan/research/data-model/contract/quickstart for plan-04. Grounds the fix in the real code map: shared failure-event builder (backend/core/failure.py) feeding tasks.py + dub_pipeline.py + dub_core.py, non-empty reason guarantee, sanitized diagnostic block, frontend renderer with docs deeplink. Closes-target: #131 (children #122, #63). Design only — no code changes yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pipeline): structured, non-empty failure events + logged tracebacks (#131) plan-04 backend: no more silent "unknown error". A shared failure helper guarantees a non-empty reason at every emit site and a sanitized, copyable diagnostic block. - backend/core/failure.py: build_failure()/build_failure_event() (reason falls back to the exception class name), sanitize() (reuses the logging_filter HF-token regex + redacts *TOKEN*/*KEY*/*SECRET* env values + home→~), diagnostic() (reuses the env capture), classify() reusing the error_docs_map 5-class taxonomy for the docs deeplink + hint. - core/tasks.py worker: structured event instead of bare str(e); keeps the logged traceback. - services/dub_pipeline.py: enrich download/extract error yields; ADD the missing outer `except Exception` (the #122 path — unhandled ingest errors were never surfaced with stage context); surface the previously-silent demucs/scene/thumbnail degradations as non-fatal `warning` events. - api/routers/batch.py: guaranteed non-empty batch failure reason. SSE payload is additive (legacy `error`/`stage`/`detail` keys preserved), so existing frontends keep working and already show the specific reason. Tests (TDD, fail-before/pass-after): 14 cases — non-empty-reason guarantee, redaction, diagnostic sanitization, and the 3 Test-matrix triggers (worker / extract / url). 483 passed, 0 regressions. Closes #131. Refs #122, #63. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dub-ui): show specific cause + docs deeplink + copyable diagnostic (#131) plan-04 frontend. The backend now sends a structured, non-empty failure; surface it to the user instead of "extract: unknown error". - dubSlice: DubFailure type + dubFailure state/setter. - useDubWorkflow: capture the structured failure on the SSE error event (reason/error_class/stage/hint/docs_topic/diagnostic); clear on new runs. - DubTab: DubFailureNotice renders the actionable hint, an "Open docs" deeplink (via the existing errorDocsMap classifier), and a "Copy diagnostic" button — shown beneath the error badge in both failure banners. typecheck + build clean; 66 frontend tests pass. Refs #131, #122, #63. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(failure): annotate intentional best-effort excepts (CodeQL) The new security workflow's CodeQL flagged 5 bare `except: pass` blocks. All are deliberate best-effort guards (sanitize/diagnostic must never throw on the failure path; the test cancels the worker to tear it down). Added explanatory comments per CodeQL's py/empty-except rule. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
7.1 KiB
Python
189 lines
7.1 KiB
Python
"""Shared pipeline-failure helper (plan-04 / #131).
|
|
|
|
Single source of truth for "what a failure looks like" so every emit site —
|
|
the TaskManager worker, the dub ingest pipeline, the dub/batch routers — produces
|
|
the same structured, **non-empty**, sanitized failure event instead of its own
|
|
ad-hoc ``str(e)`` (which is empty or cryptic for many exception types, and was
|
|
the root of the "extract: unknown error" reports in #122/#63).
|
|
|
|
Guarantees:
|
|
- ``reason`` is ALWAYS non-empty (falls back to the exception class name).
|
|
- ``detail`` / ``diagnostic`` are sanitized: HF tokens, ``*TOKEN*/*KEY*/*SECRET*``
|
|
env values, and absolute home paths never leak (Constitution I).
|
|
- the 5-class docs taxonomy is reused from ``core.error_docs_map`` (not
|
|
duplicated) so the deeplink contract stays single-sourced.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from core import error_docs_map
|
|
from core.logging_filter import REDACTED, _HF_TOKEN_RE
|
|
|
|
# Env vars whose *name* implies a credential — their values are redacted.
|
|
_SECRET_NAME_RE = re.compile(r"(TOKEN|KEY|SECRET)", re.IGNORECASE)
|
|
_REDACTED_VALUE = "***REDACTED***"
|
|
|
|
# One-line "what to do" per docs-taxonomy key. Keys mirror error_docs_map's
|
|
# taxonomy; the docs URL itself stays owned by error_docs_map.
|
|
_HINTS: dict[str, str] = {
|
|
"PKG_RESOURCES_MISSING": "Install setuptools in the backend environment (provides pkg_resources).",
|
|
"GATEKEEPER_QUARANTINE": "Clear the macOS quarantine flag (xattr -cr the app), then reopen.",
|
|
"APPIMAGE_WEBKIT_WHITESCREEN": "Launch with WEBKIT_DISABLE_DMABUF_RENDERER=1 set.",
|
|
"HF_AUTH_FAILED": "Set a valid HF_TOKEN in Settings → Hugging Face and retry.",
|
|
"PYANNOTE_LICENSE_REQUIRED": "Accept the pyannote model licenses on Hugging Face, then retry.",
|
|
}
|
|
|
|
|
|
def classify(reason: str) -> str:
|
|
"""Map a failure reason to a docs-taxonomy key, or "" when unknown.
|
|
|
|
Heuristic substring match — mirrors the frontend ``classifyError`` so the
|
|
backend log / diagnostic names the same class the UI deeplink will use.
|
|
"""
|
|
low = (reason or "").lower()
|
|
if "pkg_resources" in low:
|
|
return "PKG_RESOURCES_MISSING"
|
|
if "quarantine" in low or "is damaged" in low or "gatekeeper" in low:
|
|
return "GATEKEEPER_QUARANTINE"
|
|
if "webkit" in low or "white screen" in low or "dmabuf" in low or "appimage" in low:
|
|
return "APPIMAGE_WEBKIT_WHITESCREEN"
|
|
if "pyannote" in low or ("gated" in low and "model" in low) or "accept the" in low:
|
|
return "PYANNOTE_LICENSE_REQUIRED"
|
|
if ("huggingface" in low or "hf_token" in low or "401" in low or "unauthorized" in low) and (
|
|
"token" in low or "auth" in low or "401" in low or "unauthorized" in low
|
|
):
|
|
return "HF_AUTH_FAILED"
|
|
return ""
|
|
|
|
|
|
def sanitize(text: Optional[str]) -> str:
|
|
"""Redact secrets and strip the home path from a string.
|
|
|
|
- HF tokens (reuses the regex from ``core.logging_filter``)
|
|
- values of env vars whose name matches ``*TOKEN*/*KEY*/*SECRET*``
|
|
- the user's absolute home directory → ``~``
|
|
"""
|
|
if not text:
|
|
return text or ""
|
|
out = _HF_TOKEN_RE.sub(REDACTED, str(text))
|
|
for name, val in os.environ.items():
|
|
# Only redact substantial values so short/empty ones don't blank the text.
|
|
if val and len(val) >= 6 and _SECRET_NAME_RE.search(name):
|
|
out = out.replace(val, _REDACTED_VALUE)
|
|
try:
|
|
home = str(Path.home())
|
|
if home and home in out:
|
|
out = out.replace(home, "~")
|
|
except Exception:
|
|
# Best-effort: sanitize() must never raise (it runs on the failure path);
|
|
# if home-dir resolution fails, leave the text as-is rather than throw.
|
|
pass
|
|
return out
|
|
|
|
|
|
def _env_summary() -> str:
|
|
lines: list[str] = []
|
|
try:
|
|
lines.append(f"OS: {platform.platform()}")
|
|
except Exception:
|
|
# Best-effort env summary — omit the OS line rather than fail diagnostics.
|
|
pass
|
|
lines.append(f"Python: {sys.version.split()[0]}")
|
|
try:
|
|
import psutil # already a runtime dep
|
|
|
|
vm = psutil.virtual_memory()
|
|
lines.append(f"CPU: {os.cpu_count()} cores")
|
|
lines.append(f"RAM: {round(vm.total / 1024 ** 3, 1)} GB")
|
|
except Exception:
|
|
# Best-effort — omit CPU/RAM if psutil is unavailable or probing fails.
|
|
pass
|
|
# Only probe the GPU if torch is ALREADY imported — importing it here just
|
|
# to build a diagnostic would add seconds to every failure (and to tests).
|
|
torch = sys.modules.get("torch")
|
|
if torch is not None:
|
|
try:
|
|
if torch.cuda.is_available():
|
|
lines.append(f"GPU: CUDA {torch.cuda.get_device_name(0)}")
|
|
elif getattr(getattr(torch, "backends", None), "mps", None) and torch.backends.mps.is_available():
|
|
lines.append("GPU: MPS (Apple)")
|
|
else:
|
|
lines.append("GPU: CPU only")
|
|
except Exception:
|
|
# Best-effort — omit the GPU line if torch probing raises.
|
|
pass
|
|
return "\n".join(lines)
|
|
|
|
|
|
def diagnostic(*, reason: str, error_class: str, stage: str) -> str:
|
|
"""A sanitized, copy-paste-friendly diagnostic block for a failed job."""
|
|
block = (
|
|
"OmniVoice diagnostic\n"
|
|
"--------------------\n"
|
|
f"Stage: {stage}\n"
|
|
f"Error: {error_class}\n"
|
|
f"Reason: {reason}\n"
|
|
f"{_env_summary()}\n"
|
|
)
|
|
return sanitize(block)
|
|
|
|
|
|
def build_failure(
|
|
exc_or_msg: Any,
|
|
*,
|
|
stage: str,
|
|
context: Optional[dict] = None,
|
|
include_diagnostic: bool = True,
|
|
) -> dict:
|
|
"""Build the structured failure fields (no ``type`` — caller/prep_event adds it).
|
|
|
|
``reason`` is guaranteed non-empty: ``str(exc)`` → exception class name.
|
|
"""
|
|
if isinstance(exc_or_msg, BaseException):
|
|
error_class = type(exc_or_msg).__name__
|
|
raw = str(exc_or_msg).strip() or error_class
|
|
else:
|
|
error_class = "Error"
|
|
raw = str(exc_or_msg).strip() or "Unknown failure"
|
|
|
|
reason = sanitize(raw) or error_class
|
|
docs_topic = classify(raw)
|
|
fields: dict[str, Any] = {
|
|
"reason": reason,
|
|
"error": reason, # backward-compat mirror for older frontends
|
|
"error_class": error_class,
|
|
"stage": stage,
|
|
"hint": _HINTS.get(docs_topic, ""),
|
|
"docs_topic": docs_topic,
|
|
"docs_url": error_docs_map.ERROR_DOCS.get(docs_topic, ""),
|
|
"detail": sanitize(raw),
|
|
}
|
|
if context:
|
|
fields["context"] = {k: sanitize(str(v)) for k, v in context.items()}
|
|
if include_diagnostic:
|
|
fields["diagnostic"] = diagnostic(reason=reason, error_class=error_class, stage=stage)
|
|
return fields
|
|
|
|
|
|
def build_failure_event(
|
|
exc_or_msg: Any,
|
|
*,
|
|
stage: str,
|
|
event_type: str = "error",
|
|
context: Optional[dict] = None,
|
|
include_diagnostic: bool = True,
|
|
) -> dict:
|
|
"""``build_failure`` plus a ``type`` key, for SSE event sites (tasks.py)."""
|
|
return {
|
|
"type": event_type,
|
|
**build_failure(
|
|
exc_or_msg, stage=stage, context=context, include_diagnostic=include_diagnostic
|
|
),
|
|
}
|