@sema-agent/server 7.54.0 → 7.56.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 (76) hide show
  1. package/MIGRATION.md +3 -3
  2. package/README.md +12 -6
  3. package/README.zh-CN.md +9 -5
  4. package/USAGE.md +108 -52
  5. package/deploy/sema-up/chart/values.yaml +1 -1
  6. package/dist/approval-ask-audit-store.d.ts +129 -0
  7. package/dist/approval-ask-audit-store.js +284 -0
  8. package/dist/approval-card.d.ts +18 -6
  9. package/dist/approval-card.js +2 -2
  10. package/dist/approval-content-kind.d.ts +22 -0
  11. package/dist/approval-content-kind.js +5 -0
  12. package/dist/approval-reconciler.d.ts +2 -1
  13. package/dist/approval-reconciler.js +1 -1
  14. package/dist/boot/coordinators.d.ts +2 -0
  15. package/dist/boot/coordinators.js +18 -1
  16. package/dist/boot/leader.d.ts +21 -0
  17. package/dist/boot/leader.js +6 -0
  18. package/dist/boot/reapers.d.ts +2 -1
  19. package/dist/boot/runtime-caps.d.ts +2 -1
  20. package/dist/boot/runtime-caps.js +3 -1
  21. package/dist/boot/stores.js +8 -1
  22. package/dist/config-center/types.d.ts +2 -1
  23. package/dist/config-provider.js +1 -0
  24. package/dist/device-store.d.ts +66 -2
  25. package/dist/device-store.js +35 -0
  26. package/dist/device-ws-hub.d.ts +8 -0
  27. package/dist/device-ws-hub.js +6 -0
  28. package/dist/http/route-ctx.d.ts +15 -3
  29. package/dist/http/routes/approvals-assistant.js +3 -2
  30. package/dist/http/routes/devices.d.ts +64 -0
  31. package/dist/http/routes/devices.js +173 -0
  32. package/dist/http/routes/leader.js +2 -2
  33. package/dist/http/routes/workflows.js +1 -1
  34. package/dist/http/server.d.ts +14 -0
  35. package/dist/http/server.js +30 -5
  36. package/dist/leader/endpoint.js +5 -4
  37. package/dist/leader/wire.d.ts +50 -0
  38. package/dist/leader/wire.js +19 -7
  39. package/dist/main.js +4 -2
  40. package/dist/observability/fail-open.d.ts +7 -3
  41. package/dist/observability/fail-open.js +7 -3
  42. package/dist/plugins/approval-ask-store-memory.d.ts +11 -1
  43. package/dist/plugins/approval-ask-store-memory.js +20 -3
  44. package/dist/plugins/approval-ask-store-sql.d.ts +82 -0
  45. package/dist/plugins/approval-ask-store-sql.js +41 -10
  46. package/dist/plugins/checkpoint-store-sql.d.ts +12 -0
  47. package/dist/plugins/checkpoint-store-sql.js +4 -1
  48. package/dist/plugins/device-store-sql.d.ts +38 -1
  49. package/dist/plugins/device-store-sql.js +82 -2
  50. package/dist/plugins/file-run-store.js +2 -0
  51. package/dist/plugins/local-checkpoint-store.js +2 -0
  52. package/dist/plugins/local-session-store.d.ts +9 -9
  53. package/dist/plugins/local-session-store.js +5 -3
  54. package/dist/plugins/memory-run-store.js +2 -0
  55. package/dist/plugins/permission-rule-store-file.d.ts +22 -4
  56. package/dist/plugins/permission-rule-store-file.js +17 -8
  57. package/dist/plugins/permission-rule-store-sql.d.ts +60 -30
  58. package/dist/plugins/permission-rule-store-sql.js +23 -13
  59. package/dist/plugins/pg-session-storage.js +17 -13
  60. package/dist/plugins/remote-env-device.js +8 -1
  61. package/dist/plugins/run-store-sql.js +6 -4
  62. package/dist/plugins/sql-errors.d.ts +12 -0
  63. package/dist/plugins/sql-errors.js +10 -0
  64. package/dist/plugins/store-contracts.d.ts +4 -0
  65. package/dist/plugins/tidb-session-store.js +18 -14
  66. package/dist/rules-consent.d.ts +5 -4
  67. package/dist/rules-consent.js +5 -33
  68. package/dist/runs.js +1 -0
  69. package/dist/runtime-caps-resolver.js +3 -1
  70. package/dist/security.d.ts +7 -0
  71. package/dist/tool-approval.d.ts +66 -83
  72. package/dist/tool-approval.js +222 -186
  73. package/dist/trace/ledger-events.d.ts +9 -0
  74. package/package.json +3 -3
  75. package/skills/find-skills.md +1 -1
  76. package/skills/loop.md +1 -1
@@ -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();
@@ -120,6 +120,18 @@ export interface PendingCheckpoint {
120
120
  * 展示/分诊用,永不参与 resume / gate / CAS(core 同款 ECHO-ONLY 定性)。
121
121
  */
122
122
  hasBidiControls?: true;
123
+ /**
124
+ * [ref]([ref]② core 点名 / [ref] 认领):**内容问句分型键** —— `"content_ask"` 当这条 tool_approval
125
+ * park 门住的是保留工具 AskUserQuestion(问用户的问题,不是副作用工具),消费端(cli/壳审批面)据它
126
+ * 渲问句 UI 而不用嗅探 `toolName`。词与判据 = core `summarizeCheckpoint` 的同一行规则(行上 core 铸的
127
+ * `toolName` 的**全函数**派生;词属主 `approval-content-kind.ts`,parity 钉看漂移)——**不是**
128
+ * `hasBidiControls` 那类「server 不重算」的扫描位:派生 ≠ 重扫。
129
+ * 🔴 `"content_ask"` 或**缺席**([ref]② OMIT 纪律,恒不铸 `null`/`false`):缺席 = 非内容问句门
130
+ * (普通工具门 / 非工具 park / toolName 缺席的存量行)。展示/分诊用,永不参与 resume / gate / CAS。
131
+ * 两条 durable 读面(`GET /v1/approvals` 行 + `/stream` `pending` 帧)经同一投影同时携带;live 腿的
132
+ * 同名键在 `LivePendingRow.contentKind`(同一词属主)。
133
+ */
134
+ contentKind?: "content_ask";
123
135
  }
124
136
  /**
125
137
  * [ref]([ref] / DEBTS [ref]):session 键读口(`findPendingTokenBySession` / `peekPendingScope`)的**行绑定**。
@@ -1,3 +1,4 @@
1
+ import { contentKindOf } from "../approval-content-kind.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
3
4
  import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
@@ -437,6 +438,7 @@ export class SqlCheckpointStore {
437
438
  ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
438
439
  : await this.db.query(`${base} ORDER BY c.created_at_ms ASC`);
439
440
  const out = rows.map((r) => {
441
+ const toolName = r.tool_name ?? null;
440
442
  const toolCallId = r.tool_call_id ?? null;
441
443
  const boundInputHash = r.bound_input_hash ?? null;
442
444
  const gateKind = r.gate_kind ?? null;
@@ -444,7 +446,7 @@ export class SqlCheckpointStore {
444
446
  return {
445
447
  sessionId: String(r.session_id),
446
448
  scope: String(r.scope),
447
- toolName: r.tool_name ?? null,
449
+ toolName,
448
450
  toolCallId,
449
451
  ...(toolCallId !== null ? { boundCallId: toolCallId } : {}),
450
452
  ...(boundInputHash !== null ? { boundInputHash } : {}),
@@ -456,6 +458,7 @@ export class SqlCheckpointStore {
456
458
  riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
457
459
  ...(ruleOffers !== undefined ? { ruleOffers } : {}),
458
460
  ...(Number(r.has_bidi_controls) === 1 ? { hasBidiControls: true } : {}),
461
+ ...(contentKindOf(toolName) !== undefined ? { contentKind: "content_ask" } : {}),
459
462
  };
460
463
  });
461
464
  return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
@@ -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]);
@@ -453,6 +453,7 @@ export class FileRunStore {
453
453
  continue;
454
454
  const sorted = [...arr].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.taskId < b.taskId ? 1 : a.taskId > b.taskId ? -1 : 0));
455
455
  const latest = sorted[0];
456
+ const latestRunId = latest.result?.runId;
456
457
  const times = arr.map((r) => r.createdAt.getTime());
457
458
  const last = Math.max(...times);
458
459
  const first = Math.min(...times);
@@ -465,6 +466,7 @@ export class FileRunStore {
465
466
  objectivePreview: latest.objectivePreview,
466
467
  lastStatus: latest.status,
467
468
  lastRunId: latest.taskId,
469
+ ...(typeof latestRunId === "string" && latestRunId !== "" ? { lastTaskRunId: latestRunId } : {}),
468
470
  title: null,
469
471
  });
470
472
  }
@@ -1,3 +1,4 @@
1
+ import { contentKindOf } from "../approval-content-kind.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -160,6 +161,7 @@ export class LocalCheckpointStore {
160
161
  return rs !== undefined ? { ruleOffers: rs } : {};
161
162
  })(),
162
163
  ...(pa?.hasBidiControls === true ? { hasBidiControls: true } : {}),
164
+ ...(contentKindOf(pa?.toolName ?? null) !== undefined ? { contentKind: "content_ask" } : {}),
163
165
  });
164
166
  }
165
167
  return rows.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
@@ -27,6 +27,7 @@ export declare class LocalSessionStore implements SessionStore {
27
27
  * has only TERMINAL runs (no live tail to re-attach), so `lastRunId` falls back to null and the shell uses its
28
28
  * no-anchor path — same posture as the other local degrades here (runCount:0, lastStatus:""). Persisting it would
29
29
  * need a sidecar-format change (the #10 clobber area); deferred until a real local-file-backed need. */
30
+ /** K-5c/[ref]/[ref]:per-session 最近一次 run 的 {taskId, runId?}(noteTaskRun 席;runId omit ⇒ 清座)。 */
30
31
  private readonly lastTaskRun;
31
32
  /** 2c P1d-β — live staged-import handles keyed by stagingId, so a local staging's in-memory buffer is readable via
32
33
  * readStagedEntries (the durable twins re-read the staged session_event rows; local has no such rows). */
@@ -173,15 +174,14 @@ export declare class LocalSessionStore implements SessionStore {
173
174
  /** K-5c (core 1.155 SessionStore seam): core's Runner calls this at runTask START with the run's taskId, so the
174
175
  * latest run surfaces as `lastRunId` on listSessions even while it is still running/suspended (the local backend
175
176
  * has no runs ledger to derive it from, unlike the TiDB/PG twins). In-memory, last-write-wins per session.
176
- * [ref](core 5.65 增量批,**有意缓议**):core 的第三参 engine runId 在此**尚未消费**——它唯一的
177
- * 合法出口是 listSessions 行上的 `lastTaskRunId`,而 `SessionSummary` canonical spec 的**闭集**
178
- * (sema-sdk spec/openapi.yaml `additionalProperties:false`,producer-contract sessionsList 格咬着),
179
- * 加键是跨仓 spec 节拍的事。spec 开键那一批再落:map 值扩成 `{taskId, runId?}` + 两座同动清座语义
180
- * (对齐 core TtlSessionStore.noteTaskRun 的 omit⇒clear,session-store.js 亲读)+ 行投影。
181
- * codex 5.65 增量批 R1-[medium] 曾建议「存储先行、投影另批」——**驳**:本店 runId 的唯一读点就是
182
- * 行投影,先写不读=黑盒不可测的死状态(红先律无红可铺);清座语义单独存在也无观察面。存储与投影
183
- * 必须同批,债主=sessions 列表 wire 键的 spec 节拍批(本注即债的定位锚) */
184
- noteTaskRun(sessionId: string, taskId: string): void;
177
+ * [ref] → [ref]([ref] 兑现;上一版「有意缓议」注即本批债锚,兑现随批撤):第三参 engine runId
178
+ * 自本批起**消费** —— map 值 {taskId, runId?} + 行投影 `lastTaskRunId` + **两座同动清座**(omit
179
+ * runId 座,对齐 core TtlSessionStore.noteTaskRun 的 omit⇒clear 契约,core session.d.ts:204-212
180
+ * 亲读:旧 run runId 挂在新 task = 把上一条 run 的身份错标给新 run)。存储与行投影同批落
181
+ * (codex 5.65 R1-[medium]「存储先行、投影另批」之驳仍成立:本店 runId 唯一读点即行投影)
182
+ * wire 半场:HTTP 面零投影透传(sessions-list.ts,additive 键随行上 wire);sema-sdk spec
183
+ * `SessionSummary` 闭集候 SDK 开键([ref]/.82 班车)——SDK 落键即端到端闭合。 */
184
+ noteTaskRun(sessionId: string, taskId: string, runId?: string): void;
185
185
  /** Synthesize a session's objective preview = the most-recent USER text message (the objective the run last saw).
186
186
  * Reads the session's entries through the repo; any read error degrades to null (never fails the list). */
187
187
  private previewOf;
@@ -275,6 +275,7 @@ export class LocalSessionStore {
275
275
  continue;
276
276
  const firstActivityAt = meta.createdAt || new Date(0).toISOString();
277
277
  const lastActivityAt = this.lastActivity.get(meta.id) ?? firstActivityAt;
278
+ const lastRun = this.lastTaskRun.get(meta.id);
278
279
  all.push({
279
280
  sessionId: meta.id,
280
281
  owner,
@@ -283,7 +284,8 @@ export class LocalSessionStore {
283
284
  runCount: 0,
284
285
  objectivePreview: await this.previewOf(meta),
285
286
  lastStatus: "",
286
- lastRunId: this.lastTaskRun.get(meta.id) ?? null,
287
+ lastRunId: lastRun?.taskId ?? null,
288
+ ...(lastRun?.runId !== undefined ? { lastTaskRunId: lastRun.runId } : {}),
287
289
  title: this.titleState.titles.get(meta.id) ?? null,
288
290
  });
289
291
  }
@@ -298,8 +300,8 @@ export class LocalSessionStore {
298
300
  : filtered;
299
301
  return after.slice(0, opts.limit);
300
302
  }
301
- noteTaskRun(sessionId, taskId) {
302
- this.lastTaskRun.set(sessionId, taskId);
303
+ noteTaskRun(sessionId, taskId, runId) {
304
+ this.lastTaskRun.set(sessionId, { taskId, ...(runId !== undefined ? { runId } : {}) });
303
305
  }
304
306
  async previewOf(meta) {
305
307
  try {
@@ -186,6 +186,7 @@ export class MemoryRunStore {
186
186
  continue;
187
187
  const sorted = [...arr].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.taskId < b.taskId ? 1 : a.taskId > b.taskId ? -1 : 0));
188
188
  const latest = sorted[0];
189
+ const latestRunId = latest.result?.runId;
189
190
  const times = arr.map((r) => r.createdAt.getTime());
190
191
  const last = Math.max(...times);
191
192
  const first = Math.min(...times);
@@ -198,6 +199,7 @@ export class MemoryRunStore {
198
199
  objectivePreview: latest.objectivePreview,
199
200
  lastStatus: latest.status,
200
201
  lastRunId: latest.taskId,
202
+ ...(typeof latestRunId === "string" && latestRunId !== "" ? { lastTaskRunId: latestRunId } : {}),
201
203
  title: null,
202
204
  });
203
205
  }
@@ -1,5 +1,5 @@
1
1
  import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope, type StaleRuleApprovalRecord } from "@sema-agent/core";
2
- import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketPurpose, type RuleTicketReclaimResult, type RuleTicketRedeemResult, type RuleTicketSnapshot } from "./permission-rule-store-sql.js";
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
  /**
5
5
  * `RuleApprovalRecordStore` 的 File 形。
@@ -29,17 +29,34 @@ export declare class FileRuleApprovalRecordStore implements RuleApprovalRecordSt
29
29
  /**
30
30
  * CC 导入票的 File 形。四条信任边界(principal 绑定 / TTL / 一次性原子消费 / 载荷绑定)与 SQL 形
31
31
  * **同语义**;唯一不同的是「原子」由谁保证 —— 那边是引擎行锁,这边是单进程 + 单写者(顶注)。
32
+ *
33
+ * 🔴 **每只店对自己那口钟负责**([ref]):SQL 形的 TTL 轴锚在数据库的钟上(多副本共享的唯一一口);
34
+ * local 形没有那样一口外部钟,它的钟就是本进程的 —— 而进程墙钟**不单调**,一次 NTP 回拨就能让一张
35
+ * 真实已经走满 TTL 的票复活(codex 对抗复审 r1 [high],验真后修)。所以本形自己带一枚**单调锚**:
36
+ * 本进程铸的票,判定钟 = `铸票墙钟 + 自铸票起单调钟走过的量`;重启后重放回来的行没有锚,如实退回墙钟。
32
37
  */
33
38
  export declare class FileRuleImportTicketStore {
34
39
  private readonly rows;
35
40
  private readonly log;
36
41
  private readonly gate;
37
- constructor(dir: string, now?: () => number, onError?: (message: string) => void);
42
+ /** 单调钟(判定用)。与 {@link now}(墙钟,记账用)是两只不同的钟,别混。 */
43
+ private readonly monotonic;
44
+ constructor(dir: string, now?: () => number, onError?: (message: string) => void, monotonic?: () => number);
38
45
  private readonly now;
39
46
  /** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
40
47
  get corrupt(): boolean;
41
48
  /** 归还本面 eager 持有的日志描述符(理由与姊妹面 {@link FileRuleApprovalRecordStore.close} 逐字同源)。 */
42
49
  close(): void;
50
+ /**
51
+ * 这一刻的**判定钟**([ref] r1-F1)。
52
+ *
53
+ * 本进程铸的票 ⇒ `铸票墙钟 + 自铸票起单调钟走过的量`:墙钟被前拨不会误杀(真实只过了几秒的票还活着),
54
+ * 被回拨也不会续命(单调钟照走)。重启后重放回来的行没有锚 ⇒ 退回墙钟(单调钟的零点跨进程无意义)。
55
+ *
56
+ * **给不了票额外寿命**:投影严格按单调钟前进,真实走满 TTL 必然越过 `expiresAtMs`(负控钉在
57
+ * `permission-rule-store-file.test.ts` 的 [ref] r1-F1 族)。
58
+ */
59
+ private decisionNowMs;
43
60
  mint(input: {
44
61
  ticketId: string;
45
62
  principal: string;
@@ -53,11 +70,11 @@ export declare class FileRuleImportTicketStore {
53
70
  }): Promise<RuleImportTicket>;
54
71
  /** 一次性**认领**。五条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → wrong-purpose →
55
72
  * consumed → expired),于是两形的服务端日志归因可比;wire 面把五类折成同一个 404(零存在性 oracle)。 */
56
- consume(ticketId: string, principal: string, purpose: RuleTicketPurpose, opts?: RuleTicketDecisionClock): Promise<RuleTicketRedeemResult>;
73
+ consume(ticketId: string, principal: string, purpose: RuleTicketPurpose): Promise<RuleTicketRedeemResult>;
57
74
  /** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
58
75
  * (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。
59
76
  * `claimId` = A7 的认领围栏(R1-F1):被顶替的旧持有者动不了现持有者的认领。 */
60
- release(ticketId: string, principal: string, claimId: string, mustRemainValidMs?: number, opts?: RuleTicketDecisionClock): Promise<boolean>;
77
+ release(ticketId: string, principal: string, claimId: string, mustRemainValidMs?: number): Promise<boolean>;
61
78
  /** [ref] A7 CLAIMED → **SETTLED**。判据与 SQL 孪生逐字同源(持有**这一代**认领 ∧ 未落定;过期
62
79
  * **刻意不设门** —— 理由逐字见 SQL 侧 `settle` 的头注:拒绝记录一件已经发生的事只会把真相丢掉)。 */
63
80
  settle(ticketId: string, principal: string, claimId: string, outcome: unknown): Promise<boolean>;
@@ -89,5 +106,6 @@ export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
89
106
  export declare function createFilePermissionRuleStores(root: string, opts?: {
90
107
  onError?: (message: string) => void;
91
108
  now?: () => number;
109
+ monotonic?: () => number;
92
110
  }): FilePermissionRuleStores;
93
111
  //# sourceMappingURL=permission-rule-store-file.d.ts.map