@sema-agent/server 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +13 -9
  4. package/dist/approval-hmac.js +1 -2
  5. package/dist/bench/s1/live-deps.js +1 -1
  6. package/dist/boot/config-center.d.ts +3 -2
  7. package/dist/boot/config-center.js +33 -19
  8. package/dist/boot/resolve-spec.js +5 -5
  9. package/dist/brain.js +42 -9
  10. package/dist/config-center/apply-effective.d.ts +1 -1
  11. package/dist/config-center/apply-effective.js +4 -4
  12. package/dist/config-center/facade.d.ts +47 -0
  13. package/dist/config-center/facade.js +46 -0
  14. package/dist/config-center/http-client.d.ts +1 -1
  15. package/dist/config-center/http-client.js +6 -6
  16. package/dist/config-center/restart-signal.d.ts +11 -3
  17. package/dist/config-center/restart-signal.js +51 -7
  18. package/dist/config-center/skills-mcp.js +1 -1
  19. package/dist/config-center/types.d.ts +2 -2
  20. package/dist/config-provider.d.ts +7 -7
  21. package/dist/config-provider.js +9 -9
  22. package/dist/config.d.ts +1 -1
  23. package/dist/config.js +5 -5
  24. package/dist/http/routes/approvals-assistant.js +3 -3
  25. package/dist/http/routes/runs.js +7 -7
  26. package/dist/http/routes/sessions.js +1 -1
  27. package/dist/http/routes/trace-usage.js +2 -2
  28. package/dist/http/server.d.ts +1 -1
  29. package/dist/http/server.js +3 -3
  30. package/dist/key-resolver.d.ts +14 -0
  31. package/dist/key-resolver.js +27 -11
  32. package/dist/plugins/remote-env-host.d.ts +32 -14
  33. package/dist/plugins/remote-env-host.js +30 -10
  34. package/dist/plugins/remote-env-local-docker.d.ts +14 -0
  35. package/dist/plugins/remote-env-local-docker.js +10 -3
  36. package/dist/plugins/run-store-sql.d.ts +8 -3
  37. package/dist/plugins/run-store-sql.js +8 -3
  38. package/dist/run-local.js +2 -2
  39. package/dist/runtime-caps-resolver.d.ts +1 -1
  40. package/dist/runtime-caps-resolver.js +1 -1
  41. package/dist/runtime-governance.d.ts +1 -1
  42. package/dist/runtime-governance.js +1 -1
  43. package/dist/sema-registry.d.ts +18 -4
  44. package/dist/sema-registry.js +17 -3
  45. package/package.json +2 -2
package/README.md CHANGED
@@ -132,7 +132,7 @@ The server is configured entirely through environment variables. The most import
132
132
  | `PORT` | `8090` | HTTP listen port |
133
133
  | `BIND_HOST` (alias `HOST`) | see note | Listen address. An explicit value **always wins**. Default: `127.0.0.1` when the write face is unauthenticated (`ALLOW_UNAUTHED_WRITES=true` **and** no service token configured), otherwise all interfaces — deployments with a token are unaffected. |
134
134
  | `MODEL_GATEWAY_BASEURL` | `http://127.0.0.1:8000/v1` | OpenAI-compatible gateway base URL (without `/chat/completions`) |
135
- | `MODEL_ID` | **required** | Default model id — **no factory default since 3.0.0**. Unset ⇒ the server refuses to boot with a message naming the knob (the old baked-in default was an internal-only model name, so every external deployment failed later and further from the cause: a gateway `400` plus a cascade of title-hook warnings). Set it to whatever model name your gateway serves, or supply the catalog via the sema-registry control plane |
135
+ | `MODEL_ID` | **required** | Default model id — **no factory default since 3.0.0**. Unset ⇒ the server refuses to boot with a message naming the knob (the old baked-in default was an internal-only model name, so every external deployment failed later and further from the cause: a gateway `400` plus a cascade of title-hook warnings). Set it to whatever model name your gateway serves, or supply the catalog via the config-center control plane |
136
136
  | `MODEL_API_KEY` | — | Gateway API key (optional) |
137
137
  | `SERVICE_AUTH_TOKEN` | — | Callers must send `Authorization: Bearer <token>` |
138
138
  | `DB_BACKEND` | `local`* | `mysql` (any MySQL-protocol DB: MySQL/TiDB/MariaDB; `tidb` alias) / `pg` (PostgreSQL) / `local` (file-backed, no DB) / `memory` (explicit in-memory: nothing survives a restart, durable-runs faces 501). *Bare boot (no DB env at all) defaults to `local` so a single-user machine keeps its runs across restarts; any SQL signal (`SESSION_BACKEND` or `TIDB_/MYSQL_/PG_HOST`) keeps the `mysql` engine default, and `REQUIRE_PRINCIPAL=true` bare boots stay `memory` (the local file store has no tenant isolation — a warning says so). A DEFAULT-derived `local` that cannot create its data root degrades to memory with a warning + the `store_backend_degraded` gauge; an EXPLICIT `DB_BACKEND=local` fails loud instead. Setting `mysql`/`pg` explicitly also switches sessions to durable |
package/README.zh-CN.md CHANGED
@@ -125,7 +125,7 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
125
125
  | `PORT` | `8090` | HTTP 监听端口 |
126
126
  | `BIND_HOST`(兼容 `HOST`) | 见说明 | 监听地址。显式值**恒生效**。缺省:写面无鉴权时(`ALLOW_UNAUTHED_WRITES=true` **且**未配任何 service token)= `127.0.0.1`,否则全接口——配了 token 的部署不受影响。 |
127
127
  | `MODEL_GATEWAY_BASEURL` | `http://127.0.0.1:8000/v1` | OpenAI 兼容网关地址(不带 `/chat/completions`) |
128
- | `MODEL_ID` | **必填** | 缺省模型 id ——**3.0.0 起无出厂缺省**。未设 = 启动即失败并指路该旋钮(旧的烤死缺省是内网模型名,外部部署必炸且炸在离根因最远处:网关 `400` + 标题 hook 连环告警)。填你的网关真正提供的模型名,或改由 sema-registry 控制面下发目录 |
128
+ | `MODEL_ID` | **必填** | 缺省模型 id ——**3.0.0 起无出厂缺省**。未设 = 启动即失败并指路该旋钮(旧的烤死缺省是内网模型名,外部部署必炸且炸在离根因最远处:网关 `400` + 标题 hook 连环告警)。填你的网关真正提供的模型名,或改由配置控制面下发目录 |
129
129
  | `MODEL_API_KEY` | — | 网关 key(可选) |
130
130
  | `SERVICE_AUTH_TOKEN` | — | 调用方需带 `Authorization: Bearer <token>` |
131
131
  | `DB_BACKEND` | `mysql` | SQL 引擎:`mysql`(任何 MySQL 协议库:MySQL/TiDB/MariaDB;`tidb` 为兼容别名)/ `pg`(PostgreSQL)/ `local`(免 DB 文件持久化)。显式设置 `mysql`/`pg` 时 session 自动转 durable |
package/USAGE.md CHANGED
@@ -78,11 +78,11 @@ MODEL_DEGRADE_REACTIVE=true # ② 反应式(1.39):主模型 rate_limit
78
78
  MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;默认两者都开
79
79
  ```
80
80
  - 反应式降级 brain 包在**最外层**(core council #8);fallback brain 用自己的凭据(decorator 清掉主模型的 per-call key 防泄漏给别的 provider)。**坑**:`MODEL_DEGRADE_TO` 最好别和被限流的是同一网关/账号,否则反应式切过去照样撞同一个 rate_limit。
81
- - **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**,默认 0 = `costUsd` 读 0)。云模型(如 review-gw 的 deepseek-v4-pro)必须设,否则 spend 恒为 $0;本地自托管(qwen)留 0 即对(无 per-token 外部花费)。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。sema-registry 管的模型走 `CenterModel.cost`(中心存价、不存 secret)。
81
+ - **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**,默认 0 = `costUsd` 读 0)。云模型(如 review-gw 的 deepseek-v4-pro)必须设,否则 spend 恒为 $0;本地自托管(qwen)留 0 即对(无 per-token 外部花费)。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。配置控制面管的模型走 `CenterModel.cost`(中心存价、不存 secret)。
82
82
  - 成本计量**自动开**:从 config 的 `model.cost`(per-1M 绝对 USD)注入 `pricing`,core 算出权威的整数 `costMicroUsd`(避免浮点累计误差)。`/metrics` 新增:`model_cost_micro_usd_total{model}`(覆盖所有 brain 调用=主任务+异步+council 子任务的总花费)、`brain_first_token_ms`(网关 hang 早警)、`brain_call_latency_ms`、`tool_calls_total{name,ok}`、`budget_exceeded_total{code}`、`cost_quota_rejected_total`、`degraded_total{reason}`(1.40 降级)。
83
83
  - **近预算降级 vs 硬闸**:降级(`MODEL_DEGRADE_TO`,到 `atCostFraction` 切便宜模型)是**撑长**预算、任务仍完成(出口质量下降、发 `task.degraded` 事件可告警);硬闸(`maxCostUsd` 全额)仍在,切了便宜模型还超全额 → `budget.exceeded` 停。
84
- - **`METRICS_TOKEN`**(可选,只读):设了它,`GET /metrics`+`/metrics/summary` 接受**它或** `SERVICE_AUTH_TOKEN`。**全 fleet 设同一个值** → 控制面(sema-registry)用**一个** token 拉所有 worker 的指标,**无需持有各 worker 的全权 `SERVICE_AUTH_TOKEN`**(不破坏 secret 边界)。即使泄露也只暴露指标(只读)。
85
- - **`GET /metrics/summary`**(token-gated,同 `/metrics`):`/metrics` 的**精炼 JSON**——`{model, runsActive, tasks{status}, tokensTotal, costUsd, costUsdByModel, taskDurationAvgSec, brainFirstTokenAvgMs, brainCallAvgMs, cacheHitRateAvg, rateLimited, costQuotaRejected, budgetExceeded, degraded, cascade, verifications, councilRuns, toolErrors}`。给**轻量 fleet 看板**用(sema-registry 的 fleet 页按 worker 拉、渲染卡片、按轮询算速率;**不用 Prometheus/Grafana**)。counter 是累计值、histogram 报均值。
84
+ - **`METRICS_TOKEN`**(可选,只读):设了它,`GET /metrics`+`/metrics/summary` 接受**它或** `SERVICE_AUTH_TOKEN`。**全 fleet 设同一个值** → 管理端(sema-web,规划名 sema-admin)用**一个** token 拉所有 worker 的指标,**无需持有各 worker 的全权 `SERVICE_AUTH_TOKEN`**(不破坏 secret 边界)。即使泄露也只暴露指标(只读)。
85
+ - **`GET /metrics/summary`**(token-gated,同 `/metrics`):`/metrics` 的**精炼 JSON**——`{model, runsActive, tasks{status}, tokensTotal, costUsd, costUsdByModel, taskDurationAvgSec, brainFirstTokenAvgMs, brainCallAvgMs, cacheHitRateAvg, rateLimited, costQuotaRejected, budgetExceeded, degraded, cascade, verifications, councilRuns, toolErrors}`。给**轻量 fleet 看板**用(管理端 sema-web 的 fleet 页按 worker 拉、渲染卡片、按轮询算速率;**不用 Prometheus/Grafana**)。counter 是累计值、histogram 报均值。
86
86
  - 预算闸是 core 强制的:`maxCostUsd` pre-call 估算(`budget.precall`,没花钱就拒)+ 流中途取消 + turn 边界(`budget.exceeded`);`maxTokens` 超 → `budget.exceeded`。**review 网关**建议设 `MAX_TASK_COST_USD` 防单任务烧掉共享云 key。
87
87
  - **per-principal 累计配额**:用 `AsyncLocalStorage` 把 principal 透传到 cost tracer,所以**council/team 子任务的花费也算到发起人头上**。**配了 `SESSION_BACKEND=tidb` 时自动跨副本共享**(`cost_quota` 表,写后聚合的**原子自增** `micro=micro+delta`,对齐固定窗,最终一致——多副本花费 SUM 到一起、不丢增量);否则 in-memory per-replica 滚动窗(单副本兜底)。启动日志 `costQuota` 字段回显 `shared(tidb)`/`in-process`/`off`。跨副本是最终一致(flush 间隔内峰值可能略超,由**硬 per-task `maxCostUsd` 兜底**)。
88
88
  - **`RATE_LIMIT_RPM` 请求限流同样自动跨副本**(`SESSION_BACKEND=tidb` 时,`rate_limit` 表,与配额共用 `WriteBehindCounter`;启动日志 `rateLimit` 回显)。注意:写后聚合 = **软限流**(边界上短暂略超 OK,适合公平/热调用方防护);要**硬合规上限**得另走 CAS/原子计数,不靠写后聚合——和断路器跨副本同款权衡。
@@ -130,7 +130,7 @@ MODEL_CODE_ROLES=default,subagent # 不设=全中立;仅这些角色在「
130
130
  MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheapest→strongest;不设=cascade 不可用
131
131
  ```
132
132
  - 结果带 `cascadeOutcome:"passed"|"exhausted"` + `escalated`/`finalRung`/`attempts[]`。`/metrics` 加 `cascade_total{outcome}`。
133
- - **每档用自己的上游 key**:从 sema-registry 模型的 `apiKeyEnv`(env 变量**名**,非密钥值)解析——同一网关下不同模型/账号各用各的 key,没配 `apiKeyEnv` 的模型回落到网关 key(`MODEL_API_KEY`)。`baseUrl` 仍由 brain 层统一持有(非目标)。启动日志 `perModelKeys=N` 报有几个模型带了自己的 key。
133
+ - **每档用自己的上游 key**:从配置控制面模型的 `apiKeyEnv`(env 变量**名**,非密钥值)解析——同一网关下不同模型/账号各用各的 key,没配 `apiKeyEnv` 的模型回落到网关 key(`MODEL_API_KEY`)。`baseUrl` 仍由 brain 层统一持有(非目标)。启动日志 `perModelKeys=N` 报有几个模型带了自己的 key。
134
134
 
135
135
  **嵌入形契约(1.309+;桌面宿主把引擎作为依赖内嵌启动的正门)**
136
136
  - 入口:`import "@sema-agent/server/main"`(exports 正门;此前宿主只能用 node_modules 路径字符串定位
@@ -184,16 +184,16 @@ SEND_USER_FILE_SANDBOX_PUT_ENDPOINT=… # 可选:沙箱直传 PUT 的端
184
184
  - **执行 lane 与租户门**:e2b/k8s 沙箱 lane 任意租户可用(沙箱文件系统=租户边界,沙箱内 `curl -T` 直传、字节不中转、凭据不进沙箱);host/ssh lane 仅单用户部署(`REQUIRE_PRINCIPAL` 未开)时挂载。
185
185
  - **web 侧感知**:`GET /v1/capabilities` 透出 `sendUserFile`(READY 语义:工具真挂载**且**公网端点在场——调用真能成功才 yes)与 `s3PublicEndpoint`(渲染用公网 base;绝不透出密钥/内网端点)。
186
186
 
187
- **可选 — 接 sema-registry(中心化模型/角色/团队配置)**
187
+ **可选 — 接配置控制面(中心化模型/角色/团队配置)**
188
188
  ```bash
189
189
  # 新名(orchestrator 现注入);旧名 CONFIG_CENTER_* 仍兼容(dual-read,新名优先、旧名回退)
190
- SEMA_REGISTRY_URL=http://<sema-registry-host>:3100 # 启动拉 GET /api/config/effective(Bearer+ETag),覆盖 env 兜底
191
- SEMA_REGISTRY_TOKEN=<SERVICE_PULL_TOKEN 的值> # 取自 sema-registry 主机 .env;只读拉取令牌
190
+ SEMA_REGISTRY_URL=http://<config-center-host>:3100 # 启动拉 GET /api/config/effective(Bearer+ETag),覆盖 env 兜底
191
+ SEMA_REGISTRY_TOKEN=<SERVICE_PULL_TOKEN 的值> # 取自配置控制面主机 .env;只读拉取令牌
192
192
  SEMA_REGISTRY_DRY_RUN=true # 安全灰度:只 LOG 中心配置 vs env 推导的差异,不 apply
193
193
  SEMA_REGISTRY_WORKER=<worker名> # 可选:拉取 /effective?worker=<名> 取该 worker 的 roster(reconciler 按 worker 注);不设=全局 roster(向后兼容)
194
194
  ```
195
195
  - 中心**空/未发布** → `applyEffective` 回落 env + 内建 teams 并 warn `config_center_unpublished`,**不影响在跑的服务**(接了也安全)。
196
- - **灰度姿势**(sema-registry AI 建议):先 `SEMA_REGISTRY_DRY_RUN=true` 起一轮,看日志 `sema_registry_dry_run`(中心给的 models/roles/teams + 会否覆盖 default、per-model apiKeyEnv)对得上 env 再去掉该 flag 真正 apply。
196
+ - **灰度姿势**(配置控制面 AI 建议):先 `SEMA_REGISTRY_DRY_RUN=true` 起一轮,看日志 `sema_registry_dry_run`(中心给的 models/roles/teams + 会否覆盖 default、per-model apiKeyEnv)对得上 env 再去掉该 flag 真正 apply。
197
197
  - 拉取**只读、只取逻辑配置**(模型名册/角色/团队);密钥/网关仍在本服务 env(中心只发 env-**名** 引用,不发密钥值)。models/roles 改动需重启生效,teams 60s 热刷。回滚=去掉 `SEMA_REGISTRY_URL` 即纯 env。
198
198
  - **仅 `/v1/tasks`(同步)+ `/v1/runs`(异步)**——`/v1/tasks/stream` 不支持(多次尝试非单流,400)。与 `verify` **互斥**(同时给 → 400)。
199
199
  - 成本上界 = 任务的 `maxCostUsd`(防冷重跑税)。**三条 core 警示**:① 每档**冷重跑**重付输入成本(便宜档常过才划算);② 门收到**未脱敏**输出(自定义门转发外部 verifier 要脱敏);③ **写工具会跑 N 次**——**只用于只读/幂等任务**(每次升级整任务重跑)。开放式任务(找全 bug/文笔)没有可判定 oracle、级联会空转,那种用 `team`(广度对抗)而非级联(深度阶梯)。
@@ -239,6 +239,10 @@ SEMA_REGISTRY_WORKER=<worker名> # 可选:拉取 /effective?worke
239
239
  🪦 `LSP_ENABLED=false`(拆分前唯一的 host 腿逃生舱)自 3.0.0 起是墓碑:拒启并指路 `LSP_HOST_ENABLED`。
240
240
  `LSP_ENABLED=true` 不受影响——那是沙箱腿自己的 opt-in,语义没变。
241
241
 
242
+ **`CONFIG_REQUIRE_ROSTER=true`(3.1.0,可选,缺省关)**:远程 registry 非 dryRun 车道的 fleet worker
243
+ 可布防「roster 落地前拒接计费提交(503)」——3.0.0 起 MODEL_ID 必填,每个 worker 都带 boot 模型起服,
244
+ 旧的「无 env 模型即等 roster」推断失效;要那个保护语义现在需显式声明。
245
+
242
246
  ---
243
247
 
244
248
  ## 1. 请求与响应的通用规则
@@ -260,7 +264,7 @@ SEMA_REGISTRY_WORKER=<worker名> # 可选:拉取 /effective?worke
260
264
  | `sessionId` | — | 续聊:带上次返回的 `sessionId`,服务端自动 wake 历史 |
261
265
  | `images` | — | 图文输入 `[{data,mimeType}|{url}]`(模型需支持 vision) |
262
266
  | `attachmentIds` | — | D-1 通用文件上传(1.289+):先 `POST /v1/attachments?name=…`(raw body,content-type=mime)拿句柄,提交时引用 ≤16 个;文件物化到执行环境工作目录 `attachments/` 下,objective 尾部自动追加文件清单(内容不进会话流)。单文件缺省 ≤32 MiB(`ATTACHMENT_MAX_BYTES`);可配 mime 白名单(`ATTACHMENT_MIME_ALLOWLIST` CSV,缺省不限);上传后未引用的按 `ATTACHMENT_UNBOUND_TTL_MS`(缺省 24h)回收。**云形态(tidb/pg)字节本体存对象存储——MinIO 必配**(`MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY`,与快照 lane 同一组变量),未配则附件面 501;local 形走本地文件店。 |
263
- | `scenario` | — | `default`(默认)/ `code-review`(见 §5)/ `scan`(同 §5 的 repo 只读工具但**中性无框架提示词**——objective+中心下发 skill 全权主导输出,OA 扫描类用)/ **sema-registry 可声明任意新场景**(`{name, toolset: none\|repo-readonly, prompt?}`,组合即配置、能力钉死在部署;restart-to-apply;center 可覆盖内建名,boot 日志 `config_center_scenarios.shadowsBuiltin` 可审计) |
267
+ | `scenario` | — | `default`(默认)/ `code-review`(见 §5)/ `scan`(同 §5 的 repo 只读工具但**中性无框架提示词**——objective+中心下发 skill 全权主导输出,OA 扫描类用)/ **配置控制面可声明任意新场景**(`{name, toolset: none\|repo-readonly, prompt?}`,组合即配置、能力钉死在部署;restart-to-apply;center 可覆盖内建名,boot 日志 `config_center_scenarios.shadowsBuiltin` 可审计) |
264
268
  | `repo` / `council` / `debate` | — | `repo` 为 `code-review`/`scan` 必填;`council`/`debate` 仅 `code-review`,见 §5 |
265
269
  | ~~model / tools / prompt~~ | 🚫 | **不接受**——服务端注入 |
266
270
 
@@ -4,8 +4,7 @@
4
4
  * ./principal-jwt.ts imports {@link verifyApprovalHmac} from here for the combined proof).
5
5
  *
6
6
  * ✅ WIRED into the resolve path (was "🔴 SCAFFOLD — NOT YET WIRED" — that claim went STALE; a 2026-07-25 doc-rot
7
- * sweep traced the real call chain: {@link verifyApprovalHmac} ← `verifyDirectDoorProof` ← the live decide
8
- * routes at `http/server.ts:4796` and `:4907`). So "not wired" was wrong; "not turned on" is right, and turning it
7
+ * sweep traced the real call chain: {@link verifyApprovalHmac} ← `verifyDirectDoorProof` ← * routes at `http/server.ts`(resumeCheckpoint 链;A9 拆分后行级坐标弃用,文件级指路+grep verifyDirectDoorProof 取证). So "not wired" was wrong; "not turned on" is right, and turning it
9
8
  * on is a DEPLOYMENT + KEY-PROVISIONING task (sema-registry mints/rotates the set), not a coding task.
10
9
  *
11
10
  * FULL activation chain (an independent 2026-07-25 cross-check caught the first draft of this note naming only the
@@ -66,7 +66,7 @@ export function liveRuntimeConfigFromEnv(env = process.env) {
66
66
  if (!(costInput > 0) || !(costOutput > 0))
67
67
  return undefined;
68
68
  return {
69
- modelId: env.MODEL_ID || "deepseek-v4-pro",
69
+ modelId: env.MODEL_ID ?? (() => { throw new Error("MODEL_ID is required for the S1 bench harness (no factory default since server 3.0.0 — same invariant as loadConfig)"); })(),
70
70
  gatewayBaseUrl: env.MODEL_GATEWAY_BASEURL || "https://api.deepseek.com",
71
71
  deepseekApiKey,
72
72
  e2bApiKey,
@@ -6,7 +6,7 @@ import type { LoadedSkill } from "../capabilities/skills.js";
6
6
  import { type ServiceConfig } from "../config.js";
7
7
  import type { Logger } from "../observability/logger.js";
8
8
  import type { Metrics } from "../observability/metrics.js";
9
- import { type RestartSignal } from "../sema-registry.js";
9
+ import { type RestartSignal } from "../config-center/facade.js";
10
10
  export interface ConfigCenterRuntimeCtx {
11
11
  config: ServiceConfig;
12
12
  logger: Logger;
@@ -47,7 +47,8 @@ export interface ConfigCenterRuntime {
47
47
  } | undefined>) | undefined;
48
48
  /** 晚绑取值:center 提示词面(热采用,新任务边界生效)。 */
49
49
  getCenterPrompts(): PromptsDomainFaces | undefined;
50
- /** boot ready 门:roster 未落地 = 计费提交 503 + /health ready:false。 */
50
+ /** boot ready 门:布防(`CONFIG_REQUIRE_ROSTER=true`,远程 registry dryRun)且 roster 未落地 =
51
+ * 计费提交 503 + /health ready:false。缺省未布防 ⇒ 恒 true。 */
51
52
  modelReady(): boolean;
52
53
  /** /health.restart(orchestrator 自动重启信号)。 */
53
54
  restartState(): RestartSignal | undefined;
@@ -45,10 +45,10 @@ import { defaultLkgPath, defaultSkillCacheDir, saveLkg, loadLkg } from "../confi
45
45
  import { createConfigProvider, raceBootFetch, BOOT_FETCH_DEFERRED } from "../config-provider.js";
46
46
  import { createKeyResolver } from "../key-resolver.js";
47
47
  import { ensureSealedKeyStore, reportExecutionPublicKey } from "../sealed-key.js";
48
- import { applyEffective, mutateInPlace, logEffectiveDiff, applyCenterSkills, resolveMcpServers, restartReasons, modelPlaneChanged, planeHasActiveTiers, fetchPromptArtifact } from "../sema-registry.js";
48
+ import { applyEffective, mutateInPlace, logEffectiveDiff, applyCenterSkills, resolveMcpServers, restartReasons, modelPlaneChanged, planeHasActiveTiers, fetchPromptArtifact } from "../config-center/facade.js";
49
49
  export async function createConfigCenterRuntime(ctx) {
50
50
  const { config, logger, metrics, localRoot } = ctx;
51
- // Sema registry (sema-registry): pull the effective config on boot and apply it OVER the env
51
+ // Config-center: pull the effective config on boot and apply it OVER the env
52
52
  // defaults — env is the fallback. Unset or unreachable → keep the env-derived config. Applied BEFORE
53
53
  // the Runner is built so models/roles/teams take effect. (Logical config only; secrets stay in env.)
54
54
  let ccEtag;
@@ -81,10 +81,10 @@ export async function createConfigCenterRuntime(ctx) {
81
81
  // same-generation 200 recomputes reasons vs boot as [] and would otherwise silently clear the signal).
82
82
  // Set at the boot writer (R34 arms); folded into every tick's reasons; only a restart resolves it.
83
83
  let bootSkillStale = false;
84
- // Sema-registry restart signal: set by the refresh loop when a pull's restart-to-apply slices
84
+ // Config-center restart signal: set by the refresh loop when a pull's restart-to-apply slices
85
85
  // (skills/mcp/scenarios/runtime-gates) differ from boot; read by GET /health for orchestrator auto-restart.
86
86
  let pendingRestart;
87
- // DUAL-MODE-DESIGN §4: the config SOURCE is a backend-selectable seam — remote (sema-registry HTTP, the fleet
87
+ // DUAL-MODE-DESIGN §4: the config SOURCE is a backend-selectable seam — remote (config-center HTTP, the fleet
88
88
  // default) | local (.env + config.d via @sema-agent/registry-core FileConfigStore, the single-machine/TOC source).
89
89
  // The provider exists when SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL) is set OR CONFIG_PROVIDER=local is explicit; neither = pure-env
90
90
  // (byte-identical to before). The remote provider DELEGATES verbatim to fetchEffective, so the fleet path is
@@ -119,17 +119,24 @@ export async function createConfigCenterRuntime(ctx) {
119
119
  ...(config.configProvider === "local" ? { localDir: localRoot } : {}),
120
120
  })
121
121
  : undefined;
122
- // boot ready 门 latch:仅「远程 registry 部署(非 dryRun)+ 无显式 env MODEL_ID」时等 roster——
123
- // 首次 effective ≥1 enabled 模型即翻 true(one-way)。其余姿势(env 模型在/local provider/dryRun/纯 env)
124
- // 恒 ready=现有部署零影响。E3 废除 workers.model 后,fleet worker 的占位模型窗口由此门 fail-closed
122
+ // boot ready 门 latch:布防时等中心 roster 落地——首次 effective ≥1 enabled 模型即翻 true(one-way);
123
+ // 未布防则恒 ready。守的危险=**registry 部署的 fleet worker roster 落地前拿占位模型跑计费任务**
124
+ // (E3 废除 workers.model 之后,占位模型正是 fleet worker 的常态起服姿势)
125
125
  //
126
- // ⚠️ 如实记账(复审 2026-07-29 C1):**server 3.0.0 起 `MODEL_ID` 是必填**(未设 = loadConfig throw,
127
- // config.ts parseModelDomain),所以走到这一行时 `process.env.MODEL_ID` 必非空 ⇒ 这个初值**当前恒为
128
- // true**,「等 roster」那条臂在今天的配置面上不可达。保留原式不改行为,原因有二:① latch
129
- // `markRosterLanded` 是同一套机制,删掉初值里的判据不会让代码变简单,只会让重新开门时无处可挂;
130
- // registry 轴一旦允许「无 env MODEL_ID、模型全由中心 roster 下发」(E3 之后这正是 fleet worker 的目标
131
- // 形态),这条臂就要重新活过来 —— 届时应连同 MODEL_ID 必填的口径一起重估,而不是各改各的。
132
- const modelReadyState = { ready: !(cc && !cc.dryRun) || Boolean(process.env.MODEL_ID) };
126
+ // 🔧 复审 2026-07-29 P1-6 —— 布防判据从**推断**改成**运营者声明**。原式是
127
+ // `!(cc && !cc.dryRun) || Boolean(process.env.MODEL_ID)`,其中 `Boolean(process.env.MODEL_ID)`
128
+ // 3.0.0 前「运营者手上除了中心 roster 什么都没有」的代理判据。**server 3.0.0 起 `MODEL_ID` 必填**
129
+ // (未设 = loadConfig throw,见 config.ts parseModelDomain)⇒ 走到这一行时它必非空 ⇒ 初值恒 true ⇒
130
+ // 「等 roster」那条臂不可达、markRosterLanded 的门体是死码、/health ready:false 与计费提交的 503
131
+ // 全不可能发生。注意这不是「危险消失了」:必填 MODEL_ID 恰恰让**每个** fleet worker 都带着占位模型
132
+ // 起服,危险比以前更常见——只是那个代理判据从此写不出来了。
133
+ //
134
+ // 于是改成显式声明:`CONFIG_REQUIRE_ROSTER=true`(与 CONFIG_LKG_* / CONFIG_BOOT_FETCH_BUDGET_MS 同族,
135
+ // 直读 env,不进 ServiceConfig——纯 boot 期姿势旋钮)。**缺省关 = 现有部署逐字节零变化**;开了才真的
136
+ // fail-closed 等 roster。仅远程 registry 且非 dryRun 车道可布防:dryRun 是 compare-only(中心 roster
137
+ // 从不被应用,拿它当就绪判据无意义),local provider / 纯 env 部署则没有「中心」可等。
138
+ const rosterGateArmed = cc !== undefined && !cc.dryRun && process.env.CONFIG_REQUIRE_ROSTER === "true";
139
+ const modelReadyState = { ready: !rosterGateArmed };
133
140
  const markRosterLanded = (eff) => {
134
141
  if (!modelReadyState.ready && (eff.models?.models ?? []).some((m) => m.enabled !== false)) {
135
142
  modelReadyState.ready = true;
@@ -463,12 +470,19 @@ export async function createConfigCenterRuntime(ctx) {
463
470
  }
464
471
  }
465
472
  if (!modelReadyState.ready)
466
- logger.warn("model_roster_pending", { note: "registry deployment without an env model and no roster yet — billable submissions 503 until the first effective pull lands models" });
467
- // Per-model API key (sema-registry `apiKeyEnv`): resolve each model's own upstream key per brain call /
473
+ logger.warn("model_roster_pending", {
474
+ note: "CONFIG_REQUIRE_ROSTER=true and no center roster has landed yet — the env MODEL_ID is treated as a PLACEHOLDER, so billable submissions 503 (and /health reports ready:false) until the first effective pull lands ≥1 enabled model. Unset the knob if this worker's env model is authoritative.",
475
+ });
476
+ // Per-model API key (config-center `apiKeyEnv`): resolve each model's own upstream key per brain call /
468
477
  // cascade rung. undefined when no per-model keys are configured → spec field stays unset (core default).
469
- // `let` (not const): rebuilt on a sema-registry refresh so per-model key ADDITIONS hot-apply too (the
478
+ // `let` (not const): rebuilt on a config-center refresh so per-model key ADDITIONS hot-apply too (the
470
479
  // resolver is undefined when no per-model keys exist, so in-place mutation alone wouldn't cover empty→non-empty).
471
480
  let keyResolver;
481
+ // 复审 2026-07-29 P1-11:restart 片里唯一一块 EffectiveConfig **看不见**的输入 —— 反应式降级车道由 env
482
+ // (MODEL_DEGRADE_TO / MODEL_DEGRADE_REACTIVE)开关,而它焊死的是 CENTER 目录里那条模型的路由面。读成
483
+ // 函数而非常量:`config.degrade` 今天由 env 独占(applyEffective 不写它),但若哪天中心接管这面,这里
484
+ // 自动跟着走,不会退化成 boot 期快照。车道关(默认)⇒ ctx 空 ⇒ 该片恒 null ⇒ 零行为变化。
485
+ const restartCtx = () => (config.degrade?.reactive ? { reactiveDegradeTo: config.degrade.to } : {});
472
486
  return {
473
487
  providerKind: configProvider?.kind,
474
488
  promptSource,
@@ -794,7 +808,7 @@ export async function createConfigCenterRuntime(ctx) {
794
808
  // 顺序=先持久化(含 skill 正文预热,F1)再发信号;持久化失败=warn+照发(不发=配置永不生效;
795
809
  // 「盘坏+中心挂」双故障下环重现,接受并点名)。
796
810
  const lkgPersisted = await persistLkgDurable(r.effective, r.etag);
797
- const reasons = restartReasons(effective, r.effective);
811
+ const reasons = restartReasons(effective, r.effective, restartCtx());
798
812
  // codex R35: the sticky boot skill debt rides EVERY tick's reasons — change-detection vs boot is
799
813
  // blind to it (the stale table IS the boot baseline). Under a no-handoff deferral it folds into
800
814
  // blocked (promoted later); otherwise it keeps pendingRestart alive until the restart happens.
@@ -956,7 +970,7 @@ export async function createConfigCenterRuntime(ctx) {
956
970
  latestEffective = r.effective;
957
971
  ccEtag = r.etag;
958
972
  const lkgPersistedLate = await persistLkgDurable(r.effective, r.etag); // F2 parity:信号可见前落盘+skill 正文预热
959
- const reasons = restartReasons(undefined, r.effective); // (prompts 不在 restart slices——热采用已在上方 adopt)
973
+ const reasons = restartReasons(undefined, r.effective, restartCtx()); // (prompts 不在 restart slices——热采用已在上方 adopt)
960
974
  // codex R15 (late-boot twin): tiered env boot + tier-less late candidate ⇒ deferral fires but BOTH slice
961
975
  // fingerprints reduce to null (undefined baseline, tier-less candidate) — no models-tiers reason, plane
962
976
  // deferred forever. Deferral is the ground truth; force the reason so the orchestrator restarts and the
@@ -40,7 +40,7 @@ import { isIsolatedExecEnv, routeServiceTask, supPostureOverrides } from "../rou
40
40
  import { gateExecutionLane } from "../runtime-caps-resolver.js";
41
41
  import { applyRuntimeGovernance, stripDelegationTools } from "../runtime-governance.js";
42
42
  import { HttpError, memorySpecForRequest } from "../security.js";
43
- import { mcpForScenario } from "../sema-registry.js";
43
+ import { mcpForScenario } from "../config-center/facade.js";
44
44
  import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, normalizeSuggestNextPrompts, promptProfileFromBody, resolveTaskLimits, retainBackgroundProcessesFromBody, taskAgentsSpecFragment, toolNameListFromBody } from "../spec-fields.js";
45
45
  import { cwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
46
46
  import { resolveRequestMcp } from "../task-mcp.js";
@@ -312,8 +312,8 @@ export function createResolveSpec(ctx) {
312
312
  // them to bounded text placeholders per current model (the pass-through posture above). That modality drop
313
313
  // was invisible fleet-side; count + warn at submit. modelSupportsImages is false-safe (undeclared input →
314
314
  // true → no signal), matching core's "never wrongly strip" posture — no false positives for verbatim
315
- // pass-through models. ⚠️ sema-registry lane caveat (review LOW-2): the roster maps `vision?: boolean` to
316
- // an EXPLICIT input list (sema-registry.ts) — a vision-capable roster model that FORGOT the flag becomes
315
+ // pass-through models. ⚠️ config-center lane caveat (review LOW-2): the roster maps `vision?: boolean` to
316
+ // an EXPLICIT input list (config-center/facade.ts) — a vision-capable roster model that FORGOT the flag becomes
317
317
  // declared text-only, so this signal fires AND core really does degrade its images (signal matches
318
318
  // behavior; the fix for that footgun is the roster entry, not this counter). The per-run durable event rides core's vision.placeholder trace kind once we
319
319
  // consume >=1.245 (the authoritative replacement-time signal), not this submit-time predictor.
@@ -586,7 +586,7 @@ export function createResolveSpec(ctx) {
586
586
  // L2 ultracode (design/111): the preset FLOORS thinking at the ultra tier (= max, via core resolveReasoningProfile);
587
587
  // awareness rides for free (core composes it from thinking∈{xhigh,max}). Else the explicit reasoningEffort.
588
588
  thinking: effectiveThinking(body.reasoningEffort, parsedSettings.settings?.ultracode === true),
589
- // Per-model auth (sema-registry apiKeyEnv): core calls this per brain call / cascade rung so each
589
+ // Per-model auth (config-center apiKeyEnv): core calls this per brain call / cascade rung so each
590
590
  // model uses its own upstream key; a model without one falls back to the gateway key. undefined
591
591
  // when no per-model keys are configured → unchanged single-key behavior.
592
592
  getApiKeyAndHeaders: getKeyResolver(), // A10 搬运改写②:活引用取值(原 `keyResolver`)
@@ -950,7 +950,7 @@ export function createResolveSpec(ctx) {
950
950
  throw new HttpError(400, "sandboxImageProfile is only supported on the k8s sandbox backend");
951
951
  }
952
952
  if (!imageIndex) {
953
- throw new HttpError(400, "sandboxImageProfile requires the image index (sema-registry backend not configured)");
953
+ throw new HttpError(400, "sandboxImageProfile requires the image index (config-center backend not configured)");
954
954
  }
955
955
  if (!auth?.sessionId) {
956
956
  throw new HttpError(400, "sandboxImageProfile requires a resolved session");
package/dist/brain.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createAnthropicBrain, createCircuitBreakerBrain, createDegradingBrain, createFailoverBrain, createOpenAIBrain, createRoutingBrain, } from "@sema-agent/core";
2
+ import { resolveModelApiKey } from "./key-resolver.js";
2
3
  const DEFAULT_RESILIENCE = {
3
4
  // Watchdog defaults ON — mirrors config.ts env defaults so a config object without a
4
5
  // resilience block gets the same posture. connect/firstToken per the [880] fleet audit
@@ -95,20 +96,52 @@ export function createBrain(config, deps = {}) {
95
96
  // credentials (the decorator clears the primary's per-call apiKey so it can't leak to a different
96
97
  // provider). fallbackModel resolved from the catalog.
97
98
  if (config.degrade?.reactive) {
98
- const fallbackModel = config.models[config.degrade.to];
99
+ const degradeTo = config.degrade.to;
100
+ const fallbackModel = config.models[degradeTo];
99
101
  if (fallbackModel) {
100
102
  // Cross-gateway degrade (audit B, brain.ts): the degrading decorator clears the per-call apiKey
101
103
  // (so the primary's key can't leak to another provider), which left the fallback authenticating
102
104
  // with the PRIMARY gateway key even when the catalog points the target at a different gateway.
103
- // Resolve the target's own key at construction via the existing per-model apiKeyEnv mechanism
104
- // (sema-registry catalog); `model.baseUrl` already wins inside the openai brain — pass it
105
- // explicitly so the route is visible here. Unset catalog baseUrl/apiKeyEnv = same-gateway degrade
106
- // (unchanged), which stays meaningful for per-model limits (e.g. deepseek pro→flash).
107
- const fbKeyEnv = config.modelApiKeyEnv?.[fallbackModel.name] ?? config.modelApiKeyEnv?.[config.degrade.to];
108
- const fbApiKey = (fbKeyEnv ? process.env[fbKeyEnv] : undefined) || config.gatewayApiKey;
105
+ // Resolve the target's own key via the SAME chain the main path uses; `model.baseUrl` already wins
106
+ // inside the openai brain — pass it explicitly so the route is visible here. No per-model key =
107
+ // same-gateway degrade (unchanged), which stays meaningful for per-model limits (e.g. deepseek pro→flash).
108
+ //
109
+ // 复审 2026-07-29(同族缺口): this resolution used to read `config.modelApiKeyEnv` ONLY — half of the
110
+ // main path's chain. A degrade target under sealed-box custody lands in `config.modelApiKeys` and, by the
111
+ // registry-core mutual-exclusion contract, NEVER in `modelApiKeyEnv` ⇒ the hop silently authenticated the
112
+ // target's own gateway with the PRIMARY gateway key (wrong key upstream, not fail-loud), and a poison pill
113
+ // (unsealable key) degraded to "no per-model key configured" = the exact silent gateway-account burn the
114
+ // poison pill exists to prevent. Now it calls `resolveModelApiKey` — one chain, one implementation:
115
+ // sealed → env-NAME reference → gateway key, poison throws.
116
+ const fbApiKey = () => {
117
+ // Two lookups for the same reason the pre-fix code had them: `degrade.to` may name the catalog's
118
+ // `default` alias, whose Model carries the REAL name that the key maps are keyed by (and vice versa on
119
+ // env-lane catalogs). Poison in either lookup throws — never falls through to the gateway key.
120
+ const own = resolveModelApiKey(fallbackModel.name, config.modelApiKeyEnv ?? {}, process.env, config.modelApiKeys ?? {}) ??
121
+ resolveModelApiKey(degradeTo, config.modelApiKeyEnv ?? {}, process.env, config.modelApiKeys ?? {});
122
+ return own ?? config.gatewayApiKey;
123
+ };
109
124
  const fallback = fallbackModel.provider === "anthropic" && anthropicBrain
110
- ? anthropicBrain
111
- : createOpenAIBrain({ baseUrl: fallbackModel.baseUrl || config.gatewayBaseUrl, apiKey: fbApiKey, maxRetries: 2, fetchImpl, ...timeouts });
125
+ ? // Cloud Anthropic target: the shared cloud route (its own ANTHROPIC_* credential) — a per-model key
126
+ // on an anthropic degrade target is NOT honored here (the hop shares the primary's brain instance).
127
+ // Named residual, not an oversight: the openai-compatible lane below is where catalog-directed
128
+ // per-model credentials live (every sema-registry `apiKeyEnv`/`sealedApiKey` deployment to date).
129
+ anthropicBrain
130
+ : createOpenAIBrain({
131
+ baseUrl: fallbackModel.baseUrl || config.gatewayBaseUrl,
132
+ // HOT by getter (core reads `config.apiKey` per request inside buildRequest, openai.js:296): the key
133
+ // VALUE tracks refreshes exactly like the main path's rebuilt keyResolver — `applyEffective`
134
+ // REASSIGNS config.modelApiKeys/modelApiKeyEnv, and this closure holds `config`, so it always reads
135
+ // the live tables. Freezing it at boot would make a rotated/newly-sealed key take effect only after a
136
+ // restart, silently. The STRUCTURAL half (target present/absent, its baseUrl/id/apiKeyEnv/sealedApiKey)
137
+ // stays boot-frozen by construction and is registered as the `degrade-route` restart slice.
138
+ get apiKey() {
139
+ return fbApiKey();
140
+ },
141
+ maxRetries: 2,
142
+ fetchImpl,
143
+ ...timeouts,
144
+ });
112
145
  brain = createDegradingBrain({ primary: brain, fallback, fallbackModel, downgradeOn: config.degrade.downgradeOn });
113
146
  }
114
147
  }
@@ -77,7 +77,7 @@ export declare function resolveDefaultModelName(eff: EffectiveConfig, has: (name
77
77
  };
78
78
  /**
79
79
  * Read-only comparison (SEMA_REGISTRY_DRY_RUN): log what the center config WOULD change vs the current
80
- * (env-derived) config, WITHOUT applying it. The safe-rollout step the sema-registry recommends — verify
80
+ * (env-derived) config, WITHOUT applying it. The safe-rollout step the config-center recommends — verify
81
81
  * the center's models/roles/teams match (or intentionally differ from) the env baseline before going live.
82
82
  */
83
83
  export declare function logEffectiveDiff(config: ServiceConfig, eff: EffectiveConfig, logger?: Logger): void;
@@ -2,7 +2,7 @@
2
2
  * EffectiveConfig application — mutate the boot `ServiceConfig` OVER with a pulled `EffectiveConfig`
3
3
  * (models/roles/tiers/projects `applyEffective`, the 6 runtime gates `applyRuntimeGates`, the per-request-hot
4
4
  * governance baton `applyRuntimeHot`) plus their shared helpers (`toModel`, `mutateInPlace`,
5
- * `resolveDefaultModelName`, `logEffectiveDiff`, `runtimeHasActiveGate`). Split out of `sema-registry.ts`
5
+ * `resolveDefaultModelName`, `logEffectiveDiff`, `runtimeHasActiveGate`). Split out of `facade.ts`
6
6
  * (design/158 A13, internal-lossless — the facade re-exports every symbol below unchanged).
7
7
  */
8
8
  import { isThinkingLevel } from "@sema-agent/core";
@@ -79,11 +79,11 @@ export function mutateInPlace(target, source) {
79
79
  Object.assign(target, source);
80
80
  }
81
81
  export function applyEffective(config, eff, logger, opts = {}) {
82
- // version 0 / empty effective = the sema-registry has nothing for us yet — typically CONFIG_PUBLISH_MODE
82
+ // version 0 / empty effective = the config-center has nothing for us yet — typically CONFIG_PUBLISH_MODE
83
83
  // is ON but nothing has been published. We do NOT wipe: models/roles fall back to env (the enabled>0
84
84
  // guard below), teams to BUILTIN_TEAMS (registerTeams resets to built-ins). Warn once at boot so the
85
85
  // operator knows their config isn't live (publish a version, or disable the publish gate). See the
86
- // sema-registry's ORCHESTRATOR-REVIEW §④ publish-gate contract.
86
+ // config-center's ORCHESTRATOR-REVIEW §④ publish-gate contract.
87
87
  if (!opts.teamsOnly && !eff.version) {
88
88
  logger?.warn("sema_registry_unpublished", {
89
89
  version: eff.version,
@@ -437,7 +437,7 @@ export function resolveDefaultModelName(eff, has, fallback, onDangling) {
437
437
  }
438
438
  /**
439
439
  * Read-only comparison (SEMA_REGISTRY_DRY_RUN): log what the center config WOULD change vs the current
440
- * (env-derived) config, WITHOUT applying it. The safe-rollout step the sema-registry recommends — verify
440
+ * (env-derived) config, WITHOUT applying it. The safe-rollout step the config-center recommends — verify
441
441
  * the center's models/roles/teams match (or intentionally differ from) the env baseline before going live.
442
442
  */
443
443
  export function logEffectiveDiff(config, eff, logger) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Config-center adapter — pull the effective config from the config-center control plane and
3
+ * apply it OVER the env-derived defaults (env = fallback, center = override). Follows the
4
+ * "universal internal schema → translate at the boundary" pattern, with our security boundary kept:
5
+ *
6
+ * (name history: this module tracks the control plane's product name, which has changed over time
7
+ * — registry → sema-web → planned sema-admin. The module itself keeps a neutral, product-name-free
8
+ * identifier ("config-center") and does not chase the rename.)
9
+ *
10
+ * - the CENTER owns the LOGICAL config: the model roster (names/capabilities/tier), the role map,
11
+ * and team templates;
12
+ * - the SERVICE env still owns the SECRETS: API keys stay in env (the center never stores a secret);
13
+ * keys are per-model via `apiKeyEnv` → `config.modelApiKeyEnv` → the spec's `getApiKeyAndHeaders`
14
+ * (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
15
+ *
16
+ * Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
17
+ * core brain honors model.baseUrl, absent = "" = boot-env endpoint).
18
+ *
19
+ * Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
20
+ * TODO, which went stale when `mutateInPlace` landed):
21
+ * - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
22
+ * `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
23
+ * reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
24
+ * call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
25
+ * next task with no restart.
26
+ * - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
27
+ * at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
28
+ * - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
29
+ * (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
30
+ * at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
31
+ * restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
32
+ * gateway silently on the rate-limit path.
33
+ * - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
34
+ *
35
+ * (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives
36
+ * alongside it in `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig
37
+ * application / restart-signal detection / skills+MCP consumption). Every symbol below is
38
+ * re-exported UNCHANGED — existing `from ".../config-center/facade.js"` imports need zero changes
39
+ * across a rename of this file's *contents*; a path rename (this move, 2026-07-29) does require the
40
+ * importers to update, which was done in the same commit.
41
+ */
42
+ export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
43
+ export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
44
+ export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type RestartSignal, } from "./restart-signal.js";
45
+ export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./skills-mcp.js";
46
+ export type { CenterSkillManifest, CenterMcpServer, EffectiveConfig, ExecutionRuling, SessionMirrorRuling, } from "./types.js";
47
+ //# sourceMappingURL=facade.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Config-center adapter — pull the effective config from the config-center control plane and
3
+ * apply it OVER the env-derived defaults (env = fallback, center = override). Follows the
4
+ * "universal internal schema → translate at the boundary" pattern, with our security boundary kept:
5
+ *
6
+ * (name history: this module tracks the control plane's product name, which has changed over time
7
+ * — registry → sema-web → planned sema-admin. The module itself keeps a neutral, product-name-free
8
+ * identifier ("config-center") and does not chase the rename.)
9
+ *
10
+ * - the CENTER owns the LOGICAL config: the model roster (names/capabilities/tier), the role map,
11
+ * and team templates;
12
+ * - the SERVICE env still owns the SECRETS: API keys stay in env (the center never stores a secret);
13
+ * keys are per-model via `apiKeyEnv` → `config.modelApiKeyEnv` → the spec's `getApiKeyAndHeaders`
14
+ * (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
15
+ *
16
+ * Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
17
+ * core brain honors model.baseUrl, absent = "" = boot-env endpoint).
18
+ *
19
+ * Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
20
+ * TODO, which went stale when `mutateInPlace` landed):
21
+ * - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
22
+ * `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
23
+ * reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
24
+ * call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
25
+ * next task with no restart.
26
+ * - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
27
+ * at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
28
+ * - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
29
+ * (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
30
+ * at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
31
+ * restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
32
+ * gateway silently on the rate-limit path.
33
+ * - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
34
+ *
35
+ * (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives
36
+ * alongside it in `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig
37
+ * application / restart-signal detection / skills+MCP consumption). Every symbol below is
38
+ * re-exported UNCHANGED — existing `from ".../config-center/facade.js"` imports need zero changes
39
+ * across a rename of this file's *contents*; a path rename (this move, 2026-07-29) does require the
40
+ * importers to update, which was done in the same commit.
41
+ */
42
+ export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
43
+ export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
44
+ export { restartReasons, planeHasActiveTiers, modelPlaneChanged, } from "./restart-signal.js";
45
+ export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./skills-mcp.js";
46
+ //# sourceMappingURL=facade.js.map
@@ -33,7 +33,7 @@ export declare function fetchPrincipalCaps(baseUrl: string, token: string, princ
33
33
  executionDrift?: string;
34
34
  etag?: string;
35
35
  } | null>;
36
- /** An HTTP error from a sema-registry fetch that carries the response status so callers can branch on it
36
+ /** An HTTP error from a config-center fetch that carries the response status so callers can branch on it
37
37
  * (e.g. translate a 404 to "unknown hash → undefined") WITHOUT parsing the message string. */
38
38
  export declare class ConfigCenterHttpError extends Error {
39
39
  readonly status: number;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * sema-registry HTTP client — the transport calls the service makes against the config-center
2
+ * config-center HTTP client — the transport calls the service makes against the config-center
3
3
  * (`fetchEffective`/`fetchPrincipalCaps`/`fetchSkillContent`/`fetchPromptArtifact`/`fetchPromptBlob`) plus
4
- * the shared HTTP-error type. Split out of `sema-registry.ts` (design/158 A13, internal-lossless — the
4
+ * the shared HTTP-error type. Split out of `facade.ts` (design/158 A13, internal-lossless — the
5
5
  * facade re-exports every symbol below unchanged).
6
6
  */
7
7
  import { createHash } from "node:crypto";
@@ -22,7 +22,7 @@ export async function fetchEffective(baseUrl, token, etag, fetchImpl = fetch, wo
22
22
  if (res.status === 304)
23
23
  return null;
24
24
  if (!res.ok)
25
- throw new Error(`sema-registry HTTP ${res.status}`);
25
+ throw new Error(`config-center HTTP ${res.status}`);
26
26
  return { effective: (await res.json()), etag: res.headers.get("etag") ?? undefined };
27
27
  }
28
28
  /**
@@ -62,11 +62,11 @@ export async function fetchPrincipalCaps(baseUrl, token, principal, etag, fetchI
62
62
  // misbehaving — DON'T treat it as "unchanged" (that surfaces undefined caps = no restriction = fail OPEN).
63
63
  // Throw so the resolver fail-closes (review B). With an etag, 304 = genuine "unchanged" → null.
64
64
  if (!etag)
65
- throw new Error("sema-registry returned 304 to a non-conditional principal-caps request");
65
+ throw new Error("config-center returned 304 to a non-conditional principal-caps request");
66
66
  return null;
67
67
  }
68
68
  if (!res.ok)
69
- throw new Error(`sema-registry principal-caps HTTP ${res.status}`);
69
+ throw new Error(`config-center principal-caps HTTP ${res.status}`);
70
70
  const body = (await res.json());
71
71
  return {
72
72
  runtimeCaps: body.runtimeCaps ?? null,
@@ -118,7 +118,7 @@ export async function fetchPrincipalCaps(baseUrl, token, principal, etag, fetchI
118
118
  etag: res.headers.get("etag") ?? undefined,
119
119
  };
120
120
  }
121
- /** An HTTP error from a sema-registry fetch that carries the response status so callers can branch on it
121
+ /** An HTTP error from a config-center fetch that carries the response status so callers can branch on it
122
122
  * (e.g. translate a 404 to "unknown hash → undefined") WITHOUT parsing the message string. */
123
123
  export class ConfigCenterHttpError extends Error {
124
124
  status;
@@ -1,5 +1,13 @@
1
1
  import type { EffectiveConfig } from "./types.js";
2
- export type RestartSlice = "skills" | "mcp" | "scenarios" | "runtime-gates" | "models-tiers";
2
+ export type RestartSlice = "skills" | "mcp" | "scenarios" | "runtime-gates" | "models-tiers" | "degrade-route";
3
+ /** What the CALLER must contribute about boot-baked catalog consumers that live OUTSIDE the Runner — the
4
+ * slices cannot see them, because they are configured from env (`ServiceConfig`), not from the center's
5
+ * EffectiveConfig. Absent/empty = that lane is off ⇒ its slice is inert (null on both sides). */
6
+ export interface RestartSliceCtx {
7
+ /** `config.degrade.to` when REACTIVE degrade is on (env `MODEL_DEGRADE_TO` + `MODEL_DEGRADE_REACTIVE`);
8
+ * undefined = the lane is off ⇒ no degrading brain was composed ⇒ nothing is boot-frozen. */
9
+ reactiveDegradeTo?: string;
10
+ }
3
11
  /** Structured restart signal an orchestrator consumes (GET /health → `restart`). Change-detected against the
4
12
  * process's BOOT config, NOT presence — so it stays absent when nothing baked-at-boot changed (presence would
5
13
  * re-fire every refresh → a restart LOOP). `restartRequired` is always true when this object exists. */
@@ -7,7 +15,7 @@ export interface RestartSignal {
7
15
  restartRequired: true;
8
16
  /** Which restart-to-apply slices differ from the value baked at boot. */
9
17
  reasons: RestartSlice[];
10
- /** The sema-registry version that currently differs from boot (latest seen). */
18
+ /** The config-center version that currently differs from boot (latest seen). */
11
19
  version: number;
12
20
  /** Epoch ms when this restart first became required (stable across refreshes while `reasons` is unchanged). */
13
21
  since: number;
@@ -16,7 +24,7 @@ export interface RestartSignal {
16
24
  * nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
17
25
  * can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
18
26
  * against boot (a refresh that re-fires the same diff is idempotent, not a fresh trigger). */
19
- export declare function restartReasons(boot: EffectiveConfig | undefined, current: EffectiveConfig): RestartSlice[];
27
+ export declare function restartReasons(boot: EffectiveConfig | undefined, current: EffectiveConfig, ctx?: RestartSliceCtx): RestartSlice[];
20
28
  /** codex R10 (models-tiers 窗收口): TRUE when the MODEL PLANE (enabled models + active tier table + resolved
21
29
  * default) of `next` differs from the last-APPLIED plane `prev`. Under a tier-frozen Runner (tiers non-empty at
22
30
  * construction → private expanded copy) main.ts DEFERS the whole plane mutation until restart when this is true —