diff --git a/README.md b/README.md index cee630e..30012e4 100644 --- a/README.md +++ b/README.md @@ -155,60 +155,111 @@ Neither runtime receives the other's direct target address. ## Quick start -This example runs the real **Runtime B -> Router -> Runtime A** path. Runtime A -first publishes a lease to Nacos. Router observes the instance through a -snapshot and continuous watch. Runtime B then calls A by Agent ID and -capability; it never receives A's address. +### 1. Run the real product path + +Start here if you want to see the complete **Register -> Discover -> Install -> +Invoke -> Record** loop. The immutable +[NeKiro-Stack](https://github.com/NeKiro-project/NeKiro-Stack) prepares exact +Core, SDK, Samples, and transport revisions, starts PostgreSQL and secured +Nacos, and runs the backend acceptance. Git, Go 1.26+, Docker, Bash (Git Bash +or WSL also works on Windows), and network access are required. + +```bash +git clone https://github.com/NeKiro-project/NeKiro-Stack.git +cd NeKiro-Stack + +work_root=$(mktemp -d) +backend_env="$work_root/backend.env" +prepared_env="$work_root/prepared.env" + +./scripts/write-ci-env.sh backend "$backend_env" "$(pwd)" nekiro-quickstart +set -a +source "$backend_env" +set +a + +./scripts/prepare.sh "$(pwd)/components.json" "$work_root/checkouts" "$prepared_env" +set -a +source "$prepared_env" +set +a + +go run ./cmd/nacos-secure-fixture generate "$NEKIRO_E2E_TLS_ROOT" +docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \ + --file compose.yaml \ + --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \ + --profile router-nacos-secure \ + up --detach --wait --wait-timeout 120 +go test -tags=e2e -run '^TestInvokeToRecordAcceptance$' -count=1 ./tests/backend + +docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \ + --file compose.yaml \ + --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \ + --profile router-nacos-secure \ + --profile runtime-registration \ + --profile watch-refresh \ + down --volumes --remove-orphans +``` + +The final test proves all of the following in one run: + +| Check | Evidence | +| --- | --- | +| Register | Runtime A publishes a ready, exact-Release instance lease to Nacos. | +| Discover | Router reads the initial snapshot and watches the instance lifecycle. | +| Invoke | Runtime B reaches A by Agent ID and capability through Router only. | +| Nested invoke | A -> B and B -> A both re-enter Router; neither runtime receives the other's address. | +| Record | Ledger contains correlated parent/child metadata with `root_task_id` and `trace_id`. | +| Recovery | Removal fails closed and a replacement instance becomes routable. | + +### 2. See the call topology ```text Runtime A --lease--> Nacos --snapshot/watch--> Router Runtime B --Agent ID + capability--> Router --> Runtime A | +--> Invocation Ledger + +Runtime A <---- managed A2A ----> Router <---- managed A2A ----> Runtime B ``` -The production code is in -[NeKiro-Samples](https://github.com/NeKiro-project/NeKiro-Samples). The two -complete `package main` programs below run inside that module. They use the -Core [`registry`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry) -and [`registry/nacos`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry/nacos) -packages through a thin Samples adapter: +Consumers never resolve or dial a Provider address. Core owns the registration, +heartbeat, lease, and deregistration semantics through +[`registry`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry) and +[`registry/nacos`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry/nacos). +The Samples adapter only maps explicit `RUNTIME_A_*` / `RUNTIME_B_*` settings, +builds the secured HTTP transport, and connects lease readiness to the process +lifecycle. The endpoint ownership challenge is a separate trusted-publication +check. Since the examples use Samples `internal` packages, copy the pattern +into another Agent module instead of importing those packages directly. -```text -Runtime main - -> Samples environment + TLS/mTLS adapter - -> Core registry models + registry/nacos Registrar - -> Nacos -``` +The complete production sources are [Runtime A main](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-a/cmd/runtime-a/main.go), +[Runtime B main](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/cmd/runtime-b/main.go), +and [B -> A nested invocation](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/nested.go). -Core owns the registration, heartbeat, lease, and deregistration semantics. -The Samples adapter only maps explicit `RUNTIME_A_*` / `RUNTIME_B_*` -deployment settings, builds the secured HTTP transport, and connects the lease -to process readiness and shutdown. The endpoint ownership challenge shown in -the programs is a separate trusted-publication check, not part of instance -registration. Because the programs import Samples `internal` packages, copy -their integration pattern into another Agent module rather than importing -those packages directly. +### 3. Copy the complete Runtime mains -Runtime A starts its managed A2A endpoint, registers an exact instance, keeps -the lease alive, and deregisters during shutdown: +The programs below are complete `package main` entrypoints. They keep Runtime +configuration, Router authentication, TLS/mTLS, challenge proof, and handlers +in Samples, while [`agent/host`](https://pkg.go.dev/github.com/NeKiro-project/nekiro-sdk-go/agent/host) +owns serving, lease observation, bounded shutdown, and deregistration. + +
+Runtime A: register, serve, watch the lease, and deregister ```go package main import ( "context" - "errors" "log" "net/http" "os" - "os/signal" "syscall" "time" "github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof" "github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration" runtimea "github.com/NeKiro-project/NeKiro-Samples/runtime-a" + agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host" ) func main() { @@ -218,104 +269,82 @@ func main() { } func run() error { - config, err := runtimea.LoadConfig(os.LookupEnv) - if err != nil { - return err - } - registrationConfig, err := nacosregistration.Load( - os.LookupEnv, "RUNTIME_A", config.AgentID, config.InstanceID, - ) + return runWithLookup(os.LookupEnv) +} + +func runWithLookup(lookup func(string) (string, bool)) error { + config, err := runtimea.LoadConfig(lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime A configuration", err) } - registrationClient, err := nacosregistration.NewHTTPClient(registrationConfig) + registrationConfig, err := nacosregistration.Load(lookup, "RUNTIME_A", config.AgentID, config.InstanceID) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime A registration configuration", err) } - registration, err := nacosregistration.New(registrationConfig, registrationClient) - if err != nil { - return err + var registration agenthost.Registration + var readiness runtimea.Readiness = ready(true) + if registrationConfig.Mode == nacosregistration.ModeNacos { + registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig) + if clientErr != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A Nacos transport", clientErr) + } + runtimeRegistration, err := nacosregistration.New(registrationConfig, registrationClient) + if err != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A registration", err) + } + registration = runtimeRegistration + readiness = runtimeRegistration } - handler, err := runtimea.NewHandler(config, http.DefaultClient) if err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "create Runtime A handler", err) } - application, err := challengeproof.NewHandler( - runtimea.NewHTTPHandlerWithReadiness(handler, registration), - os.LookupEnv, - ) + application, err := challengeproof.NewHandler(runtimea.NewHTTPHandlerWithReadiness(handler, readiness), lookup) if err != nil { - return err - } - if err := registration.Register(context.Background()); err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "configure Runtime A endpoint challenge", err) } - - ctx, stop := signal.NotifyContext( - context.Background(), os.Interrupt, syscall.SIGTERM, - ) - defer stop() - server := &http.Server{Addr: config.ListenAddress, Handler: application} - serverErrors := make(chan error, 1) - go func() { - err := server.ListenAndServe() - if errors.Is(err, http.ErrServerClosed) { - err = nil - } - serverErrors <- err - }() - registrationErrors := make(chan error, 1) - go func() { registrationErrors <- registration.Run(ctx) }() - - var runErr error - registrationStopped := false - select { - case <-ctx.Done(): - case runErr = <-serverErrors: - case runErr = <-registrationErrors: - registrationStopped = true + shutdownTimeout := 5 * time.Second + if registrationConfig.RequestTimeout > 0 { + shutdownTimeout = registrationConfig.RequestTimeout } - stop() - - shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - shutdownErr := server.Shutdown(shutdownContext) - if !registrationStopped { - select { - case registrationErr := <-registrationErrors: - runErr = errors.Join(runErr, registrationErr) - case <-shutdownContext.Done(): - runErr = errors.Join(runErr, shutdownContext.Err()) - } + runtimeHost, err := agenthost.New(agenthost.Config{ + Address: config.ListenAddress, + Handler: application, + Registration: registration, + ShutdownTimeout: shutdownTimeout, + Signals: []os.Signal{os.Interrupt, syscall.SIGTERM}, + }) + if err != nil { + return err } - deregisterErr := registration.Deregister(shutdownContext) - return errors.Join(runErr, shutdownErr, deregisterErr) + return runtimeHost.Run(context.Background()) } + +type ready bool + +func (value ready) Ready() bool { return bool(value) } ``` -B registers the same way, validates the Router-issued credential on its -incoming A2A endpoint, and creates its handler with the Router URL and Agent -token. When B receives the sample's `nested` request, `NewConfiguredHandler` -uses the public Agent SDK to target A by Agent ID and capability. B never -resolves or dials A itself. +
+ +
+Runtime B: authenticate Router calls and invoke A by capability ```go package main import ( "context" - "errors" "log" "net/http" "os" - "os/signal" "syscall" "time" "github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof" "github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration" runtimeb "github.com/NeKiro-project/NeKiro-Samples/runtime-b" + agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host" "github.com/NeKiro-project/nekiro-sdk-go/agent/routerauth" ) @@ -326,147 +355,80 @@ func main() { } func run() error { - address, err := runtimeb.ListenAddressFromEnvironment(os.LookupEnv) - if err != nil { - return err - } - config, err := runtimeb.LoadConfig(os.LookupEnv) + return runWithLookup(os.LookupEnv) +} + +func runWithLookup(lookup func(string) (string, bool)) error { + address, err := runtimeb.ListenAddressFromEnvironment(lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime B listen address", err) } - authenticationConfig, err := routerauth.LoadConfig(os.LookupEnv) + authenticationConfig, err := routerauth.LoadConfig(lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime B authentication configuration", err) } - registrationConfig, err := runtimeb.LoadRegistrationConfig( - os.LookupEnv, config.AgentID, config.InstanceID, - ) + config, err := runtimeb.LoadConfig(lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime B configuration", err) } - registrationClient, err := nacosregistration.NewHTTPClient(registrationConfig) + registrationConfig, err := runtimeb.LoadRegistrationConfig(lookup, config.AgentID, config.InstanceID) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime B registration configuration", err) } - registration, err := runtimeb.NewNacosRegistration( - registrationConfig, registrationClient, - ) - if err != nil { - return err + var registration agenthost.Registration + var readiness runtimeb.Readiness = ready(true) + if registrationConfig.Mode == runtimeb.RegistrationModeNacos { + registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig) + if clientErr != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B Nacos transport", clientErr) + } + runtimeRegistration, err := runtimeb.NewNacosRegistration(registrationConfig, registrationClient) + if err != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B registration", err) + } + registration = runtimeRegistration + readiness = runtimeRegistration } - handler, err := runtimeb.NewConfiguredHandler(config, http.DefaultClient) if err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "create Runtime B handler", err) } - execution, err := runtimeb.NewHTTPHandlerWithAuthAndReadiness( - handler, authenticationConfig, registration, - ) + execution, err := runtimeb.NewHTTPHandlerWithAuthAndReadiness(handler, authenticationConfig, readiness) if err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "configure Runtime B authentication", err) } - application, err := challengeproof.NewHandler(execution, os.LookupEnv) + application, err := challengeproof.NewHandler(execution, lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "configure Runtime B endpoint challenge", err) } - if err := registration.Register(context.Background()); err != nil { - return err - } - - ctx, stop := signal.NotifyContext( - context.Background(), os.Interrupt, syscall.SIGTERM, - ) - defer stop() - server := &http.Server{Addr: address, Handler: application} - serverErrors := make(chan error, 1) - go func() { - err := server.ListenAndServe() - if errors.Is(err, http.ErrServerClosed) { - err = nil - } - serverErrors <- err - }() - registrationErrors := make(chan error, 1) - go func() { registrationErrors <- registration.Run(ctx) }() - - var runErr error - registrationStopped := false - select { - case <-ctx.Done(): - case runErr = <-serverErrors: - case runErr = <-registrationErrors: - registrationStopped = true + shutdownTimeout := 5 * time.Second + if registrationConfig.RequestTimeout > 0 { + shutdownTimeout = registrationConfig.RequestTimeout } - stop() - - shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - shutdownErr := server.Shutdown(shutdownContext) - if !registrationStopped { - select { - case registrationErr := <-registrationErrors: - runErr = errors.Join(runErr, registrationErr) - case <-shutdownContext.Done(): - runErr = errors.Join(runErr, shutdownContext.Err()) - } + runtimeHost, err := agenthost.New(agenthost.Config{ + Address: address, + Handler: application, + Registration: registration, + ShutdownTimeout: shutdownTimeout, + Signals: []os.Signal{os.Interrupt, syscall.SIGTERM}, + }) + if err != nil { + return err } - deregisterErr := registration.Deregister(shutdownContext) - return errors.Join(runErr, shutdownErr, deregisterErr) + return runtimeHost.Run(context.Background()) } -``` - -The handler extracts `PlatformContext` only after `routerauth` verifies the -Router credential, then calls `agentsdk.Client.Invoke` with -`TargetAgentID: "runtime-a"`. See the complete -[B -> A invocation implementation](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/nested.go). - -### Run the complete scenario - -The immutable [NeKiro-Stack](https://github.com/NeKiro-project/NeKiro-Stack) -supplies the parts intentionally omitted above: trusted Card/Release -publication, Workspace installation and permissions, Router credentials, -PostgreSQL, secured Nacos, exact component revisions, and Ledger assertions. -The following commands require Git, Go 1.26+, Docker, Bash, and network access: - -```bash -git clone https://github.com/NeKiro-project/NeKiro-Stack.git -cd NeKiro-Stack - -work_root=$(mktemp -d) -backend_env="$work_root/backend.env" -prepared_env="$work_root/prepared.env" - -./scripts/write-ci-env.sh backend "$backend_env" "$(pwd)" nekiro-quickstart -set -a -source "$backend_env" -set +a - -./scripts/prepare.sh "$(pwd)/components.json" "$work_root/checkouts" "$prepared_env" -set -a -source "$prepared_env" -set +a -go run ./cmd/nacos-secure-fixture generate "$NEKIRO_E2E_TLS_ROOT" -docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \ - --file compose.yaml \ - --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \ - --profile router-nacos-secure \ - up --detach --wait --wait-timeout 120 -go test -tags=e2e -run '^TestInvokeToRecordAcceptance$' -count=1 ./tests/backend +type ready bool -docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \ - --file compose.yaml \ - --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \ - --profile router-nacos-secure \ - --profile runtime-registration \ - --profile watch-refresh \ - down --volumes --remove-orphans +func (value ready) Ready() bool { return bool(value) } ``` -A passing acceptance proves that A registered, Router discovered its exact -Release-scoped instance, B reached A only through Router, and the parent/child -records share `root_task_id` and `trace_id` in Ledger. It also exercises A -> B, -instance removal, fail-closed routing, and replacement recovery. +
+ +B verifies the Router-issued credential before extracting `PlatformContext`. +The `nested` handler then calls the public Agent SDK with +`TargetAgentID: "runtime-a"`; it never reads a Nacos endpoint or dials A +directly. ### Develop Core diff --git a/README.zh-CN.md b/README.zh-CN.md index 63d87de..8f4ee80 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -135,54 +135,109 @@ Runtime B -> Router -> Runtime A ## 快速开始 -下面的例子会运行真实的 **Runtime B -> Router -> Runtime A** 链路。Runtime A -先向 Nacos 发布租约,Router 通过初始 snapshot 和持续 watch 观察到该实例。Runtime B -只使用 Agent ID 和 capability 调用 A,不会获得 A 的地址。 +### 1. 先运行真实产品链路 + +如果你想先看到 NeKiro 跑起来,从这里开始。固定精确组件版本的 +[NeKiro-Stack](https://github.com/NeKiro-project/NeKiro-Stack) 会准备 Core、SDK、 +Samples 和 transport,启动 PostgreSQL 与安全 Nacos,并验证完整的 **Register -> +Discover -> Install -> Invoke -> Record** 闭环。需要 Git、Go 1.26+、Docker、Bash +(Windows 可以使用 Git Bash 或 WSL)和网络访问。 + +```bash +git clone https://github.com/NeKiro-project/NeKiro-Stack.git +cd NeKiro-Stack + +work_root=$(mktemp -d) +backend_env="$work_root/backend.env" +prepared_env="$work_root/prepared.env" + +./scripts/write-ci-env.sh backend "$backend_env" "$(pwd)" nekiro-quickstart +set -a +source "$backend_env" +set +a + +./scripts/prepare.sh "$(pwd)/components.json" "$work_root/checkouts" "$prepared_env" +set -a +source "$prepared_env" +set +a + +go run ./cmd/nacos-secure-fixture generate "$NEKIRO_E2E_TLS_ROOT" +docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \ + --file compose.yaml \ + --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \ + --profile router-nacos-secure \ + up --detach --wait --wait-timeout 120 +go test -tags=e2e -run '^TestInvokeToRecordAcceptance$' -count=1 ./tests/backend + +docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \ + --file compose.yaml \ + --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \ + --profile router-nacos-secure \ + --profile runtime-registration \ + --profile watch-refresh \ + down --volumes --remove-orphans +``` + +最后一条测试会一次性证明: + +| 检查 | 验收证据 | +| --- | --- | +| Register | Runtime A 向 Nacos 发布 ready、精确 Release 的实例 lease。 | +| Discover | Router 读取 initial snapshot,并持续 watch 实例生命周期。 | +| Invoke | Runtime B 只通过 Router,按 Agent ID 和 capability 到达 A。 | +| Nested invoke | A -> B 与 B -> A 都会再次进入 Router;双方都拿不到对方地址。 | +| Record | Ledger 中父子 metadata 共享 `root_task_id` 与 `trace_id`。 | +| Recovery | 实例移除后路由失败关闭,替换实例上线后恢复。 | + +### 2. 看懂调用拓扑 ```text Runtime A --lease--> Nacos --snapshot/watch--> Router Runtime B --Agent ID + capability--> Router --> Runtime A | +--> Invocation Ledger + +Runtime A <---- managed A2A ----> Router <---- managed A2A ----> Runtime B ``` -生产实现位于 [NeKiro-Samples](https://github.com/NeKiro-project/NeKiro-Samples)。 -下面两个完整的 `package main` 程序需要放在该模块内运行。它们通过一层很薄的 -Samples adapter 使用 Core 的 +Consumer 不会自行解析或直连 Provider 地址。Core 通过 [`registry`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry) 和 -[`registry/nacos`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry/nacos): +[`registry/nacos`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry/nacos) +拥有注册、heartbeat、lease 与 deregistration 语义。Samples adapter 只映射显式的 +`RUNTIME_A_*` / `RUNTIME_B_*` 配置,构造安全 HTTP transport,并把 lease readiness +接入进程生命周期。Endpoint ownership challenge 是另一条可信发布校验。由于示例 +使用 Samples `internal` package,其他 Agent 模块应复制这种接入模式,而不能直接 +import 这些包。 -```text -Runtime main - -> Samples 环境变量 + TLS/mTLS adapter - -> Core registry model + registry/nacos Registrar - -> Nacos -``` +完整生产源码见 [Runtime A main](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-a/cmd/runtime-a/main.go)、 +[Runtime B main](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/cmd/runtime-b/main.go) +和 [B -> A 嵌套调用](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/nested.go)。 -注册、heartbeat、lease 和 deregister 语义都由 Core 负责。Samples adapter 只把 -显式的 `RUNTIME_A_*` / `RUNTIME_B_*` 部署配置映射为 Core model,构造安全 HTTP -transport,并将 lease 接入进程 readiness 与 shutdown。程序中的 endpoint ownership -challenge 是一条独立的可信发布校验,不属于实例注册。由于程序引用了 Samples 的 -`internal` package,其他 Agent 模块应复用这种接入模式,而不能直接 import 这些包。 +### 3. 复制完整 Runtime main -Runtime A 启动托管 A2A endpoint,注册精确实例,持续维护 lease,并在退出时注销: +下面是完整的 `package main` 入口。Runtime 配置、Router 鉴权、TLS/mTLS、challenge +proof 和 handler 仍由 Samples 拥有; +[`agent/host`](https://pkg.go.dev/github.com/NeKiro-project/nekiro-sdk-go/agent/host) +统一负责 HTTP serving、lease observation、有界 shutdown 和 deregistration。 + +
+Runtime A:注册、启动、观察 lease,并在退出时注销 ```go package main import ( "context" - "errors" "log" "net/http" "os" - "os/signal" "syscall" "time" "github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof" "github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration" runtimea "github.com/NeKiro-project/NeKiro-Samples/runtime-a" + agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host" ) func main() { @@ -192,103 +247,82 @@ func main() { } func run() error { - config, err := runtimea.LoadConfig(os.LookupEnv) - if err != nil { - return err - } - registrationConfig, err := nacosregistration.Load( - os.LookupEnv, "RUNTIME_A", config.AgentID, config.InstanceID, - ) + return runWithLookup(os.LookupEnv) +} + +func runWithLookup(lookup func(string) (string, bool)) error { + config, err := runtimea.LoadConfig(lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime A configuration", err) } - registrationClient, err := nacosregistration.NewHTTPClient(registrationConfig) + registrationConfig, err := nacosregistration.Load(lookup, "RUNTIME_A", config.AgentID, config.InstanceID) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime A registration configuration", err) } - registration, err := nacosregistration.New(registrationConfig, registrationClient) - if err != nil { - return err + var registration agenthost.Registration + var readiness runtimea.Readiness = ready(true) + if registrationConfig.Mode == nacosregistration.ModeNacos { + registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig) + if clientErr != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A Nacos transport", clientErr) + } + runtimeRegistration, err := nacosregistration.New(registrationConfig, registrationClient) + if err != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A registration", err) + } + registration = runtimeRegistration + readiness = runtimeRegistration } - handler, err := runtimea.NewHandler(config, http.DefaultClient) if err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "create Runtime A handler", err) } - application, err := challengeproof.NewHandler( - runtimea.NewHTTPHandlerWithReadiness(handler, registration), - os.LookupEnv, - ) + application, err := challengeproof.NewHandler(runtimea.NewHTTPHandlerWithReadiness(handler, readiness), lookup) if err != nil { - return err - } - if err := registration.Register(context.Background()); err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "configure Runtime A endpoint challenge", err) } - - ctx, stop := signal.NotifyContext( - context.Background(), os.Interrupt, syscall.SIGTERM, - ) - defer stop() - server := &http.Server{Addr: config.ListenAddress, Handler: application} - serverErrors := make(chan error, 1) - go func() { - err := server.ListenAndServe() - if errors.Is(err, http.ErrServerClosed) { - err = nil - } - serverErrors <- err - }() - registrationErrors := make(chan error, 1) - go func() { registrationErrors <- registration.Run(ctx) }() - - var runErr error - registrationStopped := false - select { - case <-ctx.Done(): - case runErr = <-serverErrors: - case runErr = <-registrationErrors: - registrationStopped = true + shutdownTimeout := 5 * time.Second + if registrationConfig.RequestTimeout > 0 { + shutdownTimeout = registrationConfig.RequestTimeout } - stop() - - shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - shutdownErr := server.Shutdown(shutdownContext) - if !registrationStopped { - select { - case registrationErr := <-registrationErrors: - runErr = errors.Join(runErr, registrationErr) - case <-shutdownContext.Done(): - runErr = errors.Join(runErr, shutdownContext.Err()) - } + runtimeHost, err := agenthost.New(agenthost.Config{ + Address: config.ListenAddress, + Handler: application, + Registration: registration, + ShutdownTimeout: shutdownTimeout, + Signals: []os.Signal{os.Interrupt, syscall.SIGTERM}, + }) + if err != nil { + return err } - deregisterErr := registration.Deregister(shutdownContext) - return errors.Join(runErr, shutdownErr, deregisterErr) + return runtimeHost.Run(context.Background()) } + +type ready bool + +func (value ready) Ready() bool { return bool(value) } ``` -B 使用同样的方式注册,在入站 A2A endpoint 校验 Router 签发的凭证,并使用 Router -URL 和 Agent token 创建 handler。B 收到 Sample 的 `nested` 请求时, -`NewConfiguredHandler` 使用公共 Agent SDK 按 Agent ID 与 capability 调用 A;B -不会自行解析或直连 A。 +
+ +
+Runtime B:校验 Router 调用,并按 capability 调用 A ```go package main import ( "context" - "errors" "log" "net/http" "os" - "os/signal" "syscall" "time" "github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof" "github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration" runtimeb "github.com/NeKiro-project/NeKiro-Samples/runtime-b" + agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host" "github.com/NeKiro-project/nekiro-sdk-go/agent/routerauth" ) @@ -299,145 +333,79 @@ func main() { } func run() error { - address, err := runtimeb.ListenAddressFromEnvironment(os.LookupEnv) - if err != nil { - return err - } - config, err := runtimeb.LoadConfig(os.LookupEnv) + return runWithLookup(os.LookupEnv) +} + +func runWithLookup(lookup func(string) (string, bool)) error { + address, err := runtimeb.ListenAddressFromEnvironment(lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime B listen address", err) } - authenticationConfig, err := routerauth.LoadConfig(os.LookupEnv) + authenticationConfig, err := routerauth.LoadConfig(lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime B authentication configuration", err) } - registrationConfig, err := runtimeb.LoadRegistrationConfig( - os.LookupEnv, config.AgentID, config.InstanceID, - ) + config, err := runtimeb.LoadConfig(lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime B configuration", err) } - registrationClient, err := nacosregistration.NewHTTPClient(registrationConfig) + registrationConfig, err := runtimeb.LoadRegistrationConfig(lookup, config.AgentID, config.InstanceID) if err != nil { - return err + return agenthost.Wrap(agenthost.StageConfig, "load Runtime B registration configuration", err) } - registration, err := runtimeb.NewNacosRegistration( - registrationConfig, registrationClient, - ) - if err != nil { - return err + var registration agenthost.Registration + var readiness runtimeb.Readiness = ready(true) + if registrationConfig.Mode == runtimeb.RegistrationModeNacos { + registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig) + if clientErr != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B Nacos transport", clientErr) + } + runtimeRegistration, err := runtimeb.NewNacosRegistration(registrationConfig, registrationClient) + if err != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B registration", err) + } + registration = runtimeRegistration + readiness = runtimeRegistration } - handler, err := runtimeb.NewConfiguredHandler(config, http.DefaultClient) if err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "create Runtime B handler", err) } - execution, err := runtimeb.NewHTTPHandlerWithAuthAndReadiness( - handler, authenticationConfig, registration, - ) + execution, err := runtimeb.NewHTTPHandlerWithAuthAndReadiness(handler, authenticationConfig, readiness) if err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "configure Runtime B authentication", err) } - application, err := challengeproof.NewHandler(execution, os.LookupEnv) + application, err := challengeproof.NewHandler(execution, lookup) if err != nil { - return err + return agenthost.Wrap(agenthost.StageHandler, "configure Runtime B endpoint challenge", err) } - if err := registration.Register(context.Background()); err != nil { - return err - } - - ctx, stop := signal.NotifyContext( - context.Background(), os.Interrupt, syscall.SIGTERM, - ) - defer stop() - server := &http.Server{Addr: address, Handler: application} - serverErrors := make(chan error, 1) - go func() { - err := server.ListenAndServe() - if errors.Is(err, http.ErrServerClosed) { - err = nil - } - serverErrors <- err - }() - registrationErrors := make(chan error, 1) - go func() { registrationErrors <- registration.Run(ctx) }() - - var runErr error - registrationStopped := false - select { - case <-ctx.Done(): - case runErr = <-serverErrors: - case runErr = <-registrationErrors: - registrationStopped = true + shutdownTimeout := 5 * time.Second + if registrationConfig.RequestTimeout > 0 { + shutdownTimeout = registrationConfig.RequestTimeout } - stop() - - shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - shutdownErr := server.Shutdown(shutdownContext) - if !registrationStopped { - select { - case registrationErr := <-registrationErrors: - runErr = errors.Join(runErr, registrationErr) - case <-shutdownContext.Done(): - runErr = errors.Join(runErr, shutdownContext.Err()) - } + runtimeHost, err := agenthost.New(agenthost.Config{ + Address: address, + Handler: application, + Registration: registration, + ShutdownTimeout: shutdownTimeout, + Signals: []os.Signal{os.Interrupt, syscall.SIGTERM}, + }) + if err != nil { + return err } - deregisterErr := registration.Deregister(shutdownContext) - return errors.Join(runErr, shutdownErr, deregisterErr) + return runtimeHost.Run(context.Background()) } -``` - -Handler 只会在 `routerauth` 验证 Router 凭证后提取 `PlatformContext`,再通过 -`agentsdk.Client.Invoke` 和 `TargetAgentID: "runtime-a"` 发起调用。完整实现见 -[B -> A 调用代码](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/nested.go)。 - -### 运行完整场景 - -固定全部组件 revision 的 -[NeKiro-Stack](https://github.com/NeKiro-project/NeKiro-Stack) 会补齐上面刻意省略的 -可信 Card/Release 发布、Workspace 安装与权限、Router 凭证、PostgreSQL、安全 -Nacos、精确组件版本和 Ledger 断言。以下命令需要 Git、Go 1.26+、Docker、Bash -和网络访问: - -```bash -git clone https://github.com/NeKiro-project/NeKiro-Stack.git -cd NeKiro-Stack - -work_root=$(mktemp -d) -backend_env="$work_root/backend.env" -prepared_env="$work_root/prepared.env" - -./scripts/write-ci-env.sh backend "$backend_env" "$(pwd)" nekiro-quickstart -set -a -source "$backend_env" -set +a - -./scripts/prepare.sh "$(pwd)/components.json" "$work_root/checkouts" "$prepared_env" -set -a -source "$prepared_env" -set +a -go run ./cmd/nacos-secure-fixture generate "$NEKIRO_E2E_TLS_ROOT" -docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \ - --file compose.yaml \ - --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \ - --profile router-nacos-secure \ - up --detach --wait --wait-timeout 120 -go test -tags=e2e -run '^TestInvokeToRecordAcceptance$' -count=1 ./tests/backend +type ready bool -docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \ - --file compose.yaml \ - --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \ - --profile router-nacos-secure \ - --profile runtime-registration \ - --profile watch-refresh \ - down --volumes --remove-orphans +func (value ready) Ready() bool { return bool(value) } ``` -验收通过表示:A 已注册,Router 发现了 A 对应精确 Release 的实例,B 只通过 Router -到达 A,并且 Ledger 中父子记录共享 `root_task_id` 和 `trace_id`。同一套验收还会 -覆盖 A -> B、实例下线、失败关闭路由和替换实例后的恢复。 +
+ +B 会先校验 Router 签发的凭证,再提取 `PlatformContext`。`nested` handler 随后使用 +公共 Agent SDK 和 `TargetAgentID: "runtime-a"` 发起调用;它不会读取 Nacos endpoint +或直接连接 A。 ### 开发 Core diff --git a/docs/decisions/0020-layered-error-ownership.md b/docs/decisions/0020-layered-error-ownership.md new file mode 100644 index 0000000..6a5e644 --- /dev/null +++ b/docs/decisions/0020-layered-error-ownership.md @@ -0,0 +1,98 @@ +# ADR 0020: Layered Error Ownership + +- Status: Accepted for issue #116 +- Date: 2026-08-12 +- Decision owner: Platform Architecture +- Related: `nekiro-sdk-go#3`, `nekiro-sdk-go#4`, `NeKiro-Samples#11`, and + `NeKiro-Samples#12` + +## Context + +Core domain packages, cross-process contracts, and Agent Runtime lifecycle +code need different error semantics. Treating them as one global taxonomy +would couple independent owners and make dependency details easier to leak +across trust boundaries. + +This is the same useful separation visible in Dubbo-Go: a protocol package owns +wire error codes and translation, while individual subsystems classify their +own failures. A caller can handle a stable boundary result without importing +every implementation package or parsing provider text. + +The Runtime lifecycle also repeats setup, serving, lease observation, and +shutdown handling in each Sample. The SDK needs to provide that mechanism +without taking ownership of Runtime-specific configuration, handlers, +transport security, or policy. + +## Decision + +Errors remain layered by ownership. There is no global Core `errors` package, +catch mechanism, or flattened taxonomy. + +| Owner | Owns | Boundary rule | +| --- | --- | --- | +| `config_center` | Provider-neutral source outcomes such as missing, invalid, unauthorized, unavailable, cancellation, and revision/watch failures. | Providers map their dependency responses into these classifications without exposing provider payloads. | +| `registry` | Agent Card, Release, registration, lease, and watch invariants. | Registry callers use `errors.Is/As` for local decisions; they do not infer meaning from error strings. | +| Control Plane and Router domains | Catalog, Workspace, Gateway, dispatch, authentication, resolution, transport, and Ledger failures. | Each owning package maps its dependencies explicitly before returning to another Core boundary. | +| `contracts` | Stable cross-process `PlatformErrorCode` values and versioned error shapes. | HTTP, A2A, SSE, and Ledger adapters emit only the contract-defined code, message, phase, and correlation fields. | +| `nekiro-sdk-go/agent/host` | Runtime lifecycle stages: `config`, `registration`, `handler`, `serve`, and `shutdown`. | Host errors expose the stage and static safe text while preserving the cause for `errors.Is/As`; Runtime packages own configuration and policy errors. | + +### Translation and safety rules + +1. A package may introduce a sentinel or typed error only for a fact it owns. + Wrapping preserves the owner error for local `errors.Is/As` checks. +2. A process or protocol boundary maps the complete error chain once. It must + select a versioned contract code and safe message; it must not pass through + dependency response bodies, URLs, credentials, PEM/key bytes, request or + Agent payloads, SQL details, or arbitrary cause text. +3. `PlatformErrorCode` is not a replacement for local errors. A local error + may map to one code in one boundary and a different code in another phase + when the contract requires it; the mapping is documented and tested at that + boundary. +4. Cancellation, deadline, missing, invalid, unauthorized, unavailable, + dependency, and internal outcomes remain distinct. No empty result, retry, + alternate endpoint, stale snapshot, or old revision may hide one outcome as + another without explicit policy evidence. +5. Logs and metrics may classify an error more coarsely, but that projection is + not a new public error contract and must use redacted fields. + +### Runtime host boundary + +`agent/host` starts the supplied HTTP server, registers and observes the +optional lease, and performs bounded shutdown and deregistration. It does not +load deployment configuration, construct a registry provider, validate Router +credentials, or implement Agent behavior. Runtime setup wraps failures with a +host stage; the host owns only lifecycle failures after setup. + +## Compatibility + +- Existing owner-local errors and `contracts.PlatformErrorCode` values remain + unchanged. +- The SDK host is additive. Runtime implementations may adopt it without + changing the Agent Card, Release, Router, or Ledger contracts. +- Adding a new public code, changing a code/message, or moving an error owner + requires a contract/ADR review and an explicit compatibility window. + +## Consequences + +- Core packages remain independently testable and do not depend on a shared + implementation-only error taxonomy. +- Boundary handlers have a single, reviewable place for redaction and wire + translation. +- Runtime Samples share lifecycle behavior while retaining their own setup, + security, and handler ownership. +- Operators can distinguish lifecycle stage and stable boundary code without + receiving secrets or dependency internals. + +## Rejected alternatives + +- A root-level `errors` package containing every Core failure. +- A global `recover`/catch layer that turns all failures into one response. +- Parsing `error.Error()` strings to decide authorization, availability, or + retry behavior. +- Returning raw Nacos, HTTP, SQL, Router, or Agent error text to callers. + +## Fallback Delta + +Fallback delta: removed 0, retained 0, added 0, net +0. + +Added fallback evidence: none.