@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.
- package/USAGE.md +20 -5
- package/dist/boot/governance-seams.d.ts +13 -0
- package/dist/boot/governance-seams.js +21 -1
- package/dist/boot/retention-lane.d.ts +206 -0
- package/dist/boot/retention-lane.js +279 -0
- package/dist/boot/shutdown.d.ts +8 -0
- package/dist/boot/shutdown.js +21 -2
- package/dist/config-types.d.ts +22 -1
- package/dist/config-types.js +6 -1
- package/dist/config.d.ts +15 -1
- package/dist/config.js +43 -2
- package/dist/http/routes/capabilities.js +30 -0
- package/dist/http/routes/memory-policy.d.ts +13 -0
- package/dist/http/routes/memory-policy.js +79 -2
- package/dist/http/routes/retention-ops.d.ts +36 -0
- package/dist/http/routes/retention-ops.js +190 -0
- package/dist/http/server.js +14 -0
- package/dist/main.js +74 -0
- package/dist/memory-scope.d.ts +5 -3
- package/dist/memory-scope.js +6 -8
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/plugins/caching-session-store.d.ts +11 -1
- package/dist/plugins/caching-session-store.js +13 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +3 -0
- package/dist/plugins/checkpoint-store-sql.js +4 -0
- package/dist/plugins/local-checkpoint-store.d.ts +3 -0
- package/dist/plugins/local-checkpoint-store.js +4 -0
- package/dist/plugins/local-session-store.d.ts +5 -0
- package/dist/plugins/local-session-store.js +6 -0
- package/dist/plugins/memory-engine-pg.js +22 -10
- package/dist/plugins/memory-engine-tidb.js +22 -11
- package/dist/plugins/memory-key-guards.d.ts +27 -4
- package/dist/plugins/memory-key-guards.js +57 -6
- package/dist/plugins/memory-sync-store-pg.js +5 -0
- package/dist/plugins/memory-sync-store-tidb.js +5 -0
- package/dist/plugins/pg-pool.js +4 -0
- package/dist/plugins/pg-session-storage.d.ts +2 -0
- package/dist/plugins/pg-session-storage.js +3 -0
- package/dist/plugins/retention-lane-store-sql.d.ts +254 -0
- package/dist/plugins/retention-lane-store-sql.js +236 -0
- package/dist/plugins/retention-store-sql.d.ts +468 -0
- package/dist/plugins/retention-store-sql.js +793 -0
- package/dist/plugins/store-backend.d.ts +20 -0
- package/dist/plugins/store-backend.js +7 -0
- package/dist/plugins/tidb-pool.js +5 -0
- package/dist/plugins/tidb-session-store.d.ts +3 -0
- package/dist/plugins/tidb-session-store.js +4 -0
- package/dist/plugins/tool-result-store-sql.d.ts +3 -0
- package/dist/plugins/tool-result-store-sql.js +4 -0
- package/dist/run-local.js +16 -0
- package/package.json +1 -1
package/dist/http/server.js
CHANGED
|
@@ -38,6 +38,7 @@ import { handleCapabilities } from "./routes/capabilities.js";
|
|
|
38
38
|
import { handleObservability } from "./routes/observability.js";
|
|
39
39
|
import { handleDiagnostics } from "./routes/diagnostics.js";
|
|
40
40
|
import { handleAdoption } from "./routes/adoption.js";
|
|
41
|
+
import { handleRetention } from "./routes/retention-ops.js";
|
|
41
42
|
import { handleMemoryPolicy } from "./routes/memory-policy.js";
|
|
42
43
|
import { handleSharedMemory } from "./routes/shared-memory.js";
|
|
43
44
|
import { handleRules } from "./routes/rules.js";
|
|
@@ -209,6 +210,7 @@ const ROUTE_DOMAINS = [
|
|
|
209
210
|
handleObservability,
|
|
210
211
|
handleDiagnostics,
|
|
211
212
|
handleAdoption,
|
|
213
|
+
handleRetention,
|
|
212
214
|
handleMemoryPolicy,
|
|
213
215
|
handleRules,
|
|
214
216
|
handleSharedMemory,
|
|
@@ -693,6 +695,11 @@ export function createHttpServer(rawDeps) {
|
|
|
693
695
|
// design/183 §4.3:收编面(routes/adoption.ts,operator lane;POST /v1/adoption + GET /v1/adoption/:id)。
|
|
694
696
|
if (await handleAdoption(req, res, url, ctx))
|
|
695
697
|
return;
|
|
698
|
+
// #270 车2:托管留存的 operator 治理面(routes/retention-ops.ts,operator lane;hold PUT/DELETE +
|
|
699
|
+
// 审计读)。位置跟着 adoption —— 同族(部署级 operator 动作、同一条 `explicitOperatorOk` 门、
|
|
700
|
+
// 同样 billable=false 且刻意不吃 drain / model-roster-pending 两道 503)。
|
|
701
|
+
if (await handleRetention(req, res, url, ctx))
|
|
702
|
+
return;
|
|
696
703
|
// design/158 A9:memory 导出/同步 + /v1/policy 只读面(routes/memory-policy.ts)。
|
|
697
704
|
if (await handleMemoryPolicy(req, res, url, ctx))
|
|
698
705
|
return;
|
|
@@ -3275,6 +3282,9 @@ const ROUTE_LABEL_PATTERNS = [
|
|
|
3275
3282
|
[/^\/v1\/attachments\/[^/]+$/, "/v1/attachments/:id"],
|
|
3276
3283
|
[/^\/v1\/capabilities\/scenarios\/[^/]+$/, "/v1/capabilities/scenarios/:name"],
|
|
3277
3284
|
[/^\/v1\/adoption\/[^/]+$/, "/v1/adoption/:id"],
|
|
3285
|
+
// #270 车2:留存 legal-hold 的放置/解除。`*` 不是 `+` —— 空段 = 无主桶(单用户部署里它是唯一的域;
|
|
3286
|
+
// 理由逐字见 routes/retention-ops.ts 的「域的两个边界」)。
|
|
3287
|
+
[/^\/v1\/ops\/retention\/holds\/[^/]*$/, "/v1/ops/retention/holds/:domain"],
|
|
3278
3288
|
[/^\/v1\/memory\/sync\/[^/]+$/, "/v1/memory/sync/:scope"],
|
|
3279
3289
|
[/^\/v1\/usage\/(summary|series|breakdown)$/, "/v1/usage/$sub"],
|
|
3280
3290
|
// design/177 共享记忆库只读面。两条各自成桶(列举是窗口读、正文是单文档读,延迟分布不同族)。
|
|
@@ -3316,6 +3326,10 @@ const ROUTE_LABEL_LITERALS = new Set([
|
|
|
3316
3326
|
// #A1([4147] 件3):后台 agent 名册读面。壳每次开面板打一次,是**每用户高频**的租户读 ——
|
|
3317
3327
|
// 落进 `other` 桶会把它的 duration 和 4xx 混进一堆低频运维口里。同上,写字面量不引常量。
|
|
3318
3328
|
"/v1/agents/roster",
|
|
3329
|
+
// #270 车2:留存审计读面(operator-only)。低频,但它是「留存到底删没删、按哪档删的」的唯一读口
|
|
3330
|
+
// —— 落进 `other` 桶等于合规排障现场看不见它。同上,写**字面量**不引常量(名册门与 billable
|
|
3331
|
+
// 申明门都扫源码文本)。
|
|
3332
|
+
"/v1/ops/retention/audit",
|
|
3319
3333
|
]);
|
|
3320
3334
|
/** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
|
|
3321
3335
|
* [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
|
package/dist/main.js
CHANGED
|
@@ -43,6 +43,7 @@ import { createConfigCenterRuntime } from "./boot/config-center.js";
|
|
|
43
43
|
import { createResolveSpec } from "./boot/resolve-spec.js";
|
|
44
44
|
import { createParkedReviveInheritedGate } from "./boot/parked-revive-gate.js";
|
|
45
45
|
import { startReapers } from "./boot/reapers.js";
|
|
46
|
+
import { assertRetentionLaneWirable, startRetentionLane, RETENTION_LEASE_TTL_FACTOR } from "./boot/retention-lane.js"; // #270 车2
|
|
46
47
|
import { startFleetReconciler } from "./fleet/fleet-reconciler.js";
|
|
47
48
|
import { openStores } from "./boot/stores.js";
|
|
48
49
|
import { runAdoptionBootScan } from "./boot/adoption.js";
|
|
@@ -279,6 +280,31 @@ async function main() {
|
|
|
279
280
|
// design/170 件B/C/D(#252 件1):三个部署治理座席 + 两条拒启判据(锁的装配相容性、留存能力校验)。
|
|
280
281
|
// ⚠️ 位置即契约:必须在 store 三件都构造完之后(件D 校验读它们的 `retention` 声明)、`new Runner` 之前
|
|
281
282
|
// (座席进 RunnerDeps);拒启在这里发生 = 还没开始服务。
|
|
283
|
+
// #270 —— 托管留存的**执行面装配**(设计稿 §7)。⚠️ 位置即契约:必须在 `createGovernanceSeams` 之前
|
|
284
|
+
// (件① 的 `retentionExecutorWired` 是它的入参),也必须在 store 三件构造完之后(自持门要读它们的
|
|
285
|
+
// 声明位)。两只店都是 **SQL 后端专有**,`local`/无 backend 上恒 undefined —— 那正是 lane 自持门
|
|
286
|
+
// 在 `RETENTION_SWEEP_INTERVAL_SEC>0` 时拒启的那一支。
|
|
287
|
+
//
|
|
288
|
+
// `taskAttachmentTable` 如实告知(车1 交接件③):它是「boot 真的 ensure 过那张表吗」的事实,
|
|
289
|
+
// 不是一个默认值 —— 无对象存储的部署上附件面整段不接线、表不建,一条盲发的 DELETE 会以
|
|
290
|
+
// unknown-table 打红整轮 sweep(理由逐字见 retention-store-sql.ts 的 `SqlRetentionStoreOptions`)。
|
|
291
|
+
const retentionExecutor = backend?.retention?.({ taskAttachmentTable: taskAttachmentStore !== undefined, logger });
|
|
292
|
+
const retentionLaneStore = backend?.retentionLane?.();
|
|
293
|
+
// lane **自持**的 boot 不变式(件④;codex F3)——与下面 core 的 `assertRetentionCapability` 是两道门,
|
|
294
|
+
// 各答各的问题,本道**不看 locked**。判据与三条拒启臂逐字见 `boot/retention-lane.ts`。
|
|
295
|
+
assertRetentionLaneWirable({
|
|
296
|
+
intervalSec: config.retentionSweep.intervalSec,
|
|
297
|
+
policy: config.retentionPolicy,
|
|
298
|
+
executor: retentionExecutor,
|
|
299
|
+
// 与件D 校验**同一份**店清单(下面 `retentionStores` 那三行的同源读点):两道门看的是同一批真身,
|
|
300
|
+
// 一门放行另一门拒的分歧才有意义,而"看的不是同一批店"造成的分歧只是 bug。
|
|
301
|
+
stores: [
|
|
302
|
+
{ name: "sessionStore", store: sessionStore },
|
|
303
|
+
{ name: "checkpointStore", store: checkpointStore },
|
|
304
|
+
{ name: "toolResultStore", store: runnerOffloadStore },
|
|
305
|
+
],
|
|
306
|
+
locked: (config.lockedConfigKeys ?? []).includes("retentionPolicy"),
|
|
307
|
+
});
|
|
282
308
|
const governanceSeams = createGovernanceSeams({
|
|
283
309
|
config,
|
|
284
310
|
logger,
|
|
@@ -292,6 +318,11 @@ async function main() {
|
|
|
292
318
|
// 喂错实例 = 校验漏掉了真正在跑的那一个,正是「校验读的不是真身」这类假绿。
|
|
293
319
|
{ name: "toolResultStore", store: runnerOffloadStore },
|
|
294
320
|
],
|
|
321
|
+
// 🔴 件①(车1 交接件,codex R2-[critical] 的修复面):**本 build 真的接了执行器吗**。
|
|
322
|
+
// core 的 `assertRetentionCapability` 只读店的声明位,而三只 SQL 店从车1 起就诚实地声明了
|
|
323
|
+
// `"managed"` —— 于是那道门会为一台**没有任何东西在删**的机器放行 locked policy。这一位问的是
|
|
324
|
+
// 另一个问题:有没有东西真会删。合取式逐字 = 上面那道自持门放行之后 lane 真的会起的条件。
|
|
325
|
+
retentionExecutorWired: config.retentionSweep.intervalSec > 0 && retentionExecutor !== undefined && retentionLaneStore !== undefined,
|
|
295
326
|
});
|
|
296
327
|
// design/158 A10:RunnerDeps 装配段搬到 src/boot/runner-deps.ts(逐字;runStore 晚绑改取值,见该文件头注)。
|
|
297
328
|
const runnerDeps = createRunnerDeps({
|
|
@@ -848,6 +879,46 @@ async function main() {
|
|
|
848
879
|
backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores,
|
|
849
880
|
getRunDenySweep: () => runDenySweep,
|
|
850
881
|
});
|
|
882
|
+
// #270 车2 —— 托管留存 sweep lane(**独立**定时器,不折进上面那条维护 tick;理由逐字见
|
|
883
|
+
// `boot/retention-lane.ts` 的文件头:那二十条腿全都容忍多副本,而本 lane 必须单执行者)。
|
|
884
|
+
// 两只店在场性已由上面的自持门保证(interval>0 而店缺席 ⇒ 那道门已经拒启了);这里的
|
|
885
|
+
// `&&` 只是把类型收窄出来,不是第二道门。
|
|
886
|
+
/** 租约 TTL = 2× sweep 间隔(§3)。抢租与每 domain 的续租共用同一个值 —— 两处手抄迟早只改一处。 */
|
|
887
|
+
const ttlMs = config.retentionSweep.intervalSec * 1000 * RETENTION_LEASE_TTL_FACTOR;
|
|
888
|
+
const retentionLaneStarted = retentionExecutor !== undefined && retentionLaneStore !== undefined && config.retentionPolicy !== undefined
|
|
889
|
+
? startRetentionLane({
|
|
890
|
+
intervalSec: config.retentionSweep.intervalSec,
|
|
891
|
+
policy: config.retentionPolicy,
|
|
892
|
+
mode: config.retentionSweep.mode,
|
|
893
|
+
executor: retentionExecutor,
|
|
894
|
+
// 租约 holder = 本副本的 `instanceId`(与 run 行的 claim 同一个身份):审计行里的 holder/token
|
|
895
|
+
// 于是能直接对上"哪台副本跑的那一轮"。
|
|
896
|
+
lease: {
|
|
897
|
+
acquire: async () => {
|
|
898
|
+
const claim = await retentionLaneStore.acquire(instanceId, ttlMs, Date.now());
|
|
899
|
+
return claim.held ? { held: true, fencingToken: claim.fencingToken } : { held: false };
|
|
900
|
+
},
|
|
901
|
+
// 复核**并续租**(一条 CAS)——续租的机会只有这一处:重入守卫会跳过下一 tick(理由逐字见
|
|
902
|
+
// `boot/retention-lane.ts` 的 `startRetentionLane` 头注与店侧 `renew` 的顶注)。
|
|
903
|
+
renew: (fencingToken) => retentionLaneStore.renew(instanceId, fencingToken, ttlMs, Date.now()),
|
|
904
|
+
release: () => retentionLaneStore.release(instanceId),
|
|
905
|
+
},
|
|
906
|
+
audit: { append: (row) => retentionLaneStore.appendAudit(row, Date.now()) },
|
|
907
|
+
// 省调预检(§5 v1.3:**不承重** —— 承重判在店事务内的哨兵行锁上)。
|
|
908
|
+
holdInForce: (domain) => retentionLaneStore.holdInForce(domain),
|
|
909
|
+
logger,
|
|
910
|
+
metrics,
|
|
911
|
+
now: () => Date.now(),
|
|
912
|
+
})
|
|
913
|
+
: undefined;
|
|
914
|
+
if (retentionLaneStarted !== undefined) {
|
|
915
|
+
logger.info("retention_lane_started", {
|
|
916
|
+
intervalSec: config.retentionSweep.intervalSec,
|
|
917
|
+
mode: config.retentionSweep.mode,
|
|
918
|
+
maxAgeDays: config.retentionPolicy?.maxAgeDays,
|
|
919
|
+
holder: instanceId,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
851
922
|
// Optional OTLP/HTTP metrics export (1.37). Periodically pushes the registry to an OTel collector;
|
|
852
923
|
// best-effort (a collector outage is logged, never affects serving). /metrics stays available too.
|
|
853
924
|
const otelExporter = config.otel
|
|
@@ -1267,6 +1338,9 @@ async function main() {
|
|
|
1267
1338
|
config, logger, server, reaper, fleetReconcile, otelExporter, breakerState, costQuota, rateLimiter,
|
|
1268
1339
|
runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState,
|
|
1269
1340
|
storeLiveProbe, configCenter, // #131-2:两个漏网的进程级后台环进收尾链
|
|
1341
|
+
// #270 车2:留存 lane 的定时器 + 主动让租(收尾契约 3 同族,理由见 shutdown.ts 的两条注)。
|
|
1342
|
+
retentionLane: retentionLaneStarted,
|
|
1343
|
+
releaseRetentionLease: retentionLaneStore !== undefined ? () => retentionLaneStore.release(instanceId) : undefined,
|
|
1270
1344
|
});
|
|
1271
1345
|
}
|
|
1272
1346
|
void main().catch((err) => {
|
package/dist/memory-scope.d.ts
CHANGED
|
@@ -24,9 +24,11 @@ export declare function isV2ScopeKey(key: string): boolean;
|
|
|
24
24
|
* tenant-isolated backend exists, multi-tenant memory is off by construction. */
|
|
25
25
|
/** R5(批γ):memory scope 列宽(两方言 VARCHAR(190) 同宽)。principal 上限 190 **字符**在
|
|
26
26
|
* security.ts 把身份轴守住了,但 `formatUserScope`/`formatProjScope` 的段编码(百分号转义)会
|
|
27
|
-
* **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
* **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。
|
|
28
|
+
*
|
|
29
|
+
* #271 件1:定义**搬去** `plugins/memory-key-guards.ts`(列宽与 store 写口同源单一属主),此处
|
|
30
|
+
* 只再导出——旧 import 路径(本文件)逐字不变,`memoryScopeFor` 的产出处消费点也不变。 */
|
|
31
|
+
export { MEMORY_SCOPE_COLUMN_CHARS, assertMemoryScopeWidth } from "./plugins/memory-key-guards.js";
|
|
30
32
|
export declare function memoryScopeFor(config: ServiceConfig, principal?: string, projectId?: string): string | undefined;
|
|
31
33
|
/**
|
|
32
34
|
* N0 (通宵测试 2026-07-09,三重坐实 e2b+kata+代码): the file memory engine (core design/138) materializes and
|
package/dist/memory-scope.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// "exports" only ever exposed "." and "./main" — dist/security.js was never a reachable deep-import path).
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { FileMemoryEngineBackend, resolveMemoryEngineRoot, deriveControlPlaneDir, formatUserScope, formatProjScope } from "@sema-agent/core";
|
|
8
|
+
import { assertMemoryScopeWidth } from "./plugins/memory-key-guards.js"; // #271 件1:列宽单一属主(本文件再导出)
|
|
8
9
|
/**
|
|
9
10
|
* design/142 §1.2 的 v2 typed scope-key 前缀表 —— **单一属主**(A-002.4:`config.ts` 的
|
|
10
11
|
* MEMORY_SYNC_SCOPE 派生处曾有一份逐字副本,两处咬合用途不同[盖 v2 契约标 vs 判要不要再包一层
|
|
@@ -46,14 +47,11 @@ export function isV2ScopeKey(key) {
|
|
|
46
47
|
* tenant-isolated backend exists, multi-tenant memory is off by construction. */
|
|
47
48
|
/** R5(批γ):memory scope 列宽(两方言 VARCHAR(190) 同宽)。principal 上限 190 **字符**在
|
|
48
49
|
* security.ts 把身份轴守住了,但 `formatUserScope`/`formatProjScope` 的段编码(百分号转义)会
|
|
49
|
-
* **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
`both SQL dialects pin); got ${scope.length} — use a shorter principal/projectId (non-ASCII characters expand ~9x when encoded)`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
50
|
+
* **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。
|
|
51
|
+
*
|
|
52
|
+
* #271 件1:定义**搬去** `plugins/memory-key-guards.ts`(列宽与 store 写口同源单一属主),此处
|
|
53
|
+
* 只再导出——旧 import 路径(本文件)逐字不变,`memoryScopeFor` 的产出处消费点也不变。 */
|
|
54
|
+
export { MEMORY_SCOPE_COLUMN_CHARS, assertMemoryScopeWidth } from "./plugins/memory-key-guards.js";
|
|
57
55
|
export function memoryScopeFor(config, principal, projectId) {
|
|
58
56
|
if (config.requirePrincipal === true) {
|
|
59
57
|
// S3-TOB(设计 §1.3):multi-tenant lights up ONLY on a DB backend (scope column = tenant
|
|
@@ -57,6 +57,10 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
57
57
|
readonly cls: "F";
|
|
58
58
|
readonly note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。";
|
|
59
59
|
};
|
|
60
|
+
readonly "server.retention.sweep-report-dropped": {
|
|
61
|
+
readonly cls: "F";
|
|
62
|
+
readonly note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。";
|
|
63
|
+
};
|
|
60
64
|
readonly "server.parked-revive.ancestor-classifier-unreachable": {
|
|
61
65
|
readonly cls: "P-DEBT";
|
|
62
66
|
readonly note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。";
|
|
@@ -80,6 +80,10 @@ export const FAIL_OPEN_TAGS = {
|
|
|
80
80
|
cls: "F",
|
|
81
81
|
note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。",
|
|
82
82
|
},
|
|
83
|
+
"server.retention.sweep-report-dropped": {
|
|
84
|
+
cls: "F",
|
|
85
|
+
note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。",
|
|
86
|
+
},
|
|
83
87
|
"server.parked-revive.ancestor-classifier-unreachable": {
|
|
84
88
|
cls: "P-DEBT",
|
|
85
89
|
note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AcquiredSession, SessionStore, SessionTreeEntry } from "@sema-agent/core";
|
|
1
|
+
import type { AcquiredSession, RetentionDeclaration, SessionStore, SessionTreeEntry } from "@sema-agent/core";
|
|
2
2
|
import type { SessionListItem } from "../security.js";
|
|
3
3
|
import type { StagingHandle } from "../session-sync-kernel.js";
|
|
4
4
|
export interface CachingOptions {
|
|
@@ -94,6 +94,16 @@ export declare class CachingSessionStore implements SessionStore {
|
|
|
94
94
|
/** 2c session-sync (§7/§8) — delegated IDEMPOTENT replace; like importEntries it writes durable state under
|
|
95
95
|
* `sessionId`, so a stale warm handle for that id is evicted on success (next acquire re-wakes from the new log). */
|
|
96
96
|
readonly replaceEntries?: (sessionId: string, owner: string | null, entries: SessionTreeEntry[]) => Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* #270 车1 —— 内层店的 `retention` **声明**(core `RetentionDeclaring`)。
|
|
99
|
+
*
|
|
100
|
+
* 🔴 为什么这一格必须转发,而且方向比别的转发更要命:生产默认 `SESSION_CACHE_TTL_SEC=300` 会把真店
|
|
101
|
+
* 包进本类,于是 boot 递给 `assertRetentionCapability` 的就是**包装层**。core 对缺席是 fail-closed
|
|
102
|
+
* 读 `"none"` —— 不转发 ⇒ 一个真 managed 的 SQL 部署在 locked policy 下**被拒启**,而拒启文案会指着
|
|
103
|
+
* 一只其实完全合格的店(「stores that cannot delete」)。转发是**只读快照**而不是 getter:声明是店的
|
|
104
|
+
* 构造期常量,一个会变的声明本身就是缺陷。缺席则不设(保持 fail-closed 的缺席读法)。
|
|
105
|
+
*/
|
|
106
|
+
readonly retention?: RetentionDeclaration;
|
|
97
107
|
constructor(inner: SessionStore, opts: CachingOptions);
|
|
98
108
|
acquire(sessionId?: string, opts?: {
|
|
99
109
|
requireExisting?: boolean;
|
|
@@ -69,9 +69,22 @@ export class CachingSessionStore {
|
|
|
69
69
|
/** 2c session-sync (§7/§8) — delegated IDEMPOTENT replace; like importEntries it writes durable state under
|
|
70
70
|
* `sessionId`, so a stale warm handle for that id is evicted on success (next acquire re-wakes from the new log). */
|
|
71
71
|
replaceEntries;
|
|
72
|
+
/**
|
|
73
|
+
* #270 车1 —— 内层店的 `retention` **声明**(core `RetentionDeclaring`)。
|
|
74
|
+
*
|
|
75
|
+
* 🔴 为什么这一格必须转发,而且方向比别的转发更要命:生产默认 `SESSION_CACHE_TTL_SEC=300` 会把真店
|
|
76
|
+
* 包进本类,于是 boot 递给 `assertRetentionCapability` 的就是**包装层**。core 对缺席是 fail-closed
|
|
77
|
+
* 读 `"none"` —— 不转发 ⇒ 一个真 managed 的 SQL 部署在 locked policy 下**被拒启**,而拒启文案会指着
|
|
78
|
+
* 一只其实完全合格的店(「stores that cannot delete」)。转发是**只读快照**而不是 getter:声明是店的
|
|
79
|
+
* 构造期常量,一个会变的声明本身就是缺陷。缺席则不设(保持 fail-closed 的缺席读法)。
|
|
80
|
+
*/
|
|
81
|
+
retention;
|
|
72
82
|
constructor(inner, opts) {
|
|
73
83
|
this.inner = inner;
|
|
74
84
|
this.ttlMs = Math.max(0, opts.ttlSec) * 1000;
|
|
85
|
+
const declared = inner.retention;
|
|
86
|
+
if (declared !== undefined)
|
|
87
|
+
this.retention = declared;
|
|
75
88
|
const ownerAware = inner;
|
|
76
89
|
if (ownerAware.ownerOf)
|
|
77
90
|
this.ownerOf = ownerAware.ownerOf.bind(inner);
|
|
@@ -186,6 +186,9 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
186
186
|
protected readonly logger?: {
|
|
187
187
|
info?(msg: string, meta?: unknown): void;
|
|
188
188
|
} | undefined;
|
|
189
|
+
/** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而三方法的实现体在 `retention-store-sql.ts`」
|
|
190
|
+
* 逐字见 {@link MANAGED_RETENTION}(本店的 `checkpoint`/`checkpoint_ctx` 两表由那只聚合店按期清)。 */
|
|
191
|
+
readonly retention: import("@sema-agent/core").RetentionDeclaration;
|
|
189
192
|
/**
|
|
190
193
|
* `CheckpointStore.durability` 声明(#167 欠账,#168 件5)—— 行落在 MySQL-protocol / PostgreSQL 的
|
|
191
194
|
* `checkpoint` 表里,进程重启、副本轮换、整机重建都不丢 ⇒ `"durable"`,如实。
|
|
@@ -35,6 +35,7 @@ import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
|
|
|
35
35
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
36
36
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
37
37
|
import { isDupKeyError } from "./sql-errors.js";
|
|
38
|
+
import { MANAGED_RETENTION } from "./retention-store-sql.js";
|
|
38
39
|
/** Cap the persisted/served pending-approval args so a pathological tool payload can't bloat the checkpoint row
|
|
39
40
|
* or the operator-queue response (the BFF renders this in an approval card; a few KB is plenty). */
|
|
40
41
|
const MAX_TOOL_INPUT_CHARS = 8192;
|
|
@@ -346,6 +347,9 @@ const STEER_CAS_ATTEMPTS = 8;
|
|
|
346
347
|
export class SqlCheckpointStore {
|
|
347
348
|
db;
|
|
348
349
|
logger;
|
|
350
|
+
/** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而三方法的实现体在 `retention-store-sql.ts`」
|
|
351
|
+
* 逐字见 {@link MANAGED_RETENTION}(本店的 `checkpoint`/`checkpoint_ctx` 两表由那只聚合店按期清)。 */
|
|
352
|
+
retention = MANAGED_RETENTION;
|
|
349
353
|
/**
|
|
350
354
|
* `CheckpointStore.durability` 声明(#167 欠账,#168 件5)—— 行落在 MySQL-protocol / PostgreSQL 的
|
|
351
355
|
* `checkpoint` 表里,进程重启、副本轮换、整机重建都不丢 ⇒ `"durable"`,如实。
|
|
@@ -8,6 +8,9 @@ export interface LocalCheckpointStoreOptions {
|
|
|
8
8
|
ownerOf?: (sessionId: string) => Promise<string | null | undefined>;
|
|
9
9
|
}
|
|
10
10
|
export declare class LocalCheckpointStore {
|
|
11
|
+
/** #270 车1:托管留存声明 —— **诚实的 none**(与 local session 店同一条判据:本店的行没有按期删除的腿;
|
|
12
|
+
* 留存三方法只实现在 SQL 聚合店上)。locked policy + 本店 ⇒ core 的门拒启,那是契约的正确行为。 */
|
|
13
|
+
readonly retention: import("@sema-agent/core").RetentionDeclaration;
|
|
11
14
|
/**
|
|
12
15
|
* `CheckpointStore.durability` 声明(#167 欠账,#168 件5)——**如实按内核判**:本类的内核是 core 的
|
|
13
16
|
* `FileCheckpointStore`(crash-safe ledger + snapshot,自身声明 `"durable"`),本地 scope 注册表也落
|
|
@@ -26,6 +26,7 @@ import { join } from "node:path";
|
|
|
26
26
|
import { isApprovalGateKind } from "../tool-approval.js"; // A-002.1 单一属主
|
|
27
27
|
import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
|
|
28
28
|
import { boundedRuleSuggestions, boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
|
|
29
|
+
import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
|
|
29
30
|
/** design/80 D-D read-time twin of the TiDB put-time `terminal_at_ms` column (same formula — anti-drift). */
|
|
30
31
|
function terminalAtOf(cp) {
|
|
31
32
|
return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
|
|
@@ -35,6 +36,9 @@ function ctxFileOf(sessionId) {
|
|
|
35
36
|
return `${sanitizePathComponent(sessionId).slice(0, 48)}-${createHash("sha256").update(sessionId).digest("hex").slice(0, 12)}.json`;
|
|
36
37
|
}
|
|
37
38
|
export class LocalCheckpointStore {
|
|
39
|
+
/** #270 车1:托管留存声明 —— **诚实的 none**(与 local session 店同一条判据:本店的行没有按期删除的腿;
|
|
40
|
+
* 留存三方法只实现在 SQL 聚合店上)。locked policy + 本店 ⇒ core 的门拒启,那是契约的正确行为。 */
|
|
41
|
+
retention = UNMANAGED_RETENTION;
|
|
38
42
|
/**
|
|
39
43
|
* `CheckpointStore.durability` 声明(#167 欠账,#168 件5)——**如实按内核判**:本类的内核是 core 的
|
|
40
44
|
* `FileCheckpointStore`(crash-safe ledger + snapshot,自身声明 `"durable"`),本地 scope 注册表也落
|
|
@@ -3,6 +3,11 @@ import type { SessionSummary } from "./store-contracts.js";
|
|
|
3
3
|
import { type StagingHandle } from "../session-sync-kernel.js";
|
|
4
4
|
export declare class LocalSessionStore implements SessionStore {
|
|
5
5
|
private readonly repo;
|
|
6
|
+
/** #270 车1:托管留存声明 —— **诚实的 none**。本店的行没有任何按期删除的腿(留存 lane 的三方法只实现在
|
|
7
|
+
* SQL 聚合店上),所以 locked policy + 本店 ⇒ core 的 `assertRetentionCapability` 拒启,这是契约的
|
|
8
|
+
* **正确行为**不是缺口(要留存治理先上 DB 后端;设计稿 §1「不做清单」逐字)。缺席也会被读成 none,
|
|
9
|
+
* 显式写出来是文档义务:沉默表达不出「我核对过我的介质就是删不了」。 */
|
|
10
|
+
readonly retention: import("@sema-agent/core").RetentionDeclaration;
|
|
6
11
|
/** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one (mirrors TiDBSessionStore). */
|
|
7
12
|
private readonly pending;
|
|
8
13
|
/** Service-side owner scope per session (core's repo has no owner column). Registered-ownerless ⇒ null; absent ⇒ undefined. */
|
|
@@ -31,6 +31,7 @@ import { join, resolve } from "node:path";
|
|
|
31
31
|
// 已立案的病族,过期待办比没有更有害)。
|
|
32
32
|
import { SessionError, validateEntriesForImport } from "@sema-agent/core";
|
|
33
33
|
import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
|
|
34
|
+
import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
|
|
34
35
|
import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
|
|
35
36
|
const ownerEq = (a, b) => (a ?? null) === (b ?? null);
|
|
36
37
|
const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
|
|
@@ -61,6 +62,11 @@ function userText(entry) {
|
|
|
61
62
|
const TITLE_STATE_BY_PATH = new Map();
|
|
62
63
|
export class LocalSessionStore {
|
|
63
64
|
repo;
|
|
65
|
+
/** #270 车1:托管留存声明 —— **诚实的 none**。本店的行没有任何按期删除的腿(留存 lane 的三方法只实现在
|
|
66
|
+
* SQL 聚合店上),所以 locked policy + 本店 ⇒ core 的 `assertRetentionCapability` 拒启,这是契约的
|
|
67
|
+
* **正确行为**不是缺口(要留存治理先上 DB 后端;设计稿 §1「不做清单」逐字)。缺席也会被读成 none,
|
|
68
|
+
* 显式写出来是文档义务:沉默表达不出「我核对过我的介质就是删不了」。 */
|
|
69
|
+
retention = UNMANAGED_RETENTION;
|
|
64
70
|
/** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one (mirrors TiDBSessionStore). */
|
|
65
71
|
pending = new Map();
|
|
66
72
|
/** Service-side owner scope per session (core's repo has no owner column). Registered-ownerless ⇒ null; absent ⇒ undefined. */
|
|
@@ -23,7 +23,7 @@ import { cosineDistance, jaccardDistance, termSet } from "@sema-agent/core";
|
|
|
23
23
|
import { isUniqueViolation } from "./memory-engine-vector-util.js";
|
|
24
24
|
import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
|
|
25
25
|
import { pgSafeJsonStringify, pgHasUnstorable } from "./pg-safe-json.js";
|
|
26
|
-
import {
|
|
26
|
+
import { assertMemoryScopeWidth, memoryEntryKeyWidthRefusal } from "./memory-key-guards.js"; // R5 批γ + #271 件1:scope/slug 写前宽守卫
|
|
27
27
|
/** Table names (single source). Deliberately DISJOINT from the legacy `agent_memory*` tables —
|
|
28
28
|
* the retired MemoryStore plane and this entry plane must never cross-write. */
|
|
29
29
|
export const PG_MEMORY_ENGINE_TABLES = {
|
|
@@ -265,6 +265,18 @@ export class PgMemoryEngineBackend {
|
|
|
265
265
|
report.conflicts.push({ op: patch.op, id: patch.id, reason: `patch id ${JSON.stringify(patch.id)} != entry id ${JSON.stringify(patch.entry.id)} (malformed patch refused)` });
|
|
266
266
|
return;
|
|
267
267
|
}
|
|
268
|
+
// #271 件1(R-1):键宽在**任何 I/O 之前**判,add/update 两条腿一处收(tidb 版同注)。R5 那批只
|
|
269
|
+
// 护住了 add 腿的 slug ⇒ 一次改名(update `SET scope=…, slug=…`)就能把超宽键送进 DB;而超宽键
|
|
270
|
+
// 的两个结局都坏——PG 落不可分类的裸 22001,非严格 MySQL **静默截断**(截断后的键指向**另一个**
|
|
271
|
+
// scope/slug:两租户的盘折叠 / 两条目互串)。拒绝式,与 unstorable_bytes 同族(后端能力差异诚实
|
|
272
|
+
// 暴露,重试恒同答=幂等成立);写语句一条都不发出去。
|
|
273
|
+
if (patch.op !== "delete" && patch.entry !== undefined) {
|
|
274
|
+
const refusal = memoryEntryKeyWidthRefusal(patch.entry.scope, patch.entry.slug);
|
|
275
|
+
if (refusal !== undefined) {
|
|
276
|
+
report.conflicts.push({ op: patch.op, id: patch.id, reason: refusal });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
268
280
|
if (patch.op === "add") {
|
|
269
281
|
const rawEntry = patch.entry;
|
|
270
282
|
if (!rawEntry) {
|
|
@@ -279,15 +291,7 @@ export class PgMemoryEngineBackend {
|
|
|
279
291
|
report.conflicts.push({ op: "add", id: rawEntry.id, reason: "unstorable_bytes (PG cannot store NUL/lone surrogates; strip them at the source — the store never rewrites content)" });
|
|
280
292
|
return;
|
|
281
293
|
}
|
|
282
|
-
// R5
|
|
283
|
-
// 在 test/key-width-guards.test.ts。
|
|
284
|
-
try {
|
|
285
|
-
assertSlugWidth(rawEntry.slug);
|
|
286
|
-
}
|
|
287
|
-
catch (e) {
|
|
288
|
-
report.conflicts.push({ op: "add", id: rawEntry.id, reason: e instanceof Error ? e.message : String(e) });
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
294
|
+
// (R5 的 add-only slug 守卫已上提到 applyOne 顶部的键宽块 —— add/update 同判,见那里的注。)
|
|
291
295
|
const entry = rawEntry;
|
|
292
296
|
// opus 审 C3: an add whose id already lives in a DIFFERENT scope is refused explicitly — the bare
|
|
293
297
|
// `ON CONFLICT (id) DO UPDATE SET scope=…` would silently MOVE the row across scopes (and diverge
|
|
@@ -331,6 +335,13 @@ export class PgMemoryEngineBackend {
|
|
|
331
335
|
const embedding = await this.embeddingParam(this.haystackOf(cEntry.slug, cEntry.frontmatter, cEntry.body));
|
|
332
336
|
let slug = cEntry.slug;
|
|
333
337
|
for (let n = 2;; n++) {
|
|
338
|
+
// #271 件1(codex 对抗复审 H1;tidb 版同注):**派生**候选也要吃守卫 —— 顶部那道只判原始 slug,
|
|
339
|
+
// 而撞键派生的 `${slug}-${n}` 可以越过列宽(511 + "-2" = 513),此前直奔 INSERT 落裸 22001。
|
|
340
|
+
const derivedRefusal = memoryEntryKeyWidthRefusal(entry.scope, slug);
|
|
341
|
+
if (derivedRefusal !== undefined) {
|
|
342
|
+
report.conflicts.push({ op: "add", id: entry.id, reason: `${derivedRefusal} (slug collision suffix "-${n - 1}" no longer fits the column — shorten the entry name)` });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
334
345
|
const key = `${entry.scope}|${slug}`;
|
|
335
346
|
if (!plannedSlugs.has(key)) {
|
|
336
347
|
const taken = await this.query(`SELECT id FROM ${T} WHERE scope = $1 AND slug = $2`, [entry.scope, slug]);
|
|
@@ -456,6 +467,7 @@ export class PgMemoryEngineBackend {
|
|
|
456
467
|
return res.rows.length > 0 ? String(res.rows[0].cursor) : undefined;
|
|
457
468
|
}
|
|
458
469
|
async setConsolidationCursor(scope, cursor) {
|
|
470
|
+
assertMemoryScopeWidth(scope); // #271 件1:游标表 PK 也是 varchar(190) —— 截断=把两个盘的整合游标折叠成一个
|
|
459
471
|
await this.query(`INSERT INTO ${PG_MEMORY_ENGINE_TABLES.cursor} (scope, cursor) VALUES ($1, $2)
|
|
460
472
|
ON CONFLICT (scope) DO UPDATE SET cursor = $2`, [scope, cursor]);
|
|
461
473
|
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { jaccardDistance, termSet } from "@sema-agent/core";
|
|
25
25
|
import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
|
|
26
26
|
import { pgHasUnstorable } from "./pg-safe-json.js";
|
|
27
|
-
import {
|
|
27
|
+
import { assertMemoryScopeWidth, memoryEntryKeyWidthRefusal } from "./memory-key-guards.js"; // R5 批γ + #271 件1:scope/slug 写前宽守卫
|
|
28
28
|
import { isMysqlDupKeyError } from "./sql-errors.js";
|
|
29
29
|
/** Table names (single source) — SAME names as PG_MEMORY_ENGINE_TABLES (the two dialects never share
|
|
30
30
|
* one database), deliberately DISJOINT from the legacy `agent_memory*` MemoryStore plane. */
|
|
@@ -218,6 +218,16 @@ export class TiDBMemoryEngineBackend {
|
|
|
218
218
|
report.conflicts.push({ op: patch.op, id: patch.id, reason: `patch id ${JSON.stringify(patch.id)} != entry id ${JSON.stringify(patch.entry.id)} (malformed patch refused)` });
|
|
219
219
|
return;
|
|
220
220
|
}
|
|
221
|
+
// #271 件1(R-1):键宽在任何 I/O 之前判,add/update 两条腿一处收(Pg 版同注)。本方言尤其承重
|
|
222
|
+
// ——非严格 MySQL 对超宽键**静默截断**(只发 warning),截断后的 scope/slug 指向**另一个**键:
|
|
223
|
+
// 两租户的盘折叠成一个、两个条目互串,写入侧不炸、读回侧才发现串了。拒绝式,零写。
|
|
224
|
+
if (patch.op !== "delete" && patch.entry !== undefined) {
|
|
225
|
+
const refusal = memoryEntryKeyWidthRefusal(patch.entry.scope, patch.entry.slug);
|
|
226
|
+
if (refusal !== undefined) {
|
|
227
|
+
report.conflicts.push({ op: patch.op, id: patch.id, reason: refusal });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
221
231
|
if (patch.op === "add") {
|
|
222
232
|
const entry = patch.entry;
|
|
223
233
|
if (!entry) {
|
|
@@ -233,16 +243,7 @@ export class TiDBMemoryEngineBackend {
|
|
|
233
243
|
report.conflicts.push({ op: "add", id: entry.id, reason: "unstorable_bytes (utf8mb4 cannot store NUL/lone surrogates without mangling; strip them at the source — the store never rewrites content)" });
|
|
234
244
|
return;
|
|
235
245
|
}
|
|
236
|
-
// R5
|
|
237
|
-
// 截断=两个不同 slug 折叠成一行互串;PG/严格 MySQL 落裸错不可分类。守卫常量与 DDL 同源门在
|
|
238
|
-
// test/key-width-guards.test.ts。
|
|
239
|
-
try {
|
|
240
|
-
assertSlugWidth(entry.slug);
|
|
241
|
-
}
|
|
242
|
-
catch (e) {
|
|
243
|
-
report.conflicts.push({ op: "add", id: entry.id, reason: e instanceof Error ? e.message : String(e) });
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
+
// (R5 的 add-only slug 守卫已上提到 applyOne 顶部的键宽块 —— add/update 同判,见那里的注。)
|
|
246
247
|
// Cross-scope add refusal (opus 审 C3,Pg 版同注): an add whose id already lives in a DIFFERENT
|
|
247
248
|
// scope must not silently MOVE the row; same-scope re-add stays the idempotent overwrite.
|
|
248
249
|
// One probe serves BOTH the E-02 guard and the cross-scope refusal (File/Pg parity).
|
|
@@ -276,6 +277,15 @@ export class TiDBMemoryEngineBackend {
|
|
|
276
277
|
// a concurrent loser retries the next suffix (Pg 版同注).
|
|
277
278
|
let slug = entry.slug;
|
|
278
279
|
for (let n = 2;; n++) {
|
|
280
|
+
// #271 件1(codex 对抗复审 H1,验真属实):**派生**候选也要吃守卫。顶部那道只判调用方给的原始
|
|
281
|
+
// slug,而本循环在撞键时派生 `${slug}-${n}` —— 511 字符的合法 slug 撞一次就派生出 513 字符,
|
|
282
|
+
// 绕过守卫直奔 INSERT(非严格 MySQL 截成另一个 512 字符键、还照报未截断的 slug=最坏形)。
|
|
283
|
+
// 判在循环**顶**=任何探针/写之前;后缀装不下 ⇒ 可分类 conflict,不是静默截断也不是死循环。
|
|
284
|
+
const derivedRefusal = memoryEntryKeyWidthRefusal(entry.scope, slug);
|
|
285
|
+
if (derivedRefusal !== undefined) {
|
|
286
|
+
report.conflicts.push({ op: "add", id: entry.id, reason: `${derivedRefusal} (slug collision suffix "-${n - 1}" no longer fits the column — shorten the entry name)` });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
279
289
|
const key = `${entry.scope}|${slug}`;
|
|
280
290
|
if (!plannedSlugs.has(key)) {
|
|
281
291
|
const taken = await this.selectRows(`SELECT id FROM ${T} WHERE scope = ? AND slug = ?`, [entry.scope, slug]);
|
|
@@ -442,6 +452,7 @@ export class TiDBMemoryEngineBackend {
|
|
|
442
452
|
return rows.length > 0 ? String(rows[0].cursor) : undefined;
|
|
443
453
|
}
|
|
444
454
|
async setConsolidationCursor(scope, cursor) {
|
|
455
|
+
assertMemoryScopeWidth(scope); // #271 件1:游标表 PK 也是 VARCHAR(190) —— 截断=把两个盘的整合游标折叠成一个
|
|
445
456
|
// Single unique key (the PK) on this table — ON DUPLICATE KEY UPDATE is unambiguous here,
|
|
446
457
|
// unlike the two-unique-key entries table (see the file-head dialect note).
|
|
447
458
|
await this.write(`INSERT INTO ${TIDB_MEMORY_ENGINE_TABLES.cursor} (scope, \`cursor\`) VALUES (?, ?) ON DUPLICATE KEY UPDATE \`cursor\` = VALUES(\`cursor\`)`, [scope, cursor]);
|
|
@@ -1,8 +1,31 @@
|
|
|
1
|
-
/** R5(车A [3191] 欠账,批γ
|
|
2
|
-
*
|
|
3
|
-
* 静默截断(
|
|
4
|
-
*
|
|
1
|
+
/** R5(车A [3191] 欠账,批γ 落地;#271 件1 补齐写口):memory 面的键宽写前守卫。
|
|
2
|
+
* 列宽收窄后,模型/调用方可控的键(条目名 slug、租户盘 scope)超宽此前落裸 SQL 错(PG
|
|
3
|
+
* `value too long`)或非严格 MySQL 静默截断(截断=两个不同的键折叠成同一行=**条目互串 / 两个租户
|
|
4
|
+
* 的盘塌成一个**,最危险形)。写前响亮拒,错误可分类。
|
|
5
|
+
* 列宽同源门=test/key-width-guards.test.ts(守卫常量 vs 两方言 DDL 逐字对表);写口在场门=同文件
|
|
6
|
+
* 「#271 件1」组(超宽键一条写语句都不许发出 seam)。
|
|
7
|
+
*
|
|
8
|
+
* **本文件是这两个宽度的唯一属主**(#271 件1):scope 守卫此前住在 `src/memory-scope.ts`,只被
|
|
9
|
+
* `memoryScopeFor` 的产出处消费,而 store 写口全无——同一个列宽有两个居所、两半消费面,正是
|
|
10
|
+
* 「守卫在位≠写口接上」那类缺口的温床。`memory-scope.ts` 现在只**再导出**本文件的这两个符号
|
|
11
|
+
* (旧 import 路径逐字不变)。 */
|
|
5
12
|
/** memory-engine entry 表 `slug` 列宽(两方言 VARCHAR(512) 同宽;(scope,slug) UNIQUE 键预算注在 DDL)。 */
|
|
6
13
|
export declare const MEMORY_SLUG_COLUMN_CHARS = 512;
|
|
14
|
+
/** memory 面 `scope` 列宽(两方言 VARCHAR(190) 同宽:engine 的 entry/cursor 两表 + memory-sync 的
|
|
15
|
+
* sync_cursor/push_queue/history)。`formatUserScope`/`formatProjScope` 的段编码(百分号转义)会
|
|
16
|
+
* **膨胀**——非 ASCII principal 编码后可超列宽,故产出处与写口两道都要判。 */
|
|
17
|
+
export declare const MEMORY_SCOPE_COLUMN_CHARS = 190;
|
|
7
18
|
export declare function assertSlugWidth(slug: string): void;
|
|
19
|
+
export declare function assertMemoryScopeWidth(scope: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* 一个 entry 写(add / update)的两个键宽一起判 —— 两方言的 `applyOne` 在**任何 I/O 之前**调用它。
|
|
22
|
+
*
|
|
23
|
+
* 为什么两条腿共用一个入口而不是各自展开:add 与 update 写的是**同两列**(update 的
|
|
24
|
+
* `SET scope = …, slug = …` 会改盘也会改名),R5 那批只护住了 add ⇒ 一次改名就能把超宽 slug 送进
|
|
25
|
+
* DB。一个入口=两条腿不会再各自漂。
|
|
26
|
+
*
|
|
27
|
+
* 返回**字符串**而不是抛:两方言的 `applyPatches` 契约是「冲突进 report,不抛」(与
|
|
28
|
+
* `unstorable_bytes` 的拒绝式同族——后端能力差异诚实暴露,重试恒同答=幂等成立)。`undefined` = 过。
|
|
29
|
+
*/
|
|
30
|
+
export declare function memoryEntryKeyWidthRefusal(scope: string, slug: string): string | undefined;
|
|
8
31
|
//# sourceMappingURL=memory-key-guards.d.ts.map
|