commit bd53e9ba79a14b382d337b850fa8cc9e90151bd7 Author: weichen Date: Tue Sep 29 13:43:12 2026 +0000 Add a shared error-code catalog so failures can be traced by stable codes. Services can resolve legacy aliases and expose the catalog over CLI and HTTP instead of ad-hoc error strings. Co-authored-by: Cursor diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a084d4c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,20 @@ +name: ci +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + vet-and-test: + runs-on: nightly + steps: + - uses: actions/checkout@v4 + - run: go vet ./... && go test -count=1 ./... + docker-checkout-hostgateway: + runs-on: ubuntu-latest + options: --add-host gitea:host-gateway + env: + GITHUB_SERVER_URL: http://gitea:3000 + steps: + - uses: actions/checkout@v4 + - run: test -f go.mod && echo "docker-mode checkout via host-gateway OK" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31d2457 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.test +/bin/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddaf73e --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Error-Code-System + +跨系统共用的错误码目录、进程内错误类型,以及可独立部署的查询服务。目标是让失败能够按稳定码定位,而不是依赖各服务自己拼接的错误文本。 + +本仓库是该模块的源码位置。Go 模块路径为 `error-code-system`。不依赖 Agent-Native、Agent-Squad 或任何第三方模块。 + +## 分层 + +| 包 | 职责 | 依赖边界 | +|---|---|---| +| errcode | 码格式、定义、注册表、`E` 错误类型 | 只依赖标准库 | +| domains | sys / agent / squad 种子码;Go 为权威,JSON 为导出快照 | 只依赖 errcode | +| httpx | 统一错误信封与 `X-Trace-Id` | 只依赖 errcode | +| server | HTTP API,默认 `:8091` | 组合根之一 | +| cli | `errcode` 命令 | 组合根之一 | +| conformance | 种子目录治理验收(格式、唯一、别名、未知码回落) | 只用公开 API | + +## 码 + +规范码形如 `agent.provider_unavailable`。历史字符串(`provider_unavailable`、`TIMEOUT`、`MISSING_TARGET`)登记为别名,解析后回到规范码。未知码 fail-closed,回落 `sys.unknown_code`(HTTP 500,禁止当成功,禁止无条件重放)。 + +每条码带有 HTTP 状态、严重级别、默认重试策略、副作用(`none` / `possible` / `unknown`)和中文处置建议。副作用语义与 agent-native 契约一致:`possible` 与 `unknown` 不得无条件重放。 + +映射表: + +- [码格式](docs/code-format.md) +- [注册治理](docs/governance.md) +- [agent-native](docs/agent-native-mapping.md) +- [agent-squad](docs/agent-squad-mapping.md) + +## 进程内用法 + +```go +err := errcode.Wrap(cause, errcode.AgentProviderUnavailable, "模型不可达", "model", "glm-4") +def := errcode.Default.Resolve(err.ErrorCode()) // 需先导入 error-code-system/domains +``` + +`E` 实现 `Unwrap`,可用 `errors.Is` 按码匹配,也可 JSON 传到另一个进程。日志系统若看到 `ErrorCode()` / `ErrorFields()` 两个方法,即可把码和字段写入日志,而无需依赖本模块。 + +## HTTP 信封 + +```json +{"error":{"code":"agent.provider_unavailable","message":"模型提供方不可用","reason":"模型不可达","trace_id":"..."}} +``` + +`X-Trace-Id` 由调用方传入时原样回显;缺失或含空白等非法字符时由中间件生成。用这个标识到日志系统检索同一次请求。 + +## HTTP API + +| 方法/路径 | 说明 | +|---|---| +| `GET /v1/health` | 健康检查 | +| `GET /v1/codes?domain=&q=` | 列表;`q` 匹配码、摘要、建议、别名 | +| `GET /v1/codes/{code}` | 规范码或别名;未知返回 `sys.not_found` | +| `POST /v1/validate` | 正文 `{"code":"..."}` 或 `{"codes":[...]}`,未知回落并标 `known:false` | +| `GET /v1/export?domain=` | 导出 JSON | + +## CLI + +```bash +go build -o bin/errcode . +./bin/errcode list --domain agent +./bin/errcode show TIMEOUT +./bin/errcode search 交付物 +./bin/errcode validate provider_unavailable +./bin/errcode export --domain squad +./bin/errcode serve --addr :8091 +``` + +`validate` 在码未登记时退出码为 1。 + +## 自定义域 + +```go +reg := errcode.NewRegistry() +domains.RegisterAll(reg) +if err := reg.LoadJSON(data); err != nil { /* 本批回滚 */ } +``` + +## 开发 + +```bash +go vet ./... +go test -count=1 ./... +``` + +改种子码后重写导出快照: + +```bash +WRITE_CATALOG=1 go test ./domains -run TestWriteCatalog -count=1 +``` + +## 依赖本仓库 + +``` +require error-code-system v0.0.0 + +replace error-code-system => ../Error-Code-System +``` + +远程:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git` diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..d51b01b --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,176 @@ +// Package cli 实现 errcode 命令:list / show / search / validate / export / serve。 +package cli + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" + + "error-code-system/errcode" + "error-code-system/server" +) + +// Main 执行命令行并返回进程退出码。 +func Main(args []string) int { + return Run(args, os.Stdout, os.Stderr) +} + +// Run 执行命令。stdout 写结果,stderr 写用法与错误。 +func Run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, usage) + return 2 + } + switch args[0] { + case "list": + return cmdList(args[1:], stdout, stderr) + case "show": + return cmdShow(args[1:], stdout, stderr) + case "search": + return cmdSearch(args[1:], stdout, stderr) + case "validate": + return cmdValidate(args[1:], stdout, stderr) + case "export": + return cmdExport(args[1:], stdout, stderr) + case "serve": + return cmdServe(args[1:], stderr) + case "-h", "--help", "help": + fmt.Fprintln(stdout, usage) + return 0 + default: + fmt.Fprintf(stderr, "未知命令 %q\n%s\n", args[0], usage) + return 2 + } +} + +const usage = `用法: + errcode list [--domain 域] [--json] + errcode show <码或别名> + errcode search <关键词> + errcode validate <码或别名> + errcode export [--domain 域] + errcode serve [--addr :8091]` + +func registry() *errcode.Registry { return errcode.Default } + +func cmdList(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + fs.SetOutput(stderr) + domain := fs.String("domain", "", "只列出该域") + asJSON := fs.Bool("json", false, "以 JSON 输出") + if err := fs.Parse(args); err != nil { + return 2 + } + defs := registry().List(*domain) + if *asJSON { + enc := json.NewEncoder(stdout) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(defs); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 + } + for _, def := range defs { + fmt.Fprintf(stdout, "%s\t%d\t%s\t%s\n", def.Code, def.HTTP, def.Retry, def.Summary) + } + return 0 +} + +func cmdShow(args []string, stdout, stderr io.Writer) int { + if len(args) != 1 { + fmt.Fprintln(stderr, "用法: errcode show <码或别名>") + return 2 + } + def, ok := registry().Lookup(args[0]) + if !ok { + fmt.Fprintf(stderr, "未登记: %s(回落 %s)\n", args[0], errcode.SysUnknownCode) + return 1 + } + enc := json.NewEncoder(stdout) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(def); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} + +func cmdSearch(args []string, stdout, stderr io.Writer) int { + if len(args) < 1 { + fmt.Fprintln(stderr, "用法: errcode search <关键词>") + return 2 + } + q := strings.Join(args, " ") + for _, def := range registry().Search(q) { + fmt.Fprintf(stdout, "%s\t%s\n", def.Code, def.Summary) + } + return 0 +} + +func cmdValidate(args []string, stdout, stderr io.Writer) int { + if len(args) != 1 { + fmt.Fprintln(stderr, "用法: errcode validate <码或别名>") + return 2 + } + def, ok := registry().Lookup(args[0]) + resolved := def + if !ok { + resolved = registry().Resolve(args[0]) + } + fmt.Fprintf(stdout, "input=%s known=%t resolved=%s\n", args[0], ok, resolved.Code) + if !ok { + return 1 + } + return 0 +} + +func cmdExport(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("export", flag.ContinueOnError) + fs.SetOutput(stderr) + domain := fs.String("domain", "", "只导出该域") + if err := fs.Parse(args); err != nil { + return 2 + } + var data []byte + var err error + if *domain == "" { + data, err = registry().ExportJSON() + } else { + encBuf := &strings.Builder{} + enc := json.NewEncoder(encBuf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + err = enc.Encode(registry().List(*domain)) + data = []byte(encBuf.String()) + } + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + if _, err := stdout.Write(data); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} + +func cmdServe(args []string, stderr io.Writer) int { + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + fs.SetOutput(stderr) + addr := fs.String("addr", server.DefaultAddr, "监听地址") + if err := fs.Parse(args); err != nil { + return 2 + } + fmt.Fprintf(stderr, "errcode serve %s\n", *addr) + if err := server.New(registry()).ListenAndServe(*addr); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..b76696c --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,50 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "error-code-system/domains" + "error-code-system/errcode" +) + +func TestCLIListShowValidate(t *testing.T) { + reg := errcode.NewRegistry() + domains.RegisterAll(reg) + prev := errcode.Default + errcode.Default = reg + t.Cleanup(func() { errcode.Default = prev }) + + var out, errBuf bytes.Buffer + if code := Run([]string{"list", "--domain", "sys"}, &out, &errBuf); code != 0 { + t.Fatal(errBuf.String()) + } + if !strings.Contains(out.String(), "sys.internal") || strings.Contains(out.String(), "agent.") { + t.Fatalf("list:\n%s", out.String()) + } + + out.Reset() + if code := Run([]string{"show", "TIMEOUT"}, &out, &errBuf); code != 0 { + t.Fatal(errBuf.String()) + } + if !strings.Contains(out.String(), string(errcode.AgentTimeout)) { + t.Fatalf("show:\n%s", out.String()) + } + + out.Reset() + if code := Run([]string{"validate", "nope"}, &out, &errBuf); code != 1 { + t.Fatalf("validate exit %d %s", code, out.String()) + } + if !strings.Contains(out.String(), "known=false") || !strings.Contains(out.String(), string(errcode.SysUnknownCode)) { + t.Fatalf("validate:\n%s", out.String()) + } + + out.Reset() + if code := Run([]string{"search", "角色"}, &out, &errBuf); code != 0 { + t.Fatal(errBuf.String()) + } + if !strings.Contains(out.String(), string(errcode.SquadRoleNotFound)) { + t.Fatalf("search:\n%s", out.String()) + } +} diff --git a/conformance/registry_test.go b/conformance/registry_test.go new file mode 100644 index 0000000..8c52871 --- /dev/null +++ b/conformance/registry_test.go @@ -0,0 +1,78 @@ +package conformance + +import ( + "testing" + + "error-code-system/domains" + "error-code-system/errcode" +) + +func TestSeedCatalog(t *testing.T) { + reg := errcode.NewRegistry() + domains.RegisterAll(reg) + + seen := map[errcode.Code]struct{}{} + for _, def := range reg.List("") { + if !def.Code.Valid() { + t.Fatalf("invalid code %s", def.Code) + } + if err := def.Validate(); err != nil { + t.Fatal(err) + } + if _, ok := seen[def.Code]; ok { + t.Fatalf("duplicate %s", def.Code) + } + seen[def.Code] = struct{}{} + if def.SideEffects != errcode.SideEffectNone && def.Retry == errcode.RetryAlways { + t.Fatalf("%s: side_effects=%s forbids retry=always", def.Code, def.SideEffects) + } + for _, alias := range def.Aliases { + got, ok := reg.Lookup(alias) + if !ok || got.Code != def.Code { + t.Fatalf("alias %s -> %+v ok=%v", alias, got.Code, ok) + } + } + } + + legacy := map[string]errcode.Code{ + "canceled": errcode.AgentCanceled, + "deadline_exceeded": errcode.AgentDeadlineExceeded, + "budget_exceeded": errcode.AgentBudgetExceeded, + "provider_unavailable": errcode.AgentProviderUnavailable, + "provider_rejected": errcode.AgentProviderRejected, + "tool_rejected": errcode.AgentToolRejected, + "path_outside_workspace": errcode.AgentPathOutsideWorkspace, + "missing_capability": errcode.AgentMissingCapability, + "missing_deliverable": errcode.AgentMissingDeliverable, + "empty_output": errcode.AgentEmptyOutput, + "unknown_outcome": errcode.AgentUnknownOutcome, + "internal": errcode.AgentInternal, + "PROMPT_EMPTY": errcode.AgentPromptEmpty, + "UNAUTHORIZED": errcode.AgentUnauthorized, + "TIMEOUT": errcode.AgentTimeout, + "CANCELLED": errcode.AgentCanceled, + "TRANSIENT": errcode.AgentTransient, + "MISSING_TARGET": errcode.AgentMissingDeliverable, + "RUN_FAILED": errcode.AgentRunFailed, + } + for alias, want := range legacy { + got, ok := reg.Lookup(alias) + if !ok || got.Code != want { + t.Fatalf("legacy %s => %s, got %+v ok=%v", alias, want, got.Code, ok) + } + } + + unknown := reg.Resolve("not-a-code") + if unknown.Code != errcode.SysUnknownCode || unknown.Retry != errcode.RetryNever || unknown.SideEffects != errcode.SideEffectUnknown { + t.Fatalf("fallback %+v", unknown) + } + if unknown.HTTP != 500 { + t.Fatalf("fallback http %d", unknown.HTTP) + } + + for _, domain := range []string{"sys", "agent", "squad"} { + if len(reg.List(domain)) == 0 { + t.Fatalf("domain %s empty", domain) + } + } +} diff --git a/docs/agent-native-mapping.md b/docs/agent-native-mapping.md new file mode 100644 index 0000000..b3c849d --- /dev/null +++ b/docs/agent-native-mapping.md @@ -0,0 +1,44 @@ +# agent-native 错误码映射 + +本轮不修改 Agent-Native。接入时用下表把现有字符串换成规范码。别名已登记,`Registry.Lookup` / `Resolve` 可直接吃旧值。 + +## contract.ErrorCode + +来源:`Agent-Native/contract/contract.go`。 + +| 现有码 | 规范码 | HTTP | retry | side_effects | 说明 | +|---|---|---|---|---|---| +| canceled | agent.canceled | 499 | never | possible | 取消必须传播;不可靠取消保持 Unknown,不伪报 Canceled | +| deadline_exceeded | agent.deadline_exceeded | 408 | never | possible | 与重试共享 ExecutionID、预算与截止时间 | +| budget_exceeded | agent.budget_exceeded | 429 | never | possible | 预算耗尽不是瞬时故障 | +| provider_unavailable | agent.provider_unavailable | 503 | transient | none | 传输级有界重试归 adapter | +| provider_rejected | agent.provider_rejected | 502 | never | none | 请求被拒绝,修正后再发 | +| tool_rejected | agent.tool_rejected | 403 | never | none | 不得自行扩大工具范围 | +| path_outside_workspace | agent.path_outside_workspace | 403 | never | none | 路径授权不是 OS 沙箱 | +| missing_capability | agent.missing_capability | 400 | never | none | 缺能力即拒绝,不降级 Stub | +| missing_deliverable | agent.missing_deliverable | 422 | transient | possible | 可能已有部分写入 | +| empty_output | agent.empty_output | 422 | never | none | 空输出不是成功 | +| unknown_outcome | agent.unknown_outcome | 500 | never | possible | 禁止降级为 Succeeded 或 Failed | +| internal | agent.internal | 500 | never | unknown | 副作用不明,禁止无条件重放 | + +`contract.Error.Retryable` 仍是实例字段。规范码上的 `retry` 只是默认策略:`Retryable=true` 且 `side_effects=none` 才适合 adapter 有界重试。 + +## pitransport typed-result-v1 + +来源:`Agent-Native/adapters/pitransport/resultv1.go` 的 `allowedErrorCodes`。 + +| 现有码 | 规范码 | 备注 | +|---|---|---| +| PROMPT_EMPTY | agent.prompt_empty | 永久失败,与 `AssessStepFailure` 的 prompt_empty 一致 | +| UNAUTHORIZED | agent.unauthorized | 永久失败 | +| TIMEOUT | agent.timeout | 可重试 | +| CANCELLED | agent.canceled | 并入契约取消码,不另起含义 | +| TRANSIENT | agent.transient | 可重试 | +| MISSING_TARGET | agent.missing_deliverable | 并入契约交付物缺失 | +| RUN_FAILED | agent.run_failed | 永久失败,副作用按 possible | + +`TypedRetryable` 只承认 TIMEOUT / CANCELLED / TRANSIENT。映射后对应 `agent.timeout`、`agent.canceled`、`agent.transient`。其中 `agent.canceled` 的默认策略是 never:实例上的 `retryable` 仍可表达「传输层把取消当成可重试」,调用方必须再看副作用。 + +## exec 失败分类 + +`exec.FailureClass`(none / transient / permanent)不是错误码。步骤评估继续用 `AssessStepFailure`;对外报告时再选上表中的规范码。永久原因(prompt_empty、unauthorized、治理拦截)对应 never,提供方连接类对应 transient。 diff --git a/docs/agent-squad-mapping.md b/docs/agent-squad-mapping.md new file mode 100644 index 0000000..5738b47 --- /dev/null +++ b/docs/agent-squad-mapping.md @@ -0,0 +1,32 @@ +# agent-squad 错误码映射 + +本轮不修改 Agent-Squad。当前 HTTP 层只有 `{"error":"<文本>"}`(`server.writeErr`),没有稳定码,也没有 trace。接入时把下表套进响应信封,正文理由仍放在 `reason`。 + +| 现状 | 规范码 | HTTP | 说明 | +|---|---|---|---| +| `role %s not found` | squad.role_not_found | 404 | `handleDeleteRole` 等 | +| `blueprint %s not found` | squad.blueprint_not_found | 404 | 蓝图删除 | +| Inspect / 黑板找不到运行 | squad.run_not_found | 404 | 磁盘为权威,重启后仍可能存在 | +| `goal.text is required` | squad.goal_required | 400 | 创建运行 | +| 运行处于 HITL 挂起(`RunStatus.IsHeld`) | squad.hitl_pending | 409 | 用 respond 续跑,不要并发再 Run | +| 计划确认 abort / 质检前的计划拒绝 | squad.plan_rejected | 422 | confirmation 挂起点拒绝 | +| 未知 backend,fail-closed | squad.unknown_backend | 400 | 禁止回落 stub | +| 步骤 `EvStepFailed` | squad.step_failed | 500 | 瞬时失败仍受步骤重试上限约束 | +| 质检失败且自动修复预算用尽 | squad.quality_failed | 422 | 可转 quality_review 人工放行 | +| `context cancelled` | squad.cancelled | 499 | 步骤保持 pending,可恢复 | +| JSON 解码失败 | sys.invalid_argument | 400 | 框架域,不单开小队码 | +| 未分类的 500 | sys.internal | 500 | 必须带 trace_id | + +事件名(`step_failed`、`run_failed` 等)保持不变。事件文本里建议追加 `code` 字段,便于和日志系统的 `trace_id` 对齐。脱敏仍沿用现有 `Redact`,日志系统提供同形实现。 + +## 与执行错误的边界 + +步骤失败的根因优先用 agent 域码(例如 `agent.missing_deliverable`),小队只在编排层再包一层 `squad.step_failed`: + +```text +squad.step_failed + └─ agent.missing_deliverable + └─ 原始执行错误 +``` + +这样查 `squad.step_failed` 能看到编排后果,查 `agent.missing_deliverable` 能看到执行根因。 diff --git a/docs/code-format.md b/docs/code-format.md new file mode 100644 index 0000000..7f44822 --- /dev/null +++ b/docs/code-format.md @@ -0,0 +1,34 @@ +# 错误码格式 + +规范码只有一种形式:`domain.mnemonic`。 + +| 部分 | 规则 | 例子 | +|---|---|---| +| domain | 2–32 字符,小写字母开头,仅含小写字母、数字、连字符 | `sys`、`agent`、`squad` | +| mnemonic | 2–64 字符,小写字母开头,仅含小写字母、数字、下划线 | `provider_unavailable` | + +正则:`^[a-z][a-z0-9-]{1,31}\.[a-z][a-z0-9_]{1,63}$` + +## 别名 + +历史码不是第二套规范码。它们登记为别名,解析后必须回到规范码: + +- agent-native `contract.ErrorCode`:`provider_unavailable` → `agent.provider_unavailable` +- pitransport:`PROMPT_EMPTY` → `agent.prompt_empty`,`CANCELLED` → `agent.canceled`,`MISSING_TARGET` → `agent.missing_deliverable` + +别名匹配大小写敏感,格式为 `^[A-Za-z][A-Za-z0-9_-]{1,63}$`。 + +## 元数据 + +每条码必须同时声明: + +| 字段 | 取值 | 用途 | +|---|---|---| +| http | 100–599 | HTTP 接口的默认状态码。取消使用 499(客户端关闭) | +| severity | info / warn / error / fatal | 日志与告警 | +| retry | never / transient / always | 默认重试策略。实例仍可另附是否重试 | +| side_effects | none / possible / unknown | 对齐 agent-native:possible 与 unknown 禁止无条件重放 | +| summary | 非空中文 | 给调用方的稳定摘要 | +| hint | 非空中文 | 处置建议,方便值班定位 | + +未知码 `Resolve` 到 `sys.unknown_code`,HTTP 500,retry=never,side_effects=unknown。不得当成成功。 diff --git a/docs/governance.md b/docs/governance.md new file mode 100644 index 0000000..18784bd --- /dev/null +++ b/docs/governance.md @@ -0,0 +1,36 @@ +# 错误码注册治理 + +Go 定义是唯一权威来源,位于 `domains/`。JSON 文件是导出物,供服务查询、CLI 导出和跨语言消费。 + +## 新增一条码 + +1. 在 `errcode/codes.go` 增加 `Code` 常量。 +2. 在对应域的 `register*` 中登记 HTTP、严重级别、重试、副作用、中文摘要与处置建议。 +3. 若承接旧字符串,把它写成 `Aliases`,不要新造一套平行码。 +4. 运行 `go test ./...`。目录一致性测试会失败,按测试输出重新导出 JSON: + +```bash +WRITE_CATALOG=1 go test ./domains -run TestWriteCatalog -count=1 +``` + +5. 更新 `docs/` 中对应系统的映射表。 + +## 不变量 + +- 规范码全局唯一;别名不得指向两个码,也不得与规范码同形冲突。 +- 已发布的规范码只追加别名或澄清文案,不改变码字符串的含义。含义变化时新增码,旧码保留为别名或标记在映射表中。 +- 副作用为 `possible` 或 `unknown` 的码,默认重试不得是 `always`。 +- 种子数据在进程启动时登记。登记失败直接 panic,避免带着残缺目录对外服务。 + +## 自定义域 + +产品可以不改本仓库,在运行期加载 JSON: + +```go +reg := errcode.NewRegistry() +domains.RegisterAll(reg) +data, _ := os.ReadFile("product.json") +if err := reg.LoadJSON(data); err != nil { /* 本批回滚 */ } +``` + +JSON 可以是数组,或 `{"codes":[...]}`。本批任一记录不合法时,整批回滚。 diff --git a/domains/agent.go b/domains/agent.go new file mode 100644 index 0000000..69e74a4 --- /dev/null +++ b/domains/agent.go @@ -0,0 +1,59 @@ +package domains + +import "error-code-system/errcode" + +// registerAgent 对齐 agent-native contract.ErrorCode(12)与 pitransport 历史码(7)。 +// 副作用与重试语义见 docs/agent-native-mapping.md。 +func registerAgent(r *errcode.Registry) { + must(r, def(errcode.AgentCanceled, 499, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectPossible, + "执行已取消", "确认取消已传播到模型、工具与子进程;不要伪报成功", + "canceled", "CANCELLED")) + must(r, def(errcode.AgentDeadlineExceeded, 408, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible, + "超过执行截止时间", "预算与截止时间在同一 ExecutionID 内共享,不要换 ID 无条件重放", + "deadline_exceeded")) + must(r, def(errcode.AgentBudgetExceeded, 429, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible, + "执行预算耗尽", "提高预算或缩小任务后再发起新的执行", + "budget_exceeded")) + must(r, def(errcode.AgentProviderUnavailable, 503, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectNone, + "模型提供方不可用", "视为传输级瞬时故障,只做有界重试", + "provider_unavailable")) + must(r, def(errcode.AgentProviderRejected, 502, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone, + "模型提供方拒绝请求", "检查模型名、请求体与账号权限,不要当瞬时故障重试", + "provider_rejected")) + must(r, def(errcode.AgentToolRejected, 403, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone, + "工具调用被拒绝", "工具不在允许范围内;不要自行扩大工具策略", + "tool_rejected")) + must(r, def(errcode.AgentPathOutsideWorkspace, 403, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone, + "路径越出授权工作区", "只访问 Workspace.Root 之内的路径", + "path_outside_workspace")) + must(r, def(errcode.AgentMissingCapability, 400, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone, + "后端缺少必需能力", "拒绝执行,不要静默降级到 Stub", + "missing_capability")) + must(r, def(errcode.AgentMissingDeliverable, 422, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible, + "交付物缺失", "确认目标文件未部分写入后再做一次有界重试", + "missing_deliverable", "MISSING_TARGET")) + must(r, def(errcode.AgentEmptyOutput, 422, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone, + "输出为空", "检查提示词与停止原因,不要把空输出当成成功", + "empty_output")) + must(r, def(errcode.AgentUnknownOutcome, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible, + "执行结果不确定", "Unknown 不得降级为成功或失败;由调用方依据副作用决定能否重放", + "unknown_outcome")) + must(r, def(errcode.AgentInternal, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectUnknown, + "执行器内部错误", "保留执行日志与 trace_id,不要对未知副作用无条件重放", + "internal")) + must(r, def(errcode.AgentPromptEmpty, 400, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone, + "提示词为空", "补齐 prompt 后重新发起;对应 pitransport PROMPT_EMPTY", + "PROMPT_EMPTY", "prompt_empty")) + must(r, def(errcode.AgentUnauthorized, 401, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone, + "执行器鉴权失败", "检查 runner 凭证;对应 pitransport UNAUTHORIZED", + "UNAUTHORIZED")) + must(r, def(errcode.AgentTimeout, 504, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible, + "执行超时", "传输级超时可有界重试;对应 pitransport TIMEOUT", + "TIMEOUT")) + must(r, def(errcode.AgentTransient, 503, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible, + "执行瞬时故障", "仅作有界重试;对应 pitransport TRANSIENT", + "TRANSIENT")) + must(r, def(errcode.AgentRunFailed, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible, + "执行运行失败", "查看 error_code 以外的运行日志;对应 pitransport RUN_FAILED", + "RUN_FAILED")) +} diff --git a/domains/agent.json b/domains/agent.json new file mode 100644 index 0000000..83ce3f0 --- /dev/null +++ b/domains/agent.json @@ -0,0 +1,209 @@ +[ + { + "code": "agent.budget_exceeded", + "http": 429, + "severity": "error", + "retry": "never", + "side_effects": "possible", + "summary": "执行预算耗尽", + "hint": "提高预算或缩小任务后再发起新的执行", + "aliases": [ + "budget_exceeded" + ] + }, + { + "code": "agent.canceled", + "http": 499, + "severity": "info", + "retry": "never", + "side_effects": "possible", + "summary": "执行已取消", + "hint": "确认取消已传播到模型、工具与子进程;不要伪报成功", + "aliases": [ + "CANCELLED", + "canceled" + ] + }, + { + "code": "agent.deadline_exceeded", + "http": 408, + "severity": "error", + "retry": "never", + "side_effects": "possible", + "summary": "超过执行截止时间", + "hint": "预算与截止时间在同一 ExecutionID 内共享,不要换 ID 无条件重放", + "aliases": [ + "deadline_exceeded" + ] + }, + { + "code": "agent.empty_output", + "http": 422, + "severity": "error", + "retry": "never", + "side_effects": "none", + "summary": "输出为空", + "hint": "检查提示词与停止原因,不要把空输出当成成功", + "aliases": [ + "empty_output" + ] + }, + { + "code": "agent.internal", + "http": 500, + "severity": "error", + "retry": "never", + "side_effects": "unknown", + "summary": "执行器内部错误", + "hint": "保留执行日志与 trace_id,不要对未知副作用无条件重放", + "aliases": [ + "internal" + ] + }, + { + "code": "agent.missing_capability", + "http": 400, + "severity": "error", + "retry": "never", + "side_effects": "none", + "summary": "后端缺少必需能力", + "hint": "拒绝执行,不要静默降级到 Stub", + "aliases": [ + "missing_capability" + ] + }, + { + "code": "agent.missing_deliverable", + "http": 422, + "severity": "error", + "retry": "transient", + "side_effects": "possible", + "summary": "交付物缺失", + "hint": "确认目标文件未部分写入后再做一次有界重试", + "aliases": [ + "MISSING_TARGET", + "missing_deliverable" + ] + }, + { + "code": "agent.path_outside_workspace", + "http": 403, + "severity": "error", + "retry": "never", + "side_effects": "none", + "summary": "路径越出授权工作区", + "hint": "只访问 Workspace.Root 之内的路径", + "aliases": [ + "path_outside_workspace" + ] + }, + { + "code": "agent.prompt_empty", + "http": 400, + "severity": "warn", + "retry": "never", + "side_effects": "none", + "summary": "提示词为空", + "hint": "补齐 prompt 后重新发起;对应 pitransport PROMPT_EMPTY", + "aliases": [ + "PROMPT_EMPTY", + "prompt_empty" + ] + }, + { + "code": "agent.provider_rejected", + "http": 502, + "severity": "error", + "retry": "never", + "side_effects": "none", + "summary": "模型提供方拒绝请求", + "hint": "检查模型名、请求体与账号权限,不要当瞬时故障重试", + "aliases": [ + "provider_rejected" + ] + }, + { + "code": "agent.provider_unavailable", + "http": 503, + "severity": "error", + "retry": "transient", + "side_effects": "none", + "summary": "模型提供方不可用", + "hint": "视为传输级瞬时故障,只做有界重试", + "aliases": [ + "provider_unavailable" + ] + }, + { + "code": "agent.run_failed", + "http": 500, + "severity": "error", + "retry": "never", + "side_effects": "possible", + "summary": "执行运行失败", + "hint": "查看 error_code 以外的运行日志;对应 pitransport RUN_FAILED", + "aliases": [ + "RUN_FAILED" + ] + }, + { + "code": "agent.timeout", + "http": 504, + "severity": "error", + "retry": "transient", + "side_effects": "possible", + "summary": "执行超时", + "hint": "传输级超时可有界重试;对应 pitransport TIMEOUT", + "aliases": [ + "TIMEOUT" + ] + }, + { + "code": "agent.tool_rejected", + "http": 403, + "severity": "error", + "retry": "never", + "side_effects": "none", + "summary": "工具调用被拒绝", + "hint": "工具不在允许范围内;不要自行扩大工具策略", + "aliases": [ + "tool_rejected" + ] + }, + { + "code": "agent.transient", + "http": 503, + "severity": "error", + "retry": "transient", + "side_effects": "possible", + "summary": "执行瞬时故障", + "hint": "仅作有界重试;对应 pitransport TRANSIENT", + "aliases": [ + "TRANSIENT" + ] + }, + { + "code": "agent.unauthorized", + "http": 401, + "severity": "warn", + "retry": "never", + "side_effects": "none", + "summary": "执行器鉴权失败", + "hint": "检查 runner 凭证;对应 pitransport UNAUTHORIZED", + "aliases": [ + "UNAUTHORIZED" + ] + }, + { + "code": "agent.unknown_outcome", + "http": 500, + "severity": "error", + "retry": "never", + "side_effects": "possible", + "summary": "执行结果不确定", + "hint": "Unknown 不得降级为成功或失败;由调用方依据副作用决定能否重放", + "aliases": [ + "unknown_outcome" + ] + } +] diff --git a/domains/catalog_test.go b/domains/catalog_test.go new file mode 100644 index 0000000..b955dc8 --- /dev/null +++ b/domains/catalog_test.go @@ -0,0 +1,26 @@ +package domains + +import ( + "bytes" + "testing" + + "error-code-system/errcode" +) + +func TestEmbeddedCatalogMatchesRegistry(t *testing.T) { + r := errcode.NewRegistry() + RegisterAll(r) + for _, domain := range []string{"sys", "agent", "squad"} { + got, err := ExportDomain(r, domain) + if err != nil { + t.Fatal(err) + } + want, err := CatalogJSON(domain) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("%s.json drifted from registry\n got:\n%s\nwant:\n%s", domain, got, want) + } + } +} diff --git a/domains/catalog_write_test.go b/domains/catalog_write_test.go new file mode 100644 index 0000000..6675440 --- /dev/null +++ b/domains/catalog_write_test.go @@ -0,0 +1,25 @@ +package domains + +import ( + "os" + "testing" + + "error-code-system/errcode" +) + +func TestWriteCatalog(t *testing.T) { + if os.Getenv("WRITE_CATALOG") != "1" { + t.Skip("set WRITE_CATALOG=1 to regenerate sys.json agent.json squad.json") + } + r := errcode.NewRegistry() + RegisterAll(r) + for _, domain := range []string{"sys", "agent", "squad"} { + data, err := ExportDomain(r, domain) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(domain+".json", data, 0o644); err != nil { + t.Fatal(err) + } + } +} diff --git a/domains/embed.go b/domains/embed.go new file mode 100644 index 0000000..0265178 --- /dev/null +++ b/domains/embed.go @@ -0,0 +1,13 @@ +package domains + +import "embed" + +// 导出的目录快照。改 Go 定义后用 WRITE_CATALOG=1 重写这些文件。 +// +//go:embed sys.json agent.json squad.json +var catalogFS embed.FS + +// CatalogJSON 返回已嵌入的域目录原文。 +func CatalogJSON(domain string) ([]byte, error) { + return catalogFS.ReadFile(domain + ".json") +} diff --git a/domains/export.go b/domains/export.go new file mode 100644 index 0000000..a89fff2 --- /dev/null +++ b/domains/export.go @@ -0,0 +1,35 @@ +package domains + +import ( + "bytes" + "encoding/json" + "fmt" + + "error-code-system/errcode" +) + +// ExportDomain 导出单个域的定义 JSON(字典序)。 +func ExportDomain(r *errcode.Registry, domain string) ([]byte, error) { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(r.List(domain)); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// DomainOf 返回定义切片中的域;混合域或空切片返回错误。 +func DomainOf(defs []errcode.Definition) (string, error) { + if len(defs) == 0 { + return "", fmt.Errorf("domains: empty catalog") + } + domain := defs[0].Code.Domain() + for _, def := range defs[1:] { + if def.Code.Domain() != domain { + return "", fmt.Errorf("domains: mixed domains %s and %s", domain, def.Code.Domain()) + } + } + return domain, nil +} diff --git a/domains/register.go b/domains/register.go new file mode 100644 index 0000000..e208a6a --- /dev/null +++ b/domains/register.go @@ -0,0 +1,33 @@ +// Package domains 把种子错误码登记进注册表。 +// +// Go 定义是权威来源。同目录 JSON 由导出生成,经 go:embed 提供给不编译 Go 的消费方; +// conformance 测试保证二者一致。导入本包会填充 errcode.Default。 +package domains + +import "error-code-system/errcode" + +func init() { RegisterAll(errcode.Default) } + +// RegisterAll 向 r 登记 sys / agent / squad 三域。重复调用会因码冲突失败。 +func RegisterAll(r *errcode.Registry) { + registerSys(r) + registerAgent(r) + registerSquad(r) +} + +func must(r *errcode.Registry, def errcode.Definition) { + r.MustRegister(def) +} + +func def(code errcode.Code, http int, sev errcode.Severity, retry errcode.Retry, side errcode.SideEffects, summary, hint string, aliases ...string) errcode.Definition { + return errcode.Definition{ + Code: code, + HTTP: http, + Severity: sev, + Retry: retry, + SideEffects: side, + Summary: summary, + Hint: hint, + Aliases: aliases, + } +} diff --git a/domains/squad.go b/domains/squad.go new file mode 100644 index 0000000..f8b9a53 --- /dev/null +++ b/domains/squad.go @@ -0,0 +1,26 @@ +package domains + +import "error-code-system/errcode" + +func registerSquad(r *errcode.Registry) { + must(r, def(errcode.SquadRoleNotFound, 404, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone, + "角色不存在", "确认角色键已注册;未知智能体不得静默回落 stub")) + must(r, def(errcode.SquadBlueprintNotFound, 404, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone, + "任务模板不存在", "确认蓝图键,或改走自主规划")) + must(r, def(errcode.SquadRunNotFound, 404, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone, + "运行不存在", "确认 run_id;进程重启后以磁盘状态为准")) + must(r, def(errcode.SquadGoalRequired, 400, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone, + "目标文本为空", "提供 goal.text 后再发起运行")) + must(r, def(errcode.SquadHitlPending, 409, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone, + "运行挂起等待人工", "读取 pending 请求,回应后再续跑;不要并发重复推进")) + must(r, def(errcode.SquadPlanRejected, 422, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone, + "计划被拒绝", "调整目标或编制后重新规划")) + must(r, def(errcode.SquadUnknownBackend, 400, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectNone, + "未知智能体后端", "fail-closed:拒绝执行并留痕,不要回落 stub")) + must(r, def(errcode.SquadStepFailed, 500, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible, + "步骤执行失败", "区分瞬时/永久失败;非关键步骤可降级,关键步骤终止")) + must(r, def(errcode.SquadQualityFailed, 422, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectPossible, + "交付质检未通过", "修复交付物或走人工放行;自动修复预算用尽后不要假装完成")) + must(r, def(errcode.SquadCancelled, 499, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectPossible, + "运行已取消", "取消后的步骤保持可恢复,不要写成成功")) +} diff --git a/domains/squad.json b/domains/squad.json new file mode 100644 index 0000000..6b2c437 --- /dev/null +++ b/domains/squad.json @@ -0,0 +1,92 @@ +[ + { + "code": "squad.blueprint_not_found", + "http": 404, + "severity": "info", + "retry": "never", + "side_effects": "none", + "summary": "任务模板不存在", + "hint": "确认蓝图键,或改走自主规划" + }, + { + "code": "squad.cancelled", + "http": 499, + "severity": "info", + "retry": "never", + "side_effects": "possible", + "summary": "运行已取消", + "hint": "取消后的步骤保持可恢复,不要写成成功" + }, + { + "code": "squad.goal_required", + "http": 400, + "severity": "warn", + "retry": "never", + "side_effects": "none", + "summary": "目标文本为空", + "hint": "提供 goal.text 后再发起运行" + }, + { + "code": "squad.hitl_pending", + "http": 409, + "severity": "info", + "retry": "never", + "side_effects": "none", + "summary": "运行挂起等待人工", + "hint": "读取 pending 请求,回应后再续跑;不要并发重复推进" + }, + { + "code": "squad.plan_rejected", + "http": 422, + "severity": "warn", + "retry": "never", + "side_effects": "none", + "summary": "计划被拒绝", + "hint": "调整目标或编制后重新规划" + }, + { + "code": "squad.quality_failed", + "http": 422, + "severity": "error", + "retry": "never", + "side_effects": "possible", + "summary": "交付质检未通过", + "hint": "修复交付物或走人工放行;自动修复预算用尽后不要假装完成" + }, + { + "code": "squad.role_not_found", + "http": 404, + "severity": "info", + "retry": "never", + "side_effects": "none", + "summary": "角色不存在", + "hint": "确认角色键已注册;未知智能体不得静默回落 stub" + }, + { + "code": "squad.run_not_found", + "http": 404, + "severity": "info", + "retry": "never", + "side_effects": "none", + "summary": "运行不存在", + "hint": "确认 run_id;进程重启后以磁盘状态为准" + }, + { + "code": "squad.step_failed", + "http": 500, + "severity": "error", + "retry": "transient", + "side_effects": "possible", + "summary": "步骤执行失败", + "hint": "区分瞬时/永久失败;非关键步骤可降级,关键步骤终止" + }, + { + "code": "squad.unknown_backend", + "http": 400, + "severity": "error", + "retry": "never", + "side_effects": "none", + "summary": "未知智能体后端", + "hint": "fail-closed:拒绝执行并留痕,不要回落 stub" + } +] diff --git a/domains/sys.go b/domains/sys.go new file mode 100644 index 0000000..6ebe047 --- /dev/null +++ b/domains/sys.go @@ -0,0 +1,26 @@ +package domains + +import "error-code-system/errcode" + +func registerSys(r *errcode.Registry) { + must(r, def(errcode.SysInternal, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectUnknown, + "内部错误", "保留 trace_id,按错误日志定位,不要对调用方暴露内部细节")) + must(r, def(errcode.SysUnknownCode, 500, errcode.SeverityError, errcode.RetryNever, errcode.SideEffectUnknown, + "未登记的错误码", "按失败处理(fail-closed),并把该码补登记后再放行")) + must(r, def(errcode.SysInvalidArgument, 400, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone, + "参数不合法", "检查请求字段与类型,修正后重新提交")) + must(r, def(errcode.SysUnauthorized, 401, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectNone, + "未授权", "检查凭证是否缺失、过期或与资源不匹配")) + must(r, def(errcode.SysNotFound, 404, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectNone, + "资源不存在", "确认标识是否正确,或资源是否已删除")) + must(r, def(errcode.SysConflict, 409, errcode.SeverityWarn, errcode.RetryNever, errcode.SideEffectPossible, + "状态冲突", "刷新当前状态后再决定是否重试,不要盲目重放")) + must(r, def(errcode.SysRateLimited, 429, errcode.SeverityWarn, errcode.RetryTransient, errcode.SideEffectNone, + "触发限流", "按退避重试,不要立即打满重试")) + must(r, def(errcode.SysTimeout, 504, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectPossible, + "操作超时", "先确认是否已产生副作用,再做有界重试")) + must(r, def(errcode.SysCancelled, 499, errcode.SeverityInfo, errcode.RetryNever, errcode.SideEffectPossible, + "操作已取消", "不要把取消当成成功或普通失败;需要时由调用方重新发起")) + must(r, def(errcode.SysUnavailable, 503, errcode.SeverityError, errcode.RetryTransient, errcode.SideEffectNone, + "依赖不可用", "确认依赖健康后退避重试")) +} diff --git a/domains/sys.json b/domains/sys.json new file mode 100644 index 0000000..3747cbb --- /dev/null +++ b/domains/sys.json @@ -0,0 +1,92 @@ +[ + { + "code": "sys.cancelled", + "http": 499, + "severity": "info", + "retry": "never", + "side_effects": "possible", + "summary": "操作已取消", + "hint": "不要把取消当成成功或普通失败;需要时由调用方重新发起" + }, + { + "code": "sys.conflict", + "http": 409, + "severity": "warn", + "retry": "never", + "side_effects": "possible", + "summary": "状态冲突", + "hint": "刷新当前状态后再决定是否重试,不要盲目重放" + }, + { + "code": "sys.internal", + "http": 500, + "severity": "error", + "retry": "never", + "side_effects": "unknown", + "summary": "内部错误", + "hint": "保留 trace_id,按错误日志定位,不要对调用方暴露内部细节" + }, + { + "code": "sys.invalid_argument", + "http": 400, + "severity": "warn", + "retry": "never", + "side_effects": "none", + "summary": "参数不合法", + "hint": "检查请求字段与类型,修正后重新提交" + }, + { + "code": "sys.not_found", + "http": 404, + "severity": "info", + "retry": "never", + "side_effects": "none", + "summary": "资源不存在", + "hint": "确认标识是否正确,或资源是否已删除" + }, + { + "code": "sys.rate_limited", + "http": 429, + "severity": "warn", + "retry": "transient", + "side_effects": "none", + "summary": "触发限流", + "hint": "按退避重试,不要立即打满重试" + }, + { + "code": "sys.timeout", + "http": 504, + "severity": "error", + "retry": "transient", + "side_effects": "possible", + "summary": "操作超时", + "hint": "先确认是否已产生副作用,再做有界重试" + }, + { + "code": "sys.unauthorized", + "http": 401, + "severity": "warn", + "retry": "never", + "side_effects": "none", + "summary": "未授权", + "hint": "检查凭证是否缺失、过期或与资源不匹配" + }, + { + "code": "sys.unavailable", + "http": 503, + "severity": "error", + "retry": "transient", + "side_effects": "none", + "summary": "依赖不可用", + "hint": "确认依赖健康后退避重试" + }, + { + "code": "sys.unknown_code", + "http": 500, + "severity": "error", + "retry": "never", + "side_effects": "unknown", + "summary": "未登记的错误码", + "hint": "按失败处理(fail-closed),并把该码补登记后再放行" + } +] diff --git a/errcode/code.go b/errcode/code.go new file mode 100644 index 0000000..a876eb7 --- /dev/null +++ b/errcode/code.go @@ -0,0 +1,50 @@ +// Package errcode 定义跨系统共用的错误码契约。 +// +// 码格式为「域.助记名」(domain.mnemonic)。本包不依赖 HTTP、日志或任何产品类型: +// 调用方用 Registry 登记定义,用 E 携带码在进程内传递,用 JSON 跨进程传输。 +// 未知码必须经 Registry.Resolve 回落到 sys.unknown_code,不得静默当成成功。 +package errcode + +import ( + "fmt" + "regexp" + "strings" +) + +// 域 2–32 字符,助记名 2–64 字符。域允许连字符,助记名允许下划线。 +var codePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}\.[a-z][a-z0-9_]{1,63}$`) + +// Code 是已规范化的错误码。空字符串不是合法码。 +type Code string + +// Valid 报告码是否符合 domain.mnemonic 格式。 +func (c Code) Valid() bool { return codePattern.MatchString(string(c)) } + +// Domain 返回点号前的域;格式不合法时返回空字符串。 +func (c Code) Domain() string { + d, _, ok := strings.Cut(string(c), ".") + if !ok || !c.Valid() { + return "" + } + return d +} + +// Mnemonic 返回点号后的助记名;格式不合法时返回空字符串。 +func (c Code) Mnemonic() string { + _, m, ok := strings.Cut(string(c), ".") + if !ok || !c.Valid() { + return "" + } + return m +} + +func (c Code) String() string { return string(c) } + +// ParseCode 校验并返回码。 +func ParseCode(s string) (Code, error) { + c := Code(s) + if !c.Valid() { + return "", fmt.Errorf("errcode: invalid code %q", s) + } + return c, nil +} diff --git a/errcode/codes.go b/errcode/codes.go new file mode 100644 index 0000000..9275479 --- /dev/null +++ b/errcode/codes.go @@ -0,0 +1,50 @@ +package errcode + +// 框架域。任何系统都可以直接使用,不表达产品语义。 +const ( + SysInternal Code = "sys.internal" + SysUnknownCode Code = "sys.unknown_code" + SysInvalidArgument Code = "sys.invalid_argument" + SysUnauthorized Code = "sys.unauthorized" + SysNotFound Code = "sys.not_found" + SysConflict Code = "sys.conflict" + SysRateLimited Code = "sys.rate_limited" + SysTimeout Code = "sys.timeout" + SysCancelled Code = "sys.cancelled" + SysUnavailable Code = "sys.unavailable" +) + +// 智能体执行域。对齐 agent-native contract.ErrorCode,并承接 pitransport 历史码。 +const ( + AgentCanceled Code = "agent.canceled" + AgentDeadlineExceeded Code = "agent.deadline_exceeded" + AgentBudgetExceeded Code = "agent.budget_exceeded" + AgentProviderUnavailable Code = "agent.provider_unavailable" + AgentProviderRejected Code = "agent.provider_rejected" + AgentToolRejected Code = "agent.tool_rejected" + AgentPathOutsideWorkspace Code = "agent.path_outside_workspace" + AgentMissingCapability Code = "agent.missing_capability" + AgentMissingDeliverable Code = "agent.missing_deliverable" + AgentEmptyOutput Code = "agent.empty_output" + AgentUnknownOutcome Code = "agent.unknown_outcome" + AgentInternal Code = "agent.internal" + AgentPromptEmpty Code = "agent.prompt_empty" + AgentUnauthorized Code = "agent.unauthorized" + AgentTimeout Code = "agent.timeout" + AgentTransient Code = "agent.transient" + AgentRunFailed Code = "agent.run_failed" +) + +// 小队编排域。对齐 agent-squad 现有失败场景,本轮不改动该仓库。 +const ( + SquadRoleNotFound Code = "squad.role_not_found" + SquadBlueprintNotFound Code = "squad.blueprint_not_found" + SquadRunNotFound Code = "squad.run_not_found" + SquadGoalRequired Code = "squad.goal_required" + SquadHitlPending Code = "squad.hitl_pending" + SquadPlanRejected Code = "squad.plan_rejected" + SquadUnknownBackend Code = "squad.unknown_backend" + SquadStepFailed Code = "squad.step_failed" + SquadQualityFailed Code = "squad.quality_failed" + SquadCancelled Code = "squad.cancelled" +) diff --git a/errcode/definition.go b/errcode/definition.go new file mode 100644 index 0000000..c796f1d --- /dev/null +++ b/errcode/definition.go @@ -0,0 +1,103 @@ +package errcode + +import "fmt" + +// Severity 是错误对调用方的严重级别,供日志与告警使用。 +type Severity string + +const ( + SeverityInfo Severity = "info" + SeverityWarn Severity = "warn" + SeverityError Severity = "error" + SeverityFatal Severity = "fatal" +) + +// Valid 报告严重级别是否为已登记取值。 +func (s Severity) Valid() bool { + switch s { + case SeverityInfo, SeverityWarn, SeverityError, SeverityFatal: + return true + default: + return false + } +} + +// Retry 是该错误码的默认重试策略。实例级是否重试仍可由调用方覆盖。 +type Retry string + +const ( + // RetryNever 禁止当成功路径重放。 + RetryNever Retry = "never" + // RetryTransient 允许有界重试(传输抖动、限流、短暂不可用)。 + RetryTransient Retry = "transient" + // RetryAlways 允许重试;仍须结合 SideEffects,possible 时不得无条件重放。 + RetryAlways Retry = "always" +) + +// Valid 报告重试策略是否为已登记取值。 +func (r Retry) Valid() bool { + switch r { + case RetryNever, RetryTransient, RetryAlways: + return true + default: + return false + } +} + +// SideEffects 描述失败时是否可能已产生外部副作用。 +// 语义对齐 agent-native contract:possible/unknown 禁止无条件重放。 +type SideEffects string + +const ( + SideEffectNone SideEffects = "none" + SideEffectPossible SideEffects = "possible" + SideEffectUnknown SideEffects = "unknown" +) + +// Valid 报告副作用分类是否为已登记取值。 +func (s SideEffects) Valid() bool { + switch s { + case SideEffectNone, SideEffectPossible, SideEffectUnknown: + return true + default: + return false + } +} + +// Definition 是一条错误码的稳定元数据。Go 登记是权威来源,JSON 是导出形式。 +type Definition struct { + Code Code `json:"code"` + HTTP int `json:"http"` + Severity Severity `json:"severity"` + Retry Retry `json:"retry"` + SideEffects SideEffects `json:"side_effects"` + Summary string `json:"summary"` + Hint string `json:"hint"` + Aliases []string `json:"aliases,omitempty"` +} + +// Validate 检查定义是否可登记。 +func (d Definition) Validate() error { + if !d.Code.Valid() { + return fmt.Errorf("errcode: invalid code %q", d.Code) + } + if d.HTTP < 100 || d.HTTP > 599 { + return fmt.Errorf("errcode: %s: http status %d out of range", d.Code, d.HTTP) + } + if !d.Severity.Valid() { + return fmt.Errorf("errcode: %s: invalid severity %q", d.Code, d.Severity) + } + if !d.Retry.Valid() { + return fmt.Errorf("errcode: %s: invalid retry %q", d.Code, d.Retry) + } + if !d.SideEffects.Valid() { + return fmt.Errorf("errcode: %s: invalid side_effects %q", d.Code, d.SideEffects) + } + if d.Summary == "" { + return fmt.Errorf("errcode: %s: summary is required", d.Code) + } + if d.Hint == "" { + return fmt.Errorf("errcode: %s: hint is required", d.Code) + } + return nil +} diff --git a/errcode/errcode_test.go b/errcode/errcode_test.go new file mode 100644 index 0000000..43e6a62 --- /dev/null +++ b/errcode/errcode_test.go @@ -0,0 +1,177 @@ +package errcode + +import ( + "encoding/json" + "errors" + "fmt" + "testing" +) + +func sampleDef(code Code) Definition { + return Definition{ + Code: code, + HTTP: 503, + Severity: SeverityError, + Retry: RetryTransient, + SideEffects: SideEffectNone, + Summary: "提供方不可用", + Hint: "退避后重试", + Aliases: []string{"provider_unavailable"}, + } +} + +func TestCodeFormat(t *testing.T) { + ok := []Code{SysInternal, AgentProviderUnavailable, SquadRoleNotFound, "custom-domain.snake_name"} + for _, c := range ok { + if !c.Valid() { + t.Fatalf("%s should be valid", c) + } + if c.Domain() == "" || c.Mnemonic() == "" { + t.Fatalf("%s domain/mnemonic empty", c) + } + } + bad := []Code{"", "NoDot", "A.b", "sys.", ".internal", "sys.Internal", "1sys.internal", "sys.a"} + for _, c := range bad { + if c.Valid() { + t.Fatalf("%s should be invalid", c) + } + } +} + +func TestRegisterLookupAliasResolve(t *testing.T) { + r := NewRegistry() + def := sampleDef(AgentProviderUnavailable) + if err := r.Register(def); err != nil { + t.Fatal(err) + } + if err := r.Register(def); err == nil { + t.Fatal("duplicate register should fail") + } + got, ok := r.Lookup("provider_unavailable") + if !ok || got.Code != AgentProviderUnavailable { + t.Fatalf("alias lookup: %+v ok=%v", got, ok) + } + if err := r.Alias("TRANSIENT", AgentProviderUnavailable); err != nil { + t.Fatal(err) + } + if _, ok := r.Lookup("TRANSIENT"); !ok { + t.Fatal("added alias missing") + } + unknown := r.Resolve("not.a.real") + if unknown.Code != SysUnknownCode { + t.Fatalf("resolve fallback = %s", unknown.Code) + } + if r.Known("not.a.real") { + t.Fatal("unknown should not be known") + } + if !r.Known(string(AgentProviderUnavailable)) { + t.Fatal("canonical should be known") + } +} + +func TestRegisterRejectsBadAliasAndRollsBack(t *testing.T) { + r := NewRegistry() + def := sampleDef(AgentTimeout) + def.Aliases = []string{"ok_alias", "bad alias"} + if err := r.Register(def); err == nil { + t.Fatal("expected invalid alias") + } + if r.Known(string(AgentTimeout)) || r.Known("ok_alias") { + t.Fatal("failed register must not leave partial state") + } +} + +func TestLoadJSONRollback(t *testing.T) { + r := NewRegistry() + raw := []byte(`[ + {"code":"demo.one","http":400,"severity":"warn","retry":"never","side_effects":"none","summary":"一","hint":"检查"}, + {"code":"demo.one","http":400,"severity":"warn","retry":"never","side_effects":"none","summary":"重复","hint":"检查"} + ]`) + if err := r.LoadJSON(raw); err == nil { + t.Fatal("expected duplicate failure") + } + if len(r.List("")) != 0 { + t.Fatal("batch should roll back") + } + wrapped := []byte(`{"codes":[{"code":"demo.two","http":404,"severity":"info","retry":"never","side_effects":"none","summary":"没有","hint":"确认标识"}]}`) + if err := r.LoadJSON(wrapped); err != nil { + t.Fatal(err) + } + if !r.Known("demo.two") { + t.Fatal("wrapped catalog not loaded") + } +} + +func TestSearchAndExport(t *testing.T) { + r := NewRegistry() + if err := r.Register(sampleDef(AgentProviderUnavailable)); err != nil { + t.Fatal(err) + } + found := r.Search("不可用") + if len(found) != 1 { + t.Fatalf("search %d", len(found)) + } + data, err := r.ExportJSON() + if err != nil { + t.Fatal(err) + } + var defs []Definition + if err := json.Unmarshal(data, &defs); err != nil { + t.Fatal(err) + } + if len(defs) != 1 || defs[0].Code != AgentProviderUnavailable { + t.Fatalf("export %+v", defs) + } +} + +func TestErrorWrapIsAsJSON(t *testing.T) { + root := fmt.Errorf("connection reset") + err := Wrap(root, AgentProviderUnavailable, "模型不可达", "model", "glm-4") + if !errors.Is(err, New(AgentProviderUnavailable, "")) { + t.Fatal("errors.Is should match code") + } + if !errors.Is(err, root) { + t.Fatal("errors.Is should see cause") + } + var got *E + if !errors.As(err, &got) { + t.Fatal("errors.As") + } + if From(fmt.Errorf("wrap: %w", err)).Code != AgentProviderUnavailable { + t.Fatal("From should walk the chain") + } + if err.ErrorCode() != string(AgentProviderUnavailable) { + t.Fatal(err.ErrorCode()) + } + fields := err.ErrorFields() + if fields["model"] != "glm-4" || fields["reason"] != "模型不可达" { + t.Fatalf("fields %+v", fields) + } + fields["model"] = "mutated" + if err.ErrorFields()["model"] != "glm-4" { + t.Fatal("ErrorFields must return a copy") + } + if Wrap(nil, SysInternal, "x") != nil { + t.Fatal("Wrap(nil) must be nil") + } + data, errJSON := json.Marshal(err) + if errJSON != nil { + t.Fatal(errJSON) + } + var back E + if err := json.Unmarshal(data, &back); err != nil { + t.Fatal(err) + } + if back.Code != AgentProviderUnavailable || back.Unwrap() == nil || back.Unwrap().Error() != root.Error() { + t.Fatalf("roundtrip %+v cause=%v", back, back.Unwrap()) + } +} + +func TestPanicOnOddFields(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + New(SysInternal, "x", "only-key") +} diff --git a/errcode/error.go b/errcode/error.go new file mode 100644 index 0000000..27afe2c --- /dev/null +++ b/errcode/error.go @@ -0,0 +1,154 @@ +package errcode + +import ( + "encoding/json" + "errors" + "fmt" +) + +// E 是携带稳定错误码的错误。可用 errors.Is / errors.As 识别,也可 JSON 跨进程传输。 +// 跨进程反序列化后 Cause 只保留文本,不再持有原始 Unwrap 链。 +type E struct { + Code Code `json:"code"` + Reason string `json:"reason,omitempty"` + Fields map[string]any `json:"fields,omitempty"` + cause error +} + +// New 构造一个不包装原因的错误。kv 必须成对出现,键必须是非空字符串; +// 这是调用方编程错误,会 panic(与 log/slog 的字段约定相同)。 +func New(code Code, reason string, kv ...any) *E { + return &E{Code: code, Reason: reason, Fields: fieldsFrom(kv)} +} + +// Wrap 用错误码包装 err。err 为 nil 时返回 nil,以便直接 return。 +func Wrap(err error, code Code, reason string, kv ...any) *E { + if err == nil { + return nil + } + return &E{Code: code, Reason: reason, Fields: fieldsFrom(kv), cause: err} +} + +// Error 实现 error。格式为 code: reason: cause,空段省略。 +func (e *E) Error() string { + if e == nil { + return "" + } + msg := string(e.Code) + if e.Reason != "" { + msg += ": " + e.Reason + } + if e.cause != nil { + msg += ": " + e.cause.Error() + } + return msg +} + +// Unwrap 返回被包装的原因。 +func (e *E) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +// Is 在目标也是 *E 且错误码相同时匹配,供 errors.Is 使用。 +func (e *E) Is(target error) bool { + t, ok := target.(*E) + if !ok || t == nil || e == nil { + return false + } + return e.Code != "" && e.Code == t.Code +} + +// ErrorCode 返回规范码字符串。日志系统用鸭子接口读取,无需 import 本包。 +func (e *E) ErrorCode() string { + if e == nil { + return "" + } + return string(e.Code) +} + +// ErrorFields 返回字段副本(含 reason)。日志系统用鸭子接口读取。 +func (e *E) ErrorFields() map[string]any { + if e == nil { + return nil + } + out := make(map[string]any, len(e.Fields)+1) + for k, v := range e.Fields { + out[k] = v + } + if e.Reason != "" { + out["reason"] = e.Reason + } + return out +} + +// From 沿 Unwrap 链提取最外层 *E。不存在时返回 nil。 +func From(err error) *E { + var e *E + if errors.As(err, &e) { + return e + } + return nil +} + +// MarshalJSON 序列化码、原因、字段与原因文本。 +func (e *E) MarshalJSON() ([]byte, error) { + if e == nil { + return []byte("null"), nil + } + wire := struct { + Code Code `json:"code"` + Reason string `json:"reason,omitempty"` + Fields map[string]any `json:"fields,omitempty"` + Cause string `json:"cause,omitempty"` + }{ + Code: e.Code, + Reason: e.Reason, + Fields: e.Fields, + } + if e.cause != nil { + wire.Cause = e.cause.Error() + } + return json.Marshal(wire) +} + +// UnmarshalJSON 恢复错误。原因文本包装为普通 error,Unwrap 不再指向原对象。 +func (e *E) UnmarshalJSON(data []byte) error { + var wire struct { + Code Code `json:"code"` + Reason string `json:"reason,omitempty"` + Fields map[string]any `json:"fields,omitempty"` + Cause string `json:"cause,omitempty"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + e.Code = wire.Code + e.Reason = wire.Reason + e.Fields = wire.Fields + e.cause = nil + if wire.Cause != "" { + e.cause = errors.New(wire.Cause) + } + return nil +} + +func fieldsFrom(kv []any) map[string]any { + if len(kv) == 0 { + return nil + } + if len(kv)%2 != 0 { + panic("errcode: odd number of key-value arguments") + } + fields := make(map[string]any, len(kv)/2) + for i := 0; i < len(kv); i += 2 { + key, ok := kv[i].(string) + if !ok || key == "" { + panic(fmt.Sprintf("errcode: field key must be a non-empty string, got %T", kv[i])) + } + fields[key] = kv[i+1] + } + return fields +} diff --git a/errcode/registry.go b/errcode/registry.go new file mode 100644 index 0000000..8b70f42 --- /dev/null +++ b/errcode/registry.go @@ -0,0 +1,276 @@ +package errcode + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + "sync" +) + +// 别名用于承接历史码(snake_case 或 UPPER_SNAKE),不是第二套规范码。 +var aliasPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{1,63}$`) + +// Registry 是线程安全的错误码登记表。 +type Registry struct { + mu sync.RWMutex + byCode map[Code]Definition + aliases map[string]Code +} + +// NewRegistry 返回空登记表。种子码由 domains 包登记,测试使用独立实例。 +func NewRegistry() *Registry { + return &Registry{ + byCode: map[Code]Definition{}, + aliases: map[string]Code{}, + } +} + +// Default 是进程内默认登记表。导入 domains 包后会填入种子码。 +var Default = NewRegistry() + +// Register 登记一条定义。码重复、别名冲突或字段不合法时返回错误,不部分写入。 +func (r *Registry) Register(def Definition) error { + if err := def.Validate(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.byCode[def.Code]; ok { + return fmt.Errorf("errcode: duplicate code %s", def.Code) + } + if owner, ok := r.aliases[string(def.Code)]; ok { + return fmt.Errorf("errcode: code %s collides with alias of %s", def.Code, owner) + } + staged := make([]string, 0, len(def.Aliases)) + seen := map[string]struct{}{} + for _, alias := range def.Aliases { + if alias == string(def.Code) { + continue + } + if !aliasPattern.MatchString(alias) { + return fmt.Errorf("errcode: %s: invalid alias %q", def.Code, alias) + } + if _, dup := seen[alias]; dup { + return fmt.Errorf("errcode: %s: duplicate alias %q", def.Code, alias) + } + if _, ok := r.byCode[Code(alias)]; ok { + return fmt.Errorf("errcode: alias %q collides with registered code", alias) + } + if owner, ok := r.aliases[alias]; ok { + return fmt.Errorf("errcode: alias %q already points at %s", alias, owner) + } + seen[alias] = struct{}{} + staged = append(staged, alias) + } + slices.Sort(staged) + def.Aliases = staged + r.byCode[def.Code] = def + for _, alias := range staged { + r.aliases[alias] = def.Code + } + return nil +} + +// MustRegister 登记失败时 panic,仅用于启动期种子数据。 +func (r *Registry) MustRegister(def Definition) { + if err := r.Register(def); err != nil { + panic(err) + } +} + +// Alias 为已登记的码追加一个历史别名。 +func (r *Registry) Alias(alias string, code Code) error { + r.mu.Lock() + defer r.mu.Unlock() + def, ok := r.byCode[code] + if !ok { + return fmt.Errorf("errcode: alias target %s is not registered", code) + } + if alias == string(code) { + return nil + } + if !aliasPattern.MatchString(alias) { + return fmt.Errorf("errcode: invalid alias %q", alias) + } + if _, ok := r.byCode[Code(alias)]; ok { + return fmt.Errorf("errcode: alias %q collides with registered code", alias) + } + if owner, ok := r.aliases[alias]; ok && owner != code { + return fmt.Errorf("errcode: alias %q already points at %s", alias, owner) + } + r.aliases[alias] = code + if !slices.Contains(def.Aliases, alias) { + def.Aliases = append(def.Aliases, alias) + slices.Sort(def.Aliases) + r.byCode[code] = def + } + return nil +} + +// Lookup 按规范码或别名查找。未登记返回 false,不回落。 +func (r *Registry) Lookup(s string) (Definition, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + if def, ok := r.byCode[Code(s)]; ok { + return cloneDef(def), true + } + if code, ok := r.aliases[s]; ok { + def, ok := r.byCode[code] + if !ok { + return Definition{}, false + } + return cloneDef(def), true + } + return Definition{}, false +} + +// Resolve 查找规范码或别名。未知、空串一律回落 sys.unknown_code(fail-closed)。 +// 若回落码本身尚未登记,返回合成定义,避免空表把调用方打崩。 +func (r *Registry) Resolve(s string) Definition { + if def, ok := r.Lookup(s); ok { + return def + } + if s != string(SysUnknownCode) { + if def, ok := r.Lookup(string(SysUnknownCode)); ok { + return def + } + } + return Definition{ + Code: SysUnknownCode, + HTTP: 500, + Severity: SeverityError, + Retry: RetryNever, + SideEffects: SideEffectUnknown, + Summary: "未登记的错误码", + Hint: "按失败处理,并把该码补登记到域注册表", + } +} + +// Known 报告字符串是否为已登记规范码或别名。 +func (r *Registry) Known(s string) bool { + _, ok := r.Lookup(s) + return ok +} + +// List 返回已登记定义。domain 非空时只返回该域。顺序按码字典序。 +func (r *Registry) List(domain string) []Definition { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Definition, 0, len(r.byCode)) + for _, def := range r.byCode { + if domain != "" && def.Code.Domain() != domain { + continue + } + out = append(out, cloneDef(def)) + } + slices.SortFunc(out, func(a, b Definition) int { + return strings.Compare(string(a.Code), string(b.Code)) + }) + return out +} + +// Search 在码、摘要、处置建议与别名中做大小写不敏感子串匹配。 +func (r *Registry) Search(q string) []Definition { + q = strings.ToLower(strings.TrimSpace(q)) + if q == "" { + return r.List("") + } + out := []Definition{} + for _, def := range r.List("") { + if definitionMatches(def, q) { + out = append(out, def) + } + } + return out +} + +func definitionMatches(def Definition, q string) bool { + if strings.Contains(strings.ToLower(string(def.Code)), q) { + return true + } + if strings.Contains(strings.ToLower(def.Summary), q) { + return true + } + if strings.Contains(strings.ToLower(def.Hint), q) { + return true + } + for _, alias := range def.Aliases { + if strings.Contains(strings.ToLower(alias), q) { + return true + } + } + return false +} + +// ExportJSON 导出全部定义(UTF-8、缩进),供跨语言消费与目录对照。 +func (r *Registry) ExportJSON() ([]byte, error) { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(r.List("")); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// LoadJSON 从 JSON 数组或 {"codes":[...]} 登记定义。任一失败则回滚本批。 +func (r *Registry) LoadJSON(data []byte) error { + defs, err := decodeDefinitions(data) + if err != nil { + return err + } + added := make([]Code, 0, len(defs)) + for _, def := range defs { + if err := r.Register(def); err != nil { + r.unregister(added) + return err + } + added = append(added, def.Code) + } + return nil +} + +func decodeDefinitions(data []byte) ([]Definition, error) { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return nil, fmt.Errorf("errcode: empty catalog json") + } + if data[0] == '[' { + var defs []Definition + if err := json.Unmarshal(data, &defs); err != nil { + return nil, fmt.Errorf("errcode: catalog json: %w", err) + } + return defs, nil + } + var wrap struct { + Codes []Definition `json:"codes"` + } + if err := json.Unmarshal(data, &wrap); err != nil { + return nil, fmt.Errorf("errcode: catalog json: %w", err) + } + return wrap.Codes, nil +} + +func (r *Registry) unregister(codes []Code) { + r.mu.Lock() + defer r.mu.Unlock() + drop := map[Code]struct{}{} + for _, code := range codes { + drop[code] = struct{}{} + delete(r.byCode, code) + } + for alias, code := range r.aliases { + if _, ok := drop[code]; ok { + delete(r.aliases, alias) + } + } +} + +func cloneDef(def Definition) Definition { + def.Aliases = slices.Clone(def.Aliases) + return def +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d0a418 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module error-code-system + +go 1.27 diff --git a/httpx/httpx.go b/httpx/httpx.go new file mode 100644 index 0000000..27ba48c --- /dev/null +++ b/httpx/httpx.go @@ -0,0 +1,116 @@ +// Package httpx 把错误码写成统一 HTTP 信封,并透传追踪标识。 +package httpx + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "unicode" + + "error-code-system/errcode" +) + +// TraceHeader 是跨系统传递追踪标识的请求/响应头。 +const TraceHeader = "X-Trace-Id" + +type traceKey struct{} + +// Envelope 是对外错误体。成功响应不使用此结构。 +type Envelope struct { + Error Body `json:"error"` +} + +// Body 是错误对象。code 恒为规范码;未知输入已回落 sys.unknown_code。 +type Body struct { + Code string `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` + TraceID string `json:"trace_id,omitempty"` +} + +// Trace 为每个请求保证一个合法的追踪标识:沿用合法的请求头,否则生成。 +// 标识同时写回响应头与请求上下文。 +func Trace(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := sanitizeTrace(r.Header.Get(TraceHeader)) + if id == "" { + id = newTraceID() + } + w.Header().Set(TraceHeader, id) + ctx := context.WithValue(r.Context(), traceKey{}, id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// TraceID 读取中间件写入的追踪标识,其次读取合法请求头。 +func TraceID(r *http.Request) string { + if r == nil { + return "" + } + if id, ok := r.Context().Value(traceKey{}).(string); ok && id != "" { + return id + } + return sanitizeTrace(r.Header.Get(TraceHeader)) +} + +// WriteError 按注册表把 err 写成 JSON 信封并设置 HTTP 状态码。 +// reg 为空时使用 errcode.Default。非 *errcode.E 视为 sys.internal,原文放入 reason。 +func WriteError(reg *errcode.Registry, w http.ResponseWriter, r *http.Request, err error) { + if reg == nil { + reg = errcode.Default + } + code := errcode.SysInternal + reason := "" + if err != nil { + reason = err.Error() + } + if e := errcode.From(err); e != nil { + code = e.Code + reason = e.Reason + if reason == "" && e.Unwrap() != nil { + reason = e.Unwrap().Error() + } + } + def := reg.Resolve(string(code)) + traceID := TraceID(r) + if traceID != "" { + w.Header().Set(TraceHeader, traceID) + } + writeEnvelope(w, def.HTTP, Envelope{Error: Body{ + Code: string(def.Code), + Message: def.Summary, + Reason: reason, + TraceID: traceID, + }}) +} + +func writeEnvelope(w http.ResponseWriter, status int, env Envelope) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(env) +} + +func newTraceID() string { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return "trace-unavailable" + } + return hex.EncodeToString(buf[:]) +} + +func sanitizeTrace(s string) string { + s = strings.TrimSpace(s) + if len(s) < 8 || len(s) > 128 { + return "" + } + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '.' || r == ':' { + continue + } + return "" + } + return s +} diff --git a/httpx/httpx_test.go b/httpx/httpx_test.go new file mode 100644 index 0000000..dc0cf59 --- /dev/null +++ b/httpx/httpx_test.go @@ -0,0 +1,86 @@ +package httpx + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "error-code-system/domains" + "error-code-system/errcode" +) + +func TestTraceGeneratesAndEchoes(t *testing.T) { + var seen string + h := Trace(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = TraceID(r) + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Header().Get(TraceHeader) == "" || seen != rec.Header().Get(TraceHeader) { + t.Fatalf("generated trace header=%q seen=%q", rec.Header().Get(TraceHeader), seen) + } + + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(TraceHeader, "trace-from-caller") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Header().Get(TraceHeader) != "trace-from-caller" { + t.Fatalf("echo = %s", rec.Header().Get(TraceHeader)) + } + + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(TraceHeader, "bad id") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Header().Get(TraceHeader) == "bad id" || rec.Header().Get(TraceHeader) == "" { + t.Fatal("invalid trace should be replaced") + } +} + +func TestWriteErrorEnvelope(t *testing.T) { + reg := errcode.NewRegistry() + domains.RegisterAll(reg) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(TraceHeader, "trace-abc-1234") + rec := httptest.NewRecorder() + err := errcode.Wrap(errors.New("dial timeout"), errcode.AgentProviderUnavailable, "模型不可达", "model", "glm-4") + WriteError(reg, rec, req, err) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status %d", rec.Code) + } + var env Envelope + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Error.Code != string(errcode.AgentProviderUnavailable) || env.Error.Message == "" { + t.Fatalf("body %+v", env.Error) + } + if env.Error.Reason != "模型不可达" || env.Error.TraceID != "trace-abc-1234" { + t.Fatalf("body %+v", env.Error) + } + if rec.Header().Get(TraceHeader) != "trace-abc-1234" { + t.Fatal("trace header not echoed") + } +} + +func TestWriteErrorUnknownFallsBack(t *testing.T) { + reg := errcode.NewRegistry() + domains.RegisterAll(reg) + rec := httptest.NewRecorder() + WriteError(reg, rec, httptest.NewRequest(http.MethodGet, "/", nil), errcode.New("no.such_code", "x")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var env Envelope + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Error.Code != string(errcode.SysUnknownCode) { + t.Fatalf("code %s", env.Error.Code) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6623276 --- /dev/null +++ b/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" + + "error-code-system/cli" + _ "error-code-system/domains" +) + +func main() { + os.Exit(cli.Main(os.Args[1:])) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..234af6b --- /dev/null +++ b/server/server.go @@ -0,0 +1,147 @@ +// Package server 提供错误码查询与校验 HTTP 服务。默认监听 :8091。 +package server + +import ( + "encoding/json" + "net/http" + "strings" + + "error-code-system/errcode" + "error-code-system/httpx" +) + +// DefaultAddr 是 errcode-serve 的默认监听地址。 +const DefaultAddr = ":8091" + +// Server 对外提供已登记错误码的只读 API。 +type Server struct { + Reg *errcode.Registry +} + +// New 使用给定注册表。nil 表示 errcode.Default(需已导入 domains)。 +func New(reg *errcode.Registry) *Server { return &Server{Reg: reg} } + +// Handler 返回带追踪中间件的路由。 +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/health", s.handleHealth) + mux.HandleFunc("GET /v1/codes", s.handleList) + mux.HandleFunc("GET /v1/codes/{code}", s.handleGet) + mux.HandleFunc("POST /v1/validate", s.handleValidate) + mux.HandleFunc("GET /v1/export", s.handleExport) + return httpx.Trace(mux) +} + +// ListenAndServe 在 addr 上服务。addr 为空时用 DefaultAddr。 +func (s *Server) ListenAndServe(addr string) error { + if strings.TrimSpace(addr) == "" { + addr = DefaultAddr + } + return http.ListenAndServe(addr, s.Handler()) +} + +func (s *Server) reg() *errcode.Registry { + if s != nil && s.Reg != nil { + return s.Reg + } + return errcode.Default +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + defs := s.reg().Search(r.URL.Query().Get("q")) + if domain := r.URL.Query().Get("domain"); domain != "" { + filtered := make([]errcode.Definition, 0, len(defs)) + for _, def := range defs { + if def.Code.Domain() == domain { + filtered = append(filtered, def) + } + } + defs = filtered + } + writeJSON(w, http.StatusOK, defs) +} + +func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + def, ok := s.reg().Lookup(code) + if !ok { + httpx.WriteError(s.reg(), w, r, errcode.New(errcode.SysNotFound, code)) + return + } + writeJSON(w, http.StatusOK, def) +} + +type validateRequest struct { + Code string `json:"code"` + Codes []string `json:"codes"` +} + +type validateResult struct { + Input string `json:"input"` + Known bool `json:"known"` + Resolved string `json:"resolved"` + Definition errcode.Definition `json:"definition"` +} + +func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) { + var req validateRequest + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + if err := dec.Decode(&req); err != nil { + httpx.WriteError(s.reg(), w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求体不是合法 JSON")) + return + } + inputs := req.Codes + if req.Code != "" { + inputs = append([]string{req.Code}, inputs...) + } + if len(inputs) == 0 { + httpx.WriteError(s.reg(), w, r, errcode.New(errcode.SysInvalidArgument, "code 或 codes 必填")) + return + } + results := make([]validateResult, 0, len(inputs)) + for _, input := range inputs { + def, ok := s.reg().Lookup(input) + resolved := def + if !ok { + resolved = s.reg().Resolve(input) + } + results = append(results, validateResult{ + Input: input, + Known: ok, + Resolved: string(resolved.Code), + Definition: resolved, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"results": results}) +} + +func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { + data, err := s.reg().ExportJSON() + if err != nil { + httpx.WriteError(s.reg(), w, r, errcode.Wrap(err, errcode.SysInternal, "导出失败")) + return + } + if domain := r.URL.Query().Get("domain"); domain != "" { + raw, err := json.Marshal(s.reg().List(domain)) + if err != nil { + httpx.WriteError(s.reg(), w, r, errcode.Wrap(err, errcode.SysInternal, "导出失败")) + return + } + data = append(raw, '\n') + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..6ee6ceb --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,106 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "error-code-system/domains" + "error-code-system/errcode" + "error-code-system/httpx" +) + +func testServer(t *testing.T) *httptest.Server { + t.Helper() + reg := errcode.NewRegistry() + domains.RegisterAll(reg) + return httptest.NewServer(New(reg).Handler()) +} + +func TestHealthListGetValidate(t *testing.T) { + srv := testServer(t) + defer srv.Close() + + res, err := http.Get(srv.URL + "/v1/health") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK || res.Header.Get(httpx.TraceHeader) == "" { + t.Fatalf("health %d trace %q", res.StatusCode, res.Header.Get(httpx.TraceHeader)) + } + + res, err = http.Get(srv.URL + "/v1/codes?domain=agent&q=provider") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var defs []errcode.Definition + if err := json.NewDecoder(res.Body).Decode(&defs); err != nil { + t.Fatal(err) + } + if len(defs) == 0 { + t.Fatal("expected agent provider codes") + } + for _, def := range defs { + if def.Code.Domain() != "agent" || !strings.Contains(string(def.Code), "provider") && !strings.Contains(def.Summary, "提供") { + t.Fatalf("unexpected hit %+v", def.Code) + } + } + + res, err = http.Get(srv.URL + "/v1/codes/provider_unavailable") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var def errcode.Definition + if err := json.NewDecoder(res.Body).Decode(&def); err != nil { + t.Fatal(err) + } + if def.Code != errcode.AgentProviderUnavailable { + t.Fatalf("alias get resolved to %s", def.Code) + } + + res, err = http.Get(srv.URL + "/v1/codes/missing.code") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(res.Body) + t.Fatalf("missing status %d body %s", res.StatusCode, body) + } + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/validate", strings.NewReader(`{"codes":["TIMEOUT","nope"]}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set(httpx.TraceHeader, "trace-server-1") + res, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var payload struct { + Results []struct { + Input string `json:"input"` + Known bool `json:"known"` + Resolved string `json:"resolved"` + } `json:"results"` + } + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if len(payload.Results) != 2 || !payload.Results[0].Known || payload.Results[0].Resolved != string(errcode.AgentTimeout) { + t.Fatalf("validate %+v", payload.Results) + } + if payload.Results[1].Known || payload.Results[1].Resolved != string(errcode.SysUnknownCode) { + t.Fatalf("unknown %+v", payload.Results[1]) + } + if res.Header.Get(httpx.TraceHeader) != "trace-server-1" { + t.Fatal("trace not echoed") + } +}