@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.
Files changed (72) hide show
  1. package/USAGE.md +5 -0
  2. package/dist/approval-ask-audit-store.d.ts +129 -0
  3. package/dist/approval-ask-audit-store.js +284 -0
  4. package/dist/approval-card.d.ts +18 -6
  5. package/dist/approval-card.js +2 -2
  6. package/dist/approval-reconciler.d.ts +2 -1
  7. package/dist/approval-reconciler.js +1 -1
  8. package/dist/boot/config-center.js +2 -2
  9. package/dist/boot/coordinators.d.ts +2 -0
  10. package/dist/boot/coordinators.js +18 -1
  11. package/dist/boot/leader.js +2 -0
  12. package/dist/boot/reapers.d.ts +2 -1
  13. package/dist/boot/resolve-spec.js +1 -3
  14. package/dist/boot/runner-deps.d.ts +20 -0
  15. package/dist/boot/runner-deps.js +14 -8
  16. package/dist/boot/shutdown.js +1 -1
  17. package/dist/boot/stores.js +8 -1
  18. package/dist/capabilities/team.d.ts +10 -9
  19. package/dist/config-center/apply-effective.d.ts +25 -26
  20. package/dist/config-center/apply-effective.js +28 -66
  21. package/dist/config-center/effective-keys.d.ts +30 -0
  22. package/dist/config-center/effective-keys.js +49 -0
  23. package/dist/config-center/facade.d.ts +3 -3
  24. package/dist/config-center/facade.js +1 -1
  25. package/dist/config-center/restart-signal.js +3 -9
  26. package/dist/config-center/types.d.ts +19 -40
  27. package/dist/config-lkg.js +7 -1
  28. package/dist/config-provider.js +1 -1
  29. package/dist/device-enrollment.d.ts +5 -3
  30. package/dist/device-store.d.ts +81 -3
  31. package/dist/device-store.js +37 -0
  32. package/dist/device-ws-hub.d.ts +15 -2
  33. package/dist/device-ws-hub.js +31 -5
  34. package/dist/http/routes/approvals-assistant.js +1 -0
  35. package/dist/http/routes/devices.d.ts +64 -0
  36. package/dist/http/routes/devices.js +173 -0
  37. package/dist/http/server.d.ts +14 -0
  38. package/dist/http/server.js +32 -2
  39. package/dist/http/wire-types.d.ts +19 -0
  40. package/dist/leader/wire.d.ts +12 -0
  41. package/dist/leader/wire.js +6 -5
  42. package/dist/main.js +3 -1
  43. package/dist/observability/fail-open.d.ts +20 -0
  44. package/dist/observability/fail-open.js +20 -0
  45. package/dist/plugins/approval-ask-store-memory.d.ts +11 -1
  46. package/dist/plugins/approval-ask-store-memory.js +20 -3
  47. package/dist/plugins/approval-ask-store-sql.d.ts +82 -0
  48. package/dist/plugins/approval-ask-store-sql.js +41 -10
  49. package/dist/plugins/device-store-sql.d.ts +38 -1
  50. package/dist/plugins/device-store-sql.js +82 -2
  51. package/dist/plugins/pg-pool.d.ts +29 -2
  52. package/dist/plugins/pg-pool.js +45 -2
  53. package/dist/plugins/remote-env-device.d.ts +4 -2
  54. package/dist/plugins/remote-env-device.js +12 -2
  55. package/dist/plugins/roster-store-sql.d.ts +1 -3
  56. package/dist/plugins/sql-errors.d.ts +12 -0
  57. package/dist/plugins/sql-errors.js +10 -0
  58. package/dist/plugins/store-backend.js +1 -1
  59. package/dist/plugins/tidb-pool.d.ts +13 -0
  60. package/dist/runs.js +1 -0
  61. package/dist/runtime-governance.d.ts +4 -0
  62. package/dist/runtime-governance.js +23 -1
  63. package/dist/tool-approval.d.ts +63 -39
  64. package/dist/tool-approval.js +322 -109
  65. package/dist/trace/engine-notice-wire.d.ts +1 -1
  66. package/dist/trace/engine-notice-wire.js +2 -0
  67. package/dist/trace/injection-tier.d.ts +11 -21
  68. package/dist/trace/injection-tier.js +3 -4
  69. package/dist/trace/ledger-events.d.ts +9 -0
  70. package/dist/trace/project.d.ts +1 -0
  71. package/dist/trace/project.js +1 -0
  72. 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(this.q(`UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=?, decision_actor=?, decision_note=?, updated_input=?, decided_at_ms=?, idempotency_key=?, rev=rev+1, updated_at_ms=? WHERE ask_id=? AND state='STREAM_PENDING'`, `UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=$1, decision_actor=$2, decision_note=$3, updated_input=$4, decided_at_ms=$5, idempotency_key=$6, rev=rev+1, updated_at_ms=$7 WHERE ask_id=$8 AND state='STREAM_PENDING'`), [
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 expireAsk(askId, batchId) {
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.begin();
424
- 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]);
425
- if (lockRes.affected !== 1) {
426
- await conn.rollback();
427
- return { won: false, voidedSiblings: [] };
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(this.q(`UPDATE ${APPROVAL_ASK_TABLE} SET state='PARKING', rev=rev+1, updated_at_ms=? WHERE ask_id=? AND state='STREAM_PENDING'`, `UPDATE ${APPROVAL_ASK_TABLE} SET state='PARKING', rev=rev+1, updated_at_ms=$1 WHERE ask_id=$2 AND state='STREAM_PENDING'`), [now, askId]);
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 { won: false, voidedSiblings: [] };
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 { won: true, voidedSiblings };
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
- export declare function createPgPool(opts: string | pg.PoolConfig): pg.Pool;
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
  *
@@ -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 createPgPool(opts) {
345
- return typeof opts === "string" ? new pg.Pool({ connectionString: opts }) : new pg.Pool(opts);
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 { DEVICE_RATE_LIMIT_CODE, type DeviceErrorCode, type DeviceOwner, type DeviceRow, type DeviceStore } from "../device-store.js";
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 = DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE;
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, DEVICE_RATE_LIMIT_CODE, } from "../device-store.js";
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