@sema-agent/server 7.30.0-rc.1 → 7.31.0-rc.1
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/dist/boot/session-faces.js +31 -1
- package/dist/http/route-ctx.d.ts +6 -1
- package/dist/http/routes/approvals-assistant.js +14 -2
- package/dist/http/routes/sessions-list.js +8 -4
- package/dist/http/routes/sessions.js +11 -5
- package/dist/http/server.js +44 -3
- 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 +1 -0
- package/dist/plugins/file-run-store.d.ts +11 -3
- package/dist/plugins/file-run-store.js +28 -7
- package/dist/plugins/local-session-store.d.ts +2 -0
- package/dist/plugins/local-session-store.js +2 -2
- package/dist/plugins/memory-run-store.d.ts +13 -2
- package/dist/plugins/memory-run-store.js +17 -5
- package/dist/plugins/permission-rule-store-file.d.ts +3 -3
- package/dist/plugins/permission-rule-store-file.js +8 -7
- package/dist/plugins/permission-rule-store-sql.d.ts +24 -2
- package/dist/plugins/permission-rule-store-sql.js +29 -7
- package/dist/plugins/pg-session-storage.d.ts +1 -0
- package/dist/plugins/pg-session-storage.js +2 -1
- package/dist/plugins/retention-store-sql.d.ts +19 -6
- package/dist/plugins/retention-store-sql.js +23 -13
- package/dist/plugins/run-store-sql.d.ts +31 -7
- package/dist/plugins/run-store-sql.js +42 -13
- package/dist/plugins/store-backend.d.ts +9 -2
- package/dist/plugins/store-backend.js +30 -7
- package/dist/plugins/tidb-session-store.d.ts +1 -0
- package/dist/plugins/tidb-session-store.js +3 -1
- package/dist/rules-consent.d.ts +7 -4
- package/dist/rules-consent.js +77 -2
- package/dist/security.d.ts +52 -1
- package/dist/security.js +51 -0
- package/dist/wall-clock-jump-guard.d.ts +91 -0
- package/dist/wall-clock-jump-guard.js +118 -0
- package/package.json +1 -1
- package/dist/boot/lexical-path-env.d.ts +0 -10
- package/dist/boot/lexical-path-env.js +0 -88
- package/dist/capabilities/oa-tools.d.ts +0 -15
- package/dist/capabilities/oa-tools.js +0 -54
- package/dist/finance/cost-taxonomy.d.ts +0 -34
- package/dist/finance/cost-taxonomy.js +0 -26
- package/dist/plugins/approval-store-sql.d.ts +0 -116
- package/dist/plugins/approval-store-sql.js +0 -151
- package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
- package/dist/plugins/file-workflow-journal-store.js +0 -12
- package/dist/plugins/pg-approval-store.d.ts +0 -9
- package/dist/plugins/pg-approval-store.js +0 -9
- package/dist/plugins/pg-breaker-state.d.ts +0 -8
- package/dist/plugins/pg-breaker-state.js +0 -8
- package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
- package/dist/plugins/pg-checkpoint-store.js +0 -10
- package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/pg-file-snapshot-store.js +0 -8
- package/dist/plugins/pg-image-bake.d.ts +0 -12
- package/dist/plugins/pg-image-bake.js +0 -11
- package/dist/plugins/pg-image-index.d.ts +0 -12
- package/dist/plugins/pg-image-index.js +0 -11
- package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
- package/dist/plugins/pg-outcome-ledger.js +0 -11
- package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/pg-resume-anchor-store.js +0 -7
- package/dist/plugins/pg-run-store.d.ts +0 -9
- package/dist/plugins/pg-run-store.js +0 -9
- package/dist/plugins/pg-session-policy-store.d.ts +0 -7
- package/dist/plugins/pg-session-policy-store.js +0 -7
- package/dist/plugins/pg-session-store.d.ts +0 -12
- package/dist/plugins/pg-session-store.js +0 -12
- package/dist/plugins/pg-tool-result-store.d.ts +0 -9
- package/dist/plugins/pg-tool-result-store.js +0 -9
- package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-journal-store.js +0 -9
- package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-run-store.js +0 -9
- package/dist/plugins/tidb-approval-store.d.ts +0 -8
- package/dist/plugins/tidb-approval-store.js +0 -8
- package/dist/plugins/tidb-breaker-state.d.ts +0 -7
- package/dist/plugins/tidb-breaker-state.js +0 -7
- package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
- package/dist/plugins/tidb-checkpoint-store.js +0 -9
- package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/tidb-file-snapshot-store.js +0 -8
- package/dist/plugins/tidb-image-bake.d.ts +0 -12
- package/dist/plugins/tidb-image-bake.js +0 -11
- package/dist/plugins/tidb-image-index.d.ts +0 -12
- package/dist/plugins/tidb-image-index.js +0 -11
- package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
- package/dist/plugins/tidb-outcome-ledger.js +0 -12
- package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/tidb-resume-anchor-store.js +0 -7
- package/dist/plugins/tidb-run-store.d.ts +0 -10
- package/dist/plugins/tidb-run-store.js +0 -9
- package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
- package/dist/plugins/tidb-session-policy-store.js +0 -7
- package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
- package/dist/plugins/tidb-tool-result-store.js +0 -10
- package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/tidb-workflow-journal-store.js +0 -9
- package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
- package/dist/plugins/tidb-workflow-run-store.js +0 -10
- package/dist/plugins/workflow-journal-limits.d.ts +0 -12
- package/dist/plugins/workflow-journal-limits.js +0 -12
- package/dist/sema-registry.d.ts +0 -41
- package/dist/sema-registry.js +0 -40
|
@@ -679,6 +679,18 @@ export class SqlRuleApprovalRecordStore {
|
|
|
679
679
|
return affected === 1;
|
|
680
680
|
}
|
|
681
681
|
}
|
|
682
|
+
/**
|
|
683
|
+
* [4232]③ 同族的 **GC 宽限**:`reapExpired` 只收「按墙钟至少已死一个完整 TTL」的票行。
|
|
684
|
+
*
|
|
685
|
+
* 为什么需要它:判定侧改单调锚之后,一张「按墙钟已过期、按单调钟还在窗内」的票是**可兑付**的;
|
|
686
|
+
* 若 GC 仍按 `expires_at_ms <= now` 收行,判定修好了而行先没了 —— 用户拿到的从 `expired` 变成 `unknown`,
|
|
687
|
+
* 缺陷只是换了个归因。宽限一个 TTL 覆盖住「铸票时钟慢了不到一个 TTL」这一族(含 [4232]③ 的真实构造)。
|
|
688
|
+
*
|
|
689
|
+
* 残余(如实登记,不假称已解):墙钟被拨慢**超过**一个 TTL 时,行仍可能在单调窗内被收。彻底解需要行上
|
|
690
|
+
* 存单调基准,而单调基准跨进程无意义(core [4235] 的判词)。补偿是现成的:prepare 廉价,重铸一张即可
|
|
691
|
+
* ——这正是 core 建议的姿态(「接受 + 票廉价重铸」)。
|
|
692
|
+
*/
|
|
693
|
+
export const RULE_IMPORT_TICKET_GC_GRACE_MS = 10 * 60_000;
|
|
682
694
|
/** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。 */
|
|
683
695
|
export function buildRulePayloadHash(candidates) {
|
|
684
696
|
return createHash("sha256").update(canonicalJson(candidates), "utf8").digest("hex");
|
|
@@ -726,8 +738,14 @@ export class SqlRuleImportTicketStore {
|
|
|
726
738
|
* expired/consumed 四类给服务端日志;wire 面把四类折成同一个 404)。预读说「可以」而 UPDATE 命中零行,
|
|
727
739
|
* 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。
|
|
728
740
|
*/
|
|
729
|
-
async consume(ticketId, principal) {
|
|
730
|
-
|
|
741
|
+
async consume(ticketId, principal, opts) {
|
|
742
|
+
// 🔴 [4232]③/[4235]③ 两只钟分家(**判定** vs **记账**),缺一不可:
|
|
743
|
+
// · `decisionNowMs` = 判定钟。缺席 ⇒ 墙钟(既有行为逐字不变);在场 ⇒ 调用方给的**单调锚投影**
|
|
744
|
+
// (铸票时刻的墙钟 + 自铸票起单调钟走过的量)。它只进那条 `expires_at_ms > ?` 的 WHERE。
|
|
745
|
+
// · `stampMs` = 记账钟,恒为真墙钟。写进 `consumed_at_ms` 的必须是「这件事在挂钟上什么时候发生」,
|
|
746
|
+
// 审计行不许被判定侧的投影污染(任务书自审第三镜:记账钟不动,只改判定钟)。
|
|
747
|
+
const stampMs = this.now();
|
|
748
|
+
const decisionNowMs = opts?.decisionNowMs ?? stampMs;
|
|
731
749
|
const ownerKey = buildRuleOwnerKey({ kind: "principal", principal });
|
|
732
750
|
// 预读:①取不可变的 approvalId/payloadHash(于是认领之后零 I/O);②给四类拒绝一个准确归因。
|
|
733
751
|
const row = await this.readRow(ticketId);
|
|
@@ -737,9 +755,9 @@ export class SqlRuleImportTicketStore {
|
|
|
737
755
|
return { ok: false, reason: "wrong-principal" };
|
|
738
756
|
if (row.consumedAtMs !== undefined)
|
|
739
757
|
return { ok: false, reason: "consumed" };
|
|
740
|
-
if (row.expiresAtMs <=
|
|
758
|
+
if (row.expiresAtMs <= decisionNowMs)
|
|
741
759
|
return { ok: false, reason: "expired" };
|
|
742
|
-
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ? WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = $1 WHERE ticket_id = $2 AND owner_key = $3 AND consumed_at_ms IS NULL AND expires_at_ms > $4`), [
|
|
760
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ? WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = $1 WHERE ticket_id = $2 AND owner_key = $3 AND consumed_at_ms IS NULL AND expires_at_ms > $4`), [stampMs, ticketId, ownerKey, decisionNowMs]);
|
|
743
761
|
// 认领成功 ⇒ 直接用预读那份不可变值返回,**不再发任何一条语句**(这正是本次修复的全部内容)。
|
|
744
762
|
if (affected === 1)
|
|
745
763
|
return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
|
|
@@ -761,13 +779,14 @@ export class SqlRuleImportTicketStore {
|
|
|
761
779
|
* 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。
|
|
762
780
|
* 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。
|
|
763
781
|
*/
|
|
764
|
-
async release(ticketId, principal, mustRemainValidMs = 0) {
|
|
782
|
+
async release(ticketId, principal, mustRemainValidMs = 0, opts) {
|
|
765
783
|
// 🔴 codex 交叉复审 round6 [medium](验真后修):`expires_at_ms` 也进 WHERE,而且比的是
|
|
766
784
|
// **now + 通告的重试窗**,不是 now。理由:调用方拿本方法的返回值去决定要不要对客户端说
|
|
767
785
|
// 「稍后拿同一张票重试」——若这张票在那几秒里就会过期,那句话是**必然兑现不了**的承诺
|
|
768
786
|
// (客户端等完 Retry-After 回来只会收到 404,而且这一轮的确认已经丢了)。撑不过窗 ⇒ 如实回 false,
|
|
769
787
|
// 调用方于是按不可重试报(四类同形 404),不给一条注定走死的路。
|
|
770
|
-
|
|
788
|
+
// `decisionNowMs`(判定钟)与 `consume` 同源同义 —— 本语句只有判定,没有记账列可写。
|
|
789
|
+
const nowMs = opts?.decisionNowMs ?? this.now();
|
|
771
790
|
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NOT NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = $1 AND owner_key = $2 AND consumed_at_ms IS NOT NULL AND expires_at_ms > $3`), [ticketId, buildRuleOwnerKey({ kind: "principal", principal }), nowMs + mustRemainValidMs]);
|
|
772
791
|
return affected === 1;
|
|
773
792
|
}
|
|
@@ -832,7 +851,10 @@ export function createSqlPermissionRuleStores(db, now) {
|
|
|
832
851
|
const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= ? LIMIT ${RULE_REAP_BATCH}`,
|
|
833
852
|
// PG 的 DELETE 没有 LIMIT ⇒ 用 ctid 子查询限行(PG 侧的标准写法;`ctid` 是物理行号,
|
|
834
853
|
// 子查询里带 LIMIT 才是被支持的那一形)。两方言的**语义**相同:本轮最多删这么多行。
|
|
835
|
-
`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})`),
|
|
854
|
+
`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})`),
|
|
855
|
+
// [4232]③:减去 {@link RULE_IMPORT_TICKET_GC_GRACE_MS} —— 判定侧走单调锚之后,
|
|
856
|
+
// 「按墙钟已过期、按单调钟还在窗内」的票是可兑付的,GC 不能抢在它前面把行收走。
|
|
857
|
+
[nowMs - RULE_IMPORT_TICKET_GC_GRACE_MS]);
|
|
836
858
|
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]);
|
|
837
859
|
return deadTickets.affected + orphanPending.affected;
|
|
838
860
|
},
|
|
@@ -75,6 +75,7 @@ export declare class PgSessionStore implements SessionStore {
|
|
|
75
75
|
/** @see TiDBSessionStore.listSessions — session identity from session_meta (owner-filtered), task_run LEFT JOIN for preview. */
|
|
76
76
|
listSessions(opts: {
|
|
77
77
|
owner?: string;
|
|
78
|
+
includeUnowned?: boolean;
|
|
78
79
|
cursor?: {
|
|
79
80
|
lastActivityAt: string;
|
|
80
81
|
sessionId: string;
|
|
@@ -292,8 +292,9 @@ export class PgSessionStore {
|
|
|
292
292
|
return `$${params.length}`;
|
|
293
293
|
};
|
|
294
294
|
const where = [];
|
|
295
|
+
// [4198] 窄互认第二臂的第三条件(TiDB 孪生逐字同义):`includeUnowned` ⇒「本人的 ∪ 无主的」。
|
|
295
296
|
if (opts.owner)
|
|
296
|
-
where.push(`sm.owner = ${ph(opts.owner)}`);
|
|
297
|
+
where.push(opts.includeUnowned ? `(sm.owner = ${ph(opts.owner)} OR sm.owner IS NULL)` : `sm.owner = ${ph(opts.owner)}`);
|
|
297
298
|
// `?q=` — the PG twin of the TiDB EXISTS predicate (LOWER-LIKE + ESCAPE, dialect-mirrored).
|
|
298
299
|
if (opts.q)
|
|
299
300
|
where.push(`EXISTS (SELECT 1 FROM task_run tr WHERE tr.session_id = sm.session_id AND LOWER(tr.objective_preview) LIKE LOWER(${ph(`%${escapeLike(opts.q)}%`)}) ESCAPE '\\')`);
|
|
@@ -277,11 +277,16 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
277
277
|
* 视界判据**两条臂都写出来**:`deadline` 在场按 deadline,缺席按 `created_at_ms`。少了后一条臂,一条
|
|
278
278
|
* 没有审批 TTL 的 pending 行就永远过不了期(design/80 §3 inv#3 的 terminal_at_ms 要挡的正是这种永久泄漏)。
|
|
279
279
|
*
|
|
280
|
-
* 🔴
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
280
|
+
* 🔴 **pin 释放 = 本方法的 `pending → expired` 翻转本身**(core [4180] 定谳:retention.ts 契约那句
|
|
281
|
+
* 「AND release the associated session pins」是真意,[4175] 引错了对象——它引的是引擎 live-path reap
|
|
282
|
+
* 的 JSDoc)。本仓 SQL 后端**没有独立的 pin 存储**:钉住 session 的正是 `checkpoint.status='pending'`
|
|
283
|
+
* 这一状态(它是 deleteExpiredSessions 活引用四腿之一,见下方 lineage 注 :79x),所以状态翻转 = 引用
|
|
284
|
+
* 解除 = pin 释放——不需要第二个写操作,而幂等(重跑对已 expired 行零命中)恰好满足 [4180] 的
|
|
285
|
+
* 「释放已释放 pin=no-op」要求。单写者不变式按 [4180] 改述:**每行的 pin 恰好释放一次,由终局该行
|
|
286
|
+
* 的车道执行**——live 行归 reaper,retention 行归本方法,两行集不交(下一段的 live-pin skip 正是
|
|
287
|
+
* 「不交」的执行判据)。
|
|
288
|
+
* 边界形([4175] 建议,[4180] 确认仍成立):**live-pinned 且已过视界**的行(`task_active` 还占着这条
|
|
289
|
+
* 会话)⇒ skip 计数、不动那行 —— 不在一条活会话脚下抽掉它的 resume 坐标,与 legal-hold 的 skip 同形。
|
|
285
290
|
*
|
|
286
291
|
* receipt 口径:`deleted` = **本次 CAS 赢下的行数**(过期销毁的是那道 pending 门,行本身按设计留在库里
|
|
287
292
|
* 供审计/对账);`tombstones` = 0(没有行被移除,写墓碑会让「墓碑 ⇒ 这行没了」这条读法失真)。
|
|
@@ -414,7 +419,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
414
419
|
* 顺序的两条承重理由(其余是可读性):
|
|
415
420
|
* · `checkpoint` / `checkpoint_ctx` 的属主门是 `session_meta.owner` 的 EXISTS 子查询 ⇒ 它们**必须**排在
|
|
416
421
|
* `session_meta` 删除**之前**(meta 一没,门就恒假、一行都删不掉);
|
|
417
|
-
* · `task_event` / `task_active` 经 `task_run`
|
|
422
|
+
* · `task_event` / `task_active` 经 `task_run` 连删 ⇒ 必须排在 `task_run` 删除**之前**。
|
|
418
423
|
* 在线协调器那边「子表先删、meta 最后」的次序还额外承担**跨事务崩溃**语义(半途失败时 meta 仍在 ⇒ 幂等
|
|
419
424
|
* 重试能收敛);本方法整段在一个事务里,那条理由在这里不成立,但次序照抄不改 —— 两条腿的等价断言
|
|
420
425
|
* (E21)比的就是"同一条契约的两个执行体",让次序分家等于自愿放弃那条判据。
|
|
@@ -423,6 +428,14 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
423
428
|
* 留存腿传的是**域键**(字符串,`""` 表示无主桶)⇒ 用 {@link SqlRetentionStore.domainWhere} 表达
|
|
424
429
|
* `(owner IS NULL OR owner = '')`。对有主域两者逐字等价(`col = ?` ⟺ `col <=> ?`,参数非 NULL);
|
|
425
430
|
* 对无主域,`<=>` 拿字符串 `""` 去比 NULL 会**一行都不匹配** —— 那正是「无主会话永远清不掉」的病。
|
|
431
|
+
* 这条改写只对 **`session_meta.owner` 这一族门**(meta / checkpoint / checkpoint_ctx)成立。
|
|
432
|
+
*
|
|
433
|
+
* 🔴 运行账本(task_event / task_active / task_run)**没有**属主门,按 `session_id` 单键删(F-A,2026-08-17
|
|
434
|
+
* 合并码扫描;与在线协调器同刀改):`task_run.owner` 是**每次提交的 principal**,与会话主会分叉(无主存量
|
|
435
|
+
* 会话被带 principal 的壳续聊 ⇒ 新行 owner="default")。行级属主门于是只删得掉半棵树,而 `session_meta`
|
|
436
|
+
* 在同一事务里已经删掉 ⇒ {@link SqlRetentionStore.sessionCandidates} 从此**永远枚举不到**这些行:它们不是
|
|
437
|
+
* "下一轮再清",是**没有任何清理路径**的孤儿。域隔离不靠这道门承担 —— `ids` 已经是**本域** session_meta
|
|
438
|
+
* 里选出来并 `FOR UPDATE` 锁住的候选集,行级门去掉之后越域仍然结构上不可能。
|
|
426
439
|
*/
|
|
427
440
|
protected purgeSessionTrees(conn: SqlTxConn, domain: string, ids: readonly string[]): Promise<number>;
|
|
428
441
|
/**
|
|
@@ -373,11 +373,16 @@ export class SqlRetentionStore {
|
|
|
373
373
|
* 视界判据**两条臂都写出来**:`deadline` 在场按 deadline,缺席按 `created_at_ms`。少了后一条臂,一条
|
|
374
374
|
* 没有审批 TTL 的 pending 行就永远过不了期(design/80 §3 inv#3 的 terminal_at_ms 要挡的正是这种永久泄漏)。
|
|
375
375
|
*
|
|
376
|
-
* 🔴
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
376
|
+
* 🔴 **pin 释放 = 本方法的 `pending → expired` 翻转本身**(core [4180] 定谳:retention.ts 契约那句
|
|
377
|
+
* 「AND release the associated session pins」是真意,[4175] 引错了对象——它引的是引擎 live-path reap
|
|
378
|
+
* 的 JSDoc)。本仓 SQL 后端**没有独立的 pin 存储**:钉住 session 的正是 `checkpoint.status='pending'`
|
|
379
|
+
* 这一状态(它是 deleteExpiredSessions 活引用四腿之一,见下方 lineage 注 :79x),所以状态翻转 = 引用
|
|
380
|
+
* 解除 = pin 释放——不需要第二个写操作,而幂等(重跑对已 expired 行零命中)恰好满足 [4180] 的
|
|
381
|
+
* 「释放已释放 pin=no-op」要求。单写者不变式按 [4180] 改述:**每行的 pin 恰好释放一次,由终局该行
|
|
382
|
+
* 的车道执行**——live 行归 reaper,retention 行归本方法,两行集不交(下一段的 live-pin skip 正是
|
|
383
|
+
* 「不交」的执行判据)。
|
|
384
|
+
* 边界形([4175] 建议,[4180] 确认仍成立):**live-pinned 且已过视界**的行(`task_active` 还占着这条
|
|
385
|
+
* 会话)⇒ skip 计数、不动那行 —— 不在一条活会话脚下抽掉它的 resume 坐标,与 legal-hold 的 skip 同形。
|
|
381
386
|
*
|
|
382
387
|
* receipt 口径:`deleted` = **本次 CAS 赢下的行数**(过期销毁的是那道 pending 门,行本身按设计留在库里
|
|
383
388
|
* 供审计/对账);`tombstones` = 0(没有行被移除,写墓碑会让「墓碑 ⇒ 这行没了」这条读法失真)。
|
|
@@ -622,7 +627,7 @@ export class SqlRetentionStore {
|
|
|
622
627
|
* 顺序的两条承重理由(其余是可读性):
|
|
623
628
|
* · `checkpoint` / `checkpoint_ctx` 的属主门是 `session_meta.owner` 的 EXISTS 子查询 ⇒ 它们**必须**排在
|
|
624
629
|
* `session_meta` 删除**之前**(meta 一没,门就恒假、一行都删不掉);
|
|
625
|
-
* · `task_event` / `task_active` 经 `task_run`
|
|
630
|
+
* · `task_event` / `task_active` 经 `task_run` 连删 ⇒ 必须排在 `task_run` 删除**之前**。
|
|
626
631
|
* 在线协调器那边「子表先删、meta 最后」的次序还额外承担**跨事务崩溃**语义(半途失败时 meta 仍在 ⇒ 幂等
|
|
627
632
|
* 重试能收敛);本方法整段在一个事务里,那条理由在这里不成立,但次序照抄不改 —— 两条腿的等价断言
|
|
628
633
|
* (E21)比的就是"同一条契约的两个执行体",让次序分家等于自愿放弃那条判据。
|
|
@@ -631,25 +636,30 @@ export class SqlRetentionStore {
|
|
|
631
636
|
* 留存腿传的是**域键**(字符串,`""` 表示无主桶)⇒ 用 {@link SqlRetentionStore.domainWhere} 表达
|
|
632
637
|
* `(owner IS NULL OR owner = '')`。对有主域两者逐字等价(`col = ?` ⟺ `col <=> ?`,参数非 NULL);
|
|
633
638
|
* 对无主域,`<=>` 拿字符串 `""` 去比 NULL 会**一行都不匹配** —— 那正是「无主会话永远清不掉」的病。
|
|
639
|
+
* 这条改写只对 **`session_meta.owner` 这一族门**(meta / checkpoint / checkpoint_ctx)成立。
|
|
640
|
+
*
|
|
641
|
+
* 🔴 运行账本(task_event / task_active / task_run)**没有**属主门,按 `session_id` 单键删(F-A,2026-08-17
|
|
642
|
+
* 合并码扫描;与在线协调器同刀改):`task_run.owner` 是**每次提交的 principal**,与会话主会分叉(无主存量
|
|
643
|
+
* 会话被带 principal 的壳续聊 ⇒ 新行 owner="default")。行级属主门于是只删得掉半棵树,而 `session_meta`
|
|
644
|
+
* 在同一事务里已经删掉 ⇒ {@link SqlRetentionStore.sessionCandidates} 从此**永远枚举不到**这些行:它们不是
|
|
645
|
+
* "下一轮再清",是**没有任何清理路径**的孤儿。域隔离不靠这道门承担 —— `ids` 已经是**本域** session_meta
|
|
646
|
+
* 里选出来并 `FOR UPDATE` 锁住的候选集,行级门去掉之后越域仍然结构上不可能。
|
|
634
647
|
*/
|
|
635
648
|
async purgeSessionTrees(conn, domain, ids) {
|
|
636
649
|
/** 每条腿都要一份新的谓词(PG 的 `$n` 编号随参数位置变)。 */
|
|
637
650
|
const set = (col, pgIndex) => this.idSet(col, ids, pgIndex);
|
|
638
|
-
// ① 运行账本:task_event → task_active → task_run(
|
|
651
|
+
// ① 运行账本:task_event → task_active → task_run(session_id 单键,无行级属主门;与协调器逐字同序)
|
|
639
652
|
{
|
|
640
653
|
const s = set("tr.session_id", 1);
|
|
641
|
-
|
|
642
|
-
await conn.query(this.q(`DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE ${s.sql} AND ${w.sql}`, `DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND ${s.sql} AND ${w.sql}`), [...s.params, ...w.params]);
|
|
654
|
+
await conn.query(this.q(`DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE ${s.sql}`, `DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND ${s.sql}`), [...s.params]);
|
|
643
655
|
}
|
|
644
656
|
{
|
|
645
657
|
const s = set("tr.session_id", 1);
|
|
646
|
-
|
|
647
|
-
await conn.query(this.q(`DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE ${s.sql} AND ${w.sql}`, `DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND ${s.sql} AND ${w.sql}`), [...s.params, ...w.params]);
|
|
658
|
+
await conn.query(this.q(`DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE ${s.sql}`, `DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND ${s.sql}`), [...s.params]);
|
|
648
659
|
}
|
|
649
660
|
{
|
|
650
661
|
const s = set("session_id", 1);
|
|
651
|
-
|
|
652
|
-
await conn.query(`DELETE FROM task_run WHERE ${s.sql} AND ${w.sql}`, [...s.params, ...w.params]);
|
|
662
|
+
await conn.query(`DELETE FROM task_run WHERE ${s.sql}`, [...s.params]);
|
|
653
663
|
}
|
|
654
664
|
// ② checkpoint + checkpoint_ctx(属主门 = session_meta.owner 的 EXISTS;**必须**在 meta 删除之前)
|
|
655
665
|
{
|
|
@@ -187,6 +187,7 @@ export declare class SqlRunStore {
|
|
|
187
187
|
* 🔴 Same builder divergence as {@link listRuns} — see its header note. */
|
|
188
188
|
listSessions(opts: {
|
|
189
189
|
owner?: string;
|
|
190
|
+
includeUnowned?: boolean;
|
|
190
191
|
cursor?: {
|
|
191
192
|
lastActivityAt: string;
|
|
192
193
|
sessionId: string;
|
|
@@ -198,12 +199,35 @@ export declare class SqlRunStore {
|
|
|
198
199
|
private rowToSession;
|
|
199
200
|
/**
|
|
200
201
|
* E21 (§0.5 session delete) — purge the service-owned RUNS LEDGER for one session: task_event (the replayable
|
|
201
|
-
* event log), task_active (the single-active-run claim), and task_run (the run rows).
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
202
|
+
* event log), task_active (the single-active-run claim), and task_run (the run rows). Idempotent — returns
|
|
203
|
+
* `{ removed }` (the count of task_run rows removed; 0 = nothing to purge). The conversation HISTORY
|
|
204
|
+
* (session_meta/session_event) is purged separately by the session store; the checkpoint/tool-result rows by
|
|
205
|
+
* their stores — this method owns only the runs-ledger tables. Done in one TX so a partial purge can't leak a
|
|
206
|
+
* task_active claim with no run row (a permanent session lock).
|
|
207
|
+
*
|
|
208
|
+
* 🔴 Scoped by `session_id` ALONE — deliberately NO per-row owner guard (F-A, 2026-08-17 合并码扫描;同一条
|
|
209
|
+
* 判断 `boot/session-faces.ts` 的 anchors 腿早就写过,run 账本这条腿当时没跟上):`task_run.owner` 是**每次
|
|
210
|
+
* 提交的 principal**,它与会话的 canonical owner 会分叉 —— 一条 `session_meta.owner = NULL` 的存量会话被
|
|
211
|
+
* 带 principal 的壳续聊后(REQUIRE_PRINCIPAL=false),新 run 行的 owner 是 `"default"`,而 DELETE 路由递进来
|
|
212
|
+
* 的是**会话行**的 owner(NULL)。行级 null-safe 属主门(`<=>` / `IS NOT DISTINCT FROM`)于是只删得掉 NULL
|
|
213
|
+
* 那批,具名那批连同它们的 task_event 全部存活 —— 而 `session_meta` 已经删了,留存腿的 sessionCandidates
|
|
214
|
+
* 再也枚举不到这棵树 ⇒ 孤儿行**没有任何清理路径**,路由却回 `{deleted:true}`。那就是一次**报成成功的
|
|
215
|
+
* 右删失效**(anchors 腿的注逐字写着这句)。删除决策的属主门在**路由层**(DELETE /v1/sessions/:id 先
|
|
216
|
+
* `ownerOf` 再比对 scope,fleet-wide 臂同门),本方法的唯一生产调用点是那条路由后面的 purge 协调器。
|
|
217
|
+
*
|
|
218
|
+
* 🔴 **化身围栏**(`expectedSessionOwner`;codex 对抗复审 R1-[high],验真后修)—— 属主门下来了,但会话
|
|
219
|
+
* **身份**必须在同一个事务里重新确认。会话 id 由调用方自选、且删除之后**可被别人重新登记**
|
|
220
|
+
* (`register` 是 INSERT … ON DUPLICATE KEY / ON CONFLICT,老行没了就是一条新行,新 owner):于是
|
|
221
|
+
* 「路由 `ownerOf` 已验权」这句话在两只并发 purge 交错时会过期 —— A 与 A' 同时通过属主门,A' 跑完整条
|
|
222
|
+
* purge 把 meta 删掉,租户 B 用同一个 uuid 重新登记并跑完一次 run,慢半拍的 A 才走到这里 ⇒ 按 session_id
|
|
223
|
+
* 单键删就把 **B 的**行删了(修前的行级属主门恰好挡住这一形,因为 B 的行 owner≠A 的会话主)。
|
|
224
|
+
* ⇒ 事务头部 `SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE`,行不在或 owner 与路由验过的
|
|
225
|
+
* 那一位不符 ⇒ **一行不删**回 `{removed:0}`(诚实:属于调用方的那个化身已经不在了;协调器据此回
|
|
226
|
+
* `{deleted:false}`,不会拿别人的行数冒充成功)。锁序与留存腿逐字一致(先 session_meta 后 task_*),
|
|
227
|
+
* 不引入新的死锁环;锁在本事务提交时释放,其后的 checkpoint / history 两腿各自带属主门。
|
|
228
|
+
* ⚠️ 本围栏只有 SQL 双生做得到 —— File/Memory 孪生的账本里没有 session_meta(local 车道的会话表在另一只
|
|
229
|
+
* 店里),它们的同名参数因此只作**契约对齐**用,真围栏由协调器那条 pre-delete 复读 + local 数据根的
|
|
230
|
+
* 独占 BootLock(一个数据根恒一个进程)承担;逐字见 memory-run-store 的同名方法注。
|
|
207
231
|
*
|
|
208
232
|
* 🔴 Active-run re-assert INSIDE the TX (adversarial-review LOW — TOCTOU): the route's pre-purge `getActiveTaskId`
|
|
209
233
|
* 409 check is a non-transactional SELECT — a concurrent createRun can claim `task_active(session_id)` in the
|
|
@@ -217,7 +241,7 @@ export declare class SqlRunStore {
|
|
|
217
241
|
* 🔴 The multi-table DELETEs are dialect-divergent SQL FORMS (not just placeholders): TiDB's
|
|
218
242
|
* `DELETE te FROM te JOIN tr ON …` vs PG's `DELETE FROM te USING tr WHERE …`.
|
|
219
243
|
*/
|
|
220
|
-
deleteBySession(sessionId: string,
|
|
244
|
+
deleteBySession(sessionId: string, expectedSessionOwner: string | null): Promise<{
|
|
221
245
|
removed: number;
|
|
222
246
|
} | {
|
|
223
247
|
active: string;
|
|
@@ -350,7 +350,8 @@ export class SqlRunStore {
|
|
|
350
350
|
const params = [];
|
|
351
351
|
let innerWhere = "";
|
|
352
352
|
if (opts.owner !== undefined) {
|
|
353
|
-
|
|
353
|
+
// [4198] 窄互认第二臂的第三条件(legacy task_run 聚合面的孪生;session_meta 面见 tidb-session-store.ts)
|
|
354
|
+
innerWhere = opts.includeUnowned ? " WHERE (owner = ? OR owner IS NULL)" : " WHERE owner = ?"; // "" is a valid exact owner, not "no filter" (B10)
|
|
354
355
|
params.push(opts.owner);
|
|
355
356
|
}
|
|
356
357
|
const outer = ["rn = 1"];
|
|
@@ -382,7 +383,7 @@ export class SqlRunStore {
|
|
|
382
383
|
params.push(v);
|
|
383
384
|
return `$${params.length}`;
|
|
384
385
|
};
|
|
385
|
-
const innerWhere = opts.owner !== undefined ? ` WHERE owner = ${p(opts.owner)}` : ""; // "" is a valid exact owner, not "no filter" (B10)
|
|
386
|
+
const innerWhere = opts.owner !== undefined ? (opts.includeUnowned ? ` WHERE (owner = ${p(opts.owner)} OR owner IS NULL)` : ` WHERE owner = ${p(opts.owner)}`) : ""; // "" is a valid exact owner, not "no filter" (B10)
|
|
386
387
|
const outer = ["rn = 1"];
|
|
387
388
|
// `?q=` — the PG twin of the TiDB EXISTS predicate above.
|
|
388
389
|
if (opts.q)
|
|
@@ -420,12 +421,35 @@ export class SqlRunStore {
|
|
|
420
421
|
}
|
|
421
422
|
/**
|
|
422
423
|
* E21 (§0.5 session delete) — purge the service-owned RUNS LEDGER for one session: task_event (the replayable
|
|
423
|
-
* event log), task_active (the single-active-run claim), and task_run (the run rows).
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
424
|
+
* event log), task_active (the single-active-run claim), and task_run (the run rows). Idempotent — returns
|
|
425
|
+
* `{ removed }` (the count of task_run rows removed; 0 = nothing to purge). The conversation HISTORY
|
|
426
|
+
* (session_meta/session_event) is purged separately by the session store; the checkpoint/tool-result rows by
|
|
427
|
+
* their stores — this method owns only the runs-ledger tables. Done in one TX so a partial purge can't leak a
|
|
428
|
+
* task_active claim with no run row (a permanent session lock).
|
|
429
|
+
*
|
|
430
|
+
* 🔴 Scoped by `session_id` ALONE — deliberately NO per-row owner guard (F-A, 2026-08-17 合并码扫描;同一条
|
|
431
|
+
* 判断 `boot/session-faces.ts` 的 anchors 腿早就写过,run 账本这条腿当时没跟上):`task_run.owner` 是**每次
|
|
432
|
+
* 提交的 principal**,它与会话的 canonical owner 会分叉 —— 一条 `session_meta.owner = NULL` 的存量会话被
|
|
433
|
+
* 带 principal 的壳续聊后(REQUIRE_PRINCIPAL=false),新 run 行的 owner 是 `"default"`,而 DELETE 路由递进来
|
|
434
|
+
* 的是**会话行**的 owner(NULL)。行级 null-safe 属主门(`<=>` / `IS NOT DISTINCT FROM`)于是只删得掉 NULL
|
|
435
|
+
* 那批,具名那批连同它们的 task_event 全部存活 —— 而 `session_meta` 已经删了,留存腿的 sessionCandidates
|
|
436
|
+
* 再也枚举不到这棵树 ⇒ 孤儿行**没有任何清理路径**,路由却回 `{deleted:true}`。那就是一次**报成成功的
|
|
437
|
+
* 右删失效**(anchors 腿的注逐字写着这句)。删除决策的属主门在**路由层**(DELETE /v1/sessions/:id 先
|
|
438
|
+
* `ownerOf` 再比对 scope,fleet-wide 臂同门),本方法的唯一生产调用点是那条路由后面的 purge 协调器。
|
|
439
|
+
*
|
|
440
|
+
* 🔴 **化身围栏**(`expectedSessionOwner`;codex 对抗复审 R1-[high],验真后修)—— 属主门下来了,但会话
|
|
441
|
+
* **身份**必须在同一个事务里重新确认。会话 id 由调用方自选、且删除之后**可被别人重新登记**
|
|
442
|
+
* (`register` 是 INSERT … ON DUPLICATE KEY / ON CONFLICT,老行没了就是一条新行,新 owner):于是
|
|
443
|
+
* 「路由 `ownerOf` 已验权」这句话在两只并发 purge 交错时会过期 —— A 与 A' 同时通过属主门,A' 跑完整条
|
|
444
|
+
* purge 把 meta 删掉,租户 B 用同一个 uuid 重新登记并跑完一次 run,慢半拍的 A 才走到这里 ⇒ 按 session_id
|
|
445
|
+
* 单键删就把 **B 的**行删了(修前的行级属主门恰好挡住这一形,因为 B 的行 owner≠A 的会话主)。
|
|
446
|
+
* ⇒ 事务头部 `SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE`,行不在或 owner 与路由验过的
|
|
447
|
+
* 那一位不符 ⇒ **一行不删**回 `{removed:0}`(诚实:属于调用方的那个化身已经不在了;协调器据此回
|
|
448
|
+
* `{deleted:false}`,不会拿别人的行数冒充成功)。锁序与留存腿逐字一致(先 session_meta 后 task_*),
|
|
449
|
+
* 不引入新的死锁环;锁在本事务提交时释放,其后的 checkpoint / history 两腿各自带属主门。
|
|
450
|
+
* ⚠️ 本围栏只有 SQL 双生做得到 —— File/Memory 孪生的账本里没有 session_meta(local 车道的会话表在另一只
|
|
451
|
+
* 店里),它们的同名参数因此只作**契约对齐**用,真围栏由协调器那条 pre-delete 复读 + local 数据根的
|
|
452
|
+
* 独占 BootLock(一个数据根恒一个进程)承担;逐字见 memory-run-store 的同名方法注。
|
|
429
453
|
*
|
|
430
454
|
* 🔴 Active-run re-assert INSIDE the TX (adversarial-review LOW — TOCTOU): the route's pre-purge `getActiveTaskId`
|
|
431
455
|
* 409 check is a non-transactional SELECT — a concurrent createRun can claim `task_active(session_id)` in the
|
|
@@ -439,19 +463,24 @@ export class SqlRunStore {
|
|
|
439
463
|
* 🔴 The multi-table DELETEs are dialect-divergent SQL FORMS (not just placeholders): TiDB's
|
|
440
464
|
* `DELETE te FROM te JOIN tr ON …` vs PG's `DELETE FROM te USING tr WHERE …`.
|
|
441
465
|
*/
|
|
442
|
-
async deleteBySession(sessionId,
|
|
466
|
+
async deleteBySession(sessionId, expectedSessionOwner) {
|
|
443
467
|
let result = { removed: 0 };
|
|
444
468
|
await this.tx(async (conn) => {
|
|
469
|
+
// 化身围栏(见头注):路由验过的那个会话化身必须**此刻仍在**,否则一行不删。
|
|
470
|
+
const { rows: meta } = await conn.query(this.q("SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE", "SELECT owner FROM session_meta WHERE session_id = $1 FOR UPDATE"), [sessionId]);
|
|
471
|
+
const metaRow = meta[0];
|
|
472
|
+
if (metaRow === undefined || (metaRow.owner ?? null) !== expectedSessionOwner)
|
|
473
|
+
return; // {removed:0}
|
|
445
474
|
// Transactional no-active-run gate (FOR UPDATE serializes against createRun's task_active claim).
|
|
446
475
|
const { rows: active } = await conn.query(this.q("SELECT task_id FROM task_active WHERE session_id = ? FOR UPDATE", "SELECT task_id FROM task_active WHERE session_id = $1 FOR UPDATE"), [sessionId]);
|
|
447
476
|
if (active[0]) {
|
|
448
477
|
result = { active: String(active[0].task_id) };
|
|
449
478
|
return; // abort — delete nothing; the route 409s (don't purge a live run's rows)
|
|
450
479
|
}
|
|
451
|
-
// task_event has no
|
|
452
|
-
await conn.query(this.q("DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE tr.session_id = ?
|
|
453
|
-
await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.session_id = ?
|
|
454
|
-
const { affected } = await conn.query(this.q("DELETE FROM task_run WHERE session_id = ?
|
|
480
|
+
// task_event has no session_id column → scope it through this session's task_run set (session_id alone).
|
|
481
|
+
await conn.query(this.q("DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE tr.session_id = ?", "DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND tr.session_id = $1"), [sessionId]);
|
|
482
|
+
await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.session_id = ?", "DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND tr.session_id = $1"), [sessionId]);
|
|
483
|
+
const { affected } = await conn.query(this.q("DELETE FROM task_run WHERE session_id = ?", "DELETE FROM task_run WHERE session_id = $1"), [sessionId]);
|
|
455
484
|
result = { removed: affected };
|
|
456
485
|
});
|
|
457
486
|
return result;
|
|
@@ -28,6 +28,9 @@ import { type AdoptionLogStore } from "./adoption-log-sql.js";
|
|
|
28
28
|
import { type SqlRetentionStore, type SqlRetentionStoreOptions } from "./retention-store-sql.js";
|
|
29
29
|
import { type RetentionLaneStore } from "./retention-lane-store-sql.js";
|
|
30
30
|
import type { PermissionRuleStoreBundle } from "../rules-consent.js";
|
|
31
|
+
/** boot 期的一条日志座(级别由**座位**决定,不由参数携带):`createStoreBackend` 的 `onWarn` / `onError`
|
|
32
|
+
* 两参与 `LocalBackend` 的同名字段共用此形。装配层把它们分别接到 `logger.warn` / `logger.error` 上。 */
|
|
33
|
+
export type BootLogSink = (msg: string, meta?: Record<string, unknown>) => void;
|
|
31
34
|
export type RunStore = TiDBRunStore | PgRunStore | FileRunStore;
|
|
32
35
|
/** E18 resume-at eventId→entryId anchor map — tidb/pg/local 3-backend (works LOCAL; needs only the session tree +
|
|
33
36
|
* this map, no cloud-only checkpoint). Union = the three nominal twins; consumers use put/resolve/deleteBySession.
|
|
@@ -278,8 +281,11 @@ export declare function counterStoreLabel(store: unknown, kind: string | undefin
|
|
|
278
281
|
*
|
|
279
282
|
* `onWarn` (additive, optional) is the boot-time warning sink the LOCAL backend needs for core's
|
|
280
283
|
* permission-rule DISCLOSURE face (#203 §1): an unreadable / checksum-mismatched / symlinked rule file
|
|
281
|
-
* answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one.
|
|
282
|
-
|
|
284
|
+
* answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one.
|
|
285
|
+
* `onError` (additive, optional; F-D) is the **error-level** twin of that seat — 件6 的
|
|
286
|
+
* `file_run_store_hydrate_unreadable` 按成文承诺是 error 级,warn 座满足不了那句话。两座都是**加项**:
|
|
287
|
+
* 既有一/二参调用方逐字不变(缺席 ⇒ 该披露退回 warn 座 / 只剩 fail-open 遥测那一半)。 */
|
|
288
|
+
export declare function createStoreBackend(config: ServiceConfig, onWarn?: BootLogSink, onError?: BootLogSink): StoreBackend | undefined;
|
|
283
289
|
/** boot 期打开 store 后端的**统一入口**:构造 + ensureSchema 同罩一层降级臂。
|
|
284
290
|
*
|
|
285
291
|
* 为什么必须把 `createStoreBackend()` 也罩进来(2026-07-28 修):clay 1.292 拍的口径是「裸 boot
|
|
@@ -292,6 +298,7 @@ export declare function createStoreBackend(config: ServiceConfig, onWarn?: (msg:
|
|
|
292
298
|
* operator 要了 durable,静默丢=丢数据。 */
|
|
293
299
|
export declare function openStoreBackendWithFallback(config: ServiceConfig, logger: {
|
|
294
300
|
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
301
|
+
error?: (msg: string, meta?: Record<string, unknown>) => void;
|
|
295
302
|
}): Promise<{
|
|
296
303
|
backend: StoreBackend | undefined;
|
|
297
304
|
degraded: boolean;
|
|
@@ -217,8 +217,13 @@ class LocalBackend {
|
|
|
217
217
|
/** boot 期的告警口(`openStoreBackendWithFallback` 把它接到 logger.warn 上)。可选:直接 `new` 一只
|
|
218
218
|
* LocalBackend 的测试/工具不必装配日志面 —— 缺席时 core 的披露仍留在 core 那侧,只是本仓不复述。 */
|
|
219
219
|
onWarn;
|
|
220
|
-
|
|
220
|
+
/** boot 期的 **error 级**口(F-D):`FileRunStore.hydrate` 的 readdir 失败按三处成文(本文件下方接线处 /
|
|
221
|
+
* `fail-open.ts` 的 tag note / 普查表第 29 行)承诺的是 **error 级**披露,而 warn 座满足不了那句话 ——
|
|
222
|
+
* 按 `level >= error` 配告警的运维会一条都收不到。可选:缺席时退回 {@link onWarn}(降一级好过无声)。 */
|
|
223
|
+
onError;
|
|
224
|
+
constructor(root, config, onWarn, onError) {
|
|
221
225
|
this.onWarn = onWarn;
|
|
226
|
+
this.onError = onError;
|
|
222
227
|
// The fileBackend ctor mkdirs `root` + takes the boot pidfile lock — a same-host SECOND instance FAILS FAST.
|
|
223
228
|
// Re-throw with a clear operator message (the raw core error is "another instance owns this data dir").
|
|
224
229
|
//
|
|
@@ -270,7 +275,17 @@ class LocalBackend {
|
|
|
270
275
|
this.sessionStore = new LocalSessionStore(new FileSessionRepo(this.fileBackend.root), this.fileBackend.root);
|
|
271
276
|
// P0.5 variant-1: file-backed runs ledger + E18 anchors over the SAME canonical root (under root/runs + root/
|
|
272
277
|
// resume-anchors). Hydrate the in-memory index from disk on construction → restart-visible.
|
|
273
|
-
|
|
278
|
+
// 件6(#43 案 [4281]):hydrate 的 readdir 失败要**响亮**——把 boot 告警口接过去(缺席则只走 fail-open
|
|
279
|
+
// 遥测那一半)。包一层箭头而不是直接传方法引用:后者会把方法从接收者上摘下来,带 `this` 的实现会在
|
|
280
|
+
// 第一次真披露时炸,而那一刻正是最不该炸的时候(本文件 :658 同族先例)。
|
|
281
|
+
// 🔴 F-D(2026-08-17 合并码扫描):这一座必须接 **error** 口。件6 三处成文(FileRunStore 那条 catch 注 /
|
|
282
|
+
// `fail-open.ts` 的 tag note / 普查表第 29 行)承诺的都是「一行 **error 级**启动日志」,而修前它被绑到
|
|
283
|
+
// `onWarn` 上、且 `openStoreBackendWithFallback` 的 logger 参数类型里压根没有 error 通道 ⇒ 生产恒 warn,
|
|
284
|
+
// 按 `level >= error` 配告警的运维一条都收不到,而这条债的全部价值就是「恒 miss 不再被当成本来就没有」。
|
|
285
|
+
// `onError` 缺席(只装配了 warn 座的调用方)⇒ 退回 warn 座:降一级好过无声。
|
|
286
|
+
this.runStore = new FileRunStore(this.fileBackend.root, {
|
|
287
|
+
error: (msg, fields) => void (this.onError ?? this.onWarn)?.(msg, fields),
|
|
288
|
+
});
|
|
274
289
|
this.resumeAnchorStore = new FileResumeAnchorStore(this.fileBackend.root);
|
|
275
290
|
this.approvalExemptionStore = new FileApprovalExemptionStore(this.fileBackend.root);
|
|
276
291
|
this.sendFileLedgerStore = new FileSendFileLedger(this.fileBackend.root);
|
|
@@ -377,10 +392,13 @@ export function counterStoreLabel(store, kind) {
|
|
|
377
392
|
*
|
|
378
393
|
* `onWarn` (additive, optional) is the boot-time warning sink the LOCAL backend needs for core's
|
|
379
394
|
* permission-rule DISCLOSURE face (#203 §1): an unreadable / checksum-mismatched / symlinked rule file
|
|
380
|
-
* answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one.
|
|
381
|
-
|
|
395
|
+
* answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one.
|
|
396
|
+
* `onError` (additive, optional; F-D) is the **error-level** twin of that seat — 件6 的
|
|
397
|
+
* `file_run_store_hydrate_unreadable` 按成文承诺是 error 级,warn 座满足不了那句话。两座都是**加项**:
|
|
398
|
+
* 既有一/二参调用方逐字不变(缺席 ⇒ 该披露退回 warn 座 / 只剩 fail-open 遥测那一半)。 */
|
|
399
|
+
export function createStoreBackend(config, onWarn, onError) {
|
|
382
400
|
if (config.dbBackend === "local")
|
|
383
|
-
return new LocalBackend(config.localDataRoot ?? join(homedir(), ".ai-agent"), config, onWarn);
|
|
401
|
+
return new LocalBackend(config.localDataRoot ?? join(homedir(), ".ai-agent"), config, onWarn, onError);
|
|
384
402
|
if (config.dbBackend === "pg") {
|
|
385
403
|
if (!config.pg)
|
|
386
404
|
return undefined;
|
|
@@ -402,7 +420,11 @@ export function createStoreBackend(config, onWarn) {
|
|
|
402
420
|
* 降级条件不变(两形同一姿势):`sessionBackend=auto`(配置的 DB 不可达,S5 原形)或**默认推导**的
|
|
403
421
|
* local(`dbBackend=local && !dbBackendExplicit`)。**显式** DB_BACKEND=local / tidb 仍 fail-loud——
|
|
404
422
|
* operator 要了 durable,静默丢=丢数据。 */
|
|
405
|
-
export async function openStoreBackendWithFallback(config,
|
|
423
|
+
export async function openStoreBackendWithFallback(config,
|
|
424
|
+
// 🔴 `error` 是**可选加项**(F-D):件6 的 hydrate 披露按成文承诺是 error 级,而本参数此前只有 warn 通道
|
|
425
|
+
// ⇒ 那句承诺在生产链上结构性地兑不了(注释说 error、代码只出 warn,正是本文件下方 :659 自陈的病族)。
|
|
426
|
+
// 仍可选:只装配 warn 座的调用方(测试/工具)逐字不变,它们的 hydrate 披露退回 warn 座。
|
|
427
|
+
logger) {
|
|
406
428
|
// 判据按后端分岔(2026-07-28 同批修):原先写的是 `sessionBackend === "auto" || (local && !explicit)`,
|
|
407
429
|
// 而 `auto` 是 SESSION_BACKEND 的**默认值** ⇒ 显式 `DB_BACKEND=local` 的部署照样落进第一条臂被静默降级,
|
|
408
430
|
// 「显式仍 fail-loud」这句话同样从未生效。local 形只看 DB_BACKEND 是不是显式;SQL 形维持 S5 原义
|
|
@@ -412,7 +434,8 @@ export async function openStoreBackendWithFallback(config, logger) {
|
|
|
412
434
|
try {
|
|
413
435
|
// 包一层箭头而不是直接传 `logger.warn`:后者会把方法从它的接收者上摘下来,一个带 `this` 的
|
|
414
436
|
// logger 实现会在第一次真披露时炸——而那一刻正是最不该炸的时候(本仓 fail-loud 病族先例)。
|
|
415
|
-
|
|
437
|
+
// error 座同款包法;调用方没有 error 通道时**不传**(LocalBackend 那侧据此退回 warn 座)。
|
|
438
|
+
backend = createStoreBackend(config, (msg, meta) => logger.warn(msg, meta), logger.error === undefined ? undefined : (msg, meta) => logger.error(msg, meta));
|
|
416
439
|
// connects + creates schema (also the reachability probe).
|
|
417
440
|
if (backend)
|
|
418
441
|
await backend.ensureSchema();
|
|
@@ -120,7 +120,9 @@ export class TiDBSessionStore {
|
|
|
120
120
|
const params = [];
|
|
121
121
|
const where = [];
|
|
122
122
|
if (opts.owner) {
|
|
123
|
-
|
|
123
|
+
// [4198] 窄互认第二臂的第三条件在此:`includeUnowned` 时谓词放宽到「本人的 ∪ 无主的」。
|
|
124
|
+
// 仍然**不是** `owner IS NOT DISTINCT FROM ?`——具名的别人一行都不多给。
|
|
125
|
+
where.push(opts.includeUnowned ? "(sm.owner = ? OR sm.owner IS NULL)" : "sm.owner = ?"); // idx_owner
|
|
124
126
|
params.push(opts.owner);
|
|
125
127
|
}
|
|
126
128
|
if (opts.q) {
|
package/dist/rules-consent.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type RuleOwner, type ImportPreview, type ImportResult, type ImportedSettingsLayer, type PersistedAllowRule, type RemoveResult, type RuleScope } from "@sema-agent/core";
|
|
2
2
|
import type { PermissionRuleStoreProvider, RuleApprovalRecordStore } from "@sema-agent/core";
|
|
3
|
-
import { type RuleImportTicket, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
|
|
3
|
+
import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
|
|
4
4
|
/**
|
|
5
5
|
* 本车道**真正**依赖的三面(**结构**接口,不是 SQL 类)。
|
|
6
6
|
*
|
|
@@ -28,11 +28,12 @@ export interface RuleConsentStores {
|
|
|
28
28
|
}>;
|
|
29
29
|
ttlMs: number;
|
|
30
30
|
}): Promise<RuleImportTicket>;
|
|
31
|
-
/** 一次**认领**(不是「已完成」)。语义与「认领之后怎么办」见 {@link RuleConsentStores.tickets.release}。
|
|
32
|
-
|
|
31
|
+
/** 一次**认领**(不是「已完成」)。语义与「认领之后怎么办」见 {@link RuleConsentStores.tickets.release}。
|
|
32
|
+
* `opts.decisionNowMs` = [4232]③ 的判定钟覆写(记账列不受影响),见 {@link RuleTicketDecisionClock}。 */
|
|
33
|
+
consume(ticketId: string, principal: string, opts?: RuleTicketDecisionClock): Promise<RuleTicketRedeemResult>;
|
|
33
34
|
/** 把认领**放回去**(认领之后遇到不确定/瞬时失败时)。`mustRemainValidMs` = 放回之后这张票至少还要
|
|
34
35
|
* 能用多久 —— 撑不过就**不算放回成功**(理由逐字见 store 侧 `release` 的头注)。 */
|
|
35
|
-
release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
|
|
36
|
+
release(ticketId: string, principal: string, mustRemainValidMs?: number, opts?: RuleTicketDecisionClock): Promise<boolean>;
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
@@ -219,5 +220,7 @@ export interface RuleConsentLane {
|
|
|
219
220
|
export declare function createRuleConsentLane(stores: RuleConsentStores, opts?: {
|
|
220
221
|
ticketTtlMs?: number;
|
|
221
222
|
newTicketId?: () => string;
|
|
223
|
+
/** [4232]③ 的单调钟 seam。缺省 `performance.now()` —— 本仓既定单调域。 */
|
|
224
|
+
monotonic?: () => number;
|
|
222
225
|
}): RuleConsentLane;
|
|
223
226
|
//# sourceMappingURL=rules-consent.d.ts.map
|