@sema-agent/server 7.53.0 → 7.55.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 +5 -0
- package/dist/approval-ask-audit-store.d.ts +129 -0
- package/dist/approval-ask-audit-store.js +284 -0
- package/dist/approval-card.d.ts +18 -6
- package/dist/approval-card.js +2 -2
- package/dist/approval-reconciler.d.ts +2 -1
- package/dist/approval-reconciler.js +1 -1
- package/dist/boot/config-center.js +2 -2
- package/dist/boot/coordinators.d.ts +2 -0
- package/dist/boot/coordinators.js +18 -1
- package/dist/boot/leader.js +2 -0
- package/dist/boot/reapers.d.ts +2 -1
- package/dist/boot/resolve-spec.js +1 -3
- package/dist/boot/runner-deps.d.ts +20 -0
- package/dist/boot/runner-deps.js +14 -8
- package/dist/boot/shutdown.js +1 -1
- package/dist/boot/stores.js +8 -1
- package/dist/capabilities/team.d.ts +10 -9
- package/dist/config-center/apply-effective.d.ts +25 -26
- package/dist/config-center/apply-effective.js +28 -66
- package/dist/config-center/effective-keys.d.ts +30 -0
- package/dist/config-center/effective-keys.js +49 -0
- package/dist/config-center/facade.d.ts +3 -3
- package/dist/config-center/facade.js +1 -1
- package/dist/config-center/restart-signal.js +3 -9
- package/dist/config-center/types.d.ts +19 -40
- package/dist/config-lkg.js +7 -1
- package/dist/config-provider.js +1 -1
- package/dist/device-enrollment.d.ts +5 -3
- package/dist/device-store.d.ts +81 -3
- package/dist/device-store.js +37 -0
- package/dist/device-ws-hub.d.ts +15 -2
- package/dist/device-ws-hub.js +31 -5
- package/dist/http/routes/approvals-assistant.js +1 -0
- package/dist/http/routes/devices.d.ts +64 -0
- package/dist/http/routes/devices.js +173 -0
- package/dist/http/server.d.ts +14 -0
- package/dist/http/server.js +32 -2
- package/dist/http/wire-types.d.ts +19 -0
- package/dist/leader/wire.d.ts +12 -0
- package/dist/leader/wire.js +6 -5
- package/dist/main.js +3 -1
- package/dist/observability/fail-open.d.ts +20 -0
- package/dist/observability/fail-open.js +20 -0
- package/dist/plugins/approval-ask-store-memory.d.ts +11 -1
- package/dist/plugins/approval-ask-store-memory.js +20 -3
- package/dist/plugins/approval-ask-store-sql.d.ts +82 -0
- package/dist/plugins/approval-ask-store-sql.js +41 -10
- package/dist/plugins/device-store-sql.d.ts +38 -1
- package/dist/plugins/device-store-sql.js +82 -2
- package/dist/plugins/pg-pool.d.ts +29 -2
- package/dist/plugins/pg-pool.js +45 -2
- package/dist/plugins/remote-env-device.d.ts +4 -2
- package/dist/plugins/remote-env-device.js +12 -2
- package/dist/plugins/roster-store-sql.d.ts +1 -3
- package/dist/plugins/sql-errors.d.ts +12 -0
- package/dist/plugins/sql-errors.js +10 -0
- package/dist/plugins/store-backend.js +1 -1
- package/dist/plugins/tidb-pool.d.ts +13 -0
- package/dist/runs.js +1 -0
- package/dist/runtime-governance.d.ts +4 -0
- package/dist/runtime-governance.js +23 -1
- package/dist/tool-approval.d.ts +63 -39
- package/dist/tool-approval.js +322 -109
- package/dist/trace/engine-notice-wire.d.ts +1 -1
- package/dist/trace/engine-notice-wire.js +2 -0
- package/dist/trace/injection-tier.d.ts +11 -21
- package/dist/trace/injection-tier.js +3 -4
- package/dist/trace/ledger-events.d.ts +9 -0
- package/dist/trace/project.d.ts +1 -0
- package/dist/trace/project.js +1 -0
- package/package.json +3 -3
|
@@ -241,6 +241,58 @@ export interface ExpireResult {
|
|
|
241
241
|
won: boolean;
|
|
242
242
|
voidedSiblings: string[];
|
|
243
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* [ref](DEBTS [ref]-R1 终局;core [ref] §4.4「@server 对表键形」,黑板 [ref] 认领):终局 claim 的
|
|
246
|
+
* 意图。ask 行的 TERMINAL 对儿只有两员 —— `expire`(窗到期 / emit 全灭共用:STREAM_PENDING→PARKING +
|
|
247
|
+
* 批转投递面 + 兄弟撤卡,与 `expireAsk` 同一台 CAS 机器)与 `cancel`(取消臂:STREAM_PENDING→VOID)。
|
|
248
|
+
* **人答臂(decide)不入本枚举**:`decideAsk` 带决议载荷与回决幂等键,其失败臂本就携行读数(384 §4.4
|
|
249
|
+
* 表「decideAsk(人答臂)」格的「视现有 CAS 形」裁定)——三 verb 的「一台机器义务」(384 §4.3-2)由共享
|
|
250
|
+
* WHERE 谓词 {@link pendingClaimWhere} 兑现,不是把三个不同 verb 塞进一个函数。
|
|
251
|
+
*/
|
|
252
|
+
export type AskTerminalClaimIntent = "expire" | "cancel";
|
|
253
|
+
/**
|
|
254
|
+
* 败方读数:行上终局相关列的一致投影(status 词表 = 行自己的 `AskState`,零新值域 —— 384 §4.2 的
|
|
255
|
+
* 「reuse the row's own vocabulary」)。
|
|
256
|
+
* · `state:"absent"` = 行不在,**或 scope(batchId)不匹配** —— 错 scope 的 claim 必须输且不得读出
|
|
257
|
+
* 他 scope 行的真相(读谓词与 CAS 谓词是同一份 scope WHERE;多租户隔离,384 §4.2 absent 臂原话)。
|
|
258
|
+
* · **绝无 `STREAM_PENDING` 臂**:server 的 ask 店 CAS 谓词是 status 门(STREAM_PENDING→终态),无
|
|
259
|
+
* rev/OCC 期望语义 ⇒ core 表的 `pending`(rev 失配)臂在本形结构不可达,如实缺席不硬造([ref]
|
|
260
|
+
* 补充确认①)。输 = 行在 claim 线性化点上已非 pending,而非 pending 态在状态机上**无回边**
|
|
261
|
+
* (`approval-ask-machine.ts`,本域无 reopen)⇒ 败方读数恒 ≥ 那一刻;「CAS 时行不在、读时刚被
|
|
262
|
+
* ensureAsk 插入」的编排错序形按线性化点真相投影为 `absent`。
|
|
263
|
+
* · 本域无 reopen ⇒ DECIDED 恒不回 pending,败方读数在本域退化为永恒真相;消费仍按快照律
|
|
264
|
+
* (384 §4.3-5)写,不依赖此强化([ref] 补充确认②)。
|
|
265
|
+
*/
|
|
266
|
+
export type AskTerminalClaimCurrent = {
|
|
267
|
+
state: "absent";
|
|
268
|
+
} | {
|
|
269
|
+
state: Exclude<AskState, "STREAM_PENDING">;
|
|
270
|
+
rev: number;
|
|
271
|
+
decision: AskDecision | null;
|
|
272
|
+
/** [ref] 三态读法照 {@link AskRow.updatedInput}:键缺席 = 没带编辑;`null` 是编辑成 null 的值。 */
|
|
273
|
+
updatedInput?: unknown;
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* 单次原子往返的答案(语义律的唯一规范陈述点 = core `checkpoint-store.ts` `claimTerminal` 契约 JSDoc,
|
|
277
|
+
* [ref] §4.3;本类型是其 ask 行同位物,不复述七律,只记 server 侧特有形):
|
|
278
|
+
* · 赢 ⇒ `{claimed:true}`(expire 意图随赢携兄弟撤卡名单,与 `expireAsk` 同形);
|
|
279
|
+
* · 输 ⇒ **同一原子单元**携行真相(律 1)—— 消费方拿到 `claimed:false` 后永不需要第二次读来行动;
|
|
280
|
+
* · reject(throw / 超墙钟)⇒ 什么也不证明(律 3 K2)——调用方不得按本地意图宣告终局;**迟到的
|
|
281
|
+
* settle 同样自足**:deadline 过后才回来的 `claimed:false` 直接携真决议,臂按真决议 settle 零补读。
|
|
282
|
+
* server 三臂全为**幂等消费**(按真决议 settle,谁写的无关,律 4)——本店无职责性消费面。
|
|
283
|
+
*/
|
|
284
|
+
export type AskTerminalClaimOutcome = {
|
|
285
|
+
claimed: true;
|
|
286
|
+
voidedSiblings: string[];
|
|
287
|
+
} | {
|
|
288
|
+
claimed: false;
|
|
289
|
+
current: AskTerminalClaimCurrent;
|
|
290
|
+
};
|
|
291
|
+
/**
|
|
292
|
+
* 败方读数的投影(三 twin 共用一份,别各自挑列):`null` / 错 scope 由调用方先折成 `null` 传入;
|
|
293
|
+
* `STREAM_PENDING` 折 `absent` 的理由见 {@link AskTerminalClaimCurrent} 顶注(线性化点真相)。
|
|
294
|
+
*/
|
|
295
|
+
export declare function projectAskTerminalClaimTruth(row: AskRow | null): AskTerminalClaimCurrent;
|
|
244
296
|
export interface BindGateInput {
|
|
245
297
|
gateToken: string;
|
|
246
298
|
gateBoundCallId?: string | null;
|
|
@@ -278,6 +330,15 @@ export interface ApprovalAskStore {
|
|
|
278
330
|
ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
|
|
279
331
|
transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
|
|
280
332
|
decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
|
|
333
|
+
/**
|
|
334
|
+
* [ref]:原子终局 claim —— 把「争终局」与「读真相」并成**一次**店往返(胜负判定与败方 `current` 读数
|
|
335
|
+
* 在同一原子单元内,语义见 {@link AskTerminalClaimOutcome})。[ref]-R1 的病根「CAS 与读真相是两次店
|
|
336
|
+
* 往返」由本原语从机制上消灭:settle 的答案自足(赢 ⇒ 赢;输 ⇒ 携真相),迟到的答案同样自足。
|
|
337
|
+
* 律 2「一台机器义务」:`expireAsk` 在本方法之上实现(SQL 双方言)/ 本方法委托给
|
|
338
|
+
* `expireAsk`/`transitionAsk`(InMemory,单线程天然原子)——两向都成立的判据是**没有第二套谓词**。
|
|
339
|
+
* 方法**必备**(本仓自有三形全实现,无 core 那侧的 presence 探测问题)。
|
|
340
|
+
*/
|
|
341
|
+
claimTerminal(askId: string, batchId: string, intent: AskTerminalClaimIntent): Promise<AskTerminalClaimOutcome>;
|
|
281
342
|
expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
|
|
282
343
|
bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise<BindResult>;
|
|
283
344
|
abortBatch(batchId: string): Promise<string[]>;
|
|
@@ -435,6 +496,27 @@ export declare class SqlApprovalAskStore implements ApprovalAskStore {
|
|
|
435
496
|
ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
|
|
436
497
|
transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
|
|
437
498
|
decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
|
|
499
|
+
/**
|
|
500
|
+
* [ref] 原子终局 claim(接口契约见 {@link ApprovalAskStore.claimTerminal};类型语义见
|
|
501
|
+
* {@link AskTerminalClaimOutcome})。expire 意图 = 既有 `expireAsk` 事务**原体**(步⓪-③ 逐字保留,
|
|
502
|
+
* `expireAsk` 现在是本方法的薄投影 —— 律 2 的「在 claimTerminal 之上实现」半场);cancel 意图 =
|
|
503
|
+
* 取消臂的 STREAM_PENDING→VOID(此前走通用 `transitionAsk`,无败方读数)。
|
|
504
|
+
*
|
|
505
|
+
* **原子性证明(两方言各一句,红先钉在 db-integration 的 [ref] 组)**:
|
|
506
|
+
* · 败方读数是**同一事务内**的 `SELECT … FOR UPDATE` —— TiDB 侧本事务显式 `BEGIN PESSIMISTIC`
|
|
507
|
+
* (`beginPessimistic()`,不赌部署的 `tidb_txn_mode` 缺省):悲观锁定读是 CURRENT read,读到的是
|
|
508
|
+
* 并发赢家**已提交**的最新版本(乐观模式下 FOR UPDATE 读 start_ts 快照,会把赢家终局读旧 ——
|
|
509
|
+
* 正是 bindBatch 失败臂注记的那类方言分歧,这里用显式事务模式把它结构性关死);PG 侧
|
|
510
|
+
* `beginPessimistic()` = 普通 `BEGIN`,READ COMMITTED 下 FOR UPDATE 本就等待并返回最新已提交版本。
|
|
511
|
+
* · CAS 输(affected=0)⇒ 行在 UPDATE 那一刻已非 STREAM_PENDING(或不在/错 scope),而非 pending 态
|
|
512
|
+
* 在状态机上无回边 ⇒ 锁定读读到的恒 ≥ 线性化点,且 DECIDED 决议不可变 —— 答案自足。
|
|
513
|
+
* · 本事务在输臂上除批行 rev 自增外零写,rollback 无痕。
|
|
514
|
+
*/
|
|
515
|
+
claimTerminal(askId: string, batchId: string, intent: AskTerminalClaimIntent): Promise<AskTerminalClaimOutcome>;
|
|
516
|
+
/** [ref] 起 = {@link claimTerminal}(expire 意图)的薄投影 —— 律 2「在 claimTerminal 之上实现」的
|
|
517
|
+
* SQL 半场落笔处:谓词/事务/兄弟撤卡全在 claim 里,这里零第二套写路径。败方读数按既有返回形丢弃
|
|
518
|
+
* (`ExpireResult` 不携真相 —— 要真相的调用方直接用 claimTerminal;对账收敛器的 orphan 腿输了本就
|
|
519
|
+
* 只需「不赢」这一位)。 */
|
|
438
520
|
expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
|
|
439
521
|
bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise<BindResult>;
|
|
440
522
|
abortBatch(batchId: string): Promise<string[]>;
|
|
@@ -33,6 +33,16 @@ async function withReadSignal(op, signal, where) {
|
|
|
33
33
|
signal.removeEventListener("abort", onAbort);
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
+
export function projectAskTerminalClaimTruth(row) {
|
|
37
|
+
if (row === null || row.state === "STREAM_PENDING")
|
|
38
|
+
return { state: "absent" };
|
|
39
|
+
return {
|
|
40
|
+
state: row.state,
|
|
41
|
+
rev: row.rev,
|
|
42
|
+
decision: row.decision,
|
|
43
|
+
...(row.updatedInput !== undefined ? { updatedInput: row.updatedInput } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
36
46
|
export const APPROVAL_ASK_TABLE = "approval_ask";
|
|
37
47
|
export const APPROVAL_BATCH_TABLE = "approval_batch";
|
|
38
48
|
export const TIDB_APPROVAL_ASK_STATEMENTS = [
|
|
@@ -278,6 +288,9 @@ function patchColumns(patch, json) {
|
|
|
278
288
|
function ph(dialect, n) {
|
|
279
289
|
return dialect === "tidb" ? "?" : `$${n}`;
|
|
280
290
|
}
|
|
291
|
+
function pendingClaimWhere(dialect, firstParam) {
|
|
292
|
+
return `ask_id = ${ph(dialect, firstParam)} AND batch_id = ${ph(dialect, firstParam + 1)} AND state = 'STREAM_PENDING'`;
|
|
293
|
+
}
|
|
281
294
|
export class SqlApprovalAskStore {
|
|
282
295
|
db;
|
|
283
296
|
constructor(db) {
|
|
@@ -366,6 +379,7 @@ export class SqlApprovalAskStore {
|
|
|
366
379
|
if (decision.idempotencyKey != null)
|
|
367
380
|
assertIdempotencyKeyShape(decision.idempotencyKey);
|
|
368
381
|
const now = Date.now();
|
|
382
|
+
const dialect = this.db.dialect;
|
|
369
383
|
const conn = await this.db.connect();
|
|
370
384
|
try {
|
|
371
385
|
await conn.begin();
|
|
@@ -376,7 +390,7 @@ export class SqlApprovalAskStore {
|
|
|
376
390
|
}
|
|
377
391
|
let askRes;
|
|
378
392
|
try {
|
|
379
|
-
askRes = await conn.query(
|
|
393
|
+
askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=${ph(dialect, 1)}, decision_actor=${ph(dialect, 2)}, decision_note=${ph(dialect, 3)}, updated_input=${ph(dialect, 4)}, decided_at_ms=${ph(dialect, 5)}, idempotency_key=${ph(dialect, 6)}, rev=rev+1, updated_at_ms=${ph(dialect, 7)} WHERE ${pendingClaimWhere(dialect, 8)}`, [
|
|
380
394
|
decision.decision,
|
|
381
395
|
decision.decisionActor == null ? null : this.json(decision.decisionActor),
|
|
382
396
|
decision.decisionNote ?? null,
|
|
@@ -385,6 +399,7 @@ export class SqlApprovalAskStore {
|
|
|
385
399
|
decision.idempotencyKey ?? null,
|
|
386
400
|
now,
|
|
387
401
|
askId,
|
|
402
|
+
batchId,
|
|
388
403
|
]);
|
|
389
404
|
}
|
|
390
405
|
catch (err) {
|
|
@@ -416,20 +431,32 @@ export class SqlApprovalAskStore {
|
|
|
416
431
|
throw new Error(`decideAsk: row vanished after commit (askId=${askId})`);
|
|
417
432
|
return { ok: true, row };
|
|
418
433
|
}
|
|
419
|
-
async
|
|
434
|
+
async claimTerminal(askId, batchId, intent) {
|
|
435
|
+
const to = intent === "expire" ? "PARKING" : "VOID";
|
|
436
|
+
if (!canAskTransition("STREAM_PENDING", to)) {
|
|
437
|
+
throw new Error(`approval-ask-store.claimTerminal: illegal terminal claim STREAM_PENDING -> ${to} (askId=${askId})`);
|
|
438
|
+
}
|
|
420
439
|
const now = Date.now();
|
|
440
|
+
const dialect = this.db.dialect;
|
|
421
441
|
const conn = await this.db.connect();
|
|
422
442
|
try {
|
|
423
|
-
await conn.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
443
|
+
await conn.beginPessimistic();
|
|
444
|
+
if (intent === "expire") {
|
|
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
|
+
if (lockRes.affected !== 1) {
|
|
447
|
+
await conn.rollback();
|
|
448
|
+
return { claimed: false, current: { state: "absent" } };
|
|
449
|
+
}
|
|
428
450
|
}
|
|
429
|
-
const askRes = await conn.query(
|
|
451
|
+
const askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state = ${ph(dialect, 1)}, rev = rev + 1, updated_at_ms = ${ph(dialect, 2)} WHERE ${pendingClaimWhere(dialect, 3)}`, [to, now, askId, batchId]);
|
|
430
452
|
if (askRes.affected !== 1) {
|
|
453
|
+
const sel = await conn.query(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ${ph(dialect, 1)} AND batch_id = ${ph(dialect, 2)} FOR UPDATE`, [askId, batchId]);
|
|
431
454
|
await conn.rollback();
|
|
432
|
-
return {
|
|
455
|
+
return { claimed: false, current: projectAskTerminalClaimTruth(sel.rows[0] ? mapAskRow(sel.rows[0]) : null) };
|
|
456
|
+
}
|
|
457
|
+
if (intent === "cancel") {
|
|
458
|
+
await conn.commit();
|
|
459
|
+
return { claimed: true, voidedSiblings: [] };
|
|
433
460
|
}
|
|
434
461
|
await conn.query(this.q(`UPDATE ${APPROVAL_BATCH_TABLE} SET state='ROUTING_UNBOUND', rev=rev+1, updated_at_ms=? WHERE batch_id=? AND state='OPEN'`, `UPDATE ${APPROVAL_BATCH_TABLE} SET state='ROUTING_UNBOUND', rev=rev+1, updated_at_ms=$1 WHERE batch_id=$2 AND state='OPEN'`), [now, batchId]);
|
|
435
462
|
let voidedSiblings;
|
|
@@ -449,7 +476,7 @@ export class SqlApprovalAskStore {
|
|
|
449
476
|
}
|
|
450
477
|
}
|
|
451
478
|
await conn.commit();
|
|
452
|
-
return {
|
|
479
|
+
return { claimed: true, voidedSiblings };
|
|
453
480
|
}
|
|
454
481
|
catch (err) {
|
|
455
482
|
try {
|
|
@@ -463,6 +490,10 @@ export class SqlApprovalAskStore {
|
|
|
463
490
|
conn.release();
|
|
464
491
|
}
|
|
465
492
|
}
|
|
493
|
+
async expireAsk(askId, batchId) {
|
|
494
|
+
const res = await this.claimTerminal(askId, batchId, "expire");
|
|
495
|
+
return res.claimed ? { won: true, voidedSiblings: res.voidedSiblings } : { won: false, voidedSiblings: [] };
|
|
496
|
+
}
|
|
466
497
|
async bindBatch(batchId, askId, gate) {
|
|
467
498
|
const now = Date.now();
|
|
468
499
|
const conn = await this.db.connect();
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
45
45
|
import type { Pool as PgPool } from "pg";
|
|
46
46
|
import { type SqlDriver } from "./sql-driver.js";
|
|
47
|
-
import { type AcquireConnectionInput, type AcquireConnectionResult, type AppendAuditInput, type BindSessionInput, type BindSessionResult, type ConsumeEnrollTokenInput, type ConsumeEnrollTokenResult, type DeviceAdmissionView, type DeviceAuditRow, type DeviceEnrollTokenRow, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type HeartbeatConnectionInput, type IssueEnrollTokenInput, type ListAuditFilter, type RevokeDeviceInput, type RevokeDeviceResult } from "../device-store.js";
|
|
47
|
+
import { type AcquireConnectionInput, type AcquireConnectionResult, type AppendAuditInput, type BindSessionInput, type BindSessionResult, type ConsumeEnrollTokenInput, type ConsumeEnrollTokenResult, type DeviceAdmissionView, type DeviceAuditRow, type DeviceEnrollTokenRow, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type HeartbeatConnectionInput, type IssueEnrollTokenInput, type ListAuditFilter, type RebindSessionInput, type RebindSessionResult, type RevokeDeviceInput, type RevokeDeviceResult } from "../device-store.js";
|
|
48
48
|
/**
|
|
49
49
|
* ── 命名(**落库一律单数**,与设计稿的复数形不同)──────────────────────────────────────────────────
|
|
50
50
|
* design v2 §6 的 DDL 写的是 `devices` / `device_sessions` / `device_enrollment_tokens`(复数),但本仓有一条
|
|
@@ -62,6 +62,29 @@ export declare const TIDB_DEVICE_STATEMENTS: readonly string[];
|
|
|
62
62
|
export declare function ensureTiDBDeviceSchema(pool: MySqlPool): Promise<void>;
|
|
63
63
|
type PgQuery = (sql: string, params?: unknown[]) => Promise<unknown>;
|
|
64
64
|
export declare function ensurePgDeviceSchema(q: PgQuery): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* O4 升级前置探针 —— **拒启,不是 warn**([ref]/[ref]/[ref] `assert*Schema` 族先例):`device_audit` 的
|
|
67
|
+
* 事件闭集加了第 9 员 `session_rebound`,而 `CREATE TABLE IF NOT EXISTS` 对存量表是空操作 ⇒ 一台没
|
|
68
|
+
* 重建表就升上来的 **PG** 部署(PG 强制 CHECK;TiDB 默认 `tidb_enable_check_constraint=OFF` 不执行,
|
|
69
|
+
* 见 AUDIT_EVENT_CHECK 头注的真库实测),每一次换绑赢 CAS 都在**审计 INSERT** 上撞
|
|
70
|
+
* `ck_device_audit_event` ⇒ 整事务回滚,换绑动词在运行期恒 500 —— 而启动与健康检查全绿。
|
|
71
|
+
*
|
|
72
|
+
* 判据 = **能力探测**(begin → 插一行 `session_rebound` → rollback,探针行永不落地),不读
|
|
73
|
+
* information_schema 元数据(memory `capability-detection-not-impl-names`:判「装没装」用能力面 ——
|
|
74
|
+
* TiDB 上 CHECK 元数据在而不执行,读元数据会把能写的表误判成不能写)。只有**可证**的 CHECK 拒
|
|
75
|
+
* ({@link isCheckViolationError})才换成指路;其余错误原样上抛([ref] 两轮收窄的同一条:一句指向
|
|
76
|
+
* 手工 DDL 的指路比它要挡的缺陷更贵)。
|
|
77
|
+
*
|
|
78
|
+
* 🔴 指路刻意**不是** `DROP TABLE`(与兄弟探针不同):`device_audit` 是安全轴审计史,为扩一个词表
|
|
79
|
+
* 销毁全部审计行不成比例。本仓不发 `ALTER TABLE`(SCHEMA POLICY),但闭集执法的权威本就在应用层
|
|
80
|
+
* (读侧 `parseDeviceAuditEvent`),CHECK 只是纵深 —— 所以给运维的是**手工**约束级重建语句
|
|
81
|
+
* (drop + 按当前闭集 add),历史一行不丢,纵深回到当前词表。
|
|
82
|
+
*
|
|
83
|
+
* 调用点:`boot/stores.ts`,**只在 device 车道真会装配时探**(`config.remoteExec?.provider ===
|
|
84
|
+
* "device"`,与 main.ts 的 `createDeviceLane` 谓词同源)——非 device 部署零审计写点,为它拒启是纯误伤
|
|
85
|
+
* ([ref] round2 的同一条收窄)。
|
|
86
|
+
*/
|
|
87
|
+
export declare function assertDeviceAuditRebindEventSchema(db: SqlDriver): Promise<void>;
|
|
65
88
|
export declare class SqlDeviceStore implements DeviceStore {
|
|
66
89
|
private readonly db;
|
|
67
90
|
constructor(db: SqlDriver);
|
|
@@ -82,6 +105,8 @@ export declare class SqlDeviceStore implements DeviceStore {
|
|
|
82
105
|
readDeviceForAdmission(deviceId: string, owner: DeviceOwner): Promise<DeviceAdmissionView | null>;
|
|
83
106
|
getDeviceUnscoped(deviceId: string): Promise<DeviceRow | null>;
|
|
84
107
|
listDevicesByOwner(owner: DeviceOwner): Promise<DeviceRow[]>;
|
|
108
|
+
/** O4/S-4 operator 全量(消费点必须先过 explicit operator 门 —— 契约注见 `DeviceStore`)。 */
|
|
109
|
+
listDevicesUnscoped(): Promise<DeviceRow[]>;
|
|
85
110
|
/**
|
|
86
111
|
* 吊销 = 终态 CAS(`status='active'` 前态)+ **租约作废 + 世代推进**(§8-R4 在途语义):
|
|
87
112
|
* 世代一推,在途连接的每一次带世代谓词的写(心跳/结果帧入账)当场影响 0 行 ⇒ 设备侧立即失权。
|
|
@@ -113,6 +138,18 @@ export declare class SqlDeviceStore implements DeviceStore {
|
|
|
113
138
|
* 输家回读既有行 —— 同 deviceId = 幂等成功,异 deviceId = `conflict`(无未定义态)。
|
|
114
139
|
*/
|
|
115
140
|
bindSession(input: BindSessionInput): Promise<BindSessionResult>;
|
|
141
|
+
/**
|
|
142
|
+
* O4 显式换绑(2026-08-30 v2 稿 §2)—— **单事务**:目标设备门(当前读)+ 幂等短路 + 前态 CAS +
|
|
143
|
+
* `session_rebound` 审计行一起提交。三形语义 oracle = `InMemoryDeviceStore.rebindSession`。
|
|
144
|
+
*
|
|
145
|
+
* 🔴 `beginPessimistic` + 两次 `FOR UPDATE`(bindSession 的同一条真库红的射程):
|
|
146
|
+
* · 设备行必须当前读 —— 快照读会把「与吊销并发的换绑」绑到一台刚被吊销的设备上(double-admit 形);
|
|
147
|
+
* · 绑定行必须当前读且**持锁到提交** —— 两个并发换绑(同 expectedRev)在锁上串行:赢家 rev+1 提交,
|
|
148
|
+
* 输家的锁读看到新 rev ⇒ 走 `rev_conflict` 携当前行(单赢家,无未定义态)。锁读判等之后,CAS
|
|
149
|
+
* UPDATE(`rev = ?` 谓词 + `rev+1` 恒变列)在持锁下不可能 0 行 —— 0 行即驱动/引擎异常,响亮抛,
|
|
150
|
+
* 不折成业务分支([ref]:未知失败不伪装成正常拒绝)。
|
|
151
|
+
*/
|
|
152
|
+
rebindSession(input: RebindSessionInput): Promise<RebindSessionResult>;
|
|
116
153
|
getSessionBinding(rootSessionId: string): Promise<DeviceSessionRow | null>;
|
|
117
154
|
deleteByRootSession(rootSessionId: string): Promise<number>;
|
|
118
155
|
appendAudit(input: AppendAuditInput): Promise<void>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
2
|
-
import { isDupKeyError } from "./sql-errors.js";
|
|
3
|
-
import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRevokeShape, assertRootSessionIdShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
|
|
2
|
+
import { isDupKeyError, isCheckViolationError } from "./sql-errors.js";
|
|
3
|
+
import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRebindShape, assertRevokeShape, assertRootSessionIdShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
|
|
4
4
|
export const DEVICES_TABLE = "device";
|
|
5
5
|
export const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token";
|
|
6
6
|
export const DEVICE_SESSIONS_TABLE = "device_session";
|
|
@@ -149,6 +149,39 @@ export async function ensurePgDeviceSchema(q) {
|
|
|
149
149
|
await q(`CREATE INDEX IF NOT EXISTS idx_device_audit_device ON ${DEVICE_AUDIT_TABLE} (device_id, at)`);
|
|
150
150
|
await q(`CREATE INDEX IF NOT EXISTS idx_device_audit_event ON ${DEVICE_AUDIT_TABLE} (event, at)`);
|
|
151
151
|
}
|
|
152
|
+
export async function assertDeviceAuditRebindEventSchema(db) {
|
|
153
|
+
const conn = await db.connect();
|
|
154
|
+
try {
|
|
155
|
+
await conn.begin();
|
|
156
|
+
try {
|
|
157
|
+
await conn.query(db.dialect === "tidb"
|
|
158
|
+
? `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, NULL, NULL, NULL, NULL)`
|
|
159
|
+
: `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, NULL, NULL, NULL, NULL)`, ["session_rebound"]);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
if (!isCheckViolationError(err, db.dialect))
|
|
163
|
+
throw err;
|
|
164
|
+
const remedy = db.dialect === "pg"
|
|
165
|
+
? `ALTER TABLE ${DEVICE_AUDIT_TABLE} DROP CONSTRAINT ck_device_audit_event; ALTER TABLE ${DEVICE_AUDIT_TABLE} ADD CONSTRAINT ck_device_audit_event CHECK (event IN (${AUDIT_EVENT_CHECK}));`
|
|
166
|
+
: `ALTER TABLE ${DEVICE_AUDIT_TABLE} DROP CHECK ck_device_audit_event; ALTER TABLE ${DEVICE_AUDIT_TABLE} ADD CONSTRAINT ck_device_audit_event CHECK (event IN (${AUDIT_EVENT_CHECK}));`;
|
|
167
|
+
throw new Error(`${DEVICE_AUDIT_TABLE} still carries a pre-O4 event CHECK that rejects 'session_rebound' — refusing to start. ` +
|
|
168
|
+
"The rebind verb (POST /v1/devices/sessions/:rootSessionId/rebind) writes this audit event in the same transaction " +
|
|
169
|
+
"as its CAS, so on this table every rebind would fail at runtime after a healthy-looking boot. This repository ships " +
|
|
170
|
+
"no ALTER TABLE migrations, and dropping an AUDIT table would destroy security-axis history — run the manual " +
|
|
171
|
+
`constraint rebuild instead (history preserved, defense-in-depth restored to the current closed set): \`${remedy}\` ` +
|
|
172
|
+
"The application layer remains the enforcing authority for this closed set (parseDeviceAuditEvent). " +
|
|
173
|
+
`Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
|
|
174
|
+
}
|
|
175
|
+
await conn.rollback();
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
await conn.rollback().catch(() => undefined);
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
conn.release();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
152
185
|
const ms = (dialect, col, alias) => dialect === "tidb" ? `ROUND(UNIX_TIMESTAMP(${col}) * 1000) AS ${alias}` : `(EXTRACT(EPOCH FROM ${col}) * 1000)::bigint AS ${alias}`;
|
|
153
186
|
const NOW = (dialect) => (dialect === "tidb" ? "NOW(3)" : "now()");
|
|
154
187
|
const nowPlusSeconds = (dialect, placeholder) => dialect === "tidb" ? `NOW(3) + INTERVAL ${placeholder} SECOND` : `now() + (${placeholder}::double precision * INTERVAL '1 second')`;
|
|
@@ -413,6 +446,10 @@ export class SqlDeviceStore {
|
|
|
413
446
|
const { rows } = await this.db.query(this.q(`SELECT ${deviceCols("tidb")} FROM ${DEVICES_TABLE} WHERE owner_tenant = ? AND owner_subject = ? ORDER BY device_id ASC`, `SELECT ${deviceCols("pg")} FROM ${DEVICES_TABLE} WHERE owner_tenant = $1 AND owner_subject = $2 ORDER BY device_id ASC`), [owner.tenant, owner.subject]);
|
|
414
447
|
return rows.map(mapDeviceRow);
|
|
415
448
|
}
|
|
449
|
+
async listDevicesUnscoped() {
|
|
450
|
+
const { rows } = await this.db.query(this.q(`SELECT ${deviceCols("tidb")} FROM ${DEVICES_TABLE} ORDER BY device_id ASC`, `SELECT ${deviceCols("pg")} FROM ${DEVICES_TABLE} ORDER BY device_id ASC`));
|
|
451
|
+
return rows.map(mapDeviceRow);
|
|
452
|
+
}
|
|
416
453
|
async revokeDevice(input) {
|
|
417
454
|
assertRevokeShape(input);
|
|
418
455
|
const conn = await this.db.connect();
|
|
@@ -515,6 +552,49 @@ export class SqlDeviceStore {
|
|
|
515
552
|
conn.release();
|
|
516
553
|
}
|
|
517
554
|
}
|
|
555
|
+
async rebindSession(input) {
|
|
556
|
+
assertRebindShape(input);
|
|
557
|
+
const conn = await this.db.connect();
|
|
558
|
+
try {
|
|
559
|
+
await conn.beginPessimistic();
|
|
560
|
+
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]);
|
|
561
|
+
if (!dev.rows[0] || parseDeviceStatus(bufToStr(dev.rows[0].status)) !== "active") {
|
|
562
|
+
await conn.rollback();
|
|
563
|
+
return { outcome: "device_not_found" };
|
|
564
|
+
}
|
|
565
|
+
const cur = await conn.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ? AND owner_tenant = ? AND owner_subject = ? FOR UPDATE`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1 AND owner_tenant = $2 AND owner_subject = $3 FOR UPDATE`), [input.rootSessionId, input.owner.tenant, input.owner.subject]);
|
|
566
|
+
if (!cur.rows[0]) {
|
|
567
|
+
await conn.rollback();
|
|
568
|
+
return { outcome: "binding_not_found" };
|
|
569
|
+
}
|
|
570
|
+
const row = mapSessionRow(cur.rows[0]);
|
|
571
|
+
if (row.deviceId === input.toDeviceId && row.rev === input.expectedRev) {
|
|
572
|
+
await conn.rollback();
|
|
573
|
+
return { outcome: "idempotent", row };
|
|
574
|
+
}
|
|
575
|
+
if (row.rev !== input.expectedRev) {
|
|
576
|
+
await conn.rollback();
|
|
577
|
+
return { outcome: "rev_conflict", row };
|
|
578
|
+
}
|
|
579
|
+
const cas = await conn.query(this.q(`UPDATE ${DEVICE_SESSIONS_TABLE} SET device_id = ?, bound_at = NOW(3), rev = rev + 1 WHERE root_session_id = ? AND owner_tenant = ? AND owner_subject = ? AND rev = ?`, `UPDATE ${DEVICE_SESSIONS_TABLE} SET device_id = $1, bound_at = now(), rev = rev + 1 WHERE root_session_id = $2 AND owner_tenant = $3 AND owner_subject = $4 AND rev = $5`), [input.toDeviceId, input.rootSessionId, input.owner.tenant, input.owner.subject, input.expectedRev]);
|
|
580
|
+
if (cas.affected !== 1) {
|
|
581
|
+
throw new Error(`device-store: rebind CAS affected ${cas.affected} rows under a held row lock — engine anomaly, refusing to guess`);
|
|
582
|
+
}
|
|
583
|
+
await this.insertAuditOn(conn, { event: "session_rebound", deviceId: input.toDeviceId, owner: row.owner, detail: { from: row.deviceId, to: input.toDeviceId, rev: row.rev + 1 } });
|
|
584
|
+
const { rows } = await conn.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ?`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1`), [input.rootSessionId]);
|
|
585
|
+
await conn.commit();
|
|
586
|
+
if (!rows[0])
|
|
587
|
+
throw new Error("device-store: rebound session binding vanished inside its own transaction");
|
|
588
|
+
return { outcome: "rebound", row: mapSessionRow(rows[0]) };
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
await conn.rollback().catch(() => undefined);
|
|
592
|
+
throw err;
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
conn.release();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
518
598
|
async getSessionBinding(rootSessionId) {
|
|
519
599
|
assertRootSessionIdShape(rootSessionId);
|
|
520
600
|
const { rows } = await this.db.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ?`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1`), [rootSessionId]);
|
|
@@ -39,9 +39,36 @@ export declare const PG_SCHEMA_STATEMENTS: readonly string[];
|
|
|
39
39
|
* Unset = both off (byte-compat: legit heavyweight statements — boot DDL, ≤64 MiB snapshot-blob rows — make a
|
|
40
40
|
* universal default unsafe; the S9 write-behind counter face carries its own always-on per-query timeout). */
|
|
41
41
|
export declare function pgPoolOptions(pgCfg: NonNullable<ServiceConfig["pg"]>, dbQueryTimeoutMs?: number): pg.PoolConfig;
|
|
42
|
+
/** 池连接错误的结构化摘要([ref];idle 态与 checked-out 态两监听共用)。纯函数,单测直打。字段
|
|
43
|
+
* **诚实缺席**:code/severity 只在错误对象上真有 string 值时才出键(pg 的 DatabaseError 把两者
|
|
44
|
+
* 平铺在错误对象上;裸网络错误没有)—— 不 String() 硬铸,「不知道 ≠ 铸一个假值」([ref] 同族纪律)。 */
|
|
45
|
+
export interface PgPoolClientErrorMeta {
|
|
46
|
+
message: string;
|
|
47
|
+
code?: string;
|
|
48
|
+
severity?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function pgPoolClientErrorMeta(err: unknown): PgPoolClientErrorMeta;
|
|
51
|
+
/** `createPgPool` 的 warn 座(形=store-backend 的 `BootLogSink`;不 import 它——那是反向依赖)。 */
|
|
52
|
+
export type PgPoolWarnSink = (msg: string, meta?: Record<string, unknown>) => void;
|
|
42
53
|
/** Create the PG pool from a connection string (or pg.PoolConfig). Thin — production backend-selection wiring
|
|
43
|
-
* (a ServiceConfig.pg sibling of .tidb) is a tracked follow-on.
|
|
44
|
-
|
|
54
|
+
* (a ServiceConfig.pg sibling of .tidb) is a tracked follow-on.
|
|
55
|
+
*
|
|
56
|
+
* ── [ref](DEBTS;test [ref] 独立复现 FATAL 57P01→exit)——池必须挂 'error' 监听 ─────────────────
|
|
57
|
+
* node-postgres 语义(pg-pool@3.14.0 `makeIdleListener`):一条**空闲**连接出错
|
|
58
|
+
* (`pg_terminate_backend` / 云端故障切换 / LB 空闲回收 / 网络断)⇒ 池先 `_remove(client)` 再
|
|
59
|
+
* `pool.emit('error', err, client)`。Pool 是 EventEmitter ⇒ 无 'error' 监听时 Node 把这枚事件转
|
|
60
|
+
* throw(ERR_UNHANDLED_ERROR)⇒ `boot/shutdown.ts` 的 uncaughtException 钩无条件 exit 1 ⇒
|
|
61
|
+
* **一条空闲连接的死换整进程的死**。修在源头监听,三件明确**不做**:
|
|
62
|
+
* · 不 exit —— 坏连接在 emit **之前**已被池淘汰,下次 acquire 发新连接,在飞查询(在别的
|
|
63
|
+
* client 上)零影响,这不是进程级故障;
|
|
64
|
+
* · 不静默 —— 每次事件一条结构化 warn(含 code/severity)+ `recordFailOpen`(F 类,census 第
|
|
65
|
+
* 52 行):「连接不断被外部杀」(故障切换风暴 / 回收策略过激)与「一切正常」在服务面同形,
|
|
66
|
+
* 不留痕则只能靠间接症状发现;
|
|
67
|
+
* · 不动 shutdown.ts 的 uncaughtException 钩 —— exit 1 对**真**未捕获仍是正确姿势,旁路兜底
|
|
68
|
+
* 会吞掉别人的崩溃([ref] 纪律:修源头,不下游兜)。
|
|
69
|
+
* `onIdleClientError` 缺省 console.warn(与本文件 `ensurePgSchema` 的既有告警姿势一致)——生产
|
|
70
|
+
* 装配(store-backend `createStoreBackend`)递结构化 logger 的 warn 座。 */
|
|
71
|
+
export declare function createPgPool(opts: string | pg.PoolConfig, onIdleClientError?: PgPoolWarnSink): pg.Pool;
|
|
45
72
|
/** Idempotent central schema apply (the PG twin of tidb-pool.ts ensureSchema): the run/checkpoint tables here +
|
|
46
73
|
* every PG store's own (disjoint) tables. Order is irrelevant (CREATE IF NOT EXISTS, no cross-table FKs).
|
|
47
74
|
*
|
package/dist/plugins/pg-pool.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pg from "pg";
|
|
2
|
+
import { recordFailOpen } from "../observability/fail-open.js";
|
|
2
3
|
import { ensureSchema as ensureSessionSchema } from "./pg-session-storage.js";
|
|
3
4
|
import { ensureSchema as ensureToolResultSchema } from "./tool-result-store-sql.js";
|
|
4
5
|
import { ensureSchema as ensureCostQuotaSchema } from "./pg-cost-quota.js";
|
|
@@ -341,8 +342,50 @@ export function pgPoolOptions(pgCfg, dbQueryTimeoutMs) {
|
|
|
341
342
|
...(dbQueryTimeoutMs ? { statement_timeout: dbQueryTimeoutMs, query_timeout: dbQueryTimeoutMs + 5_000 } : {}),
|
|
342
343
|
};
|
|
343
344
|
}
|
|
344
|
-
export function
|
|
345
|
-
|
|
345
|
+
export function pgPoolClientErrorMeta(err) {
|
|
346
|
+
const bag = typeof err === "object" && err !== null ? err : {};
|
|
347
|
+
return {
|
|
348
|
+
message: err instanceof Error ? err.message : String(err),
|
|
349
|
+
...(typeof bag.code === "string" ? { code: bag.code } : {}),
|
|
350
|
+
...(typeof bag.severity === "string" ? { severity: bag.severity } : {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
export function createPgPool(opts, onIdleClientError) {
|
|
354
|
+
const pool = typeof opts === "string" ? new pg.Pool({ connectionString: opts }) : new pg.Pool(opts);
|
|
355
|
+
const warn = onIdleClientError ?? ((msg, meta) => console.warn(`[pg-pool] ${msg}`, meta));
|
|
356
|
+
const report = (msgName, state, err, count) => {
|
|
357
|
+
const meta = pgPoolClientErrorMeta(err);
|
|
358
|
+
try {
|
|
359
|
+
warn(msgName, { ...meta });
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
}
|
|
363
|
+
if (!count)
|
|
364
|
+
return;
|
|
365
|
+
const firstLine = meta.message.split("\n", 1)[0] ?? "";
|
|
366
|
+
recordFailOpen("server.pg-pool.idle-client-error", `state=${state} ${meta.code ?? "uncoded"}/${meta.severity ?? "?"} ${firstLine.slice(0, 200)}`);
|
|
367
|
+
};
|
|
368
|
+
pool.on("error", (err) => report("pg_pool_idle_client_error", "idle", err, true));
|
|
369
|
+
const checkoutGuards = new WeakMap();
|
|
370
|
+
pool.on("acquire", (client) => {
|
|
371
|
+
if (checkoutGuards.has(client))
|
|
372
|
+
return;
|
|
373
|
+
let counted = false;
|
|
374
|
+
const guard = (err) => {
|
|
375
|
+
report("pg_pool_checked_out_client_error", "checked-out", err, !counted);
|
|
376
|
+
counted = true;
|
|
377
|
+
};
|
|
378
|
+
checkoutGuards.set(client, guard);
|
|
379
|
+
client.on("error", guard);
|
|
380
|
+
});
|
|
381
|
+
pool.on("release", (_err, client) => {
|
|
382
|
+
const guard = checkoutGuards.get(client);
|
|
383
|
+
if (guard !== undefined) {
|
|
384
|
+
client.removeListener("error", guard);
|
|
385
|
+
checkoutGuards.delete(client);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
return pool;
|
|
346
389
|
}
|
|
347
390
|
const ENSURE_SCHEMA_LOCK_KEY = 4906;
|
|
348
391
|
export async function ensurePgSchema(pool) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ExecutionError, FileError, RemoteExecutionError, type ExecutionEnv, type ExecutionEnvFactory, type ExecStreamOptions, type FileInfo, type OutputChunk, type RemoteConnectConfig, type RemoteExecutionEnv, type Result, type SessionToken, type SnapshotId, type VmLifecycleOptions, type WorkspaceHandle, type WriteExpectation, type WriteReceipt } from "@sema-agent/core";
|
|
2
|
-
import {
|
|
2
|
+
import { type DeviceOwner, type DeviceRejectCode, type DeviceRow, type DeviceStore } from "../device-store.js";
|
|
3
3
|
import type { DeviceEnrollment } from "../device-enrollment.js";
|
|
4
4
|
import type { DeviceWsHub } from "../device-ws-hub.js";
|
|
5
5
|
import type { DeviceInstructionKind, DeviceOutcomeErrorCode } from "../device-ws-protocol.js";
|
|
@@ -34,7 +34,7 @@ export declare const DEVICE_OUTCOME_TO_FILE_ERROR: Readonly<Record<DeviceOutcome
|
|
|
34
34
|
* **从未启动**,因为可执行面解析不了」,与「设备没有这条指令面」同侧;never-started 语义保住)。
|
|
35
35
|
*/
|
|
36
36
|
export declare const DEVICE_OUTCOME_TO_EXEC_ERROR: Readonly<Record<DeviceOutcomeErrorCode, ExecutionError["code"]>>;
|
|
37
|
-
type RejectCode =
|
|
37
|
+
type RejectCode = DeviceRejectCode;
|
|
38
38
|
/**
|
|
39
39
|
* 汇聚端的 **typed 拒**(投递门**之前**,§5.3 投递边界:设备从未可能见过这条指令)→ core 错误码。
|
|
40
40
|
*
|
|
@@ -44,6 +44,8 @@ type RejectCode = DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE;
|
|
|
44
44
|
* 例外 `identity_mismatch` / `revoked` / `not_found`:那是**永久**拒(换设备/吊销/不是你的),重试
|
|
45
45
|
* 只会同样失败 —— exec 面落 `auth_failed`(NEVER retry 的那一格),file 面无对位词落 `permission_denied`
|
|
46
46
|
* (「这条路你走不通,而且不是暂时的」是它最近的语义)。
|
|
47
|
+
* `draining`([ref] 借用位)与 busy 同侧(commit 前拒,从未启动)—— 判别靠 rejectText 把 `[draining]`
|
|
48
|
+
* 整句带进文案:重试该去**替换副本**,不是稍后打同一个。
|
|
47
49
|
*/
|
|
48
50
|
export declare const DEVICE_REJECT_TO_EXEC_ERROR: Readonly<Record<RejectCode, ExecutionError["code"]>>;
|
|
49
51
|
export declare const DEVICE_REJECT_TO_FILE_ERROR: Readonly<Record<RejectCode, FileError["code"]>>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { ExecutionError, FileError, RemoteExecutionError, } from "@sema-agent/core";
|
|
3
|
-
import { DEVICE_ERROR_CODES,
|
|
3
|
+
import { DEVICE_ERROR_CODES, } from "../device-store.js";
|
|
4
4
|
export const DEVICE_PROVIDER = "device";
|
|
5
5
|
const ok = (value) => ({ ok: true, value });
|
|
6
6
|
const STREAM_PENDING_MAX_BYTES = 2 * 1024 * 1024;
|
|
@@ -59,7 +59,10 @@ export const DEVICE_REJECT_TO_EXEC_ERROR = {
|
|
|
59
59
|
"device.revoked": "auth_failed",
|
|
60
60
|
"device.enrollment_invalid": "auth_failed",
|
|
61
61
|
"not_found.device": "auth_failed",
|
|
62
|
+
"device.rebind_rev_conflict": "target_unavailable",
|
|
63
|
+
"device.rebind_active_run": "target_unavailable",
|
|
62
64
|
"limit.rate_exceeded": "target_unavailable",
|
|
65
|
+
"draining": "target_unavailable",
|
|
63
66
|
};
|
|
64
67
|
export const DEVICE_REJECT_TO_FILE_ERROR = {
|
|
65
68
|
"device.not_bound": "transport_lost",
|
|
@@ -72,7 +75,10 @@ export const DEVICE_REJECT_TO_FILE_ERROR = {
|
|
|
72
75
|
"device.revoked": "permission_denied",
|
|
73
76
|
"device.enrollment_invalid": "permission_denied",
|
|
74
77
|
"not_found.device": "permission_denied",
|
|
78
|
+
"device.rebind_rev_conflict": "transport_lost",
|
|
79
|
+
"device.rebind_active_run": "transport_lost",
|
|
75
80
|
"limit.rate_exceeded": "transport_lost",
|
|
81
|
+
"draining": "transport_lost",
|
|
76
82
|
};
|
|
77
83
|
const DEVICE_REJECT_TO_REMOTE_ERROR = {
|
|
78
84
|
"device.not_bound": "connect_failed",
|
|
@@ -85,7 +91,10 @@ const DEVICE_REJECT_TO_REMOTE_ERROR = {
|
|
|
85
91
|
"device.revoked": "auth_failed",
|
|
86
92
|
"device.enrollment_invalid": "auth_failed",
|
|
87
93
|
"not_found.device": "auth_failed",
|
|
94
|
+
"device.rebind_rev_conflict": "connect_failed",
|
|
95
|
+
"device.rebind_active_run": "connect_failed",
|
|
88
96
|
"limit.rate_exceeded": "connect_failed",
|
|
97
|
+
"draining": "connect_failed",
|
|
89
98
|
};
|
|
90
99
|
function rejectText(reject) {
|
|
91
100
|
return `[${reject.code}] ${reject.message}`;
|
|
@@ -172,7 +181,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
172
181
|
if (admitted.value.deviceId !== this.cfg.deviceId) {
|
|
173
182
|
return {
|
|
174
183
|
ok: false,
|
|
175
|
-
error: new RemoteExecutionError("auth_failed", `[${DEVICE_ERROR_CODES.IDENTITY_MISMATCH}] this session is now bound to a different device than the one this environment was minted for — refusing to silently re-target (device lane identity, v2 §4.5)`),
|
|
184
|
+
error: new RemoteExecutionError("auth_failed", `[${DEVICE_ERROR_CODES.IDENTITY_MISMATCH}] this session is now bound to a different device (${admitted.value.deviceId}) than the one this environment was minted for — refusing to silently re-target; re-initiate the work on the currently bound device (device lane identity, v2 §4.5/§3)`),
|
|
176
185
|
};
|
|
177
186
|
}
|
|
178
187
|
{
|
|
@@ -236,6 +245,7 @@ export class RemoteDeviceExecutionEnv {
|
|
|
236
245
|
const input = {
|
|
237
246
|
rootSessionId: this.cfg.rootSessionId,
|
|
238
247
|
requester: this.cfg.owner,
|
|
248
|
+
expectedDeviceId: this.cfg.deviceId,
|
|
239
249
|
kind,
|
|
240
250
|
args,
|
|
241
251
|
timeoutMs: opts.timeoutMs,
|
|
@@ -21,8 +21,7 @@
|
|
|
21
21
|
* 是「core 变语义必须先过迁移裁量」的告警钉)。
|
|
22
22
|
*/
|
|
23
23
|
import type { Pool } from "mysql2/promise";
|
|
24
|
-
import { type RosterStore, type RosterEntry } from "@sema-agent/core";
|
|
25
|
-
type RosterAccess = Parameters<RosterStore["resolve"]>[1];
|
|
24
|
+
import { type RosterStore, type RosterEntry, type RosterAccess } from "@sema-agent/core";
|
|
26
25
|
import type { PgQueryFn } from "./pg-query.js";
|
|
27
26
|
export declare const ROSTER_TABLE = "agent_roster";
|
|
28
27
|
export declare function ensureTiDBRosterSchema(pool: Pool): Promise<void>;
|
|
@@ -51,5 +50,4 @@ export declare class PgRosterStore implements RosterStore {
|
|
|
51
50
|
/** [ref] MED2(TiDB twin 同案注)。 */
|
|
52
51
|
reapOlderThan(cutoffMs: number): Promise<number>;
|
|
53
52
|
}
|
|
54
|
-
export {};
|
|
55
53
|
//# sourceMappingURL=roster-store-sql.d.ts.map
|
|
@@ -56,4 +56,16 @@ export declare const MYSQL_ER_BAD_FIELD_CODE = "ER_BAD_FIELD_ERROR";
|
|
|
56
56
|
* MySQL 侧两个归因键都认(mysql2 的 `code`/`errno` 是同一件事的两种写法),与 dup-key 那条同姿势。
|
|
57
57
|
*/
|
|
58
58
|
export declare function isMissingColumnError(err: unknown, dialect: SqlDialect): boolean;
|
|
59
|
+
/** PostgreSQL `check_violation` 的 SQLSTATE。 */
|
|
60
|
+
export declare const PG_CHECK_VIOLATION_SQLSTATE = "23514";
|
|
61
|
+
/** MySQL/TiDB `ER_CHECK_CONSTRAINT_VIOLATED` 的数字 errno(TiDB 仅在 `tidb_enable_check_constraint=ON` 时可达)。 */
|
|
62
|
+
export declare const MYSQL_ER_CHECK_VIOLATED_ERRNO = 3819;
|
|
63
|
+
/** MySQL/TiDB `ER_CHECK_CONSTRAINT_VIOLATED` 的字符串错误码。 */
|
|
64
|
+
export declare const MYSQL_ER_CHECK_VIOLATED_CODE = "ER_CHECK_CONSTRAINT_VIOLATED";
|
|
65
|
+
/**
|
|
66
|
+
* 「这个错误**是**『CHECK 约束被拒』吗」—— O4 `device_audit` 事件闭集升级探针
|
|
67
|
+
* (`assertDeviceAuditRebindEventSchema`)的唯一判据。收窄纪律与 {@link isMissingColumnError} 逐字同源:
|
|
68
|
+
* 它守的是一句**指向手工 DDL 的**错误指路,认不出的一律不是 CHECK 拒(原始错误原样上抛)。
|
|
69
|
+
*/
|
|
70
|
+
export declare function isCheckViolationError(err: unknown, dialect: SqlDialect): boolean;
|
|
59
71
|
//# sourceMappingURL=sql-errors.d.ts.map
|
|
@@ -25,4 +25,14 @@ export function isMissingColumnError(err, dialect) {
|
|
|
25
25
|
return keyOf(err, "code") === PG_UNDEFINED_COLUMN_SQLSTATE;
|
|
26
26
|
return keyOf(err, "errno") === MYSQL_ER_BAD_FIELD_ERRNO || keyOf(err, "code") === MYSQL_ER_BAD_FIELD_CODE;
|
|
27
27
|
}
|
|
28
|
+
export const PG_CHECK_VIOLATION_SQLSTATE = "23514";
|
|
29
|
+
export const MYSQL_ER_CHECK_VIOLATED_ERRNO = 3819;
|
|
30
|
+
export const MYSQL_ER_CHECK_VIOLATED_CODE = "ER_CHECK_CONSTRAINT_VIOLATED";
|
|
31
|
+
export function isCheckViolationError(err, dialect) {
|
|
32
|
+
if (err === null || typeof err !== "object")
|
|
33
|
+
return false;
|
|
34
|
+
if (dialect === "pg")
|
|
35
|
+
return keyOf(err, "code") === PG_CHECK_VIOLATION_SQLSTATE;
|
|
36
|
+
return keyOf(err, "errno") === MYSQL_ER_CHECK_VIOLATED_ERRNO || keyOf(err, "code") === MYSQL_ER_CHECK_VIOLATED_CODE;
|
|
37
|
+
}
|
|
28
38
|
//# sourceMappingURL=sql-errors.js.map
|