@sema-agent/server 7.29.0 → 7.30.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 (52) hide show
  1. package/USAGE.md +20 -5
  2. package/dist/boot/governance-seams.d.ts +13 -0
  3. package/dist/boot/governance-seams.js +21 -1
  4. package/dist/boot/retention-lane.d.ts +206 -0
  5. package/dist/boot/retention-lane.js +279 -0
  6. package/dist/boot/shutdown.d.ts +8 -0
  7. package/dist/boot/shutdown.js +21 -2
  8. package/dist/config-types.d.ts +22 -1
  9. package/dist/config-types.js +6 -1
  10. package/dist/config.d.ts +15 -1
  11. package/dist/config.js +43 -2
  12. package/dist/http/routes/capabilities.js +30 -0
  13. package/dist/http/routes/memory-policy.d.ts +13 -0
  14. package/dist/http/routes/memory-policy.js +79 -2
  15. package/dist/http/routes/retention-ops.d.ts +36 -0
  16. package/dist/http/routes/retention-ops.js +190 -0
  17. package/dist/http/server.js +14 -0
  18. package/dist/main.js +74 -0
  19. package/dist/memory-scope.d.ts +5 -3
  20. package/dist/memory-scope.js +6 -8
  21. package/dist/observability/fail-open.d.ts +4 -0
  22. package/dist/observability/fail-open.js +4 -0
  23. package/dist/plugins/caching-session-store.d.ts +11 -1
  24. package/dist/plugins/caching-session-store.js +13 -0
  25. package/dist/plugins/checkpoint-store-sql.d.ts +3 -0
  26. package/dist/plugins/checkpoint-store-sql.js +4 -0
  27. package/dist/plugins/local-checkpoint-store.d.ts +3 -0
  28. package/dist/plugins/local-checkpoint-store.js +4 -0
  29. package/dist/plugins/local-session-store.d.ts +5 -0
  30. package/dist/plugins/local-session-store.js +6 -0
  31. package/dist/plugins/memory-engine-pg.js +22 -10
  32. package/dist/plugins/memory-engine-tidb.js +22 -11
  33. package/dist/plugins/memory-key-guards.d.ts +27 -4
  34. package/dist/plugins/memory-key-guards.js +57 -6
  35. package/dist/plugins/memory-sync-store-pg.js +5 -0
  36. package/dist/plugins/memory-sync-store-tidb.js +5 -0
  37. package/dist/plugins/pg-pool.js +4 -0
  38. package/dist/plugins/pg-session-storage.d.ts +2 -0
  39. package/dist/plugins/pg-session-storage.js +3 -0
  40. package/dist/plugins/retention-lane-store-sql.d.ts +254 -0
  41. package/dist/plugins/retention-lane-store-sql.js +236 -0
  42. package/dist/plugins/retention-store-sql.d.ts +468 -0
  43. package/dist/plugins/retention-store-sql.js +793 -0
  44. package/dist/plugins/store-backend.d.ts +20 -0
  45. package/dist/plugins/store-backend.js +7 -0
  46. package/dist/plugins/tidb-pool.js +5 -0
  47. package/dist/plugins/tidb-session-store.d.ts +3 -0
  48. package/dist/plugins/tidb-session-store.js +4 -0
  49. package/dist/plugins/tool-result-store-sql.d.ts +3 -0
  50. package/dist/plugins/tool-result-store-sql.js +4 -0
  51. package/dist/run-local.js +16 -0
  52. package/package.json +1 -1
@@ -0,0 +1,236 @@
1
+ import { mysqlDriver, pgDriver } from "./sql-driver.js";
2
+ import { RETENTION_AUDIT_TABLE, RETENTION_HOLD_TABLE, RETENTION_LEASE_TABLE } from "./retention-store-sql.js";
3
+ /** 审计读面的单页上界(与 roster 名册窗同姿势:读面要有界)。 */
4
+ export const RETENTION_AUDIT_MAX_LIMIT = 200;
5
+ /** 审计读面的缺省页大小。 */
6
+ export const RETENTION_AUDIT_DEFAULT_LIMIT = 50;
7
+ /**
8
+ * 执行面的非破坏性 SQL 三组口(双方言;绑定类在文件底部)。
9
+ *
10
+ * 🔴 本类**删不掉任何业务数据** —— 它写的只有 `retention_lease` 的一行、`retention_hold` 的一列、
11
+ * 与 `retention_audit` 的追加行。破坏性的三条腿在车1 的 `SqlRetentionStore` 上,拿本对象够不着。
12
+ */
13
+ export class SqlRetentionLaneStore {
14
+ db;
15
+ constructor(db) {
16
+ this.db = db;
17
+ }
18
+ /** 方言取文(两条语句都写在调用点;A12 判据)。 */
19
+ q(tidb, pg) {
20
+ return this.db.dialect === "tidb" ? tidb : pg;
21
+ }
22
+ // ───────────────────────────── ① sweep 租约(§3)─────────────────────────────
23
+ /**
24
+ * 抢/续租一次(单行 CAS)。抢到 ⇒ `{held:true, fencingToken}`;别人正持着 ⇒ `{held:false}`,调用方
25
+ * **本 tick 零写静默跳过**。
26
+ *
27
+ * 事务三步,一步不能省:
28
+ * ① `INSERT IGNORE`(PG:`ON CONFLICT DO NOTHING`)一条 `expires_at_ms = 0` 的**哨兵行** —— 它永远
29
+ * 不会改写一条既存租约(两方言的这两个动词都只在缺行时写);
30
+ * ② `SELECT … FOR UPDATE` —— 行现在必定存在,锁真的拿得到;
31
+ * ③ 判 + `UPDATE`,同事务提交。
32
+ *
33
+ * 🔴 为什么必须先造行、而不是「一条 `UPDATE … WHERE expires_at_ms < now OR holder = self`」了事
34
+ * (设计稿 §3 写的正是那条裸 UPDATE,本实现是它的**收口**,与车1 hold 哨兵是同一条教训):裸 UPDATE
35
+ * 在**空表**上影响 0 行 —— 于是首启的两个副本都读到「没抢到」,lane 在一个从没跑过的部署上**永远不
36
+ * 起**(静默,读数是"每 tick 跳过",看上去像"别人在跑")。补一条无条件 INSERT 又会在两副本同拍首启时
37
+ * 撞 PK。哨兵 + 行锁把两件事一次解决,并且顺带让 fencing token 的自增判据可以在应用层算。
38
+ *
39
+ * 🔴 fencing token 只在**抢租**(holder 变了)时自增,续租不动它:token 的语义是「第几轮**执行权**」,
40
+ * 一个持续持租的副本跑的是同一段连续执行,给它每 tick 换号会让审计上「同一轮的行」看起来跨了很多轮。
41
+ *
42
+ * @param holder 本副本的身份(instanceId);同一 holder 再来 = 续租。
43
+ * @param ttlMs 租约时长 = 2× sweep 间隔(§3)。
44
+ */
45
+ async acquire(holder, ttlMs, nowMs) {
46
+ const conn = await this.db.connect();
47
+ try {
48
+ await conn.begin();
49
+ await conn.query(this.q(`INSERT IGNORE INTO ${RETENTION_LEASE_TABLE} (singleton, holder, fencing_token, expires_at_ms) VALUES ('x','',0,0)`, `INSERT INTO ${RETENTION_LEASE_TABLE} (singleton, holder, fencing_token, expires_at_ms) VALUES ('x','',0,0) ON CONFLICT (singleton) DO NOTHING`));
50
+ const { rows } = await conn.query(this.q(`SELECT holder, fencing_token, expires_at_ms FROM ${RETENTION_LEASE_TABLE} WHERE singleton = 'x' FOR UPDATE`, `SELECT holder, fencing_token, expires_at_ms FROM ${RETENTION_LEASE_TABLE} WHERE singleton = 'x' FOR UPDATE`));
51
+ const cur = rows[0];
52
+ const curHolder = String(cur?.holder ?? "");
53
+ const curToken = Number(cur?.fencing_token ?? 0);
54
+ const expiresAtMs = Number(cur?.expires_at_ms ?? 0);
55
+ // 别人持着且还没过期 ⇒ 不抢(**过期判据用 `>` 而不是 `>=`**:恰好到点的那一刻租约已经不再有效,
56
+ // 而 `>=` 会让两个副本在同一毫秒上各自认为对方还持着 —— 都不抢,lane 停摆一拍)。
57
+ if (curHolder !== holder && curHolder !== "" && expiresAtMs > nowMs) {
58
+ await conn.commit();
59
+ return { held: false, holder: curHolder, expiresAtMs };
60
+ }
61
+ const renewing = curHolder === holder;
62
+ const fencingToken = renewing ? curToken : curToken + 1;
63
+ await conn.query(this.q(`UPDATE ${RETENTION_LEASE_TABLE} SET holder = ?, fencing_token = ?, expires_at_ms = ? WHERE singleton = 'x'`, `UPDATE ${RETENTION_LEASE_TABLE} SET holder = $1, fencing_token = $2, expires_at_ms = $3 WHERE singleton = 'x'`), [holder, fencingToken, nowMs + ttlMs]);
64
+ await conn.commit();
65
+ return { held: true, fencingToken, holder };
66
+ }
67
+ catch (err) {
68
+ await conn.rollback().catch(() => undefined);
69
+ throw err;
70
+ }
71
+ finally {
72
+ conn.release();
73
+ }
74
+ }
75
+ /**
76
+ * 「这一轮的执行权还在我手上吗」**并同时续租** —— 每 domain 处理前调一次(§3 丢租即停)。
77
+ * 返回 `false` = 不再属于我 ⇒ 调用方当场中止本轮。
78
+ *
79
+ * 判据三合取写进**一条 CAS 的 WHERE**:holder 是我 ∧ fencing token 还是本轮那个 ∧ **尚未过期**。
80
+ * · 第三项是承重的:租约过期而 holder 还写着我,必须读成**不再属于我**(fail-closed)——另一个副本
81
+ * 随时可能抢走,两个副本同时跑三方法是这条腿最不能出的事。
82
+ * · 「复核」与「续租」合成一条语句而不是先读后写(codex 对抗复审 R1-[high],验真后改):
83
+ * ① 读+写两条语句之间有窗口,而 CAS 的谓词与更新在引擎里是原子的;
84
+ * ② 更要紧的是**续租本身**:`startRetentionLane` 的重入守卫会跳过下一 tick,于是一轮的续租机会
85
+ * 只有轮首那一次 —— 一轮跑满 2×interval 之后租约自己到期,而本副本毫不知情、继续删下去。
86
+ * 轮子转多久租约就跟着延多久,那个窗口才真正关上。
87
+ *
88
+ * ⚠️ **如实登记的残余**(设计稿 §3 已成文接受,本实现不假装消灭):本调用返回 true 之后、本 domain 的
89
+ * 破坏性事务提交之前,仍有一个「租约在这中间被别人接管」的理论窗口 —— 关它需要把 fencing token 的
90
+ * 校验下推进**每一个破坏性事务的 WHERE**(车1 的三只方法),那是跨车的契约改动。补偿按设计稿:
91
+ * 每条破坏性审计行都带 fencing token,旧轮的写因此**可判别**(§6 的那一列正是为此存在)。
92
+ * 续租之后,这个窗口的宽度从「一整轮」缩到「一个 domain 的耗时」,且要求租约恰在这段内到期。
93
+ */
94
+ async renew(holder, fencingToken, ttlMs, nowMs) {
95
+ const { affected } = await this.db.query(this.q(`UPDATE ${RETENTION_LEASE_TABLE} SET expires_at_ms = ? WHERE singleton = 'x' AND holder = ? AND fencing_token = ? AND expires_at_ms > ?`, `UPDATE ${RETENTION_LEASE_TABLE} SET expires_at_ms = $1 WHERE singleton = 'x' AND holder = $2 AND fencing_token = $3 AND expires_at_ms > $4`), [nowMs + ttlMs, holder, fencingToken, nowMs]);
96
+ return affected > 0;
97
+ }
98
+ /**
99
+ * 主动让租(优雅停机)——把到期时间归零,**保留 holder 与 token**(它们是审计线索,不是锁本身)。
100
+ * 谓词带 `holder = ?`:一个已经丢了租的副本在退出时不许把**别人**的租约推倒。
101
+ */
102
+ async release(holder) {
103
+ await this.db.query(this.q(`UPDATE ${RETENTION_LEASE_TABLE} SET expires_at_ms = 0 WHERE singleton = 'x' AND holder = ?`, `UPDATE ${RETENTION_LEASE_TABLE} SET expires_at_ms = 0 WHERE singleton = 'x' AND holder = $1`), [holder]);
104
+ }
105
+ // ───────────────────────────── ② legal-hold 的放置/解除(§5)─────────────────────────────
106
+ /**
107
+ * 放置或解除一个域的 legal hold —— **同一 PK 的 upsert 改 `held` 列,绝不 INSERT/DELETE 行**
108
+ * (车1 交接件②,逐字)。
109
+ *
110
+ * 🔴 为什么删行是**拆锁**而不是"解除":`retention_hold` 的行同时是**域级互斥哨兵** —— 车1 的三条破坏性
111
+ * 事务首步就是锁读这一行(`INSERT IGNORE` 补哨兵 → `SELECT … FOR UPDATE`),而 `FOR UPDATE`
112
+ * **锁不住一条不存在的行**(TiDB 无 gap lock;PG READ COMMITTED 同病)。删掉行 = 下一个 PUT 与一条
113
+ * 在飞的删除事务又可以同时读到"没有 hold",于是 operator 拿到成功回执之后数据仍被删 —— 那正是 §5 F2
114
+ * 要消灭的那件事。upsert 让两者抢**同一把行锁**,「PUT 提交完成 ⇒ 其后每个破坏性事务的首读必见
115
+ * held=1」这句承诺才成立。
116
+ *
117
+ * 解除时把 `placed_by/placed_at_ms/note` 一并清空:这张表存的是**当前状态**不是历史,历史在
118
+ * `retention_audit` 的 `hold_placed`/`hold_released` 行上(追加不更新)。留一个「谁放的」在一条
119
+ * held=0 的行上,读它的人分不清那是"现在冻结着"还是"上次谁冻过"。
120
+ */
121
+ async setHold(input) {
122
+ await this.setHoldOn(this.db, input);
123
+ }
124
+ /** upsert 的**唯一** SQL 文本(自动提交口与事务口共用;见 {@link setHoldAudited} 的头注)。 */
125
+ async setHoldOn(exec, input) {
126
+ const held = input.held ? 1 : 0;
127
+ const by = input.held ? (input.placedBy ?? null) : null;
128
+ const at = input.held ? input.nowMs : null;
129
+ const note = input.held ? (input.note ?? null) : null;
130
+ await exec.query(this.q(`INSERT INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES (?,?,?,?,?) ` +
131
+ `ON DUPLICATE KEY UPDATE held = VALUES(held), placed_by = VALUES(placed_by), placed_at_ms = VALUES(placed_at_ms), note = VALUES(note)`, `INSERT INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES ($1,$2,$3,$4,$5) ` +
132
+ `ON CONFLICT (domain) DO UPDATE SET held = EXCLUDED.held, placed_by = EXCLUDED.placed_by, placed_at_ms = EXCLUDED.placed_at_ms, note = EXCLUDED.note`), [input.domain, held, by, at, note]);
133
+ }
134
+ /**
135
+ * 放置/解除 **+ 治理审计行,同一个事务**(codex 对抗复审 R1-[high],验真后加)。
136
+ *
137
+ * 🔴 为什么必须原子(与 §6 F5 的「删除与审计同事务」是同一条判据,只是换了一张表):两次独立提交下,
138
+ * 第二步失败会留下一次**无审计的状态变更**。PUT 那一支是「冻结了但账上没有」;**DELETE 那一支更重**
139
+ * —— 冻结已经解除、sweep 从下一拍起就能删这个域的数据,而审计里没有任何 `hold_released` 行说明是谁
140
+ * 在什么时候解的;客户端只看到一个 500,重试之前发生的删除再也无法从账本上追溯回那次释放。
141
+ * 一张表两条语句、同库同连接 ⇒ 原子性零分布式代价,没有不做的理由。
142
+ *
143
+ * `protected` 的两条私有腿(`setHoldOn` / `appendAuditOn`)让本方法与 {@link setHold} /
144
+ * {@link appendAudit} 共用**同一份 SQL 文本** —— 两处手抄一份 upsert,迟早只改一处。
145
+ */
146
+ async setHoldAudited(input) {
147
+ const conn = await this.db.connect();
148
+ try {
149
+ await conn.begin();
150
+ await this.setHoldOn(conn, input);
151
+ await this.appendAuditOn(conn, input.audit, input.nowMs);
152
+ await conn.commit();
153
+ }
154
+ catch (err) {
155
+ await conn.rollback().catch(() => undefined);
156
+ throw err;
157
+ }
158
+ finally {
159
+ conn.release();
160
+ }
161
+ }
162
+ /** 「这个域现在冻结着吗」—— lane 的**省调**预检(§5 v1.3:降级为优化,**不承重**;承重判在车1 的
163
+ * 店事务内)。零写。 */
164
+ async holdInForce(domain) {
165
+ const { rows } = await this.db.query(this.q(`SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = ?`, `SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = $1`), [domain]);
166
+ return Number(rows[0]?.held ?? 0) === 1;
167
+ }
168
+ // ───────────────────────────── ③ 审计:非破坏行的写 + 读面(§6)─────────────────────────────
169
+ /** 非破坏性审计行的追加(属主 = lane / 路由)。破坏性三词由车1 的店在删除同事务内写。 */
170
+ async appendAudit(row, nowMs) {
171
+ await this.appendAuditOn(this.db, row, nowMs);
172
+ }
173
+ /** 追加的**唯一** SQL 文本(同上)。 */
174
+ async appendAuditOn(exec, row, nowMs) {
175
+ await exec.query(this.q(`INSERT INTO ${RETENTION_AUDIT_TABLE} (domain, action, mode, policy_days, fencing_token, deleted, skipped, tombstones, executed_at_ms, error) VALUES (?,?,?,?,?,?,?,?,?,?)`, `INSERT INTO ${RETENTION_AUDIT_TABLE} (domain, action, mode, policy_days, fencing_token, deleted, skipped, tombstones, executed_at_ms, error) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`), [row.domain, row.action, row.mode, row.policyDays, row.fencingToken, row.deleted, row.skipped, row.tombstones, nowMs, row.error ?? null]);
176
+ }
177
+ /**
178
+ * 审计读面(keyset 分页,`id` 降序)。
179
+ *
180
+ * 排序键 = **自增 `id` 单列**,而不是 roster 那样的 `(时间戳, 次键)` 二元组:`id` 本身就是全序且唯一,
181
+ * 二元组存在的全部理由(同毫秒兄弟行无 tiebreak ⇒ 漏页/重页)在这里结构上不存在。索引
182
+ * `idx_retention_audit_domain (domain, id)` 正是为这条查询建的。
183
+ *
184
+ * `before` = 上一页最后一行的 `id`(严格小于)。
185
+ *
186
+ * 🔴 读上限是 `MAX_LIMIT + 1` 而不是 `MAX_LIMIT`(codex 对抗复审 R1-[medium],验真属实):调用方要判
187
+ * 「还有没有下一页」就得多取一行,而**页**的上限是 `MAX_LIMIT`。旧形把 limit 无条件夹到 `MAX_LIMIT`
188
+ * ⇒ 请求 `limit=200` 时 scanned 恒 ≤ 200 ⇒ `hasMore` 恒 false ⇒ **第 201 行之后的审计对这条分页链
189
+ * 永久不可达**。这一格是「窗的上限」与「探路的那一行」两个不同的数被写成同一个数造成的,
190
+ * 修法是让它们各是各的:页 ≤ MAX_LIMIT(路由侧保证),读 ≤ MAX_LIMIT+1(本方法)。
191
+ */
192
+ async listAudit(input) {
193
+ const limit = Math.min(Math.max(1, Math.floor(input.limit)), RETENTION_AUDIT_MAX_LIMIT + 1);
194
+ const where = [];
195
+ const params = [];
196
+ let n = 0;
197
+ if (input.domain !== undefined) {
198
+ where.push(this.q(`domain = ?`, `domain = $${++n}`));
199
+ params.push(input.domain);
200
+ }
201
+ if (input.beforeId !== undefined) {
202
+ where.push(this.q(`id < ?`, `id < $${++n}`));
203
+ params.push(input.beforeId);
204
+ }
205
+ const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
206
+ const { rows } = await this.db.query(this.q(`SELECT id, domain, action, mode, policy_days, fencing_token, deleted, skipped, tombstones, executed_at_ms, error ` +
207
+ `FROM ${RETENTION_AUDIT_TABLE} ${clause} ORDER BY id DESC LIMIT ?`, `SELECT id, domain, action, mode, policy_days, fencing_token, deleted, skipped, tombstones, executed_at_ms, error ` +
208
+ `FROM ${RETENTION_AUDIT_TABLE} ${clause} ORDER BY id DESC LIMIT $${++n}`), [...params, limit]);
209
+ return rows.map((r) => ({
210
+ id: Number(r.id),
211
+ domain: String(r.domain ?? ""),
212
+ action: String(r.action),
213
+ mode: String(r.mode ?? ""),
214
+ policyDays: Number(r.policy_days ?? 0),
215
+ fencingToken: r.fencing_token === null || r.fencing_token === undefined ? null : Number(r.fencing_token),
216
+ deleted: Number(r.deleted ?? 0),
217
+ skipped: Number(r.skipped ?? 0),
218
+ tombstones: Number(r.tombstones ?? 0),
219
+ executedAtMs: Number(r.executed_at_ms ?? 0),
220
+ error: r.error === null || r.error === undefined ? null : String(r.error),
221
+ }));
222
+ }
223
+ }
224
+ /** MySQL-protocol (TiDB) binding。 */
225
+ export class TiDBRetentionLaneStore extends SqlRetentionLaneStore {
226
+ constructor(pool) {
227
+ super(mysqlDriver(pool));
228
+ }
229
+ }
230
+ /** PostgreSQL binding。 */
231
+ export class PgRetentionLaneStore extends SqlRetentionLaneStore {
232
+ constructor(pool) {
233
+ super(pgDriver(pool));
234
+ }
235
+ }
236
+ //# sourceMappingURL=retention-lane-store-sql.js.map