Release v1.0.11: shared runtime diagnostics and media_assets probe helpers
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 23s
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 23s
This commit is contained in:
@@ -25,7 +25,10 @@ from .media_assets import (
|
||||
MediaAssetsStatus,
|
||||
background_music_issue,
|
||||
ensure_media_assets,
|
||||
is_git_lfs_pointer,
|
||||
is_usable_audio_file,
|
||||
pick_background_music,
|
||||
probe_background_music,
|
||||
probe_ffmpeg,
|
||||
probe_media_assets,
|
||||
resolve_ffmpeg,
|
||||
@@ -33,6 +36,15 @@ from .media_assets import (
|
||||
resolve_media_assets_root,
|
||||
resolve_music_dir,
|
||||
)
|
||||
from .runtime_diagnostics import (
|
||||
RuntimeDiagnostics,
|
||||
RuntimeIssue,
|
||||
collect_runtime_diagnostics,
|
||||
format_runtime_health_lines,
|
||||
is_jiangchang_skill_core_from_skill_tree,
|
||||
runtime_diagnostics_dict,
|
||||
version_ge,
|
||||
)
|
||||
|
||||
try:
|
||||
from . import rpa
|
||||
@@ -46,8 +58,16 @@ __all__ = [
|
||||
"EntitlementResult",
|
||||
"EntitlementServiceError",
|
||||
"MediaAssetsStatus",
|
||||
"RuntimeDiagnostics",
|
||||
"RuntimeIssue",
|
||||
"background_music_issue",
|
||||
"collect_runtime_diagnostics",
|
||||
"ensure_media_assets",
|
||||
"format_runtime_health_lines",
|
||||
"is_git_lfs_pointer",
|
||||
"is_jiangchang_skill_core_from_skill_tree",
|
||||
"is_usable_audio_file",
|
||||
"probe_background_music",
|
||||
"probe_ffmpeg",
|
||||
"probe_media_assets",
|
||||
"pick_background_music",
|
||||
@@ -55,6 +75,8 @@ __all__ = [
|
||||
"resolve_ffprobe",
|
||||
"resolve_media_assets_root",
|
||||
"resolve_music_dir",
|
||||
"runtime_diagnostics_dict",
|
||||
"version_ge",
|
||||
"apply_cli_local_defaults",
|
||||
"attach_unified_file_handler",
|
||||
"ensure_env_file",
|
||||
|
||||
@@ -16,7 +16,7 @@ import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
MEDIA_ASSETS_BUNDLE_URL = (
|
||||
"https://git.jc2009.com/client-commons/media-assets/releases/download/vlatest/media-assets.zip"
|
||||
@@ -29,17 +29,20 @@ _NON_WIN_DEFAULT_DATA_ROOT = Path.home() / ".jiangchang"
|
||||
|
||||
_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac"}
|
||||
_GIT_LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
|
||||
_MIN_AUDIO_BYTES = 128
|
||||
_MIN_AUDIO_BYTES = 1024
|
||||
|
||||
|
||||
def _is_git_lfs_pointer(path: Path) -> bool:
|
||||
def is_git_lfs_pointer(path: str | Path) -> bool:
|
||||
try:
|
||||
head = path.read_bytes()[:256]
|
||||
head = Path(path).read_bytes()[:256]
|
||||
except OSError:
|
||||
return False
|
||||
return head.startswith(_GIT_LFS_POINTER_PREFIX)
|
||||
|
||||
|
||||
_is_git_lfs_pointer = is_git_lfs_pointer
|
||||
|
||||
|
||||
def _enumerate_audio_like(music_dir: Path) -> list[Path]:
|
||||
return [
|
||||
path
|
||||
@@ -76,21 +79,25 @@ def _needs_music_content_repair(status: MediaAssetsStatus) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _is_usable_audio_file(path: Path) -> bool:
|
||||
if not path.is_file():
|
||||
def is_usable_audio_file(path: str | Path, *, min_bytes: int = _MIN_AUDIO_BYTES) -> bool:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return False
|
||||
if path.suffix.lower() not in _AUDIO_EXTENSIONS:
|
||||
if p.suffix.lower() not in _AUDIO_EXTENSIONS:
|
||||
return False
|
||||
if _is_git_lfs_pointer(path):
|
||||
if is_git_lfs_pointer(p):
|
||||
return False
|
||||
try:
|
||||
if path.stat().st_size < _MIN_AUDIO_BYTES:
|
||||
if p.stat().st_size < min_bytes:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_is_usable_audio_file = is_usable_audio_file
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaAssetsStatus:
|
||||
root: Path
|
||||
@@ -724,6 +731,46 @@ def resolve_music_dir(
|
||||
return status.music_dir
|
||||
|
||||
|
||||
def probe_background_music(
|
||||
root: str | Path | None = None,
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""只读探测背景音乐目录,不触发 media-assets 下载。"""
|
||||
env_map = _env_dict(dict(env) if env is not None else None)
|
||||
if root is not None:
|
||||
music_dir = Path(root) / "music"
|
||||
if not music_dir.is_dir():
|
||||
music_dir = Path(root) if Path(root).is_dir() else None
|
||||
else:
|
||||
data_root = (env_map.get("JIANGCHANG_DATA_ROOT") or env_map.get("CLAW_DATA_ROOT") or "").strip()
|
||||
assets_root = resolve_media_assets_root(data_root or None, env_map)
|
||||
status = _inspect_media_assets(assets_root, source="local")
|
||||
music_dir = status.music_dir
|
||||
|
||||
music_root = str(music_dir) if music_dir is not None else None
|
||||
if music_dir is None or not music_dir.is_dir():
|
||||
return {
|
||||
"music_root": music_root,
|
||||
"mp3_count": 0,
|
||||
"usable_count": 0,
|
||||
"sample_path": None,
|
||||
"issue": "music_dir_missing",
|
||||
}
|
||||
|
||||
mp3_files = [path for path in music_dir.rglob("*.mp3") if path.is_file()]
|
||||
usable = [path for path in music_dir.rglob("*") if is_usable_audio_file(path)]
|
||||
issue = _music_content_issue(music_dir)
|
||||
sample = str(sorted(usable, key=lambda p: str(p).lower())[0]) if usable else None
|
||||
return {
|
||||
"music_root": music_root,
|
||||
"mp3_count": len(mp3_files),
|
||||
"usable_count": len(usable),
|
||||
"sample_path": sample,
|
||||
"issue": issue,
|
||||
}
|
||||
|
||||
|
||||
def background_music_issue(
|
||||
data_root: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
|
||||
321
src/jiangchang_skill_core/runtime_diagnostics.py
Normal file
321
src/jiangchang_skill_core/runtime_diagnostics.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""共享 Runtime 诊断:health 输出与 media-assets / ffmpeg 探测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
import jiangchang_skill_core
|
||||
from .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
|
||||
from .runtime_env import platform_default_data_root
|
||||
|
||||
_SHARED_CORE_HINT = (
|
||||
"jiangchang_skill_core is loaded from the skill tree; expected the shared "
|
||||
"jiangchang-platform-kit installed in the host Python runtime."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeIssue:
|
||||
code: str
|
||||
message: str
|
||||
severity: str = "warning"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeDiagnostics:
|
||||
skill_slug: str
|
||||
python_executable: str
|
||||
platform_kit_version: str | None
|
||||
platform_kit_min_version: str | None
|
||||
platform_kit_version_ok: bool | None
|
||||
jiangchang_skill_core_file: str | None
|
||||
|
||||
claw_data_root: str | None
|
||||
jiangchang_data_root: str | None
|
||||
resolved_data_root: str | None
|
||||
|
||||
media_assets_root: str | None
|
||||
ffmpeg_available: bool
|
||||
ffmpeg_path: str | None
|
||||
|
||||
background_music_mp3_count: int
|
||||
background_music_usable_count: int
|
||||
background_music_issue: str | None
|
||||
background_music_sample_path: str | None
|
||||
|
||||
record_video_enabled: bool
|
||||
issues: tuple[RuntimeIssue, ...]
|
||||
|
||||
@property
|
||||
def has_fatal_issues(self) -> bool:
|
||||
return any(issue.severity == "error" for issue in self.issues)
|
||||
|
||||
def issue_codes(self) -> list[str]:
|
||||
return [issue.code for issue in self.issues]
|
||||
|
||||
|
||||
def _env_dict(env: Mapping[str, str] | None) -> dict[str, str]:
|
||||
return dict(env) if env is not None else dict(os.environ)
|
||||
|
||||
|
||||
def _resolve_data_root(env: Mapping[str, str]) -> str:
|
||||
root = (env.get("CLAW_DATA_ROOT") or env.get("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if root:
|
||||
return root
|
||||
return platform_default_data_root()
|
||||
|
||||
|
||||
def _parse_version(version: str) -> tuple[int, ...]:
|
||||
parts: list[int] = []
|
||||
for piece in version.strip().split("."):
|
||||
digits = ""
|
||||
for ch in piece:
|
||||
if ch.isdigit():
|
||||
digits += ch
|
||||
else:
|
||||
break
|
||||
if digits:
|
||||
parts.append(int(digits))
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def version_ge(installed: str, required: str) -> bool:
|
||||
"""Compare PEP 440-like versions without external deps (post release suffix ignored)."""
|
||||
return _parse_version(installed) >= _parse_version(required)
|
||||
|
||||
|
||||
def _path_under(parent: str | Path, child: str | Path) -> bool:
|
||||
try:
|
||||
parent_path = Path(parent).resolve()
|
||||
child_path = Path(child).resolve()
|
||||
if sys.platform == "win32":
|
||||
parent_norm = os.path.normcase(str(parent_path))
|
||||
child_norm = os.path.normcase(str(child_path))
|
||||
common = os.path.commonpath([parent_norm, child_norm])
|
||||
return common == parent_norm
|
||||
return os.path.commonpath([str(parent_path), str(child_path)]) == str(parent_path)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def is_jiangchang_skill_core_from_skill_tree(
|
||||
*,
|
||||
skill_root: str | Path,
|
||||
core_file: str | Path | None = None,
|
||||
) -> bool:
|
||||
"""Return True when jiangchang_skill_core is loaded from inside skill_root."""
|
||||
try:
|
||||
root = Path(skill_root)
|
||||
if not str(root).strip():
|
||||
return False
|
||||
core = Path(core_file) if core_file is not None else Path(jiangchang_skill_core.__file__)
|
||||
return _path_under(root, core)
|
||||
except (OSError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _platform_kit_version() -> str | None:
|
||||
try:
|
||||
return importlib.metadata.version("jiangchang-platform-kit")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _bool_from_env(env: Mapping[str, str], key: str, default: bool = False) -> bool:
|
||||
val = env.get(key)
|
||||
if val is None or val == "":
|
||||
return default
|
||||
return str(val).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def collect_runtime_diagnostics(
|
||||
*,
|
||||
skill_slug: str,
|
||||
platform_kit_min_version: str | None = None,
|
||||
skill_root: str | Path | None = None,
|
||||
record_video: bool | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> RuntimeDiagnostics:
|
||||
env_map = _env_dict(env)
|
||||
issues: list[RuntimeIssue] = []
|
||||
|
||||
claw_data_root = env_map.get("CLAW_DATA_ROOT")
|
||||
jiangchang_data_root = env_map.get("JIANGCHANG_DATA_ROOT")
|
||||
resolved_data_root = _resolve_data_root(env_map)
|
||||
|
||||
media_root_path = resolve_media_assets_root(resolved_data_root, env_map)
|
||||
media_status = probe_media_assets(resolved_data_root, env_map)
|
||||
ffmpeg_path_obj = media_status.ffmpeg_path
|
||||
ffmpeg_ok = ffmpeg_path_obj is not None and ffmpeg_path_obj.is_file()
|
||||
|
||||
music_probe = probe_background_music(media_root_path, env=env_map)
|
||||
mp3_count = int(music_probe["mp3_count"])
|
||||
usable_count = int(music_probe["usable_count"])
|
||||
music_issue = music_probe.get("issue")
|
||||
music_sample = music_probe.get("sample_path")
|
||||
|
||||
if music_issue is None and not media_status.music_ready and media_status.warnings:
|
||||
for warning in media_status.warnings:
|
||||
if warning.startswith("background_music") or warning == "music_dir_missing":
|
||||
music_issue = warning
|
||||
break
|
||||
|
||||
platform_version = _platform_kit_version()
|
||||
platform_ok: bool | None
|
||||
if platform_kit_min_version is None:
|
||||
platform_ok = None
|
||||
elif platform_version is None:
|
||||
platform_ok = False
|
||||
else:
|
||||
platform_ok = version_ge(platform_version, platform_kit_min_version)
|
||||
|
||||
if platform_version is None:
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="platform_kit_not_installed",
|
||||
message="jiangchang-platform-kit package metadata not found",
|
||||
severity="error",
|
||||
)
|
||||
)
|
||||
elif platform_kit_min_version is not None and not platform_ok:
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="platform_kit_version_low",
|
||||
message=(
|
||||
f"jiangchang-platform-kit version {platform_version} "
|
||||
f"is below required {platform_kit_min_version}"
|
||||
),
|
||||
severity="error",
|
||||
)
|
||||
)
|
||||
|
||||
core_file: str | None
|
||||
try:
|
||||
core_file = os.path.abspath(jiangchang_skill_core.__file__)
|
||||
except (OSError, TypeError, ValueError):
|
||||
core_file = None
|
||||
|
||||
if skill_root is not None and core_file is not None:
|
||||
if is_jiangchang_skill_core_from_skill_tree(skill_root=skill_root, core_file=core_file):
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="jiangchang_skill_core_loaded_from_skill_tree",
|
||||
message=_SHARED_CORE_HINT,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
|
||||
if record_video is None:
|
||||
record_video_enabled = _bool_from_env(env_map, "OPENCLAW_RECORD_VIDEO", False)
|
||||
else:
|
||||
record_video_enabled = record_video
|
||||
|
||||
if not ffmpeg_ok:
|
||||
severity = "warning"
|
||||
message = "ffmpeg not available"
|
||||
if record_video_enabled:
|
||||
message = "OPENCLAW_RECORD_VIDEO enabled but ffmpeg is not available"
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="ffmpeg_unavailable",
|
||||
message=message,
|
||||
severity=severity,
|
||||
)
|
||||
)
|
||||
|
||||
if record_video_enabled and music_issue:
|
||||
issues.append(
|
||||
RuntimeIssue(
|
||||
code="background_music_unavailable",
|
||||
message=f"background music unavailable: {music_issue}",
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
|
||||
return RuntimeDiagnostics(
|
||||
skill_slug=skill_slug,
|
||||
python_executable=sys.executable,
|
||||
platform_kit_version=platform_version,
|
||||
platform_kit_min_version=platform_kit_min_version,
|
||||
platform_kit_version_ok=platform_ok,
|
||||
jiangchang_skill_core_file=core_file,
|
||||
claw_data_root=claw_data_root,
|
||||
jiangchang_data_root=jiangchang_data_root,
|
||||
resolved_data_root=resolved_data_root,
|
||||
media_assets_root=str(media_root_path),
|
||||
ffmpeg_available=ffmpeg_ok,
|
||||
ffmpeg_path=str(ffmpeg_path_obj) if ffmpeg_path_obj else None,
|
||||
background_music_mp3_count=mp3_count,
|
||||
background_music_usable_count=usable_count,
|
||||
background_music_issue=str(music_issue) if music_issue else None,
|
||||
background_music_sample_path=str(music_sample) if music_sample else None,
|
||||
record_video_enabled=record_video_enabled,
|
||||
issues=tuple(issues),
|
||||
)
|
||||
|
||||
|
||||
def format_runtime_health_lines(diagnostics: RuntimeDiagnostics) -> list[str]:
|
||||
lines = [
|
||||
f"skill_slug: {diagnostics.skill_slug}",
|
||||
f"python_executable: {diagnostics.python_executable}",
|
||||
f"platform_kit_version: {diagnostics.platform_kit_version or ''}",
|
||||
f"platform_kit_min_version: {diagnostics.platform_kit_min_version or ''}",
|
||||
f"platform_kit_version_ok: {diagnostics.platform_kit_version_ok}",
|
||||
f"jiangchang_skill_core_file: {diagnostics.jiangchang_skill_core_file or ''}",
|
||||
f"CLAW_DATA_ROOT: {diagnostics.claw_data_root or ''}",
|
||||
f"JIANGCHANG_DATA_ROOT: {diagnostics.jiangchang_data_root or ''}",
|
||||
f"resolved_data_root: {diagnostics.resolved_data_root or ''}",
|
||||
f"media_assets_root: {diagnostics.media_assets_root or ''}",
|
||||
f"ffmpeg_available: {diagnostics.ffmpeg_available}",
|
||||
f"ffmpeg_path: {diagnostics.ffmpeg_path or ''}",
|
||||
f"background_music_mp3_count: {diagnostics.background_music_mp3_count}",
|
||||
f"background_music_usable_count: {diagnostics.background_music_usable_count}",
|
||||
f"background_music_issue: {diagnostics.background_music_issue or ''}",
|
||||
f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}",
|
||||
f"record_video_enabled: {diagnostics.record_video_enabled}",
|
||||
]
|
||||
for issue in diagnostics.issues:
|
||||
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} — {issue.message}")
|
||||
return lines
|
||||
|
||||
|
||||
def runtime_diagnostics_dict(diagnostics: RuntimeDiagnostics) -> dict[str, Any]:
|
||||
return {
|
||||
"skill_slug": diagnostics.skill_slug,
|
||||
"python_executable": diagnostics.python_executable,
|
||||
"platform_kit_version": diagnostics.platform_kit_version,
|
||||
"platform_kit_min_version": diagnostics.platform_kit_min_version,
|
||||
"platform_kit_version_ok": diagnostics.platform_kit_version_ok,
|
||||
"jiangchang_skill_core_file": diagnostics.jiangchang_skill_core_file,
|
||||
"CLAW_DATA_ROOT": diagnostics.claw_data_root,
|
||||
"JIANGCHANG_DATA_ROOT": diagnostics.jiangchang_data_root,
|
||||
"resolved_data_root": diagnostics.resolved_data_root,
|
||||
"media_assets_root": diagnostics.media_assets_root,
|
||||
"ffmpeg_available": diagnostics.ffmpeg_available,
|
||||
"ffmpeg_path": diagnostics.ffmpeg_path,
|
||||
"background_music_mp3_count": diagnostics.background_music_mp3_count,
|
||||
"background_music_usable_count": diagnostics.background_music_usable_count,
|
||||
"background_music_issue": diagnostics.background_music_issue,
|
||||
"background_music_sample_path": diagnostics.background_music_sample_path,
|
||||
"record_video_enabled": diagnostics.record_video_enabled,
|
||||
"runtime_issues": [
|
||||
{"code": issue.code, "message": issue.message, "severity": issue.severity}
|
||||
for issue in diagnostics.issues
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def log_runtime_diagnostics(
|
||||
diagnostics: RuntimeDiagnostics,
|
||||
logger: logging.Logger | None = None,
|
||||
level: int = logging.INFO,
|
||||
) -> None:
|
||||
log = logger or logging.getLogger("jiangchang_skill_core.runtime_diagnostics")
|
||||
for line in format_runtime_health_lines(diagnostics):
|
||||
log.log(level, line)
|
||||
Reference in New Issue
Block a user