Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
33 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "cai-tang-www/goai", "files": {"config/config_test.go": {"a29b39c7dd2c161a16727ea80c95e72d5be6d880": {"t": 1786068374.0848744, "n": 14656, "h": "21dee0a1275a04b8", "c": "package config\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\n// TestValidateStartupSuccess 验证完整关键配置可以通过启动期校验。\nfunc TestValidateStartupSuccess(t *testing.T) {\n\tcfg := &Config{\n\t\tMySQLHost: \"localhost\",\n\t\tMySQLPort: 3306,\n\t\tMySQLUser: \"root\",\n\t\tMySQLDatabase: \"goai\",\n\t\tRedisHost: \"localhost\",\n\t\tRedisPort: 6379,\n\t\tServerPort: \"8080\",\n\t\tServerShutdownTimeout: 15 * time.Second,\n\t\tRunResumeLeaseDuration: 30 * time.Second,\n\t\tRunResumeHeartbeatInterval: 10 * time.Second,\n\t\tRunRecoveryScanInterval: 5 * time.Second,\n\t\tRunRecoveryBatchSize: 100,\n\t\tKafkaBootstrapServers: \"localhost:9092\",\n\t\tKafkaRunTopic: \"run_execute\",\n\t\tKafkaRunGroupID: \"run-worker-group\",\n\t\tJWTSecret: \"test-secret\",\n\t}\n\tif err := cfg.ValidateStartup(); err != nil {\n\t\tt.Fatalf(\"expected config valid, got %v\", err)\n\t}\n}\n\n// TestValidateStartupDefaultsResumeQueue 验证直接构造配置时也会补齐恢复队列默认值。\nfunc TestValidateStartupDefaultsResumeQueue(t *testing.T) {\n\tcfg := validStartupConfig()\n\tcfg.KafkaRunResumeTopic = \"\"\n\tcfg.KafkaRunResumeGroupID = \"\"\n\n\tif err := cfg.ValidateStartup(); err != nil {\n\t\tt.Fatalf(\"expected config valid, got %v\", err)\n\t}\n\tif cfg.KafkaRunResumeTopic != \"run_resume\" {\n\t\tt.Fatalf(\"expected default resume topic, got %q\", cfg.KafkaRunResumeTopic)\n\t}\n\tif cfg.KafkaRunResumeGroupID != \"run-resume-worker-group\" {\n\t\tt.Fatalf(\"expected default resume group, got %q\", cfg.KafkaRunResumeGroupID)\n\t}\n}\n\n// TestValidateStartupValidatesA2ACallbackURL 验证 callback 地址只允许 loopback HTTP 或远程 HTTPS。\nfunc TestValidateStartupValidatesA2ACallbackURL(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\trawURL string\n\t\twantErr string\n\t}{\n\t\t{name: \"loopback IPv4 HTTP\", rawURL: \"http://127.0.0.1:8080\"},\n\t\t{name: \"loopback IPv6 HTTP\", rawURL: \"http://[::1]:8080\"},\n\t\t{name: \"localhost HTTP\", rawURL: \"http://localhost:8080\"},\n\t\t{name: \"remote HTTPS\", rawURL: \"https://agents.example.com\"},\n\t\t{name: \"remote HTTP\", rawURL: \"http://agents.example.com\", wantErr: \"remote callback URL must use HTTPS\"},\n\t\t{name: \"relative URL\", rawURL: \"/a2a/callback\", wantErr: \"must be an absolute URL\"},\n\t\t{name: \"user info\", rawURL: \"https://user:password@agents.example.com\", wantErr: \"must not contain user info\"},\n\t\t{name: \"query\", rawURL: \"https://agents.example.com?token=secret\", wantErr: \"query and fragment are not allowed\"},\n\t\t{name: \"fragment\", rawURL: \"https://agents.example.com#callback\", wantErr: \"query and fragment are not allowed\"},\n\t\t{name: \"unsupported scheme\", rawURL: \"ftp://agents.example.com\", wantErr: \"unsupported scheme\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := validStartupConfig()\n\t\t\tcfg.A2ACallbackBaseURL = tt.rawURL\n\n\t\t\terr := cfg.ValidateStartup()\n\t\t\tif tt.wantErr == \"\" {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"expected URL valid, got %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err == nil || !strings.Contains(err.Error(), tt.wantErr) {\n\t\t\t\tt.Fatalf(\"expected error containing %q, got %v\", tt.wantErr, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestValidateStartupReportsMissingCriticalFields 验证缺失关键配置时会返回可读错误。\nfunc TestValidateStartupReportsMissingCriticalFields(t *testing.T) {\n\tcfg := &Config{}\n\terr := cfg.ValidateStartup()\n\tif err == nil {\n\t\tt.Fatal(\"expected validation error, got nil\")\n\t}\n\tmessage := err.Error()\n\tfor _, want := range []string{\"MYSQL_DATABASE\", \"REDIS_PORT\", \"SERVER_SHUTDOWN_TIMEOUT_SECONDS\", \"KAFKA_BOOTSTRAP_SERVERS\", \"JWT_SECRET\"} {\n\t\tif !strings.Contains(message, want) {\n\t\t\tt.Fatalf(\"expected error message contains %s, got %s\", want, message)\n\t\t}\n\t}\n}\n\n// TestValidateStartupRequiresDefaultProviderProfile 验证启用 provider 配置时必须存在完整默认 profile。\nfunc TestValidateStartupRequiresDefaultProviderProfile(t *testing.T) {\n\tcfg := &Config{\n\t\tMySQLHost: \"localhost\",\n\t\tMySQLPort: 3306,\n\t\tMySQLUser: \"root\",\n\t\tMySQLDatabase: \"goai\",\n\t\tRedisHost: \"localhost\",\n\t\tRedisPort: 6379,\n\t\tServerPort: \"8080\",\n\t\tServerShutdownTimeout: 15 * time.Second,\n\t\tRunResumeLeaseDuration: 30 * time.Second,\n\t\tRunResumeHeartbeatInterval: 10 * time.Second,\n\t\tRunRecoveryScanInterval: 5 * time.Second,\n\t\tRunRecoveryBatchSize: 100,\n\t\tKafkaBootstrapServers: \"localhost:9092\",\n\t\tKafkaRunTopic: \"run_execute\",\n\t\tKafkaRunGroupID: \"run-worker-group\",\n\t\tJWTSecret: \"test-secret\",\n\t\tModelProviderDefault: \"deepseek\",\n\t\tModelProviders: map[string]ModelProviderConfig{\n\t\t\t\"deepseek\": {\n\t\t\t\tDriver: \"openai_compatible\",\n\t\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tDefaultModel: \"deepseek-chat\",\n\t\t\t},\n\t\t},\n\t}\n\terr := cfg.ValidateStartup()\n\tif err == nil {\n\t\tt.Fatal(\"expected provider validation error, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"MODEL_API_KEY\") {\n\t\tt.Fatalf(\"expected provider api key error, got %v\", err)\n\t}\n}\n\n// TestLoadConfigRejectsInvalidPortEnv 验证端口环境变量非法时会在加载阶段直接失败。\nfunc TestLoadConfigRejectsInvalidPortEnv(t *testing.T) {\n\tt.Setenv(\"MYSQL_HOST\", \"localhost\")\n\tt.Setenv(\"MYSQL_PORT\", \"3306\")\n\tt.Setenv(\"MYSQL_USER\", \"root\")\n\tt.Setenv(\"MYSQL_DATABASE\", \"goai\")\n\tt.Setenv(\"REDIS_HOST\", \"localhost\")\n\tt.Setenv(\"REDIS_PORT\", \"bad-port\")\n\tt.Setenv(\"SERVER_PORT\", \"8080\")\n\tt.Setenv(\"KAFKA_BOOTSTRAP_SERVERS\", \"localhost:9092\")\n\tt.Setenv(\"KAFKA_RUN_TOPIC\", \"run_execute\")\n\tt.Setenv(\"KAFKA_RUN_GROUP_ID\", \"run-worker-group\")\n\tt.Setenv(\"JWT_SECRET\", \"test-secret\")\n\tt.Setenv(\"MODEL_PROVIDER_DEFAULT\", \"\")\n\n\terr := LoadConfig()\n\tif err == nil {\n\t\tt.Fatal(\"expected load config error, got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"REDIS_PORT\") {\n\t\tt.Fatalf(\"expected REDIS_PORT error, got %v\", err)\n\t}\n}\n\n// TestValidateStartupRejectsNonPositiveShutdownTimeout 验证关闭超时必须为正数。\nfunc TestValidateStartupRejectsNonPositiveShutdownTimeout(t *testing.T) {\n\tfor _, timeout := range []time.Duration{0, -time.Second} {\n\t\tcfg := &Config{\n\t\t\tMySQLHost: \"localhost\",\n\t\t\tMySQLPort: 3306,\n\t\t\tMySQLUser: \"root\",\n\t\t\tMySQLDatabase: \"goai\",\n\t\t\tRedisHost: \"localhost\",\n\t\t\tRedisPort: 6379,\n\t\t\tServerPort: \"8080\",\n\t\t\tServerShutdownTimeout: timeout,\n\t\t\tRunResumeLeaseDuration: 30 * time.Second,\n\t\t\tRunResumeHeartbeatInterval: 10 * time.Second,\n\t\t\tRunRecoveryScanInterval: 5 * time.Second,\n\t\t\tRunRecoveryBatchSize: 100,\n\t\t\tKafkaBootstrapServers: \"localhost:9092\",\n\t\t\tKafkaRunTopic: \"run_execute\",\n\t\t\tKafkaRunGroupID: \"run-worker-group\",\n\t\t\tJWTSecret: \"test-secret\",\n\t\t}\n\t\terr := cfg.ValidateStartup()\n\t\tif err == nil || !strings.Contains(err.Error(), \"SERVER_SHUTDOWN_TIMEOUT_SECONDS\") {\n\t\t\tt.Fatalf(\"timeout %s: expected shutdown timeout validation error, got %v\", timeout, err)\n\t\t}\n\t}\n}\n\n// TestLoadConfigDefaultsShutdownTimeout 验证未配置关闭超时时采用 15 秒默认值。\nfunc TestLoadConfigDefaultsShutdownTimeout(t *testing.T) {\n\tt.Setenv(\"MYSQL_HOST\", \"localhost\")\n\tt.Setenv(\"MYSQL_PORT\", \"3306\")\n\tt.Setenv(\"MYSQL_USER\", \"root\")\n\tt.Setenv(\"MYSQL_DATABASE\", \"goai\")\n\tt.Setenv(\"REDIS_HOST\", \"localhost\")\n\tt.Setenv(\"REDIS_PORT\", \"6379\")\n\tt.Setenv(\"SERVER_PORT\", \"8080\")\n\tt.Setenv(\"SERVER_SHUTDOWN_TIMEOUT_SECONDS\", \"\")\n\tt.Setenv(\"KAFKA_BOOTSTRAP_SERVERS\", \"localhost:9092\")\n\tt.Setenv(\"KAFKA_RUN_TOPIC\", \"run_execute\")\n\tt.Setenv(\"KAFKA_RUN_GROUP_ID\", \"run-worker-group\")\n\tt.Setenv(\"JWT_SECRET\", \"test-secret\")\n\tt.Setenv(\"MODEL_PROVIDER_DEFAULT\", \"\")\n\n\tif err := LoadConfig(); err != nil {\n\t\tt.Fatalf(\"load config failed: %v\", err)\n\t}\n\tif AppConfig.ServerShutdownTimeout != 15*time.Second {\n\t\tt.Fatalf(\"expected 15s shutdown timeout, got %s\", AppConfig.ServerShutdownTimeout)\n\t}\n\tif AppConfig.RunResumeLeaseDuration != 30*time.Second || AppConfig.RunResumeHeartbeatInterval != 10*time.Second {\n\t\tt.Fatalf(\"unexpected resume lease defaults: lease=%s heartbeat=%s\", AppConfig.RunResumeLeaseDuration, AppConfig.RunResumeHeartbeatInterval)\n\t}\n\tif AppConfig.RunRecoveryScanInterval != 5*time.Second || AppConfig.RunRecoveryBatchSize != 100 {\n\t\tt.Fatalf(\"unexpected recovery defaults: interval=%s batch=%d\", AppConfig.RunRecoveryScanInterval, AppConfig.RunRecoveryBatchSize)\n\t}\n}\n\n// TestValidateStartupRejectsInvalidResumeRecoveryConfig 验证恢复租约与扫描参数的边界关系。\nfunc TestValidateStartupRejectsInvalidResumeRecoveryConfig(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmutate func(*Config)\n\t\twantErr string\n\t}{\n\t\t{name: \"non-positive lease\", mutate: func(cfg *Config) { cfg.RunResumeLeaseDuration = 0 }, wantErr: \"RUN_RESUME_LEASE_SECONDS\"},\n\t\t{name: \"non-positive heartbeat\", mutate: func(cfg *Config) { cfg.RunResumeHeartbeatInterval = 0 }, wantErr: \"RUN_RESUME_HEARTBEAT_SECONDS\"},\n\t\t{name: \"heartbeat equals lease\", mutate: func(cfg *Config) { cfg.RunResumeHeartbeatInterval = cfg.RunResumeLeaseDuration }, wantErr: \"must be less than RUN_RESUME_LEASE_SECONDS\"},\n\t\t{name: \"non-positive scan interval\", mutate: func(cfg *Config) { cfg.RunRecoveryScanInterval = 0 }, wantErr: \"RUN_RECOVERY_SCAN_INTERVAL_SECONDS\"},\n\t\t{name: \"non-positive batch size\", mutate: func(cfg *Config) { cfg.RunRecoveryBatchSize = 0 }, wantErr: \"RUN_RECOVERY_BATCH_SIZE\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := validStartupConfig()\n\t\t\ttt.mutate(cfg)\n\t\t\terr := cfg.ValidateStartup()\n\t\t\tif err == nil || !strings.Contains(err.Error(), tt.wantErr) {\n\t\t\t\tt.Fatalf(\"expected error containing %q, got %v\", tt.wantErr, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestValidateStartupRejectsInvalidGovernanceConfig 验证启用服务治理时关键参数必须为正数。\nfunc TestValidateStartupRejectsInvalidGovernanceConfig(t *testing.T) {\n\tcfg := &Config{\n\t\tMySQLHost: \"localhost\",\n\t\tMySQLPort: 3306,\n\t\tMySQLUser: \"root\",\n\t\tMySQLDatabase: \"goai\",\n\t\tRedisHost: \"localhost\",\n\t\tRedisPort: 6379,\n\t\tServerPort: \"8080\",\n\t\tServerShutdownTimeout: 15 * time.Second,\n\t\tKafkaBootstrapServers: \"localhost:9092\",\n\t\tKafkaRunTopic: \"run_execute\",\n\t\tKafkaRunGroupID: \"run-worker-group\",\n\t\tJWTSecret: \"test-secret\",\n\t\tServiceGovernanceEnabled: true,\n\t\tRateLimitRequestsPerSecond: 0,\n\t\tRateLimitBurst: 40,\n\t\tRateLimitMaxKeys: 100,\n\t\tDownstreamRequestTimeout: 30 * time.Second,\n\t\tCircuitFailureThreshold: 3,\n\t\tCircuitOpenTimeout: 10 * time.Second,\n\t}\n\tif err := cfg.ValidateStartup(); err == nil || !strings.Contains(err.Error(), \"RATE_LIMIT_REQUESTS_PER_SECOND\") {\n\t\tt.Fatalf(\"expected governance validation error, got %v\", err)\n\t}\n}\n\n// TestValidateStartupSkipsGovernanceWhenDisabled 验证关闭治理时不强制要求治理参数。\nfunc TestValidateStartupSkipsGovernanceWhenDisabled(t *testing.T) {\n\tcfg := &Config{\n\t\tMySQLHost: \"localhost\",\n\t\tMySQLPort: 3306,\n\t\tMySQLUser: \"root\",\n\t\tMySQLDatabase: \"goai\",\n\t\tRedisHost: \"localhost\",\n\t\tRedisPort: 6379,\n\t\tServerPort: \"8080\",\n\t\tServerShutdownTimeout: 15 * time.Second,\n\t\tRunResumeLeaseDuration: 30 * time.Second,\n\t\tRunResumeHeartbeatInterval: 10 * time.Second,\n\t\tRunRecoveryScanInterval: 5 * time.Second,\n\t\tRunRecoveryBatchSize: 100,\n\t\tKafkaBootstrapServers: \"localhost:9092\",\n\t\tKafkaRunTopic: \"run_execute\",\n\t\tKafkaRunGroupID: \"run-worker-group\",\n\t\tJWTSecret: \"test-secret\",\n\t}\n\tif err := cfg.ValidateStartup(); err != nil {\n\t\tt.Fatalf(\"disabled governance should pass without governance settings: %v\", err)\n\t}\n}\n\nfunc TestLoadFloatEnvRejectsNonFiniteValues(t *testing.T) {\n\tfor _, value := range []string{\"NaN\", \"+Inf\", \"-Inf\"} {\n\t\tt.Setenv(\"GOAI_TEST_FLOAT\", value)\n\t\tif _, err := loadFloatEnv(\"GOAI_TEST_FLOAT\", 1); err == nil {\n\t\t\tt.Fatalf(\"value %q should be rejected\", value)\n\t\t}\n\t}\n}\n\nfunc TestParseStrictBoolEnvRejectsUnknownValue(t *testing.T) {\n\tif _, err := parseStrictBoolEnv(\"SERVICE_GOVERNANCE_ENABLE\", \"maybe\"); err == nil {\n\t\tt.Fatal(\"unknown boolean value should be rejected\")\n\t}\n}\n\nfunc TestParseScopesStrictRejectsUnknownScope(t *testing.T) {\n\tif _, err := parseScopesStrict(\"api,unknown\"); err == nil {\n\t\tt.Fatal(\"unknown governance scope should be rejected\")\n\t}\n}\n\nfunc TestLoadConfigParsesA2AAuthentication(t *testing.T) {\n\tt.Setenv(\"MYSQL_HOST\", \"localhost\")\n\tt.Setenv(\"MYSQL_USER\", \"root\")\n\tt.Setenv(\"MYSQL_DATABASE\", \"goai\")\n\tt.Setenv(\"REDIS_HOST\", \"localhost\")\n\tt.Setenv(\"SERVER_PORT\", \"8080\")\n\tt.Setenv(\"KAFKA_BOOTSTRAP_SERVERS\", \"localhost:9092\")\n\tt.Setenv(\"KAFKA_RUN_TOPIC\", \"run_execute\")\n\tt.Setenv(\"KAFKA_RUN_GROUP_ID\", \"run-worker-group\")\n\tt.Setenv(\"JWT_SECRET\", \"test-secret\")\n\tt.Setenv(\"MODEL_PROVIDER_DEFAULT\", \"\")\n\tt.Setenv(\"A2A_AUTH_REQUIRED\", \"true\")\n\tt.Setenv(\"A2A_AUTH_MAX_CLOCK_SKEW_SECONDS\", \"120\")\n\tt.Setenv(\"A2A_AUTH_CREDENTIALS_JSON\", `{\"planner-key\":\"test-only-a2a-secret-at-least-32-bytes-long\"}`)\n\tif err := LoadConfig(); err != nil {\n\t\tt.Fatalf(\"load config failed: %v\", err)\n\t}\n\tif !AppConfig.A2AAuthRequired || AppConfig.A2AAuthMaxClockSkew != 2*time.Minute || len(AppConfig.A2ACredentials) != 1 {\n\t\tt.Fatalf(\"unexpected A2A auth config: required=%v skew=%s credentials=%d\", AppConfig.A2AAuthRequired, AppConfig.A2AAuthMaxClockSkew, len(AppConfig.A2ACredentials))\n\t}\n}\n\nfunc TestLoadConfigRejectsMalformedA2ACredentialsWithoutLeakingValue(t *testing.T) {\n\tt.Setenv(\"A2A_AUTH_CREDENTIALS_JSON\", `{\"secret\":\"sensitive-value\"`)\n\tif err := LoadConfig(); err == nil || !strings.Contains(err.Error(), \"A2A_AUTH_CREDENTIALS_JSON\") || strings.Contains(err.Error(), \"sensitive-value\") {\n\t\tt.Fatalf(\"unexpected credentials parse error: %v\", err)\n\t}\n}\n\nfunc validStartupConfig() *Config {\n\treturn &Config{\n\t\tMySQLHost: \"localhost\",\n\t\tMySQLPort: 3306,\n\t\tMySQLUser: \"root\",\n\t\tMySQLDatabase: \"goai\",\n\t\tRedisHost: \"localhost\",\n\t\tRedisPort: 6379,\n\t\tServerPort: \"8080\",\n\t\tServerShutdownTimeout: 15 * time.Second,\n\t\tRunResumeLeaseDuration: 30 * time.Second,\n\t\tRunResumeHeartbeatInterval: 10 * time.Second,\n\t\tRunRecoveryScanInterval: 5 * time.Second,\n\t\tRunRecoveryBatchSize: 100,\n\t\tKafkaBootstrapServers: \"localhost:9092\",\n\t\tKafkaRunTopic: \"run_execute\",\n\t\tKafkaRunGroupID: \"run-worker-group\",\n\t\tKafkaRunResumeTopic: \"run_resume\",\n\t\tKafkaRunResumeGroupID: \"run-resume-worker-group\",\n\t\tJWTSecret: \"test-secret\",\n\t\tA2ACallbackBaseURL: \"http://127.0.0.1:8080\",\n\t}\n}\n"}}, "docs/API_PROTOCOL_CONTRACT.md": {"a29b39c7dd2c161a16727ea80c95e72d5be6d880": {"t": 1786068467.5911274, "n": 13874, "h": "0ced564f5a9d7314", "c": "# GoAI API / Protocol Contract\n\n本文档描述 GoAI 当前 V1 对外可消费的 HTTP、AG-UI 和 A2A 契约。协议 Gateway 只负责解析和编码外部协议,内部统一落成 `Thread / Message / Run / Delegation`,不把外部字段直接扩散到服务层。\n\n## 1. Common Conventions\n\n### Base URL\n\n以下示例假定服务地址为 `http://127.0.0.1:8080`。生产环境的远程 Agent Endpoint 必须使用 HTTPS;本地开发才允许 loopback HTTP。\n\n### Authentication\n\n- `/ping`、`/auth/register`、`/auth/login` 不需要 JWT。\n- `/api/*` 需要 `Authorization: Bearer <jwt>`。\n- `/api/agents/:agent_code/agui` 和 `/api/runs` 需要 `run:create`。\n- Agent Registry 管理接口分别使用 `agent:create`、`agent:read`、`agent:update`、`agent:activate`;`agent:manage` 只提供跨 owner 管理能力,不替代路由权限。\n- A2A Agent Card discovery 公开;`message:send`、Task 查询、终态 callback 及其他业务路由默认要求 `GoAI-HMAC-SHA256` 机器身份认证。\n\n### Trace and Errors\n\n普通 JSON API 使用 `code / message / data / trace_id` envelope。请求可以携带 `X-Trace-ID`,服务端会在响应头和响应体中复用同一个值。\n\n完整错误码和 SSE 契约见 [统一响应契约](RESPONSE_CONTRACT.md)。AG-UI 与 A2A 保持各自协议的 wire format,不把平台 JSON envelope 套在协议事件或 A2A JSON-RPC 结果外层。\n\n## 2. HTTP API Matrix\n\n| Method | Path | Auth | Result |\n| --- | --- | --- | --- |\n| `GET` | `/ping` | public | JSON envelope |\n| `POST` | `/auth/register` | public | JSON envelope |\n| `POST` | `/auth/login` | public | JSON envelope with JWT |\n| `POST` | `/api/chat` | JWT + `chat:use` | debug SSE |\n| `POST` | `/api/agents/:agent_code/agui` | JWT + `run:create` | AG-UI SSE |\n| `POST/GET` | `/api/agents` | JWT + `agent:create` / `agent:read` | create or list managed Agents |\n| `GET/PUT` | `/api/agents/:agent_code` | JWT + `agent:read` / `agent:update` | Agent detail or metadata update |\n| `POST` | `/api/agents/:agent_code/activate` | JWT + `agent:activate` | validate and publish Agent |\n| `POST` | `/api/agents/:agent_code/deactivate` | JWT + `agent:activate` | deactivate Agent |\n| `POST/GET/PUT` | `/api/agents/:agent_code/capabilities[...]` | JWT + `agent:update` / `agent:read` | manage Capability assets |\n| `POST/GET/PUT` | `/api/agents/:agent_code/endpoints[...]` | JWT + `agent:update` / `agent:read` | manage and health-check A2A Endpoints |\n| `POST` | `/api/runs` | JWT + `run:create` | JSON envelope, `202` or idempotent `200` |\n| `GET` | `/api/runs/:run_id` | JWT + `run:read` | JSON envelope |\n| `GET` | `/api/runs/:run_id/steps` | JWT + `run:read` | JSON envelope |\n| `POST` | `/api/runs/:run_id/replay` | JWT + `run:replay` | JSON envelope, `202` or idempotent `200` |\n| `GET` | `/a2a/agents/:agent_code/.well-known/agent-card.json` | protocol gateway | A2A Agent Card |\n| `POST` | `/a2a/agents/:agent_code/message:send` | protocol gateway | A2A Task |\n| `GET` | `/a2a/agents/:agent_code/tasks/:task_id` | protocol gateway | A2A Task |\n| `POST` | `/a2a/agents/:source_agent_code/callbacks/tasks/:task_id` | protocol gateway | callback accepted |\n\n## 3. Authentication Examples\n\n### Register\n\n```http\nPOST /auth/register HTTP/1.1\nContent-Type: application/json\nX-Trace-ID: trace-register-001\n\n{\n \"username\": \"alice\",\n \"email\": \"alice@example.com\",\n \"password\": \"change-me-in-local-development\"\n}\n```\n\n```json\n{\n \"code\": \"OK\",\n \"message\": \"register success\",\n \"data\": {\"user_id\": 1, \"username\": \"alice\"},\n \"trace_id\": \"trace-register-001\"\n}\n```\n\n### Login\n\n```http\nPOST /auth/login HTTP/1.1\nContent-Type: application/json\n\n{\"username\":\"alice\",\"password\":\"change-me-in-local-development\"}\n```\n\n```json\n{\n \"code\": \"OK\",\n \"message\": \"login success\",\n \"data\": {\"token\": \"<jwt returned by the service>\"},\n \"trace_id\": \"trace-login-001\"\n}\n```\n\n不要把真实 JWT、密码或 API Key 写入仓库、日志和文档。\n\n## 4. Run API Examples\n\n### Create a Run\n\n```http\nPOST /api/runs HTTP/1.1\nAuthorization: Bearer <jwt>\nContent-Type: application/json\nIdempotency-Key: create-demo-001\nX-Trace-ID: trace-run-001\n\n{\n \"agent_code\": \"planner\",\n \"workflow_version\": 1,\n \"thread_id\": \"thread-demo\",\n \"trigger_type\": \"api\",\n \"input\": {\"prompt\": \"拆解这个需求\"},\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\"\n}\n```\n\n```json\n{\n \"code\": \"OK\",\n \"message\": \"success\",\n \"data\": {\"run_id\": \"run-demo\", \"status\": \"queued\"},\n \"trace_id\": \"trace-run-001\"\n}\n```\n\n首次创建返回 `202 Accepted`。同一用户、operation 和 `Idempotency-Key` 对应相同请求时返回 `200 OK` 和原 Run;请求哈希不同返回 `409 IDEMPOTENCY_KEY_REUSED`。\n\n### Query and Replay\n\n```http\nGET /api/runs/run-demo HTTP/1.1\nAuthorization: Bearer <jwt>\n```\n\n```http\nGET /api/runs/run-demo/steps HTTP/1.1\nAuthorization: Bearer <jwt>\n```\n\n```http\nPOST /api/runs/run-demo/replay HTTP/1.1\nAuthorization: Bearer <jwt>\nIdempotency-Key: replay-demo-001\n```\n\nReplay 使用原 Run 的输入和 Workflow 创建新 Run;管理员可以跨用户查询或回放,普通用户只能访问自己的 Run。\n\nRun 查询保持既有 Run 字段格式,并在 Parent resume 存在时增加可选的 `resume` 对象:\n\n```json\n{\n \"code\": \"OK\",\n \"message\": \"success\",\n \"data\": {\n \"RunID\": \"run-parent-001\",\n \"Status\": \"running\",\n \"CurrentStep\": \"summarize\",\n \"resume\": {\n \"delegation_id\": \"delegation-review-001\",\n \"status\": \"claimed\",\n \"error\": \"resume execution lease expired before completion; recovery event scheduled\",\n \"publish_attempts\": 2,\n \"execution_attempt\": 3,\n \"lease_owner\": \"resume-worker-2\",\n \"lease_claimed_at\": \"2026-08-03T12:00:00Z\",\n \"lease_heartbeat_at\": \"2026-08-03T12:00:05Z\",\n \"lease_expires_at\": \"2026-08-03T12:00:30Z\"\n }\n },\n \"trace_id\": \"trace-parent\"\n}\n```\n\n`resume` 是受 JWT、RBAC 和 owner/admin 约束的管理诊断信息。租约字段不会进入公开 A2A Task metadata;没有恢复记录时该字段省略。\n\n## 5. Agent Registry Management API\n\nAgent Registry is the management plane. It controls which Agent identities, capabilities, and protocol endpoints are eligible for discovery. It never replaces A2A execution: local Agents still use loopback HTTP and remote Agents use HTTPS through the same A2A Gateway contract.\n\n### Create and publish an Agent\n\n1. POST /api/agents creates an inactive Agent owned by the current user.\n2. POST /api/agents/:agent_code/capabilities adds a capability asset. V1 publication requires at least one active Workflow capability backed by the same Agent's active Workflow; Tool and Custom assets are not advertised as executable Agent Card skills yet.\n3. POST /api/agents/:agent_code/endpoints adds an inactive A2A endpoint.\n4. POST /api/agents/:agent_code/endpoints/:endpoint_code/health-check discovers the Agent Card, validates its declared agentCode, transport, binding, and Delegation extension, then marks the endpoint active.\n5. POST /api/agents/:agent_code/activate validates all publication invariants and publishes the Agent.\n\n~~~http\nPOST /api/agents HTTP/1.1\nAuthorization: Bearer <jwt>\nContent-Type: application/json\n\n{\"agent_code\":\"writer\",\"name\":\"Writer\",\"description\":\"Generates articles\"}\n~~~\n\n~~~http\nPOST /api/agents/writer/capabilities HTTP/1.1\nAuthorization: Bearer <jwt>\nContent-Type: application/json\n\n{\n \"capability_code\": \"write\",\n \"name\": \"Write\",\n \"capability_type\": \"workflow\",\n \"workflow_id\": 42,\n \"version\": \"1\",\n \"input_schema_json\": \"{\\\"type\\\":\\\"object\\\"}\",\n \"output_schema_json\": \"{\\\"type\\\":\\\"object\\\"}\"\n}\n~~~\n\n~~~http\nPOST /api/agents/writer/endpoints HTTP/1.1\nAuthorization: Bearer <jwt>\nContent-Type: application/json\n\n{\n \"endpoint_code\": \"primary\",\n \"protocol\": \"a2a\",\n \"transport\": \"https\",\n \"address\": \"https://agents.example.com/a2a/agents/writer\",\n \"auth_type\": \"goai_hmac_sha256\",\n \"credential_ref\": \"writer-a2a-key\"\n}\n~~~\n\nThe API returns only credential_ref; the referenced secret is never stored in or returned by Registry records. Endpoint config_json is restricted to non-sensitive transport metadata; secret, password, token, private-key, authorization, and credential fields are rejected. Endpoint updates reset health to inactive. An active Agent cannot lose its last executable Workflow Capability or healthy Endpoint without first being deactivated.\n\nMember access is owner-scoped. A caller with the separate agent:manage permission can bypass ownership, but still needs the route-specific action permission.\n\n## 6. AG-UI Gateway\n\n### Request\n\n入口:`POST /api/agents/:agent_code/agui`。\n\n```http\nPOST /api/agents/planner/agui HTTP/1.1\nAuthorization: Bearer <jwt>\nContent-Type: application/json\nX-Trace-ID: trace-agui-001\n\n{\n \"threadId\": \"thread-demo\",\n \"runId\": \"run-demo\",\n \"state\": {},\n \"messages\": [\n {\n \"id\": \"message-demo\",\n \"role\": \"user\",\n \"content\": \"请把需求拆成三个子任务\"\n }\n ],\n \"tools\": [],\n \"context\": []\n}\n```\n\nV1 接受普通文本 `user / assistant / system / developer` 消息。非空 `state`、`tools`、`context`、`forwardedProps`、`parentRunId` 和 `resume` 会在进入流式阶段前拒绝;多模态和高级消息字段也不在 V1 范围内。\n\n### Event Stream\n\n成功进入流式阶段后,HTTP 状态为 `200`,响应类型为 `text/event-stream`。每个 `data:` 是官方 AG-UI 事件 JSON:\n\n```text\ndata: {\"type\":\"RUN_STARTED\",\"threadId\":\"thread-demo\",\"runId\":\"run-demo\"}\n\ndata: {\"type\":\"STEP_STARTED\",\"stepName\":\"prepare\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"message-result\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"message-result\",\"delta\":\"已拆分\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"message-result\"}\n\ndata: {\"type\":\"STEP_FINISHED\",\"stepName\":\"prepare\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"threadId\":\"thread-demo\",\"runId\":\"run-demo\"}\n```\n\n当前可能出现的事件包括 `RUN_STARTED`、`STEP_STARTED`、`STEP_FINISHED`、`TEXT_MESSAGE_START`、`TEXT_MESSAGE_CONTENT`、`TEXT_MESSAGE_END`、`RUN_FINISHED` 和 `RUN_ERROR`。流开始前的参数错误使用普通 JSON envelope;流开始后的失败使用 AG-UI `RUN_ERROR`。\n\n## 7. A2A Gateway\n\nA2A 是 Agent 与 Agent 之间的语义通信协议。Kafka 只能承载 GoAI 内部异步执行消息,不能替代 A2A;本地 Agent 和远程 Agent 必须经过同一 A2A Gateway 契约。\n\n### Agent Card\n\n```http\nGET /a2a/agents/writer/.well-known/agent-card.json HTTP/1.1\nAccept: application/json\n```\n\n最小响应形状如下,实际 skills 来自目标 Agent 当前启用的 Capability:\n\n```json\n{\n \"name\": \"Writer\",\n \"description\": \"负责文档写作\",\n \"version\": \"1.0\",\n \"supportedInterfaces\": [\n {\n \"url\": \"http://127.0.0.1:8080/a2a/agents/writer\",\n \"protocolBinding\": \"HTTP+JSON\",\n \"protocolVersion\": \"0.3.0\"\n }\n ],\n \"capabilities\": {\n \"pushNotifications\": true,\n \"extensions\": [\n {\n \"uri\": \"https://goai.dev/extensions/delegation/v1\",\n \"description\": \"GoAI multi-agent delegation metadata\",\n \"required\": true\n }\n ]\n },\n \"defaultInputModes\": [\"text/plain\", \"application/json\"],\n \"defaultOutputModes\": [\"application/json\"],\n \"skills\": [\n {\n \"id\": \"write\",\n \"name\": \"Write\",\n \"description\": \"生成文档\",\n \"tags\": [\"text\", \"v1\"],\n \"inputModes\": [\"text/plain\", \"application/json\"],\n \"outputModes\": [\"application/json\"]\n }\n ]\n}\n```\n\n本地 HTTP Endpoint 必须是 loopback 地址;远程 Endpoint 必须使用 HTTPS。\n\n### Delegate with `message:send`\n\n```http\nPOST /a2a/agents/writer/message:send HTTP/1.1\nContent-Type: application/json\n\n{\n \"message\": {\n \"messageId\": \"msg-001\",\n \"contextId\": \"thread-demo\",\n \"taskId\": \"run-child-001\",\n \"extensions\": [\"https://goai.dev/extensions/delegation/v1\"],\n \"metadata\": {\n \"https://goai.dev/extensions/delegation/v1\": {\n \"sourceAgentCode\": \"planner\",\n \"capabilityCode\": \"write\",\n \"parentRunId\": \"run-parent-001\"\n }\n },\n \"parts\": [{\"text\": \"把需求整理成文档\"}],\n \"role\": \"ROLE_USER\"\n }\n}\n```\n\n出站 Runtime 会在官方 SendMessageConfig 中设置 `returnImmediately=true`,并携带由 `A2A_CALLBACK_BASE_URL`、源 Agent code 和稳定 Task ID 生成的 PushConfig。远程 callback URL 必须使用 HTTPS,本地开发只允许 loopback HTTP。\n\n成功响应是官方 A2A Task;accepted/working 表示目标已持久化委派,不表示业务已经完成:\n\n```json\n{\n \"id\": \"run-child-001\",\n \"contextId\": \"thread-demo\",\n \"status\": {\"state\": \"TASK_STATE_SUBMITTED\"},\n \"history\": []\n}\n```\n\n同一协议重试使用相同标识时会复用已有协作记录;复用标识但改变请求内容会返回 A2A invalid request 错误。\n\n### Query Task\n\n```http\nGET /a2a/agents/writer/tasks/run-child-001?historyLength=10 HTTP/1.1\nAccept: application/json\n```\n\n返回的 Task 状态由 Child Run 和 Delegation 快照映射而来。Task 查询保留为诊断与兼容接口;Parent Worker 不轮询该接口等待终态,主链路使用 Push Notification callback。\n\n### Terminal Callback and Parent Resume\n\nTarget Child Run 进入 `completed / failed / canceled / rejected` 后,将官方 A2A `StreamResponse` 事件发送到:\n\n```http\nPOST /a2a/agents/planner/callbacks/tasks/run-child-001 HTTP/1.1\nAuthorization: GoAI-HMAC-SHA256 ...\nX-GoAI-Agent-Code: writer\nA2A-Notification-Token: <task-bound-token>\nX-Trace-ID: trace-parent\nContent-Type: application/json\n```\n\ncallback 处理规则:\n\n- Gateway 先校验 HMAC 机器身份,再校验 PushConfig 中绑定的 notification token、来源/目标 Agent、Task ID 和 Delegation。\n- 只接受终态事件;相同终态事件通过事件哈希幂等返回 `202`,同一 Task 的冲突终态返回 `409`。\n- 成功事件幂等写入跨 Runtime 一致的 Result Message,并发布内部 Kafka `run_resume` 消息。\n- 失败或取消事件终结 Parent Run,不发布成功 resume。\n- Resume Worker 原子 claim `waiting_external` Parent Run,并从 Delegation 保存的后继节点游标继续 Eino Graph;重复消息安全 no-op。\n- callback 或 resume 发布失败由 RecoveryWorker 基于持久化状态恢复,进程重启不丢失恢复意图。\n\nParent Run 等待 callback 时,AG-UI SSE 继续观察持久化快照;AG-UI 客户端断开不会取消已持久化的 Child Run 或 Delegation。\n\n### A2A Errors\n\nA2A 错误使用官方 JSON-RPC/HTTP+JSON 错误结构,不使用 GoAI 普通 HTTP envelope:\n\n- 目标 Agent 或 capability 不存在:`InvalidParams`\n- Task/Run 不存在:`TaskNotFound`\n- 稳定 Task、Message 或 Delegation 标识冲突:`InvalidRequest`\n- 未映射 Runtime 错误:`InternalError`\n\n## 8. Protocol-to-Domain Mapping\n\n| External concept | Internal model |\n| --- | --- |\n| AG-UI `threadId` | `Thread.thread_id` |\n| AG-UI input message | `Message` with `message_type=input` |\n| AG-UI run | `Run` with `trigger_type=agui` |\n| A2A message:send | `Message + Delegation + Child Run` |\n| A2A Task | Child `Run`/`Delegation` snapshot |\n| Eino Workflow/Graph | Agent capability execution, not communication transport |\n\n协议 Gateway 不允许通过进程内 Service 直调绕过 A2A。Workflow 的 Agent 节点必须经过 A2A Client;这样本地和远程 Agent 执行的通信语义一致。\n\n## 9. Compatibility and Current Limits\n\n- V1 只做文本消息和 callback 驱动的 Child suspend/resume;显式 `agent_group` 已支持多个 Child Run 的 `all`、`any`、`quorum` fan-out/fan-in 和部分失败聚合。不在当前范围内的是多模态、A2A Cancel、任意并行 DAG 和主动取消剩余远程 Child Task。\n- AG-UI `parentRunId` 分支和用户主动 resume 尚未开放;它们与 A2A Delegation 的 Parent/Child Run 不是同一语义。\n- 远程来源 Delegation 当前可能使用远程 A2A Task ID 填充 `ChildRunID`;后续可独立建模 `RemoteTaskID`,不改变当前协议字段。\n- A2A 业务请求必须携带 `Authorization`、`X-GoAI-Agent-Code`、`X-GoAI-Timestamp`、`X-GoAI-Nonce`、`X-GoAI-Content-SHA256`;来源身份必须匹配委派 metadata,Task 查询仅允许委派来源 Agent。\n- 外部协议版本升级时,先更新 Gateway 适配和本文件,再变更内部领域模型;不要把 SDK 类型直接作为 Service 接口。\n"}}}}