@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,468 @@
1
+ /**
2
+ * 托管留存(managed retention)执行面的 **store 半场** —— SINGLE-FILE DUAL-DIALECT(design/158 A12 定型半场;
3
+ * 先例 = `leader-run-store-sql.ts` / `checkpoint-store-sql.ts`)。#270 车1,设计稿
4
+ * `docs/DESIGN-270-retention-lane.md` v1.2。上游契约真源 = `@sema-agent/core` 的
5
+ * `core/retention.d.ts`(`ManagedRetentionCapability` / `RetentionReceipt` / `RetentionDeclaration`)。
6
+ *
7
+ * ── 为什么这四张表 + 一只**聚合**店 ─────────────────────────────────────────────────────────────────
8
+ * core 把破坏性级联整个下沉成 store 方法(`expireCheckpoints` / `deleteExpiredSessions` /
9
+ * `deleteOrphanToolResults`),并点名 server 的 session-purge 顺序契约(E21,`boot/session-faces.ts` 的
10
+ * `purgeSession` 协调器)为参考实现。那条顺序契约横跨**十余张表**(run 账本 / checkpoint / tool_result /
11
+ * anchor / exemption / policy / attachment / snapshot / workflow inbox / session_meta+event),而设计稿
12
+ * §6 的 F5 又要求「审计行与删除同一个 DB 事务」—— 一个事务 ⇒ 一条连接 ⇒ **一个属主**。
13
+ *
14
+ * ⇒ 布局裁定:本能力**不能**挂在 session / checkpoint / tool-result 任何一只店上(每只店只拥有自己的表,
15
+ * 把跨表级联塞进其中一只 = 让它去写别人的表,且三只店各持自己的 driver ⇒ 三个事务,F5 当场破)。它是
16
+ * `purgeSession` 协调器的**事务化孪生**:协调器在应用层按序调各店,本店在**一个事务里**按同一顺序发同
17
+ * 一组 DELETE。同一条顺序契约,两个属主(在线删除 = 协调器;留存删除 = 本店),等价性由
18
+ * `retention-store-db-integration.test.ts` 的 E21 同种子行集断言钉住。
19
+ *
20
+ * ── 域(domain)口径 ────────────────────────────────────────────────────────────────────────────────
21
+ * 域 = 租户键。三张保留表各有自己的载体列:`session_meta.owner`(**可空**)、`checkpoint.scope`(非空)、
22
+ * `tool_result` 无域列(经属主 session 派生)。归一化:
23
+ * · 域键 = `COALESCE(session_meta.owner, '')` / `checkpoint.scope` 原样;
24
+ * · **空串 `''` = 无主(anonymous/dev)桶**,谓词写成 `(owner IS NULL OR owner = '')` —— 两者合并成一个
25
+ * 桶是**保守**的(合并只发生在无主侧,永远不会伸进一个真租户);拆成两个桶才会漏(NULL 那半永不被
26
+ * 枚举 = codex F4 同族的病)。真部署里 `''` 不是可铸的 principal(auth 门拒空 system 名)。
27
+ *
28
+ * ── 方言差异台账(显式,永不藏进抽象;A12 判据)───────────────────────────────────────────────────
29
+ * - `?` 占位符 vs `$n`;多值集合 MySQL 用 `IN (?,?,…)`、PG 用 `= ANY($n::text[])`(先例:
30
+ * workflow-run-store-sql 的 reap)
31
+ * - 幂等插入:`INSERT IGNORE` vs `ON CONFLICT (…) DO NOTHING`
32
+ * - 级联 DELETE 的 JOIN 形:`DELETE te FROM a te JOIN b …` vs `DELETE FROM a te USING b …`
33
+ * - null-safe 比较:`<=>` vs `IS NOT DISTINCT FROM`(及其**占位符数量**差:`?` 不可复用)
34
+ * - JSON 提取(workflow_run 的 originatingSessionId):`JSON_UNQUOTE(JSON_EXTRACT(run,'$.x'))` vs
35
+ * `run::json->>'x'`
36
+ * - 表级 `COLLATE utf8mb4_bin` vs 逐列 `COLLATE "C"`(`isolation-key-collation` 门执法)
37
+ * - 事务动词:两条方言都是平凡 `begin()` —— **不是** `beginPessimistic()`(它是 TiDB 专有语法,发到
38
+ * 普通 MySQL 上语法错;完整判据见 `SqlRetentionStore.tx` 的头注)
39
+ * - schema 属主:MySQL DDL 由本文件导出、展开进 `tidb-pool.ts` 的 `SCHEMA_STATEMENTS`;PG DDL 由本文件的
40
+ * `ensurePgRetentionSchema` 导出、由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合(与 leader-run 同姿势)。
41
+ *
42
+ * ── 命名(#192 纪律,与设计稿的复数写法的**有意**偏离)────────────────────────────────────────────
43
+ * 设计稿 §5/§2 写的是 `retention_holds` / `retention_tombstones`;本仓的表名闭集词表是**单数**制且由
44
+ * `test/schema-naming-invariants.test.ts` 机器执法(「一张表是一类行的集合,表名说的是『一行是什么』」)。
45
+ * ⇒ 落库名一律单数:`retention_audit` / `retention_hold` / `retention_lease` / `retention_tombstone`。
46
+ */
47
+ import type { ManagedRetentionCapability, RetentionDeclaration, RetentionReceipt } from "@sema-agent/core";
48
+ import type { Pool as MySqlPool } from "mysql2/promise";
49
+ import type { Pool as PgPool } from "pg";
50
+ import { type SqlDriver, type SqlTxConn } from "./sql-driver.js";
51
+ /**
52
+ * 三只**被托管**的 SQL 店(session / checkpoint / tool-result)共用的 `retention` 声明常量。
53
+ *
54
+ * 🔴 为什么是一个共享常量而不是各写各的字面量:这一格是 core `assertRetentionCapability` 的**唯一**读点,
55
+ * 三只店的答案必须同进同退 —— 哪天本能力从某只店的行上撤了(比如 tool_result 改由别处清),漏改一处
56
+ * 就是一句谎,而谎的方向是「locked policy 放行了一只其实不删的店」。
57
+ *
58
+ * 🔴 声明的**读法**(与 core 契约的字面对齐,别读大也别读小):`"managed"` 说的是「**这只店的行**由本
59
+ * 部署的托管留存按期删除」,不是「本对象自己实现了三个方法」。三方法的实现体是本文件的
60
+ * `SqlRetentionStore` —— 它是 E21 purge 协调器的事务化孪生,横跨十余张表(理由见文件顶注的布局裁定)。
61
+ * core 的门读的是「这只店的数据会不会被按期删掉」(拒启文案逐字:「a locked retention policy over stores
62
+ * that cannot delete would be *policy locked, data immortal*」),SQL 三店的诚实答案就是 `"managed"`。
63
+ * file/in-memory 形没有任何东西会按期删它们的行 ⇒ 显式 `"none"`(缺席也读 none,但显式是文档义务)。
64
+ */
65
+ export declare const MANAGED_RETENTION: RetentionDeclaration;
66
+ /** file/in-memory 形的诚实声明(见 {@link MANAGED_RETENTION} 的读法说明)。 */
67
+ export declare const UNMANAGED_RETENTION: RetentionDeclaration;
68
+ /** 追加式审计表(设计稿 §6)。 */
69
+ export declare const RETENTION_AUDIT_TABLE = "retention_audit";
70
+ /** per-domain 法务保留标记(设计稿 §5)——**行在 = 该域整体冻结**。 */
71
+ export declare const RETENTION_HOLD_TABLE = "retention_hold";
72
+ /** sweep 互斥的单行租约表(设计稿 §3)。表建在车1(schema 一次齐),抢/续租的逻辑归车2。 */
73
+ export declare const RETENTION_LEASE_TABLE = "retention_lease";
74
+ /** 删除墓碑(设计稿 §2)——副本/备份收敛 **+ 域枚举并集的第四条腿**(codex F4)。 */
75
+ export declare const RETENTION_TOMBSTONE_TABLE = "retention_tombstone";
76
+ /**
77
+ * 墓碑的 `kind` 闭集。**词表在这里,不在调用点**:一个手写的 kind 字面量会让域枚举与孤儿归属各读各的。
78
+ * v1 两个成员 —— 会话树(`deleteExpiredSessions`)与孤儿卸载结果(`deleteOrphanToolResults`)。
79
+ */
80
+ export type RetentionTombstoneKind = "session" | "tool_result";
81
+ /**
82
+ * 审计行的 `action` 闭集(设计稿 §6 逐字)。**破坏性三词**由本店在删除同事务内写;其余四词
83
+ * (`audit_only` / `skipped_legal_hold` / `failed` / `hold_placed` / `hold_released`)属主是 lane/路由(车2)
84
+ * —— 它们没有「删了没记」的窗口。词表整只放在这里是为了两半场共用一份真源。
85
+ */
86
+ export type RetentionAuditAction = "expired_checkpoints" | "deleted_sessions" | "deleted_tool_results" | "audit_only" | "skipped_legal_hold" | "failed" | "hold_placed" | "hold_released";
87
+ /** MySQL 协议方言的建表语句(真源;由 `tidb-pool.ts` 展开进中央 `SCHEMA_STATEMENTS`)。 */
88
+ export declare const TIDB_RETENTION_STATEMENTS: readonly string[];
89
+ /** PG 方言的建表语句(MySQL 孪生的逐条翻译:AUTO_INCREMENT→BIGSERIAL,逐列 `COLLATE "C"`,内联 KEY→独立
90
+ * CREATE INDEX)。语义逐字见 MySQL 孪生的行内注,此处不复述(两份注释迟早分叉)。 */
91
+ export declare const PG_RETENTION_SCHEMA: readonly string[];
92
+ /** PG 侧的幂等 schema apply(由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合;裸 `PgQueryFn` 形,与
93
+ * approval-ask / leader-run 同姿势 —— DDL 仍跑在中央那条 client 上,advisory lock 的 session 语义不受影响)。 */
94
+ export declare function ensurePgRetentionSchema(query: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
95
+ /**
96
+ * 一次破坏性留存调用的**审计上下文**——判定时点的三格,由调用方(车2 的 lane)传入。
97
+ *
98
+ * 🔴 为什么必须由调用方传、而不是店自己去读配置:`mode`/`policyDays` 是**判定时点**的事实(策略是部署
99
+ * env、模式是灰度旋钮,两者都会在两次 sweep 之间被改),`fencingToken` 更是本轮租约的身份。店去现读
100
+ * 配置 = 审计行记的是「写这行的那一刻的配置」,而不是「按哪档删的」,那正好把审计的意义抹掉。
101
+ */
102
+ export interface RetentionAuditContext {
103
+ /** 判定时点的灰度档。词表闭集(与 §4 同一张表);`audit-only` 档下 lane 不该调破坏性方法,但如果调了,
104
+ * 审计行会如实记下这个矛盾(而不是被店悄悄改写成 enforce)。 */
105
+ mode: "audit-only" | "enforce";
106
+ /** 判定时点的策略值(天)。 */
107
+ policyDays: number;
108
+ /** 本轮 sweep lease 的 fencing token(§3)。`null` = 调用方没有租约面(单机/测试);破坏性行仍会落
109
+ * 审计,只是这一位为空 —— 「没有轮次身份」本身也是审计要记的事实。 */
110
+ fencingToken: number | null;
111
+ }
112
+ /** 三只契约方法的入参 —— core 的 `{domain, cutoffMs}` **超集**(多一个可选的审计上下文,见下)。 */
113
+ export interface RetentionCallInput {
114
+ domain: string;
115
+ cutoffMs: number;
116
+ /**
117
+ * 🔴 类型上**可选**、运行时**必需**(fail-closed)。可选是为了结构上仍然满足 core 的
118
+ * `ManagedRetentionCapability`(拿着契约类型的调用方看得见的形必须是 core 那个形);运行时缺席则当场
119
+ * 抛 —— 一次没有审计上下文的破坏性调用 = 无证删除,而 §6 F5 的全部意义就是不许有无证删除。
120
+ */
121
+ audit?: RetentionAuditContext;
122
+ }
123
+ /** `previewRetention` 的三候选计数(**非契约**窄读口:core 无 count 面,这是 server 自己店上的超集)。 */
124
+ export interface RetentionPreview {
125
+ checkpoints: number;
126
+ sessions: number;
127
+ toolResults: number;
128
+ }
129
+ /** 装配面必须如实告知的**可选表在场性**(见 {@link SqlRetentionStore} 的构造器注)。 */
130
+ export interface SqlRetentionStoreOptions {
131
+ /**
132
+ * `task_attachment` 表是否在场。**必填,没有默认值**:boot 只在配了对象存储时才 ensure 这张表
133
+ * (`boot/stores.ts` 的附件段:无 MinIO ⇒ 整段不接线、表不建),于是一条盲发的 DELETE 在无对象存储的
134
+ * 部署上会以 unknown-table 报错、把整轮 sweep 打红。让装配层如实回答,比让店去吞一个错误好 —— 吞错误
135
+ * 就是静默 fail-open(附件行从此永不被留存清理,而没有任何人知道)。
136
+ */
137
+ taskAttachmentTable: boolean;
138
+ /** 诊断日志座(可选)。 */
139
+ logger?: {
140
+ info?(msg: string, meta?: unknown): void;
141
+ warn?(msg: string, meta?: unknown): void;
142
+ };
143
+ }
144
+ /** 单次调用的候选上界(**安全界,不是分批语义**)。设计稿 §3 的 v1 口径是「先由 cutoff 天然限量」,
145
+ * 这里额外扣一个上界,理由是运维面而不是功能面:一个从未清过账的大租户会让「一个事务删十余张表的
146
+ * 全部历史」变成一场锁堆积。剩下的行在下一拍自然收敛(三方法皆幂等),读数因此偏保守而不会偏危险。 */
147
+ export declare const RETENTION_SESSION_BATCH = 500;
148
+ /** checkpoint / tool_result 腿的同款上界(单行代价远小于会话树,故取大一档)。 */
149
+ export declare const RETENTION_ROW_BATCH = 2000;
150
+ /**
151
+ * 托管留存能力的双方言实现 —— core `ManagedRetentionCapability` 的 server 侧真身。
152
+ * 方言差异台账见文件顶注;每个破坏性方法的事务形(hold 锁读 → 变更+墓碑 → 审计行,同 commit 同 rollback)
153
+ * 见各方法的头注。
154
+ */
155
+ export declare class SqlRetentionStore implements ManagedRetentionCapability {
156
+ protected readonly db: SqlDriver;
157
+ protected readonly opts: SqlRetentionStoreOptions;
158
+ /** 本店自己也是一只 `retention: "managed"` 的店(它就是那三个方法的实现体)。 */
159
+ readonly retention: RetentionDeclaration;
160
+ constructor(db: SqlDriver, opts: SqlRetentionStoreOptions);
161
+ /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE (A12 判据)。 */
162
+ protected q(tidb: string, pg: string): string;
163
+ /**
164
+ * 域谓词。**四种写法逐条写出来**(方言 × 有主/无主):
165
+ * · 有主:`col = ?` / `col = $n`;
166
+ * · 无主(域键 `""`):`(col IS NULL OR col = ?)` / `(col IS NULL OR col = $n)` —— 把 SQL NULL 与空串
167
+ * 合并进同一个桶。合并只发生在**无主侧**,永远伸不进一个真租户;拆开才会漏(NULL 那半永不被枚举)。
168
+ * 🔴 两支的**占位符数量刻意相同**(无主支也绑一个 `""`):arity 一变,PG 的 `$n` 编号就会在同一条 SQL 的
169
+ * 后续参数上错位——那是一类只在某个域值上才复现的 bug,不许存在。
170
+ */
171
+ protected domainWhere(col: string, domain: string, pgIndex: number): {
172
+ sql: string;
173
+ params: unknown[];
174
+ };
175
+ /**
176
+ * 多值集合谓词 —— **真方言分叉**(不是 SQL 文本差):MySQL 展开 N 个 `?`,PG 用**一枚**数组参数
177
+ * `= ANY($n::text[])`。占位符数量因此不同(N vs 1),这是先例(workflow-run-store-sql 的 reap)也是
178
+ * 必然:把 500 个 id 展开成 500 个 `$n` 会让语句文本随批量变形,PG 的预编译缓存跟着退化。
179
+ */
180
+ protected idSet(col: string, ids: readonly string[], pgIndex: number): {
181
+ sql: string;
182
+ params: unknown[];
183
+ };
184
+ /**
185
+ * 事务壳 —— **平凡 `begin()`,不是 `beginPessimistic()`**(真库实测定谳,不是口味):
186
+ *
187
+ * · MySQL-protocol 腿:`BEGIN PESSIMISTIC` 是 **TiDB 专有语法**,发到普通 MySQL 上是语法错
188
+ * (本机 MySQL 9.7 实测 `ER_PARSE_ERROR near 'PESSIMISTIC'`)—— 而发车门 2.6 的 canonical
189
+ * `TIDB_TEST_URL` 默认指向的正是一台**普通 MySQL**。用那条动词等于让本店在标准工作流上必红。
190
+ * · 语义上也不需要它:本店要的只是 `SELECT … FOR UPDATE` 是**当前读**。InnoDB 的加锁读在 RR 下本来
191
+ * 就读最新已提交版本;TiDB 自 3.0.8 起事务默认就是悲观模式,同样是当前读。显式动词只在「部署把
192
+ * TiDB 改回乐观模式」这一种配置下才多出保障,而那种部署会同时打破仓内其它 FOR UPDATE 腿
193
+ * (run-store 的 task_active 门、checkpoint 的 approval 门),不是本店能独自承担的假设。
194
+ * · PG:READ COMMITTED 逐语句取新快照,平凡 `BEGIN` 即所需。
195
+ *
196
+ * `protected` = 真库套件的语句录制注入点(判据「hold 锁读是事务首条 SQL」靠它自证)。
197
+ */
198
+ protected tx<T>(fn: (conn: SqlTxConn) => Promise<T>): Promise<T>;
199
+ /**
200
+ * 破坏性调用的**入门检**:审计上下文缺席 ⇒ 当场抛(fail-closed)。
201
+ * core 的契约形只有 `{domain, cutoffMs}`,所以一个拿着契约类型的调用方**在类型上**能省掉它 —— 那正是
202
+ * 这道运行时门存在的理由:省掉它的那次调用如果照删不误,库里就多了一批无证删除的行。
203
+ */
204
+ protected requireAudit(input: RetentionCallInput, action: RetentionAuditAction): RetentionAuditContext;
205
+ /**
206
+ * 事务**首步**:拿本域的互斥行锁,并读出「这个域是不是冻结中」(§5 F2 承重)。
207
+ *
208
+ * 两步,缺一不可:
209
+ * ① `INSERT IGNORE`(PG:`ON CONFLICT DO NOTHING`)一条 `held = 0` 的**哨兵行** —— 它永远不会改写
210
+ * 一条既存的 hold(两方言的这两个动词都只在缺行时写),只保证**接下来那把行锁有东西可锁**;
211
+ * ② `SELECT held … FOR UPDATE` —— 现在这行必定存在,于是锁真的拿到了。
212
+ *
213
+ * 🔴 为什么必须先造行(codex 对抗复审 R1-[high],验真后改):`FOR UPDATE` 锁不住缺席行(TiDB 无 gap
214
+ * lock;PG READ COMMITTED 同样)。presence 形(「行在 = 冻结」)下,operator 的 PUT 与本事务可以**同时**
215
+ * 都读到「没有 hold」,PUT 提交返回成功之后本事务照删不误 —— 设计稿 §5 声称由行锁给出的串行化在那一形
216
+ * 上根本不存在。哨兵行把 hold 的写点与删除的读点压到**同一把行锁**上,那句承诺才成立。
217
+ * ⚠️ 车2 交接:PUT/DELETE hold 必须走**同一 PK 的 upsert 改 `held` 列**(不是 INSERT/DELETE 行),
218
+ * 否则这把锁又会退回缺席形。
219
+ */
220
+ protected holdInForce(conn: SqlTxConn, domain: string): Promise<boolean>;
221
+ /**
222
+ * 审计行的**唯一**写点(§6 F5:破坏性行由店在删除同事务内写)。
223
+ * `protected` 是**有意的注入口**:真双库套件用一只覆盖本方法为抛错的子类来证 V10(「注入 audit 写失败
224
+ * ⇒ 整事务回滚、数据仍在」)。把注入点做成 mock 层的猴补丁反而证不了这件事 —— 它证的是 mock。
225
+ */
226
+ protected writeAuditRow(conn: SqlTxConn, row: {
227
+ domain: string;
228
+ action: RetentionAuditAction;
229
+ audit: RetentionAuditContext;
230
+ deleted: number;
231
+ skipped: number;
232
+ tombstones: number;
233
+ }): Promise<void>;
234
+ /**
235
+ * 墓碑写入(幂等)。返回**真正新落地**的墓碑数 —— 重跑时既有墓碑不重复计数,于是 receipt 的
236
+ * `tombstones` 与 `deleted` 在幂等重跑上同步归零。
237
+ *
238
+ * 方言差异:MySQL 展开多值 `VALUES (…),(…)` + `INSERT IGNORE`;PG 用 `unnest($n::text[])` 定 arity +
239
+ * `ON CONFLICT (domain, kind, row_key) DO NOTHING`(数组形让语句文本不随批量变形)。
240
+ */
241
+ protected writeTombstones(conn: SqlTxConn, domain: string, kind: RetentionTombstoneKind, keys: readonly string[]): Promise<number>;
242
+ /**
243
+ * 三保留表 ∪ 墓碑的域并集(v1.2 codex F4 形)。
244
+ *
245
+ * 🔴 为什么 `tool_result` 没有自己的 UNION 臂(如实说明,不是漏写):这张表**没有域列** —— 一行卸载结果的
246
+ * 域由它的属主 session 派生,所以「属主还在」的行的域 ⊆ session 臂;而属主 session 一旦没了(上一轮删掉/
247
+ * 崩在中途),它的域在 SQL 里**无从得知**。这正是墓碑臂存在的理由:会话树删除时落的墓碑带着 domain,于是
248
+ * 「只剩孤儿的域」依然可枚举(V11)。再写一条 join 臂只会得到 session 臂的子集,不会多枚举出任何东西。
249
+ *
250
+ * 文本**两方言逐字相同**(无占位符)⇒ 单条共享语句(先例:file-snapshot-store 的 gcOrphanBlobs)。
251
+ * 排序在 JS 侧,判据面因此与引擎的排序规则无关。
252
+ */
253
+ listRetentionDomains(): Promise<readonly string[]>;
254
+ /**
255
+ * 三候选计数(audit-only 档的读数源)。**零写、零事务**:它是纯读口,开事务只会白占一条连接。
256
+ *
257
+ * 口径 = 与三个破坏性方法**同一套谓词**(候选而非上限):
258
+ * · checkpoints:本域过视界的 pending 行,**扣掉 live-pinned**(那些行本来就不会被删);
259
+ * · sessions:本域过视界、且当下无活引用的会话树;
260
+ * · toolResults:本域可归属的孤儿卸载结果。
261
+ * 🔴 preview **不看 hold**:hold 是「这一轮跳不跳」的域级裁决(lane 记 `skipped_legal_hold` 行),而
262
+ * preview 回答的是「命中集有多大」。两件事混在一起,operator 就看不出「冻结期间到底积压了多少候选」。
263
+ */
264
+ previewRetention(input: {
265
+ domain: string;
266
+ cutoffMs: number;
267
+ }): Promise<RetentionPreview>;
268
+ /**
269
+ * CAS 栅栏 `pending → expired`,每行恰一胜者(胜负由 `status = 'pending'` 谓词在引擎里裁,跨副本安全)。
270
+ *
271
+ * 事务形(§5 F2 + §6 F5,一步不省):
272
+ * ① `retention_hold` 本域行的**锁读** —— 行在 ⇒ 本事务零变更、全计 skipped 返回;
273
+ * ② 候选读 + CAS UPDATE;
274
+ * ③ 审计行(同事务)→ COMMIT。**没有**提交前的第二次 hold 复核:域的行锁在 ① 就拿住了并持有到
275
+ * COMMIT,一个并发的 PUT hold 在此期间提交不了(它抢同一把锁)——一段恒真的"复核"比没有更坏。
276
+ *
277
+ * 视界判据**两条臂都写出来**:`deadline` 在场按 deadline,缺席按 `created_at_ms`。少了后一条臂,一条
278
+ * 没有审批 TTL 的 pending 行就永远过不了期(design/80 §3 inv#3 的 terminal_at_ms 要挡的正是这种永久泄漏)。
279
+ *
280
+ * 🔴 **pin 释放 = 本方法的 `pending → expired` 翻转本身**(core [4180] 定谳:retention.ts 契约那句
281
+ * 「AND release the associated session pins」是真意,[4175] 引错了对象——它引的是引擎 live-path reap
282
+ * 的 JSDoc)。本仓 SQL 后端**没有独立的 pin 存储**:钉住 session 的正是 `checkpoint.status='pending'`
283
+ * 这一状态(它是 deleteExpiredSessions 活引用四腿之一,见下方 lineage 注 :79x),所以状态翻转 = 引用
284
+ * 解除 = pin 释放——不需要第二个写操作,而幂等(重跑对已 expired 行零命中)恰好满足 [4180] 的
285
+ * 「释放已释放 pin=no-op」要求。单写者不变式按 [4180] 改述:**每行的 pin 恰好释放一次,由终局该行
286
+ * 的车道执行**——live 行归 reaper,retention 行归本方法,两行集不交(下一段的 live-pin skip 正是
287
+ * 「不交」的执行判据)。
288
+ * 边界形([4175] 建议,[4180] 确认仍成立):**live-pinned 且已过视界**的行(`task_active` 还占着这条
289
+ * 会话)⇒ skip 计数、不动那行 —— 不在一条活会话脚下抽掉它的 resume 坐标,与 legal-hold 的 skip 同形。
290
+ *
291
+ * receipt 口径:`deleted` = **本次 CAS 赢下的行数**(过期销毁的是那道 pending 门,行本身按设计留在库里
292
+ * 供审计/对账);`tombstones` = 0(没有行被移除,写墓碑会让「墓碑 ⇒ 这行没了」这条读法失真)。
293
+ */
294
+ expireCheckpoints(input: RetentionCallInput): Promise<RetentionReceipt>;
295
+ /**
296
+ * 视界谓词的两条臂(`deadline` 在场按 deadline、缺席按 `created_at_ms`)—— 两方言逐字相同,占位符各自编号。
297
+ * 抽成一处是因为它同时被「可过期候选」与「被 pin 的计数」两条查询用,手抄两份必分家。
298
+ */
299
+ private horizonArms;
300
+ /** 「这条 checkpoint 的会话被一次活跑占着」= [4175] 的 live-pinned 判据(SQL 谓词形,两方言同文本)。 */
301
+ private static readonly PINNED_EXISTS;
302
+ /**
303
+ * **可过期**的候选 token(保护谓词写在 SQL 里、扣在 `LIMIT` **之前**)。
304
+ *
305
+ * 🔴 为什么保护必须下推(codex 对抗复审 R1-[medium],验真后改):旧形先按时间取 N 行、再在 JS 里剔除
306
+ * pinned —— 只要最老的一批持续被占着,每轮取回的都是同一批、每轮删 0,**它们后面**那些同样过期、又
307
+ * 没有任何引用的行**永远轮不到**。那不是「下一拍自然收敛」,那是一个域的留存静默停摆(而且读数一直
308
+ * 是 deleted=0,看上去像"没有候选")。判据下推之后 LIMIT 扣的是**真能动的行**,头部被保护多少行都不
309
+ * 影响后面的收敛。
310
+ */
311
+ private expiryCandidates;
312
+ /**
313
+ * 过期视界之内、**当下被 live-pin 护住**的 pending 行数 = receipt 的 `skipped`。
314
+ *
315
+ * 🔴 账法定谳(两轮对抗复审各推了一次,最终落在这一形):
316
+ * · 曾经用「候选 + pinned 两读相加当分母、差值当 skipped」——被 codex R2 抓到会把中途翻转 pin 的行数两遍;
317
+ * · 改成「视界总数当分母、差值当 skipped」——被 codex R3 抓到在 PG 的 READ COMMITTED 下可以**为负**
318
+ * (分母读完之后又有行被 put/reopen 成 pending,候选与 UPDATE 都看得见它 ⇒ expired > 分母),而一条
319
+ * 负数写进**追加式**审计表是比「小幅重复计数」坏得多的事(审计不可改,读它的人无从判断哪一格出了错);
320
+ * · ⇒ 现形:`deleted` = UPDATE 的**真实命中**(pin 判据写在它自己的 WHERE 里,数据面按构造正确),
321
+ * `skipped` = 同一事务内测得的**受保护行数**(本方法)。两者是**两个独立测得的事实**,不构成
322
+ * `deleted + skipped = 总数` 的恒等式,两者都恒 ≥ 0。中途翻转 pin 状态的行最多被两边各记一次
323
+ * (量级=翻转数,通常 0),这是刻意选的失真方向:**宁可轻微重复计数,也不让审计出现负数**。
324
+ *
325
+ * 🔴 **有界**(codex R2-[medium],验真后改):裸 `COUNT(*)` 对一个积压很深的域是无界扫描,而 EXISTS 关联的
326
+ * 都是没有为本用途建过索引的列。留存是每小时一拍的后台活,不该把一次精确到最后一行的计数买成数据库尖峰
327
+ * ⇒ 计数扣在与批量同阶的上界内(派生表 LIMIT),读数是「至少这么多」。少算的部分下一拍照样看得见
328
+ * (判据幂等),而运维要的是「有没有积压、量级多大」。索引面(workflow originating_session_id 物化列 /
329
+ * background-agent 活锚 / checkpoint 的 scope+status+deadline 复合索引)是独立一车,已进交接清单。
330
+ */
331
+ private countPinnedExpiryCandidates;
332
+ /**
333
+ * 会话树的**四条活引用腿**的 SQL 谓词(`sm` = session_meta 的别名)。写成一处的理由与 {@link horizonArms}
334
+ * 相同,而且更重:它同时是「可删候选」的否定谓词与「被保护计数」的肯定谓词,两份手抄必然在某次改动后分家,
335
+ * 而分家的方向是**多删**(候选放宽、计数收紧,两边都不会有人红)。
336
+ *
337
+ * 腿的词表:活跑 claim / 挂起 park / 活着的后台子代(running|parked,三根会话锚)/ 在跑的 workflow
338
+ * (`originatingSessionId` 住在 run blob 里 ⇒ JSON 提取是真方言分叉)。fork 子代**结构上没有边**,
339
+ * 理由见 {@link SqlRetentionStore.deleteExpiredSessions} 的头注。
340
+ */
341
+ private liveReferenceExists;
342
+ /**
343
+ * 本域过视界、**当下被活引用护住**的会话数 = receipt 的 `skipped`。
344
+ * 账法与理由(为什么不是「总数减已删」的差值、为什么恒 ≥ 0、为什么有界)逐字见
345
+ * {@link SqlRetentionStore.countPinnedExpiryCandidates}:两条腿同一条账法,不许在这里另立一套。
346
+ */
347
+ private countProtectedSessions;
348
+ /**
349
+ * **可删**的会话 id:本域、过视界、且四条活引用腿**一条都不命中**(保护谓词下推,扣在 LIMIT 之前 ——
350
+ * 理由与 {@link expiryCandidates} 逐字相同:头部被保护的树不许饿死它后面的树)。
351
+ *
352
+ * `lock = true`(删除事务里的读)追加 `FOR UPDATE`,这一格是**承重**的(codex 对抗复审 R1-[high]):
353
+ * · 会话行是**存在**的行 ⇒ 这把锁与 hold 哨兵不同,它真的锁得住;
354
+ * · 锁住之后,并发的 `touch()`(把会话重新拉进视界)与 session-sync 的 **owner 改写**(importEntries /
355
+ * replaceEntries / staged commit 都会重写 `session_meta.owner`)必须排队等我们提交 —— 否则「按旧候选集
356
+ * 删完附属表、最后那条属主门 DELETE 却落空」就成立,而下一句无条件的 `session_event` 删除会把一个
357
+ * **刚刚换了主**的会话的历史一并抹掉(跨租户不可逆丢失)。
358
+ * · 锁 + 谓词在同一条语句里,于是「候选」与「删除」之间不存在窗口;`purgeSessionTrees` 尾部再断言
359
+ * 「meta 命中数 == 预期数」,两道一起把这条路封死。
360
+ */
361
+ private sessionCandidates;
362
+ /** 孤儿卸载结果的候选计数(实现随 `deleteOrphanToolResults` 同刀落地)。 */
363
+ private countOrphanToolResultCandidates;
364
+ /**
365
+ * 本域**可归属**的孤儿卸载结果(属主 session 已亡 + 墓碑归属本域 + 归属**无歧义**)。
366
+ *
367
+ * 🔴 第三个条件是 fail-closed 的归属门(codex 对抗复审 R3-[high],验真后加):`tool_result` 没有域列,
368
+ * 它的域完全靠「属主 sessionId 的墓碑」推。而会话 id 是**调用方可自选**的 ⇒ 两个租户先后用过同一个 id
369
+ * 完全合法;两边都删过之后,同一个 row_key 上会有**两个域**的墓碑,这时任何一边的 sweep 拿自己的墓碑
370
+ * 去认领这些字节都只是猜。判据因此是:**存在第二个域的同名墓碑 ⇒ 这批行不可归属 ⇒ 一根不删**,
371
+ * 实体清理退回既有的 TTL 腿(`reapOlderThan`)。少删一次是可接受的;删掉另一个租户的字节、还绕过它的
372
+ * legal hold 与审计域,不可接受。
373
+ */
374
+ protected orphanToolResultCandidates(exec: {
375
+ query: SqlDriver["query"];
376
+ }, input: {
377
+ domain: string;
378
+ cutoffMs: number;
379
+ }): Promise<string[]>;
380
+ /**
381
+ * lineage-aware 的会话树删除。**引用在删除事务内重查**(core 契约逐字:「references are re-checked inside
382
+ * the deletion transaction, never assumed from a prior scan」),有活引用的树**整棵**跳过计 `skipped`。
383
+ *
384
+ * 事务形(§5 F2 + §6 F5):
385
+ * ① `retention_hold` 哨兵行的**锁读** —— `held=1` ⇒ 零删、全 skip 返回;锁持有到 COMMIT;
386
+ * ② **一条**加锁读同时完成「候选 + 活引用重查」(谓词下推 + `FOR UPDATE`,见
387
+ * {@link SqlRetentionStore.sessionCandidates});
388
+ * ③ 按 E21 顺序逐表删 → 墓碑 → 审计行(同事务)→ COMMIT。
389
+ *
390
+ * 🔴 活引用重查为什么是**候选查询本身**而不是第二条查询(codex 对抗复审 R1 的两条 high 合并采纳):
391
+ * · 两条查询之间有窗口,而下推之后「被查到的」按构造就是「四条腿一条都不命中的」;
392
+ * · `FOR UPDATE` 让这些会话行在本事务期间不可被 `touch()` 拉回视界、也不可被 session-sync 改写属主 ——
393
+ * 旧形(不加锁 + 尾部无条件删 `session_event`)可以在属主被并发改写后,把**别的租户**的历史删掉;
394
+ * · 旧形还在删完 `task_active` **之后**才去复核它,那道复核因此恒真(自己刚把证据删了)—— 一段恒真的
395
+ * "防护"比没有更坏。现在删除前的最后一次判据就是候选查询,删除后不再假装复核。
396
+ * ⚠️ **残余**(如实成文,不假装消灭):`createRun` 只写 `task_active`(亲读 run-store-sql 坐实,它不碰
397
+ * `session_meta`),所以本事务的会话行锁**不**与它构成互斥。一次恰好在候选查询之后、本事务提交之前
398
+ * 提交的新 claim 仍会被这一轮删掉。窗口 = 级联本身的耗时,且与**在线** purge 协调器的同款窗口一模一样
399
+ * (它那条 `SELECT … FOR UPDATE task_active` 同样锁不住缺席行)。真正的收口是让 `createRun` 也去锁
400
+ * `session_meta` 那一行(共同锁序)—— 那是在线热路径的改动,归属另一次裁定,已进交接清单。
401
+ *
402
+ * 活引用的四条腿(与设计稿 §2 的清单对表,并如实标注一条**结构上不存在**的边):
403
+ * · `task_active` —— 活跑 claim(在线协调器的第一道门,同一张表同一条判据);
404
+ * · `checkpoint.status='pending'` —— 还赎得回的 park;
405
+ * · `background_agent` 的 `running|parked` 行(session_id / parent_session_id / root_session_id 三根锚);
406
+ * · `workflow_run` 的 `running` 行(`originatingSessionId` 在 run blob 里,JSON 提取两方言各写各的)。
407
+ * · 🔴 **fork 子代没有 durable 边**(亲读 `TiDBSessionStore.fork` / `session_meta` 的 DDL 坐实):E17 的
408
+ * fork 是**整段历史拷贝**成一个独立会话,库里不留父子指针。这不是漏查 —— 拷贝形下删父会话不会让子
409
+ * 会话悬空(它的字节是它自己的),所以这条边**结构上不需要保护**。设计稿把「fork 子」列进 lineage
410
+ * 清单是按 core 契约的通用措辞抄的;本仓的 fork 语义使它落空,记在这里免得后人当缺口再"补"一次。
411
+ *
412
+ * receipt 口径:`deleted` = **删掉的会话树棵数**(= `session_meta` 的命中行数),不是级联删掉的总行数
413
+ * ——后者跨十余张表、随部署形态漂(附件表可能不在场),做不成稳定判据;而「幂等重跑 ⇒ 0」这条契约在
414
+ * 两种口径下同真。`tombstones` = 本次**新落地**的墓碑数(重跑时既有墓碑不重复计)。
415
+ */
416
+ deleteExpiredSessions(input: RetentionCallInput): Promise<RetentionReceipt>;
417
+ /**
418
+ * E21 顺序契约的**事务化搬入**:同一组 DELETE、同一个顺序,一次性发在一条事务连接上。
419
+ * 顺序的两条承重理由(其余是可读性):
420
+ * · `checkpoint` / `checkpoint_ctx` 的属主门是 `session_meta.owner` 的 EXISTS 子查询 ⇒ 它们**必须**排在
421
+ * `session_meta` 删除**之前**(meta 一没,门就恒假、一行都删不掉);
422
+ * · `task_event` / `task_active` 经 `task_run` 的属主门连删 ⇒ 必须排在 `task_run` 删除**之前**。
423
+ * 在线协调器那边「子表先删、meta 最后」的次序还额外承担**跨事务崩溃**语义(半途失败时 meta 仍在 ⇒ 幂等
424
+ * 重试能收敛);本方法整段在一个事务里,那条理由在这里不成立,但次序照抄不改 —— 两条腿的等价断言
425
+ * (E21)比的就是"同一条契约的两个执行体",让次序分家等于自愿放弃那条判据。
426
+ *
427
+ * 属主门的一处**有意改写**(而不是照抄 `<=>`):在线协调器传的是"这个会话的 owner"(可能是 SQL NULL),
428
+ * 留存腿传的是**域键**(字符串,`""` 表示无主桶)⇒ 用 {@link SqlRetentionStore.domainWhere} 表达
429
+ * `(owner IS NULL OR owner = '')`。对有主域两者逐字等价(`col = ?` ⟺ `col <=> ?`,参数非 NULL);
430
+ * 对无主域,`<=>` 拿字符串 `""` 去比 NULL 会**一行都不匹配** —— 那正是「无主会话永远清不掉」的病。
431
+ */
432
+ protected purgeSessionTrees(conn: SqlTxConn, domain: string, ids: readonly string[]): Promise<number>;
433
+ /**
434
+ * 属主 session 已亡的 offloaded 结果(core 契约:「whose owning sessions are gone (or past the horizon),
435
+ * skipping refs still reachable from live transcripts」)。
436
+ *
437
+ * 三条口径,逐条都是判据(它们决定了这条腿**不会**碰什么):
438
+ * · **删的那一支**:`owner_session_id` 指向一个**不在 `session_meta` 里**的会话,且该会话在本域留有
439
+ * `kind='session'` 墓碑,且行本身过了视界。墓碑是**域归属的唯一证据** —— 没有它,一条孤儿行的租户
440
+ * 在 SQL 里根本问不出来(表没有域列),而按 ref 前缀猜属主是 core 明令禁止的(ref 是 opaque handle,
441
+ * 调用方可自铸;`tool-result-store-sql.deleteBySession` 的顶注逐字记着这条被 codex 收窄过的教训)。
442
+ * · **skip 的那一支**:属主会话**还在** = 转录活着 = 「still reachable from live transcripts」⇒ 过视界
443
+ * 也只计 `skipped` 不删。会话本体的清理归 {@link SqlRetentionStore.deleteExpiredSessions}(它会连着
444
+ * 这行一起删),两条腿因此不重叠也不互相抢行。
445
+ * · **一根不碰的那一支**:`owner_session_id IS NULL` 的**无出处**行。没有出处就没有域,按任何域去删它
446
+ * 都是猜;实体清理归既有的 TTL 腿(`reapOlderThan`,`boot/reapers.ts` 驱动)。这与 core 的
447
+ * 「an UNOWNED entry is never deleted」是同一条纪律的两处执行点。
448
+ *
449
+ * 事务形与另外两只方法逐字同款(hold 哨兵行锁读 → 删除+墓碑 → 审计行,同 commit 同 rollback)。
450
+ */
451
+ deleteOrphanToolResults(input: RetentionCallInput): Promise<RetentionReceipt>;
452
+ /** 「活转录可达」的行数(属主会话仍在 `session_meta`、域是本域、行已过视界)= receipt 的 `skipped`。 */
453
+ protected countLiveReachableToolResults(exec: {
454
+ query: SqlDriver["query"];
455
+ }, input: {
456
+ domain: string;
457
+ cutoffMs: number;
458
+ }): Promise<number>;
459
+ }
460
+ /** MySQL-protocol (TiDB) binding。 */
461
+ export declare class TiDBRetentionStore extends SqlRetentionStore {
462
+ constructor(pool: MySqlPool, opts: SqlRetentionStoreOptions);
463
+ }
464
+ /** PostgreSQL binding。 */
465
+ export declare class PgRetentionStore extends SqlRetentionStore {
466
+ constructor(pool: PgPool, opts: SqlRetentionStoreOptions);
467
+ }
468
+ //# sourceMappingURL=retention-store-sql.d.ts.map