diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index 63979533..cfa2a3c2 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -121,12 +121,14 @@ 交付物:[../server-rs/scripts/test.ps1](../server-rs/scripts/test.ps1)、[../server-rs/scripts/test.sh](../server-rs/scripts/test.sh) - [x] 新增 lint / fmt / clippy / check 脚本 交付物:[../server-rs/scripts/check.ps1](../server-rs/scripts/check.ps1)、[../server-rs/scripts/check.sh](../server-rs/scripts/check.sh) -- [ ] 新增 smoke 脚本 +- [x] 新增 smoke 脚本 + 交付物:[../server-rs/scripts/smoke.ps1](../server-rs/scripts/smoke.ps1)、[../server-rs/scripts/smoke.sh](../server-rs/scripts/smoke.sh) - [ ] 新增 SpacetimeDB 本地开发脚本 ### 阶段验收 -- [ ] Axum 服务可独立启动 +- [x] Axum 服务可独立启动 + 证据:`./server-rs/scripts/smoke.ps1` 已通过,覆盖临时启动 `api-server`、等待 `/healthz` 就绪并验证 raw / envelope 协议。 - [x] `/healthz` 返回与当前工程兼容 - [x] 基础 response envelope 与 request id 行为稳定 证据:`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖 envelope 协商与 `/healthz` 头部回写。 diff --git a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md index 7ca9ea45..1768e95d 100644 --- a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md +++ b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md @@ -245,7 +245,7 @@ server-rs/ ├─ dev.sh / dev.ps1 ├─ check.sh / check.ps1 ├─ spacetime-publish.sh - └─ smoke.sh + └─ smoke.sh / smoke.ps1 ``` 目录职责约束: diff --git a/server-rs/README.md b/server-rs/README.md index 792c9992..7da3a059 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -14,7 +14,7 @@ ## 2. 当前阶段说明 -当前目录已经完成以下三十项初始化: +当前目录已经完成以下三十二项初始化: 1. 为新后端预留正式目录并把路径固定到仓库结构中。 2. 创建虚拟 workspace `Cargo.toml`,后续 package 会逐项挂入。 @@ -46,11 +46,12 @@ 28. 创建 `scripts/test.sh`,固定 Unix-like 本地测试入口。 29. 创建 `scripts/check.ps1`,固定 Windows 本地统一检查入口。 30. 创建 `scripts/check.sh`,固定 Unix-like 本地统一检查入口。 +31. 创建 `scripts/smoke.ps1`,固定 Windows 本地冒烟验证入口。 +32. 创建 `scripts/smoke.sh`,固定 Unix-like 本地冒烟验证入口。 后续任务会继续在本目录内按顺序补齐: -1. smoke 脚本 -2. SpacetimeDB 本地开发脚本 +1. SpacetimeDB 本地开发脚本 ## 3. 已冻结边界 diff --git a/server-rs/apps/api-server/README.md b/server-rs/apps/api-server/README.md index aac1f3b2..09ea73b0 100644 --- a/server-rs/apps/api-server/README.md +++ b/server-rs/apps/api-server/README.md @@ -14,6 +14,7 @@ 6. 由 `../../scripts/dev.ps1` 与 `../../scripts/dev.sh` 驱动的本地开发启动链路 7. 由 `../../scripts/test.ps1` 与 `../../scripts/test.sh` 驱动的本地测试链路 8. 由 `../../scripts/check.ps1` 与 `../../scripts/check.sh` 驱动的本地统一检查链路 +9. 由 `../../scripts/smoke.ps1` 与 `../../scripts/smoke.sh` 驱动的本地启动与协议冒烟链路 ## 2. 当前阶段说明 @@ -81,6 +82,12 @@ 3. 当只需聚焦单个 package 时,可通过 `-Package` 或 `SERVER_RS_CHECK_PACKAGE` 收窄 `clippy / check / test` 目标。 4. `cargo fmt --all --check` 仍固定覆盖整个 workspace,避免多 package 下格式基线漂移。 +当前本地 smoke 链路约定: + +1. `../../scripts/smoke.ps1` 与 `../../scripts/smoke.sh` 会先构建 `api-server`,再拉起临时本地进程完成冒烟验证。 +2. smoke 当前固定校验 `/healthz` 的 raw 响应、envelope 响应以及 `x-request-id`、`x-api-version`、`x-route-version`、`x-response-time-ms` 头。 +3. smoke 通过后,可作为“Axum 服务可独立启动且基础 contract 可联通”的本地自动化证据。 + ## 3. 边界约束 1. `api-server` 负责 HTTP、SSE、Cookie、Header、路由与协议装配。 diff --git a/server-rs/scripts/smoke.ps1 b/server-rs/scripts/smoke.ps1 new file mode 100644 index 00000000..891c5b4e --- /dev/null +++ b/server-rs/scripts/smoke.ps1 @@ -0,0 +1,239 @@ +[CmdletBinding()] +param( + [Alias("h")] + [switch]$Help, + [string]$ApiHost = "127.0.0.1", + [int]$Port = 3101, + [string]$Log = "warn,tower_http=warn", + [int]$StartupTimeoutSeconds = 30 +) + +$ErrorActionPreference = "Stop" + +function Write-Usage { + @( + 'Usage:' + ' ./server-rs/scripts/smoke.ps1' + ' ./server-rs/scripts/smoke.ps1 -ApiHost 127.0.0.1 -Port 3201 -Log "info,tower_http=info"' + '' + 'Notes:' + ' 1. Build api-server and start an ephemeral local process' + ' 2. Verify /healthz raw payload, response headers, and envelope contract' + ' 3. Stop the temporary process automatically after the smoke checks finish' + ) -join [Environment]::NewLine +} + +function Assert-Condition { + param( + [bool]$Condition, + [string]$Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function Set-InheritedEnvVar { + param( + [string]$Name, + [string]$Value, + [hashtable]$Snapshot + ) + + if (-not $Snapshot.ContainsKey($Name)) { + $Snapshot[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process") + } + + [Environment]::SetEnvironmentVariable($Name, $Value, "Process") +} + +function Restore-InheritedEnvVars { + param([hashtable]$Snapshot) + + foreach ($entry in $Snapshot.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process") + } +} + +function Get-HeaderValue { + param( + $Headers, + [string]$Name + ) + + if ($null -eq $Headers) { + return $null + } + + return $Headers[$Name] +} + +function Wait-ForHealthz { + param( + [string]$Uri, + [int]$TimeoutSeconds, + $Process + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastError = $null + + while ((Get-Date) -lt $deadline) { + if ($Process.HasExited) { + throw "api-server exited before /healthz became ready." + } + + try { + $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 2 + if ($response.StatusCode -eq 200) { + return + } + + $lastError = "Unexpected status code $($response.StatusCode)" + } + catch { + $lastError = $_.Exception.Message + } + + Start-Sleep -Milliseconds 300 + } + + throw "Timed out waiting for /healthz readiness. Last error: $lastError" +} + +function Write-ProcessLogs { + param( + [string]$StdoutLog, + [string]$StderrLog + ) + + if (Test-Path $StdoutLog) { + Write-Host "[server-rs:smoke] stdout:" + Get-Content -Path $StdoutLog + } + + if (Test-Path $StderrLog) { + Write-Host "[server-rs:smoke] stderr:" + Get-Content -Path $StderrLog + } +} + +if ($Help) { + Write-Usage + exit 0 +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$serverRsDir = Split-Path -Parent $scriptDir +$manifestPath = Join-Path $serverRsDir "Cargo.toml" +$binaryPath = Join-Path $serverRsDir "target\debug\api-server.exe" +$baseUrl = "http://$ApiHost`:$Port" +$healthzUrl = "$baseUrl/healthz" +$runId = [Guid]::NewGuid().ToString("N") +$stdoutLog = Join-Path $env:TEMP "genarrative-server-rs-smoke-$runId.stdout.log" +$stderrLog = Join-Path $env:TEMP "genarrative-server-rs-smoke-$runId.stderr.log" +$envSnapshot = @{} +$serverProcess = $null +$shouldKeepLogs = $false + +if (-not (Test-Path $manifestPath)) { + throw "Missing server-rs/Cargo.toml, cannot start smoke script." +} + +Push-Location $serverRsDir +try { + Write-Host "[server-rs:smoke] step: cargo build -p api-server" + cargo build -p api-server --manifest-path $manifestPath + + Assert-Condition (Test-Path $binaryPath) "Missing api-server binary at $binaryPath after cargo build." + + Set-InheritedEnvVar -Name "GENARRATIVE_API_HOST" -Value $ApiHost -Snapshot $envSnapshot + Set-InheritedEnvVar -Name "GENARRATIVE_API_PORT" -Value "$Port" -Snapshot $envSnapshot + Set-InheritedEnvVar -Name "GENARRATIVE_API_LOG" -Value $Log -Snapshot $envSnapshot + + Write-Host "[server-rs:smoke] step: start api-server binary" + $serverProcess = Start-Process ` + -FilePath $binaryPath ` + -WorkingDirectory $serverRsDir ` + -PassThru ` + -RedirectStandardOutput $stdoutLog ` + -RedirectStandardError $stderrLog + + Restore-InheritedEnvVars -Snapshot $envSnapshot + + Write-Host "[server-rs:smoke] step: wait for /healthz readiness" + Wait-ForHealthz -Uri $healthzUrl -TimeoutSeconds $StartupTimeoutSeconds -Process $serverProcess + + $rawRequestId = "smoke-healthz-raw" + Write-Host "[server-rs:smoke] step: verify raw /healthz contract" + $rawResponse = Invoke-WebRequest ` + -Uri $healthzUrl ` + -Headers @{ "x-request-id" = $rawRequestId } ` + -UseBasicParsing ` + -TimeoutSec 5 + $rawBody = $rawResponse.Content | ConvertFrom-Json + + Assert-Condition ($rawResponse.StatusCode -eq 200) "Raw /healthz did not return HTTP 200." + Assert-Condition ($rawBody.ok -eq $true) "Raw /healthz body is missing ok=true." + Assert-Condition ($rawBody.service -eq "genarrative-node-server") "Raw /healthz body returned an unexpected service name." + + $apiVersionHeader = Get-HeaderValue -Headers $rawResponse.Headers -Name "x-api-version" + $routeVersionHeader = Get-HeaderValue -Headers $rawResponse.Headers -Name "x-route-version" + $requestIdHeader = Get-HeaderValue -Headers $rawResponse.Headers -Name "x-request-id" + $responseTimeHeader = Get-HeaderValue -Headers $rawResponse.Headers -Name "x-response-time-ms" + $responseTimeValue = 0 + + Assert-Condition ($requestIdHeader -eq $rawRequestId) "Raw /healthz did not echo x-request-id." + Assert-Condition (-not [string]::IsNullOrWhiteSpace($apiVersionHeader)) "Raw /healthz is missing x-api-version." + Assert-Condition ($routeVersionHeader -eq $apiVersionHeader) "Raw /healthz x-route-version is not aligned with x-api-version." + Assert-Condition ( + [int]::TryParse(($responseTimeHeader | ForEach-Object { $_ }), [ref]$responseTimeValue) + ) "Raw /healthz x-response-time-ms is not a valid integer." + Assert-Condition ($responseTimeValue -ge 0) "Raw /healthz x-response-time-ms must be >= 0." + + $envelopeRequestId = "smoke-healthz-envelope" + Write-Host "[server-rs:smoke] step: verify envelope /healthz contract" + $envelopeResponse = Invoke-WebRequest ` + -Uri $healthzUrl ` + -Headers @{ + "x-request-id" = $envelopeRequestId + "x-genarrative-response-envelope" = "1" + } ` + -UseBasicParsing ` + -TimeoutSec 5 + $envelopeBody = $envelopeResponse.Content | ConvertFrom-Json + + Assert-Condition ($envelopeResponse.StatusCode -eq 200) "Envelope /healthz did not return HTTP 200." + Assert-Condition ($envelopeBody.ok -eq $true) "Envelope /healthz body is missing ok=true." + Assert-Condition ($null -eq $envelopeBody.error) "Envelope /healthz should return error=null." + Assert-Condition ($envelopeBody.data.ok -eq $true) "Envelope /healthz data.ok should be true." + Assert-Condition ($envelopeBody.data.service -eq "genarrative-node-server") "Envelope /healthz returned an unexpected service name." + Assert-Condition ($envelopeBody.meta.apiVersion -eq $apiVersionHeader) "Envelope /healthz meta.apiVersion does not match x-api-version." + Assert-Condition ($envelopeBody.meta.requestId -eq $envelopeRequestId) "Envelope /healthz meta.requestId did not echo x-request-id." + Assert-Condition ($envelopeBody.meta.routeVersion -eq $routeVersionHeader) "Envelope /healthz meta.routeVersion does not match x-route-version." + Assert-Condition ($envelopeBody.meta.operation -eq "GET /healthz") "Envelope /healthz meta.operation is unexpected." + Assert-Condition ($envelopeBody.meta.latencyMs -ge 0) "Envelope /healthz meta.latencyMs must be >= 0." + + Write-Host "[server-rs:smoke] all checks passed" +} +catch { + $shouldKeepLogs = $true + Write-ProcessLogs -StdoutLog $stdoutLog -StderrLog $stderrLog + throw +} +finally { + Restore-InheritedEnvVars -Snapshot $envSnapshot + + if ($null -ne $serverProcess -and -not $serverProcess.HasExited) { + Stop-Process -Id $serverProcess.Id -Force + $serverProcess.WaitForExit() + } + + Pop-Location + + if (-not $shouldKeepLogs) { + Remove-Item -LiteralPath $stdoutLog -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $stderrLog -ErrorAction SilentlyContinue + } +} diff --git a/server-rs/scripts/smoke.sh b/server-rs/scripts/smoke.sh new file mode 100644 index 00000000..26fb831c --- /dev/null +++ b/server-rs/scripts/smoke.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# 先编译并拉起临时 api-server 进程,再校验 /healthz 的裸响应、envelope 和关键响应头契约。 + +usage() { + cat <<'EOF' +用法: + ./server-rs/scripts/smoke.sh + GENARRATIVE_SMOKE_PORT=3201 ./server-rs/scripts/smoke.sh + +说明: + 1. 先执行 `cargo build -p api-server` + 2. 启动一个临时 `api-server` 进程并等待 `/healthz` 就绪 + 3. 校验 raw `/healthz`、envelope `/healthz` 以及 `x-request-id / x-api-version / x-route-version / x-response-time-ms` + 4. 冒烟结束后自动回收临时进程 +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SERVER_RS_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +MANIFEST_PATH="${SERVER_RS_DIR}/Cargo.toml" +SERVER_BIN="${SERVER_RS_DIR}/target/debug/api-server" +API_HOST="${GENARRATIVE_SMOKE_HOST:-127.0.0.1}" +API_PORT="${GENARRATIVE_SMOKE_PORT:-3101}" +API_LOG="${GENARRATIVE_SMOKE_LOG:-warn,tower_http=warn}" +STARTUP_TIMEOUT_SECONDS="${GENARRATIVE_SMOKE_TIMEOUT_SECONDS:-30}" +BASE_URL="http://${API_HOST}:${API_PORT}" +HEALTHZ_URL="${BASE_URL}/healthz" +RUN_ID="$(date +%s)-$$" +STDOUT_LOG="${TMPDIR:-/tmp}/genarrative-server-rs-smoke-${RUN_ID}.stdout.log" +STDERR_LOG="${TMPDIR:-/tmp}/genarrative-server-rs-smoke-${RUN_ID}.stderr.log" +KEEP_LOGS=0 +SERVER_PID="" + +assert_contains() { + local haystack="$1" + local needle="$2" + local message="$3" + + if [[ "${haystack}" != *"${needle}"* ]]; then + echo "[server-rs:smoke] ${message}" >&2 + exit 1 + fi +} + +print_logs() { + if [[ -f "${STDOUT_LOG}" ]]; then + echo "[server-rs:smoke] stdout:" + cat "${STDOUT_LOG}" + fi + + if [[ -f "${STDERR_LOG}" ]]; then + echo "[server-rs:smoke] stderr:" + cat "${STDERR_LOG}" + fi +} + +cleanup() { + if [[ -n "${SERVER_PID}" ]] && kill -0 "${SERVER_PID}" 2>/dev/null; then + kill "${SERVER_PID}" 2>/dev/null || true + wait "${SERVER_PID}" 2>/dev/null || true + fi + + if [[ "${KEEP_LOGS}" -eq 0 ]]; then + rm -f "${STDOUT_LOG}" "${STDERR_LOG}" + fi +} + +trap cleanup EXIT + +if [[ ! -f "${MANIFEST_PATH}" ]]; then + echo "[server-rs:smoke] 未找到 ${MANIFEST_PATH},无法启动冒烟脚本。" >&2 + exit 1 +fi + +cd "${SERVER_RS_DIR}" + +echo "[server-rs:smoke] 步骤: cargo build -p api-server" +cargo build -p api-server --manifest-path "${MANIFEST_PATH}" + +if [[ ! -x "${SERVER_BIN}" ]]; then + echo "[server-rs:smoke] 未找到 ${SERVER_BIN},构建结果异常。" >&2 + exit 1 +fi + +echo "[server-rs:smoke] 步骤: start api-server binary" +GENARRATIVE_API_HOST="${API_HOST}" \ +GENARRATIVE_API_PORT="${API_PORT}" \ +GENARRATIVE_API_LOG="${API_LOG}" \ +"${SERVER_BIN}" >"${STDOUT_LOG}" 2>"${STDERR_LOG}" & +SERVER_PID=$! + +echo "[server-rs:smoke] 步骤: wait for /healthz readiness" +READY=0 +for ((i = 0; i < STARTUP_TIMEOUT_SECONDS * 10; i++)); do + if ! kill -0 "${SERVER_PID}" 2>/dev/null; then + KEEP_LOGS=1 + print_logs + echo "[server-rs:smoke] api-server exited before /healthz became ready." >&2 + exit 1 + fi + + if curl -fsS "${HEALTHZ_URL}" >/dev/null 2>&1; then + READY=1 + break + fi + + sleep 0.1 +done + +if [[ "${READY}" -ne 1 ]]; then + KEEP_LOGS=1 + print_logs + echo "[server-rs:smoke] /healthz readiness timed out." >&2 + exit 1 +fi + +RAW_BODY_FILE="${TMPDIR:-/tmp}/genarrative-server-rs-smoke-${RUN_ID}.raw.body.json" +RAW_HEADER_FILE="${TMPDIR:-/tmp}/genarrative-server-rs-smoke-${RUN_ID}.raw.headers.txt" +ENVELOPE_BODY_FILE="${TMPDIR:-/tmp}/genarrative-server-rs-smoke-${RUN_ID}.envelope.body.json" +ENVELOPE_HEADER_FILE="${TMPDIR:-/tmp}/genarrative-server-rs-smoke-${RUN_ID}.envelope.headers.txt" + +trap 'rm -f "${RAW_BODY_FILE}" "${RAW_HEADER_FILE}" "${ENVELOPE_BODY_FILE}" "${ENVELOPE_HEADER_FILE}"; cleanup' EXIT + +RAW_REQUEST_ID="smoke-healthz-raw" +echo "[server-rs:smoke] 步骤: verify raw /healthz contract" +curl -fsS \ + -H "x-request-id: ${RAW_REQUEST_ID}" \ + -D "${RAW_HEADER_FILE}" \ + -o "${RAW_BODY_FILE}" \ + "${HEALTHZ_URL}" + +RAW_BODY="$(tr -d '\n' <"${RAW_BODY_FILE}")" +RAW_REQUEST_ID_HEADER="$(grep -i '^x-request-id:' "${RAW_HEADER_FILE}" | tail -n 1 | cut -d':' -f2- | tr -d '\r' | xargs)" +API_VERSION_HEADER="$(grep -i '^x-api-version:' "${RAW_HEADER_FILE}" | tail -n 1 | cut -d':' -f2- | tr -d '\r' | xargs)" +ROUTE_VERSION_HEADER="$(grep -i '^x-route-version:' "${RAW_HEADER_FILE}" | tail -n 1 | cut -d':' -f2- | tr -d '\r' | xargs)" +RESPONSE_TIME_HEADER="$(grep -i '^x-response-time-ms:' "${RAW_HEADER_FILE}" | tail -n 1 | cut -d':' -f2- | tr -d '\r' | xargs)" + +assert_contains "${RAW_BODY}" '"ok":true' "raw /healthz body 缺少 ok=true。" +assert_contains "${RAW_BODY}" '"service":"genarrative-node-server"' "raw /healthz body 返回了意外的 service。" + +if [[ "${RAW_REQUEST_ID_HEADER}" != "${RAW_REQUEST_ID}" ]]; then + echo "[server-rs:smoke] raw /healthz 没有正确回写 x-request-id。" >&2 + exit 1 +fi + +if [[ -z "${API_VERSION_HEADER}" ]]; then + echo "[server-rs:smoke] raw /healthz 缺少 x-api-version。" >&2 + exit 1 +fi + +if [[ "${ROUTE_VERSION_HEADER}" != "${API_VERSION_HEADER}" ]]; then + echo "[server-rs:smoke] raw /healthz 的 x-route-version 与 x-api-version 不一致。" >&2 + exit 1 +fi + +if [[ ! "${RESPONSE_TIME_HEADER}" =~ ^[0-9]+$ ]]; then + echo "[server-rs:smoke] raw /healthz 的 x-response-time-ms 不是合法整数。" >&2 + exit 1 +fi + +ENVELOPE_REQUEST_ID="smoke-healthz-envelope" +echo "[server-rs:smoke] 步骤: verify envelope /healthz contract" +curl -fsS \ + -H "x-request-id: ${ENVELOPE_REQUEST_ID}" \ + -H "x-genarrative-response-envelope: 1" \ + -D "${ENVELOPE_HEADER_FILE}" \ + -o "${ENVELOPE_BODY_FILE}" \ + "${HEALTHZ_URL}" + +ENVELOPE_BODY="$(tr -d '\n' <"${ENVELOPE_BODY_FILE}")" + +assert_contains "${ENVELOPE_BODY}" '"ok":true' "envelope /healthz body 缺少 ok=true。" +assert_contains "${ENVELOPE_BODY}" '"error":null' "envelope /healthz body 缺少 error=null。" +assert_contains "${ENVELOPE_BODY}" '"data":{"ok":true,"service":"genarrative-node-server"}' "envelope /healthz data 返回异常。" +assert_contains "${ENVELOPE_BODY}" "\"requestId\":\"${ENVELOPE_REQUEST_ID}\"" "envelope /healthz meta.requestId 没有回写请求 ID。" +assert_contains "${ENVELOPE_BODY}" "\"apiVersion\":\"${API_VERSION_HEADER}\"" "envelope /healthz meta.apiVersion 与响应头不一致。" +assert_contains "${ENVELOPE_BODY}" "\"routeVersion\":\"${ROUTE_VERSION_HEADER}\"" "envelope /healthz meta.routeVersion 与响应头不一致。" +assert_contains "${ENVELOPE_BODY}" '"operation":"GET /healthz"' "envelope /healthz meta.operation 异常。" + +echo "[server-rs:smoke] all checks passed"