@sema-agent/server 7.12.0 → 7.14.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 +51 -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/coordinators.js +2 -1
- package/dist/boot/memory-boundary.d.ts +84 -0
- package/dist/boot/memory-boundary.js +110 -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/resolve-spec.js +31 -0
- package/dist/boot/runner-deps.d.ts +16 -2
- package/dist/boot/runner-deps.js +24 -4
- package/dist/boot/stores.js +93 -3
- package/dist/capabilities/memory-notice.d.ts +83 -0
- package/dist/capabilities/memory-notice.js +90 -0
- package/dist/config-types.d.ts +100 -11
- package/dist/config.d.ts +1 -1
- package/dist/config.js +111 -1
- package/dist/governance-ask-marks.js +2 -1
- 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 +35 -5
- 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/routes/trace-usage.js +43 -14
- package/dist/http/server.d.ts +35 -9
- package/dist/http/server.js +111 -17
- package/dist/http/wire-types.d.ts +6 -1
- 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/approval-ask-store-sql.d.ts +2 -1
- package/dist/plugins/approval-ask-store-sql.js +2 -1
- package/dist/plugins/file-run-store.d.ts +85 -1
- package/dist/plugins/file-run-store.js +450 -17
- package/dist/plugins/memory-embedder.d.ts +44 -0
- package/dist/plugins/memory-embedder.js +173 -0
- 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.d.ts +3 -1
- package/dist/plugins/store-backend.js +24 -1
- package/dist/plugins/tidb-pool.js +11 -4
- package/dist/plugins/tool-result-store-sql.d.ts +35 -2
- package/dist/plugins/tool-result-store-sql.js +127 -11
- package/dist/plugins/web-search.d.ts +3 -1
- package/dist/plugins/web-search.js +3 -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/security.js +3 -1
- package/dist/tool-approval.d.ts +32 -0
- package/dist/tool-approval.js +39 -0
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { MemoryEmbedderConfig } from "../config-types.js";
|
|
2
|
+
import type { PgEmbedder } from "./pg-query.js";
|
|
3
|
+
/** 工厂入参 = 配置坐标(单一属主 `config-types.ts`)+ 部署腿(观测/注入)。 */
|
|
4
|
+
export interface OpenAiCompatEmbedderOptions extends MemoryEmbedderConfig {
|
|
5
|
+
/** 注入 fetch(测试/代理场景);缺省全局 `fetch`。 */
|
|
6
|
+
fetchImpl?: typeof fetch;
|
|
7
|
+
/**
|
|
8
|
+
* 失败观测腿(codex 复审 F2,已核真):embed 抛出去之后,`PgMemoryEngineBackend.applyPatches` 的
|
|
9
|
+
* 外层 catch 会把它折成一条 `io error: …` **冲突**记进 PatchReport(core 的冲突词表把它讲成「并发
|
|
10
|
+
* 改动」),而 `search` 那腿直接 `.catch(() => null)` 退回词面档 —— 两条路都不会在部署面留下任何
|
|
11
|
+
* 「向量供应商挂了」的信号。这个 hook 就是那个信号:装配点接 metrics + warn。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ hook 本身不改变结果:抛照抛(不吞、不补零),它只负责让故障**被看见**。
|
|
14
|
+
*/
|
|
15
|
+
onFailure?: (err: unknown) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 端点归一:`MEMORY_EMBEDDER_ENDPOINT` 收两种写法 —— OpenAI 兼容**基址**(`https://x/v1`)或**完整**
|
|
19
|
+
* 端点(`https://x/v1/embeddings`)。两种都是 operator 手上真实存在的抄法,而一律硬拼 `/embeddings`
|
|
20
|
+
* 会产出 `/v1/embeddings/embeddings`(这条键最常见的手滑形)。归一不是降级:两条路都指向同一个真 URL,
|
|
21
|
+
* 没有任何一种输入被静默改成**别的**语义。
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ 只动 `pathname`(codex 复审 F3,已核真):第一版在**整串**上做 `endsWith`/拼接,于是带 query 的
|
|
24
|
+
* 端点全错 —— Azure OpenAI 形 `https://h/v1/embeddings?api-version=1` 会被拼成 query 值里带
|
|
25
|
+
* `1/embeddings`,`https://h/v1?api-version=1` 则永远拿不到 `/embeddings` 路径。两种都过得了启动期的
|
|
26
|
+
* URL 合法性门,只在运行期炸(而运行期的炸法就是上面那条「写入失败/检索退档」的静默病)。
|
|
27
|
+
*/
|
|
28
|
+
export declare function embeddingsUrl(endpoint: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* 诊断面用的**脱敏** URL(codex 复审 F4,已核真):端点里可能带 userinfo(`https://user:pass@h/v1`)
|
|
31
|
+
* 或把凭据放 query(Azure 的 `api-key=`),而错误文本会进日志、进 PatchReport 的 conflict reason。
|
|
32
|
+
* 主机与路径保留(诊断价值全在这里),userinfo 抹掉、query 值一律换成 `***`(键名保留,便于认形)。
|
|
33
|
+
*/
|
|
34
|
+
export declare function redactEndpointForLog(raw: string): string;
|
|
35
|
+
/** 造一个 OpenAI 兼容的 `PgEmbedder`(工厂命名律:返回带行为的对象 ⇒ `create*`)。 */
|
|
36
|
+
export declare function createOpenAiCompatEmbedder(opts: OpenAiCompatEmbedderOptions): PgEmbedder;
|
|
37
|
+
/**
|
|
38
|
+
* 装配腿:配置在场 ⇒ 造 embedder,缺席 ⇒ `undefined`(= core 的三档推断落回 lexical)。
|
|
39
|
+
* 这一层单独存在是为了让「注入真的接上了」可测 —— `openStores` 本身要真 DB 才跑得起来。
|
|
40
|
+
*/
|
|
41
|
+
export declare function memoryEmbedderFor(config: {
|
|
42
|
+
memoryEmbedder?: MemoryEmbedderConfig;
|
|
43
|
+
}, deps?: Pick<OpenAiCompatEmbedderOptions, "fetchImpl" | "onFailure">): PgEmbedder | undefined;
|
|
44
|
+
//# sourceMappingURL=memory-embedder.d.ts.map
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #228 —— OpenAI 兼容的 embedder(黑板 [3590] 裁②:core 只留了 `PgEmbedder` 这个**纯接口**
|
|
3
|
+
* (`{ embed(text) → number[]; dimensions }`),没有任何现成实现件;部署半场归本仓)。
|
|
4
|
+
*
|
|
5
|
+
* ## 形
|
|
6
|
+
*
|
|
7
|
+
* `POST {endpoint}/embeddings`,body `{ model, input }`,读 `data[0].embedding` —— OpenAI /
|
|
8
|
+
* DashScope / TEI / vllm / ollama 的 `/v1` 面同形。auth 可选(自托管端点通常没有 key)。
|
|
9
|
+
*
|
|
10
|
+
* ## 为什么校验做得这么硬
|
|
11
|
+
*
|
|
12
|
+
* 向量列写错维度是**静默毒库**形([3606] 裁③):错长度向量既不会让 DB 报错(本仓 pg 记忆表的
|
|
13
|
+
* embedding 列是 `jsonb`,无维度约束),也不会让检索报错(维度不匹配的行只是悄悄退回词面档)——
|
|
14
|
+
* 于是「向量面开着」这句话变成一句谎,而且没有任何一层会说出来。所以:
|
|
15
|
+
* · 响应形用 zod 校验(`data[0].embedding` 必须是 `number[]`),不符 = 抛带上下文的错;
|
|
16
|
+
* · 长度与声明维度不等 = 抛,**绝不截断 / 补零**(那正是把毒喂进库的两种手法);
|
|
17
|
+
* · 非 2xx / 非 JSON = 抛(status + body 前 200 字节,memory-sync transport 同款诊断姿势)。
|
|
18
|
+
*
|
|
19
|
+
* 抛出去之后由调用方处置:`PgMemoryEngineBackend.embeddingParam` 的长度门会让该行退回词面档,
|
|
20
|
+
* `search` 的词面腿 `.catch(() => null)` 会让检索继续跑 —— 即「embedder 坏了 = 退回 lexical」,
|
|
21
|
+
* 而不是「embedder 坏了 = 写坏向量」。
|
|
22
|
+
*
|
|
23
|
+
* ## 超时
|
|
24
|
+
*
|
|
25
|
+
* `AbortSignal.timeout(timeoutMs)`(缺省 30s,`MEMORY_EMBEDDER_TIMEOUT_MS` 覆盖,越界/坏值拒启)。
|
|
26
|
+
* 记忆写入路径是同步等 embed 的,挂死的端点会把整条 harvest 吊住(undici 默认 headers 超时 300s 太钝)。
|
|
27
|
+
*/
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
/** OpenAI `/v1/embeddings` 响应的**最小**契约:只读第一条向量,其余键(usage/object/model)不关心。 */
|
|
30
|
+
const EmbeddingsResponse = z.object({
|
|
31
|
+
data: z.array(z.object({ embedding: z.array(z.number()) })).min(1),
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* 端点归一:`MEMORY_EMBEDDER_ENDPOINT` 收两种写法 —— OpenAI 兼容**基址**(`https://x/v1`)或**完整**
|
|
35
|
+
* 端点(`https://x/v1/embeddings`)。两种都是 operator 手上真实存在的抄法,而一律硬拼 `/embeddings`
|
|
36
|
+
* 会产出 `/v1/embeddings/embeddings`(这条键最常见的手滑形)。归一不是降级:两条路都指向同一个真 URL,
|
|
37
|
+
* 没有任何一种输入被静默改成**别的**语义。
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ 只动 `pathname`(codex 复审 F3,已核真):第一版在**整串**上做 `endsWith`/拼接,于是带 query 的
|
|
40
|
+
* 端点全错 —— Azure OpenAI 形 `https://h/v1/embeddings?api-version=1` 会被拼成 query 值里带
|
|
41
|
+
* `1/embeddings`,`https://h/v1?api-version=1` 则永远拿不到 `/embeddings` 路径。两种都过得了启动期的
|
|
42
|
+
* URL 合法性门,只在运行期炸(而运行期的炸法就是上面那条「写入失败/检索退档」的静默病)。
|
|
43
|
+
*/
|
|
44
|
+
export function embeddingsUrl(endpoint) {
|
|
45
|
+
const u = new URL(endpoint); // config 层已校验;直调工厂时坏 URL 在这里响亮抛
|
|
46
|
+
u.hash = ""; // fragment 对服务端无意义,带上只会污染日志
|
|
47
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
48
|
+
// 「已经带尾巴了吗」按**解码后**的末段判(codex 二轮 F3,已核真):`/v1/%65mbeddings` 与
|
|
49
|
+
// `/v1/embeddings` 是同一条路径,只比对序列化文本会给前者再叠一层。只解 unreserved 转义
|
|
50
|
+
// (RFC 3986 的 `A-Za-z0-9-._~`)—— 其余转义原样保留,decodeURIComponent 的坏 `%` 抛错面也一并避开。
|
|
51
|
+
u.pathname = decodeUnreservedEscapes(path).endsWith("/embeddings") ? path : `${path}/embeddings`;
|
|
52
|
+
return u.toString();
|
|
53
|
+
}
|
|
54
|
+
/** 只把 unreserved 字符的百分号转义解回来(不抛、不改变其它转义)。 */
|
|
55
|
+
function decodeUnreservedEscapes(s) {
|
|
56
|
+
return s.replace(/%([0-9A-Fa-f]{2})/g, (whole, hex) => {
|
|
57
|
+
const c = String.fromCharCode(Number.parseInt(hex, 16));
|
|
58
|
+
return /[A-Za-z0-9\-._~]/.test(c) ? c : whole;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 诊断面用的**脱敏** URL(codex 复审 F4,已核真):端点里可能带 userinfo(`https://user:pass@h/v1`)
|
|
63
|
+
* 或把凭据放 query(Azure 的 `api-key=`),而错误文本会进日志、进 PatchReport 的 conflict reason。
|
|
64
|
+
* 主机与路径保留(诊断价值全在这里),userinfo 抹掉、query 值一律换成 `***`(键名保留,便于认形)。
|
|
65
|
+
*/
|
|
66
|
+
export function redactEndpointForLog(raw) {
|
|
67
|
+
if (!URL.canParse(raw))
|
|
68
|
+
return "<invalid-url>"; // 谓词先行,不用 catch 兜(#191 静默降级门:catch 回默认值是被数的形)
|
|
69
|
+
const u = new URL(raw);
|
|
70
|
+
u.username = "";
|
|
71
|
+
u.password = "";
|
|
72
|
+
for (const k of [...u.searchParams.keys()])
|
|
73
|
+
u.searchParams.set(k, "***");
|
|
74
|
+
return u.toString();
|
|
75
|
+
}
|
|
76
|
+
/** 端点 query 上的**值**(Azure 形把 key 放 query;这些字面量要从任何诊断文本里洗掉)。 */
|
|
77
|
+
function credentialsInQuery(endpoint) {
|
|
78
|
+
if (!URL.canParse(endpoint))
|
|
79
|
+
return [];
|
|
80
|
+
return [...new URL(endpoint).searchParams.values()].filter((v) => v.length > 0);
|
|
81
|
+
}
|
|
82
|
+
/** 造一个 OpenAI 兼容的 `PgEmbedder`(工厂命名律:返回带行为的对象 ⇒ `create*`)。 */
|
|
83
|
+
export function createOpenAiCompatEmbedder(opts) {
|
|
84
|
+
const url = embeddingsUrl(opts.endpoint);
|
|
85
|
+
const shownUrl = redactEndpointForLog(url);
|
|
86
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
87
|
+
const { model, dimensions, timeoutMs, apiKey, onFailure } = opts;
|
|
88
|
+
// 洗白名单 = 本部署已知的**全部**凭据字面量:apiKey + 端点 query 上的值(Azure 形把 key 放 query;
|
|
89
|
+
// codex 二轮 F1)。userinfo 在 config 层就被拒了,这里不再单列。
|
|
90
|
+
const secrets = [...(apiKey !== undefined ? [apiKey] : []), ...credentialsInQuery(opts.endpoint)];
|
|
91
|
+
/** 供应商 body / 传输层错误进诊断文本前先洗一遍:回显自家凭据的代理/网关是真实存在的形。 */
|
|
92
|
+
const scrub = (raw) => secrets.reduce((acc, s) => acc.replaceAll(s, "***"), raw);
|
|
93
|
+
const safeSlice = (raw) => scrub(raw).slice(0, 200);
|
|
94
|
+
const call = async (text) => {
|
|
95
|
+
// 传输层失败(DNS/连不上/超时)也要走脱敏形(codex 二轮 F1):undici 的错误链里可能带上完整 URL,
|
|
96
|
+
// 而这条错会进 warn 日志与 PatchReport 的 conflict reason。**刻意不挂 cause**:挂上等于把没洗过的
|
|
97
|
+
// 原始文本从后门放回诊断面(打印错误链的人一样看得见),这里只带洗过的文本。
|
|
98
|
+
let res;
|
|
99
|
+
try {
|
|
100
|
+
res = await fetchImpl(url, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
"content-type": "application/json",
|
|
104
|
+
...(apiKey !== undefined ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({ model, input: text }),
|
|
107
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (transportErr) {
|
|
111
|
+
throw new Error(`memory embedder POST ${shownUrl}: transport failure — ${safeSlice(String(transportErr))}`);
|
|
112
|
+
}
|
|
113
|
+
const raw = await res.text().catch((readErr) => {
|
|
114
|
+
throw new Error(`memory embedder POST ${shownUrl}: response body unreadable — ${safeSlice(String(readErr))}`);
|
|
115
|
+
});
|
|
116
|
+
// 诊断面纪律:回显 status + body 前缀(端点的 typed error body 就是最好的线索),但 URL 走脱敏形、
|
|
117
|
+
// body 先洗掉全部已知凭据字面量 —— 错误文本会进日志与 PatchReport 的 conflict reason。
|
|
118
|
+
if (!res.ok)
|
|
119
|
+
throw new Error(`memory embedder POST ${shownUrl}: HTTP ${res.status} ${safeSlice(raw)}`);
|
|
120
|
+
let json;
|
|
121
|
+
try {
|
|
122
|
+
json = JSON.parse(raw);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new Error(`memory embedder POST ${shownUrl}: response is not JSON — ${safeSlice(raw)}`);
|
|
126
|
+
}
|
|
127
|
+
const parsed = EmbeddingsResponse.safeParse(json);
|
|
128
|
+
if (!parsed.success) {
|
|
129
|
+
throw new Error(`memory embedder POST ${shownUrl}: not an OpenAI-compatible embeddings response (${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}) — body ${safeSlice(raw)}`);
|
|
130
|
+
}
|
|
131
|
+
const vector = parsed.data.data[0].embedding;
|
|
132
|
+
if (vector.length !== dimensions) {
|
|
133
|
+
throw new Error(`memory embedder model "${model}" returned ${vector.length} dimensions but MEMORY_EMBEDDER_DIM=${dimensions} — refusing to truncate or pad (a wrong-length vector in the memory embedding column is silent corruption: neither the DB nor retrieval reports it). Fix MEMORY_EMBEDDER_DIM to match the model, or point MEMORY_EMBEDDER_MODEL at the model you sized for.`);
|
|
134
|
+
}
|
|
135
|
+
return vector;
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
dimensions,
|
|
139
|
+
async embed(text) {
|
|
140
|
+
try {
|
|
141
|
+
return await call(text);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
// 先让部署面看见,再**原样**抛(观测腿绝不改变结果,连真错的属性都不碰)
|
|
145
|
+
try {
|
|
146
|
+
onFailure?.(err);
|
|
147
|
+
}
|
|
148
|
+
catch (hookErr) {
|
|
149
|
+
// 观测腿自己坏了:①不许顶替真故障(拿监控的病盖住业务的病是最坏的一种交换);②不许改动真错
|
|
150
|
+
// ——第一版往 err.cause 上挂,冻结/不可写的 err 会让**赋值本身**抛,于是 TypeError 顶替了
|
|
151
|
+
// 真错(codex 二轮 F2,已核真);③也不许无声 —— 走 process.emitWarning,不碰真错分毫。
|
|
152
|
+
process.emitWarning(`memory embedder onFailure hook threw: ${String(hookErr)}`, "MemoryEmbedderObservabilityWarning");
|
|
153
|
+
}
|
|
154
|
+
throw err; // 原对象原样抛出(identity 不变)
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 装配腿:配置在场 ⇒ 造 embedder,缺席 ⇒ `undefined`(= core 的三档推断落回 lexical)。
|
|
161
|
+
* 这一层单独存在是为了让「注入真的接上了」可测 —— `openStores` 本身要真 DB 才跑得起来。
|
|
162
|
+
*/
|
|
163
|
+
export function memoryEmbedderFor(config, deps = {}) {
|
|
164
|
+
const cfg = config.memoryEmbedder;
|
|
165
|
+
if (cfg === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
return createOpenAiCompatEmbedder({
|
|
168
|
+
...cfg,
|
|
169
|
+
...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
|
|
170
|
+
...(deps.onFailure !== undefined ? { onFailure: deps.onFailure } : {}),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=memory-embedder.js.map
|
|
@@ -232,6 +232,35 @@ export declare class SqlRuleImportTicketStore {
|
|
|
232
232
|
release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
|
|
233
233
|
private readRow;
|
|
234
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* 孤儿 pending 审批记录的保留期(A-010.17)。
|
|
237
|
+
*
|
|
238
|
+
* 判据是「**可证已死**」,不是一个拍脑袋的时长:一条 pending 记录只能经 `redeemRuleTicket` 走活,
|
|
239
|
+
* 而兑付要么发生在铸它的**那一次请求内**(`persistCardRule` 的 prepare→confirm→redeem 三步同请求),
|
|
240
|
+
* 要么要拿一张导入票 —— 而票自铸起最多活 {@link RULE_IMPORT_TICKET_TTL_MS}(10 分钟,`consume` 的
|
|
241
|
+
* `expires_at_ms > now` 是硬条件)。所以创建时刻早于「now − 票 TTL」的 pending 记录**再也不可能**
|
|
242
|
+
* 被兑付。24 小时是在这条上界之上再压两个数量级的余量,留给运维「昨天那次导入怎么没成」的排查窗。
|
|
243
|
+
*
|
|
244
|
+
* 🔴 **只收 pending**。`approved` / `redeemed` 是一次真人同意的**审计事实**(与 `discardPendingRecord`
|
|
245
|
+
* 的 `WHERE state = 'pending'` 同一条硬约束,也与收编把这张表判成 D2「史实不改写」同源)——
|
|
246
|
+
* 保留期策略动不到它们,本腿一行都不碰。
|
|
247
|
+
*/
|
|
248
|
+
export declare const RULE_PENDING_APPROVAL_RETENTION_MS: number;
|
|
249
|
+
/**
|
|
250
|
+
* 保留期腿**每轮**最多删多少行(codex R2 [high])。
|
|
251
|
+
*
|
|
252
|
+
* 为什么必须有界:`permission_rule_approval` 按设计**永久**留 approved/redeemed 的审计事实(收编把它
|
|
253
|
+
* 判成 D2「史实不改写」,`discardPendingRecord` 的 `WHERE state='pending'` 也是同一条硬约束)——
|
|
254
|
+
* 也就是说这张表**只增不减**。一条无界 DELETE 在首次开清扫、或一次长期没跑的部署上,会在一个事务里
|
|
255
|
+
* 处理整个积压。500 的取值:一轮的最坏工作量钉在「几百行删除」这个数量级,而常态每轮的真实行数是个位数
|
|
256
|
+
* (一次 prepare 留一行);积压按轮渐进清空,每轮都是完整语义,绝不留半干净状态。
|
|
257
|
+
*
|
|
258
|
+
* ⚠️ 两方言的**索引到货方式不对称**,如实登记:PG 侧是独立的 `CREATE INDEX IF NOT EXISTS`,存量库
|
|
259
|
+
* 下次 `ensureSchema` 就补建;MySQL 侧索引写在 `CREATE TABLE` 里,而本仓 schema 口径是「启动 DDL 是唯一
|
|
260
|
+
* 真源、不发 ALTER」⇒ **存量表拿不到这两个索引**,要靠一次删库重建(与 7.8.0 / 7.10.0 两次 BREAKING 窗
|
|
261
|
+
* 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
|
|
262
|
+
*/
|
|
263
|
+
export declare const RULE_REAP_BATCH = 500;
|
|
235
264
|
/** 三个 SQL 店的一次性装配束(one driver, three faces)。 */
|
|
236
265
|
export interface PermissionRuleStores {
|
|
237
266
|
provider: SqlPermissionRuleStoreProvider;
|
|
@@ -244,6 +273,22 @@ export interface PermissionRuleStores {
|
|
|
244
273
|
* 代价不划算。桶数回答的正是审计要问的那个问题(「这台部署上有没有既有的规则状态」),而且是一次
|
|
245
274
|
* 索引级 `COUNT(*)`。消费点(`boot/permission-rules-audit.ts`)的文案因此逐字说的是 bucket,不是 rule。 */
|
|
246
275
|
countBuckets(): Promise<number>;
|
|
276
|
+
/**
|
|
277
|
+
* 保留期腿(A-010.17)。返回删掉的**总行数**(两张表合计),给 reaper 的 `reapCount` 用。
|
|
278
|
+
*
|
|
279
|
+
* 病:这两张表此前**没有任何 retention 腿**,而它们都是只进不出的:
|
|
280
|
+
* · `permission_rule_ticket` —— 每一次 `POST /v1/rules/cc-import/prepare` 落一行,不管有没有人去
|
|
281
|
+
* 兑付。而普查门把它登记成「过期即死」—— 那句话描述的是**语义**(过期票 `consume` 必拒),
|
|
282
|
+
* 库里那一行从来没有人删。一个把 CC settings 导来导去的部署,这张表每次预览都长一行,永久。
|
|
283
|
+
* · `permission_rule_approval` 的 **pending** 行 —— core 在**返回预览之前**就落记录,于是「看了预览
|
|
284
|
+
* 没按确认」这条最常见的人类路径,每走一次留一条永远没人要的行(`discardPendingRecord` 只收
|
|
285
|
+
* 「超帽当场拒」那一条路,不收「人改主意了」)。
|
|
286
|
+
*
|
|
287
|
+
* 两条谓词都只删**可证已死**的行,所以本腿不需要旋钮(没有可调的语义):
|
|
288
|
+
* · 票:`expires_at_ms <= now` —— `consume` 的硬条件是 `expires_at_ms > now`,过期票已不可兑付;
|
|
289
|
+
* · 记录:`state = 'pending' AND created_at_ms < now − {@link RULE_PENDING_APPROVAL_RETENTION_MS}`。
|
|
290
|
+
*/
|
|
291
|
+
reapExpired(nowMs: number): Promise<number>;
|
|
247
292
|
}
|
|
248
293
|
export declare function createSqlPermissionRuleStores(db: SqlDriver, now?: () => number): PermissionRuleStores;
|
|
249
294
|
//# sourceMappingURL=permission-rule-store-sql.d.ts.map
|
|
@@ -190,7 +190,12 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
|
|
|
190
190
|
created_at_ms BIGINT NOT NULL,
|
|
191
191
|
updated_at_ms BIGINT NOT NULL,
|
|
192
192
|
PRIMARY KEY (record_id),
|
|
193
|
-
KEY idx_permission_rule_approval_owner (owner_key)
|
|
193
|
+
KEY idx_permission_rule_approval_owner (owner_key),
|
|
194
|
+
-- 保留期腿的**支撑索引**(A-010.17 / codex R2 [high])。这张表按设计**永久**留着 approved/redeemed
|
|
195
|
+
-- 的审计事实,所以它只增不减 ⇒ 一条没有索引的 WHERE state='pending' AND created_at_ms < ? 谓词
|
|
196
|
+
-- 是一次随审计史无限增长的全表扫,而且每 tick 一次。(state, created_at_ms) 复合序把清扫收敛成
|
|
197
|
+
-- 一次窄区间扫:pending 段本来就短命,超期的那几行紧挨着。
|
|
198
|
+
KEY idx_permission_rule_approval_sweep (state, created_at_ms)
|
|
194
199
|
) COLLATE utf8mb4_bin`,
|
|
195
200
|
`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TICKET_TABLE} (
|
|
196
201
|
ticket_id VARCHAR(190) NOT NULL,
|
|
@@ -209,7 +214,9 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
|
|
|
209
214
|
consumed_at_ms BIGINT NULL,
|
|
210
215
|
created_at_ms BIGINT NOT NULL,
|
|
211
216
|
PRIMARY KEY (ticket_id),
|
|
212
|
-
KEY idx_permission_rule_ticket_owner (owner_key)
|
|
217
|
+
KEY idx_permission_rule_ticket_owner (owner_key),
|
|
218
|
+
-- 保留期腿的支撑索引(同上):WHERE expires_at_ms <= ? 是每 tick 一次的区间扫。
|
|
219
|
+
KEY idx_permission_rule_ticket_expires (expires_at_ms)
|
|
213
220
|
) COLLATE utf8mb4_bin`,
|
|
214
221
|
];
|
|
215
222
|
/** {@link TIDB_PERMISSION_RULE_STATEMENTS} 的遍历壳(生产路径走 `tidb-pool.ts` 中央 `ensureSchema`;
|
|
@@ -255,6 +262,10 @@ export async function ensurePgPermissionRuleSchema(q) {
|
|
|
255
262
|
PRIMARY KEY (record_id)
|
|
256
263
|
)`);
|
|
257
264
|
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_approval_owner ON ${PERMISSION_RULE_APPROVAL_TABLE} (owner_key)`);
|
|
265
|
+
// 保留期腿的支撑索引(MySQL 孪生的行内注写了理由:这张表按设计永久留审计事实,无索引的清扫谓词
|
|
266
|
+
// 是一次随史增长的全表扫)。PG 侧是独立 CREATE INDEX —— 存量库上 `IF NOT EXISTS` 会**真的补建**,
|
|
267
|
+
// 与 MySQL 侧「索引写在 CREATE TABLE 里、存量表拿不到」的不对称如实登记在 reapExpired 的注里。
|
|
268
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_approval_sweep ON ${PERMISSION_RULE_APPROVAL_TABLE} (state, created_at_ms)`);
|
|
258
269
|
await q(`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TICKET_TABLE} (
|
|
259
270
|
ticket_id VARCHAR(190) COLLATE "C" NOT NULL,
|
|
260
271
|
owner_key VARCHAR(190) COLLATE "C" NOT NULL,
|
|
@@ -268,6 +279,7 @@ export async function ensurePgPermissionRuleSchema(q) {
|
|
|
268
279
|
PRIMARY KEY (ticket_id)
|
|
269
280
|
)`);
|
|
270
281
|
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_owner ON ${PERMISSION_RULE_TICKET_TABLE} (owner_key)`);
|
|
282
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_expires ON ${PERMISSION_RULE_TICKET_TABLE} (expires_at_ms)`);
|
|
271
283
|
}
|
|
272
284
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
273
285
|
// 纯函数(桶键 / 摘要 / delta 折叠)
|
|
@@ -807,11 +819,57 @@ export class SqlRuleImportTicketStore {
|
|
|
807
819
|
};
|
|
808
820
|
}
|
|
809
821
|
}
|
|
822
|
+
/**
|
|
823
|
+
* 孤儿 pending 审批记录的保留期(A-010.17)。
|
|
824
|
+
*
|
|
825
|
+
* 判据是「**可证已死**」,不是一个拍脑袋的时长:一条 pending 记录只能经 `redeemRuleTicket` 走活,
|
|
826
|
+
* 而兑付要么发生在铸它的**那一次请求内**(`persistCardRule` 的 prepare→confirm→redeem 三步同请求),
|
|
827
|
+
* 要么要拿一张导入票 —— 而票自铸起最多活 {@link RULE_IMPORT_TICKET_TTL_MS}(10 分钟,`consume` 的
|
|
828
|
+
* `expires_at_ms > now` 是硬条件)。所以创建时刻早于「now − 票 TTL」的 pending 记录**再也不可能**
|
|
829
|
+
* 被兑付。24 小时是在这条上界之上再压两个数量级的余量,留给运维「昨天那次导入怎么没成」的排查窗。
|
|
830
|
+
*
|
|
831
|
+
* 🔴 **只收 pending**。`approved` / `redeemed` 是一次真人同意的**审计事实**(与 `discardPendingRecord`
|
|
832
|
+
* 的 `WHERE state = 'pending'` 同一条硬约束,也与收编把这张表判成 D2「史实不改写」同源)——
|
|
833
|
+
* 保留期策略动不到它们,本腿一行都不碰。
|
|
834
|
+
*/
|
|
835
|
+
export const RULE_PENDING_APPROVAL_RETENTION_MS = 24 * 60 * 60_000;
|
|
836
|
+
/**
|
|
837
|
+
* 保留期腿**每轮**最多删多少行(codex R2 [high])。
|
|
838
|
+
*
|
|
839
|
+
* 为什么必须有界:`permission_rule_approval` 按设计**永久**留 approved/redeemed 的审计事实(收编把它
|
|
840
|
+
* 判成 D2「史实不改写」,`discardPendingRecord` 的 `WHERE state='pending'` 也是同一条硬约束)——
|
|
841
|
+
* 也就是说这张表**只增不减**。一条无界 DELETE 在首次开清扫、或一次长期没跑的部署上,会在一个事务里
|
|
842
|
+
* 处理整个积压。500 的取值:一轮的最坏工作量钉在「几百行删除」这个数量级,而常态每轮的真实行数是个位数
|
|
843
|
+
* (一次 prepare 留一行);积压按轮渐进清空,每轮都是完整语义,绝不留半干净状态。
|
|
844
|
+
*
|
|
845
|
+
* ⚠️ 两方言的**索引到货方式不对称**,如实登记:PG 侧是独立的 `CREATE INDEX IF NOT EXISTS`,存量库
|
|
846
|
+
* 下次 `ensureSchema` 就补建;MySQL 侧索引写在 `CREATE TABLE` 里,而本仓 schema 口径是「启动 DDL 是唯一
|
|
847
|
+
* 真源、不发 ALTER」⇒ **存量表拿不到这两个索引**,要靠一次删库重建(与 7.8.0 / 7.10.0 两次 BREAKING 窗
|
|
848
|
+
* 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
|
|
849
|
+
*/
|
|
850
|
+
export const RULE_REAP_BATCH = 500;
|
|
810
851
|
export function createSqlPermissionRuleStores(db, now) {
|
|
852
|
+
const q = (tidb, pg) => (db.dialect === "tidb" ? tidb : pg);
|
|
811
853
|
return {
|
|
812
854
|
provider: new SqlPermissionRuleStoreProvider(db, now),
|
|
813
855
|
approvals: new SqlRuleApprovalRecordStore(db, now),
|
|
814
856
|
tickets: new SqlRuleImportTicketStore(db, now),
|
|
857
|
+
reapExpired: async (nowMs) => {
|
|
858
|
+
// 两条 DELETE **不包事务**:它们互相独立、各自幂等,一条失败不该把另一条已删的行回滚回来
|
|
859
|
+
// (维护腿的既有姿势 —— 邻居 reaper 腿全是独立语句)。
|
|
860
|
+
//
|
|
861
|
+
// 🔴 **每 tick 有界**(codex 对抗复审 R2 [high],验真后修):第一版是两条**无界** DELETE。
|
|
862
|
+
// 首次开清扫(或一次长时间没跑的部署)会在**一个事务**里删掉整个积压 —— 一条能跑很久、锁很多行、
|
|
863
|
+
// 把 binlog/WAL 顶起来的语句;而调用点当时又没有重入守卫,一旦耗时越过 tick 间隔,下一轮就叠上来。
|
|
864
|
+
// 收成每轮 {@link RULE_REAP_BATCH} 行:积压按轮**渐进**清空(每轮都是完整语义,不留半干净状态),
|
|
865
|
+
// 单条语句的最坏时长与库大小脱钩。调用侧另配了 in-flight 守卫(`boot/reapers.ts`)。
|
|
866
|
+
const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= ? LIMIT ${RULE_REAP_BATCH}`,
|
|
867
|
+
// PG 的 DELETE 没有 LIMIT ⇒ 用 ctid 子查询限行(PG 侧的标准写法;`ctid` 是物理行号,
|
|
868
|
+
// 子查询里带 LIMIT 才是被支持的那一形)。两方言的**语义**相同:本轮最多删这么多行。
|
|
869
|
+
`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs]);
|
|
870
|
+
const orphanPending = await db.query(q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < ? LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_PENDING_APPROVAL_RETENTION_MS]);
|
|
871
|
+
return deadTickets.affected + orphanPending.affected;
|
|
872
|
+
},
|
|
815
873
|
// 两个方言逐字同形(`COUNT(*)` 无方言差),所以刻意**不**走 `q(tidb, pg)` 的双串姿势 —— 那会造出
|
|
816
874
|
// 两份可以各自漂的同一句 SQL。参数空数组:本语句没有绑定位。
|
|
817
875
|
countBuckets: async () => {
|
|
@@ -154,10 +154,15 @@ export declare class SqlSharedMemoryStore implements SharedMemoryStoreProvider {
|
|
|
154
154
|
signal?: AbortSignal;
|
|
155
155
|
}): Promise<SharedMemorySnapshot>;
|
|
156
156
|
/**
|
|
157
|
-
* 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
157
|
+
* 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)。
|
|
158
|
+
*
|
|
159
|
+
* 🔴 A-010.11(两臂不对称,验真后修):此前 PG 臂显式抬隔离级别、**MySQL 臂只靠服务端默认值**,
|
|
160
|
+
* 注里写的是「MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 天然同快照」。那句话对**默认配置**
|
|
161
|
+
* 为真,但 `transaction_isolation` 是可设的服务端/会话变量:一台跑 READ COMMITTED 的实例(托管
|
|
162
|
+
* MySQL 的厂商默认、中间层代理、运维手改)会把「一个事务一个快照」悄悄降成「每条语句各自取快照」,
|
|
163
|
+
* 而本方法**不会报任何错**——它只是读到一对撕裂的 scope/store 行。「靠默认值成立」不是结构保证。
|
|
164
|
+
* 两臂现在都走 {@link SqlTxConn.beginRepeatableRead}(语句文本与**摆放位置**的方言分歧写在那里:
|
|
165
|
+
* MySQL 必须在 BEGIN **之前**发、PG 必须在 BEGIN **之后**发)。
|
|
161
166
|
*/
|
|
162
167
|
private readBinding;
|
|
163
168
|
private readerFor;
|
|
@@ -194,12 +199,21 @@ export declare class SqlSharedMemoryStore implements SharedMemoryStoreProvider {
|
|
|
194
199
|
* 与插入之间穿过去,留下一行孤儿文档 —— 被删的内容在 id 复用时复活。
|
|
195
200
|
*/
|
|
196
201
|
putDocument(record: SharedMemoryDocumentRecord): Promise<void>;
|
|
197
|
-
/**
|
|
198
|
-
*
|
|
202
|
+
/**
|
|
203
|
+
* Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a
|
|
204
|
+
* successor tenant's library). Returns whether a row was actually there.
|
|
205
|
+
*
|
|
206
|
+
* 🔴 A-010.14(验真后修):此前围栏是**两条独立语句** —— `assertOwnedBy()` 先查一次登记表,然后另
|
|
207
|
+
* 起一条 DELETE。它自称与 `putDocument` 对齐,但 `putDocument` 的检查与写是**同事务同行锁**
|
|
208
|
+
* (`FOR UPDATE`),而这里的两条语句之间有一道真窗:登记检查通过之后、DELETE 发出之前,另一个 org
|
|
209
|
+
* 完成 `deleteStore` + `putStore` 的 id 复用,这条 DELETE 就落进**继任租户**的库里删掉他们的文档。
|
|
210
|
+
* 那正是 `putDocument` 的注里写明要挡住的那一形。现在逐字照它:同一事务、对登记行 `FOR UPDATE`、
|
|
211
|
+
* 拿到锁之后再删。
|
|
212
|
+
*
|
|
213
|
+
* 「未登记 ⇒ 响亮抛」与「登记了但没这条文档 ⇒ 回 false」两件事仍然分家(调用方据此分支),所以
|
|
214
|
+
* 不能收成一条 `DELETE … WHERE EXISTS(…)`:那样 `affected === 0` 会把两种结局压成同一个读数。
|
|
215
|
+
*/
|
|
199
216
|
deleteDocument(storeId: string, scopeKey: string, path: string): Promise<boolean>;
|
|
200
|
-
/** 属主围栏的共用断言(轮3 F1)。**不**覆盖同一个 org 内的"删了又建"代际重用——那不是跨租户面,
|
|
201
|
-
* 真要挡住需要一枚不可变的 generation token;这里如实说明边界,不假装它被覆盖了。 */
|
|
202
|
-
private assertOwnedBy;
|
|
203
217
|
/**
|
|
204
218
|
* De-register a store AND its documents, ATOMICALLY.
|
|
205
219
|
*
|
|
@@ -215,17 +215,20 @@ export class SqlSharedMemoryStore {
|
|
|
215
215
|
};
|
|
216
216
|
}
|
|
217
217
|
/**
|
|
218
|
-
* 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
218
|
+
* 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)。
|
|
219
|
+
*
|
|
220
|
+
* 🔴 A-010.11(两臂不对称,验真后修):此前 PG 臂显式抬隔离级别、**MySQL 臂只靠服务端默认值**,
|
|
221
|
+
* 注里写的是「MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 天然同快照」。那句话对**默认配置**
|
|
222
|
+
* 为真,但 `transaction_isolation` 是可设的服务端/会话变量:一台跑 READ COMMITTED 的实例(托管
|
|
223
|
+
* MySQL 的厂商默认、中间层代理、运维手改)会把「一个事务一个快照」悄悄降成「每条语句各自取快照」,
|
|
224
|
+
* 而本方法**不会报任何错**——它只是读到一对撕裂的 scope/store 行。「靠默认值成立」不是结构保证。
|
|
225
|
+
* 两臂现在都走 {@link SqlTxConn.beginRepeatableRead}(语句文本与**摆放位置**的方言分歧写在那里:
|
|
226
|
+
* MySQL 必须在 BEGIN **之前**发、PG 必须在 BEGIN **之后**发)。
|
|
222
227
|
*/
|
|
223
228
|
async readBinding(scopes) {
|
|
224
229
|
const conn = await this.db.connect();
|
|
225
230
|
try {
|
|
226
|
-
await conn.
|
|
227
|
-
if (this.db.dialect === "pg")
|
|
228
|
-
await conn.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
|
|
231
|
+
await conn.beginRepeatableRead();
|
|
229
232
|
const scopeRows = await conn.query(this.q(`SELECT scope_key, state, message FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")})`, `SELECT scope_key, state, message FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${i + 1}`).join(", ")})`), [...scopes]);
|
|
230
233
|
const storeRows = await conn.query(this.q(`SELECT store_id, scope_key, description, writable FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")}) ORDER BY store_id`, `SELECT store_id, scope_key, description, writable FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${i + 1}`).join(", ")}) ORDER BY store_id`), [...scopes]);
|
|
231
234
|
await conn.commit();
|
|
@@ -444,23 +447,57 @@ export class SqlSharedMemoryStore {
|
|
|
444
447
|
throw new Error(`shared-memory store ${JSON.stringify(record.storeId)} is not registered under ${JSON.stringify(record.scopeKey)} — either it was never created, or the id has since been recycled by another org (call putStore() first; a stale retry must NOT land in the new tenant's library)`);
|
|
445
448
|
}
|
|
446
449
|
}
|
|
447
|
-
/**
|
|
448
|
-
*
|
|
450
|
+
/**
|
|
451
|
+
* Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a
|
|
452
|
+
* successor tenant's library). Returns whether a row was actually there.
|
|
453
|
+
*
|
|
454
|
+
* 🔴 A-010.14(验真后修):此前围栏是**两条独立语句** —— `assertOwnedBy()` 先查一次登记表,然后另
|
|
455
|
+
* 起一条 DELETE。它自称与 `putDocument` 对齐,但 `putDocument` 的检查与写是**同事务同行锁**
|
|
456
|
+
* (`FOR UPDATE`),而这里的两条语句之间有一道真窗:登记检查通过之后、DELETE 发出之前,另一个 org
|
|
457
|
+
* 完成 `deleteStore` + `putStore` 的 id 复用,这条 DELETE 就落进**继任租户**的库里删掉他们的文档。
|
|
458
|
+
* 那正是 `putDocument` 的注里写明要挡住的那一形。现在逐字照它:同一事务、对登记行 `FOR UPDATE`、
|
|
459
|
+
* 拿到锁之后再删。
|
|
460
|
+
*
|
|
461
|
+
* 「未登记 ⇒ 响亮抛」与「登记了但没这条文档 ⇒ 回 false」两件事仍然分家(调用方据此分支),所以
|
|
462
|
+
* 不能收成一条 `DELETE … WHERE EXISTS(…)`:那样 `affected === 0` 会把两种结局压成同一个读数。
|
|
463
|
+
*/
|
|
449
464
|
async deleteDocument(storeId, scopeKey, path) {
|
|
450
|
-
await this.assertOwnedBy(storeId, scopeKey);
|
|
451
|
-
const res = await this.db.query(this.q(`DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = ? AND path = ?`, `DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = $1 AND path = $2`), [storeId, path]);
|
|
452
|
-
return res.affected > 0;
|
|
453
|
-
}
|
|
454
|
-
/** 属主围栏的共用断言(轮3 F1)。**不**覆盖同一个 org 内的"删了又建"代际重用——那不是跨租户面,
|
|
455
|
-
* 真要挡住需要一枚不可变的 generation token;这里如实说明边界,不假装它被覆盖了。 */
|
|
456
|
-
async assertOwnedBy(storeId, scopeKey) {
|
|
457
465
|
assertStoreId(storeId);
|
|
458
466
|
assertScopeKey(scopeKey);
|
|
459
|
-
|
|
460
|
-
|
|
467
|
+
let unregistered = false;
|
|
468
|
+
let deleted = false;
|
|
469
|
+
const conn = await this.db.connect();
|
|
470
|
+
try {
|
|
471
|
+
await conn.begin(); // 为什么不是 beginPessimistic:见本类头注的"事务与锁"段(与 putDocument 同判)
|
|
472
|
+
const registered = await conn.query(this.q(`SELECT store_id FROM ${SHARED_MEMORY_STORE_TABLE} WHERE store_id = ? AND scope_key = ? FOR UPDATE`, `SELECT store_id FROM ${SHARED_MEMORY_STORE_TABLE} WHERE store_id = $1 AND scope_key = $2 FOR UPDATE`), [storeId, scopeKey]);
|
|
473
|
+
if (registered.rows.length === 0) {
|
|
474
|
+
unregistered = true;
|
|
475
|
+
await conn.rollback();
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
const res = await conn.query(this.q(`DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = ? AND path = ?`, `DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = $1 AND path = $2`), [storeId, path]);
|
|
479
|
+
deleted = res.affected > 0;
|
|
480
|
+
await conn.commit();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
await rollbackPreservingError(conn, err);
|
|
485
|
+
throw err;
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
conn.release();
|
|
489
|
+
}
|
|
490
|
+
if (unregistered) {
|
|
491
|
+
// 文案逐字同 assertOwnedBy(调用方/测试按它对表);围栏的**执行面**改了,拒绝的**说法**没改。
|
|
461
492
|
throw new Error(`shared-memory store ${JSON.stringify(storeId)} is not registered under ${JSON.stringify(scopeKey)} — either it does not exist, or the id has since been recycled by another org`);
|
|
462
493
|
}
|
|
494
|
+
return deleted;
|
|
463
495
|
}
|
|
496
|
+
// 属主围栏的共用断言 `assertOwnedBy()` 曾住在这里(轮3 F1)。A-010.14 把它**唯一**的消费者
|
|
497
|
+
// (`deleteDocument`)改成「同事务 + FOR UPDATE」之后它就没有调用点了 —— 留着一个自带围栏语义却
|
|
498
|
+
// 无人调用的私有方法,下一个人会以为「围栏在这儿,照着调就行」,而它恰恰是被判定为不够强的那一版。
|
|
499
|
+
// 边界的如实说明随之搬进 `deleteDocument` / `putDocument`:两者**都不**覆盖同一个 org 内的
|
|
500
|
+
// "删了又建"代际重用 —— 那不是跨租户面,真要挡住需要一枚不可变的 generation token。
|
|
464
501
|
/**
|
|
465
502
|
* De-register a store AND its documents, ATOMICALLY.
|
|
466
503
|
*
|
|
@@ -91,6 +91,25 @@ export interface SqlTxConn extends SqlExec {
|
|
|
91
91
|
begin(): Promise<void>;
|
|
92
92
|
/** TiDB: `BEGIN PESSIMISTIC` (current-read txn). PG: plain `BEGIN`. */
|
|
93
93
|
beginPessimistic(): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Open a transaction whose reads are pinned to ONE snapshot (A-010.11).
|
|
96
|
+
*
|
|
97
|
+
* 🔴 Why this is a named verb and not "just `begin()` — MySQL defaults to REPEATABLE READ anyway":
|
|
98
|
+
* a *server default* is not a structural guarantee. `transaction_isolation` is a settable
|
|
99
|
+
* server/session variable; a deployment (or a proxy, or a managed-MySQL vendor default) that runs
|
|
100
|
+
* READ COMMITTED silently turns "one transaction one snapshot" into "each statement its own
|
|
101
|
+
* snapshot" — and the caller that relied on it (`shared-memory-store-sql.ts` readBinding) goes on
|
|
102
|
+
* reading a torn scope/store pair with no error anywhere. The PG arm was already explicit; the
|
|
103
|
+
* MySQL arm was leaning on the default. Both arms now PIN it.
|
|
104
|
+
*
|
|
105
|
+
* The two arms diverge in WHERE the statement goes, and that is not cosmetic:
|
|
106
|
+
* · MySQL/TiDB — `SET TRANSACTION ISOLATION LEVEL …` with no scope keyword applies to the **next**
|
|
107
|
+
* transaction, and issuing it *inside* an open transaction is an ERROR
|
|
108
|
+
* (`ER_CANT_CHANGE_TX_CHARACTERISTICS`). So it must precede `beginTransaction()`.
|
|
109
|
+
* · PG — the same statement must be issued *inside* the transaction, before its first query.
|
|
110
|
+
* Writing one "portable" form would be wrong on one of the two engines; hence one verb, two texts.
|
|
111
|
+
*/
|
|
112
|
+
beginRepeatableRead(): Promise<void>;
|
|
94
113
|
commit(): Promise<void>;
|
|
95
114
|
rollback(): Promise<void>;
|
|
96
115
|
release(): void;
|
|
@@ -24,6 +24,12 @@ export function mysqlDriver(pool) {
|
|
|
24
24
|
beginPessimistic: async () => {
|
|
25
25
|
await c.query("BEGIN PESSIMISTIC");
|
|
26
26
|
},
|
|
27
|
+
// Scope-less `SET TRANSACTION …` = next-transaction only, so it goes BEFORE the BEGIN and
|
|
28
|
+
// leaves the pooled connection's session default untouched for whoever gets it next.
|
|
29
|
+
beginRepeatableRead: async () => {
|
|
30
|
+
await c.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
|
|
31
|
+
await c.beginTransaction();
|
|
32
|
+
},
|
|
27
33
|
commit: () => c.commit(),
|
|
28
34
|
rollback: () => c.rollback(),
|
|
29
35
|
release: () => c.release(),
|
|
@@ -50,6 +56,12 @@ export function pgDriver(pool) {
|
|
|
50
56
|
beginPessimistic: async () => {
|
|
51
57
|
await c.query("BEGIN");
|
|
52
58
|
},
|
|
59
|
+
// PG takes it INSIDE the transaction (and only before its first query) — the mirror image of
|
|
60
|
+
// the MySQL arm above. Same verb, deliberately different placement.
|
|
61
|
+
beginRepeatableRead: async () => {
|
|
62
|
+
await c.query("BEGIN");
|
|
63
|
+
await c.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
|
|
64
|
+
},
|
|
53
65
|
commit: async () => {
|
|
54
66
|
await c.query("COMMIT");
|
|
55
67
|
},
|
|
@@ -93,7 +93,9 @@ export type ToolResultStoreFull = ToolResultStore & {
|
|
|
93
93
|
/** TTL sweep (SQL twins only): purge rows older than the cutoff. Local file store omits it (CC posture:
|
|
94
94
|
* a single user's tool-result files persist like transcripts; bounded by being text previews). */
|
|
95
95
|
reapOlderThan?(cutoffMs: number): Promise<number>;
|
|
96
|
-
/** E21 purge (SQL twins only): delete
|
|
96
|
+
/** E21 purge (SQL twins only): delete this session's refs when a session is purged. core 5.26.0 (#119)
|
|
97
|
+
* changed the mint from `tr_<sid>_<call>` to `tr_<sid>~<call>~<content>`, so the twin matches BOTH prefixes —
|
|
98
|
+
* see `deleteBySession` in tool-result-store-sql.ts for why dropping either one is a silent purge failure. */
|
|
97
99
|
deleteBySession?(sessionId: string): Promise<number>;
|
|
98
100
|
};
|
|
99
101
|
/** Cross-replica counter twins expose the write-behind lifecycle (startRefresh/stop) main.ts drives. */
|
|
@@ -222,7 +222,30 @@ class LocalBackend {
|
|
|
222
222
|
// adoptLocalDataRoot 到完成,或修 adoption.json」的出路),我们没有更好的话可说,包一层只会更差。
|
|
223
223
|
// 包装只留给真正的 boot-lock 类失败(那句文案的适用条件)。
|
|
224
224
|
try {
|
|
225
|
-
this.fileBackend = new FileStorageBackend({
|
|
225
|
+
this.fileBackend = new FileStorageBackend({
|
|
226
|
+
root,
|
|
227
|
+
...(config.rewindSnapshotMaxMb !== undefined ? { snapshotBounds: { maxBytes: Math.round(config.rewindSnapshotMaxMb * 1024 * 1024) } } : {}), // REWIND_SNAPSHOT_MAX_MB
|
|
228
|
+
// 🔴 腐读披露座(交接件④,与 `run-local.ts` 的同座同形、同 warn 名 `file_store_corrupt_read`)。
|
|
229
|
+
// core 对**读不出来的**持久文件是 documented fail-open —— 当作「缺席」继续跑(抛会让整条任务死,
|
|
230
|
+
// 也会自锁修复写)。这条 fail-open 是 core 的裁定,本仓不改它;但它**不许无声**(#157 安全轴纪律)。
|
|
231
|
+
// 此前 HTTP 服务的 local 车道**根本没接这个座**:同一份坏字节,run-local 上打一行、服务上一个字
|
|
232
|
+
// 都没有 —— 而服务形恰恰是没人盯着终端的那一个。
|
|
233
|
+
//
|
|
234
|
+
// ⚠️ 文案按脸分列后果、**不下统一结论**(run-local 那次 codex R2-medium 的教训一并吸收):一个座
|
|
235
|
+
// 覆盖 core 转发的三张脸,后果各不相同 —— 把「少了会话规则这一层」写成「整个任务无约束」会误导
|
|
236
|
+
// 事故定级,而且对另外两张脸根本不成立。共同事实只有一句:一次持久读被当成了缺席。
|
|
237
|
+
// ENOENT 不触发(那是真缺席,不是腐读)。
|
|
238
|
+
onCorruptRead: (info) => this.onWarn?.("file_store_corrupt_read", {
|
|
239
|
+
path: info.path,
|
|
240
|
+
reason: info.reason,
|
|
241
|
+
...(info.sessionId !== undefined ? { sessionId: info.sessionId } : {}),
|
|
242
|
+
...(info.principal !== undefined ? { principal: info.principal } : {}),
|
|
243
|
+
note: "a durable read was treated as ABSENT because the bytes were unreadable (never a plain ENOENT). " +
|
|
244
|
+
"Consequence depends on which store read it: session-policy = that run lost its SUBTRACTIVE session-rule " +
|
|
245
|
+
"layer (deployment policy / hooks / shell gate still applied); session repo = a listing silently omitted " +
|
|
246
|
+
"rows; file-snapshot = a snapshot or scope read as missing. Inspect the named path before deciding.",
|
|
247
|
+
}),
|
|
248
|
+
});
|
|
226
249
|
}
|
|
227
250
|
catch (e) {
|
|
228
251
|
if (e instanceof AdoptionError)
|