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 @@
-
+
[中文版](./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
-
+
[中文版](./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/..."
)