"""Bounded, read-only task diagnostics shared by MCP and HTTP clients.""" import os import re from apps.admin_console.database.repositories.session_repository import session_repo from apps.admin_console.services import evidence_service, report_export from apps.admin_console.services.automation_store import identifier, store from apps.admin_console.services.request_diagnostics import request_diagnostics _SECRET = re.compile( r"(?i)^(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|client_secret|token_key)$" ) def public_value(value, *, max_chars: int = 60000): """Remove common credential forms and bound text without inventing missing evidence.""" remaining = max_chars remaining_nodes = 2000 truncated = False secrets = [ v for k, v in os.environ.items() if len(v) >= 8 and re.search(r"(?i)(api_?key|password|secret|token)$", k) ] def clean(item, depth=0): nonlocal remaining, remaining_nodes, truncated remaining_nodes -= 1 if depth > 12: truncated = True return "[嵌套内容已截断]" if isinstance(item, dict): result = {} for k, v in item.items(): if remaining_nodes <= 0 or len(result) >= 100: truncated = True break key = clean(str(k), depth + 1) result[key] = "[REDACTED]" if _SECRET.fullmatch(str(k)) else clean(v, depth + 1) return result if isinstance(item, list): result = [] for v in item: if remaining_nodes <= 0 or len(result) >= 100: truncated = True break result.append(clean(v, depth + 1)) return result if not isinstance(item, str): return item for secret in secrets: item = item.replace(secret, "[REDACTED]") item = re.sub(r"(?i)\bBearer\s+[^\s\"',;]+", "Bearer [REDACTED]", item) item = re.sub(r"\bsk-[A-Za-z0-9_-]+", "[REDACTED]", item) item = re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[REDACTED]@", item) item = re.sub( r"""(?ix)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|token_key)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s&,;]+)""", r"\1[REDACTED]", item, ) allowance = min(8000, remaining) if len(item) > allowance: truncated = True item = item[:allowance] + "…[已截断;按步骤或关键词缩小范围]" remaining = max(0, remaining - min(len(item), allowance)) return item result = clean(value) return {**result, "text_truncated": truncated, "content_truncated": truncated} def page(items: list, offset: int, limit: int) -> dict: return { "items": items[offset : offset + limit], "total": len(items), "offset": offset, "limit": limit, "next_offset": offset + limit if offset + limit < len(items) else None, } def inspect_task(session_id: str, view: str, *, offset: int = 0, limit: int = 20) -> dict: identifier(session_id) if offset < 0 or not 1 <= limit <= 100: raise ValueError("offset 必须非负,limit 必须在 1–100 之间") if not session_repo.get_session_by_id(session_id) and not store.job(session_id): raise LookupError("任务记录不存在") if view == "usage": result = session_repo.get_session_usage(session_id) result["cache_hit_rate"] = ( result["cached_tokens"] / result["prompt_tokens"] if result["prompt_tokens"] else None ) result["note"] = ( "缓存率=已记录 cached_tokens / prompt_tokens;无用量不等于零消耗,未上报的请求无法补推。" ) elif view == "requests": result = request_diagnostics(session_id, limit=limit, offset=offset) elif view == "logs": root = evidence_service.task_root(session_id) result = {"logs": []} for name in ("stdout.log", "stderr.log"): path = evidence_service.evidence_file(root, name) if path is None: result["logs"].append({"name": name, "available": False}) continue with path.open("rb") as stream: size = path.stat().st_size start = max(0, size - 128 * 1024) stream.seek(start) raw = stream.read(128 * 1024) lines = raw.decode("utf-8", errors="replace").splitlines() if start and lines: lines = lines[1:] # Offset counts backward from the newest line within the bounded tail. end = max(0, len(lines) - offset) result["logs"].append( { "name": name, "available": True, "bytes": size, "lines": lines[max(0, end - limit) : end], "offset": offset, "next_offset": offset + limit if end > limit else None, "older_bytes_omitted": start, "order": "oldest_to_newest", } ) elif view in {"report", "evidence"}: report = report_export.session_report(session_id) if view == "report": result = {**report, "steps": page(report.get("steps", []), offset, limit)} else: index = evidence_service.export_index( session_id, report, report_export.session_videos(report) ) result = { **index, **{ key: page(index.get(key, []), offset, limit) for key in ("steps", "calls", "recordings", "android_videos", "files") }, } else: raise ValueError("view 需为 report / usage / requests / logs / evidence") return public_value({"session_id": session_id, **result})