Files
xinmi/internal/workbench/diagnostics.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

214 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package workbench
import (
"context"
"errors"
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
)
var httpStatusPattern = regexp.MustCompile(`(?i)(?:status(?: code)?[ :]+)([45][0-9]{2})\b`)
var diagnosticSecretPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)(bearer\s+)[^\s,;]+`),
regexp.MustCompile(`(?i)((?:api[_-]?key|access[_-]?token|authorization|password|secret)\s*[=:]\s*)[^\s,;]+`),
regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{8,}`),
}
type phaseTimeoutError struct {
phase string
elapsed time.Duration
cause error
}
func (e *phaseTimeoutError) Error() string { return e.phase + " timeout" }
func (e *phaseTimeoutError) Unwrap() error { return e.cause }
func phaseFailure(err error, phase string, started time.Time) error {
if err == nil {
return nil
}
var existing *phaseTimeoutError
if errors.As(err, &existing) {
return err
}
var network net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &network) && network.Timeout()) {
return &phaseTimeoutError{phase: phase, elapsed: time.Since(started), cause: err}
}
return err
}
func redactDiagnosticMessage(message string) string {
for _, pattern := range diagnosticSecretPatterns {
message = pattern.ReplaceAllString(message, "[已隐藏]")
}
message = strings.Join(strings.Fields(message), " ")
if runes := []rune(message); len(runes) > 600 {
message = string(runes[:600]) + "..."
}
return message
}
// Diagnostic values are derived from error types and known status markers.
// Provider response bodies can echo prompts or credentials and are never logged here.
func jobErrorDetails(err error) (string, string) {
var phase *phaseTimeoutError
if errors.As(err, &phase) {
label := "AI 模型响应"
if phase.phase == "retrieval" {
label = "知识库检索"
} else if phase.phase == "reading" {
label = "资料摘要读取"
}
return phase.phase + "_timeout", fmt.Sprintf("%s超时(已等待 %d 秒),原内容已保留;可按任务编号查看具体阶段后重试", label, max(1, int(phase.elapsed.Seconds())))
}
var structured interface{ StructuredOutputDiagnostic() JSON }
if errors.As(err, &structured) {
return "structured_output_invalid", redactDiagnosticMessage(err.Error())
}
var appError *apperrors.AppError
if errors.As(err, &appError) && appError.HTTPCode >= 400 && appError.HTTPCode < 500 {
return fmt.Sprintf("application_%d", appError.Code), redactDiagnosticMessage(appError.Message)
}
switch {
case errors.Is(err, context.DeadlineExceeded):
return "timeout", "任务处理超过时限,原内容已保留;请按任务编号查看具体阶段后重试"
case errors.Is(err, context.Canceled):
return "cancelled", "任务执行已中断,已保留原内容,请重试"
case errors.Is(err, ErrForbidden):
return "forbidden", redactDiagnosticMessage(err.Error())
case errors.Is(err, ErrConflict):
return "conflict", redactDiagnosticMessage(err.Error())
case errors.Is(err, ErrInvalid):
return "invalid", redactDiagnosticMessage(err.Error())
case errors.Is(err, gorm.ErrRecordNotFound):
return "resource_unavailable", "任务引用的资源不存在或已不可访问,请检查 Agent、导图和画布配置"
case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
return "provider_disconnected", "模型服务连接中断或返回不完整,已保留原内容,请重试"
}
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() {
return "network_timeout", "连接模型或知识服务超时,已保留原内容,请重试"
}
return "network_error", "无法连接模型或知识服务,请检查服务地址及网络后重试"
}
if match := httpStatusPattern.FindStringSubmatch(err.Error()); len(match) == 2 {
status, _ := strconv.Atoi(match[1])
message := "模型服务返回错误"
switch status {
case 401:
message = "模型服务认证失败,请检查模型密钥"
case 403:
message = "模型服务拒绝访问,请检查密钥的模型权限"
case 404:
message = "模型或服务接口不存在,请检查模型名称及服务地址"
case 429:
message = "模型服务限流或额度不足,请检查额度并稍后重试"
default:
if status >= 500 {
message = "模型服务暂时异常,请稍后重试"
}
}
return fmt.Sprintf("provider_http_%d", status), fmt.Sprintf("%s(HTTP %d),原内容已保留", message, status)
}
return "internal_error", "系统处理失败,已记录错误;请联系管理员按发生时间查看服务日志"
}
func jobErrorMetadata(err error) JSON {
class, message := jobErrorDetails(err)
errorTypes := []string{}
for current := err; current != nil && len(errorTypes) < 8; current = errors.Unwrap(current) {
errorTypes = append(errorTypes, fmt.Sprintf("%T", current))
}
metadata := JSON{"error_class": class, "error_message": message, "error_type": strings.Join(errorTypes, " -> ")}
var phase *phaseTimeoutError
if errors.As(err, &phase) {
metadata["phase"], metadata["elapsed_ms"] = phase.phase, phase.elapsed.Milliseconds()
}
var appError *apperrors.AppError
if errors.As(err, &appError) {
metadata["application_code"], metadata["application_http_status"] = appError.Code, appError.HTTPCode
}
var structured interface{ StructuredOutputDiagnostic() JSON }
if errors.As(err, &structured) {
metadata["structured_output"] = structured.StructuredOutputDiagnostic()
}
return metadata
}
func logJobFailure(ctx context.Context, job *Job, stage string, err error) {
fields := logger.Fields(jobErrorMetadata(err))
fields["stage"] = stage
if job != nil {
fields["job_id"], fields["resource_id"] = job.ID, job.ResourceID
fields["tenant_id"], fields["owner_id"] = job.TenantID, job.OwnerID
fields["kind"], fields["action"] = job.Kind, job.Action
}
logger.GetLogger(ctx).WithFields(fields).Error("工作台任务处理失败")
}
func (s *Service) recordJobFailure(ctx context.Context, job *Job, stage string, err error) {
if job == nil || job.TenantID == 0 {
return
}
ctx = context.WithValue(ctx, types.TenantIDContextKey, job.TenantID)
ctx = context.WithValue(ctx, types.UserIDContextKey, job.OwnerID)
metadata := jobErrorMetadata(err)
metadata["source"], metadata["job_id"], metadata["stage"] = "background_job", job.ID, stage
metadata["kind"], metadata["action"], metadata["job_status"] = job.Kind, job.Action, job.Status
var stored Job
if queryErr := s.db.WithContext(ctx).Select("status", "progress", "stage", "generation").Where("id=? AND tenant_id=?", job.ID, job.TenantID).Take(&stored).Error; queryErr == nil {
metadata["progress"], metadata["job_stage"], metadata["job_status"] = stored.Progress, stored.Stage, stored.Status
for _, key := range []string{"model_id", "agent_id", "attempt"} {
if value, exists := stored.Generation[key]; exists {
metadata[key] = value
}
}
if phase, exists := stored.Generation["phase"]; exists {
metadata["generation_phase"] = phase
if _, diagnosed := metadata["phase"]; !diagnosed {
metadata["phase"] = phase
}
}
}
level := "error"
if class := metadata["error_class"].(string); class == "forbidden" || class == "conflict" || class == "invalid" || class == "cancelled" || class == "resource_unavailable" || strings.HasPrefix(class, "application_") {
level = "warn"
}
row := OperationLog{Kind: "system", Level: level, Action: actionName(job.Action), ResourceID: job.ResourceID, TraceID: job.ID,
Message: actionName(job.Action) + "失败:" + metadata["error_message"].(string), Metadata: metadata}
if recordErr := s.recordOperation(ctx, row); recordErr != nil {
logJobFailure(ctx, job, "persist_failure", recordErr)
}
}
func (s *Service) recoverExpiredJobs(ctx context.Context, now time.Time) error {
var jobs []Job
if err := s.db.WithContext(ctx).Where("status = ? AND (lease_until IS NULL OR lease_until <= ?)", "running", now).Order("updated_at ASC").Limit(100).Find(&jobs).Error; err != nil {
return err
}
for i := range jobs {
job := &jobs[i]
result := s.db.WithContext(ctx).Model(&Job{}).Where("id = ? AND tenant_id = ? AND status = ? AND (lease_until IS NULL OR lease_until <= ?)", job.ID, job.TenantID, "running", now).
Updates(map[string]any{"status": "failed", "stage": "执行中断", "error": "任务执行中断,请检查结果后重试", "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 1 {
s.recordJobFailure(ctx, job, "lease_recovery", context.Canceled)
}
}
return nil
}