@sema-agent/server 7.12.0 → 7.13.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.
- package/USAGE.md +30 -2
- package/dist/adoption/plan.d.ts +38 -4
- package/dist/adoption/plan.js +72 -0
- package/dist/adoption/quiesce.d.ts +70 -0
- package/dist/adoption/quiesce.js +148 -0
- package/dist/adoption/runner.js +63 -5
- package/dist/adoption/sql.d.ts +15 -0
- package/dist/adoption/sql.js +18 -0
- package/dist/adoption/wire.d.ts +7 -1
- package/dist/adoption/wire.js +6 -0
- package/dist/approval-card.d.ts +5 -0
- package/dist/approval-card.js +22 -0
- package/dist/boot/permission-rules-audit.js +29 -1
- package/dist/boot/reapers.d.ts +15 -0
- package/dist/boot/reapers.js +101 -44
- package/dist/boot/runner-deps.d.ts +16 -2
- package/dist/boot/runner-deps.js +5 -4
- package/dist/config-types.d.ts +14 -2
- package/dist/http/active-run-conflict.d.ts +33 -8
- package/dist/http/active-run-conflict.js +37 -2
- package/dist/http/routes/adoption.js +25 -2
- package/dist/http/routes/approvals-assistant.js +33 -3
- package/dist/http/routes/capabilities.js +25 -1
- package/dist/http/routes/images.js +18 -0
- package/dist/http/routes/runs.js +21 -5
- package/dist/http/routes/tasks.js +18 -6
- package/dist/http/server.d.ts +26 -8
- package/dist/http/server.js +102 -16
- package/dist/main.js +46 -6
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/plugins/adoption-log-sql.d.ts +40 -0
- package/dist/plugins/adoption-log-sql.js +69 -2
- package/dist/plugins/file-run-store.d.ts +85 -1
- package/dist/plugins/file-run-store.js +450 -17
- package/dist/plugins/permission-rule-store-sql.d.ts +45 -0
- package/dist/plugins/permission-rule-store-sql.js +60 -2
- package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
- package/dist/plugins/shared-memory-store-sql.js +55 -18
- package/dist/plugins/sql-driver.d.ts +19 -0
- package/dist/plugins/sql-driver.js +12 -0
- package/dist/plugins/store-backend.js +24 -1
- package/dist/rules-consent.d.ts +33 -4
- package/dist/rules-consent.js +43 -2
- package/dist/run-local.js +6 -2
- package/dist/runtime-governance.d.ts +33 -0
- package/dist/runtime-governance.js +32 -0
- package/dist/tool-approval.d.ts +32 -0
- package/dist/tool-approval.js +20 -0
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/package.json +3 -3
package/USAGE.md
CHANGED
|
@@ -379,6 +379,10 @@ MCP_ELICITATION_TTL_MS=300000 # 一张没人填的表单挂多久后释放(
|
|
|
379
379
|
| `Authorization: Bearer <SERVICE_AUTH_TOKEN>` | 设了 `SERVICE_AUTH_TOKEN` 时,所有非 `/health` 请求 | **谁有权调本服务**(OA 后端持有,服务到服务) |
|
|
380
380
|
| `x-agent-principal: user:42` | 设了 `REQUIRE_PRINCIPAL=true` 时 | **代表哪个终端用户**(决定 session 归属 + 记忆隔离;**绝不从 body 取**) |
|
|
381
381
|
|
|
382
|
+
| env 键 | 缺省 | 说明 |
|
|
383
|
+
|---|---|---|
|
|
384
|
+
| `PRINCIPAL_HEADER` | `x-agent-principal` | 上面那个身份头的**名字**。🔴 **缺省值是跨仓 wire 常量,不是给部署方自定义的**:这个名字**不在任何 wire 面上广播**(`/v1/capabilities`、`/health` 都没有它),所以没有下游能在运行期问 server「你叫它什么」——SDK 有一个 `principalHeader` 逃生舱但 cli/client-core 都没接线,浏览器端 BFF 是逐字硬编码,`mcp-oa` 侧车自己读同名 env(各读各的)。本旋钮只服务于「入口网关已经把身份写进别的头名」这一种特殊部署(让 server 迁就既有网关),不是给部署方起新名字用的。**改名即断下游**,但后果分四形(逐条对过代码):`REQUIRE_PRINCIPAL=true` 下提交腿(`POST /v1/tasks|/v1/runs`)是 `401` + **粗码 `auth.unauthorized`**(authorizer 抛的无 code HttpError 走状态码兜底映射),属主寻址的读/动词面是 `401 auth.principal_required`(路由级细码);两者的文案都逐字回显**你配的头名**,那是现场唯一能指认改名的线索。`REQUIRE_PRINCIPAL` 未开时更坏:**新会话照常成功**、每一条被当成**匿名**(session 归属 / 记忆隔离 / 规则车道 / operator 判定静默走无身份分支,无任何错误码),而**接续一条已属主的会话**会 `401`(`session is principal-owned; missing principal header …`)。⚠️ **SSO 与 direct-door 两条身份来源不经过这个头**(`verifiedPrincipal`),改名对它们无影响 —— 混合形部署因此会出现「一半调用方有身份、一半静默变匿名」的混着长。改了后果自负,且必须同批把每一家下游改到同名。取值须是**单个**合法 header 名(带空格/逗号 ⇒ 启动期拒启:它会撕裂 CORS `allow-headers`)。成文契约见 [`docs/ASSISTANT-WIRE-CONTRACT.md` §0.5](docs/ASSISTANT-WIRE-CONTRACT.md) |
|
|
385
|
+
|
|
382
386
|
> ⚠️ **Bearer 是"每个请求",不只是 POST。** 配了 `SERVICE_AUTH_TOKEN` 后,**`GET /v1/runs/:id`、`GET /v1/runs/:id/events`(SSE)、`/metrics`** 等所有非 `/health` 路由都要带 `Authorization: Bearer`——漏带一律 `401`。下面示例为简洁**省略了 Bearer**,真实调用请逐个补上。
|
|
383
387
|
|
|
384
388
|
**请求体字段(你能传的全部):**
|
|
@@ -563,6 +567,28 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
|
|
|
563
567
|
> 今天仍有效的预防旋钮 = **在开放 `settings.hooks.PreToolUse` 的部署上不依赖后台子代的 durable 审批**
|
|
564
568
|
> (或用 `REQUIRE_PRINCIPAL` 关掉该面),同样只对**此后**新铸的行生效。
|
|
565
569
|
>
|
|
570
|
+
> **🔧 升级到 7.10.0 前:必须删库重建(BREAKING,两条同窗)。** ① pg 侧 memory 两表的
|
|
571
|
+
> `agent_memory_engine_entry.scope/slug` 与 `agent_memory_engine_cursor.scope` 由 `text` 收窄为
|
|
572
|
+
> `varchar(190)` / `varchar(512)`(与 MySQL-protocol 方言同宽);② 七个 epoch-BIGINT 列补 `_ms` 后缀,
|
|
573
|
+
> 两方言同窗改:`checkpoint.created_at|decided_at|terminal_at`、`workflow_resume_claim.claimed_at`、
|
|
574
|
+
> `workflow_run.ended_at`、`workflow_notify_journal.created_at|acked_at`。
|
|
575
|
+
> **本服务的 schema 契约 = 启动 DDL 是唯一真源,不发 `ALTER` 增量 seam**,而建表语句是
|
|
576
|
+
> `CREATE TABLE IF NOT EXISTS` ⇒ 对已存在的旧表**一字不改**:旧列名的存量表在新代码下每一次读写都直接
|
|
577
|
+
> 报错,**没有静默降级路径**。滚版前重建这几张表(或整库),基线见 `docs/schema/baseline-*.sql`。
|
|
578
|
+
> wire/HTTP 面零变化——改的只是列名,对外字段仍是 `createdAt` / `decidedAt` / `endedAt` / `ackedAt`
|
|
579
|
+
> 等 camelCase 形。
|
|
580
|
+
>
|
|
581
|
+
> **🔧 升级到 7.8.0 前:必须删库重建(BREAKING,SQL 命名三轴归一化)。** ①**表名单数化 9 张**:
|
|
582
|
+
> `approval_asks`→`approval_ask`、`approval_batches`→`approval_batch`、
|
|
583
|
+
> `background_agents`→`background_agent`、`mailboxes`→`mailbox`、`mailbox_messages`→`mailbox_message`、
|
|
584
|
+
> `task_list_items`→`task_list_item`、`agent_memory_engine_entries|cursors|sync_cursors`→
|
|
585
|
+
> `…entry|cursor|sync_cursor`(表限定形索引名的表段随改);②五个 epoch 毫秒 BIGINT 列补 `_ms` 后缀
|
|
586
|
+
> ×双方言:`checkpoint_ctx.updated_at_ms`、`workflow_journal.created_at_ms`、`workflow_run.created_at_ms`、
|
|
587
|
+
> `workflow_completion_inbox.enqueued_at_ms`、`agent_memory_engine_push_queue.next_attempt_at_ms`;
|
|
588
|
+
> ③OCC 词归一:approval 两表 `version`→`rev`。同上——`CREATE TABLE IF NOT EXISTS` 不改存量表,
|
|
589
|
+
> **旧表名/旧列名在新代码下读写即报错**,滚版前删库重建(零存量用户窗口,不做增量迁移)。
|
|
590
|
+
> wire 面零变化(approval 店的列名不出 wire)。
|
|
591
|
+
>
|
|
566
592
|
> **🔧 升级到 7.7.0(引擎 core 5.20.0)前:检查数值旋钮的写法。** 5.20.0 把「坏数值旋钮被接受、然后
|
|
567
593
|
> 悄悄做**相反**的事」这一类全部改成响亮拒或响亮钳位。两条与运维直接相关:
|
|
568
594
|
>
|
|
@@ -597,10 +623,12 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
|
|
|
597
623
|
> 需要动作的只有一种行:**升级前就已经 pending 的那些**——它们带的是旧版本铸的哈希与(可能已降级的)
|
|
598
624
|
> 参数,新引擎不会追认改写。**滚版前把它们批/否掉**(`GET /v1/approvals` 列出来,逐个 `/decide`),
|
|
599
625
|
> 或明确接受那批老行仍按旧语义结算。新铸的行不受影响。
|
|
600
|
-
> **同一次升级还要重建 mailbox 两表。**
|
|
626
|
+
> **同一次升级还要重建 mailbox 两表。** 消息表新增 `hop_chain` 列(引擎的 peer 消息守卫),
|
|
601
627
|
> 而建表语句是 `CREATE TABLE IF NOT EXISTS` —— 对已存在的旧表**一字不改**,升级后每一次 teammate 消息
|
|
602
628
|
> 投递都会报 unknown column 并失败。按本服务的 schema 契约(删库重建、不做增量迁移),滚版时
|
|
603
|
-
> **重建
|
|
629
|
+
> **重建 mailbox 两表**(或整库),基线见 `docs/schema/baseline-*.sql`。
|
|
630
|
+
> ⚠️ 表名按**你要升到的版本**读:7.8.0 之前叫 `mailboxes` / `mailbox_messages`,7.8.0 的单数化归一
|
|
631
|
+
> (见上「升级到 7.8.0」条)之后叫 **`mailbox` / `mailbox_message`** —— 后者是现役名。
|
|
604
632
|
> 该表是带 TTL 的短命投递队列而非账本,重建只丢排队中的 teammate 消息;介意就先让在飞的 peer 会话收敛。
|
|
605
633
|
>
|
|
606
634
|
> 顺带一提,新引擎会在铸点**直接拒绝 park**(点名后端、退回同步门)的只有两类值,而且都只可能由
|
package/dist/adoption/plan.d.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import type { AdoptionLegAction, AdoptionNotMigrated } from "./wire.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* 身份值的编码。
|
|
4
|
+
* · `verbatim` —— 列里存的就是 principal 本身;
|
|
5
|
+
* · `memory-scope` —— core 的 v2 typed scope 键(`user:<enc>` / `proj:<enc>/…`);
|
|
6
|
+
* · `rule-owner-key` —— 规则桶键 `sha256("principal:" + principal)` 的十六进制(#154 车二的
|
|
7
|
+
* `buildRuleOwnerKey`)。身份在**hash 原像**里,所以「旧值 → 新值」这一对必须**算**出来,
|
|
8
|
+
* 不能拿 principal 本身去比 —— 这正是 A-010.16 里残留计数恒报 0 的根因:旧式按明文 principal
|
|
9
|
+
* 查 `owner_key`,那个谓词在这张表上**永远匹配不到任何一行**。
|
|
10
|
+
*/
|
|
11
|
+
export type IdentityEncoding = "verbatim" | "memory-scope" | "rule-owner-key";
|
|
4
12
|
/**
|
|
5
13
|
* 腿的执行形。
|
|
6
14
|
* · `bulk-rebind` —— 通用的等值 UPDATE(绝大多数腿);
|
|
7
15
|
* · `session-policy-rekey` —— 逐行**重算主键**(policy_key = sha256([sessionId, principal]),身份在键的
|
|
8
16
|
* hash 原像里,等值 UPDATE 够不着);
|
|
9
|
-
* · `blob-rewrite` —— 逐行改写 **JSON
|
|
17
|
+
* · `blob-rewrite` —— 逐行改写 **JSON 载荷列**里的身份字段;
|
|
18
|
+
* · `rule-owner-rekey` —— 规则桶的**整桶改键**(owner_key = sha256 原像里带身份,与 session-policy-rekey
|
|
19
|
+
* 同族)。与那条腿的差别:桶键**不含**任何逐行变量(只有 principal),所以不必逐行扫出来重算 ——
|
|
20
|
+
* 一对 (fromKey, toKey) 算一次,一条等值 UPDATE 走完全表。
|
|
10
21
|
*
|
|
11
22
|
* 🔴 `blob-rewrite` 为什么必须存在(codex 对抗复审 R1-F2,亲核属实):本仓有三张表的 JSON 载荷是**真源**,
|
|
12
23
|
* 身份列只是它的投影,而读面读的是载荷:
|
|
@@ -18,7 +29,7 @@ export type IdentityEncoding = "verbatim" | "memory-scope";
|
|
|
18
29
|
* 只迁投影 = 库里两份身份说法不一致,而**说了算的那份没迁**。这一形在空表上完全无声(集成种子若把载荷
|
|
19
30
|
* 写成 `{}` 就永远测不出来)。
|
|
20
31
|
*/
|
|
21
|
-
export type LegKind = "bulk-rebind" | "session-policy-rekey" | "blob-rewrite";
|
|
32
|
+
export type LegKind = "bulk-rebind" | "session-policy-rekey" | "blob-rewrite" | "rule-owner-rekey";
|
|
22
33
|
/** `blob-rewrite` 腿的坐标。 */
|
|
23
34
|
export interface BlobRewriteSpec {
|
|
24
35
|
/** JSON 载荷列(真源)。 */
|
|
@@ -121,6 +132,29 @@ export declare const IDENTITY_WIDTH_LIMITS: readonly {
|
|
|
121
132
|
}[];
|
|
122
133
|
/** memory scope 列宽(两方言 VARCHAR(190) 同宽;段编码后才是真长度)。 */
|
|
123
134
|
export declare const MEMORY_SCOPE_MAX_CHARS = 190;
|
|
135
|
+
/**
|
|
136
|
+
* 收编身份的**键空间卫生门**(A-010.15,验真后修)。纯函数、零 I/O ⇒ 在铸任何行之前判。
|
|
137
|
+
*
|
|
138
|
+
* ── 病 ──────────────────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
* `adoption_log.from_principal` 上的 UNIQUE 是 183 D7「同一个源不许被收编两次」的**数据库强形**,
|
|
140
|
+
* 而这张表的 MySQL 臂是 `VARCHAR(190) … COLLATE utf8mb4_bin`。`utf8mb4_bin` 虽然是 binary collation,
|
|
141
|
+
* **却仍然是 PAD SPACE 的**(MySQL 只有 `utf8mb4_0900_*` 族是 NO PAD)—— 等值比较与唯一键检查会把两侧
|
|
142
|
+
* 右填充到等长。于是 `"user:a"` 与 `"user:a "` 在 MySQL 腿上是**同一个键**,在 PG 腿(`COLLATE "C"`,
|
|
143
|
+
* 无填充)上是**两个键**。同一份代码、同一个请求,两个后端上结局不同:
|
|
144
|
+
* · MySQL:第二次收编被 `adoption.source_already_bound` 拒 —— 一个运维完全看不懂的 409;
|
|
145
|
+
* · PG:落第二行,弧照跑,却**一行都迁不到**(别的表里没有任何列存着带尾空格的那个身份)。
|
|
146
|
+
*
|
|
147
|
+
* ── 为什么修在边界而不是把列改成 VARBINARY(本仓已有的字节孪生形)─────────────────────────────
|
|
148
|
+
* 本仓对**同一族**问题已经裁过一次,逐字在 `plugins/approval-ask-store-sql.ts` 的
|
|
149
|
+
* `assertIdempotencyKeyShape` 头注里:「在边界上 fail-loud 拒掉,方言分歧面整个消失(比把列改
|
|
150
|
+
* VARBINARY 更窄、且对三形同时成立)」。这里的判据比那次还硬一层 —— **带首尾空白的 principal 在本仓
|
|
151
|
+
* 根本不可能是一个真身份**:`security.ts` 的 `principalFrom` 读头时逐字 `.trim()`,所以没有任何
|
|
152
|
+
* owner/scope 列里存得下带尾空格的值。收编一个这样的源身份,能迁到的行恒为零。
|
|
153
|
+
* 拒 = 把一个注定空转(或注定撞出误导性 409)的请求在**入库之前**变成一句说得清的 400。
|
|
154
|
+
* 改列则要动 schema + 基线 + 双库,却只把 MySQL 腿对齐到 PG 腿,对「这个值本来就不是合法身份」这件事
|
|
155
|
+
* 一个字都没说 —— 那才是治标。
|
|
156
|
+
*/
|
|
157
|
+
export declare function checkAdoptionPrincipalShape(fromPrincipal: string, toPrincipal: string): string | undefined;
|
|
124
158
|
/**
|
|
125
159
|
* 目的地身份能不能被每一根被写列容纳?返回**人可读的拒绝理由**,`undefined` = 可以。
|
|
126
160
|
* 纯函数(零 I/O),所以校验发生在任何库动作之前。
|
package/dist/adoption/plan.js
CHANGED
|
@@ -382,6 +382,32 @@ export const REBIND_LEGS = [
|
|
|
382
382
|
residualKey: ["session_id"], // policy_key = sha256([session_id, principal]) ⇒ 同 session 的目的地行即冲突
|
|
383
383
|
why: "E6 逐会话逐 principal 的工具收紧规则:身份在**主键的 hash 原像**里,等值 UPDATE 够不着 ⇒ 逐行读出→重算 policy_key→改写",
|
|
384
384
|
},
|
|
385
|
+
// ── 规则桶的整桶改键(A-010.4 / A-010.16 / A-010.22)────────────────────────────────────────────
|
|
386
|
+
{
|
|
387
|
+
leg: "permission_rule#owner_key",
|
|
388
|
+
table: "permission_rule",
|
|
389
|
+
kind: "rule-owner-rekey",
|
|
390
|
+
action: "bucket-rebind",
|
|
391
|
+
// 两列同腿:`owner_key` 是**派生**的桶键、`principal` 是它的明文原像列(店按前者寻址、运维按后者读)。
|
|
392
|
+
// 只改一列 = 库里两份身份说法不一致,而寻址用的那份与人读的那份互相矛盾。
|
|
393
|
+
columns: ["owner_key", "principal"],
|
|
394
|
+
matchColumn: "owner_key",
|
|
395
|
+
encoding: "rule-owner-key",
|
|
396
|
+
// PK(owner_key) ⇒ 唯一键**就是**这根轴本身 ⇒ 目的地已有桶即冲突(退化成两侧各有任意一行)。
|
|
397
|
+
// 与 agent_memory_engine_cursor 的 PK(scope) 同形,故 `[]` 而不是 undefined。
|
|
398
|
+
//
|
|
399
|
+
// 🔴 连带的一条真语义(真双库上实测出来的,不是推的):**迟到桶清扫不掉,而这是对的**。
|
|
400
|
+
// 终态之后旧身份下若又长出一只桶(= 部署配置没随迁,还在按旧身份导规则),改键式清扫会撞上新身份
|
|
401
|
+
// 已经占住的那把 PK ⇒ 腿抛 ⇒ `sweepLate` 捕获 + 响亮 warn,残留读数继续报非零。
|
|
402
|
+
// 不给它加「合并」臂:两只桶合并是一次 **CRDT 语义 join**(core `applySyncJoin` 的管辖面),
|
|
403
|
+
// 收编弧自己发明一个 join 就是在放行面上编语义 —— 本车的既定口径是「不做逐表 merge,拒比合安全
|
|
404
|
+
// 且可重来」。运维侧的可行动信号是三处同时指向同一个真因:一行 warn + 不肯归零的
|
|
405
|
+
// `residualSourceRows` + 非空的 `outstandingConfigs`。
|
|
406
|
+
residualKey: [],
|
|
407
|
+
why: "#154 车二的**常驻放行桶** —— 一条 allow 规则就是「这个人对这条命令的持久同意」,是本仓最直白的**活授权轴**。" +
|
|
408
|
+
"core 5.23.0 form a 把 permission rules 列为身份轴重绑腿之首(A-010.4:server form b 此前与它有实质分歧);" +
|
|
409
|
+
"#203 把 `PERMISSION_RULES_ENABLED` 默认翻 ON 之后,不迁 = 收编完成而**旧身份名下仍躺着一桶活着的放行规则**",
|
|
410
|
+
},
|
|
385
411
|
];
|
|
386
412
|
/**
|
|
387
413
|
* server 侧**按设计不迁**的面(183 §12 裁决号在案)。这份清单进回执的 `notMigratedByDesign`,
|
|
@@ -406,6 +432,22 @@ export const NOT_MIGRATED_BY_DESIGN = [
|
|
|
406
432
|
{ face: "approval-exemption-grantor", ruling: "D2", reason: "approval_exemption.granted_by is a historical fact (who granted this exemption), not a live authorization axis" },
|
|
407
433
|
{ face: "approval-ask-decision-actor", ruling: "D2", reason: "approval_ask.decision_actor / decision_note record who decided — historical audit fields, never rewritten" },
|
|
408
434
|
{ face: "image-bake-requested-by", ruling: "D2", reason: "image_bake.requested_by records who requested the build — historical audit field" },
|
|
435
|
+
// 🔴 A-010.22:规则店族的**分家表态**。同一族三张表,三个不同的结局,回执必须逐个说清楚:
|
|
436
|
+
// · `permission_rule`(活桶) → **真迁**,见 REBIND_LEGS 的 `permission_rule#owner_key` 腿;
|
|
437
|
+
// · `permission_rule_approval`(审批史)→ D2,下面这一员;
|
|
438
|
+
// · `permission_rule_ticket`(导入票) → D1,下面那一员。
|
|
439
|
+
// 判据与既有 D1/D2 六员逐字同源,不是为这一族新造的标准:改写「谁批准过哪条规则」= 伪造审计;
|
|
440
|
+
// 分钟级 TTL 的一次性导入票是治理瞬态,过期即死,搬过去没有任何可兑付的价值。
|
|
441
|
+
{
|
|
442
|
+
face: "permission-rule-approval-history",
|
|
443
|
+
ruling: "D2",
|
|
444
|
+
reason: "permission_rule_approval records WHICH principal approved WHICH rule candidates — a historical audit fact, never rewritten (the live grants those approvals produced DO migrate, on the permission_rule#owner_key leg)",
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
face: "permission-rule-import-ticket",
|
|
448
|
+
ruling: "D1",
|
|
449
|
+
reason: "permission_rule_ticket is a minute-TTL single-use CC-import ticket bound to the minting principal — a governance transient that dies on expiry; an in-flight ticket at adoption time means the engine was not stopped first (183 I1), so it is left to expire rather than re-bound",
|
|
450
|
+
},
|
|
409
451
|
];
|
|
410
452
|
export const AFFECTED_CONFIG_TEMPLATES = [
|
|
411
453
|
{ deployment: "web-client-bff", key: "BFF injected principal (bff/server.mjs)", requiredValue: "%TO%" },
|
|
@@ -451,6 +493,36 @@ function scopeSegmentRejection(label, principal) {
|
|
|
451
493
|
}
|
|
452
494
|
/** memory scope 列宽(两方言 VARCHAR(190) 同宽;段编码后才是真长度)。 */
|
|
453
495
|
export const MEMORY_SCOPE_MAX_CHARS = 190;
|
|
496
|
+
/**
|
|
497
|
+
* 收编身份的**键空间卫生门**(A-010.15,验真后修)。纯函数、零 I/O ⇒ 在铸任何行之前判。
|
|
498
|
+
*
|
|
499
|
+
* ── 病 ──────────────────────────────────────────────────────────────────────────────────────────
|
|
500
|
+
* `adoption_log.from_principal` 上的 UNIQUE 是 183 D7「同一个源不许被收编两次」的**数据库强形**,
|
|
501
|
+
* 而这张表的 MySQL 臂是 `VARCHAR(190) … COLLATE utf8mb4_bin`。`utf8mb4_bin` 虽然是 binary collation,
|
|
502
|
+
* **却仍然是 PAD SPACE 的**(MySQL 只有 `utf8mb4_0900_*` 族是 NO PAD)—— 等值比较与唯一键检查会把两侧
|
|
503
|
+
* 右填充到等长。于是 `"user:a"` 与 `"user:a "` 在 MySQL 腿上是**同一个键**,在 PG 腿(`COLLATE "C"`,
|
|
504
|
+
* 无填充)上是**两个键**。同一份代码、同一个请求,两个后端上结局不同:
|
|
505
|
+
* · MySQL:第二次收编被 `adoption.source_already_bound` 拒 —— 一个运维完全看不懂的 409;
|
|
506
|
+
* · PG:落第二行,弧照跑,却**一行都迁不到**(别的表里没有任何列存着带尾空格的那个身份)。
|
|
507
|
+
*
|
|
508
|
+
* ── 为什么修在边界而不是把列改成 VARBINARY(本仓已有的字节孪生形)─────────────────────────────
|
|
509
|
+
* 本仓对**同一族**问题已经裁过一次,逐字在 `plugins/approval-ask-store-sql.ts` 的
|
|
510
|
+
* `assertIdempotencyKeyShape` 头注里:「在边界上 fail-loud 拒掉,方言分歧面整个消失(比把列改
|
|
511
|
+
* VARBINARY 更窄、且对三形同时成立)」。这里的判据比那次还硬一层 —— **带首尾空白的 principal 在本仓
|
|
512
|
+
* 根本不可能是一个真身份**:`security.ts` 的 `principalFrom` 读头时逐字 `.trim()`,所以没有任何
|
|
513
|
+
* owner/scope 列里存得下带尾空格的值。收编一个这样的源身份,能迁到的行恒为零。
|
|
514
|
+
* 拒 = 把一个注定空转(或注定撞出误导性 409)的请求在**入库之前**变成一句说得清的 400。
|
|
515
|
+
* 改列则要动 schema + 基线 + 双库,却只把 MySQL 腿对齐到 PG 腿,对「这个值本来就不是合法身份」这件事
|
|
516
|
+
* 一个字都没说 —— 那才是治标。
|
|
517
|
+
*/
|
|
518
|
+
export function checkAdoptionPrincipalShape(fromPrincipal, toPrincipal) {
|
|
519
|
+
for (const [label, p] of [["fromPrincipal", fromPrincipal], ["toPrincipal", toPrincipal]]) {
|
|
520
|
+
if (p.trim() !== p) {
|
|
521
|
+
return `${label} has leading or trailing whitespace (${JSON.stringify(p)}). A verified principal never does — the identity header is trimmed at the door (security.ts principalFrom), so no owner/scope column in this deployment can hold this value and the adoption would migrate nothing. It is also not portable: the MySQL-protocol arm's utf8mb4_bin collation is PAD SPACE, so this key is EQUAL to the trimmed one there while PostgreSQL's COLLATE "C" treats them as two distinct keys.`;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return undefined;
|
|
525
|
+
}
|
|
454
526
|
/**
|
|
455
527
|
* 目的地身份能不能被每一根被写列容纳?返回**人可读的拒绝理由**,`undefined` = 可以。
|
|
456
528
|
* 纯函数(零 I/O),所以校验发生在任何库动作之前。
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A-010.18 —— 收编弧跑动期间的**进程内静默闸**(design/183 I1「引擎先停」在本进程里的执法面)。
|
|
3
|
+
*
|
|
4
|
+
* ── 病 ──────────────────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* 183 I1 写的是「发起收编之前必须先把引擎停掉」。那是一条**运维前提**,而本进程此前对它**零执法**:
|
|
6
|
+
* `POST /v1/adoption` 收下请求、弧开始逐表 UPDATE 的同时,本副本的维护 tick 照常每几秒跑一轮,
|
|
7
|
+
* 而那一轮里好几条腿写的正是被迁的那些表。最锋利的一条是**审批收敛器**(`approval-reconciler.ts`):
|
|
8
|
+
* 它按 `owner` 读 PARKING/STREAM_PENDING 行并 CAS 结算。收编把 `approval_ask.owner` 从 A 改成 B 的
|
|
9
|
+
* 那一瞬,收敛器手里可能正握着按**旧** owner 读出来的一批行 —— 结算下去就是把一只本该 PARKED 的 ask
|
|
10
|
+
* 写成 **DENIED**,而 DENIED 是终局:收编回滚不了它,重跑也追认不回来。
|
|
11
|
+
*
|
|
12
|
+
* ── 修 ──────────────────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
* 弧跑动期间把维护 tick 整轮让开。判据不是「挑几条腿避让」而是**整轮**:
|
|
14
|
+
* ① 维护 tick 是 best-effort 的周期动作(每条腿都幂等、都能等下一轮),让开几轮零代价;而逐腿判
|
|
15
|
+
* 「这条腿碰不碰被迁的表」会立刻变成第二份需要与 `REBIND_LEGS` 同步的表 —— 那正是本仓反复吃过亏的
|
|
16
|
+
* 「两份判据必然漂」形(漏判一条新腿是**静默**的:它照写不误)。
|
|
17
|
+
* ② 弧本身是毫秒级的几条 UPDATE,库里常态在飞行数为 0 —— 让开的窗极短。
|
|
18
|
+
*
|
|
19
|
+
* ── 射程边界(如实登记,不假称已解)──────────────────────────────────────────────────────────────
|
|
20
|
+
* 两条边界,都写在明处:
|
|
21
|
+
* ① **进程内**。这道闸只挡得住**运行这条弧的那个副本**;多副本部署里别的副本的 tick 照跑,那部分仍然
|
|
22
|
+
* 只能靠 183 I1 的运维前提(以及弧内那些 rev-CAS:载荷改写撞上并发写会响亮抛、整体回滚)。
|
|
23
|
+
* 更强的形(跨副本静默)需要一枚库上的 quiesce 租约 + 每条腿的取租,那是一次独立设计,不在本条射程。
|
|
24
|
+
* ② **排空有界**。等不到就带着读数继续(见下),所以「本进程零腿在写被迁的表」是**通常**成立而不是
|
|
25
|
+
* 恒成立 —— 超时那一次由 `onDrainTimeout` 响亮说出来,不许静默当成已排空。
|
|
26
|
+
* 本闸因此是**纵深防御**,不是把 I1 变成机器保证。
|
|
27
|
+
*
|
|
28
|
+
* ── 形态 ────────────────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
* 模块级计数器而不是布尔:并发同参 POST 是本车明确支持的形(UNIQUE 仲裁),两条弧可以叠在一起,
|
|
30
|
+
* 布尔会被先结束的那条**提前**放开闸。计数器 + `try/finally` ⇒ 抛错也一定归零。
|
|
31
|
+
*/
|
|
32
|
+
/** 本进程此刻是否有收编弧在跑(维护 tick 的让路判据)。 */
|
|
33
|
+
export declare function isAdoptionQuiescing(): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* 登记一条**刚起飞**的维护腿。调用方仍然 `void` 它自己的链(错误处置不变);本函数只借用它的落地时刻。
|
|
36
|
+
*
|
|
37
|
+
* `catch` 是必须的:登记的这一份引用绝不能变成第二个未处理拒绝源(腿自己的链已经有 guard 接住了)。
|
|
38
|
+
*/
|
|
39
|
+
export declare function trackMaintenanceLeg(leg: Promise<unknown>): void;
|
|
40
|
+
/** 取证口(测试用):此刻有几只等待方挂着。生产不读 —— 它存在是为了让「重复超时不泄漏」可被断言。 */
|
|
41
|
+
export declare function drainWaiterCountForTest(): number;
|
|
42
|
+
/** 排空结果:`drained` = 全部落地;否则 `pending` = 超时时仍在飞的条数。 */
|
|
43
|
+
export interface DrainReading {
|
|
44
|
+
drained: boolean;
|
|
45
|
+
pending: number;
|
|
46
|
+
}
|
|
47
|
+
/** 有界排空(见 {@link airborneLegs} 的注)。`timeoutMs <= 0` ⇒ 不等,直接如实回报在飞数。 */
|
|
48
|
+
export declare function drainMaintenanceLegs(timeoutMs: number): Promise<DrainReading>;
|
|
49
|
+
/** 排空的默认上界。取值理由:维护腿的常态是毫秒级的几条 SQL;5s 足够让它们全部落地,又不会把一次
|
|
50
|
+
* 收编吊死在一条卡住的腿上(卡住的那一条由它自己的重入守卫与 throttled-warn 负责被看见)。 */
|
|
51
|
+
export declare const ADOPTION_DRAIN_TIMEOUT_MS = 5000;
|
|
52
|
+
export interface QuiesceOpts {
|
|
53
|
+
/** 排空上界(测试可调小);缺省 {@link ADOPTION_DRAIN_TIMEOUT_MS}。 */
|
|
54
|
+
drainTimeoutMs?: number;
|
|
55
|
+
/** 没排空时的**响亮**出口(生产接 logger.warn)。缺席 ⇒ 不复述(读数仍由返回值带出)。 */
|
|
56
|
+
onDrainTimeout?: (reading: DrainReading) => void;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 在闸内跑一条收编弧:**先关闸,再排空,最后才动数据**。
|
|
60
|
+
*
|
|
61
|
+
* 三步的次序是承重的 —— 先关闸(此后不会再有新 tick 起飞新腿),再等已经在飞的那些落地,然后才把
|
|
62
|
+
* 控制权交给弧。反过来(先排空再关闸)之间那道窗里正好可以起飞新的一轮。
|
|
63
|
+
*
|
|
64
|
+
* `finally` 归零是硬的:抛出去的异常照常上抛(调用方的错误语义一个字不改),但闸绝不能因为一次失败
|
|
65
|
+
* 就永久关着 —— 那会把一次收编失败升级成「这台副本从此不再做任何后台维护」。
|
|
66
|
+
*/
|
|
67
|
+
export declare function withAdoptionQuiesce<T>(fn: () => Promise<T>, opts?: QuiesceOpts): Promise<T>;
|
|
68
|
+
/** 测试用的复位口(用例之间零串味)。生产路径**不调** —— 它会把别的在飞弧的计数一起抹掉。 */
|
|
69
|
+
export declare function resetAdoptionQuiesceForTest(): void;
|
|
70
|
+
//# sourceMappingURL=quiesce.d.ts.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A-010.18 —— 收编弧跑动期间的**进程内静默闸**(design/183 I1「引擎先停」在本进程里的执法面)。
|
|
3
|
+
*
|
|
4
|
+
* ── 病 ──────────────────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* 183 I1 写的是「发起收编之前必须先把引擎停掉」。那是一条**运维前提**,而本进程此前对它**零执法**:
|
|
6
|
+
* `POST /v1/adoption` 收下请求、弧开始逐表 UPDATE 的同时,本副本的维护 tick 照常每几秒跑一轮,
|
|
7
|
+
* 而那一轮里好几条腿写的正是被迁的那些表。最锋利的一条是**审批收敛器**(`approval-reconciler.ts`):
|
|
8
|
+
* 它按 `owner` 读 PARKING/STREAM_PENDING 行并 CAS 结算。收编把 `approval_ask.owner` 从 A 改成 B 的
|
|
9
|
+
* 那一瞬,收敛器手里可能正握着按**旧** owner 读出来的一批行 —— 结算下去就是把一只本该 PARKED 的 ask
|
|
10
|
+
* 写成 **DENIED**,而 DENIED 是终局:收编回滚不了它,重跑也追认不回来。
|
|
11
|
+
*
|
|
12
|
+
* ── 修 ──────────────────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
* 弧跑动期间把维护 tick 整轮让开。判据不是「挑几条腿避让」而是**整轮**:
|
|
14
|
+
* ① 维护 tick 是 best-effort 的周期动作(每条腿都幂等、都能等下一轮),让开几轮零代价;而逐腿判
|
|
15
|
+
* 「这条腿碰不碰被迁的表」会立刻变成第二份需要与 `REBIND_LEGS` 同步的表 —— 那正是本仓反复吃过亏的
|
|
16
|
+
* 「两份判据必然漂」形(漏判一条新腿是**静默**的:它照写不误)。
|
|
17
|
+
* ② 弧本身是毫秒级的几条 UPDATE,库里常态在飞行数为 0 —— 让开的窗极短。
|
|
18
|
+
*
|
|
19
|
+
* ── 射程边界(如实登记,不假称已解)──────────────────────────────────────────────────────────────
|
|
20
|
+
* 两条边界,都写在明处:
|
|
21
|
+
* ① **进程内**。这道闸只挡得住**运行这条弧的那个副本**;多副本部署里别的副本的 tick 照跑,那部分仍然
|
|
22
|
+
* 只能靠 183 I1 的运维前提(以及弧内那些 rev-CAS:载荷改写撞上并发写会响亮抛、整体回滚)。
|
|
23
|
+
* 更强的形(跨副本静默)需要一枚库上的 quiesce 租约 + 每条腿的取租,那是一次独立设计,不在本条射程。
|
|
24
|
+
* ② **排空有界**。等不到就带着读数继续(见下),所以「本进程零腿在写被迁的表」是**通常**成立而不是
|
|
25
|
+
* 恒成立 —— 超时那一次由 `onDrainTimeout` 响亮说出来,不许静默当成已排空。
|
|
26
|
+
* 本闸因此是**纵深防御**,不是把 I1 变成机器保证。
|
|
27
|
+
*
|
|
28
|
+
* ── 形态 ────────────────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
* 模块级计数器而不是布尔:并发同参 POST 是本车明确支持的形(UNIQUE 仲裁),两条弧可以叠在一起,
|
|
30
|
+
* 布尔会被先结束的那条**提前**放开闸。计数器 + `try/finally` ⇒ 抛错也一定归零。
|
|
31
|
+
*/
|
|
32
|
+
/** 当前正在本进程里跑的收编弧条数。**只**由 {@link withAdoptionQuiesce} 增减。 */
|
|
33
|
+
let inFlight = 0;
|
|
34
|
+
/** 本进程此刻是否有收编弧在跑(维护 tick 的让路判据)。 */
|
|
35
|
+
export function isAdoptionQuiescing() {
|
|
36
|
+
return inFlight > 0;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* **已经起飞**的维护腿(codex 对抗复审 R1 [high],验真后修)。
|
|
40
|
+
*
|
|
41
|
+
* 🔴 病:第一版的闸只在 tick 的**入口**判一次。可维护 tick 的每条腿都是 `void <promise>` 异步起飞的
|
|
42
|
+
* —— tick 回调本身立刻返回。于是这个次序完全可能:`t0` tick 起飞了审批收敛器(它按旧 owner 读出一批
|
|
43
|
+
* 行,正等库回话)→ `t0+ε` 收编弧开始改 `approval_ask.owner` → 收敛器拿着旧读数回来 CAS,把一只本该
|
|
44
|
+
* PARKED 的 ask 结算成 **DENIED**。也就是说:**这正是本条声称要消灭的那一形**,闸只是让它更难撞上。
|
|
45
|
+
* 我原来的钉也测不出来 —— 它先开闸再推进时钟,探针又是立即完成的,反向次序根本没被覆盖。
|
|
46
|
+
*
|
|
47
|
+
* 🔴 修:闸不止「拦住下一轮」,还要**排空这一轮**。每条腿起飞时在这里登记,收编弧进库之前先等它们落地。
|
|
48
|
+
*
|
|
49
|
+
* ⏱ **有界等待**是硬的:维护腿里有会长时间挂住的(`worktreeReap` 的 `git worktree prune` 随 worktree
|
|
50
|
+
* 数增长,它自己的重入守卫就是为此而设)。无界 await 会把「一条腿卡住」升级成「收编永远起不来」——
|
|
51
|
+
* 比原病更坏。所以排空是**尽力而为 + 响亮**:超时就带着读数继续,让运维看得见「这次收编没能等到 N 条
|
|
52
|
+
* 腿落地」,而不是既没等到也没人知道。
|
|
53
|
+
*/
|
|
54
|
+
const airborneLegs = new Set();
|
|
55
|
+
/**
|
|
56
|
+
* 登记一条**刚起飞**的维护腿。调用方仍然 `void` 它自己的链(错误处置不变);本函数只借用它的落地时刻。
|
|
57
|
+
*
|
|
58
|
+
* `catch` 是必须的:登记的这一份引用绝不能变成第二个未处理拒绝源(腿自己的链已经有 guard 接住了)。
|
|
59
|
+
*/
|
|
60
|
+
export function trackMaintenanceLeg(leg) {
|
|
61
|
+
const tracked = leg.catch(() => undefined);
|
|
62
|
+
airborneLegs.add(tracked);
|
|
63
|
+
void tracked.finally(() => {
|
|
64
|
+
airborneLegs.delete(tracked);
|
|
65
|
+
// 集合空了才是「排空」——通知所有在等的收编弧。这是**唯一**挂在腿身上的 handler(每条腿恰一个,
|
|
66
|
+
// 在它起飞时挂),排空等待方一律等下面那只**自己的** waiter,不再往腿上加第二个 handler。
|
|
67
|
+
if (airborneLegs.size === 0) {
|
|
68
|
+
for (const wake of drainWaiters)
|
|
69
|
+
wake();
|
|
70
|
+
drainWaiters.clear();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 正在等排空的收编弧(codex 对抗复审 R2 [medium],验真后修)。
|
|
76
|
+
*
|
|
77
|
+
* 🔴 病:第一版每次排空都现铸 `Promise.allSettled([...airborneLegs])`。**超时那一路**里,那只 allSettled
|
|
78
|
+
* 永远不会兑现,而它已经把 handler 挂在了每一条挂死的腿上 —— 且 `clearTimeout` 清不掉这些 promise
|
|
79
|
+
* reaction。于是「有一条腿永久挂住」这个**本来就是有界逃逸要处理的**场景,会让每一次 POST / 每一次 boot
|
|
80
|
+
* 续跑再挂一批清不掉的闭包上去:逃逸机制自己变成了泄漏源。
|
|
81
|
+
*
|
|
82
|
+
* 🔴 修:等待方注册一只**自己的** waiter,超时时把它注销掉(`finally` 里 delete)。腿身上的 handler 恒为
|
|
83
|
+
* 「起飞时挂的那一个」,与等待次数无关 ⇒ 重复超时不再增长任何东西。
|
|
84
|
+
*/
|
|
85
|
+
const drainWaiters = new Set();
|
|
86
|
+
/** 取证口(测试用):此刻有几只等待方挂着。生产不读 —— 它存在是为了让「重复超时不泄漏」可被断言。 */
|
|
87
|
+
export function drainWaiterCountForTest() {
|
|
88
|
+
return drainWaiters.size;
|
|
89
|
+
}
|
|
90
|
+
/** 有界排空(见 {@link airborneLegs} 的注)。`timeoutMs <= 0` ⇒ 不等,直接如实回报在飞数。 */
|
|
91
|
+
export async function drainMaintenanceLegs(timeoutMs) {
|
|
92
|
+
if (airborneLegs.size === 0)
|
|
93
|
+
return { drained: true, pending: 0 };
|
|
94
|
+
if (timeoutMs <= 0)
|
|
95
|
+
return { drained: false, pending: airborneLegs.size };
|
|
96
|
+
let wake;
|
|
97
|
+
const drained = new Promise((resolve) => {
|
|
98
|
+
wake = () => resolve("drained");
|
|
99
|
+
});
|
|
100
|
+
drainWaiters.add(wake);
|
|
101
|
+
let timer;
|
|
102
|
+
const deadline = new Promise((resolve) => {
|
|
103
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
104
|
+
timer.unref?.(); // 排空的等待绝不该把进程吊住
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
const outcome = await Promise.race([drained, deadline]);
|
|
108
|
+
return outcome === "drained" ? { drained: true, pending: 0 } : { drained: false, pending: airborneLegs.size };
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
if (timer !== undefined)
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
// 🔴 注销是本修的**全部内容**:超时之后这只 waiter 不许继续挂着(见 drainWaiters 的注)。
|
|
114
|
+
drainWaiters.delete(wake);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** 排空的默认上界。取值理由:维护腿的常态是毫秒级的几条 SQL;5s 足够让它们全部落地,又不会把一次
|
|
118
|
+
* 收编吊死在一条卡住的腿上(卡住的那一条由它自己的重入守卫与 throttled-warn 负责被看见)。 */
|
|
119
|
+
export const ADOPTION_DRAIN_TIMEOUT_MS = 5_000;
|
|
120
|
+
/**
|
|
121
|
+
* 在闸内跑一条收编弧:**先关闸,再排空,最后才动数据**。
|
|
122
|
+
*
|
|
123
|
+
* 三步的次序是承重的 —— 先关闸(此后不会再有新 tick 起飞新腿),再等已经在飞的那些落地,然后才把
|
|
124
|
+
* 控制权交给弧。反过来(先排空再关闸)之间那道窗里正好可以起飞新的一轮。
|
|
125
|
+
*
|
|
126
|
+
* `finally` 归零是硬的:抛出去的异常照常上抛(调用方的错误语义一个字不改),但闸绝不能因为一次失败
|
|
127
|
+
* 就永久关着 —— 那会把一次收编失败升级成「这台副本从此不再做任何后台维护」。
|
|
128
|
+
*/
|
|
129
|
+
export async function withAdoptionQuiesce(fn, opts) {
|
|
130
|
+
inFlight += 1; // ① 关闸:此后的 tick 一律让路
|
|
131
|
+
try {
|
|
132
|
+
// ② 排空这一轮已经起飞的腿(有界;超时如实响亮)。
|
|
133
|
+
const reading = await drainMaintenanceLegs(opts?.drainTimeoutMs ?? ADOPTION_DRAIN_TIMEOUT_MS);
|
|
134
|
+
if (!reading.drained)
|
|
135
|
+
opts?.onDrainTimeout?.(reading);
|
|
136
|
+
return await fn(); // ③ 动数据
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
inFlight -= 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** 测试用的复位口(用例之间零串味)。生产路径**不调** —— 它会把别的在飞弧的计数一起抹掉。 */
|
|
143
|
+
export function resetAdoptionQuiesceForTest() {
|
|
144
|
+
inFlight = 0;
|
|
145
|
+
airborneLegs.clear();
|
|
146
|
+
drainWaiters.clear();
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=quiesce.js.map
|
package/dist/adoption/runner.js
CHANGED
|
@@ -23,8 +23,10 @@
|
|
|
23
23
|
import { randomUUID } from "node:crypto";
|
|
24
24
|
import { ADOPTION_PHASE, adoptionLockName, } from "../plugins/adoption-log-sql.js";
|
|
25
25
|
import { policyKeyFor } from "../plugins/session-policy-store-sql.js";
|
|
26
|
+
import { buildRuleOwnerKey } from "../plugins/permission-rule-store-sql.js";
|
|
27
|
+
import { withAdoptionQuiesce } from "./quiesce.js";
|
|
26
28
|
import { AFFECTED_CONFIG_TEMPLATES, MEMORY_SCOPE_MAX_CHARS, NOT_MIGRATED_BY_DESIGN, REBIND_LEGS, buildVerbatimPair, rewriteMemoryScope, } from "./plan.js";
|
|
27
|
-
import { buildBlobScanSql, buildBlobWriteSql, buildConflictProbeSql, buildMemoryScopeScanSql, buildResidualCountSql, buildRebindSql, buildSessionPolicyRewriteSql, buildSessionPolicyScanSql, } from "./sql.js";
|
|
29
|
+
import { buildBlobScanSql, buildBlobWriteSql, buildConflictProbeSql, buildMemoryScopeScanSql, buildResidualCountSql, buildRebindSql, buildRuleOwnerRekeySql, buildSessionPolicyRewriteSql, buildSessionPolicyScanSql, } from "./sql.js";
|
|
28
30
|
import { AdoptionConfigsSchema, AdoptionLegsSchema, AdoptionReceiptSchema, AdoptionRejectDetailSchema, AdoptionReportSchema, } from "./wire.js";
|
|
29
31
|
import { formatUserScope, encodeScopeSegment } from "@sema-agent/core";
|
|
30
32
|
/** MySQL `ER_DUP_ENTRY` / PG `unique_violation` —— 跨轴组合冲突的最后一道 fail-closed 门。 */
|
|
@@ -42,6 +44,18 @@ function msgOf(e) {
|
|
|
42
44
|
async function resolveValuePairs(exec, dialect, leg, fromPrincipal, toPrincipal) {
|
|
43
45
|
if (leg.encoding === "verbatim")
|
|
44
46
|
return [buildVerbatimPair(fromPrincipal, toPrincipal)];
|
|
47
|
+
// 规则桶键:恒一对,**算**出来而不是扫出来(原像里只有 principal,没有逐行变量)。
|
|
48
|
+
// 🔴 这一支同时服务 preflight 的冲突探针与 `countResidual` 的残留计数 —— 少了它,那两处会拿**明文**
|
|
49
|
+
// principal 去比 `owner_key`,而那个谓词在这张表上永远匹配不到任何一行:preflight 恒说「无冲突」、
|
|
50
|
+
// 残留恒报 0(A-010.16 / A-010.22 点名的正是后者)。
|
|
51
|
+
if (leg.encoding === "rule-owner-key") {
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
from: buildRuleOwnerKey({ kind: "principal", principal: fromPrincipal }),
|
|
55
|
+
to: buildRuleOwnerKey({ kind: "principal", principal: toPrincipal }),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
45
59
|
const seg = encodeScopeSegment(fromPrincipal);
|
|
46
60
|
const projHead = `proj:${seg}/`;
|
|
47
61
|
const userProjHead = `userproj:${seg}/`;
|
|
@@ -183,6 +197,15 @@ export function createAdoptionRunner(deps) {
|
|
|
183
197
|
out.push({ store: leg.leg, action: leg.action, rows });
|
|
184
198
|
continue;
|
|
185
199
|
}
|
|
200
|
+
if (leg.kind === "rule-owner-rekey") {
|
|
201
|
+
// 一对键算一次(见 buildRuleOwnerRekeySql 的注),一条等值 UPDATE 走完;`principal` 明文列同改。
|
|
202
|
+
const [pair] = await resolveValuePairs(exec, dialect, leg, fromPrincipal, toPrincipal);
|
|
203
|
+
if (pair === undefined)
|
|
204
|
+
throw new Error(`runLegs: leg ${leg.leg} resolved no identity pair`);
|
|
205
|
+
const res = await exec.query(buildRuleOwnerRekeySql(dialect, leg), [pair.to, toPrincipal, pair.from]);
|
|
206
|
+
out.push({ store: leg.leg, action: leg.action, rows: res.affected });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
186
209
|
const pairs = await resolveValuePairs(exec, dialect, leg, fromPrincipal, toPrincipal);
|
|
187
210
|
let rows = 0;
|
|
188
211
|
for (const pair of pairs) {
|
|
@@ -230,11 +253,26 @@ export function createAdoptionRunner(deps) {
|
|
|
230
253
|
slot.blob = leg;
|
|
231
254
|
else {
|
|
232
255
|
slot.match.add(leg.matchColumn);
|
|
233
|
-
|
|
234
|
-
|
|
256
|
+
// 🔴 A-010.16(验真后修):这里此前只认 `memory-scope` 一种**非明文**编码,别的编码一律
|
|
257
|
+
// 落到下面那条 `[{ from: fromPrincipal, … }]` 兜底 —— 也就是拿**明文 principal** 去比一根存
|
|
258
|
+
// 派生键的列。对 `permission_rule.owner_key`(sha256 原像里带身份)那个谓词**永远匹配不到
|
|
259
|
+
// 任何一行**,于是残留计数在这张表上结构性恒 0:回执一边说「adopted」、一边把一整桶还挂在
|
|
260
|
+
// 旧身份名下的活规则报成「零残留」。判据改成**编码非 verbatim 即需解析**,新编码天然进对臂。
|
|
261
|
+
if (leg.encoding !== "verbatim")
|
|
262
|
+
slot.codedLeg = leg;
|
|
235
263
|
}
|
|
236
264
|
byTable.set(leg.table, slot);
|
|
237
265
|
}
|
|
266
|
+
// 🔴 一张表内**混编码**会让下面那条「一条 COUNT + 一个值对」的形悄悄算错(OR 起来的每一列都会被
|
|
267
|
+
// 绑上同一个值,而它们的编码不同 ⇒ 至少一列的谓词恒假)。今天没有这样的表;真出现时必须响亮拦下,
|
|
268
|
+
// 而不是报一个偏小的残留数(偏小 = 说「没落下」,正是本条要消灭的谎)。
|
|
269
|
+
for (const [table, slot] of byTable) {
|
|
270
|
+
const encodings = new Set(REBIND_LEGS.filter((l) => l.table === table && l.kind !== "blob-rewrite").map((l) => l.encoding));
|
|
271
|
+
if (encodings.size > 1) {
|
|
272
|
+
throw new Error(`adoption: table ${table} carries identity axes in more than one encoding (${[...encodings].sort().join(", ")}) — the residual count binds ONE value pair across the OR'd columns, so it would silently under-report. Split the count per encoding before adding such a leg.`);
|
|
273
|
+
}
|
|
274
|
+
void slot;
|
|
275
|
+
}
|
|
238
276
|
let n = 0;
|
|
239
277
|
for (const [table, slot] of byTable) {
|
|
240
278
|
if (slot.blob !== undefined) {
|
|
@@ -252,8 +290,8 @@ export function createAdoptionRunner(deps) {
|
|
|
252
290
|
// 普通表:一条 COUNT,谓词是该表全部身份轴的并。memory 族的值对逐对各来一次(不同 scope 键 =
|
|
253
291
|
// 不同的行集合,不会重复计数同一行)。
|
|
254
292
|
const cols = [...slot.match];
|
|
255
|
-
const pairs = slot.
|
|
256
|
-
? await resolveValuePairs(exec, dialect, slot.
|
|
293
|
+
const pairs = slot.codedLeg !== undefined
|
|
294
|
+
? await resolveValuePairs(exec, dialect, slot.codedLeg, fromPrincipal, toPrincipal)
|
|
257
295
|
: [{ from: fromPrincipal, to: toPrincipal }];
|
|
258
296
|
for (const pair of pairs) {
|
|
259
297
|
const res = await exec.query(buildResidualCountSql(dialect, table, cols), cols.map(() => pair.from));
|
|
@@ -421,7 +459,27 @@ export function createAdoptionRunner(deps) {
|
|
|
421
459
|
return 1;
|
|
422
460
|
}
|
|
423
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* 🔴 A-010.18:**唯一**的静默闸挂点。三条入口(`start` / `get` 的在飞续跑 / boot 的 `resumeInFlight`)
|
|
464
|
+
* 全部经由本函数动数据,所以闸挂在这里一处就覆盖全部会写被迁表的路径 —— 挂在 `start` 上会漏掉后两条,
|
|
465
|
+
* 而那两条恰恰是「没人按重试、副本自己续跑」的场景,与维护 tick 撞车的概率并不更低。
|
|
466
|
+
* 闸的语义、为什么让开**整轮** tick、以及它只挡本副本这条射程边界,逐字见 `adoption/quiesce.ts`。
|
|
467
|
+
*/
|
|
424
468
|
async function driveUnderLock(row, sweep) {
|
|
469
|
+
return withAdoptionQuiesce(() => driveUnderLockInner(row, sweep), {
|
|
470
|
+
// 排空没等到 ⇒ **响亮**(codex R1 [high] 的第二半):等不到不等于可以装作等到了。这条 warn 是
|
|
471
|
+
// 运维判断「那次收编期间有没有腿在跟它抢同一批行」的唯一读数。
|
|
472
|
+
onDrainTimeout: (reading) => deps.logger?.warn?.("adoption_maintenance_drain_timeout", {
|
|
473
|
+
adoptionId: row.adoptionId,
|
|
474
|
+
pendingLegs: reading.pending,
|
|
475
|
+
note: "the adoption arc stopped waiting for this replica's already-airborne maintenance legs to land. " +
|
|
476
|
+
"New ticks are held off for the arc's duration, but a leg that was ALREADY running may still write a " +
|
|
477
|
+
"table this arc rebinds (the approval reconciler is the sharp one). design/183 I1 (stop the engine " +
|
|
478
|
+
"first) remains the operator premise; this reading says the in-process half could not fully cover it.",
|
|
479
|
+
}),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
async function driveUnderLockInner(row, sweep) {
|
|
425
483
|
const out = await store.withLock(adoptionLockName(row.fromPrincipal),
|
|
426
484
|
// 🔴 **拿到锁之后重读**(codex R2-F3):`row` 是排队**之前**读到的快照,而排队期间别的副本可能已经
|
|
427
485
|
// 把这条弧推完或拒掉了。拿旧快照进状态机 = 按一个已经不成立的世界做决定(实测可达的形:一条已被
|
package/dist/adoption/sql.d.ts
CHANGED
|
@@ -65,6 +65,21 @@ export declare function buildBlobScanSql(dialect: SqlDialect, leg: RebindLegSpec
|
|
|
65
65
|
* (行数可能仍报 1,故回执的行数按「**内容真变了**才计」在调用侧收窄)。
|
|
66
66
|
*/
|
|
67
67
|
export declare function buildBlobWriteSql(dialect: SqlDialect, leg: RebindLegSpec): string;
|
|
68
|
+
/**
|
|
69
|
+
* 规则桶的**整桶改键**(A-010.16)。参数序:`[newOwnerKey, toPrincipal, oldOwnerKey]`。
|
|
70
|
+
*
|
|
71
|
+
* `owner_key = sha256("principal:" + principal)` —— 身份在**键的 hash 原像**里,与 session_policy
|
|
72
|
+
* 同族。差别在于这把键**不含逐行变量**(session_policy 的原像里还有 session_id),所以不必逐行扫出来
|
|
73
|
+
* 重算:一对 (旧键, 新键) 算一次,一条等值 UPDATE 走完全表。
|
|
74
|
+
*
|
|
75
|
+
* `principal` 列跟着同改:它是 owner_key 的**明文原像列**(店按前者寻址、`GET /v1/rules` 的回执与运维
|
|
76
|
+
* 的 `WHERE principal = …` 排查按后者读)。只改一列,库里就有两份互相矛盾的身份说法。
|
|
77
|
+
*
|
|
78
|
+
* 幂等性来自谓词:重跑时旧桶键已经不在库里 ⇒ 零行匹配 ⇒ 零行变化。
|
|
79
|
+
* `owner_kind = 'local-owner'` 的那只桶天然不受影响 —— 它的 owner_key 是 `sha256("local-owner")`,
|
|
80
|
+
* 与任何 principal 派生的键都不相等,所以**匹配不到**(不需要额外的 owner_kind 谓词来护住它)。
|
|
81
|
+
*/
|
|
82
|
+
export declare function buildRuleOwnerRekeySql(dialect: SqlDialect, leg: RebindLegSpec): string;
|
|
68
83
|
/** session_policy 重键腿的扫描:拿到 from 侧每一行的 `(policy_key, session_id)`。参数序:`[fromPrincipal]`。 */
|
|
69
84
|
export declare function buildSessionPolicyScanSql(dialect: SqlDialect): string;
|
|
70
85
|
/**
|
package/dist/adoption/sql.js
CHANGED
|
@@ -90,6 +90,24 @@ export function buildBlobWriteSql(dialect, leg) {
|
|
|
90
90
|
// —— 收编那时已经报 adopted。推进 rev 才让那些「站在过去」的写者按 OCC 的本意输掉并重试。
|
|
91
91
|
return `UPDATE ${leg.table} SET ${blob.column} = ${ph(dialect, 1)}, ${blob.revColumn} = ${blob.revColumn} + 1 WHERE ${where} AND ${blob.revColumn} = ${ph(dialect, blob.pk.length + 2)}`;
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* 规则桶的**整桶改键**(A-010.16)。参数序:`[newOwnerKey, toPrincipal, oldOwnerKey]`。
|
|
95
|
+
*
|
|
96
|
+
* `owner_key = sha256("principal:" + principal)` —— 身份在**键的 hash 原像**里,与 session_policy
|
|
97
|
+
* 同族。差别在于这把键**不含逐行变量**(session_policy 的原像里还有 session_id),所以不必逐行扫出来
|
|
98
|
+
* 重算:一对 (旧键, 新键) 算一次,一条等值 UPDATE 走完全表。
|
|
99
|
+
*
|
|
100
|
+
* `principal` 列跟着同改:它是 owner_key 的**明文原像列**(店按前者寻址、`GET /v1/rules` 的回执与运维
|
|
101
|
+
* 的 `WHERE principal = …` 排查按后者读)。只改一列,库里就有两份互相矛盾的身份说法。
|
|
102
|
+
*
|
|
103
|
+
* 幂等性来自谓词:重跑时旧桶键已经不在库里 ⇒ 零行匹配 ⇒ 零行变化。
|
|
104
|
+
* `owner_kind = 'local-owner'` 的那只桶天然不受影响 —— 它的 owner_key 是 `sha256("local-owner")`,
|
|
105
|
+
* 与任何 principal 派生的键都不相等,所以**匹配不到**(不需要额外的 owner_kind 谓词来护住它)。
|
|
106
|
+
*/
|
|
107
|
+
export function buildRuleOwnerRekeySql(dialect, leg) {
|
|
108
|
+
return (`UPDATE ${leg.table} SET owner_key = ${ph(dialect, 1)}, principal = ${ph(dialect, 2)}` +
|
|
109
|
+
` WHERE owner_key = ${ph(dialect, 3)}`);
|
|
110
|
+
}
|
|
93
111
|
/** session_policy 重键腿的扫描:拿到 from 侧每一行的 `(policy_key, session_id)`。参数序:`[fromPrincipal]`。 */
|
|
94
112
|
export function buildSessionPolicyScanSql(dialect) {
|
|
95
113
|
return `SELECT policy_key, session_id FROM session_policy WHERE principal = ${ph(dialect, 1)}`;
|