diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..d76f5b11 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# Default code owners for all files +# These users will be automatically requested for review on PRs. + +* @DingTalk-Real-AI/cli-maintainers diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..815557ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug Report +about: Report a bug to help us improve +title: "[Bug] " +labels: bug +assignees: '' +--- + +## Describe the Bug + +A clear and concise description of what the bug is. + +## Steps to Reproduce + +1. Run `dws ...` +2. ... +3. See error + +## Expected Behavior + +A clear and concise description of what you expected to happen. + +## Actual Behavior + +What actually happened, including any error messages or unexpected output. + +## Environment + +- **OS**: [e.g., macOS 15.2, Ubuntu 24.04, Windows 11] +- **Architecture**: [e.g., arm64, amd64] +- **CLI Version**: [output of `dws version`] +- **Go Version** (if building from source): [output of `go version`] + +## Additional Context + +Add any other context about the problem here (logs, screenshots, etc.). diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..0726cb8a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: "[Feature] " +labels: enhancement +assignees: '' +--- + +## Problem Statement + +A clear and concise description of the problem or limitation you are experiencing. + +## Proposed Solution + +Describe the solution you'd like. Include any specific CLI commands, flags, or behaviors you envision. + +## Alternatives Considered + +A clear and concise description of any alternative solutions or features you've considered. + +## Use Case + +Describe the use case(s) that would benefit from this feature. + +## Additional Context + +Add any other context, mockups, or examples about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..cab641ce --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## Summary + +- What changed? +- Why is this change needed? + +## Verification + +- [ ] `make build` +- [ ] `make lint` +- [ ] `make test` +- [ ] `make policy` +- [ ] `./scripts/policy/check-generated-drift.sh` +- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed) + +## Notes + +- Any risks, follow-up work, or intentional scope cuts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c538750f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip + + - name: Build + run: make build + + - name: Format Check + run: | + unformatted="$(find cmd hack internal test -name '*.go' -print0 | xargs -0r gofmt -l)" + test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1) + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.64.8 + args: ./... + + - name: Test + run: make test + + - name: Policy + run: make policy + + - name: Generated Drift + run: ./scripts/policy/check-generated-drift.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..ad09439f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Post-release packaging + run: ./scripts/release/post-goreleaser.sh diff --git a/.gitignore b/.gitignore index 5039f159..13741b1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,51 +1,27 @@ -# Python __pycache__/ -.pytest_cache/ -.venv*/ -*.pyc -*.pyo - -# Build outputs dist/ .tmp-bin/ -dws - -# IDE -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# OS +.worktrees/ +.pytest_cache/ +.venv*/ +var/ .DS_Store -Thumbs.db +.agents/ +/.idea/ +/CLAUDE.md +/.claude/ +/.idea/vcs.xml +/.idea/.gitignore +dws +test/cli/testdata/ +tmp/ +test/cli_compat/testdata/ +.gitignore +.worktrees/ -# Environment & Secrets +# Secrets & credentials .env .env.* *.pem *.key -credentials* - -# Test artifacts -*.log -coverage/ -test/cli/testdata/ -test/cli_compat/testdata/ - -# Local working directories -.worktrees/ -.agents/ -var/ - -# Node (if applicable) -node_modules/ -npm-debug.log* - -# Plans (local design docs) -docs/plans/ - -# Claude -/CLAUDE.md -/.claude/ +credentials* \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 00000000..c3119b69 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,71 @@ +# GoReleaser configuration for dws +# Docs: https://goreleaser.com +# +# To release: +# git tag -a v0.1.0 -m "Release v0.1.0" +# git push origin v0.1.0 +# +# To test locally (no publish): +# goreleaser release --snapshot --clean + +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - main: ./cmd + binary: dws + env: + - CGO_ENABLED=0 + flags: + - -buildmode=pie + - -trimpath + ldflags: + - -s -w + - -X github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app.version=v{{.Version}} + - -X github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app.gitCommit={{.ShortCommit}} + - -X github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app.buildTime={{.Date}} + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + +archives: + - formats: + - tar.gz + name_template: "dws-{{ .Os }}-{{ .Arch }}" + format_overrides: + - goos: windows + formats: + - zip + files: + - LICENSE + - NOTICE + - README.md + - CHANGELOG.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" + +release: + github: + owner: DingTalk-Real-AI + name: dingtalk-workspace-cli + draft: false + prerelease: auto + name_template: "v{{.Version}}" diff --git a/CHANGELOG.md b/CHANGELOG.md index de3f43ba..0ca1e097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file. The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/). +## [1.1.0] - 2026-03-28 + +Backward-compatible feature and security update after the initial 1.0.0 release. + +### Added + +- JSON output support for `dws auth login` and `dws auth status` +- Cross-platform keychain-backed secure storage and migration helpers +- Atomic file write helpers to avoid partial config and download writes +- Stronger path and input validation helpers for local file operations +- Install-script coverage for local-source installs + +### Changed + +- Improved `auth login` help text, hidden compatibility flags, and interactive UX +- Added root-level flag suggestions for common compatibility mistakes such as `--json` and legacy auth flags +- Updated AITable upload parsing to accept nested `content` payloads +- Refreshed bundled skills metadata for the new CLI version + ## [1.0.0] - 2026-03-27 First public release of DingTalk Workspace CLI. diff --git a/README.md b/README.md index 33691549..7c0a92ce 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@

Go 1.25+ License Apache-2.0 - v1.0.0 + v1.1.0

[中文版](./README.md) | [English](./README_en.md) diff --git a/README_en.md b/README_en.md index dd6a0501..818d3e89 100644 --- a/README_en.md +++ b/README_en.md @@ -15,7 +15,7 @@ Access contacts, calendar, todos, attendance, AI tables and more with zero boile

Go 1.25+ License Apache-2.0 - v1.0.0 + v1.1.0

[中文版](./README.md) | [English](./README_en.md) diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index bf6655e3..aa6f67a2 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -502,19 +502,3 @@ func writeAuthLoginJSON(w io.Writer, data *authpkg.TokenData, forced bool) error enc.SetIndent("", " ") return enc.Encode(resp) } - -// authLogoutResponse is the JSON response for auth logout command. -type authLogoutResponse struct { - Success bool `json:"success"` - Message string `json:"message"` -} - -func writeAuthLogoutJSON(w io.Writer) error { - resp := authLogoutResponse{ - Success: true, - Message: "已清除所有认证信息", - } - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(resp) -} diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go new file mode 100644 index 00000000..30ed2e42 --- /dev/null +++ b/internal/app/recovery_command.go @@ -0,0 +1,501 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "sort" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" + "github.com/spf13/cobra" +) + +func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *GlobalFlags) *cobra.Command { + var ( + planUseLast bool + planEventID string + executeUseLast bool + executeEventID string + finalEventID string + finalOutcome string + executionFile string + ) + + runtime := newRecoveryRuntime(loader, flags) + + cmd := &cobra.Command{ + Use: "recovery", + Short: "错误恢复辅助命令", + Long: "读取失败快照,生成恢复分析,并回写恢复结果。", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + planCmd := &cobra.Command{ + Use: "plan", + Short: "基于失败快照生成恢复计划", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + store := recovery.NewStore(defaultConfigDir()) + last, err := loadRecoverySnapshot(store, planUseLast, planEventID) + if err != nil { + return err + } + + planner := recovery.NewPlanner(runtime) + plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{ + EventID: last.EventID, + EnableDocSearch: true, + }) + recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan) + if err := store.SavePlan(last.EventID, plan); err != nil { + return fmt.Errorf("保存恢复计划失败: %w", err) + } + + payload := map[string]any{ + "event_id": last.EventID, + "context": last.Context, + "plan": plan, + } + return output.WriteCommandPayload(cmd, payload, output.FormatJSON) + }, + } + planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照") + planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照") + + executeCmd := &cobra.Command{ + Use: "execute", + Short: "生成面向 Agent 的恢复分析包", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + store := recovery.NewStore(defaultConfigDir()) + last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID) + if err != nil { + return err + } + + planner := recovery.NewPlanner(runtime) + executor := recovery.NewExecutor(planner, runtime) + bundle := executor.Execute(cmd.Context(), *last) + if err := store.SaveAnalysis(last.EventID, bundle.Plan, bundle); err != nil { + return fmt.Errorf("保存恢复分析失败: %w", err) + } + + return output.WriteCommandPayload(cmd, bundle, output.FormatJSON) + }, + } + executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照") + executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照") + + finalizeCmd := &cobra.Command{ + Use: "finalize", + Short: "回写恢复闭环结果", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(finalEventID) == "" { + return fmt.Errorf("必须提供 --event-id") + } + if strings.TrimSpace(finalOutcome) == "" { + return fmt.Errorf("必须提供 --outcome") + } + switch finalOutcome { + case "recovered", "failed", "handoff": + default: + return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff") + } + + store := recovery.NewStore(defaultConfigDir()) + var execution *recovery.RecoveryExecution + if strings.TrimSpace(executionFile) != "" { + loaded, err := loadRecoveryExecution(executionFile) + if err != nil { + return err + } + execution = &loaded + } + if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil { + return fmt.Errorf("回写恢复结果失败: %w", err) + } + + payload := map[string]any{ + "event_id": finalEventID, + "outcome": finalOutcome, + "success": true, + } + if execution != nil { + payload["execution_recorded"] = true + } + return output.WriteCommandPayload(cmd, payload, output.FormatJSON) + }, + } + finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID") + finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff") + finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件") + + cmd.AddCommand(planCmd, executeCmd, finalizeCmd) + return cmd +} + +func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) { + if useLast && strings.TrimSpace(eventID) != "" { + return nil, fmt.Errorf("--last 和 --event-id 不能同时使用") + } + switch { + case useLast: + last, err := store.LoadLastError() + if err != nil { + return nil, fmt.Errorf("读取失败快照失败: %w", err) + } + return last, nil + case strings.TrimSpace(eventID) != "": + last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID)) + if err != nil { + return nil, fmt.Errorf("读取失败快照失败: %w", err) + } + return last, nil + default: + return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照") + } +} + +func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) { + var execution recovery.RecoveryExecution + data, err := os.ReadFile(path) + if err != nil { + return execution, fmt.Errorf("读取恢复执行详情失败: %w", err) + } + var payload recoveryExecutionPayload + if err := json.Unmarshal(data, &payload); err != nil { + return execution, fmt.Errorf("解析恢复执行详情失败: %w", err) + } + execution.Actions = append([]string(nil), payload.Actions...) + if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" { + execution.Actions = []string{strings.TrimSpace(payload.Action)} + } + execution.Result = strings.TrimSpace(payload.Result) + execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary) + if execution.ErrorSummary == "" { + execution.ErrorSummary = strings.TrimSpace(payload.Error) + } + + attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary) + if err != nil { + return execution, fmt.Errorf("解析恢复执行详情失败: %w", err) + } + if len(attempts) == 0 && payload.Attempt > 0 { + attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary) + } + execution.Attempts = attempts + return execution, nil +} + +type recoveryExecutionPayload struct { + Action string `json:"action,omitempty"` + Actions []string `json:"actions,omitempty"` + Attempt int `json:"attempt,omitempty"` + Attempts json.RawMessage `json:"attempts,omitempty"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` + ErrorSummary string `json:"error_summary,omitempty"` +} + +func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return nil, nil + } + if strings.HasPrefix(trimmed, "[") { + var attempts []recovery.RecoveryAttempt + if err := json.Unmarshal(raw, &attempts); err != nil { + return nil, err + } + return attempts, nil + } + + var count int + if err := json.Unmarshal(raw, &count); err != nil { + return nil, err + } + return legacyRecoveryAttempts(count, actions, result, errorSummary), nil +} + +func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt { + if count <= 0 { + return nil + } + summary := strings.TrimSpace(strings.Join(actions, ", ")) + if summary == "" { + summary = "legacy execution attempt" + } + attempts := make([]recovery.RecoveryAttempt, 0, count) + for i := 0; i < count; i++ { + attempts = append(attempts, recovery.RecoveryAttempt{ + CommandSummary: summary, + Result: result, + ErrorSummary: errorSummary, + Source: "legacy_execution_file", + }) + } + return attempts +} + +type recoveryRuntime struct { + loader cli.CatalogLoader + transport *transport.Client + flags *GlobalFlags +} + +func newRecoveryRuntime(loader cli.CatalogLoader, flags *GlobalFlags) *recoveryRuntime { + var httpClient *http.Client + if flags != nil && flags.Timeout > 0 { + httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second} + } + client := transport.NewClient(httpClient) + client.ExtraHeaders = resolveIdentityHeaders() + return &recoveryRuntime{ + loader: loader, + transport: client, + flags: flags, + } +} + +func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) { + const ( + searchPage = 1 + searchSize = 5 + ) + requestArgs := map[string]any{ + "keyword": query, + "page": searchPage, + "size": searchSize, + } + + retrieval := recovery.KnowledgeRetrieval{ + DocSearch: recovery.DocSearch{ + Provider: "open_platform_docs", + Query: query, + Page: searchPage, + Size: searchSize, + Status: "empty", + Request: &recovery.ToolCallRecord{ + ServerID: "devdoc", + ToolName: "search_open_platform_docs", + Arguments: cloneRecoveryArgs(requestArgs), + }, + }, + } + if r == nil || strings.TrimSpace(query) == "" { + retrieval.DocSearch.Status = "skipped" + return retrieval, nil + } + result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs", requestArgs) + if result != nil { + retrieval.DocSearch.Response = toRecoveryToolResponse(result) + } + if err != nil { + retrieval.DocSearch.Status = "error" + retrieval.DocSearch.Error = err.Error() + return retrieval, err + } + + retrieval.DocSearch.Items = parseDocSearchItems(result) + if len(retrieval.DocSearch.Items) > 0 { + retrieval.DocSearch.Status = "success" + retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items) + } + return retrieval, nil +} + +func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) { + if r == nil || r.transport == nil { + return nil, fmt.Errorf("recovery runtime not initialized") + } + endpoint, err := r.resolveEndpoint(ctx, serverID) + if err != nil { + return nil, err + } + tc := r.transport.WithAuth(resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)), resolveIdentityHeaders()) + result, err := tc.CallTool(ctx, endpoint, toolName, args) + if err != nil { + return nil, err + } + if result.IsError { + return &result, apperrors.NewAPI( + extractMCPErrorMessage(result), + apperrors.WithOperation("tools/call"), + apperrors.WithReason("mcp_tool_error"), + apperrors.WithServerKey(serverID), + ) + } + return &result, nil +} + +func (r *recoveryRuntime) resolveEndpoint(ctx context.Context, productID string) (string, error) { + if endpoint, ok := directRuntimeEndpoint(productID); ok { + return endpoint, nil + } + if r == nil || r.loader == nil { + return "", fmt.Errorf("未找到服务 %s 的 endpoint", productID) + } + catalog, err := r.loader.Load(ctx) + if err != nil { + return "", err + } + product, ok := catalog.FindProduct(productID) + if !ok || strings.TrimSpace(product.Endpoint) == "" { + return "", fmt.Errorf("未找到服务 %s 的 endpoint", productID) + } + return strings.TrimSpace(product.Endpoint), nil +} + +func recoveryRuntimeToken(flags *GlobalFlags) string { + if flags == nil { + return "" + } + return strings.TrimSpace(flags.Token) +} + +func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse { + if result == nil { + return nil + } + response := &recovery.ToolResponse{IsError: result.IsError} + if len(result.Blocks) > 0 { + response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks)) + for _, block := range result.Blocks { + response.Content = append(response.Content, recovery.ToolResponseBlock{ + Type: block.Type, + Text: block.Text, + }) + } + } + return response +} + +func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem { + if result == nil { + return nil + } + if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 { + return items + } + for _, block := range result.Blocks { + var payload map[string]any + if err := json.Unmarshal([]byte(block.Text), &payload); err == nil { + if items := parseDocSearchItemsFromMap(payload); len(items) > 0 { + return items + } + } + } + return nil +} + +func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem { + if len(payload) == 0 { + return nil + } + if items := toDocSearchItems(payload["items"]); len(items) > 0 { + return items + } + if data, ok := payload["data"].(map[string]any); ok { + if items := toDocSearchItems(data["items"]); len(items) > 0 { + return items + } + } + if result, ok := payload["result"].(map[string]any); ok { + if items := toDocSearchItems(result["items"]); len(items) > 0 { + return items + } + } + return nil +} + +func toDocSearchItems(raw any) []recovery.DocSearchItem { + list, ok := raw.([]any) + if !ok { + return nil + } + items := make([]recovery.DocSearchItem, 0, len(list)) + for _, entry := range list { + object, ok := entry.(map[string]any) + if !ok { + continue + } + item := recovery.DocSearchItem{} + if title, ok := object["title"].(string); ok { + item.Title = title + } + if url, ok := object["url"].(string); ok { + item.URL = url + } + if desc, ok := object["desc"].(string); ok { + item.Desc = desc + } + if item.Title != "" || item.URL != "" || item.Desc != "" { + items = append(items, item) + } + } + return items +} + +func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit { + if len(items) == 0 { + return nil + } + keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query))) + type scoredHit struct { + hit recovery.KBHit + score float64 + } + scored := make([]scoredHit, 0, len(items)) + for _, item := range items { + text := strings.ToLower(strings.Join(append([]string{ + item.Title, + item.URL, + item.Desc, + rc.ToolName, + }, rc.CommandPath...), " ")) + score := 0.0 + for _, keyword := range keywords { + if strings.Contains(text, keyword) { + score += 1 + } + } + scored = append(scored, scoredHit{ + hit: recovery.KBHit{ + Source: "open_platform_docs", + Title: item.Title, + URL: item.URL, + Snippet: item.Desc, + Score: score, + }, + score: score, + }) + } + sort.SliceStable(scored, func(i, j int) bool { + return scored[i].score > scored[j].score + }) + limit := len(scored) + if limit > 3 { + limit = 3 + } + hits := make([]recovery.KBHit, 0, limit) + for _, item := range scored[:limit] { + hits = append(hits, item.hit) + } + return hits +} diff --git a/internal/app/recovery_command_test.go b/internal/app/recovery_command_test.go new file mode 100644 index 00000000..32fa6cf7 --- /dev/null +++ b/internal/app/recovery_command_test.go @@ -0,0 +1,324 @@ +package app + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery" +) + +func TestRecoveryPlanReadsLastSnapshotAndPrintsJSON(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + writeRecoverySnapshot(t, configDir, recovery.LastError{ + EventID: "evt_test", + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Context: recovery.RecoveryContext{ + CommandPath: []string{"approval", "instance", "get"}, + ServerID: "approval", + ToolName: "get_approval_instance", + OperationKind: recovery.OperationRead, + CLIErrorCode: "RESOURCE_NOT_FOUND", + RawError: "resource_not_found", + Fingerprint: "fp-1", + }, + Replay: recovery.Replay{ + ServerID: "approval", + ToolName: "get_approval_instance", + OperationKind: recovery.OperationRead, + ToolArgs: map[string]any{"instanceId": "ins_1"}, + RedactedCommand: "dws approval instance get --instance-id ins_1 --format json", + }, + }) + + root := NewRootCommand() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"recovery", "plan", "--last", "-f", "json"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute(recovery plan) error = %v", err) + } + if !strings.Contains(out.String(), `"event_id": "evt_test"`) { + t.Fatalf("output missing event id:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"category": "resource"`) { + t.Fatalf("output missing resource category:\n%s", out.String()) + } +} + +func TestRecoveryExecuteReadsLastSnapshotAndPrintsJSON(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + writeRecoverySnapshot(t, configDir, recovery.LastError{ + EventID: "evt_exec", + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Context: recovery.RecoveryContext{ + CommandPath: []string{"approval", "instance", "get"}, + ServerID: "approval", + ToolName: "get_approval_instance", + OperationKind: recovery.OperationRead, + CLIErrorCode: "RESOURCE_NOT_FOUND", + RawError: "resource_not_found", + Fingerprint: "fp-2", + }, + Replay: recovery.Replay{ + ServerID: "approval", + ToolName: "get_approval_instance", + OperationKind: recovery.OperationRead, + ToolArgs: map[string]any{"instanceId": "ins_1"}, + RedactedCommand: "dws approval instance get --instance-id ins_1 --format json", + }, + }) + + root := NewRootCommand() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"recovery", "execute", "--last", "-f", "json"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute(recovery execute) error = %v", err) + } + if !strings.Contains(out.String(), `"event_id": "evt_exec"`) { + t.Fatalf("output missing event id:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"status": "needs_agent_action"`) { + t.Fatalf("output missing bundle status:\n%s", out.String()) + } +} + +func TestRecoveryFinalizeRequiresEventIDAndOutcome(t *testing.T) { + root := NewRootCommand() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"recovery", "finalize"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute(recovery finalize) error = nil, want validation") + } + if !strings.Contains(err.Error(), "--event-id") { + t.Fatalf("error = %v, want event-id requirement", err) + } +} + +func TestRecoveryPlanRejectsLastAndEventIDTogether(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + writeRecoverySnapshot(t, configDir, recovery.LastError{ + EventID: "evt_conflict", + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Context: recovery.RecoveryContext{ + CommandPath: []string{"approval", "instance", "get"}, + ServerID: "approval", + ToolName: "get_approval_instance", + OperationKind: recovery.OperationRead, + CLIErrorCode: "RESOURCE_NOT_FOUND", + RawError: "resource_not_found", + Fingerprint: "fp-conflict", + }, + }) + + root := NewRootCommand() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"recovery", "plan", "--last", "--event-id", "evt_conflict"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute(recovery plan) error = nil, want conflict validation") + } + if !strings.Contains(err.Error(), "--last") || !strings.Contains(err.Error(), "--event-id") { + t.Fatalf("error = %v, want mutually exclusive flags", err) + } +} + +func TestRecoveryFinalizeAcceptsLegacyExecutionFile(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + writeRecoverySnapshot(t, configDir, recovery.LastError{ + EventID: "evt_legacy_finalize", + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Context: recovery.RecoveryContext{ + CommandPath: []string{"approval", "instance", "get"}, + ServerID: "approval", + ToolName: "get_approval_instance", + OperationKind: recovery.OperationUnknown, + RawError: "unexpected upstream failure", + Fingerprint: "fp-legacy-finalize", + }, + }) + + executionPath := filepath.Join(configDir, "legacy_execution.json") + if err := os.WriteFile(executionPath, []byte(`{"action":"verify_resource_exists","attempts":2,"result":"failed","error":"resource still missing"}`), 0o600); err != nil { + t.Fatalf("WriteFile(legacy execution) error = %v", err) + } + + root := NewRootCommand() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{ + "recovery", "finalize", + "--event-id", "evt_legacy_finalize", + "--outcome", "failed", + "--execution-file", executionPath, + "-f", "json", + }) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute(recovery finalize) error = %v", err) + } + if !strings.Contains(out.String(), `"execution_recorded": true`) { + t.Fatalf("output missing execution_recorded flag:\n%s", out.String()) + } + + data, err := os.ReadFile(filepath.Join(configDir, "recovery", "recovery_events.jsonl")) + if err != nil { + t.Fatalf("ReadFile(recovery_events.jsonl) error = %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + lastLine := lines[len(lines)-1] + if !strings.Contains(lastLine, `"phase":"finalized"`) { + t.Fatalf("expected finalized event, got %s", lastLine) + } + if !strings.Contains(lastLine, `"legacy_execution_file"`) { + t.Fatalf("expected legacy execution attempts to be normalized, got %s", lastLine) + } +} + +func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) { + setupRuntimeCommandTest(t) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1") + t.Setenv("DWS_TRUSTED_DOMAINS", "*") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + switch req["method"] { + case "initialize": + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": req["id"], + "result": map[string]any{ + "protocolVersion": "2025-03-26", + "capabilities": map[string]any{"tools": map[string]any{"listChanged": false}}, + "serverInfo": map[string]any{"name": "doc", "version": "1.0.0"}, + }, + }) + case "notifications/initialized": + w.WriteHeader(http.StatusNoContent) + case "tools/list": + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": req["id"], + "result": map[string]any{ + "tools": []map[string]any{ + { + "name": "search_documents", + "title": "Search", + "description": "Search documents", + "inputSchema": map[string]any{"type": "object"}, + }, + }, + }, + }) + case "tools/call": + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": req["id"], + "result": map[string]any{ + "content": []map[string]any{ + { + "type": "text", + "text": "baseId is required", + }, + }, + "isError": true, + }, + }) + } + })) + defer server.Close() + + t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.URL, false)) + + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`} + + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe(stdout) error = %v", err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe(stderr) error = %v", err) + } + oldStdout := os.Stdout + oldStderr := os.Stderr + defer func() { + os.Stdout = oldStdout + os.Stderr = oldStderr + }() + os.Stdout = stdoutW + os.Stderr = stderrW + + exitCode := Execute() + + _ = stdoutW.Close() + _ = stderrW.Close() + stdoutData, _ := io.ReadAll(stdoutR) + stderrData, _ := io.ReadAll(stderrR) + + if exitCode == 0 { + t.Fatalf("Execute() exitCode = 0, want failure\nstdout:\n%s\nstderr:\n%s", stdoutData, stderrData) + } + if !strings.Contains(string(stderrData), "RECOVERY_EVENT_ID=evt_") { + t.Fatalf("stderr missing recovery event id:\n%s", stderrData) + } + + data, err := os.ReadFile(filepath.Join(configDir, "recovery", "last_error.json")) + if err != nil { + t.Fatalf("ReadFile(last_error.json) error = %v", err) + } + var last recovery.LastError + if err := json.Unmarshal(data, &last); err != nil { + t.Fatalf("json.Unmarshal(last_error) error = %v", err) + } + if last.EventID == "" || last.Context.ToolName != "search_documents" { + t.Fatalf("unexpected recovery snapshot %#v", last) + } +} + +func writeRecoverySnapshot(t *testing.T, configDir string, last recovery.LastError) { + t.Helper() + + recoveryDir := filepath.Join(configDir, "recovery") + if err := os.MkdirAll(recoveryDir, 0o700); err != nil { + t.Fatalf("MkdirAll(recovery) error = %v", err) + } + data, err := json.MarshalIndent(last, "", " ") + if err != nil { + t.Fatalf("json.MarshalIndent() error = %v", err) + } + if err := os.WriteFile(filepath.Join(recoveryDir, "last_error.json"), append(data, '\n'), 0o600); err != nil { + t.Fatalf("WriteFile(last_error.json) error = %v", err) + } +} diff --git a/internal/app/recovery_runtime.go b/internal/app/recovery_runtime.go new file mode 100644 index 00000000..6d824824 --- /dev/null +++ b/internal/app/recovery_runtime.go @@ -0,0 +1,94 @@ +package app + +import ( + "os" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery" +) + +func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) { + if rawErr == nil && wrappedErr == nil { + return + } + store := recovery.NewStore(defaultConfigDir()) + if store == nil || !store.Enabled() { + return + } + input := recovery.CaptureInput{ + CommandPath: runtimeCommandPath(invocation), + ServerID: strings.TrimSpace(invocation.CanonicalProduct), + ToolName: strings.TrimSpace(invocation.Tool), + Args: cloneRecoveryArgs(invocation.Params), + Argv: append([]string(nil), os.Args[1:]...), + RawErr: rawErr, + WrappedErr: wrappedErr, + } + _, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input)) +} + +func runtimeCommandPath(invocation executor.Invocation) []string { + if path := currentCommandPath(); len(path) > 0 { + return path + } + if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 { + return legacy + } + if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" { + if tool := strings.TrimSpace(invocation.Tool); tool != "" { + return []string{product, tool} + } + return []string{product} + } + return nil +} + +func currentCommandPath() []string { + boolFlags := map[string]struct{}{ + "--verbose": {}, + "-v": {}, + "--debug": {}, + "--mock": {}, + "--dry-run": {}, + "--yes": {}, + "-y": {}, + "--help": {}, + "-h": {}, + "--json": {}, + } + path := make([]string, 0, len(os.Args)) + skipNext := false + for _, arg := range os.Args[1:] { + if skipNext { + skipNext = false + continue + } + if arg == "--" { + break + } + if strings.HasPrefix(arg, "-") { + if strings.Contains(arg, "=") { + continue + } + if _, ok := boolFlags[arg]; ok { + continue + } + skipNext = true + continue + } + path = append(path, arg) + } + return path +} + +func cloneRecoveryArgs(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 { + out[key] = value + } + return out +} diff --git a/internal/app/root.go b/internal/app/root.go index e337ed4c..ba55f164 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -36,6 +36,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -43,11 +44,14 @@ import ( type outputFileContextKey struct{} +const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID=" + // Execute runs the root command and returns the process exit code. func Execute() int { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() + recovery.ResetRuntimeState() root := NewRootCommand(ctx) executed, err := root.ExecuteC() if err != nil { @@ -60,6 +64,9 @@ func Execute() int { _, _ = fmt.Fprintln(os.Stderr) } _ = printExecutionError(executed, os.Stdout, os.Stderr, err) + if last := recovery.LatestCapture(); last != nil && last.EventID != "" { + _, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID) + } return apperrors.ExitCode(err) } return 0 @@ -210,6 +217,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command { newAuthCommand(), newCacheCommand(), newCompletionCommand(root), + newRecoveryCommand(rootCtx, loader, flags), newVersionCommand(), schemaCmd, genSkillsCmd, diff --git a/internal/app/runner.go b/internal/app/runner.go index 5c82b5fd..130c9dd5 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -149,6 +149,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params) if err != nil { + captureRuntimeFailure(invocation, err, err) return executor.Result{}, err } @@ -160,6 +161,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, apperrors.WithServerKey(invocation.CanonicalProduct), apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."), ) + captureRuntimeFailure(invocation, mcpErr, mcpErr) return executor.Result{}, mcpErr } diff --git a/internal/app/version.go b/internal/app/version.go index e3f15463..43317dce 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -13,7 +13,7 @@ package app -var version = "v1.0.0" +var version = "v1.1.0" // Version returns the current CLI version string, including build metadata // when injected via ldflags (buildTime, gitCommit). diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 6bf1a60f..ca33aa97 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -41,7 +41,7 @@ type frontmatterSpec struct { } const ( - skillVersion = "1.0.0" + skillVersion = "1.1.0" skillCategoryService = "service" skillCategoryHelper = "helper" skillCategoryPersona = "persona" diff --git a/internal/recovery/executor.go b/internal/recovery/executor.go new file mode 100644 index 00000000..b364d208 --- /dev/null +++ b/internal/recovery/executor.go @@ -0,0 +1,338 @@ +package recovery + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" +) + +type ToolInvoker interface { + CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) +} + +type Probe func(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult + +type Executor struct { + planner *Planner + invoker ToolInvoker + probes []Probe +} + +func NewExecutor(planner *Planner, invoker ToolInvoker) *Executor { + return &Executor{ + planner: planner, + invoker: invoker, + probes: defaultProbes(), + } +} + +func (e *Executor) Execute(ctx context.Context, last LastError) RecoveryBundle { + bundle := RecoveryBundle{ + Status: "needs_agent_action", + EventID: last.EventID, + Context: cloneRecoveryContext(last.Context), + Replay: cloneReplay(last.Replay), + } + if e == nil || e.planner == nil { + bundle.Status = "analysis_failed" + bundle.AnalysisError = "recovery executor unavailable" + return bundle + } + + plan := e.planner.PlanWithOptions(ctx, last.Context, PlanOptions{ + EventID: last.EventID, + EnableDocSearch: true, + }) + HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan) + + bundle.Plan = plan + bundle.DocSearch = cloneDocSearch(plan.DocSearch) + bundle.KBHits = cloneKBHits(plan.KBHits) + bundle.DocActions = cloneDocActions(plan.DocActions) + bundle.HumanActions = nonNilStrings(plan.HumanActions) + bundle.ProbeResults = e.runProbes(ctx, last.Context, last.Replay, plan) + if bundle.Plan.AgentRoute.Payload != nil { + bundle.Plan.AgentRoute.Payload.ProbeResults = cloneProbeResults(bundle.ProbeResults) + } + bundle.AgentTask = buildAgentTask(last.EventID, last.Context, last.Replay, plan, bundle.ProbeResults) + bundle.FinalizeHint = buildFinalizeHint(last.EventID) + + analysisErrors := make([]string, 0, 3) + if isEmptyReplay(last.Replay) { + analysisErrors = append(analysisErrors, "replay data missing") + } + if plan.DocSearch.Status == "error" { + analysisErrors = append(analysisErrors, "doc search failed") + } + for _, probe := range bundle.ProbeResults { + if probe.Status == "error" { + analysisErrors = append(analysisErrors, fmt.Sprintf("probe %s failed", probe.Name)) + } + } + if len(analysisErrors) > 0 { + bundle.Status = "analysis_failed" + bundle.AnalysisError = strings.Join(uniqueStrings(analysisErrors), "; ") + } + return bundle +} + +func (e *Executor) runProbes(ctx context.Context, rc RecoveryContext, replay Replay, plan RecoveryPlan) []ProbeResult { + if len(e.probes) == 0 { + return nil + } + results := make([]ProbeResult, 0, len(e.probes)) + for _, probe := range e.probes { + result := probe(ctx, e.invoker, rc, replay, plan) + if result == nil { + continue + } + results = append(results, *result) + } + return results +} + +func defaultProbes() []Probe { + return []Probe{probeUnknownContextAudit, probeAITableBaseCatalog} +} + +func probeUnknownContextAudit(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult { + if normalizeDecisionOwner(plan) != DecisionOwnerAgent { + return nil + } + + sourcesChecked := []string{ + "recovery_context.command_path", + "recovery_context.args_summary", + "replay.tool_args", + "replay.redacted_command", + "plan.doc_search", + "plan.kb_hits", + "plan.doc_actions", + } + availableSources := make([]string, 0, len(sourcesChecked)) + missingSources := make([]string, 0, len(sourcesChecked)) + candidateIdentifiers := make([]map[string]any, 0) + + appendCandidates := func(source string, values map[string]any) { + for key, value := range values { + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" { + continue + } + lowerKey := strings.ToLower(key) + if !strings.Contains(lowerKey, "id") && !strings.Contains(lowerKey, "uuid") { + continue + } + candidateIdentifiers = append(candidateIdentifiers, map[string]any{ + "source": source, + "field": key, + "value": text, + }) + } + } + + appendCandidates("context.args_summary", rc.ArgsSummary) + appendCandidates("replay.tool_args", replay.ToolArgs) + + if len(rc.CommandPath) > 0 { + availableSources = append(availableSources, "recovery_context.command_path") + } else { + missingSources = append(missingSources, "recovery_context.command_path") + } + if len(rc.ArgsSummary) > 0 { + availableSources = append(availableSources, "recovery_context.args_summary") + } else { + missingSources = append(missingSources, "recovery_context.args_summary") + } + if len(replay.ToolArgs) > 0 { + availableSources = append(availableSources, "replay.tool_args") + } else { + missingSources = append(missingSources, "replay.tool_args") + } + if strings.TrimSpace(replay.RedactedCommand) != "" { + availableSources = append(availableSources, "replay.redacted_command") + } else { + missingSources = append(missingSources, "replay.redacted_command") + } + if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" { + availableSources = append(availableSources, "plan.doc_search") + } else { + missingSources = append(missingSources, "plan.doc_search") + } + if len(plan.KBHits) > 0 { + availableSources = append(availableSources, "plan.kb_hits") + } else { + missingSources = append(missingSources, "plan.kb_hits") + } + if len(plan.DocActions) > 0 { + availableSources = append(availableSources, "plan.doc_actions") + } else { + missingSources = append(missingSources, "plan.doc_actions") + } + + summary := fmt.Sprintf("audited %d local recovery source(s)", len(sourcesChecked)) + if len(candidateIdentifiers) == 0 { + summary += "; no identifier candidates found" + } else { + summary += fmt.Sprintf("; found %d identifier candidate(s)", len(candidateIdentifiers)) + } + + return &ProbeResult{ + Name: "unknown_context_audit", + Status: "success", + Summary: summary, + Output: map[string]any{ + "sources_checked": sourcesChecked, + "available_sources": availableSources, + "missing_sources": missingSources, + "candidate_identifiers": candidateIdentifiers, + "decision_owner": string(normalizeDecisionOwner(plan)), + }, + } +} + +func probeAITableBaseCatalog(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult { + if len(rc.CommandPath) < 2 || rc.CommandPath[0] != "aitable" || rc.CommandPath[1] != "base" { + return nil + } + if len(rc.CommandPath) < 3 || rc.CommandPath[2] != "get" { + return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "no registered read-only probe for this aitable command"} + } + if invoker == nil { + return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "tool invoker unavailable"} + } + result, err := invoker.CallToolDirect(ctx, replay.ServerID, "list_bases", map[string]any{"limit": 5}) + if err != nil { + return &ProbeResult{ + Name: "aitable_base_catalog_probe", + Status: "error", + ServerID: replay.ServerID, + ToolName: "list_bases", + Error: err.Error(), + Summary: "failed to run read-only aitable base catalog probe", + } + } + payload, summary := summarizeProbeOutput(result) + return &ProbeResult{ + Name: "aitable_base_catalog_probe", + Status: "success", + ServerID: replay.ServerID, + ToolName: "list_bases", + ArgsSummary: SummarizeArgs(map[string]any{"limit": 5}), + Summary: summary, + Output: payload, + } +} + +func summarizeProbeOutput(result *transport.ToolCallResult) (any, string) { + if result == nil { + return nil, "probe returned no output" + } + for _, block := range result.Blocks { + if block.Type != "text" || strings.TrimSpace(block.Text) == "" { + continue + } + var parsed any + if err := json.Unmarshal([]byte(block.Text), &parsed); err == nil { + return parsed, "probe returned JSON payload" + } + return block.Text, "probe returned text payload" + } + if len(result.Content) > 0 { + return result.Content, "probe returned content payload" + } + return result, "probe returned non-text payload" +} + +func buildAgentTask(eventID string, rc RecoveryContext, replay Replay, plan RecoveryPlan, probes []ProbeResult) AgentTask { + whyParts := []string{fmt.Sprintf("recovery category=%s", plan.Category)} + if len(probes) > 0 { + whyParts = append(whyParts, fmt.Sprintf("probe results=%d", len(probes))) + } + if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" { + whyParts = append(whyParts, "doc search results included") + } + if replay.RedactedCommand != "" { + whyParts = append(whyParts, "failed command="+replay.RedactedCommand) + } + + return AgentTask{ + Goal: "阅读完整 RecoveryBundle,并基于 dws skill 对 unknown 恢复场景做整体分析后再决定是否发起 grounded 的下一次 dws 尝试", + Why: strings.Join(whyParts, "; "), + MustReadRefs: buildMustReadRefs(rc.CommandPath), + AllowedActions: []string{ + "读取完整 recovery bundle、recovery-guide 和对应产品参考文档", + "基于 doc_actions、kb_hits、probe_results 与 replay/context 整体判断下一步", + "仅在修复依据明确时重新发起新的 dws 命令", + }, + ForbiddenActions: []string{ + "编造 ID、UUID、token、URL 或其他业务参数", + "绕过 dws 直接调用 HTTP API、curl 或浏览器", + "未确认前把失败命令替换成另一套业务流程", + }, + StopConditions: []string{ + "新的 dws 命令已经成功并可回写 recovered", + "没有 grounded 修复依据,只能回写 handoff", + "继续尝试会要求猜测或扩大副作用边界", + }, + FinalizeRequirement: buildFinalizeHint(eventID).Command, + } +} + +func buildFinalizeHint(eventID string) FinalizeHint { + return FinalizeHint{ + Required: true, + Command: fmt.Sprintf( + "dws recovery finalize --event-id %s --outcome --execution-file --format json", + eventID, + ), + ExecutionFileFields: []string{"actions", "attempts", "result", "error_summary"}, + AllowedOutcomes: []string{"recovered", "failed", "handoff"}, + } +} + +func buildMustReadRefs(commandPath []string) []string { + refs := []string{ + "skills/references/recovery-guide.md", + "skills/references/error-codes.md", + "skills/references/global-reference.md", + } + if len(commandPath) > 0 { + if ref := productReferenceFor(commandPath[0]); ref != "" { + refs = append(refs, ref) + } + } + return refs +} + +func productReferenceFor(commandRoot string) string { + refs := map[string]string{ + "aitable": "skills/references/products/aitable.md", + "attendance": "skills/references/products/attendance.md", + "calendar": "skills/references/products/calendar.md", + "chat": "skills/references/products/chat.md", + "contact": "skills/references/products/contact.md", + "devdoc": "skills/references/products/simple.md", + "ding": "skills/references/products/ding.md", + "report": "skills/references/products/report.md", + "todo": "skills/references/products/todo.md", + "workbench": "skills/references/products/workbench.md", + "approval": "skills/references/products/simple.md", + } + return refs[commandRoot] +} + +func cloneProbeResults(results []ProbeResult) []ProbeResult { + if len(results) == 0 { + return nil + } + out := make([]ProbeResult, len(results)) + for i, result := range results { + out[i] = result + out[i].ArgsSummary = cloneMap(result.ArgsSummary) + } + return out +} diff --git a/internal/recovery/models.go b/internal/recovery/models.go new file mode 100644 index 00000000..d8d86d38 --- /dev/null +++ b/internal/recovery/models.go @@ -0,0 +1,679 @@ +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, "") + 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 + "", 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, ""}, + {reUUID, ""}, + {reTimestamp, ""}, + {reResourceID, ""}, + {reLongDigits, ""}, + {reLongToken, ""}, + } + 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 +} diff --git a/internal/recovery/planner.go b/internal/recovery/planner.go new file mode 100644 index 00000000..666343ce --- /dev/null +++ b/internal/recovery/planner.go @@ -0,0 +1,537 @@ +package recovery + +import ( + "context" + "fmt" + "strings" +) + +type KnowledgeRetriever interface { + Search(ctx context.Context, query string, rc RecoveryContext) (KnowledgeRetrieval, error) +} + +type PlanOptions struct { + EventID string + EnableDocSearch bool +} + +type Planner struct { + retriever KnowledgeRetriever +} + +func NewPlanner(retriever KnowledgeRetriever) *Planner { + return &Planner{retriever: retriever} +} + +func (p *Planner) Plan(ctx context.Context, rc RecoveryContext) RecoveryPlan { + return p.PlanWithOptions(ctx, rc, PlanOptions{EnableDocSearch: true}) +} + +func (p *Planner) PlanWithOptions(ctx context.Context, rc RecoveryContext, opts PlanOptions) RecoveryPlan { + plan, exact := planExact(rc) + if !exact { + plan = RecoveryPlan{ + Category: "unknown", + DecisionOwner: DecisionOwnerAgent, + Confidence: 0.35, + Evidence: []string{"未命中内置高频恢复规则"}, + } + } + + routeReasons := buildAgentRouteReasons(plan) + if opts.EnableDocSearch && len(routeReasons) > 0 { + query := BuildFallbackQuery(rc) + retrieval := p.searchKnowledge(ctx, query, rc) + plan.DocSearch = retrieval.DocSearch + if query != "" { + plan.Evidence = append(plan.Evidence, fmt.Sprintf("fallback_query=%s", query)) + } + switch plan.DocSearch.Status { + case "success": + plan.Evidence = append(plan.Evidence, "命中开放平台文档检索结果") + case "empty": + plan.Evidence = append(plan.Evidence, "开放平台文档检索无结果") + case "error": + if strings.TrimSpace(plan.DocSearch.Error) != "" { + plan.Evidence = append(plan.Evidence, fmt.Sprintf("开放平台文档检索失败=%s", plan.DocSearch.Error)) + } else { + plan.Evidence = append(plan.Evidence, "开放平台文档检索失败") + } + } + if len(retrieval.KBHits) > 0 { + plan.KBHits = append(plan.KBHits, retrieval.KBHits...) + if plan.Category == "unknown" { + plan.Confidence = 0.55 + } + } + } else { + plan.DocSearch = DocSearch{Status: "skipped"} + } + + plan.KBHits = dedupeKBHits(plan.KBHits) + plan.DocActions = dedupeDocActions(extractDocActions(plan.KBHits)) + plan.SafeActions = nonNilStrings(normalizeSafeActions(plan.AutoActions)) + plan.AutoActions = nonNilStrings(plan.SafeActions) + plan.DecisionOwner = normalizeDecisionOwner(plan) + plan.DecisionHints = buildDecisionHints(plan, rc) + plan.HumanActions = nonNilStrings(uniqueStrings(plan.HumanActions)) + plan.Evidence = nonNilStrings(uniqueStrings(plan.Evidence)) + plan.RuleHints = buildRuleHints(plan) + plan.AgentRoute = buildAgentRoute(opts.EventID, rc, Replay{}, plan, routeReasons) + return plan +} + +func (p *Planner) searchKnowledge(ctx context.Context, query string, rc RecoveryContext) KnowledgeRetrieval { + retrieval := KnowledgeRetrieval{DocSearch: DocSearch{Query: query, Status: "skipped"}} + if query == "" || p == nil || p.retriever == nil { + return retrieval + } + result, err := p.retriever.Search(ctx, query, rc) + if result.DocSearch.Query == "" { + result.DocSearch.Query = query + } + if err != nil { + if result.DocSearch.Status == "" { + result.DocSearch.Status = "error" + } + if strings.TrimSpace(result.DocSearch.Error) == "" { + result.DocSearch.Error = err.Error() + } + return result + } + if result.DocSearch.Status == "" { + if len(result.DocSearch.Items) > 0 { + result.DocSearch.Status = "success" + } else { + result.DocSearch.Status = "empty" + } + } + return result +} + +func dedupeKBHits(hits []KBHit) []KBHit { + if len(hits) <= 1 { + return hits + } + seen := make(map[string]struct{}, len(hits)) + out := make([]KBHit, 0, len(hits)) + for _, hit := range hits { + key := strings.TrimSpace(hit.Source) + "|" + strings.TrimSpace(hit.Title) + "|" + strings.TrimSpace(hit.URL) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, hit) + } + return out +} + +func planExact(rc RecoveryContext) (RecoveryPlan, bool) { + evidence := []string{} + if rc.CLIErrorCode != "" { + evidence = append(evidence, "cli_error_code="+rc.CLIErrorCode) + } + if rc.HTTPStatus != 0 { + evidence = append(evidence, fmt.Sprintf("http_status=%d", rc.HTTPStatus)) + } + if rc.CallStage != "" { + evidence = append(evidence, "call_stage="+rc.CallStage) + } + + switch { + case rc.CLIErrorCode == cliAuthExpiredCode || strings.Contains(strings.ToLower(rc.RawError), "token验证失败") || strings.Contains(strings.ToLower(rc.RawError), "user_token_illegal"): + shouldRetry := safeToRetry(rc, "auth") + plan := RecoveryPlan{ + Category: "auth", + DecisionOwner: DecisionOwnerBuiltinRule, + Confidence: 0.98, + HumanActions: []string{"如果重复执行两次仍失败,再运行 dws auth login 重新登录"}, + Evidence: append(evidence, "命中认证失效规则"), + KBHits: []KBHit{{Source: "builtin", Title: "认证失效恢复规则", Score: 1}}, + ShouldRetry: shouldRetry, + ShouldStop: !shouldRetry, + } + if shouldRetry { + plan.AutoActions = []string{"rerun_original_command"} + } + return plan, true + case rc.CLIErrorCode == cliInvalidJSONCode || rc.CLIErrorCode == cliMissingParamCode || strings.Contains(strings.ToLower(rc.RawError), "json 解析失败"): + return RecoveryPlan{ + Category: "input", + DecisionOwner: DecisionOwnerBuiltinRule, + Confidence: 0.96, + HumanActions: []string{"检查 JSON 结构、必填参数和字段名后再重试原命令"}, + Evidence: append(evidence, "命中输入参数规则"), + KBHits: []KBHit{{Source: "builtin", Title: "输入参数修正规则", Score: 1}}, + ShouldStop: true, + }, true + case strings.HasSuffix(rc.CLIErrorCode, "_NOT_FOUND") || rc.CLIErrorCode == cliResourceNotFoundCode || containsAny(rc.RawError, "not found", "资源不存在", "不存在", "does not exist", "has been deleted", "deleted", "base_not_found", "document.notfound", "resource.notfound", "specified base does not exist"): + return RecoveryPlan{ + Category: "resource", + DecisionOwner: DecisionOwnerBuiltinRule, + Confidence: 0.92, + HumanActions: []string{"先确认资源是否仍存在、ID 是否正确,再决定是否重试原命令"}, + Evidence: append(evidence, "命中资源不存在规则"), + KBHits: []KBHit{{Source: "builtin", Title: "资源不存在恢复规则", Score: 1}}, + ShouldStop: true, + }, true + case rc.CLIErrorCode == cliPermissionCode || rc.HTTPStatus == 403 || containsAny(rc.RawError, "forbidden", "permission", "权限不足", "无权限", "operationillegal"): + return RecoveryPlan{ + Category: "permission", + DecisionOwner: DecisionOwnerBuiltinRule, + Confidence: 0.95, + HumanActions: []string{"确认当前账号对目标资源有访问权限,必要时申请授权后再重试"}, + Evidence: append(evidence, "命中权限规则"), + KBHits: []KBHit{{Source: "builtin", Title: "权限不足恢复规则", Score: 1}}, + ShouldStop: true, + }, true + case rc.CLIErrorCode == cliRateLimitCode || rc.HTTPStatus == 429 || containsAny(rc.RawError, "too many requests", "rate limit"): + shouldRetry := safeToRetry(rc, "rate_limit") + plan := RecoveryPlan{ + Category: "rate_limit", + DecisionOwner: DecisionOwnerBuiltinRule, + Confidence: 0.94, + HumanActions: []string{"如仍失败,请降低调用频率后再重试"}, + Evidence: append(evidence, "命中限流规则"), + KBHits: []KBHit{{Source: "builtin", Title: "限流恢复规则", Score: 1}}, + ShouldRetry: shouldRetry, + ShouldStop: !shouldRetry, + } + if shouldRetry { + plan.AutoActions = []string{"wait_and_retry"} + } + return plan, true + case rc.CLIErrorCode == cliTimeoutCode || rc.HTTPStatus >= 500 || containsAny(rc.RawError, "timeout", "deadline exceeded", "connection refused", "connection reset", "broken pipe", "502", "503", "504"): + shouldRetry := safeToRetry(rc, "network") + plan := RecoveryPlan{ + Category: "network", + DecisionOwner: DecisionOwnerBuiltinRule, + Confidence: 0.9, + HumanActions: []string{"如果重试后仍失败,请检查网络或稍后再试"}, + Evidence: append(evidence, "命中网络/服务异常规则"), + KBHits: []KBHit{{Source: "builtin", Title: "网络异常恢复规则", Score: 1}}, + ShouldRetry: shouldRetry, + ShouldStop: !shouldRetry, + } + if shouldRetry { + plan.AutoActions = []string{"retry_original_command"} + } + return plan, true + } + + return RecoveryPlan{}, false +} + +func BuildFallbackQuery(rc RecoveryContext) string { + parts := make([]string, 0, 6) + if rc.CLIErrorCode != "" { + parts = append(parts, rc.CLIErrorCode) + } + if rc.ToolName != "" { + parts = append(parts, rc.ToolName) + } + if len(rc.CommandPath) > 0 { + parts = append(parts, strings.Join(rc.CommandPath, " ")) + } + parts = append(parts, stableKeywords(rc.RawError)...) + return strings.Join(uniqueStrings(parts), " ") +} + +func safeToRetry(rc RecoveryContext, category string) bool { + switch rc.OperationKind { + case OperationRead: + return category == "auth" || category == "rate_limit" || category == "network" + case OperationWrite: + if rc.CallStage != "http" { + return false + } + return category == "auth" || category == "rate_limit" + default: + return false + } +} + +func normalizeDecisionOwner(plan RecoveryPlan) DecisionOwner { + if plan.DecisionOwner != "" { + return plan.DecisionOwner + } + if plan.Category == "unknown" { + return DecisionOwnerAgent + } + return DecisionOwnerBuiltinRule +} + +func stableKeywords(raw string) []string { + normalized := normalizeRawError(raw) + if normalized == "" { + return nil + } + parts := strings.FieldsFunc(normalized, func(r rune) bool { + return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' + }) + out := make([]string, 0, len(parts)) + for _, part := range parts { + if len(part) < 3 || strings.HasPrefix(part, "<") { + continue + } + out = append(out, part) + if len(out) >= 6 { + break + } + } + return uniqueStrings(out) +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func containsAny(value string, targets ...string) bool { + value = strings.ToLower(value) + for _, target := range targets { + if strings.Contains(value, strings.ToLower(target)) { + return true + } + } + return false +} + +const ( + cliAuthExpiredCode = "AUTH_TOKEN_EXPIRED" + cliInvalidJSONCode = "INPUT_INVALID_JSON" + cliMissingParamCode = "INPUT_MISSING_PARAM" + cliPermissionCode = "AUTH_PERMISSION_DENIED" + cliResourceNotFoundCode = "RESOURCE_NOT_FOUND" + cliRateLimitCode = "NETWORK_RATE_LIMITED" + cliTimeoutCode = "NETWORK_TIMEOUT" +) + +func normalizeSafeActions(actions []string) []string { + allowed := make([]string, 0, len(actions)) + for _, action := range actions { + switch action { + case "rerun_original_command", "retry_original_command", "wait_and_retry": + allowed = append(allowed, action) + } + } + return uniqueStrings(allowed) +} + +func buildDecisionHints(plan RecoveryPlan, rc RecoveryContext) DecisionHints { + signals := []string{rc.CLIErrorCode, rc.RawError} + for _, hit := range plan.KBHits { + signals = append(signals, hit.Title, hit.Snippet) + } + for _, action := range plan.DocActions { + signals = append(signals, action.Action, action.Reason) + } + text := strings.Join(signals, " ") + return DecisionHints{ + Retryable: plan.ShouldRetry || len(plan.SafeActions) > 0, + PermissionSensitive: plan.Category == "permission" || containsAny(text, "permission", "forbidden", "无权限", "operatorid has permission", "有访问权限", "inaccessible"), + AuthRelated: plan.Category == "auth" || containsAny(text, "auth_token_expired", "token验证失败", "user_token_illegal", "认证失效", "token 已过期"), + ResourceStateRelated: plan.Category == "resource" || containsAny(text, "not found", "does not exist", "deleted", "document.notfound", "resource.notfound", "资源不存在", "文档不存在", "彻底删除", "base_not_found"), + } +} + +func buildRuleHints(plan RecoveryPlan) RuleHints { + return RuleHints{ + Category: plan.Category, + DecisionOwner: normalizeDecisionOwner(plan), + Confidence: plan.Confidence, + SafeActions: nonNilStrings(plan.SafeActions), + HumanActions: nonNilStrings(plan.HumanActions), + Evidence: nonNilStrings(plan.Evidence), + ShouldRetry: plan.ShouldRetry, + ShouldStop: plan.ShouldStop, + } +} + +func buildAgentRouteReasons(plan RecoveryPlan) []string { + reasons := make([]string, 0, 2) + if plan.Category == "unknown" { + reasons = append(reasons, "unknown_category") + } + return nonNilStrings(uniqueStrings(reasons)) +} + +func buildAgentRoute(eventID string, rc RecoveryContext, replay Replay, plan RecoveryPlan, reasons []string) AgentRoute { + required := len(reasons) > 0 + var payload *AgentRoutePayload + decisionOwner := normalizeDecisionOwner(plan) + if required { + payload = &AgentRoutePayload{ + EventID: eventID, + Context: cloneRecoveryContext(rc), + Replay: cloneReplay(replay), + RawError: rc.RawError, + Category: plan.Category, + DecisionOwner: decisionOwner, + Confidence: plan.Confidence, + ShouldRetry: plan.ShouldRetry, + ShouldStop: plan.ShouldStop, + SafeActions: nonNilStrings(plan.SafeActions), + DocActions: cloneDocActions(plan.DocActions), + KBHits: cloneKBHits(plan.KBHits), + DocSearch: cloneDocSearch(plan.DocSearch), + HumanActions: nonNilStrings(plan.HumanActions), + DecisionHints: plan.DecisionHints, + Evidence: nonNilStrings(plan.Evidence), + RuleHints: plan.RuleHints, + } + } + return AgentRoute{ + Required: required, + Target: ternaryString(required, "agent_llm", ""), + Executor: ternaryString(required, "dingtalk-workspace", ""), + Reasons: nonNilStrings(reasons), + Payload: payload, + } +} + +func HydratePlanForEvent(eventID string, rc RecoveryContext, replay Replay, plan *RecoveryPlan) { + if plan == nil { + return + } + plan.RuleHints = buildRuleHints(*plan) + plan.AgentRoute = buildAgentRoute(eventID, rc, replay, *plan, buildAgentRouteReasons(*plan)) +} + +func extractDocActions(hits []KBHit) []DocAction { + out := make([]DocAction, 0) + for _, hit := range hits { + if strings.TrimSpace(hit.Snippet) == "" { + continue + } + lines := strings.Split(hit.Snippet, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "|") { + continue + } + cells := splitTableRow(line) + if len(cells) < 5 { + continue + } + if strings.EqualFold(cells[0], "httpcode") || strings.HasPrefix(cells[0], "--") { + continue + } + action := strings.TrimSpace(cells[len(cells)-1]) + if action == "" || action == "%s" { + continue + } + reasonParts := make([]string, 0, 2) + if len(cells) > 1 && cells[1] != "" { + reasonParts = append(reasonParts, cells[1]) + } + if len(cells) > 3 && cells[3] != "" && cells[3] != "%s" { + reasonParts = append(reasonParts, cells[3]) + } + out = append(out, DocAction{ + Action: action, + Reason: strings.Join(reasonParts, ": "), + SourceTitle: hit.Title, + SourceURL: hit.URL, + }) + } + } + return out +} + +func splitTableRow(line string) []string { + parts := strings.Split(line, "|") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + out = append(out, part) + } + return out +} + +func dedupeDocActions(actions []DocAction) []DocAction { + if len(actions) <= 1 { + return actions + } + seen := make(map[string]struct{}, len(actions)) + out := make([]DocAction, 0, len(actions)) + for _, action := range actions { + key := strings.TrimSpace(action.Action) + "|" + strings.TrimSpace(action.Reason) + "|" + strings.TrimSpace(action.SourceTitle) + "|" + strings.TrimSpace(action.SourceURL) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, action) + } + return out +} + +func cloneDocActions(actions []DocAction) []DocAction { + if len(actions) == 0 { + return nil + } + out := make([]DocAction, len(actions)) + copy(out, actions) + return out +} + +func cloneKBHits(hits []KBHit) []KBHit { + if len(hits) == 0 { + return nil + } + out := make([]KBHit, len(hits)) + copy(out, hits) + return out +} + +func cloneDocSearch(search DocSearch) DocSearch { + cloned := search + if search.Request != nil { + cloned.Request = &ToolCallRecord{ + ServerID: search.Request.ServerID, + ToolName: search.Request.ToolName, + Arguments: cloneMap(search.Request.Arguments), + } + } + if search.Response != nil { + cloned.Response = &ToolResponse{IsError: search.Response.IsError} + if len(search.Response.Content) > 0 { + cloned.Response.Content = make([]ToolResponseBlock, len(search.Response.Content)) + copy(cloned.Response.Content, search.Response.Content) + } + } + if len(search.Items) > 0 { + cloned.Items = make([]DocSearchItem, len(search.Items)) + copy(cloned.Items, search.Items) + } + return cloned +} + +func ternaryString(ok bool, yes, no string) string { + if ok { + return yes + } + return no +} + +func nonNilStrings(values []string) []string { + if len(values) == 0 { + return []string{} + } + return append([]string{}, values...) +} diff --git a/internal/recovery/recovery_test.go b/internal/recovery/recovery_test.go new file mode 100644 index 00000000..b59a475f --- /dev/null +++ b/internal/recovery/recovery_test.go @@ -0,0 +1,310 @@ +package recovery + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" +) + +func TestBuildContext_SanitizesArgsAndPreservesTypedFields(t *testing.T) { + rawErr := &transport.CallError{ + Stage: transport.CallStageHTTP, + HTTPStatus: 429, + RetryAfter: "3", + TraceID: "trace-1", + RequestID: "req-1", + Cause: errors.New("HTTP 429: too many requests"), + } + wrapped := apperrors.NewAPI( + "request failed", + apperrors.WithReason("http_429"), + apperrors.WithRetryable(true), + apperrors.WithCause(rawErr), + ) + + ctx := BuildContext(CaptureInput{ + CommandPath: []string{"chat", "message", "send"}, + ServerID: "chat", + ToolName: "send_message_as_user", + Args: map[string]any{ + "text": "very secret text body", + "token": "secret-token", + "payload": map[string]any{"header": "sensitive", "name": "demo"}, + }, + RawErr: rawErr, + WrappedErr: wrapped, + }) + + if ctx.CLIErrorCode != cliRateLimitCode { + t.Fatalf("expected CLI error code %q, got %q", cliRateLimitCode, ctx.CLIErrorCode) + } + if ctx.CallStage != string(transport.CallStageHTTP) || ctx.HTTPStatus != 429 { + t.Fatalf("expected typed call metadata, got stage=%q status=%d", ctx.CallStage, ctx.HTTPStatus) + } + if ctx.RetryAfter != "3" || ctx.TraceID != "trace-1" || ctx.RequestID != "req-1" { + t.Fatalf("expected transport metadata, got %#v", ctx) + } + if _, ok := ctx.ArgsSummary["token"]; ok { + t.Fatal("sensitive args must be dropped") + } + textSummary, ok := ctx.ArgsSummary["text"].(map[string]any) + if !ok { + t.Fatalf("expected text summary, got %#v", ctx.ArgsSummary["text"]) + } + if int(textSummary["length"].(float64)) != len("very secret text body") { + t.Fatalf("expected text summary length, got %#v", textSummary) + } + if ctx.Fingerprint == "" { + t.Fatal("expected fingerprint to be computed") + } +} + +func TestBuildReplay_SummarizesContentArgsAndSanitizesNestedValues(t *testing.T) { + replay := BuildReplay(CaptureInput{ + CommandPath: []string{"chat", "message", "send"}, + ServerID: "chat", + ToolName: "send_message_as_user", + Args: map[string]any{ + "text": "very secret text body", + "records": []map[string]any{{ + "title": "demo record", + "token": "secret-token", + }}, + "payload": map[string]any{ + "header": "sensitive", + "name": "demo", + "text": "nested body", + }, + "baseId": "base_demo", + }, + Argv: []string{"chat", "message", "send", "--token", "secret-token"}, + }) + + textSummary, ok := replay.ToolArgs["text"].(map[string]any) + if !ok { + t.Fatalf("expected text summary in replay, got %#v", replay.ToolArgs["text"]) + } + if int(textSummary["length"].(float64)) != len("very secret text body") { + t.Fatalf("expected text length summary, got %#v", textSummary) + } + + recordsSummary, ok := replay.ToolArgs["records"].(map[string]any) + if !ok { + t.Fatalf("expected records summary in replay, got %#v", replay.ToolArgs["records"]) + } + if recordsSummary["kind"] != "array" || int(recordsSummary["count"].(float64)) != 1 { + t.Fatalf("expected records count summary, got %#v", recordsSummary) + } + + payload, ok := replay.ToolArgs["payload"].(map[string]any) + if !ok { + t.Fatalf("expected nested payload in replay, got %#v", replay.ToolArgs["payload"]) + } + if _, ok := payload["header"]; ok { + t.Fatalf("expected nested sensitive field to be removed, got %#v", payload) + } + if payload["name"] != "demo" { + t.Fatalf("expected non-sensitive nested field to remain, got %#v", payload) + } + nestedText, ok := payload["text"].(map[string]any) + if !ok { + t.Fatalf("expected nested text summary, got %#v", payload["text"]) + } + if int(nestedText["length"].(float64)) != len("nested body") { + t.Fatalf("expected nested text length summary, got %#v", nestedText) + } + + if replay.ToolArgs["baseId"] != "base_demo" { + t.Fatalf("expected replayable identifier to remain, got %#v", replay.ToolArgs["baseId"]) + } + if len(replay.RedactedArgv) != 5 || replay.RedactedArgv[4] != "" { + t.Fatalf("expected argv token to be redacted, got %#v", replay.RedactedArgv) + } +} + +func TestComputeFingerprint_NormalizesVolatileSegments(t *testing.T) { + ctxA := RecoveryContext{ + ServerID: "aitable", + ToolName: "get_base", + CLIErrorCode: cliResourceNotFoundCode, + HTTPStatus: 404, + RawError: "resource base_abcd1234 not found at 2026-03-19T10:10:10Z trace=550e8400-e29b-41d4-a716-446655440000", + } + ctxB := RecoveryContext{ + ServerID: "aitable", + ToolName: "get_base", + CLIErrorCode: cliResourceNotFoundCode, + HTTPStatus: 404, + RawError: "resource base_zzzz9999 not found at 2026-03-20T10:10:10Z trace=660e8400-e29b-41d4-a716-446655440000", + } + if gotA, gotB := ComputeFingerprint(ctxA), ComputeFingerprint(ctxB); gotA != gotB { + t.Fatalf("expected normalized fingerprint to stay stable, got %q vs %q", gotA, gotB) + } +} + +func TestPlanner_ExactRulesAndFallback(t *testing.T) { + planner := NewPlanner(fakeRetriever{retrieval: KnowledgeRetrieval{ + KBHits: []KBHit{{Source: "docs", Title: "doc"}}, + DocSearch: DocSearch{ + Provider: "open_platform_docs", + Query: "get_approval_instance approval approval get weird upstream issue", + Status: "success", + Items: []DocSearchItem{{ + Title: "doc", + URL: "https://open.dingtalk.com/doc", + Desc: "审批接口文档", + }}, + }, + }}) + + authPlan := planner.Plan(context.Background(), RecoveryContext{ + CLIErrorCode: cliAuthExpiredCode, + OperationKind: OperationRead, + }) + if authPlan.Category != "auth" || !authPlan.ShouldRetry { + t.Fatalf("expected auth plan with retry, got %#v", authPlan) + } + if len(authPlan.SafeActions) != 1 || authPlan.SafeActions[0] != "rerun_original_command" { + t.Fatalf("expected safe action alias for auth plan, got %#v", authPlan.SafeActions) + } + if !authPlan.DecisionHints.AuthRelated || !authPlan.DecisionHints.Retryable { + t.Fatalf("expected auth decision hints, got %#v", authPlan.DecisionHints) + } + if authPlan.AgentRoute.Required { + t.Fatalf("expected auth retryable plan to stay on rule path, got %#v", authPlan.AgentRoute) + } + + unknownPlan := planner.Plan(context.Background(), RecoveryContext{ + CommandPath: []string{"approval", "approval", "get"}, + ToolName: "get_approval_instance", + OperationKind: OperationUnknown, + RawError: "weird upstream issue", + }) + if unknownPlan.Category != "unknown" { + t.Fatalf("expected unknown category, got %s", unknownPlan.Category) + } + if unknownPlan.DecisionOwner != DecisionOwnerAgent { + t.Fatalf("expected unknown plan to be agent-owned, got %#v", unknownPlan.DecisionOwner) + } + if len(unknownPlan.KBHits) == 0 { + t.Fatal("expected fallback retriever hits") + } + if !strings.Contains(strings.Join(unknownPlan.Evidence, " "), "fallback_query=") { + t.Fatalf("expected fallback query evidence, got %#v", unknownPlan.Evidence) + } + if unknownPlan.ShouldRetry || unknownPlan.ShouldStop { + t.Fatalf("expected unknown plan to stay undecided, got %#v", unknownPlan) + } + if len(unknownPlan.HumanActions) != 0 { + t.Fatalf("expected unknown plan to avoid built-in human actions, got %#v", unknownPlan.HumanActions) + } + if !unknownPlan.AgentRoute.Required { + t.Fatalf("expected unknown plan to force agent route, got %#v", unknownPlan.AgentRoute) + } + if len(unknownPlan.AgentRoute.Reasons) != 1 || unknownPlan.AgentRoute.Reasons[0] != "unknown_category" { + t.Fatalf("expected unknown_category route reason, got %#v", unknownPlan.AgentRoute.Reasons) + } + if unknownPlan.AgentRoute.Payload == nil || unknownPlan.AgentRoute.Payload.DecisionOwner != DecisionOwnerAgent { + t.Fatalf("expected unknown payload to include agent decision owner, got %#v", unknownPlan.AgentRoute.Payload) + } + if unknownPlan.DocSearch.Status != "success" || len(unknownPlan.DocSearch.Items) != 1 { + t.Fatalf("expected doc search payload to be preserved, got %#v", unknownPlan.DocSearch) + } + if unknownPlan.RuleHints.DecisionOwner != DecisionOwnerAgent || unknownPlan.RuleHints.Confidence != unknownPlan.Confidence { + t.Fatalf("expected rule hints to mirror final unknown plan state, got %#v", unknownPlan.RuleHints) + } +} + +func TestStore_CaptureLoadAndFinalizeLifecycle(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + ctx := RecoveryContext{ + CommandPath: []string{"aitable", "base", "get"}, + ServerID: "aitable", + ToolName: "get_base", + OperationKind: OperationRead, + CLIErrorCode: cliResourceNotFoundCode, + RawError: "base_not_found", + Fingerprint: "fp-1", + } + replay := Replay{ + ServerID: "aitable", + ToolName: "get_base", + OperationKind: OperationRead, + ToolArgs: map[string]any{"baseId": "base_1"}, + } + + last, err := store.Capture(ctx, replay) + if err != nil { + t.Fatalf("Capture() error = %v", err) + } + if last == nil || last.EventID == "" { + t.Fatalf("Capture() = %#v, want event id", last) + } + + loaded, err := store.LoadLastError() + if err != nil { + t.Fatalf("LoadLastError() error = %v", err) + } + if loaded.EventID != last.EventID { + t.Fatalf("LoadLastError().EventID = %q, want %q", loaded.EventID, last.EventID) + } + + byEvent, err := store.LoadErrorByEvent(last.EventID) + if err != nil { + t.Fatalf("LoadErrorByEvent() error = %v", err) + } + if byEvent.EventID != last.EventID { + t.Fatalf("LoadErrorByEvent().EventID = %q, want %q", byEvent.EventID, last.EventID) + } + + plan := RecoveryPlan{Category: "resource", Confidence: 0.92} + if err := store.SavePlan(last.EventID, plan); err != nil { + t.Fatalf("SavePlan() error = %v", err) + } + bundle := RecoveryBundle{EventID: last.EventID, Plan: plan, Status: "needs_agent_action"} + if err := store.SaveAnalysis(last.EventID, plan, bundle); err != nil { + t.Fatalf("SaveAnalysis() error = %v", err) + } + exec := &RecoveryExecution{ + Actions: []string{"inspect_bundle"}, + Result: "handoff", + } + if err := store.Finalize(last.EventID, "handoff", exec); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + + eventsPath := filepath.Join(dir, "recovery", "recovery_events.jsonl") + data, err := os.ReadFile(eventsPath) + if err != nil { + t.Fatalf("ReadFile(events) error = %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 4 { + t.Fatalf("expected 4 recovery event lines, got %d", len(lines)) + } + var lastEvent RecoveryEvent + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &lastEvent); err != nil { + t.Fatalf("json.Unmarshal(final event) error = %v", err) + } + if lastEvent.Phase != "finalized" || lastEvent.Outcome != "handoff" { + t.Fatalf("unexpected final event %#v", lastEvent) + } +} + +type fakeRetriever struct { + retrieval KnowledgeRetrieval + err error +} + +func (f fakeRetriever) Search(ctx context.Context, query string, rc RecoveryContext) (KnowledgeRetrieval, error) { + return f.retrieval, f.err +} diff --git a/internal/recovery/store.go b/internal/recovery/store.go new file mode 100644 index 00000000..fa2a28a3 --- /dev/null +++ b/internal/recovery/store.go @@ -0,0 +1,464 @@ +package recovery + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + recoveryDirName = "recovery" + lastErrorFile = "last_error.json" + eventsFile = "recovery_events.jsonl" + recoveryMaxAge = 30 * 24 * time.Hour +) + +type Store struct { + baseDir string +} + +var runtimeState struct { + mu sync.Mutex + lastCapture *LastError +} + +func NewStore(configDir string) *Store { + return &Store{baseDir: configDir} +} + +func (s *Store) Enabled() bool { + return s != nil && s.baseDir != "" +} + +func (s *Store) Capture(ctx RecoveryContext, replay ...Replay) (*LastError, error) { + if !s.Enabled() { + return nil, nil + } + + last := &LastError{ + EventID: newEventID(), + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Context: ctx, + } + if len(replay) > 0 { + last.Replay = cloneReplay(replay[0]) + } + if err := s.writeJSON(s.lastErrorPath(), last); err != nil { + return nil, err + } + var replayPtr *Replay + if !isEmptyReplay(last.Replay) { + copied := cloneReplay(last.Replay) + replayPtr = &copied + } + if err := s.appendEvent(RecoveryEvent{ + EventID: last.EventID, + Phase: "captured", + RecordedAt: last.RecordedAt, + Context: &last.Context, + Replay: replayPtr, + }); err != nil { + return nil, err + } + setLatestCapture(last) + return last, nil +} + +func (s *Store) LoadLastError() (*LastError, error) { + if !s.Enabled() { + return nil, fmt.Errorf("recovery store disabled") + } + if err := s.pruneExpiredArtifacts(time.Now().UTC()); err != nil { + return nil, err + } + data, err := os.ReadFile(s.lastErrorPath()) + if err != nil { + return nil, err + } + var last LastError + if err := json.Unmarshal(data, &last); err != nil { + return nil, err + } + return &last, nil +} + +func (s *Store) LoadErrorByEvent(eventID string) (*LastError, error) { + if !s.Enabled() { + return nil, fmt.Errorf("recovery store disabled") + } + if eventID == "" { + return nil, fmt.Errorf("event id is required") + } + if err := s.pruneExpiredArtifacts(time.Now().UTC()); err != nil { + return nil, err + } + + file, err := os.Open(s.eventsPath()) + if err != nil { + if !os.IsNotExist(err) { + return nil, err + } + file = nil + } + if file != nil { + defer file.Close() + reader := bufio.NewReader(file) + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + var event RecoveryEvent + if json.Unmarshal(line, &event) == nil && event.EventID == eventID && event.Phase == "captured" && event.Context != nil { + last := &LastError{ + EventID: event.EventID, + RecordedAt: event.RecordedAt, + Context: cloneRecoveryContext(*event.Context), + } + if event.Replay != nil { + last.Replay = cloneReplay(*event.Replay) + } + return last, nil + } + } + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + } + } + + last, err := s.LoadLastError() + if err == nil && last != nil && last.EventID == eventID { + return last, nil + } + return nil, fmt.Errorf("未找到 event_id=%s 对应的失败快照", eventID) +} + +func (s *Store) SavePlan(eventID string, plan RecoveryPlan) error { + if !s.Enabled() { + return nil + } + return s.appendEvent(RecoveryEvent{ + EventID: eventID, + Phase: "planned", + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Plan: &plan, + }) +} + +func (s *Store) SaveAnalysis(eventID string, plan RecoveryPlan, bundle RecoveryBundle) error { + if !s.Enabled() { + return nil + } + return s.appendEvent(RecoveryEvent{ + EventID: eventID, + Phase: "analyzed", + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Plan: &plan, + Bundle: &bundle, + }) +} + +func (s *Store) Finalize(eventID, outcome string, exec *RecoveryExecution) error { + if !s.Enabled() { + return nil + } + return s.appendEvent(RecoveryEvent{ + EventID: eventID, + Phase: "finalized", + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Execution: exec, + Outcome: outcome, + }) +} + +func (s *Store) writeJSON(path string, payload any) error { + if err := s.ensureDir(); err != nil { + return err + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +func (s *Store) appendEvent(event RecoveryEvent) error { + if err := s.ensureDir(); err != nil { + return err + } + file, err := os.OpenFile(s.eventsPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer file.Close() + + writer := bufio.NewWriter(file) + defer writer.Flush() + + data, err := json.Marshal(event) + if err != nil { + return err + } + if _, err := writer.Write(append(data, '\n')); err != nil { + return err + } + return nil +} + +func (s *Store) ensureDir() error { + if !s.Enabled() { + return fmt.Errorf("recovery store disabled") + } + if err := os.MkdirAll(s.dir(), 0o700); err != nil { + return err + } + return s.pruneExpiredArtifacts(time.Now().UTC()) +} + +func (s *Store) dir() string { + return filepath.Join(s.baseDir, recoveryDirName) +} + +func (s *Store) lastErrorPath() string { + return filepath.Join(s.dir(), lastErrorFile) +} + +func (s *Store) eventsPath() string { + return filepath.Join(s.dir(), eventsFile) +} + +func newEventID() string { + return fmt.Sprintf("evt_%d", time.Now().UTC().UnixNano()) +} + +func LatestCapture() *LastError { + runtimeState.mu.Lock() + defer runtimeState.mu.Unlock() + if runtimeState.lastCapture == nil { + return nil + } + copied := *runtimeState.lastCapture + copied.Context = cloneRecoveryContext(copied.Context) + copied.Replay = cloneReplay(copied.Replay) + return &copied +} + +func ResetRuntimeState() { + runtimeState.mu.Lock() + defer runtimeState.mu.Unlock() + runtimeState.lastCapture = nil +} + +func setLatestCapture(last *LastError) { + runtimeState.mu.Lock() + defer runtimeState.mu.Unlock() + if last == nil { + runtimeState.lastCapture = nil + return + } + copied := *last + copied.Context = cloneRecoveryContext(copied.Context) + copied.Replay = cloneReplay(copied.Replay) + runtimeState.lastCapture = &copied +} + +func cloneRecoveryContext(ctx RecoveryContext) RecoveryContext { + cloned := ctx + if len(ctx.CommandPath) > 0 { + cloned.CommandPath = append([]string(nil), ctx.CommandPath...) + } + if len(ctx.ArgsSummary) > 0 { + cloned.ArgsSummary = cloneMap(ctx.ArgsSummary) + } + return cloned +} + +func cloneReplay(replay Replay) Replay { + cloned := replay + if len(replay.ToolArgs) > 0 { + cloned.ToolArgs = cloneMap(replay.ToolArgs) + } + if len(replay.RedactedArgv) > 0 { + cloned.RedactedArgv = append([]string(nil), replay.RedactedArgv...) + } + return cloned +} + +func isEmptyReplay(replay Replay) bool { + return replay.ServerID == "" && + replay.ToolName == "" && + replay.OperationKind == "" && + len(replay.ToolArgs) == 0 && + len(replay.RedactedArgv) == 0 && + replay.RedactedCommand == "" +} + +func cloneMap(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = cloneValue(value) + } + return dst +} + +func cloneSlice(src []any) []any { + if len(src) == 0 { + return nil + } + dst := make([]any, len(src)) + for i, value := range src { + dst[i] = cloneValue(value) + } + return dst +} + +func cloneValue(value any) any { + switch v := value.(type) { + case map[string]any: + return cloneMap(v) + case []any: + return cloneSlice(v) + case []string: + out := make([]string, len(v)) + copy(out, v) + return out + default: + return v + } +} + +func (s *Store) pruneExpiredArtifacts(now time.Time) error { + if !s.Enabled() { + return nil + } + cutoff := now.Add(-recoveryMaxAge) + if err := s.pruneLastError(cutoff); err != nil { + return err + } + if err := s.pruneEvents(cutoff); err != nil { + return err + } + return s.pruneOtherArtifacts(cutoff) +} + +func (s *Store) pruneLastError(cutoff time.Time) error { + path := s.lastErrorPath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var last LastError + if err := json.Unmarshal(data, &last); err == nil { + if recordedAt, ok := parseRecordedAt(last.RecordedAt); ok && recordedAt.Before(cutoff) { + return os.Remove(path) + } + return nil + } + + info, statErr := os.Stat(path) + if statErr != nil { + if os.IsNotExist(statErr) { + return nil + } + return statErr + } + if info.ModTime().Before(cutoff) { + return os.Remove(path) + } + return nil +} + +func (s *Store) pruneEvents(cutoff time.Time) error { + path := s.eventsPath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + kept := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var event RecoveryEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + kept = append(kept, line) + continue + } + recordedAt, ok := parseRecordedAt(event.RecordedAt) + if ok && recordedAt.Before(cutoff) { + continue + } + kept = append(kept, line) + } + + if len(kept) == 0 { + return os.Remove(path) + } + content := strings.Join(kept, "\n") + "\n" + return os.WriteFile(path, []byte(content), 0o600) +} + +func (s *Store) pruneOtherArtifacts(cutoff time.Time) error { + entries, err := os.ReadDir(s.dir()) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + name := entry.Name() + if name == lastErrorFile || name == eventsFile { + continue + } + path := filepath.Join(s.dir(), name) + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + continue + } + return err + } + if info.ModTime().Before(cutoff) { + if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { + return err + } + } + } + return nil +} + +func parseRecordedAt(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, true + } + } + return time.Time{}, false +} diff --git a/internal/transport/call_error.go b/internal/transport/call_error.go new file mode 100644 index 00000000..c9e7833e --- /dev/null +++ b/internal/transport/call_error.go @@ -0,0 +1,46 @@ +package transport + +import "fmt" + +type CallStage string + +const ( + CallStageRequest CallStage = "request" + CallStageHTTP CallStage = "http" + CallStageJSONRPC CallStage = "jsonrpc" +) + +// CallError carries recoverable transport metadata without changing the +// repository-local structured error contract. +type CallError struct { + Stage CallStage + HTTPStatus int + RetryAfter string + TraceID string + RequestID string + RPCCode int + Cause error +} + +func (e *CallError) Error() string { + if e == nil { + return "" + } + switch { + case e.Cause != nil: + return e.Cause.Error() + case e.HTTPStatus != 0: + return fmt.Sprintf("%s failure: http %d", e.Stage, e.HTTPStatus) + case e.RPCCode != 0: + return fmt.Sprintf("%s failure: rpc %d", e.Stage, e.RPCCode) + default: + return string(e.Stage) + " failure" + } +} + +func (e *CallError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} diff --git a/internal/transport/client.go b/internal/transport/client.go index 0dc8fcac..84322680 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -17,6 +17,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -407,6 +408,10 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) apperrors.WithOperation("jsonrpc"), apperrors.WithReason("request_build_failed"), apperrors.WithHint(i18n.T("请检查服务 endpoint 是否为空或格式不合法。")), + apperrors.WithCause(&CallError{ + Stage: CallStageRequest, + Cause: err, + }), ) } req.Header.Set("Content-Type", "application/json") @@ -446,6 +451,10 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) apperrors.WithOperation("jsonrpc"), apperrors.WithReason("request_cancelled"), apperrors.WithHint(i18n.T("请求在重试过程中被取消;请检查调用侧超时设置。")), + apperrors.WithCause(&CallError{ + Stage: CallStageRequest, + Cause: err, + }), ) } } @@ -457,6 +466,10 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) apperrors.WithRetryable(true), apperrors.WithHint(i18n.T("请检查网络连通性和 MCP 服务状态后重试。")), apperrors.WithActions(discoveryActions("")...), + apperrors.WithCause(&CallError{ + Stage: CallStageRequest, + Cause: lastErr, + }), ) } @@ -631,6 +644,11 @@ func httpStatusError(method, endpoint string, statusCode int, snapshotPath strin apperrors.WithReason(fmt.Sprintf("http_%d", statusCode)), apperrors.WithRetryable(retryable(statusCode)), apperrors.WithSnapshot(snapshotPath), + apperrors.WithCause(&CallError{ + Stage: CallStageHTTP, + HTTPStatus: statusCode, + Cause: errors.New(message), + }), } switch { @@ -677,6 +695,11 @@ func jsonrpcEnvelopeError(method string, rpcErr *RPCError, snapshotPath string) apperrors.WithRPCCode(rpcErr.Code), apperrors.WithRPCData(rpcErr.Data), apperrors.WithSnapshot(snapshotPath), + apperrors.WithCause(&CallError{ + Stage: CallStageJSONRPC, + RPCCode: rpcErr.Code, + Cause: errors.New(message), + }), } if rpcErr.Code == -32602 { diff --git a/internal/transport/recovery_metadata_test.go b/internal/transport/recovery_metadata_test.go new file mode 100644 index 00000000..2c7fb0b5 --- /dev/null +++ b/internal/transport/recovery_metadata_test.go @@ -0,0 +1,74 @@ +package transport + +import ( + "context" + "errors" + "net/http" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +func TestHTTPStatusErrorIncludesCallMetadata(t *testing.T) { + err := httpStatusError("tools/call", "https://mcp.dingtalk.com/server", http.StatusTooManyRequests, "") + + var callErr *CallError + if !errors.As(err, &callErr) { + t.Fatalf("expected CallError in chain, got %T", err) + } + if callErr.Stage != CallStageHTTP { + t.Fatalf("Stage = %q, want %q", callErr.Stage, CallStageHTTP) + } + if callErr.HTTPStatus != http.StatusTooManyRequests { + t.Fatalf("HTTPStatus = %d, want %d", callErr.HTTPStatus, http.StatusTooManyRequests) + } + + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("expected structured errors.Error, got %T", err) + } + if typed.Reason != "http_429" { + t.Fatalf("Reason = %q, want http_429", typed.Reason) + } +} + +func TestJSONRPCEnvelopeErrorIncludesCallMetadata(t *testing.T) { + err := jsonrpcEnvelopeError("tools/call", &RPCError{Code: -32602, Message: "invalid params"}, "") + + var callErr *CallError + if !errors.As(err, &callErr) { + t.Fatalf("expected CallError in chain, got %T", err) + } + if callErr.Stage != CallStageJSONRPC { + t.Fatalf("Stage = %q, want %q", callErr.Stage, CallStageJSONRPC) + } + if callErr.RPCCode != -32602 { + t.Fatalf("RPCCode = %d, want -32602", callErr.RPCCode) + } +} + +func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) { + client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: connection refused") + })}) + client.MaxRetries = 0 + + _, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`)) + if err == nil { + t.Fatal("doWithRetry() error = nil, want request failure") + } + + var callErr *CallError + if !errors.As(err, &callErr) { + t.Fatalf("expected CallError in chain, got %T", err) + } + if callErr.Stage != CallStageRequest { + t.Fatalf("Stage = %q, want %q", callErr.Stage, CallStageRequest) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/skills/SKILL.md b/skills/SKILL.md index 4f6d5b8b..2c7ec60e 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -1,7 +1,7 @@ --- name: dws description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)时使用。 -cli_version: ">=1.0.0" +cli_version: ">=1.1.0" --- # 钉钉全产品 Skill @@ -88,9 +88,10 @@ Step 3 → 加 --yes 执行命令 ## 错误处理 1. 遇到错误,加 `--verbose` 重试一次 -2. 仍然失败,报告完整错误信息给用户,禁止自行尝试替代方案 -3. 认证失败时,参考 [global-reference.md](./references/global-reference.md) 中的认证章节处理 -4. 各产品高频错误及排查流程见 [error-codes.md](./references/error-codes.md) +2. 若 stderr 出现 `RECOVERY_EVENT_ID=`,优先按 [recovery-guide.md](./references/recovery-guide.md) 执行 recovery 闭环 +3. 仍然失败,报告完整错误信息给用户,禁止自行尝试替代方案 +4. 认证失败时,参考 [global-reference.md](./references/global-reference.md) 中的认证章节处理 +5. 各产品高频错误及排查流程见 [error-codes.md](./references/error-codes.md) ## 详细参考 (按需读取) @@ -100,4 +101,5 @@ Step 3 → 加 --yes 执行命令 - [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式 - [references/field-rules.md](./references/field-rules.md) — AI表格字段类型规则 - [references/error-codes.md](./references/error-codes.md) — 错误码 + 调试流程 +- [references/recovery-guide.md](./references/recovery-guide.md) — recovery 闭环、`RECOVERY_EVENT_ID`、`execute/finalize` 规范 - [scripts/](./scripts/) — 各产品批量操作脚本(AI表格/日历/机器人消息/通讯录/考勤/日志/待办等) diff --git a/skills/references/error-codes.md b/skills/references/error-codes.md index 986f49b7..00b10f1f 100644 --- a/skills/references/error-codes.md +++ b/skills/references/error-codes.md @@ -20,6 +20,21 @@ ## 通用错误 - 请求超时 — 网络慢或服务端响应慢 → `--timeout 60` 重试 - 网络连接失败 — 无法连接 MCP Server → 用最简命令验证: `dws contact user get-self --format json` +- stderr 出现 `RECOVERY_EVENT_ID=` — runtime 失败已被 CLI 捕获 → 优先执行 `dws recovery execute --event-id --format json` + +## Recovery 闭环 + +- `dws recovery plan --last|--event-id --format json`:读取失败快照并生成恢复计划 +- `dws recovery execute --last|--event-id --format json`:生成带 `doc_search`、`probe_results`、`agent_task` 的分析包 +- `dws recovery finalize --event-id --outcome recovered|failed|handoff --execution-file execution.json --format json`:回写恢复结果 + +执行 `finalize` 时: + +- `execution-file` 至少应包含 `actions`、`attempts`、`result`、`error_summary` +- 兼容旧格式:`action` + 数值型 `attempts` +- 若 recovery bundle 已进入 unknown/agent 路线,不要把 `human_actions` 视为最终结论;必须结合完整 bundle 判断 + +更多字段解释见 [recovery-guide.md](./recovery-guide.md)。 --- diff --git a/skills/references/global-reference.md b/skills/references/global-reference.md index 956ad2b8..90dd18a2 100644 --- a/skills/references/global-reference.md +++ b/skills/references/global-reference.md @@ -36,6 +36,23 @@ dws auth import credentials.json ``` refresh_token 单设备独占,远程刷新后源设备凭证失效。 +## Recovery + +当 runtime/MCP 命令失败且 stderr 额外输出 `RECOVERY_EVENT_ID=` 时,说明 CLI 已经持久化了失败快照,可进入 recovery 闭环: + +```bash +dws recovery plan --event-id --format json +dws recovery execute --event-id --format json +dws recovery finalize --event-id --outcome recovered|failed|handoff --execution-file execution.json --format json +``` + +- `plan` / `execute` 也支持 `--last`,但 `--last` 与 `--event-id` 互斥 +- recovery 文件保存在 `DWS_CONFIG_DIR/recovery/` +- CLI 会自动清理 30 天前的 recovery 文件和事件记录 +- recovery 自己发起的文档检索与只读 probe 不会再创建新的 recovery 事件 + +更多闭环要求见 [recovery-guide.md](./recovery-guide.md)。 + ## 全局标志 diff --git a/skills/references/recovery-guide.md b/skills/references/recovery-guide.md new file mode 100644 index 00000000..3fe4eba5 --- /dev/null +++ b/skills/references/recovery-guide.md @@ -0,0 +1,94 @@ +# Recovery Guide + +`dws` 的 recovery 闭环以 `dws recovery execute` 为正式入口。主入口仍是 [SKILL.md](../SKILL.md),错误分类与排查细节可结合 [error-codes.md](./error-codes.md) 和 [global-reference.md](./global-reference.md) 一起使用。 + +## 标准流程 + +1. 原始 `dws` 命令失败后,从 stderr 提取 `RECOVERY_EVENT_ID=` +2. 执行 `dws recovery execute --event-id --format json` +3. 读取返回的 `RecoveryBundle` +4. 先查看 `plan.decision_owner` +5. 当 `decision_owner == "agent"` 时,把完整 `RecoveryBundle` 视为事实包,由 Agent 基于事实包、对应产品文档和 `dws` skill 做整体判断,再决定是否组织下一次 grounded 的 `dws` 恢复尝试 +6. 恢复尝试结束后,执行 `dws recovery finalize --event-id --outcome recovered|failed|handoff --execution-file --format json` + +CLI 会把 recovery 过程文件保存在 `DWS_CONFIG_DIR/recovery/` 下,并自动清理 30 天前的旧文件与旧事件记录。 + +## 如何阅读 `RecoveryBundle` + +`RecoveryBundle` 重点字段: + +- `status`:`needs_agent_action` 或 `analysis_failed` +- `context`:原始失败上下文 +- `replay`:可重放的脱敏命令信息和工具参数 +- `plan`:规则分类、`decision_owner`、`agent_route`、`doc_search`、`kb_hits`、`doc_actions`、`human_actions` +- `doc_search.request`:CLI 实际发起的开放平台文档检索请求参数 +- `doc_search.response`:CLI 收到的原始文档检索返回内容块 +- `probe_results`:CLI 已完成的只读探测和上下文审计结果 +- `agent_task`:给 Agent 的明确工作单 +- `finalize_hint`:最终必须回写的 `finalize` 命令模板和 `execution-file` 要求 + +当 `status == "analysis_failed"` 时,不要假设 CLI 已经修复问题;应先读取 `analysis_error`、`doc_search`、`probe_results`,再判断是否还能继续 grounded 尝试。 + +当 `plan.decision_owner == "agent"` 时: + +- 必须把 CLI 返回内容视为事实包,而不是已定案的恢复结论 +- 优先基于完整 `RecoveryBundle` 做判断;若只能读取 `agent_route.payload`,也必须使用其中的 `context`、`replay`、`doc_search`、`kb_hits`、`doc_actions`、`probe_results` +- unknown 场景里,不要把 `should_retry`、`should_stop`、`human_actions` 当作 CLI 已经替 Agent 作出的最终决定 + +## Agent 允许做什么 + +- 读取 [global-reference.md](./global-reference.md)、[error-codes.md](./error-codes.md) 和对应产品文档 +- 基于完整 `RecoveryBundle`,尤其是 `doc_actions`、`kb_hits`、`probe_results`、`context`、`replay` 做整体判断 +- 仅在依据明确时重新发起新的 `dws` 命令 +- 将真实尝试过程记录进 `execution-file`,再调用 `finalize` + +## Agent 禁止做什么 + +- 编造 taskId、recordId、threadId、UUID、token、URL 或其他业务参数 +- 绕过 `dws` 改用 curl、HTTP API、浏览器或其他未授权手段 +- 未确认前把失败命令替换成另一套业务流程 +- 因为 `human_actions` 里提到用户步骤,就跳过 bundle 里的其余分析信息 + +## `execution-file` 最小要求 + +`execution-file` 必须至少包含: + +- `actions` +- `attempts` +- `result` +- `error_summary` + +`attempts` 是数组,每次尝试至少记录: + +- `command_summary` +- `result` +- `error_summary` +- `source` + +兼容旧格式: + +- `action` 会被归一化成单元素 `actions` +- 数值型 `attempts` 会被展开为 `legacy_execution_file` 来源的 attempts 记录 +- `error` 会被归一化到 `error_summary` + +## unknown 参数错误处理 + +如果 `execute` 进入 unknown 场景,且 `probe_results` 已给出 CLI 检查过的上下文来源,Agent 应: + +- 先检查上一步输出、已有上下文、产品文档或 `probe_results` 里是否存在真实参数来源 +- 若没有可靠来源,就回写 `handoff` +- 不要盲猜新的 ID、UUID、URL、token 或其他业务参数 + +对应标准闭环: + +```bash +dws recovery execute --event-id --format json +dws recovery finalize --event-id --outcome handoff --execution-file execution.json --format json +``` + +## 必读参考 + +- [error-codes.md](./error-codes.md) +- [global-reference.md](./global-reference.md) +- [intent-guide.md](./intent-guide.md) +- [products/](./products/) diff --git a/test/integration/recovery/recovery_test.go b/test/integration/recovery/recovery_test.go new file mode 100644 index 00000000..990ac3aa --- /dev/null +++ b/test/integration/recovery/recovery_test.go @@ -0,0 +1,341 @@ +package recovery_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery" +) + +func TestRecoveryClosedLoopDoesNotRecursivelyCaptureExecuteFailures(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1") + t.Setenv("DWS_TRUSTED_DOMAINS", "*") + + server, calls := newRecoveryRuntimeServer(t) + defer server.Close() + + t.Setenv("DINGTALK_AITABLE_MCP_URL", server.URL+"/server/aitable") + t.Setenv("DINGTALK_DEVDOC_MCP_URL", server.URL+"/server/devdoc") + t.Setenv(cli.CatalogFixtureEnv, writeRecoveryCatalogFixture(t, server.URL)) + + root := app.NewRootCommand() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"aitable", "base", "get", "--base-id", "BASE_MISSING", "-f", "json"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute(aitable base get) error = nil, want captured runtime failure") + } + + store := recovery.NewStore(configDir) + last, err := store.LoadLastError() + if err != nil { + t.Fatalf("LoadLastError() error = %v", err) + } + if last.EventID == "" { + t.Fatal("expected captured event id") + } + if got := strings.Join(last.Context.CommandPath, " "); got != "aitable base get" { + t.Fatalf("captured command path = %q, want \"aitable base get\"", got) + } + + eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl") + beforeExecute, err := os.ReadFile(eventsPath) + if err != nil { + t.Fatalf("ReadFile(recovery_events.jsonl) error = %v", err) + } + if got := countPhase(beforeExecute, "captured"); got != 1 { + t.Fatalf("captured phase count before execute = %d, want 1", got) + } + + root = app.NewRootCommand() + var executeOut bytes.Buffer + root.SetOut(&executeOut) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"recovery", "execute", "--event-id", last.EventID, "-f", "json"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute(recovery execute) error = %v", err) + } + + var bundle recovery.RecoveryBundle + if err := json.Unmarshal(executeOut.Bytes(), &bundle); err != nil { + t.Fatalf("json.Unmarshal(bundle) error = %v\noutput:\n%s", err, executeOut.String()) + } + if bundle.EventID != last.EventID { + t.Fatalf("bundle.EventID = %q, want %q", bundle.EventID, last.EventID) + } + if bundle.Status != "analysis_failed" { + t.Fatalf("bundle.Status = %q, want analysis_failed (doc_search=%#v probes=%#v)", bundle.Status, bundle.DocSearch, bundle.ProbeResults) + } + if bundle.DocSearch.Status != "error" { + t.Fatalf("bundle.DocSearch.Status = %q, want error", bundle.DocSearch.Status) + } + + foundProbe := false + for _, probe := range bundle.ProbeResults { + if probe.Name != "aitable_base_catalog_probe" { + continue + } + foundProbe = true + if probe.Status != "success" { + t.Fatalf("probe.Status = %q, want success", probe.Status) + } + if probe.ToolName != "list_bases" { + t.Fatalf("probe.ToolName = %q, want list_bases", probe.ToolName) + } + } + if !foundProbe { + t.Fatalf("bundle.ProbeResults = %#v, want aitable_base_catalog_probe", bundle.ProbeResults) + } + + afterExecute, err := os.ReadFile(eventsPath) + if err != nil { + t.Fatalf("ReadFile(recovery_events.jsonl after execute) error = %v", err) + } + if got := countPhase(afterExecute, "captured"); got != 1 { + t.Fatalf("captured phase count after execute = %d, want 1", got) + } + if got := countPhase(afterExecute, "analyzed"); got != 1 { + t.Fatalf("analyzed phase count after execute = %d, want 1", got) + } + + executionFile := filepath.Join(configDir, "execution.json") + if err := os.WriteFile(executionFile, []byte(`{"actions":["inspect_bundle","handoff"],"attempts":[{"command_summary":"dws recovery execute --event-id EVT --format json","result":"failed","error_summary":"probe list_bases failed","source":"agent_analysis"}],"result":"handoff","error_summary":"aitable base catalog probe still failing"}`), 0o600); err != nil { + t.Fatalf("WriteFile(execution.json) error = %v", err) + } + + root = app.NewRootCommand() + var finalizeOut bytes.Buffer + root.SetOut(&finalizeOut) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{ + "recovery", "finalize", + "--event-id", last.EventID, + "--outcome", "handoff", + "--execution-file", executionFile, + "-f", "json", + }) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(recovery finalize) error = %v", err) + } + if !strings.Contains(finalizeOut.String(), `"execution_recorded": true`) { + t.Fatalf("finalize output missing execution_recorded:\n%s", finalizeOut.String()) + } + + afterFinalize, err := os.ReadFile(eventsPath) + if err != nil { + t.Fatalf("ReadFile(recovery_events.jsonl after finalize) error = %v", err) + } + if got := countPhase(afterFinalize, "captured"); got != 1 { + t.Fatalf("captured phase count after finalize = %d, want 1", got) + } + if got := countPhase(afterFinalize, "finalized"); got != 1 { + t.Fatalf("finalized phase count after finalize = %d, want 1", got) + } + + if got := calls.getBase.Load(); got != 1 { + t.Fatalf("get_base calls = %d, want 1", got) + } + if got := calls.listBases.Load(); got != 1 { + t.Fatalf("list_bases calls = %d, want 1", got) + } + if got := calls.docSearch.Load(); got < 1 { + t.Fatalf("search_open_platform_docs calls = %d, want at least 1", got) + } +} + +type recoveryRuntimeCalls struct { + getBase atomic.Int32 + listBases atomic.Int32 + docSearch atomic.Int32 +} + +func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeCalls) { + t.Helper() + + calls := &recoveryRuntimeCalls{} + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/server/aitable", func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + switch req["method"] { + case "initialize": + writeJSONRPCResult(t, w, req["id"], map[string]any{ + "protocolVersion": "2025-03-26", + "capabilities": map[string]any{"tools": map[string]any{"listChanged": false}}, + "serverInfo": map[string]any{"name": "aitable", "version": "1.0.0"}, + }) + case "notifications/initialized": + w.WriteHeader(http.StatusNoContent) + case "tools/list": + writeJSONRPCResult(t, w, req["id"], map[string]any{ + "tools": []map[string]any{ + { + "name": "get_base", + "title": "Get Base", + "description": "Get base", + "inputSchema": map[string]any{"type": "object"}, + }, + { + "name": "list_bases", + "title": "List Bases", + "description": "List bases", + "inputSchema": map[string]any{"type": "object"}, + }, + }, + }) + case "tools/call": + params, _ := req["params"].(map[string]any) + name, _ := params["name"].(string) + switch name { + case "get_base": + calls.getBase.Add(1) + writeJSONRPCResult(t, w, req["id"], map[string]any{ + "content": []map[string]any{ + {"type": "text", "text": "unexpected upstream failure while reading base"}, + }, + "isError": true, + }) + case "list_bases": + calls.listBases.Add(1) + writeJSONRPCResult(t, w, req["id"], map[string]any{ + "content": []map[string]any{ + {"type": "text", "text": `{"items":[{"baseId":"BASE_001","name":"项目台账"}]}`}, + }, + }) + default: + t.Fatalf("unexpected aitable tool %q", name) + } + default: + t.Fatalf("unexpected aitable method %q", req["method"]) + } + }) + + mux.HandleFunc("/server/devdoc", func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + switch req["method"] { + case "initialize": + writeJSONRPCResult(t, w, req["id"], map[string]any{ + "protocolVersion": "2025-03-26", + "capabilities": map[string]any{"tools": map[string]any{"listChanged": false}}, + "serverInfo": map[string]any{"name": "devdoc", "version": "1.0.0"}, + }) + case "notifications/initialized": + w.WriteHeader(http.StatusNoContent) + case "tools/list": + writeJSONRPCResult(t, w, req["id"], map[string]any{ + "tools": []map[string]any{ + { + "name": "search_open_platform_docs", + "title": "Search Docs", + "description": "Search docs", + "inputSchema": map[string]any{"type": "object"}, + }, + }, + }) + case "tools/call": + params, _ := req["params"].(map[string]any) + name, _ := params["name"].(string) + if name != "search_open_platform_docs" { + t.Fatalf("unexpected devdoc tool %q", name) + } + calls.docSearch.Add(1) + http.Error(w, "upstream doc search failed", http.StatusBadGateway) + default: + t.Fatalf("unexpected devdoc method %q", req["method"]) + } + }) + + return server, calls +} + +func writeJSONRPCResult(t *testing.T, w http.ResponseWriter, id any, result map[string]any) { + t.Helper() + if err := json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": result, + }); err != nil { + t.Fatalf("json.NewEncoder() error = %v", err) + } +} + +func writeRecoveryCatalogFixture(t *testing.T, baseURL string) string { + t.Helper() + + payload := map[string]any{ + "products": []any{ + map[string]any{ + "id": "aitable", + "display_name": "AI表格", + "server_key": "aitable-fixture", + "endpoint": baseURL + "/server/aitable", + "tools": []any{ + map[string]any{ + "rpc_name": "get_base", + "canonical_path": "aitable.get_base", + }, + map[string]any{ + "rpc_name": "list_bases", + "canonical_path": "aitable.list_bases", + }, + }, + }, + map[string]any{ + "id": "devdoc", + "display_name": "开放平台文档", + "server_key": "devdoc-fixture", + "endpoint": baseURL + "/server/devdoc", + "tools": []any{ + map[string]any{ + "rpc_name": "search_open_platform_docs", + "canonical_path": "devdoc.search_open_platform_docs", + }, + }, + }, + }, + } + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + path := filepath.Join(t.TempDir(), "catalog.json") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("WriteFile(catalog.json) error = %v", err) + } + return path +} + +func countPhase(data []byte, phase string) int { + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + count := 0 + for _, line := range lines { + if strings.Contains(line, `"phase":"`+phase+`"`) { + count++ + } + } + return count +} diff --git a/test/scripts/run_all_tests.sh b/test/scripts/run_all_tests.sh index 03d8f6ad..23743b10 100755 --- a/test/scripts/run_all_tests.sh +++ b/test/scripts/run_all_tests.sh @@ -38,6 +38,7 @@ declare -a SUITES=( "cli|./test/cli/..." "contract|./test/contract/..." "integration/extensions|./test/integration/extensions/..." + "integration/recovery|./test/integration/recovery/..." "mock_mcp|./test/mock_mcp/..." "scripts|./test/scripts/..." )