@sema-agent/server 7.59.0 → 7.60.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/README.md +4 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +8 -1
- package/dist/boot/stores.d.ts +1 -0
- package/dist/boot/stores.js +4 -1
- package/dist/http/routes/approvals-assistant.js +3 -0
- package/dist/http/routes/capabilities.js +2 -0
- package/dist/http/routes/diagnostics.js +1 -0
- package/dist/http/server.d.ts +6 -0
- package/dist/main.js +10 -4
- package/dist/plugins/approval-ask-store-sql.d.ts +9 -9
- package/dist/plugins/approval-ask-store-sql.js +1 -1
- package/dist/plugins/device-store-sql.d.ts +11 -7
- package/dist/plugins/device-store-sql.js +6 -6
- package/dist/plugins/file-history-store-sql.d.ts +15 -3
- package/dist/plugins/file-history-store-sql.js +32 -29
- package/dist/plugins/image-bake-store-sql.js +1 -1
- package/dist/plugins/permission-rule-store-file.d.ts +4 -4
- package/dist/plugins/permission-rule-store-file.js +4 -4
- package/dist/plugins/permission-rule-store-sql.d.ts +15 -11
- package/dist/plugins/permission-rule-store-sql.js +7 -11
- package/dist/plugins/retention-lane-store-sql.d.ts +2 -2
- package/dist/plugins/retention-store-sql.d.ts +4 -12
- package/dist/plugins/retention-store-sql.js +4 -0
- package/dist/plugins/session-policy-store-sql.d.ts +9 -2
- package/dist/plugins/shared-memory-store-sql.d.ts +25 -19
- package/dist/plugins/shared-memory-store-sql.js +17 -15
- package/dist/plugins/sql-driver.d.ts +78 -26
- package/dist/plugins/sql-driver.js +97 -14
- package/dist/plugins/store-backend.d.ts +6 -2
- package/dist/rules-consent.d.ts +54 -12
- package/dist/rules-consent.js +4 -4
- package/dist/sql-engine-posture.d.ts +49 -0
- package/dist/sql-engine-posture.js +13 -0
- package/dist/tool-approval.d.ts +18 -0
- package/dist/tool-approval.js +1 -0
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/project.d.ts +25 -0
- package/dist/trace/project.js +26 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,7 +30,10 @@ model gateways, and cloud agent execution behind an HTTP/SSE contract.
|
|
|
30
30
|
- **Stateless by design.** Replicas are cattle: `docker run` starts one, start as many as you need
|
|
31
31
|
behind a load balancer. Durable state — sessions, runs, replayable event logs, checkpoints,
|
|
32
32
|
approvals — lives in an external SQL store (any MySQL-protocol database such as MySQL/TiDB/MariaDB,
|
|
33
|
-
or PostgreSQL; a file-backed `local` mode covers single-machine use).
|
|
33
|
+
or PostgreSQL; a file-backed `local` mode covers single-machine use). Each pooled connection pins its
|
|
34
|
+
own session isolation (`REPEATABLE READ` on the MySQL-protocol leg, `READ COMMITTED` on PostgreSQL) and
|
|
35
|
+
verifies it by reading back; on TiDB the session is switched to pessimistic transaction mode, and boot is
|
|
36
|
+
refused only when that switch cannot be made or verified — see docs/DEPLOY-PREREQS.md. Any replica can serve any
|
|
34
37
|
run's event stream; a task can suspend on one replica and resume on another.
|
|
35
38
|
- **The full server-side capability surface** (discoverable at `GET /v1/capabilities`):
|
|
36
39
|
asynchronous runs with replayable SSE, durable checkpoints with human-in-the-loop approvals,
|
package/README.zh-CN.md
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
不进请求体、不进模型、不进沙箱。
|
|
30
30
|
- **无状态设计。** 副本是 cattle:`docker run` 即起一个,挂在负载均衡后想开多少开多少。
|
|
31
31
|
持久化状态 —— 会话、run、可重放事件日志、检查点、审批 —— 全在外部 SQL 存储
|
|
32
|
-
(任何 MySQL 协议数据库:MySQL/TiDB/MariaDB,或 PostgreSQL;单机场景另有文件持久化的
|
|
32
|
+
(任何 MySQL 协议数据库:MySQL/TiDB/MariaDB,或 PostgreSQL;每条池连接自钉会话隔离级(MySQL 协议腿 `REPEATABLE READ`、PostgreSQL `READ COMMITTED`)并回读复核,TiDB 上会话切到悲观事务模式、切不成或复核不符才拒启,见 docs/DEPLOY-PREREQS.md;单机场景另有文件持久化的
|
|
33
33
|
`local` 模式)。任意副本都能服务任意 run 的事件流;任务可以在一个副本上挂起、在另一个副本上恢复。
|
|
34
34
|
- **完整的服务端能力面**(可在 `GET /v1/capabilities` 发现):
|
|
35
35
|
异步 run + 可重放 SSE、持久化检查点 + 人工审批(HITL)、会话与长期记忆、
|
package/USAGE.md
CHANGED
|
@@ -29,7 +29,14 @@ docker compose up --build
|
|
|
29
29
|
# 起来后 OA 只认 http://<host>:8090(LB)
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
**生产(N worker 集群,需 MySQL 协议库——MySQL/TiDB 均可;支持异步 `/v1/runs` + 断线重连)**
|
|
32
|
+
**生产(N worker 集群,需 MySQL 协议库——MySQL(≥8.0)/ MariaDB(≥10.5)/ TiDB 均可;支持异步 `/v1/runs` + 断线重连)**
|
|
33
|
+
|
|
34
|
+
> 🔒 7.60.0 起本仓在**每条池连接**上把会话隔离级钉死并**回读复核**:MySQL 协议腿 `REPEATABLE READ`
|
|
35
|
+
> + TiDB 悲观事务模式(设不上就拒启),PG 腿 `READ COMMITTED`(同样是设了再验,不问服务器缺省 ——
|
|
36
|
+
> 把 PG 钉成 RR 或让部署把默认配成 RR/SERIALIZABLE,都会让每条 `FOR UPDATE` 变成 `40001`)。本仓所有
|
|
37
|
+
> `SELECT … FOR UPDATE` 的单赢家判据都以此为前提。运维读面:
|
|
38
|
+
> `GET /v1/diagnostics/wiring` 的 `sqlEngine` 段与 `GET /v1/capabilities` 的 `sql` 位。判据与升级句见
|
|
39
|
+
> `docs/DEPLOY-PREREQS.md`「事务读语义的结构保证」。
|
|
33
40
|
```bash
|
|
34
41
|
IMAGE_REGISTRY=<registry-namespace> \
|
|
35
42
|
DB_BACKEND=mysql MYSQL_HOST=… MYSQL_USER=… MYSQL_PASSWORD=… MYSQL_DATABASE=sema_server \
|
package/dist/boot/stores.d.ts
CHANGED
|
@@ -31,5 +31,6 @@ export declare function openStores(ctx: OpenStoresCtx): Promise<{
|
|
|
31
31
|
usageWindowStore: import("@sema-agent/core").UsageWindowStore | undefined;
|
|
32
32
|
memoryPosture: import("../memory-posture.js").MemoryPosture;
|
|
33
33
|
taskListLane: import("./task-list-lane.js").TaskListLane;
|
|
34
|
+
sqlEngineFacts: (() => import("../plugins/sql-driver.js").SqlEngineFacts | null) | undefined;
|
|
34
35
|
}>;
|
|
35
36
|
//# sourceMappingURL=stores.d.ts.map
|
package/dist/boot/stores.js
CHANGED
|
@@ -29,6 +29,7 @@ import { assertFileHistorySchema } from "../plugins/file-history-store-sql.js";
|
|
|
29
29
|
import { resolveStreamApprovalGate } from "../tool-approval.js";
|
|
30
30
|
import { buildMemoryWriteBoundaryAudit, effectiveMemoryPersistenceCapable } from "./memory-boundary.js";
|
|
31
31
|
import { buildMemoryPosture } from "../memory-posture.js";
|
|
32
|
+
import { establishSqlEnginePosture } from "../sql-engine-posture.js";
|
|
32
33
|
const DARK_REASON_PROSE = {
|
|
33
34
|
engine_off: () => "MEMORY_ENGINE=off",
|
|
34
35
|
multi_tenant_file_backend: () => "multi-tenant on the FILE backend (no tenant isolation; set MEMORY_ENGINE_BACKEND=pg|tidb to light up)",
|
|
@@ -387,9 +388,11 @@ export async function openStores(ctx) {
|
|
|
387
388
|
await ensureTiDBTaskListSchema(mysqlPool);
|
|
388
389
|
return createTaskListLane({ mysqlPool, pgPool, logger });
|
|
389
390
|
})();
|
|
391
|
+
let sqlEngineFacts;
|
|
390
392
|
{
|
|
391
393
|
const mysqlPool = backend?.mysqlPool?.();
|
|
392
394
|
const pgPool = backend?.pgPool?.();
|
|
395
|
+
sqlEngineFacts = await establishSqlEnginePosture({ mysqlPool, pgPool });
|
|
393
396
|
if (pgPool)
|
|
394
397
|
await ensurePgDeviceSchema(async (text, params) => pgPool.query(text, params));
|
|
395
398
|
if (mysqlPool)
|
|
@@ -433,7 +436,7 @@ export async function openStores(ctx) {
|
|
|
433
436
|
return {
|
|
434
437
|
backend, storeBackendDegraded, memoryEngine, memorySyncCursors, rosterStore, backgroundAgentStore,
|
|
435
438
|
taskAttachmentStore, mailboxStore, memoryExportBackend, memorySyncRunner, sessionStore, breakerState,
|
|
436
|
-
usageWindowStore, memoryPosture, taskListLane,
|
|
439
|
+
usageWindowStore, memoryPosture, taskListLane, sqlEngineFacts,
|
|
437
440
|
};
|
|
438
441
|
}
|
|
439
442
|
//# sourceMappingURL=stores.js.map
|
|
@@ -177,6 +177,9 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
177
177
|
...(s.spentMicroUsd !== undefined ? { spentMicroUsd: s.spentMicroUsd } : {}),
|
|
178
178
|
...(s.deadline !== undefined ? { deadline: s.deadline } : {}),
|
|
179
179
|
...(s.hasBidiControls === true ? { hasBidiControls: true } : {}),
|
|
180
|
+
...(s.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
181
|
+
...(s.denialLimitFallback !== undefined ? { denialLimitFallback: s.denialLimitFallback } : {}),
|
|
182
|
+
...(s.origin !== undefined ? { origin: s.origin } : {}),
|
|
180
183
|
objective: ctx?.body?.objective ?? null,
|
|
181
184
|
input,
|
|
182
185
|
};
|
|
@@ -9,6 +9,7 @@ import { mcpInjectionHonored } from "../../task-mcp.js";
|
|
|
9
9
|
import { sendJson, sendError } from "../send.js";
|
|
10
10
|
import { resolveStreamApprovalGate } from "../../tool-approval.js";
|
|
11
11
|
import { projectMemoryEngineCapability } from "../../memory-posture.js";
|
|
12
|
+
import { projectSqlEngineCapability } from "../../sql-engine-posture.js";
|
|
12
13
|
import { workflowModelAllowlistFor, selfOrchestrationDenial } from "../../task-workflow.js";
|
|
13
14
|
import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
|
|
14
15
|
import { judgeAutoModeArming } from "../../auto-mode-face.js";
|
|
@@ -76,6 +77,7 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
|
|
|
76
77
|
memory: false,
|
|
77
78
|
memoryWrite: false,
|
|
78
79
|
memoryEngine: projectMemoryEngineCapability(deps.memoryPosture),
|
|
80
|
+
sql: projectSqlEngineCapability(deps.sqlEngineFacts?.()),
|
|
79
81
|
sessionBackgroundable: Boolean(deps.runStore),
|
|
80
82
|
runMemoryCaptureOptOut: Boolean(deps.runStore),
|
|
81
83
|
sharedMemory: Boolean(deps.sharedMemoryStore && deps.orgMemoryDirectory),
|
|
@@ -86,6 +86,7 @@ async function handleDiagnosticsBody(req, res, url, ctx, miss) {
|
|
|
86
86
|
sendJson(res, 200, {
|
|
87
87
|
static: deps.staticWiring,
|
|
88
88
|
memoryPosture: deps.memoryPosture ?? null,
|
|
89
|
+
sqlEngine: deps.sqlEngineFacts?.() ?? null,
|
|
89
90
|
configApply: deps.configApplyState?.() ?? null,
|
|
90
91
|
mcpRevocations: deps.mcpRevocationState?.() ?? null,
|
|
91
92
|
serverGates: buildServerWiringGates({
|
package/dist/http/server.d.ts
CHANGED
|
@@ -654,6 +654,12 @@ export interface ServiceDeploymentDeps {
|
|
|
654
654
|
* `GET /v1/capabilities` 的 `memoryEngine` 位(窄投影)。缺席 = 这个进程不是由 composition root 装
|
|
655
655
|
* 起来的(测试夹具形)⇒ 诊断面报 null、能力位取诚实下限 false。 */
|
|
656
656
|
memoryPosture?: import("../memory-posture.js").MemoryPosture;
|
|
657
|
+
/** S-131:SQL 引擎姿态的 **live getter**(`sql-engine-posture.ts` 的单一推导点 —— boot 期借一条池连接
|
|
658
|
+
* 触发驱动的连接初始化,读的是 `SET` 之后**回读复核**过的真值)。两个读面共用:
|
|
659
|
+
* `GET /v1/diagnostics/wiring` 的 `sqlEngine` 段(operator-only,全量含版本)与 `GET /v1/capabilities`
|
|
660
|
+
* 的 `sql` 位(窄投影)。getter 而不是快照:池重连后驱动会重新初始化并刷新事实,快照会说谎。
|
|
661
|
+
* 缺席 / 返回 null = 本部署没有 SQL 后端(env-only worker、local 文件后端)—— 诚实缺席,不铸空壳。 */
|
|
662
|
+
sqlEngineFacts?: () => import("../plugins/sql-driver.js").SqlEngineFacts | null;
|
|
657
663
|
/** ① core ruling — SPLIT: `capabilities.workflows` = the ENGINE-CAN axis, boot-computed from core's own
|
|
658
664
|
* `workflowsCapability(deps)` (hardened script runner ∧ governance), NOT the `Boolean(workflowRunStore)` store
|
|
659
665
|
* proxy. Orthogonal to `workflowsList` (the durable-list axis = `workflowRunStore`). Falls back to the store
|
package/dist/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { Runner, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, probeSearchBackend, describeStaticWiring, expandTiers, DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
|
|
4
|
+
import { Runner, createPermissionRuleStoreProvider, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, probeSearchBackend, describeStaticWiring, expandTiers, DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
|
|
5
5
|
import { createSessionTitler } from "./session-titler.js";
|
|
6
6
|
import { posIntEnv } from "./session-watch.js";
|
|
7
7
|
import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
|
|
@@ -128,7 +128,7 @@ async function main() {
|
|
|
128
128
|
}
|
|
129
129
|
const configCenter = await createConfigCenterRuntime({ config, logger, metrics, localRoot });
|
|
130
130
|
await configCenter.applyLocalRemoteExec();
|
|
131
|
-
const { backend, storeBackendDegraded, memoryEngine, memorySyncCursors, rosterStore, backgroundAgentStore, taskAttachmentStore, mailboxStore, memoryExportBackend, memorySyncRunner, sessionStore, breakerState, usageWindowStore, memoryPosture, taskListLane, } = await openStores({ config, logger, metrics, localRoot });
|
|
131
|
+
const { backend, storeBackendDegraded, memoryEngine, memorySyncCursors, rosterStore, backgroundAgentStore, taskAttachmentStore, mailboxStore, memoryExportBackend, memorySyncRunner, sessionStore, breakerState, usageWindowStore, memoryPosture, taskListLane, sqlEngineFacts, } = await openStores({ config, logger, metrics, localRoot });
|
|
132
132
|
const instanceId = uuidv7();
|
|
133
133
|
const sessionCaptureRecordStore = backend?.sessionCaptureRecords();
|
|
134
134
|
const memoryBundleFaces = memoryEngine ? createMemoryBundleFaces(memoryEngine, { ...(sessionCaptureRecordStore !== undefined ? { captureRecordStore: sessionCaptureRecordStore } : {}) }) : undefined;
|
|
@@ -153,7 +153,12 @@ async function main() {
|
|
|
153
153
|
}
|
|
154
154
|
const { brain, pricing, counterDegradeHook, costQuota, modelUsageTracker, promptManifestTracker, fleetUsage, fleetLease, tracer, sideQueryAccounting, toolResultStore, sessionPolicyStore, fileHistoryStore, } = createBudgetAndTracing({ config, logger, metrics, backend, breakerState });
|
|
155
155
|
const permissionRuleStores = config.permissionRulesEnabled && backend ? backend.permissionRule() : undefined;
|
|
156
|
-
const
|
|
156
|
+
const permissionRuleProvider = permissionRuleStores
|
|
157
|
+
? createPermissionRuleStoreProvider({ durable: permissionRuleStores.durable })
|
|
158
|
+
: undefined;
|
|
159
|
+
const ruleConsent = permissionRuleStores && permissionRuleProvider
|
|
160
|
+
? createRuleConsentLane({ ...permissionRuleStores, provider: permissionRuleProvider })
|
|
161
|
+
: undefined;
|
|
157
162
|
if (config.permissionRulesEnabled && permissionRuleStores === undefined) {
|
|
158
163
|
logger.info("permission_rules_lane_unavailable", {
|
|
159
164
|
knob: "PERMISSION_RULES_ENABLED",
|
|
@@ -268,7 +273,7 @@ async function main() {
|
|
|
268
273
|
sessionCaptureRecordStore,
|
|
269
274
|
memorySyncRunner, toolResultStore: runnerOffloadStore, sessionPolicyStore, runtimeCapsResolver, fileHistoryStore,
|
|
270
275
|
legacyRewindBoundaryProbe: backend?.legacyRewindBoundaryProbe ? backend.legacyRewindBoundaryProbe.bind(backend) : undefined,
|
|
271
|
-
permissionRuleStore:
|
|
276
|
+
permissionRuleStore: permissionRuleProvider,
|
|
272
277
|
executionEnvFactory, lspManager, fleetBus, deploymentHooks, workflowRunStore, workflowJournalStore,
|
|
273
278
|
workflowAgentRegistry, workflowNotifyGate, workflowCompletionInbox, deliverWorkflowCompletion, orgMemoryAdmission,
|
|
274
279
|
governanceSeams,
|
|
@@ -695,6 +700,7 @@ async function main() {
|
|
|
695
700
|
workflowModelAllowlist: workflowModelAllowlistSnapshot,
|
|
696
701
|
staticWiring,
|
|
697
702
|
memoryPosture,
|
|
703
|
+
sqlEngineFacts,
|
|
698
704
|
instanceId,
|
|
699
705
|
capabilities: {
|
|
700
706
|
version: serviceVersion(),
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* (`rev = rev + 1`),这样只要「行在且谓词命中」就必然 affected=1,不会被这个驱动怪癖
|
|
15
15
|
* 误判成「CAS 输了」。本文件每一条 CAS UPDATE 都带 `rev = rev + 1`。
|
|
16
16
|
* 3. 事务:走 `SqlDriver.connect()` → `conn.begin()` → 一串 `conn.query()` → `conn.commit()`,失败
|
|
17
|
-
* `conn.rollback()`(形照 image-bake-store-sql.ts 的 try/catch/finally 骨架)
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* `conn.rollback()`(形照 image-bake-store-sql.ts 的 try/catch/finally 骨架)。事务动词只有一个;
|
|
18
|
+
* 读语义(隔离级 / TiDB 悲观模式 / 事务内集合读必须锁读)的判据只有一处:{@link SqlTxConn.begin}
|
|
19
|
+
* 的 `@contract txn.read-semantics`。本店每条 CAS 都是对已存在主键行的 `UPDATE … WHERE state=?`
|
|
20
|
+
* (DML 对被更新行恒是 current read,与快照无关),败方读数则一律 `SELECT … FOR UPDATE`。
|
|
21
21
|
*
|
|
22
22
|
* ── 方言差异(显式写在每个调用点,[ref] A12 doctrine)──────────────────────────────────────────
|
|
23
23
|
* - `?` 占位符(位置序)vs `$n`(显式编号)——动态列清单(`transitionAsk`/`resolveProvisional`,列集合
|
|
@@ -503,11 +503,11 @@ export declare class SqlApprovalAskStore implements ApprovalAskStore {
|
|
|
503
503
|
* 取消臂的 STREAM_PENDING→VOID(此前走通用 `transitionAsk`,无败方读数)。
|
|
504
504
|
*
|
|
505
505
|
* **原子性证明(两方言各一句,红先钉在 db-integration 的 [ref] 组)**:
|
|
506
|
-
* · 败方读数是**同一事务内**的 `SELECT … FOR UPDATE` —— TiDB
|
|
507
|
-
* (
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* `
|
|
506
|
+
* · 败方读数是**同一事务内**的 `SELECT … FOR UPDATE` —— TiDB 侧的悲观锁定读是 CURRENT read,读到的是
|
|
507
|
+
* 并发赢家**已提交**的最新版本(乐观模式下 FOR UPDATE 读 start_ts 快照,会把赢家终局读旧 —— 正是
|
|
508
|
+
* bindBatch 失败臂注记的那类方言分歧)。悲观模式**不赌部署的 `tidb_txn_mode` 缺省**:它是连接初始化
|
|
509
|
+
* 的结构保证,设不上就拒启({@link SqlTxConn.begin} 的 @contract txn.read-semantics)。PG 侧
|
|
510
|
+
* `BEGIN` = READ COMMITTED,FOR UPDATE 本就等待并返回最新已提交版本。
|
|
511
511
|
* · CAS 输(affected=0)⇒ 行在 UPDATE 那一刻已非 STREAM_PENDING(或不在/错 scope),而非 pending 态
|
|
512
512
|
* 在状态机上无回边 ⇒ 锁定读读到的恒 ≥ 线性化点,且 DECIDED 决议不可变 —— 答案自足。
|
|
513
513
|
* · 本事务在输臂上除批行 rev 自增外零写,rollback 无痕。
|
|
@@ -440,7 +440,7 @@ export class SqlApprovalAskStore {
|
|
|
440
440
|
const dialect = this.db.dialect;
|
|
441
441
|
const conn = await this.db.connect();
|
|
442
442
|
try {
|
|
443
|
-
await conn.
|
|
443
|
+
await conn.begin();
|
|
444
444
|
if (intent === "expire") {
|
|
445
445
|
const lockRes = await conn.query(this.q(`UPDATE ${APPROVAL_BATCH_TABLE} SET rev=rev+1 WHERE batch_id=?`, `UPDATE ${APPROVAL_BATCH_TABLE} SET rev=rev+1 WHERE batch_id=$1`), [batchId]);
|
|
446
446
|
if (lockRes.affected !== 1) {
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
* ——**所有** CAS UPDATE 因此必须带一个恒变列(`rev = rev + 1`)。本文件每一条 CAS UPDATE 都带。
|
|
19
19
|
* 在本店这条尤其致命:心跳续租在「同一毫秒内重复心跳」时 SET 的值可能与当前值相同,没有 rev
|
|
20
20
|
* 就会被判成「本连接已失权」⇒ 设备被自己的心跳踢下线。
|
|
21
|
-
* 3. 事务:`SqlDriver.connect()` → `begin()
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* 3. 事务:`SqlDriver.connect()` → `begin()` → `query()` → `commit()`,失败 `rollback()`。读语义
|
|
22
|
+
* (隔离级 / TiDB 悲观模式 / 事务内集合读必须锁读)的判据只有一处:`sql-driver.ts` 头注的
|
|
23
|
+
* {@link SqlTxConn.begin} `@contract txn.read-semantics`。**失败臂的回读必须走当前这条 `conn`**
|
|
24
|
+
* (approval 店复审 F1 的真缺陷:走池级 query 会与自己持有的连接死锁),且**先 rollback 再读**
|
|
25
|
+
* (回滚后本连接回到 autocommit,那条 SELECT 自成事务 ⇒ 读到最新已提交视图)。
|
|
24
26
|
*
|
|
25
27
|
* ── 方言差异(A12 doctrine:每处显式写在调用点)────────────────────────────────────────────────────
|
|
26
28
|
* - `?`(位置序) vs `$n`(显式编号);动态 WHERE(`listAudit`)靠 `ph(dialect, n)` 生成。
|
|
@@ -131,9 +133,10 @@ export declare class SqlDeviceStore implements DeviceStore {
|
|
|
131
133
|
/**
|
|
132
134
|
* §4.3.2 首绑写协议 —— **单事务**:设备校验(active ∧ owner)与首绑 CAS 插入在一个事务里。
|
|
133
135
|
*
|
|
134
|
-
* 🔴 `
|
|
135
|
-
*
|
|
136
|
-
* 的
|
|
136
|
+
* 🔴 `FOR UPDATE`(设备行):设备行必须是**当前读**。普通读下,一次与吊销并发的绑定会读到「还 active」
|
|
137
|
+
* 的旧版本,于是把 session 绑到一台**刚被吊销**的设备上(double-admit 形)。锁读在两引擎都是当前读 ——
|
|
138
|
+
* 前提写在 {@link SqlTxConn.begin} 的 @contract txn.read-semantics。这把锁在 `devices` 表上,与下面
|
|
139
|
+
* 被插入的 `device_session` 行不是同一行。
|
|
137
140
|
*
|
|
138
141
|
* 🔴 形 B · cas-insert(S-127 / B-013)—— 本站点是全仓 `INSERT IGNORE` 的**正当用法**:
|
|
139
142
|
* `INSERT IGNORE` / `ON CONFLICT DO NOTHING` 的 affected(1/0)是引擎对「谁赢」的**权威**回答,而
|
|
@@ -166,7 +169,8 @@ export declare class SqlDeviceStore implements DeviceStore {
|
|
|
166
169
|
* O4 显式换绑(2026-08-30 v2 稿 §2)—— **单事务**:目标设备门(当前读)+ 幂等短路 + 前态 CAS +
|
|
167
170
|
* `session_rebound` 审计行一起提交。三形语义 oracle = `InMemoryDeviceStore.rebindSession`。
|
|
168
171
|
*
|
|
169
|
-
* 🔴
|
|
172
|
+
* 🔴 两次 `FOR UPDATE`(bindSession 的同一条真库红的射程;当前读的前提见
|
|
173
|
+
* {@link SqlTxConn.begin} 的 @contract txn.read-semantics):
|
|
170
174
|
* · 设备行必须当前读 —— 快照读会把「与吊销并发的换绑」绑到一台刚被吊销的设备上(double-admit 形);
|
|
171
175
|
* · 绑定行必须当前读且**持锁到提交** —— 两个并发换绑(同 expectedRev)在锁上串行:赢家 rev+1 提交,
|
|
172
176
|
* 输家的锁读看到新 rev ⇒ 走 `rev_conflict` 携当前行(单赢家,无未定义态)。锁读判等之后,CAS
|
|
@@ -367,7 +367,7 @@ export class SqlDeviceStore {
|
|
|
367
367
|
}
|
|
368
368
|
const conn = await this.db.connect();
|
|
369
369
|
try {
|
|
370
|
-
await conn.
|
|
370
|
+
await conn.begin();
|
|
371
371
|
const cas = await conn.query(this.q(`UPDATE ${DEVICE_ENROLL_TOKENS_TABLE} SET consumed_at = NOW(3), consumed_device_id = ?, consumed_pubkey = ?, rev = rev + 1
|
|
372
372
|
WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > NOW(3) AND target_tenant = ? AND target_subject = ?`, `UPDATE ${DEVICE_ENROLL_TOKENS_TABLE} SET consumed_at = now(), consumed_device_id = $1, consumed_pubkey = $2, rev = rev + 1
|
|
373
373
|
WHERE token_hash = $3 AND consumed_at IS NULL AND expires_at > now() AND target_tenant = $4 AND target_subject = $5`), [input.device.deviceId, input.device.pubkey, input.tokenHash, input.requester.tenant, input.requester.subject]);
|
|
@@ -454,7 +454,7 @@ export class SqlDeviceStore {
|
|
|
454
454
|
assertRevokeShape(input);
|
|
455
455
|
const conn = await this.db.connect();
|
|
456
456
|
try {
|
|
457
|
-
await conn.
|
|
457
|
+
await conn.begin();
|
|
458
458
|
const cas = await conn.query(this.q(`UPDATE ${DEVICES_TABLE} SET status = 'revoked', revoked_at = NOW(3), revoked_by = ?, revoke_reason = ?, lease_expires_at = NULL, conn_generation = conn_generation + 1, rev = rev + 1
|
|
459
459
|
WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? AND status = 'active'`, `UPDATE ${DEVICES_TABLE} SET status = 'revoked', revoked_at = now(), revoked_by = $1, revoke_reason = $2, lease_expires_at = NULL, conn_generation = conn_generation + 1, rev = rev + 1
|
|
460
460
|
WHERE device_id = $3 AND owner_tenant = $4 AND owner_subject = $5 AND status = 'active'`), [input.revokedBy, input.reason ?? null, input.deviceId, input.owner.tenant, input.owner.subject]);
|
|
@@ -484,7 +484,7 @@ export class SqlDeviceStore {
|
|
|
484
484
|
assertAcquireShape(input);
|
|
485
485
|
const conn = await this.db.connect();
|
|
486
486
|
try {
|
|
487
|
-
await conn.
|
|
487
|
+
await conn.begin();
|
|
488
488
|
const cas = await conn.query(this.q(`UPDATE ${DEVICES_TABLE} SET conn_generation = conn_generation + 1, conn_epoch = ?, replica_id = ?, lease_expires_at = ${nowPlusSeconds("tidb", "?")}, connected_at = NOW(3), last_seen_at = NOW(3), rev = rev + 1
|
|
489
489
|
WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? AND status = 'active' AND (lease_expires_at IS NULL OR lease_expires_at <= NOW(3))`, `UPDATE ${DEVICES_TABLE} SET conn_generation = conn_generation + 1, conn_epoch = $1, replica_id = $2, lease_expires_at = ${nowPlusSeconds("pg", "$3")}, connected_at = now(), last_seen_at = now(), rev = rev + 1
|
|
490
490
|
WHERE device_id = $4 AND owner_tenant = $5 AND owner_subject = $6 AND status = 'active' AND (lease_expires_at IS NULL OR lease_expires_at <= now())`), [input.connEpoch, input.replicaId, input.leaseSeconds, input.deviceId, input.owner.tenant, input.owner.subject]);
|
|
@@ -523,7 +523,7 @@ export class SqlDeviceStore {
|
|
|
523
523
|
assertDeviceOwnerShape(input.owner);
|
|
524
524
|
const conn = await this.db.connect();
|
|
525
525
|
try {
|
|
526
|
-
await conn.
|
|
526
|
+
await conn.begin();
|
|
527
527
|
const dev = await conn.query(this.q(`SELECT status FROM ${DEVICES_TABLE} WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? FOR UPDATE`, `SELECT status FROM ${DEVICES_TABLE} WHERE device_id = $1 AND owner_tenant = $2 AND owner_subject = $3 FOR UPDATE`), [input.deviceId, input.owner.tenant, input.owner.subject]);
|
|
528
528
|
if (!dev.rows[0]) {
|
|
529
529
|
await conn.rollback();
|
|
@@ -554,7 +554,7 @@ export class SqlDeviceStore {
|
|
|
554
554
|
async observeLostBind(input) {
|
|
555
555
|
const conn = await this.db.connect();
|
|
556
556
|
try {
|
|
557
|
-
await conn.
|
|
557
|
+
await conn.begin();
|
|
558
558
|
const { rows } = await conn.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ? FOR UPDATE`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1 FOR UPDATE`), [input.rootSessionId]);
|
|
559
559
|
await conn.commit();
|
|
560
560
|
const row = rows[0] ? mapSessionRow(rows[0]) : null;
|
|
@@ -575,7 +575,7 @@ export class SqlDeviceStore {
|
|
|
575
575
|
assertRebindShape(input);
|
|
576
576
|
const conn = await this.db.connect();
|
|
577
577
|
try {
|
|
578
|
-
await conn.
|
|
578
|
+
await conn.begin();
|
|
579
579
|
const dev = await conn.query(this.q(`SELECT status FROM ${DEVICES_TABLE} WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? FOR UPDATE`, `SELECT status FROM ${DEVICES_TABLE} WHERE device_id = $1 AND owner_tenant = $2 AND owner_subject = $3 FOR UPDATE`), [input.toDeviceId, input.owner.tenant, input.owner.subject]);
|
|
580
580
|
if (!dev.rows[0] || parseDeviceStatus(bufToStr(dev.rows[0].status)) !== "active") {
|
|
581
581
|
await conn.rollback();
|
|
@@ -79,7 +79,11 @@ export declare class SqlFileHistoryStore implements FileHistoryStore {
|
|
|
79
79
|
env: ExecutionEnv;
|
|
80
80
|
signal?: AbortSignal;
|
|
81
81
|
}): Promise<FileHistoryResult>;
|
|
82
|
+
/** `forUpdate` = the {@link SqlTxConn.begin} `@contract txn.read-semantics` ③ arm: a collection read that
|
|
83
|
+
* runs inside a transaction must be a LOCKING read (a plain SELECT is pinned to the transaction's start on
|
|
84
|
+
* TiDB, so the lock-wait window's commits are invisible — B-022). Pool-level callers pass nothing. */
|
|
82
85
|
private loadTracked;
|
|
86
|
+
/** `forUpdate` — see {@link loadTracked}: inside a transaction this is a collection read, so it locks. */
|
|
83
87
|
private loadVersions;
|
|
84
88
|
/** [ref] twin 保留条款:这个 entry 的键是否**已花掉**(被 reap 剪过)。台账 `file_history_reaped_entry`。
|
|
85
89
|
* `forUpdate`(r3-①连带):发布 tx 内的复核必须是**锁定读**——TiDB 悲观事务的普通 SELECT 按文档读
|
|
@@ -109,14 +113,18 @@ export declare class SqlFileHistoryStore implements FileHistoryStore {
|
|
|
109
113
|
/** PROTECTED RACE SEAM (test-only override): runs inside `restore` between the boundary-header check and
|
|
110
114
|
* the mapping read. Production body is empty. */
|
|
111
115
|
protected duringRestorePlan(_scope: string, _entryId: string): Promise<void>;
|
|
112
|
-
/** PROTECTED RACE SEAM (test-only override): runs inside the publish tx AFTER `
|
|
113
|
-
*
|
|
114
|
-
*
|
|
116
|
+
/** PROTECTED RACE SEAM (test-only override): runs inside the publish tx AFTER `begin()` and BEFORE
|
|
117
|
+
* the scope lock — the window where another replica's publish can commit while this tx's snapshot is
|
|
118
|
+
* already pinned. That window is exactly what B-022 rode: a plain SELECT taken after the lock is still
|
|
119
|
+
* pinned to the transaction's start, so this tx's collection reads are LOCKING reads
|
|
120
|
+
* ({@link SqlTxConn.begin} → `@contract txn.read-semantics`). Production body is empty. */
|
|
115
121
|
protected beforePublishLock(_scope: string): Promise<void>;
|
|
116
122
|
/** PROTECTED RACE SEAM (test-only override): runs inside `exportOf` between the boundary-header read and
|
|
117
123
|
* the mapping read. Production body is empty. */
|
|
118
124
|
protected duringExportGraph(_scope: string): Promise<void>;
|
|
119
125
|
private applyBoundaryRetention;
|
|
126
|
+
/** `forUpdate` — see {@link loadTracked}: the in-transaction re-check must be a LOCKING read, or it just
|
|
127
|
+
* replays the pool-level pre-check's snapshot and its documented `null` arm becomes unreachable. */
|
|
120
128
|
private boundaryExists;
|
|
121
129
|
snapshot(scope: string, entryId: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryResult>;
|
|
122
130
|
restore(scope: string, entryId: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryRestoreResult>;
|
|
@@ -146,6 +154,10 @@ export declare class SqlFileHistoryStore implements FileHistoryStore {
|
|
|
146
154
|
hasBlobs(hashes: string[]): Promise<Set<string>>;
|
|
147
155
|
adoptScope(fromScope: string, toScope: string): Promise<FileHistoryResult>;
|
|
148
156
|
private scopeEmpty;
|
|
157
|
+
/** `forUpdate` — see {@link loadTracked}. Every in-transaction caller passes `true`: the four reads are
|
|
158
|
+
* one graph, and a graph materialized from a start-of-transaction snapshot disagrees with the LOCKING
|
|
159
|
+
* emptiness probe that decided to materialize it (that disagreement answered a legal idempotent
|
|
160
|
+
* re-import as `conflict` — B-022). */
|
|
149
161
|
private exportOf;
|
|
150
162
|
exportHistory(scope: string): Promise<FileHistoryExport | null>;
|
|
151
163
|
importHistory(scope: string, data: FileHistoryExport, srcGetBlob: (hash: string) => Promise<Uint8Array | undefined>): Promise<FileHistoryResult>;
|
|
@@ -92,7 +92,7 @@ export class SqlFileHistoryStore {
|
|
|
92
92
|
const conn = await this.db.connect();
|
|
93
93
|
let minted = false;
|
|
94
94
|
try {
|
|
95
|
-
await conn.
|
|
95
|
+
await conn.begin();
|
|
96
96
|
await this.lockScope(conn, scope);
|
|
97
97
|
const ins = await conn.query(this.q("INSERT IGNORE INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES (?,?,?,?,?,?,0,?)", "INSERT INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES ($1,$2,$3,$4,$5,$6,FALSE,$7) ON CONFLICT (scope, key_hash) DO NOTHING"), [scope, keyHash, key, keyDesc.kind, keyDesc.family, this.bool(baseAvailable), Date.now()]);
|
|
98
98
|
minted = ins.affected === 1;
|
|
@@ -159,7 +159,7 @@ export class SqlFileHistoryStore {
|
|
|
159
159
|
}
|
|
160
160
|
const conn = await this.db.connect();
|
|
161
161
|
try {
|
|
162
|
-
await conn.
|
|
162
|
+
await conn.begin();
|
|
163
163
|
await this.lockScope(conn, scope);
|
|
164
164
|
const del = await conn.query(this.q(`DELETE FROM file_history_tracked WHERE scope = ? AND key_hash = ? AND observed_later = 0
|
|
165
165
|
AND NOT EXISTS (SELECT 1 FROM file_history_boundary_file bf WHERE bf.scope = ? AND bf.key_hash = ?)
|
|
@@ -202,8 +202,9 @@ export class SqlFileHistoryStore {
|
|
|
202
202
|
baseAvailable: truthy(row.base_available),
|
|
203
203
|
}));
|
|
204
204
|
}
|
|
205
|
-
async loadVersions(exec, scope) {
|
|
206
|
-
const
|
|
205
|
+
async loadVersions(exec, scope, forUpdate = false) {
|
|
206
|
+
const suffix = forUpdate ? " FOR UPDATE" : "";
|
|
207
|
+
const r = await exec.query(this.q(`SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = ? ORDER BY version${suffix}`, `SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = $1 ORDER BY version${suffix}`), [scope]);
|
|
207
208
|
const out = new Map();
|
|
208
209
|
for (const row of r.rows) {
|
|
209
210
|
const kh = String(row.key_hash);
|
|
@@ -240,9 +241,9 @@ export class SqlFileHistoryStore {
|
|
|
240
241
|
await this.beforeRetentionPass(scope);
|
|
241
242
|
const conn = await this.db.connect();
|
|
242
243
|
try {
|
|
243
|
-
await conn.
|
|
244
|
+
await conn.begin();
|
|
244
245
|
await this.lockScope(conn, scope);
|
|
245
|
-
const { rows } = await conn.query(this.q("SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq ASC", "SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq ASC"), [scope]);
|
|
246
|
+
const { rows } = await conn.query(this.q("SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq ASC FOR UPDATE", "SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq ASC FOR UPDATE"), [scope]);
|
|
246
247
|
const ordered = rows.map((r) => ({ id: String(r.entry_id), seq: Number(r.publish_seq) }));
|
|
247
248
|
if (ordered.length === 0) {
|
|
248
249
|
await conn.rollback();
|
|
@@ -275,8 +276,9 @@ export class SqlFileHistoryStore {
|
|
|
275
276
|
recordFailOpen("server.file-history.retention-trim-failed", `scope=${scope} entry=${justCommitted}: ${err instanceof Error ? err.message : String(err)}`);
|
|
276
277
|
}
|
|
277
278
|
}
|
|
278
|
-
async boundaryExists(exec, scope, entryId) {
|
|
279
|
-
const
|
|
279
|
+
async boundaryExists(exec, scope, entryId, forUpdate = false) {
|
|
280
|
+
const suffix = forUpdate ? " FOR UPDATE" : "";
|
|
281
|
+
const r = await exec.query(this.q(`SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? LIMIT 1${suffix}`, `SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 LIMIT 1${suffix}`), [scope, entryId]);
|
|
280
282
|
return Boolean(r.rows[0]);
|
|
281
283
|
}
|
|
282
284
|
async snapshot(scope, entryId, env, root, signal) {
|
|
@@ -344,7 +346,7 @@ export class SqlFileHistoryStore {
|
|
|
344
346
|
await this.beforePublish(scope);
|
|
345
347
|
const conn = await this.db.connect();
|
|
346
348
|
try {
|
|
347
|
-
await conn.
|
|
349
|
+
await conn.begin();
|
|
348
350
|
await this.beforePublishLock(scope);
|
|
349
351
|
await this.lockScope(conn, scope);
|
|
350
352
|
if (signal?.aborted) {
|
|
@@ -356,7 +358,7 @@ export class SqlFileHistoryStore {
|
|
|
356
358
|
await conn.rollback();
|
|
357
359
|
return reapedEntryRefusal(entryId);
|
|
358
360
|
}
|
|
359
|
-
const seqRow = await conn.query(this.q("SELECT publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE", "SELECT publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq DESC LIMIT 1"), [scope]);
|
|
361
|
+
const seqRow = await conn.query(this.q("SELECT publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE", "SELECT publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE"), [scope]);
|
|
360
362
|
const publishSeq = (seqRow.rows[0] !== undefined ? Number(seqRow.rows[0].publish_seq) : 0) + 1;
|
|
361
363
|
const priorWinner = await conn.query(this.q("SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? FOR UPDATE", "SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 FOR UPDATE"), [scope, entryId]);
|
|
362
364
|
if (priorWinner.rows[0]) {
|
|
@@ -496,16 +498,16 @@ export class SqlFileHistoryStore {
|
|
|
496
498
|
async restorePlan(scope, entryId) {
|
|
497
499
|
const conn = await this.db.connect();
|
|
498
500
|
try {
|
|
499
|
-
await conn.
|
|
501
|
+
await conn.begin();
|
|
500
502
|
await this.lockScope(conn, scope);
|
|
501
|
-
if (!(await this.boundaryExists(conn, scope, entryId))) {
|
|
503
|
+
if (!(await this.boundaryExists(conn, scope, entryId, true))) {
|
|
502
504
|
await conn.rollback();
|
|
503
505
|
return null;
|
|
504
506
|
}
|
|
505
507
|
await this.duringRestorePlan(scope, entryId);
|
|
506
|
-
const tracked = (await this.loadTracked(conn, scope)).sort((a, b) => (a.trackKey < b.trackKey ? -1 : a.trackKey > b.trackKey ? 1 : 0));
|
|
507
|
-
const versions = await this.loadVersions(conn, scope);
|
|
508
|
-
const mapRows = await conn.query(this.q("SELECT key_hash, version FROM file_history_boundary_file WHERE scope = ? AND entry_id = ?", "SELECT key_hash, version FROM file_history_boundary_file WHERE scope = $1 AND entry_id = $2"), [scope, entryId]);
|
|
508
|
+
const tracked = (await this.loadTracked(conn, scope, true)).sort((a, b) => (a.trackKey < b.trackKey ? -1 : a.trackKey > b.trackKey ? 1 : 0));
|
|
509
|
+
const versions = await this.loadVersions(conn, scope, true);
|
|
510
|
+
const mapRows = await conn.query(this.q("SELECT key_hash, version FROM file_history_boundary_file WHERE scope = ? AND entry_id = ? FOR UPDATE", "SELECT key_hash, version FROM file_history_boundary_file WHERE scope = $1 AND entry_id = $2 FOR UPDATE"), [scope, entryId]);
|
|
509
511
|
const boundary = new Map(mapRows.rows.map((r) => [String(r.key_hash), Number(r.version)]));
|
|
510
512
|
await conn.commit();
|
|
511
513
|
return { tracked, versions, boundary };
|
|
@@ -524,9 +526,9 @@ export class SqlFileHistoryStore {
|
|
|
524
526
|
async reap(scope, keepEntryIds) {
|
|
525
527
|
const conn = await this.db.connect();
|
|
526
528
|
try {
|
|
527
|
-
await conn.
|
|
529
|
+
await conn.begin();
|
|
528
530
|
await this.lockScope(conn, scope);
|
|
529
|
-
const ids = await conn.query(this.q("SELECT entry_id FROM file_history_boundary WHERE scope = ?", "SELECT entry_id FROM file_history_boundary WHERE scope = $1"), [scope]);
|
|
531
|
+
const ids = await conn.query(this.q("SELECT entry_id FROM file_history_boundary WHERE scope = ? FOR UPDATE", "SELECT entry_id FROM file_history_boundary WHERE scope = $1 FOR UPDATE"), [scope]);
|
|
530
532
|
const dropped = await this.reapWithin(conn, scope, new Set(keepEntryIds), ids.rows.map((r) => String(r.entry_id)));
|
|
531
533
|
await conn.commit();
|
|
532
534
|
return dropped;
|
|
@@ -557,7 +559,7 @@ export class SqlFileHistoryStore {
|
|
|
557
559
|
async deleteBySession(scope) {
|
|
558
560
|
const conn = await this.db.connect();
|
|
559
561
|
try {
|
|
560
|
-
await conn.
|
|
562
|
+
await conn.begin();
|
|
561
563
|
await this.lockScope(conn, scope);
|
|
562
564
|
let total = 0;
|
|
563
565
|
for (const table of ["file_history_boundary_file", "file_history_boundary", "file_history_reaped_entry", "file_history_version", "file_history_tracked", "file_history_scope"]) {
|
|
@@ -606,13 +608,13 @@ export class SqlFileHistoryStore {
|
|
|
606
608
|
return { ok: true };
|
|
607
609
|
const conn = await this.db.connect();
|
|
608
610
|
try {
|
|
609
|
-
await conn.
|
|
611
|
+
await conn.begin();
|
|
610
612
|
await this.lockScope(conn, fromScope, toScope);
|
|
611
613
|
if (!(await this.scopeEmpty(conn, toScope, true))) {
|
|
612
614
|
await conn.rollback();
|
|
613
615
|
return { ok: false, error: { code: "conflict", message: `adoptScope target "${toScope}" already has history of its own — adoption is legal only onto an EMPTY scope (the fork/resume boundary before the new session's first turn)` } };
|
|
614
616
|
}
|
|
615
|
-
if (await this.scopeEmpty(conn, fromScope)) {
|
|
617
|
+
if (await this.scopeEmpty(conn, fromScope, true)) {
|
|
616
618
|
await this.copyReapedEntries(conn, fromScope, toScope);
|
|
617
619
|
await conn.commit();
|
|
618
620
|
return { ok: true };
|
|
@@ -645,14 +647,15 @@ export class SqlFileHistoryStore {
|
|
|
645
647
|
const b = await exec.query(this.q(`SELECT 1 FROM file_history_boundary WHERE scope = ? LIMIT 1${suffix}`, `SELECT 1 FROM file_history_boundary WHERE scope = $1 LIMIT 1${suffix}`), [scope]);
|
|
646
648
|
return !b.rows[0];
|
|
647
649
|
}
|
|
648
|
-
async exportOf(exec, scope) {
|
|
649
|
-
const
|
|
650
|
-
const
|
|
651
|
-
const
|
|
650
|
+
async exportOf(exec, scope, forUpdate = false) {
|
|
651
|
+
const suffix = forUpdate ? " FOR UPDATE" : "";
|
|
652
|
+
const tracked = await this.loadTracked(exec, scope, forUpdate);
|
|
653
|
+
const versions = await this.loadVersions(exec, scope, forUpdate);
|
|
654
|
+
const headers = await exec.query(this.q(`SELECT entry_id FROM file_history_boundary WHERE scope = ?${suffix}`, `SELECT entry_id FROM file_history_boundary WHERE scope = $1${suffix}`), [scope]);
|
|
652
655
|
if (tracked.length === 0 && headers.rows.length === 0)
|
|
653
656
|
return null;
|
|
654
657
|
await this.duringExportGraph(scope);
|
|
655
|
-
const files = await exec.query(this.q(
|
|
658
|
+
const files = await exec.query(this.q(`SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = ?${suffix}`, `SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = $1${suffix}`), [scope]);
|
|
656
659
|
const keyOfHash = new Map(tracked.map((t) => [t.keyHash, t.trackKey]));
|
|
657
660
|
const byEntry = new Map();
|
|
658
661
|
for (const row of files.rows) {
|
|
@@ -688,9 +691,9 @@ export class SqlFileHistoryStore {
|
|
|
688
691
|
async exportHistory(scope) {
|
|
689
692
|
const conn = await this.db.connect();
|
|
690
693
|
try {
|
|
691
|
-
await conn.
|
|
694
|
+
await conn.begin();
|
|
692
695
|
await this.lockScope(conn, scope);
|
|
693
|
-
const out = await this.exportOf(conn, scope);
|
|
696
|
+
const out = await this.exportOf(conn, scope, true);
|
|
694
697
|
await conn.commit();
|
|
695
698
|
return out;
|
|
696
699
|
}
|
|
@@ -743,10 +746,10 @@ export class SqlFileHistoryStore {
|
|
|
743
746
|
}
|
|
744
747
|
const conn = await this.db.connect();
|
|
745
748
|
try {
|
|
746
|
-
await conn.
|
|
749
|
+
await conn.begin();
|
|
747
750
|
await this.lockScope(conn, scope);
|
|
748
751
|
if (!(await this.scopeEmpty(conn, scope, true))) {
|
|
749
|
-
const now = await this.exportOf(conn, scope);
|
|
752
|
+
const now = await this.exportOf(conn, scope, true);
|
|
750
753
|
if (now !== null && fileHistoryExportDigest(now) === fileHistoryExportDigest(data)) {
|
|
751
754
|
await conn.commit();
|
|
752
755
|
return { ok: true };
|
|
@@ -139,7 +139,7 @@ export class SqlImageBake {
|
|
|
139
139
|
let inserted = false;
|
|
140
140
|
try {
|
|
141
141
|
if (this.db.dialect === "tidb") {
|
|
142
|
-
await conn.
|
|
142
|
+
await conn.begin();
|
|
143
143
|
await conn.query("INSERT INTO image_bake_admit (pool) VALUES (?) ON DUPLICATE KEY UPDATE pool = pool", [this.poolName]);
|
|
144
144
|
await conn.query("SELECT pool FROM image_bake_admit WHERE pool = ? FOR UPDATE", [this.poolName]);
|
|
145
145
|
const busy = await conn.query("SELECT 1 FROM image_bake WHERE status IN ('queued','running') AND dry_run = 0 LIMIT 1 FOR UPDATE");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FileDurableRulePartitionProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope, type StaleRuleApprovalRecord } from "@sema-agent/core";
|
|
2
2
|
import { type RuleImportTicket, type RuleTicketPurpose, type RuleTicketReclaimResult, type RuleTicketRedeemResult, type RuleTicketSnapshot } from "./permission-rule-store-sql.js";
|
|
3
3
|
import type { PermissionRuleStoreBundle } from "../rules-consent.js";
|
|
4
4
|
/**
|
|
@@ -84,9 +84,9 @@ export declare class FileRuleImportTicketStore {
|
|
|
84
84
|
* 那一条:过期只挡开始一次兑付,不挡做完一次已经开始的)。抢到之后**换一代认领**。 */
|
|
85
85
|
reclaim(ticketId: string, principal: string, purpose: RuleTicketPurpose, minClaimAgeMs: number): Promise<RuleTicketReclaimResult>;
|
|
86
86
|
}
|
|
87
|
-
/** local 车道的三面束 + 生命周期。`dispose()` 释放 core File provider 的写锁(见 {@link createFilePermissionRuleStores})。 */
|
|
87
|
+
/** local 车道的三面束 + 生命周期。`dispose()` 释放 core File durable 分区 provider 的写锁(见 {@link createFilePermissionRuleStores})。 */
|
|
88
88
|
export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
|
|
89
|
-
|
|
89
|
+
durable: FileDurableRulePartitionProvider;
|
|
90
90
|
approvals: FileRuleApprovalRecordStore;
|
|
91
91
|
tickets: FileRuleImportTicketStore;
|
|
92
92
|
dispose(): void;
|
|
@@ -94,7 +94,7 @@ export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
|
|
|
94
94
|
/**
|
|
95
95
|
* 装配 local 三面束。
|
|
96
96
|
*
|
|
97
|
-
* 🔴 `
|
|
97
|
+
* 🔴 `durable` 必须是**单例**(F1):core 的 `FileDurableRulePartitionProvider` 在第一次取写面时对规则
|
|
98
98
|
* 目录取一把进程级写锁,每次 `new` 一个就是第二个持有者 —— 而它对第二个持有者是**响亮拒绝**,不是
|
|
99
99
|
* 排队。所以束在 `LocalBackend` 上按字段持有,`LocalBackend.close()` 调 `dispose()` 释放
|
|
100
100
|
* (不释放 ⇒ 一次优雅重启会被自己上一条命留下的锁挡在门外,与数据根 `root/LOCK` 同一个病)。
|
|
@@ -2,7 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { performance } from "node:perf_hooks";
|
|
5
|
-
import {
|
|
5
|
+
import { FileDurableRulePartitionProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
|
|
8
8
|
import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
|
|
@@ -341,18 +341,18 @@ export class FileRuleImportTicketStore {
|
|
|
341
341
|
export function createFilePermissionRuleStores(root, opts) {
|
|
342
342
|
const dir = join(root, "permission-rules");
|
|
343
343
|
ensureDir(dir);
|
|
344
|
-
const
|
|
344
|
+
const durable = opts?.onError ? new FileDurableRulePartitionProvider(dir, opts.onError) : new FileDurableRulePartitionProvider(dir);
|
|
345
345
|
const approvals = new FileRuleApprovalRecordStore(dir, opts?.onError);
|
|
346
346
|
const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.onError, opts?.monotonic);
|
|
347
347
|
return {
|
|
348
|
-
|
|
348
|
+
durable,
|
|
349
349
|
approvals,
|
|
350
350
|
tickets,
|
|
351
351
|
countBuckets: async () => readdirSync(dir).filter((n) => n === "local-owner.json" || /^[0-9a-f]{64}\.json$/.test(n)).length,
|
|
352
352
|
dispose: () => {
|
|
353
353
|
approvals.close();
|
|
354
354
|
tickets.close();
|
|
355
|
-
|
|
355
|
+
durable.dispose();
|
|
356
356
|
},
|
|
357
357
|
};
|
|
358
358
|
}
|