@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/boot/shutdown.js
CHANGED
|
@@ -18,7 +18,7 @@ import { createSighupIdleHandler } from "../sighup-idle.js";
|
|
|
18
18
|
import { createParentWatch } from "../parent-watch.js";
|
|
19
19
|
/** 注册 SIGTERM/SIGINT/SIGHUP 收尾链。**必须在 listen 之后调用**(见文件头「位置即契约」)。 */
|
|
20
20
|
export function installShutdownHandlers(ctx) {
|
|
21
|
-
const { config, logger, server, reaper, fleetReconcile, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, } = ctx;
|
|
21
|
+
const { config, logger, server, reaper, fleetReconcile, retentionLane, releaseRetentionLease, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, } = ctx;
|
|
22
22
|
let closing = false;
|
|
23
23
|
/** #219:parent 监视腿(装配在本函数尾,`config.parentPid` 在场才建)。声明提前只为让 hardShutdown
|
|
24
24
|
* 能把它一并停掉 —— 文件头契约 3 同族(收尾期不再有后台 tick)。 */
|
|
@@ -29,6 +29,21 @@ export function installShutdownHandlers(ctx) {
|
|
|
29
29
|
closing = true;
|
|
30
30
|
clearInterval(reaper);
|
|
31
31
|
clearInterval(fleetReconcile.timer); // #261:契约 3 同族——收尾期不再有对账 tick 打向正在关闭的池
|
|
32
|
+
// #270 车2:契约 3 同族,且这一条最要紧——留存 tick 起飞的是跨十余张表的**破坏性**事务。
|
|
33
|
+
// 🔴 **四步的次序都是承重的**(codex 对抗复审 R1/R2 两轮各修一半,均验真后改):
|
|
34
|
+
// 停表 → 等在飞那一轮落地 → 让租 → `backend.close()`。
|
|
35
|
+
// · `clearInterval` 只挡新 tick,不等当前这一轮;停表之后紧跟着就让租 ⇒ 新副本立刻接租,而本副本
|
|
36
|
+
// 还在删同一个域的数据 —— 一个由**正常** SIGTERM/SIGINT 稳定触发的双执行者窗口(R1);
|
|
37
|
+
// · 而让租之后若不等就关池,这一轮会被从底下抽掉连接、半途而废(R2 —— 见下方 server.close 回调)。
|
|
38
|
+
// · 进程若在整条链跑完之前就退了,租约不会被让出 ⇒ 下一位等满 TTL —— 那是**安全**的那一侧。
|
|
39
|
+
// 三步的产物是一条**可等的** promise —— 下面 `server.close()` 的回调必须等它落地才 `backend.close()`。
|
|
40
|
+
const retentionTeardown = retentionLane === undefined
|
|
41
|
+
? Promise.resolve()
|
|
42
|
+
: retentionLane
|
|
43
|
+
.stop()
|
|
44
|
+
.then(() => releaseRetentionLease?.())
|
|
45
|
+
.then(() => undefined)
|
|
46
|
+
.catch(() => undefined);
|
|
32
47
|
fleetReconcile.reconciler.stop(); // 摘总线订阅(观察者已停,扇出不该再喂它)
|
|
33
48
|
parentWatch?.stop(); // #219:契约 3 同族——收尾期不再探父进程
|
|
34
49
|
storeLiveProbe?.stop(); // #131-2:契约 3 同族——收尾期不再有探针 tick 打向正在关闭的池
|
|
@@ -60,7 +75,11 @@ export function installShutdownHandlers(ctx) {
|
|
|
60
75
|
}
|
|
61
76
|
void fleetClient?.stop(); // stop heartbeat + flush final usage window + best-effort DELETE deregistration
|
|
62
77
|
server.close(() => {
|
|
63
|
-
|
|
78
|
+
// 🔴 `backend.close()` 必须排在留存收尾**之后**(codex 对抗复审 R2-[high],验真后修):
|
|
79
|
+
// 那一轮打的是跨十余张表的破坏性事务,把连接池从它底下抽掉 = 这一轮自己半途而废
|
|
80
|
+
// (比"让租早了"更重:让租早了只是**可能**双执行者,池被抽掉是必然的半成品)。
|
|
81
|
+
// 兜底仍在:下面那条 15s `finalExit` 无条件退出,一轮卡死的 sweep 拖不住停机。
|
|
82
|
+
void retentionTeardown.then(() => (backend ? backend.close() : Promise.resolve())).finally(() => process.exit(0));
|
|
64
83
|
});
|
|
65
84
|
// 对抗复查 B-4(HIGH):the exit above only fires after backend.close() SETTLES — a wedged pool teardown
|
|
66
85
|
// (dead DB, hung socket) kept the "force-terminated" process alive indefinitely (the 10s closeAllConnections
|
package/dist/config-types.d.ts
CHANGED
|
@@ -29,6 +29,20 @@ export interface MemoryEmbedderConfig {
|
|
|
29
29
|
/** 可选 Bearer(自托管 TEI/vllm/ollama 通常不需要;公有云端点需要)。 */
|
|
30
30
|
apiKey?: string;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* #270 §4 —— 托管留存的**灰度档**闭集。`as const` 数组是词表的唯一真源(config 解析、能力位、审计行
|
|
34
|
+
* 的 `mode` 列共读它);两词之外的值在启动期响亮拒 —— 一个拼错的档位静默落回 `audit-only` 会让运维
|
|
35
|
+
* 以为在删、其实没删,而拼成别的方向就是反过来那件更坏的事。
|
|
36
|
+
*/
|
|
37
|
+
export declare const RETENTION_MODES: readonly ["audit-only", "enforce"];
|
|
38
|
+
export type RetentionMode = (typeof RETENTION_MODES)[number];
|
|
39
|
+
/** #270 §3/§4 —— sweep lane 的节律与灰度档(语义与三问见 {@link ServiceConfigFlat.retentionSweep})。 */
|
|
40
|
+
export interface RetentionSweepConfig {
|
|
41
|
+
/** `audit-only` = 照跑照判、只记 `audit_only` 行,**不调任何破坏性方法**;`enforce` = 真删。默认前者。 */
|
|
42
|
+
mode: RetentionMode;
|
|
43
|
+
/** sweep 节律(秒)。`0` = **关**(默认);租约 TTL = 2×本值。 */
|
|
44
|
+
intervalSec: number;
|
|
45
|
+
}
|
|
32
46
|
/** A sema-registry MCP server resolved to a core spec (env-NAME refs already → real values) plus the
|
|
33
47
|
* scenarios it applies to (empty = all). `resolveSpec` filters by scenario and passes `spec` to core. */
|
|
34
48
|
export interface ScopedMcpServer {
|
|
@@ -704,6 +718,13 @@ export interface ServiceConfigFlat {
|
|
|
704
718
|
* managed 店时,启动会打一条 `retention_policy_not_executed` 的响亮 warn。
|
|
705
719
|
*/
|
|
706
720
|
retentionPolicy?: RetentionPolicy;
|
|
721
|
+
/**
|
|
722
|
+
* #270 车2 —— 托管留存 **sweep lane** 的两个旋钮(设计稿 `DESIGN-270-retention-lane.md` §3/§4)。
|
|
723
|
+
* `retentionPolicy` 说的是「保留多久」(一句声明),本格说的是「谁在什么节律上按那句话删」。
|
|
724
|
+
* **恒在场**(不是可选块):两根都有文档化的缺省,而缺省本身是一条要能被读出来的姿态 ——
|
|
725
|
+
* `{mode:"audit-only", intervalSec:0}` = 「lane 关着,真开起来也先只记账」。
|
|
726
|
+
*/
|
|
727
|
+
retentionSweep: RetentionSweepConfig;
|
|
707
728
|
/** Tool names that require operator approval before running (F4). Empty = no approval gate. */
|
|
708
729
|
approvalRequire: string[];
|
|
709
730
|
/** Principals allowed to act as OPERATORS on the F4 approval queue (`/v1/approvals`): list ANY
|
|
@@ -1284,7 +1305,7 @@ export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled"
|
|
|
1284
1305
|
* 三者都是 operator 在配置面写下的**治理声明**(与 autonomy/commandPolicy 同族),不是存储旋钮
|
|
1285
1306
|
* ——`retentionPolicy` 尤其容易被误归 store 组:它的消费点是 core 的启动期能力校验与将来的调度面,
|
|
1286
1307
|
* config 这一层它只是一句声明。 */
|
|
1287
|
-
export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authTokens" | "allowUnauthedWrites" | "corsOrigins" | "principalHeader" | "requirePrincipal" | "autonomy" | "commandPolicy" | "compliancePosture" | "lockedConfigKeys" | "retentionPolicy" | "operatorPrincipals" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "principalJwtMaxTtlSec" | "bindHost" | "bindHostSource">;
|
|
1308
|
+
export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authTokens" | "allowUnauthedWrites" | "corsOrigins" | "principalHeader" | "requirePrincipal" | "autonomy" | "commandPolicy" | "compliancePosture" | "lockedConfigKeys" | "retentionPolicy" | "retentionSweep" | "operatorPrincipals" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "principalJwtMaxTtlSec" | "bindHost" | "bindHostSource">;
|
|
1288
1309
|
/** 组:orchestration(编排 + 执行车道 + 沙箱面 + workflow/后台 agent 存留)。 */
|
|
1289
1310
|
export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "rosterRetentionMs" | "leaderRunStaleMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude">;
|
|
1290
1311
|
/** 组:limitsHttp(HTTP 面 + 各类上限/配额/回收窗)。 */
|
package/dist/config-types.js
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* #270 §4 —— 托管留存的**灰度档**闭集。`as const` 数组是词表的唯一真源(config 解析、能力位、审计行
|
|
3
|
+
* 的 `mode` 列共读它);两词之外的值在启动期响亮拒 —— 一个拼错的档位静默落回 `audit-only` 会让运维
|
|
4
|
+
* 以为在删、其实没删,而拼成别的方向就是反过来那件更坏的事。
|
|
5
|
+
*/
|
|
6
|
+
export const RETENTION_MODES = ["audit-only", "enforce"];
|
|
2
7
|
//# sourceMappingURL=config-types.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { ServiceConfig, ServiceConfigFlat, ServiceConfigGroups } from "./co
|
|
|
5
5
|
* every existing `from "./config.js"` importer compiles unchanged (pure-type consumers should prefer
|
|
6
6
|
* importing config-types.js directly — a type-only leaf, no loader baggage). */
|
|
7
7
|
export type { ServiceConfig, ScopedMcpServer, ImageBakeConfig, ServiceConfigFlat, ServiceConfigGroups, ServiceStoreConfig, ServiceModelPlaneConfig, ServiceApprovalConfig, ServiceMemoryConfig, ServiceAuthConfig, ServiceOrchestrationConfig, ServiceLimitsHttpConfig, ServiceObservabilityConfig, ServiceIntegrationsConfig, MemoryEmbedderConfig, // #228:embedder 坐标(plugins/memory-embedder.ts 消费同一属主类型)
|
|
8
|
-
A2aServeConfig, A2aServeSkill, } from "./config-types.js";
|
|
8
|
+
A2aServeConfig, A2aServeSkill, RetentionMode, RetentionSweepConfig, } from "./config-types.js";
|
|
9
|
+
export { RETENTION_MODES } from "./config-types.js";
|
|
9
10
|
/** Parse the AUTONOMY env into a validated autonomy mode. Unset/empty → undefined (unmanaged → no extra
|
|
10
11
|
* tightening). An UNKNOWN value FAILS at startup rather than silently becoming a no-op (a typo'd `AUTONOMY=readonly`
|
|
11
12
|
* must not silently leave a deployment ungoverned — fail-loud, same discipline as numEnv). Exported so the HOT
|
|
@@ -186,6 +187,19 @@ export declare function findCcRuleFormNames(names: readonly string[]): Unmatchab
|
|
|
186
187
|
export declare function findUnmatchableToolNames(names: readonly string[]): UnmatchableToolName[];
|
|
187
188
|
/** {@link findUnmatchableToolNames} 的成句形——三条腿的文案同源(只有前缀/出口不同)。 */
|
|
188
189
|
export declare function formatUnmatchableToolNames(source: string, bad: readonly UnmatchableToolName[]): string;
|
|
190
|
+
/**
|
|
191
|
+
* design/170 件B/C/D —— 三条**部署治理声明**的 env 解析(auth 域的一段,fail-loud 全在这里)。
|
|
192
|
+
*
|
|
193
|
+
* 三条的词表真源都在 core(`ComplianceProfile` / `ComplianceCapability` / `LOCKED_KEY_REGISTRY`),本函数
|
|
194
|
+
* 只负责「env 文本 → 闭集值」这一步,并且**每一步坏值都拒启**:这三根旋钮全在安全/治理轴上,一个静默
|
|
195
|
+
* 不生效的档位或一个拼错的锁键,是「以为在管、其实没管」——比没配更贵(#210 A 档 + design/170 §3.4)。
|
|
196
|
+
*
|
|
197
|
+
* 半配也拒:`COMPLIANCE_ADDITIONAL_DENIES` 没有 profile 可收紧时是一句无处安放的话,静默丢掉它等于
|
|
198
|
+
* 让 operator 以为多禁了一条(与 `MEMORY_EMBEDDER_*` 的半配拒启同族)。
|
|
199
|
+
*/
|
|
200
|
+
/** #270 车2 —— sweep 节律的上界(秒)。`setInterval` 的 delay 是 32 位有符号毫秒(2^31-1);越界会被
|
|
201
|
+
* Node **重置成 1ms**,理由与拒启判据见调用点。导出供判据与文档单点引用。 */
|
|
202
|
+
export declare const RETENTION_SWEEP_INTERVAL_MAX_SEC: number;
|
|
189
203
|
/** 组名 → 该组取景的平铺键(introspection 面:测试用它钉「每个平铺键恰好被一组取景」)。 */
|
|
190
204
|
export declare const CONFIG_GROUP_KEYS: {
|
|
191
205
|
readonly [G in keyof ServiceConfigGroups]: readonly (keyof ServiceConfigFlat)[];
|
package/dist/config.js
CHANGED
|
@@ -8,6 +8,8 @@ import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
|
|
|
8
8
|
import { isV2ScopeKey } from "./memory-scope.js"; // A-002.4: v2 scope 前缀词表的单一属主(纯谓词,无反向依赖)
|
|
9
9
|
import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
|
|
10
10
|
import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js"; // P1-2: 方言词表叶模块(config 不得值引 capabilities 层)
|
|
11
|
+
import { RETENTION_MODES } from "./config-types.js"; // #270 车2:留存灰度档的闭集词表(值导入 —— config-types 是纯叶,无装载环)
|
|
12
|
+
export { RETENTION_MODES } from "./config-types.js";
|
|
11
13
|
function csv(name) {
|
|
12
14
|
return (process.env[name] ?? "")
|
|
13
15
|
.split(",")
|
|
@@ -1613,8 +1615,11 @@ function parseMemoryDomain(ctx) {
|
|
|
1613
1615
|
* 半配也拒:`COMPLIANCE_ADDITIONAL_DENIES` 没有 profile 可收紧时是一句无处安放的话,静默丢掉它等于
|
|
1614
1616
|
* 让 operator 以为多禁了一条(与 `MEMORY_EMBEDDER_*` 的半配拒启同族)。
|
|
1615
1617
|
*/
|
|
1618
|
+
/** #270 车2 —— sweep 节律的上界(秒)。`setInterval` 的 delay 是 32 位有符号毫秒(2^31-1);越界会被
|
|
1619
|
+
* Node **重置成 1ms**,理由与拒启判据见调用点。导出供判据与文档单点引用。 */
|
|
1620
|
+
export const RETENTION_SWEEP_INTERVAL_MAX_SEC = Math.floor((2 ** 31 - 1) / 1000);
|
|
1616
1621
|
function parseGovernanceDeclarations() {
|
|
1617
|
-
const out = {};
|
|
1622
|
+
const out = { retentionSweep: { mode: "audit-only", intervalSec: 0 } };
|
|
1618
1623
|
/**
|
|
1619
1624
|
* codex 对抗复审 F1(high,验真后采纳)——**「未定义」与「定义了但是空」必须分开**。
|
|
1620
1625
|
*
|
|
@@ -1668,6 +1673,42 @@ function parseGovernanceDeclarations() {
|
|
|
1668
1673
|
if (retentionRaw !== undefined) {
|
|
1669
1674
|
out.retentionPolicy = { maxAgeDays: parseNumOrFailNonNegative("RETENTION_MAX_AGE_DAYS", retentionRaw) };
|
|
1670
1675
|
}
|
|
1676
|
+
// ── #270 车2:执行面的两根旋钮(策略说"保留多久",这两根说"谁在什么节律上按它删")。
|
|
1677
|
+
//
|
|
1678
|
+
// 🔴 灰度档是**闭集**,拼错即拒(#157 词表纪律)。静默落回 `audit-only` 会让运维以为在删而其实没删;
|
|
1679
|
+
// 静默落成另一档就是反方向那件更坏的事。词表从 config-types 的 `RETENTION_MODES` 单点读,
|
|
1680
|
+
// 能力位/审计行的 `mode` 列与它同源。
|
|
1681
|
+
const modeRaw = requireNonBlank("RETENTION_MODE", process.env.RETENTION_MODE);
|
|
1682
|
+
if (modeRaw !== undefined) {
|
|
1683
|
+
if (!RETENTION_MODES.includes(modeRaw)) {
|
|
1684
|
+
throw new Error(`RETENTION_MODE must be one of ${RETENTION_MODES.join("|")}, got ${JSON.stringify(modeRaw)} — a misspelled rollout stage ` +
|
|
1685
|
+
`would run as the DEFAULT stage, i.e. an operator who wrote "enforce" with a typo would watch a retention lane that ` +
|
|
1686
|
+
`deletes nothing and believe it is deleting (the reverse typo is worse still).`);
|
|
1687
|
+
}
|
|
1688
|
+
out.retentionSweep.mode = modeRaw;
|
|
1689
|
+
}
|
|
1690
|
+
// 🔴 节律必须是**非负整数秒**:`Math.round` 掉一个小数会把"配错了"变成"配得有点怪但跑起来了",而
|
|
1691
|
+
// 这条腿是每小时一拍的后台删除活 —— `0.5` 只可能是把毫秒/分钟当成秒写的手滑。`0` 是显式关(默认)。
|
|
1692
|
+
const sweepRaw = requireNonBlank("RETENTION_SWEEP_INTERVAL_SEC", process.env.RETENTION_SWEEP_INTERVAL_SEC);
|
|
1693
|
+
if (sweepRaw !== undefined) {
|
|
1694
|
+
const n = parseNumOrFailNonNegative("RETENTION_SWEEP_INTERVAL_SEC", sweepRaw);
|
|
1695
|
+
if (!Number.isInteger(n)) {
|
|
1696
|
+
throw new Error(`env RETENTION_SWEEP_INTERVAL_SEC=${n} must be a whole number of seconds (0 = the lane is off)`);
|
|
1697
|
+
}
|
|
1698
|
+
// 🔴 上界 = `setInterval` 的 32 位 delay 上限(codex 对抗复审 R1-[high],验真属实)。
|
|
1699
|
+
// 病:delay 超过 2^31-1 ms 时 Node 打一条 `TimeoutOverflowWarning` 并把它**重置成 1ms** —— 一个想
|
|
1700
|
+
// 「约 25 天扫一次」的 enforce 部署于是变成几乎连续扫库,而这条腿每一拍发的都是跨十余张表的
|
|
1701
|
+
// **破坏性**事务。夹取在这里同样是错的补偿(悄悄改小 = 换一个运维没要的策略),拒启才是
|
|
1702
|
+
// ——与 `RETENTION_MAX_AGE_DAYS` 那根「删数据的旋钮不许 NaN 流通」同一条判据。
|
|
1703
|
+
// 顺带这条上界也保住了 TTL 算术:`intervalSec*1000*RETENTION_LEASE_TTL_FACTOR + Date.now()` 在
|
|
1704
|
+
// 本上界内离 `Number.MAX_SAFE_INTEGER` 还差九个数量级。
|
|
1705
|
+
if (n > RETENTION_SWEEP_INTERVAL_MAX_SEC) {
|
|
1706
|
+
throw new Error(`env RETENTION_SWEEP_INTERVAL_SEC=${n} exceeds ${RETENTION_SWEEP_INTERVAL_MAX_SEC} (setInterval's 32-bit delay ceiling, ~24.8 days) — ` +
|
|
1707
|
+
`Node would silently RESET an overflowing delay to 1ms, turning an "occasional" retention sweep into a nearly continuous stream of ` +
|
|
1708
|
+
`destructive transactions. Pick an interval at or below the ceiling.`);
|
|
1709
|
+
}
|
|
1710
|
+
out.retentionSweep.intervalSec = n;
|
|
1711
|
+
}
|
|
1671
1712
|
return out;
|
|
1672
1713
|
}
|
|
1673
1714
|
/** 域:auth(身份/凭证/绑址)—— service token 面、CORS、principal 头与开关、JWT 锚、autonomy、绑址。 */
|
|
@@ -2283,7 +2324,7 @@ const MEMORY_GROUP_KEYS = [
|
|
|
2283
2324
|
];
|
|
2284
2325
|
const AUTH_GROUP_KEYS = [
|
|
2285
2326
|
"authToken", "authTokens", "allowUnauthedWrites", "corsOrigins", "principalHeader", "requirePrincipal", "autonomy",
|
|
2286
|
-
"commandPolicy", "compliancePosture", "lockedConfigKeys", "retentionPolicy",
|
|
2327
|
+
"commandPolicy", "compliancePosture", "lockedConfigKeys", "retentionPolicy", "retentionSweep",
|
|
2287
2328
|
"operatorPrincipals", "principalJwtPubkeys", "principalJwtIss", "principalJwtAud",
|
|
2288
2329
|
"principalJwtMaxTtlSec", "bindHost", "bindHostSource",
|
|
2289
2330
|
];
|
|
@@ -8,6 +8,22 @@ import { resolveStreamApprovalGate } from "../../tool-approval.js";
|
|
|
8
8
|
import { projectMemoryEngineCapability } from "../../memory-posture.js";
|
|
9
9
|
import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
|
|
10
10
|
import { gatedPrincipal } from "../principal-gate.js";
|
|
11
|
+
/**
|
|
12
|
+
* #270 车2 —— 能力位 `retention` 的**唯一**判据(`{mode, maxAgeDays} | null`)。
|
|
13
|
+
*
|
|
14
|
+
* 谓词为什么可以只看 config 而不合取"执行器在场"(「说 yes ⟺ 面真能用」的结构性论证):
|
|
15
|
+
* `intervalSec > 0` 且没有真执行器的那台**根本起不来** —— `boot/retention-lane.ts` 的
|
|
16
|
+
* `assertRetentionLaneWirable` 在 boot 期就拒了(policy 缺 / 执行器缺 / 有店声明 "none" 三条臂,
|
|
17
|
+
* 且**不看 locked**)。所以在一个已经在应答 HTTP 的进程里,那个合取式恒真;再写一遍只会让读者以为
|
|
18
|
+
* 这里还有第二道门。policy 在场仍显式合取:它是 `maxAgeDays` 那一格的来源。
|
|
19
|
+
*/
|
|
20
|
+
function retentionAdvertisement(config) {
|
|
21
|
+
const sweep = config.retentionSweep; // 见调用点注:遗留双重断言 config 可能真缺这一格
|
|
22
|
+
const policy = config.retentionPolicy;
|
|
23
|
+
if (sweep === undefined || sweep.intervalSec <= 0 || policy === undefined)
|
|
24
|
+
return null;
|
|
25
|
+
return { mode: sweep.mode, maxAgeDays: policy.maxAgeDays };
|
|
26
|
+
}
|
|
11
27
|
export async function handleCapabilities(req, res, url, ctx) {
|
|
12
28
|
const miss = { fell: false };
|
|
13
29
|
await handleCapabilitiesBody(req, res, url, ctx, miss);
|
|
@@ -405,6 +421,20 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
|
|
|
405
421
|
// ⚠️ 辖域=**列举面**:名册回的是 content-free 投影(handle/name/agentType/status/三个时间戳);
|
|
406
422
|
// 要子代产物正文走 `/v1/runs/:id/subagents/:handle/output`(那条口有它自己的谓词 `subagentOutput`)。
|
|
407
423
|
agentRoster: Boolean(deps.backgroundAgentStore),
|
|
424
|
+
// #270 车2:**托管留存**的部署事实(设计稿 §7 能力位)。`null` = sweep lane 关着(缺省);
|
|
425
|
+
// 在场 = `{mode, maxAgeDays}` —— 两格都是**部署事实不是秘密**(策略数值本来就要告诉用户"你的
|
|
426
|
+
// 数据留多久"),消费端(web admin 面 / test)按位渲染。
|
|
427
|
+
//
|
|
428
|
+
// 🔴 谓词为什么可以只看 config 而不合取"执行器在场"(「说 yes ⟺ 面真能用」的结构性论证):
|
|
429
|
+
// `intervalSec > 0` 且没有真执行器的那台**根本起不来** —— `boot/retention-lane.ts` 的
|
|
430
|
+
// `assertRetentionLaneWirable` 在 boot 期就拒了(policy 缺 / 执行器缺 / 有店声明 "none" 三条臂,
|
|
431
|
+
// 且**不看 locked**)。所以在一个已经在应答 HTTP 的进程里,这个合取式恒真;再写一遍只会让读者
|
|
432
|
+
// 以为这里还有第二道门。policy 缺席仍显式合取:它是那句 `{mode, maxAgeDays}` 里第二格的来源。
|
|
433
|
+
// ⚠️ `retentionSweep` 在类型上是**必填**(`loadConfig` 恒产出它),这里仍显式判在场:本仓还有一批
|
|
434
|
+
// 遗留测试用 `as unknown as ServiceDeps["config"]` 手搓 config 字面量 —— 那种双重断言把字段检查
|
|
435
|
+
// 整个关掉,于是一个必填字段在它们手里可以真的缺席。缺席臂的方向是**保守**的(`null` = 本部署
|
|
436
|
+
// 没有托管留存),不是放宽,所以它不是一处静默 fail-open;生产路径根本到不了这一臂。
|
|
437
|
+
retention: retentionAdvertisement(deps.config),
|
|
408
438
|
sessionList: Boolean(deps.sessionStorage?.listSessions ?? deps.runStore?.listSessions),
|
|
409
439
|
sessionSearch: Boolean(deps.sessionStorage?.listSessions ?? deps.runStore?.listSessions), // GET /v1/sessions?q= (all lister backends carry it)
|
|
410
440
|
sessionFork: Boolean(deps.sessionStorage?.fork),
|
|
@@ -7,5 +7,18 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
9
|
import type { RouteCtx } from "../route-ctx.js";
|
|
10
|
+
/**
|
|
11
|
+
* #271 件3(#275 A2 车立的欠账,[4163] 我方认领)—— `GET /v1/memory/export` 的 keyset 分页上限。
|
|
12
|
+
*
|
|
13
|
+
* 在此之前本面是**整盘导出**:一个几千条目的盘就是一个几 MB 的响应体,壳侧(sema-web 只读页)
|
|
14
|
+
* 无从分次要 —— 那是它真实撞到的天花板。分页是 **additive** 的:`?limit=` 缺席 ⇒ 旧契约逐字不变
|
|
15
|
+
* (全量数组、响应键一个不多),所以现行消费端零破坏;要分页的调用方自己带 `limit` 进来。
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ 诚实记档:本面的分页封的是**回体**(wire 天花板),不是 DB 读 —— reader
|
|
18
|
+
* (`exportMemoryScope`)仍是整 scope 一次读出。真正的下推需要 core 的 `MemoryBackend.listHeaders`
|
|
19
|
+
* 长出分页参数(上游接缝,源头修复纪律:不在本层用别的口硬凑)。壳侧的痛点(响应体大小/首屏)由
|
|
20
|
+
* 本形解决,库侧的读放大另案。
|
|
21
|
+
*/
|
|
22
|
+
export declare const MEMORY_EXPORT_MAX_LIMIT = 500;
|
|
10
23
|
export declare function handleMemoryPolicy(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
11
24
|
//# sourceMappingURL=memory-policy.d.ts.map
|
|
@@ -3,6 +3,40 @@ import { parseMemorySyncRequest } from "../../memory-sync.js";
|
|
|
3
3
|
import { sendJson, sendError } from "../send.js";
|
|
4
4
|
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
5
5
|
const ORG_PREFIX = "org:";
|
|
6
|
+
/**
|
|
7
|
+
* #271 件3(#275 A2 车立的欠账,[4163] 我方认领)—— `GET /v1/memory/export` 的 keyset 分页上限。
|
|
8
|
+
*
|
|
9
|
+
* 在此之前本面是**整盘导出**:一个几千条目的盘就是一个几 MB 的响应体,壳侧(sema-web 只读页)
|
|
10
|
+
* 无从分次要 —— 那是它真实撞到的天花板。分页是 **additive** 的:`?limit=` 缺席 ⇒ 旧契约逐字不变
|
|
11
|
+
* (全量数组、响应键一个不多),所以现行消费端零破坏;要分页的调用方自己带 `limit` 进来。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ 诚实记档:本面的分页封的是**回体**(wire 天花板),不是 DB 读 —— reader
|
|
14
|
+
* (`exportMemoryScope`)仍是整 scope 一次读出。真正的下推需要 core 的 `MemoryBackend.listHeaders`
|
|
15
|
+
* 长出分页参数(上游接缝,源头修复纪律:不在本层用别的口硬凑)。壳侧的痛点(响应体大小/首屏)由
|
|
16
|
+
* 本形解决,库侧的读放大另案。
|
|
17
|
+
*/
|
|
18
|
+
export const MEMORY_EXPORT_MAX_LIMIT = 500;
|
|
19
|
+
/** 游标 = 条目 `id` 的不透明 base64url(声明序=id 升序;id 是 entry 平面的 PK ⇒ 唯一,单键即全序,
|
|
20
|
+
* 不像 roster 的 `(spawnedAt, handle)` 需要 tiebreak)。`before` / `nextBefore` 是本仓分页的
|
|
21
|
+
* **同一套词**(roster 先例):调用方只把上一页的 `nextBefore` 原样回传,不解读内容。 */
|
|
22
|
+
function encodeExportCursor(c) {
|
|
23
|
+
return Buffer.from(JSON.stringify(c)).toString("base64url");
|
|
24
|
+
}
|
|
25
|
+
function decodeExportCursor(raw) {
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(Buffer.from(raw, "base64url").toString());
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return { ok: false, why: `not decodable base64url JSON (${err instanceof Error ? err.name : "unknown error"})` };
|
|
32
|
+
}
|
|
33
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
34
|
+
return { ok: false, why: "cursor payload is not an object" };
|
|
35
|
+
const { id } = parsed;
|
|
36
|
+
if (typeof id !== "string" || id.length === 0)
|
|
37
|
+
return { ok: false, why: "cursor payload lacks a non-empty `id`" };
|
|
38
|
+
return { ok: true, cursor: { id } };
|
|
39
|
+
}
|
|
6
40
|
/** 本面是否让目录参与判决。闭集穷举 switch:新增第三个模式是**编译**错误,不会静默落进某个默认臂
|
|
7
41
|
* ——这条门在授权轴上,静默默认值正是它不许有的东西。 */
|
|
8
42
|
function directoryDecidesThisFace(mode) {
|
|
@@ -100,16 +134,59 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
|
|
|
100
134
|
sendError(res, 501, "capability.memory_store_required", "memory export requires a DB memory backend (MEMORY_ENGINE_BACKEND=pg|tidb)");
|
|
101
135
|
return;
|
|
102
136
|
}
|
|
103
|
-
const
|
|
137
|
+
const q = new URL(req.url ?? "", "http://x").searchParams; // route-level `url` is query-stripped
|
|
138
|
+
const scope = q.get("scope");
|
|
104
139
|
if (!scope) {
|
|
105
140
|
sendError(res, 400, "request.query_invalid", "missing ?scope=<key>");
|
|
106
141
|
return;
|
|
107
142
|
}
|
|
108
143
|
if (sendScopeGateRefusal(res, await gateMemoryScope(deps, principal, scope, "read")))
|
|
109
144
|
return;
|
|
145
|
+
// 分页参数的验型排在**属主门之后**(与 roster 的「先门后参」同序):越界者恒 404,再坏的查询串
|
|
146
|
+
// 也不许把它换成 400 —— 那就是一格「这个 scope 存在」的信号。
|
|
147
|
+
const limitRaw = q.get("limit");
|
|
148
|
+
if (limitRaw !== null && !(/^[0-9]+$/.test(limitRaw) && Number(limitRaw) >= 1 && Number(limitRaw) <= MEMORY_EXPORT_MAX_LIMIT)) {
|
|
149
|
+
sendError(res, 400, "request.query_invalid", `limit must be a decimal integer in 1..${MEMORY_EXPORT_MAX_LIMIT}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const beforeRaw = q.get("before");
|
|
153
|
+
// 🔴 `before` 必须与 `limit` 同行(codex 对抗复审 H2):否则「缺席 limit = 旧契约逐字不变」有两个
|
|
154
|
+
// 破口 —— 合法游标会让调用方拿到**过滤过的**子集却按全量读,坏游标会把旧行为的 200 全量变 400。
|
|
155
|
+
// 取**响亮拒**而不是「静默忽略 before」:后者让「分页时忘了带 limit」的调用方悄悄收到整盘,
|
|
156
|
+
// 正是本件要消灭的那个天花板。两个参数都不带的旧调用**一个字节都没碰到这条腿**。
|
|
157
|
+
if (beforeRaw !== null && limitRaw === null) {
|
|
158
|
+
sendError(res, 400, "request.query_invalid", "before requires an explicit limit — without one this endpoint returns the whole scope by contract");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const decoded = beforeRaw === null ? undefined : decodeExportCursor(beforeRaw);
|
|
162
|
+
if (decoded !== undefined && !decoded.ok) {
|
|
163
|
+
sendError(res, 400, "request.query_invalid", "before must be the opaque `nextBefore` cursor from a previous page of this endpoint", { detail: decoded.why });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
110
166
|
try {
|
|
111
167
|
const entries = await deps.memoryExport(scope);
|
|
112
|
-
|
|
168
|
+
// 🔴 缺席 limit = **旧契约逐字不变**:同一个数组、同一个顺序、响应键一个不多(sema-web 现行
|
|
169
|
+
// 消费零破坏)。分页面才排序/切片 —— 两条腿在真部署上给出同一个序(reader 是
|
|
170
|
+
// exportMemoryScope,两方言读面都 ORDER BY id),但冻结面不靠"碰巧相同",它就不动。
|
|
171
|
+
if (limitRaw === null) { // `before` 无 limit 已在上面 400 掉 ⇒ 这里等价于「两个分页参数都不在场」
|
|
172
|
+
sendJson(res, 200, { scope, exportedAt: new Date().toISOString(), entries });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// 声明序 = **id 升序**(entry 平面的既有确定序;id 是 PK ⇒ 唯一,单键游标无同值 tiebreak 洞)。
|
|
176
|
+
// 本面自己排,不继承 reader 的顺序:游标的正确性(无重无漏)是本面的承诺,不是上游的巧合。
|
|
177
|
+
const sorted = [...entries].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
178
|
+
const after = decoded?.ok === true ? sorted.filter((e) => e.id > decoded.cursor.id) : sorted;
|
|
179
|
+
const page = after.slice(0, Number(limitRaw)); // limitRaw 在此必然在场且已验型(上面两道门)
|
|
180
|
+
const last = page[page.length - 1];
|
|
181
|
+
const hasMore = last !== undefined && after.length > page.length;
|
|
182
|
+
sendJson(res, 200, {
|
|
183
|
+
scope,
|
|
184
|
+
exportedAt: new Date().toISOString(),
|
|
185
|
+
entries: page,
|
|
186
|
+
// `truncated` 与 `nextBefore` 同生同灭,但**两个都要有**:前者回答「这是全量吗」(缺席 limit
|
|
187
|
+
// 的旧契约给的就是全量,所以带 limit 时必须显式说不是),后者回答「从哪儿接着要」。
|
|
188
|
+
...(hasMore ? { truncated: true, nextBefore: encodeExportCursor({ id: last.id }) } : {}),
|
|
189
|
+
});
|
|
113
190
|
}
|
|
114
191
|
catch (err) {
|
|
115
192
|
deps.logger?.warn?.("memory_export_failed", { scope, err: String(err) });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #270 车2 —— **托管留存的 operator 治理面**(设计稿 `docs/DESIGN-270-retention-lane.md` §5/§6)。
|
|
3
|
+
*
|
|
4
|
+
* · `PUT /v1/ops/retention/holds/:domain` —— 放置 legal hold;
|
|
5
|
+
* · `DELETE /v1/ops/retention/holds/:domain` —— 解除;
|
|
6
|
+
* · `GET /v1/ops/retention/audit?domain=&limit=&before=` —— 审计读面(keyset 分页)。
|
|
7
|
+
*
|
|
8
|
+
* 形态与 `routes/adoption.ts` 同族:**部署级动作**,不是常驻业务面 ⇒ billable=false(零模型工作)、
|
|
9
|
+
* operator-only、没有 SQL 后端的部署上诚实 501。门序也逐字同它:身份(401)→ 授权(403)→ 能力(501)
|
|
10
|
+
* → 验型(400)——授权在能力之前,一个够不着任何东西的调用方不该从「有没有 SQL 后端」上读出部署形态。
|
|
11
|
+
*
|
|
12
|
+
* ── 🔴 hold 的写形是**唯一**的一条纪律(车1 交接件②,逐字)────────────────────────────────────
|
|
13
|
+
* 放置与解除都走 `setHold`(同一 PK 的 upsert 改 `held` 列),**绝不 INSERT/DELETE 行**。
|
|
14
|
+
* `retention_hold` 的行同时是**域级互斥哨兵**:车1 的三条破坏性事务首步锁读它,而
|
|
15
|
+
* `SELECT … FOR UPDATE` **锁不住一条不存在的行**(TiDB 无 gap lock;PG READ COMMITTED 同病)。
|
|
16
|
+
* 删掉行 = 下一个 PUT 与一条在飞的删除事务又能同时读到「没有 hold」⇒ operator 拿到成功回执之后数据
|
|
17
|
+
* 仍被删。本模块因此**结构上**够不着删行:它拿到的 `RetentionLaneStore` 接口上根本没有那个方法。
|
|
18
|
+
*
|
|
19
|
+
* ── 域(domain)的两个边界 ──────────────────────────────────────────────────────────────────
|
|
20
|
+
* · **无主桶**:域键 `""`(`session_meta.owner` 为 NULL/空串的那一桶)在单用户部署里是**唯一**的域。
|
|
21
|
+
* 路径段允许为空(`/v1/ops/retention/holds/`)正是为了它 —— 不给它一条路 = 单用户部署根本放不了
|
|
22
|
+
* legal hold,而那是这条面最该管用的部署形态之一。这也是为什么路由正则是 `([^/]*)` 而不是 `+`。
|
|
23
|
+
* · **列宽**:`retention_hold.domain` 是 `VARCHAR(190)`。超宽在**铸行之前**拒(与 adoption 的
|
|
24
|
+
* `checkAdoptionWidths` 同一条判据):一次静默截断会让 hold 落在一个**别的**域上。
|
|
25
|
+
*
|
|
26
|
+
* ── 审计行为什么放置/解除都要写 ────────────────────────────────────────────────────────────
|
|
27
|
+
* hold 本身就是一次留存治理动作,不留痕的 hold 是审计漏洞(设计稿 §5 逐字)。行**每次成功调用都写**,
|
|
28
|
+
* 而不是"只在状态真的翻转时写":审计表记的是**发生过的动作**(operator 在某时刻按了这个按钮),不是
|
|
29
|
+
* 状态机的边。一次对已冻结域的重复 PUT 同样是一次需要留痕的操作。
|
|
30
|
+
*
|
|
31
|
+
* 分层:本模块不值 import `server.ts`(那条边闭合运行时装载环),只 `import type`。
|
|
32
|
+
*/
|
|
33
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
34
|
+
import type { RouteCtx } from "../route-ctx.js";
|
|
35
|
+
export declare function handleRetention(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
|
|
36
|
+
//# sourceMappingURL=retention-ops.d.ts.map
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { sendJson, sendError } from "../send.js";
|
|
2
|
+
import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
3
|
+
import { RETENTION_AUDIT_DEFAULT_LIMIT, RETENTION_AUDIT_MAX_LIMIT } from "../../plugins/retention-lane-store-sql.js";
|
|
4
|
+
/** hold 路径。**`*` 不是 `+`**:空段 = 无主桶(见文件头「域的两个边界」)。 */
|
|
5
|
+
const HOLD_RE = /^\/v1\/ops\/retention\/holds\/([^/]*)$/;
|
|
6
|
+
const AUDIT_PATH = "/v1/ops/retention/audit";
|
|
7
|
+
/** `retention_hold.domain` / `retention_audit.domain` 的列宽(车1 的 DDL:`VARCHAR(190)`)。 */
|
|
8
|
+
const DOMAIN_MAX_CHARS = 190;
|
|
9
|
+
/** hold 备注的上界。`note` 列是 TEXT(不会截断),但一个无界的 operator 输入不该原样进一张**追加式**
|
|
10
|
+
* 的治理表 —— 4000 字符足够写清案号与出处,再长的属于工单系统。 */
|
|
11
|
+
const NOTE_MAX_CHARS = 4000;
|
|
12
|
+
/** operator 面的**唯一**授权文案(三条路由同受众、同理由 ⇒ 同一句;分成三句只会让 freeze 表长三倍
|
|
13
|
+
* 而消费端读到的判据一模一样)。 */
|
|
14
|
+
const OPERATOR_ONLY_MESSAGE = "managed-retention governance is an operator-only deployment action";
|
|
15
|
+
/**
|
|
16
|
+
* 审计游标 = `{id}` 的不透明 base64url。
|
|
17
|
+
*
|
|
18
|
+
* 🔴 排序键是**自增 `id` 单列**,不是 roster 那样的二元组:`id` 本身全序且唯一,二元组存在的全部理由
|
|
19
|
+
* (同毫秒兄弟行无 tiebreak ⇒ 漏页/重页)在这张表上结构性不存在。游标形仍照 roster 逐字同姿势
|
|
20
|
+
* (base64url JSON / 判别式解码 / 400 带**类名** detail)—— 消费端跨端点的游标处理代码因此同形。
|
|
21
|
+
*/
|
|
22
|
+
function encodeCursor(c) {
|
|
23
|
+
return Buffer.from(JSON.stringify(c)).toString("base64url");
|
|
24
|
+
}
|
|
25
|
+
function decodeCursor(raw) {
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(Buffer.from(raw, "base64url").toString());
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
// 只带错误的**类名**,不回显 payload 片段(与 agents-roster 的同款纪律:被误粘进查询串的凭据
|
|
32
|
+
// 不该经由错误文案再走一圈)。
|
|
33
|
+
return { ok: false, why: `not decodable base64url JSON (${err instanceof Error ? err.name : "unknown error"})` };
|
|
34
|
+
}
|
|
35
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
36
|
+
return { ok: false, why: "cursor payload is not an object" };
|
|
37
|
+
const { id } = parsed;
|
|
38
|
+
if (typeof id !== "number" || !Number.isInteger(id) || id < 0)
|
|
39
|
+
return { ok: false, why: "cursor payload lacks a non-negative integer `id`" };
|
|
40
|
+
return { ok: true, id };
|
|
41
|
+
}
|
|
42
|
+
export async function handleRetention(req, res, url, ctx) {
|
|
43
|
+
const miss = { fell: false };
|
|
44
|
+
await handleRetentionBody(req, res, url, ctx, miss);
|
|
45
|
+
return !miss.fell;
|
|
46
|
+
}
|
|
47
|
+
async function handleRetentionBody(req, res, url, ctx, miss) {
|
|
48
|
+
const { deps } = ctx;
|
|
49
|
+
const path = url.split("?")[0] ?? url;
|
|
50
|
+
const holdMatch = req.method === "PUT" || req.method === "DELETE" ? HOLD_RE.exec(path) : null;
|
|
51
|
+
const isAudit = req.method === "GET" && path === AUDIT_PATH;
|
|
52
|
+
if (!holdMatch && !isAudit) {
|
|
53
|
+
miss.fell = true;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// 门序 = operator 面的既有口径(routes/adoption.ts 逐字):身份 → 授权 → 能力 → 验型。
|
|
57
|
+
const principal = gatedPrincipal(req, deps.config); // direct-door safe:验证过的身份,不是可伪造的头
|
|
58
|
+
if (deps.config.requirePrincipal && !principal) {
|
|
59
|
+
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
|
|
63
|
+
sendError(res, 403, "auth.operator_only", OPERATOR_ONLY_MESSAGE);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const store = deps.backend?.retentionLane?.();
|
|
67
|
+
if (!store) {
|
|
68
|
+
// ⚠️ 文案里用**单引号**写 `retention:'none'`:文案冻结门的字面提取器不认带反斜杠转义的双引号串
|
|
69
|
+
// (`"([^"\n\\]*)"`),一个 `\"` 会让这条站点对那道门隐形 —— 那不是"过了门",是绕过门。
|
|
70
|
+
sendError(res, 501, "capability.retention_store_required", "managed retention requires a SQL store backend (DB_BACKEND=mysql|pg) — the local/file backend declares retention:'none' and holds nothing to govern");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (holdMatch) {
|
|
74
|
+
await handleHold(req, res, ctx, store, holdMatch[1] ?? "", principal);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await handleAuditRead(req, res, store);
|
|
78
|
+
}
|
|
79
|
+
/** 放置(PUT)/ 解除(DELETE)—— 两支共用一条 upsert(见文件头的唯一纪律)。 */
|
|
80
|
+
async function handleHold(req, res, ctx, store, rawDomain, principal) {
|
|
81
|
+
const domain = ctx.helpers.safeDecode(rawDomain);
|
|
82
|
+
if (domain === null) {
|
|
83
|
+
// 复用既有的 `request.id_invalid`(sessions/attachments/adoption 同族),不为一条新路由铸第二个
|
|
84
|
+
// 同义码 —— 附录 A 判据②:只有消费端真会分支时才铸新码。
|
|
85
|
+
sendError(res, 400, "request.id_invalid", "invalid retention domain segment");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (domain.length > DOMAIN_MAX_CHARS) {
|
|
89
|
+
sendError(res, 400, "request.field_invalid", "the retention domain does not fit the hold table's identity column", {
|
|
90
|
+
detail: `domain is ${domain.length} characters; retention_hold.domain holds at most ${DOMAIN_MAX_CHARS}`,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const held = req.method === "PUT";
|
|
95
|
+
let note;
|
|
96
|
+
if (held) {
|
|
97
|
+
// 体是**可选**的(`PUT …/holds/acme` 不带体是合法的最小放置)。空体走 `{}`。
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
raw = await ctx.helpers.readJson(req);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
sendError(res, 400, "request.invalid_json", "invalid JSON body");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (raw !== undefined && raw !== null && raw !== "") {
|
|
107
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
108
|
+
sendError(res, 400, "request.body_shape", "invalid retention hold body — expected an object with an optional `note` string");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const n = raw.note;
|
|
112
|
+
if (n !== undefined) {
|
|
113
|
+
if (typeof n !== "string") {
|
|
114
|
+
sendError(res, 400, "request.body_shape", "invalid retention hold body — expected an object with an optional `note` string");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (n.length > NOTE_MAX_CHARS) {
|
|
118
|
+
// 铸行之前拒:静默截断会把一条**被改写过的**审计事实写进一张追加式的表。
|
|
119
|
+
sendError(res, 400, "request.field_invalid", "the retention hold note is longer than this face accepts", {
|
|
120
|
+
detail: `note is ${n.length} characters; the limit is ${NOTE_MAX_CHARS}`,
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
note = n;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const nowMs = Date.now();
|
|
129
|
+
const placedBy = principal ?? "operator"; // 单用户 turnkey 部署没有 principal 这个概念(见 explicitOperatorOk)
|
|
130
|
+
// 🔴 状态变更与治理审计行走**同一个事务**(codex 对抗复审 R1-[high],验真后修)。两次独立提交下,
|
|
131
|
+
// 第二步失败会留下一次**无审计的状态变更** —— DELETE 那一支尤其重:冻结已解除、sweep 从下一拍起就能
|
|
132
|
+
// 删这个域的数据,而账上没有任何 `hold_released` 行说明是谁在什么时候解的。
|
|
133
|
+
// `mode`/`policyDays` 记的是**这一刻的部署事实**:一条 hold 行不带策略档,事后没人说得清"当时是在
|
|
134
|
+
// 什么留存策略下冻结的"。`fencingToken` 诚实留空 —— 它只属于 sweep 轮次,operator 动作不在任何一轮里。
|
|
135
|
+
await store.setHoldAudited({
|
|
136
|
+
domain,
|
|
137
|
+
held,
|
|
138
|
+
...(held ? { placedBy } : {}),
|
|
139
|
+
...(note !== undefined ? { note } : {}),
|
|
140
|
+
nowMs,
|
|
141
|
+
audit: {
|
|
142
|
+
domain,
|
|
143
|
+
action: held ? "hold_placed" : "hold_released",
|
|
144
|
+
mode: ctx.deps.config.retentionSweep?.mode ?? "audit-only",
|
|
145
|
+
policyDays: ctx.deps.config.retentionPolicy?.maxAgeDays ?? 0,
|
|
146
|
+
fencingToken: null,
|
|
147
|
+
deleted: 0,
|
|
148
|
+
skipped: 0,
|
|
149
|
+
tombstones: 0,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
sendJson(res, 200, held ? { domain, held: true, placedBy, placedAtMs: nowMs } : { domain, held: false });
|
|
153
|
+
}
|
|
154
|
+
/** 审计读面(keyset 分页;窗与游标语义见文件头)。 */
|
|
155
|
+
async function handleAuditRead(req, res, store) {
|
|
156
|
+
const q = new URL(req.url ?? "", "http://x").searchParams;
|
|
157
|
+
const limitRaw = q.get("limit");
|
|
158
|
+
if (limitRaw !== null && !(/^[0-9]+$/.test(limitRaw) && Number(limitRaw) >= 1 && Number(limitRaw) <= RETENTION_AUDIT_MAX_LIMIT)) {
|
|
159
|
+
sendError(res, 400, "request.query_invalid", `limit must be a decimal integer in 1..${RETENTION_AUDIT_MAX_LIMIT}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const limit = limitRaw === null ? RETENTION_AUDIT_DEFAULT_LIMIT : Number(limitRaw);
|
|
163
|
+
const beforeRaw = q.get("before");
|
|
164
|
+
const decoded = beforeRaw === null ? undefined : decodeCursor(beforeRaw);
|
|
165
|
+
if (decoded !== undefined && !decoded.ok) {
|
|
166
|
+
// 文案冻结在 wire 上(api-error-text-freeze 的一行);`detail` 是 additive 的**为什么**,
|
|
167
|
+
// 供调用方自诊(它描述的是调用方自己传进来的串,零跨租户信息)。
|
|
168
|
+
sendError(res, 400, "request.query_invalid", "before must be the opaque `nextBefore` cursor from a previous page of this endpoint", { detail: decoded.why });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const domainRaw = q.get("domain");
|
|
172
|
+
if (domainRaw !== null && domainRaw.length > DOMAIN_MAX_CHARS) {
|
|
173
|
+
sendError(res, 400, "request.query_invalid", `domain must be at most ${DOMAIN_MAX_CHARS} characters`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// 多取一行用来判「还有没有下一页」——不进结果。`hasMore` 因此是**事实**而不是"limit 满了就猜有"。
|
|
177
|
+
const scanned = await store.listAudit({
|
|
178
|
+
...(domainRaw !== null ? { domain: domainRaw } : {}),
|
|
179
|
+
limit: Math.min(limit + 1, RETENTION_AUDIT_MAX_LIMIT + 1),
|
|
180
|
+
...(decoded?.ok === true ? { beforeId: decoded.id } : {}),
|
|
181
|
+
});
|
|
182
|
+
const hasMore = scanned.length > limit;
|
|
183
|
+
const rows = hasMore ? scanned.slice(0, limit) : scanned;
|
|
184
|
+
const last = rows[rows.length - 1];
|
|
185
|
+
sendJson(res, 200, {
|
|
186
|
+
rows,
|
|
187
|
+
...(hasMore && last !== undefined ? { nextBefore: encodeCursor({ id: last.id }) } : {}),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=retention-ops.js.map
|