Files
dingtalk-workspace-cli/internal/recovery/models.go
T

680 lines
21 KiB
Go

package recovery
import (
"crypto/sha1"
"encoding/hex"
stderrors "errors"
"fmt"
"regexp"
"sort"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
type OperationKind string
const (
OperationRead OperationKind = "read"
OperationWrite OperationKind = "write"
OperationUnknown OperationKind = "unknown"
)
type DecisionOwner string
const (
DecisionOwnerBuiltinRule DecisionOwner = "builtin_rule"
DecisionOwnerAgent DecisionOwner = "agent"
)
type RecoveryContext struct {
CommandPath []string `json:"command_path"`
ServerID string `json:"server_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
OperationKind OperationKind `json:"operation_kind"`
CLIErrorCode string `json:"cli_error_code,omitempty"`
RawError string `json:"raw_error"`
CallStage string `json:"call_stage,omitempty"`
HTTPStatus int `json:"http_status,omitempty"`
RetryAfter string `json:"retry_after,omitempty"`
TraceID string `json:"trace_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
ArgsSummary map[string]any `json:"args_summary,omitempty"`
Fingerprint string `json:"fingerprint"`
}
type Replay struct {
ServerID string `json:"server_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
OperationKind OperationKind `json:"operation_kind"`
ToolArgs map[string]any `json:"tool_args,omitempty"`
RedactedArgv []string `json:"redacted_argv,omitempty"`
RedactedCommand string `json:"redacted_command,omitempty"`
}
type RecoveryPlan struct {
Category string `json:"category"`
DecisionOwner DecisionOwner `json:"decision_owner,omitempty"`
Confidence float64 `json:"confidence"`
AutoActions []string `json:"auto_actions"`
SafeActions []string `json:"safe_actions"`
DocActions []DocAction `json:"doc_actions,omitempty"`
DocSearch DocSearch `json:"doc_search"`
DecisionHints DecisionHints `json:"decision_hints"`
HumanActions []string `json:"human_actions"`
Evidence []string `json:"evidence"`
KBHits []KBHit `json:"kb_hits"`
ShouldRetry bool `json:"should_retry"`
ShouldStop bool `json:"should_stop"`
RuleHints RuleHints `json:"rule_hints"`
AgentRoute AgentRoute `json:"agent_route"`
}
type KBHit struct {
Source string `json:"source"`
Title string `json:"title"`
URL string `json:"url,omitempty"`
Snippet string `json:"snippet,omitempty"`
Score float64 `json:"score,omitempty"`
}
type DocAction struct {
Action string `json:"action"`
Reason string `json:"reason,omitempty"`
SourceTitle string `json:"source_title,omitempty"`
SourceURL string `json:"source_url,omitempty"`
}
type DocSearch struct {
Provider string `json:"provider,omitempty"`
Query string `json:"query,omitempty"`
Page int `json:"page,omitempty"`
Size int `json:"size,omitempty"`
CurrentPage int `json:"current_page,omitempty"`
TotalCount int `json:"total_count,omitempty"`
HasMore bool `json:"has_more"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
Request *ToolCallRecord `json:"request,omitempty"`
Response *ToolResponse `json:"response,omitempty"`
Items []DocSearchItem `json:"items,omitempty"`
}
type DocSearchItem struct {
Title string `json:"title"`
URL string `json:"url,omitempty"`
Desc string `json:"desc,omitempty"`
}
type ToolCallRecord struct {
ServerID string `json:"server_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Arguments map[string]any `json:"arguments,omitempty"`
}
type ToolResponse struct {
IsError bool `json:"is_error,omitempty"`
Content []ToolResponseBlock `json:"content,omitempty"`
}
type ToolResponseBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
type RuleHints struct {
Category string `json:"category"`
DecisionOwner DecisionOwner `json:"decision_owner,omitempty"`
Confidence float64 `json:"confidence"`
SafeActions []string `json:"safe_actions"`
HumanActions []string `json:"human_actions"`
Evidence []string `json:"evidence"`
ShouldRetry bool `json:"should_retry"`
ShouldStop bool `json:"should_stop"`
}
type DecisionHints struct {
Retryable bool `json:"retryable"`
PermissionSensitive bool `json:"permission_sensitive"`
AuthRelated bool `json:"auth_related"`
ResourceStateRelated bool `json:"resource_state_related"`
}
type AgentRoute struct {
Required bool `json:"required"`
Target string `json:"target,omitempty"`
Executor string `json:"executor,omitempty"`
Reasons []string `json:"reasons"`
Payload *AgentRoutePayload `json:"payload,omitempty"`
}
type AgentRoutePayload struct {
EventID string `json:"event_id"`
Context RecoveryContext `json:"context"`
Replay Replay `json:"replay"`
RawError string `json:"raw_error"`
Category string `json:"category"`
DecisionOwner DecisionOwner `json:"decision_owner,omitempty"`
Confidence float64 `json:"confidence"`
ShouldRetry bool `json:"should_retry"`
ShouldStop bool `json:"should_stop"`
SafeActions []string `json:"safe_actions"`
DocActions []DocAction `json:"doc_actions,omitempty"`
KBHits []KBHit `json:"kb_hits"`
DocSearch DocSearch `json:"doc_search"`
HumanActions []string `json:"human_actions"`
DecisionHints DecisionHints `json:"decision_hints"`
Evidence []string `json:"evidence"`
RuleHints RuleHints `json:"rule_hints"`
ProbeResults []ProbeResult `json:"probe_results,omitempty"`
}
type ProbeResult struct {
Name string `json:"name"`
Status string `json:"status"`
ServerID string `json:"server_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ArgsSummary map[string]any `json:"args_summary,omitempty"`
Summary string `json:"summary,omitempty"`
Output any `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
type AgentTask struct {
Goal string `json:"goal"`
Why string `json:"why"`
MustReadRefs []string `json:"must_read_refs,omitempty"`
AllowedActions []string `json:"allowed_actions,omitempty"`
ForbiddenActions []string `json:"forbidden_actions,omitempty"`
StopConditions []string `json:"stop_conditions,omitempty"`
FinalizeRequirement string `json:"finalize_requirement,omitempty"`
}
type FinalizeHint struct {
Required bool `json:"required"`
Command string `json:"command,omitempty"`
ExecutionFileFields []string `json:"execution_file_fields,omitempty"`
AllowedOutcomes []string `json:"allowed_outcomes,omitempty"`
}
type RecoveryBundle struct {
Status string `json:"status"`
EventID string `json:"event_id"`
Context RecoveryContext `json:"context"`
Replay Replay `json:"replay"`
Plan RecoveryPlan `json:"plan"`
DocSearch DocSearch `json:"doc_search"`
KBHits []KBHit `json:"kb_hits,omitempty"`
DocActions []DocAction `json:"doc_actions,omitempty"`
HumanActions []string `json:"human_actions,omitempty"`
ProbeResults []ProbeResult `json:"probe_results,omitempty"`
AgentTask AgentTask `json:"agent_task"`
FinalizeHint FinalizeHint `json:"finalize_hint"`
AnalysisError string `json:"analysis_error,omitempty"`
}
type LastError struct {
EventID string `json:"event_id"`
RecordedAt string `json:"recorded_at"`
Context RecoveryContext `json:"context"`
Replay Replay `json:"replay,omitempty"`
}
type RecoveryEvent struct {
EventID string `json:"event_id"`
Phase string `json:"phase"`
RecordedAt string `json:"recorded_at"`
Context *RecoveryContext `json:"context,omitempty"`
Replay *Replay `json:"replay,omitempty"`
Plan *RecoveryPlan `json:"plan,omitempty"`
Bundle *RecoveryBundle `json:"bundle,omitempty"`
Execution *RecoveryExecution `json:"execution,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
type RecoveryAttempt struct {
CommandSummary string `json:"command_summary,omitempty"`
Result string `json:"result,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
Source string `json:"source,omitempty"`
}
type RecoveryExecution struct {
Actions []string `json:"actions,omitempty"`
Attempts []RecoveryAttempt `json:"attempts,omitempty"`
Result string `json:"result,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
}
type CaptureInput struct {
CommandPath []string
ServerID string
ToolName string
OperationKind OperationKind
Args map[string]any
Argv []string
RawErr error
WrappedErr error
}
type KnowledgeRetrieval struct {
KBHits []KBHit `json:"kb_hits"`
DocSearch DocSearch `json:"doc_search"`
}
func BuildContext(input CaptureInput) RecoveryContext {
operationKind := input.OperationKind
if operationKind == "" || operationKind == OperationUnknown {
operationKind = InferOperationKind(input.ToolName)
}
rawError := ""
if input.RawErr != nil {
rawError = input.RawErr.Error()
}
ctx := RecoveryContext{
CommandPath: append([]string(nil), input.CommandPath...),
ServerID: input.ServerID,
ToolName: input.ToolName,
OperationKind: operationKind,
RawError: rawError,
ArgsSummary: SummarizeArgs(input.Args),
}
if code := canonicalCLIErrorCode(input.WrappedErr, rawError); code != "" {
ctx.CLIErrorCode = code
}
var callErr *transport.CallError
if stderrors.As(input.RawErr, &callErr) || stderrors.As(input.WrappedErr, &callErr) {
ctx.CallStage = string(callErr.Stage)
ctx.HTTPStatus = callErr.HTTPStatus
ctx.RetryAfter = callErr.RetryAfter
ctx.TraceID = callErr.TraceID
ctx.RequestID = callErr.RequestID
}
ctx.Fingerprint = ComputeFingerprint(ctx)
return ctx
}
func canonicalCLIErrorCode(err error, rawError string) string {
var typed *apperrors.Error
if stderrors.As(err, &typed) {
reason := strings.ToLower(strings.TrimSpace(typed.Reason))
message := strings.ToLower(strings.TrimSpace(typed.Message + " " + rawError))
switch typed.Category {
case apperrors.CategoryValidation:
if strings.Contains(message, "required") || strings.Contains(message, "missing") {
return cliMissingParamCode
}
return cliInvalidJSONCode
case apperrors.CategoryAuth:
if reason == "http_403" || strings.Contains(message, "forbidden") || strings.Contains(message, "permission") || strings.Contains(message, "无权限") {
return cliPermissionCode
}
return cliAuthExpiredCode
case apperrors.CategoryAPI, apperrors.CategoryDiscovery:
switch {
case reason == "http_429" || typed.Retryable && strings.Contains(message, "rate limit"):
return cliRateLimitCode
case strings.Contains(reason, "timeout") || strings.Contains(message, "timeout") || strings.Contains(message, "deadline exceeded") || strings.Contains(message, "connection refused") || strings.Contains(message, "connection reset"):
return cliTimeoutCode
case strings.Contains(reason, "invalid_params"):
return cliInvalidJSONCode
case strings.Contains(reason, "method_not_found"):
return cliResourceNotFoundCode
case strings.Contains(reason, "http_404"):
return cliResourceNotFoundCode
}
}
}
normalized := strings.ToLower(strings.TrimSpace(rawError))
switch {
case strings.Contains(normalized, "token验证失败") || strings.Contains(normalized, "user_token_illegal"):
return cliAuthExpiredCode
case strings.Contains(normalized, "forbidden") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "无权限"):
return cliPermissionCode
case strings.Contains(normalized, "too many requests") || strings.Contains(normalized, "rate limit"):
return cliRateLimitCode
case strings.Contains(normalized, "timeout") || strings.Contains(normalized, "deadline exceeded") || strings.Contains(normalized, "connection refused") || strings.Contains(normalized, "connection reset"):
return cliTimeoutCode
case strings.Contains(normalized, "not found") || strings.Contains(normalized, "资源不存在") || strings.Contains(normalized, "base_not_found"):
return cliResourceNotFoundCode
}
return ""
}
func BuildReplay(input CaptureInput) Replay {
operationKind := input.OperationKind
if operationKind == "" || operationKind == OperationUnknown {
operationKind = InferOperationKind(input.ToolName)
}
replay := Replay{
ServerID: input.ServerID,
ToolName: input.ToolName,
OperationKind: operationKind,
ToolArgs: sanitizeReplayMap(input.Args),
}
if len(input.Argv) > 0 {
replay.RedactedArgv = sanitizeArgv(input.Argv)
replay.RedactedCommand = strings.Join(replay.RedactedArgv, " ")
}
return replay
}
func InferOperationKind(toolName string) OperationKind {
toolName = strings.ToLower(strings.TrimSpace(toolName))
if toolName == "" {
return OperationUnknown
}
readPrefixes := []string{"get_", "list_", "search_", "query_", "status_", "download_"}
for _, prefix := range readPrefixes {
if strings.HasPrefix(toolName, prefix) {
return OperationRead
}
}
writePrefixes := []string{
"create_", "update_", "delete_", "send_", "approve_", "reject_", "revoke_",
"upload_", "import_", "commit_", "add_", "remove_", "modify_", "set_",
"assign_", "insert_", "recall_", "batch_send_", "batch_recall_", "save_",
"generate", "edit", "upscale", "isolate",
}
for _, prefix := range writePrefixes {
if strings.HasPrefix(toolName, prefix) {
return OperationWrite
}
}
return OperationUnknown
}
func SummarizeArgs(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
keys := make([]string, 0, len(args))
for key := range args {
keys = append(keys, key)
}
sort.Strings(keys)
summary := make(map[string]any, len(keys))
for _, key := range keys {
if isSensitiveKey(key) {
continue
}
summary[key] = summarizeValue(key, args[key])
}
return summary
}
func sanitizeReplayMap(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
if isSensitiveKey(key) {
continue
}
out[key] = sanitizeReplayField(key, value)
}
return out
}
func sanitizeReplayField(key string, value any) any {
lowerKey := strings.ToLower(key)
if isContentKey(lowerKey) {
return summarizeValue(lowerKey, value)
}
switch v := value.(type) {
case string:
if len(v) > 120 {
return summarizeValue(lowerKey, v)
}
return v
case map[string]string:
return sanitizeReplayStringMap(v)
case []map[string]any:
out := make([]map[string]any, 0, len(v))
for _, item := range v {
out = append(out, sanitizeReplayMap(item))
}
return out
default:
return sanitizeReplayValue(v)
}
}
func sanitizeReplayValue(value any) any {
switch v := value.(type) {
case map[string]any:
return sanitizeReplayMap(v)
case map[string]string:
return sanitizeReplayStringMap(v)
case []any:
out := make([]any, 0, len(v))
for _, item := range v {
out = append(out, sanitizeReplayValue(item))
}
return out
case []string:
out := make([]string, len(v))
copy(out, v)
return out
case []map[string]any:
out := make([]map[string]any, 0, len(v))
for _, item := range v {
out = append(out, sanitizeReplayMap(item))
}
return out
default:
return v
}
}
func sanitizeReplayStringMap(args map[string]string) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
if isSensitiveKey(key) {
continue
}
out[key] = sanitizeReplayField(key, value)
}
return out
}
func sanitizeArgv(argv []string) []string {
if len(argv) == 0 {
return nil
}
sensitiveFlags := map[string]struct{}{
"--token": {},
"--auth-code": {},
"--refresh-token": {},
}
out := make([]string, 0, len(argv))
skipValue := false
for _, arg := range argv {
if skipValue {
out = append(out, "<redacted>")
skipValue = false
continue
}
if value, ok := redactSensitiveArg(arg); ok {
out = append(out, value)
continue
}
if _, ok := sensitiveFlags[arg]; ok {
out = append(out, arg)
skipValue = true
continue
}
out = append(out, arg)
}
return out
}
func redactSensitiveArg(arg string) (string, bool) {
for _, prefix := range []string{"--token=", "--auth-code=", "--refresh-token="} {
if strings.HasPrefix(arg, prefix) {
return prefix + "<redacted>", true
}
}
return "", false
}
func summarizeValue(key string, value any) any {
lowerKey := strings.ToLower(key)
switch v := value.(type) {
case string:
if isContentKey(lowerKey) || len(v) > 120 {
return map[string]any{"kind": "string", "length": float64(len(v))}
}
return v
case []string:
if isContentKey(lowerKey) {
return map[string]any{"kind": "array", "count": float64(len(v))}
}
out := make([]string, len(v))
copy(out, v)
return out
case []any:
return map[string]any{"kind": "array", "count": float64(len(v))}
case []map[string]any:
if isContentKey(lowerKey) {
return map[string]any{"kind": "array", "count": float64(len(v))}
}
out := make([]map[string]any, 0, len(v))
for _, item := range v {
nested := make(map[string]any, len(item))
for nestedKey, nestedValue := range item {
if isSensitiveKey(nestedKey) {
continue
}
nested[nestedKey] = summarizeValue(nestedKey, nestedValue)
}
out = append(out, nested)
}
return out
case map[string]any:
if isContentKey(lowerKey) {
return map[string]any{"kind": "object", "keys": sortedKeys(v)}
}
out := make(map[string]any, len(v))
for nestedKey, nestedValue := range v {
if isSensitiveKey(nestedKey) {
continue
}
out[nestedKey] = summarizeValue(nestedKey, nestedValue)
}
return out
case map[string]string:
if isContentKey(lowerKey) {
return map[string]any{"kind": "object", "keys": sortedStringKeys(v)}
}
out := make(map[string]any, len(v))
for nestedKey, nestedValue := range v {
if isSensitiveKey(nestedKey) {
continue
}
out[nestedKey] = summarizeValue(nestedKey, nestedValue)
}
return out
default:
return value
}
}
func ComputeFingerprint(ctx RecoveryContext) string {
base := strings.Join([]string{
ctx.ServerID,
ctx.ToolName,
ctx.CLIErrorCode,
fmt.Sprintf("%d", ctx.HTTPStatus),
normalizeRawError(ctx.RawError),
}, "|")
sum := sha1.Sum([]byte(base))
return hex.EncodeToString(sum[:])
}
var (
reURLQuery = regexp.MustCompile(`https?://[^\s?]+(?:\?[^\s]+)?`)
reUUID = regexp.MustCompile(`\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`)
reLongDigits = regexp.MustCompile(`\b\d{6,}\b`)
reTimestamp = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}[t\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:z|[+-]\d{2}:?\d{2})?\b`)
reLongToken = regexp.MustCompile(`\b[a-z0-9_-]{18,}\b`)
reResourceID = regexp.MustCompile(`\b(?:base|tbl|fld|rec|conv|user|task)_[a-z0-9_-]+\b`)
reMultiWhitespace = regexp.MustCompile(`\s+`)
)
func normalizeRawError(raw string) string {
normalized := strings.ToLower(strings.TrimSpace(raw))
replacements := []struct {
re *regexp.Regexp
repl string
}{
{reURLQuery, "<url>"},
{reUUID, "<uuid>"},
{reTimestamp, "<ts>"},
{reResourceID, "<id>"},
{reLongDigits, "<num>"},
{reLongToken, "<token>"},
}
for _, item := range replacements {
normalized = item.re.ReplaceAllString(normalized, item.repl)
}
normalized = reMultiWhitespace.ReplaceAllString(normalized, " ")
return strings.TrimSpace(normalized)
}
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sortedStringKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func isSensitiveKey(key string) bool {
key = strings.ToLower(key)
sensitive := []string{"token", "authcode", "refresh", "cookie", "header", "authorization", "x-user-access-token"}
for _, token := range sensitive {
if strings.Contains(key, token) {
return true
}
}
return false
}
func isContentKey(key string) bool {
key = strings.ToLower(key)
content := []string{"text", "body", "markdown", "json", "records", "fields"}
for _, token := range content {
if strings.Contains(key, token) {
return true
}
}
return false
}