@sema-agent/server 7.55.0 → 7.57.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 (129) hide show
  1. package/MIGRATION.md +3 -3
  2. package/README.md +14 -7
  3. package/README.zh-CN.md +10 -6
  4. package/USAGE.md +207 -59
  5. package/deploy/sema-up/chart/values.yaml +1 -1
  6. package/dist/approval-ask-audit-store.d.ts +100 -1
  7. package/dist/approval-ask-audit-store.js +103 -2
  8. package/dist/approval-card.d.ts +148 -13
  9. package/dist/approval-card.js +82 -13
  10. package/dist/approval-content-kind.d.ts +22 -0
  11. package/dist/approval-content-kind.js +5 -0
  12. package/dist/approval.d.ts +31 -0
  13. package/dist/approval.js +18 -0
  14. package/dist/auto-mode-face.d.ts +111 -0
  15. package/dist/auto-mode-face.js +99 -0
  16. package/dist/bench/s1/arms.js +5 -5
  17. package/dist/bench/s1/live-deps.js +11 -11
  18. package/dist/bench/s1/run-firm.js +3 -0
  19. package/dist/bench/s1/runner-ctx.d.ts +4 -0
  20. package/dist/bench/s1/runner-ctx.js +7 -0
  21. package/dist/boot/config-center.js +23 -1
  22. package/dist/boot/coordinators.js +13 -1
  23. package/dist/boot/leader.d.ts +21 -0
  24. package/dist/boot/leader.js +6 -0
  25. package/dist/boot/parked-revive-gate.d.ts +7 -2
  26. package/dist/boot/parked-revive-gate.js +12 -1
  27. package/dist/boot/resolve-spec.d.ts +3 -0
  28. package/dist/boot/resolve-spec.js +3 -1
  29. package/dist/boot/runner-deps.d.ts +12 -0
  30. package/dist/boot/runner-deps.js +28 -3
  31. package/dist/boot/runtime-caps.d.ts +19 -8
  32. package/dist/boot/runtime-caps.js +50 -20
  33. package/dist/boot/session-shell-gate-registry.d.ts +39 -0
  34. package/dist/boot/session-shell-gate-registry.js +21 -0
  35. package/dist/config-catalog.js +14 -5
  36. package/dist/config-center/types.d.ts +2 -1
  37. package/dist/config-provider.js +1 -0
  38. package/dist/config-types.d.ts +41 -10
  39. package/dist/config.d.ts +21 -0
  40. package/dist/config.js +40 -5
  41. package/dist/http/route-ctx.d.ts +90 -3
  42. package/dist/http/routes/a2a-serve.js +5 -3
  43. package/dist/http/routes/approvals-assistant.js +21 -8
  44. package/dist/http/routes/capabilities.js +21 -4
  45. package/dist/http/routes/leader.js +2 -2
  46. package/dist/http/routes/memory-origin.d.ts +2 -2
  47. package/dist/http/routes/rules.js +3 -2
  48. package/dist/http/routes/runs.d.ts +0 -14
  49. package/dist/http/routes/runs.js +20 -8
  50. package/dist/http/routes/tasks.js +34 -8
  51. package/dist/http/routes/workflows.js +20 -5
  52. package/dist/http/server.d.ts +11 -1
  53. package/dist/http/server.js +220 -34
  54. package/dist/http/wire-types.d.ts +9 -4
  55. package/dist/leader/diffout.js +2 -1
  56. package/dist/leader/endpoint.js +49 -15
  57. package/dist/leader/fanout.js +6 -5
  58. package/dist/leader/leader.js +17 -14
  59. package/dist/leader/merge.js +17 -12
  60. package/dist/leader/planner.js +3 -2
  61. package/dist/leader/repair-oracle.js +6 -4
  62. package/dist/leader/repair-wire.js +5 -3
  63. package/dist/leader/wire.d.ts +80 -1
  64. package/dist/leader/wire.js +55 -26
  65. package/dist/main.js +44 -8
  66. package/dist/observability/err-text.d.ts +6 -0
  67. package/dist/observability/err-text.js +10 -0
  68. package/dist/observability/fail-open.d.ts +40 -0
  69. package/dist/observability/fail-open.js +19 -0
  70. package/dist/observability/metrics.js +1 -1
  71. package/dist/observability/run-terminal-log.d.ts +120 -0
  72. package/dist/observability/run-terminal-log.js +360 -0
  73. package/dist/orchestration/workflow-completion-inbox.d.ts +9 -0
  74. package/dist/orchestration/workflow-completion-inbox.js +8 -0
  75. package/dist/permission-rule-vocab.d.ts +40 -0
  76. package/dist/permission-rule-vocab.js +17 -0
  77. package/dist/plugins/checkpoint-store-sql.d.ts +26 -0
  78. package/dist/plugins/checkpoint-store-sql.js +23 -4
  79. package/dist/plugins/file-run-store.d.ts +3 -34
  80. package/dist/plugins/file-run-store.js +21 -0
  81. package/dist/plugins/local-checkpoint-store.d.ts +10 -0
  82. package/dist/plugins/local-checkpoint-store.js +5 -0
  83. package/dist/plugins/local-session-store.d.ts +9 -9
  84. package/dist/plugins/local-session-store.js +5 -3
  85. package/dist/plugins/memory-run-store.d.ts +3 -15
  86. package/dist/plugins/memory-run-store.js +21 -0
  87. package/dist/plugins/permission-rule-store-file.d.ts +22 -4
  88. package/dist/plugins/permission-rule-store-file.js +25 -11
  89. package/dist/plugins/permission-rule-store-sql.d.ts +60 -30
  90. package/dist/plugins/permission-rule-store-sql.js +39 -23
  91. package/dist/plugins/pg-session-storage.js +17 -13
  92. package/dist/plugins/remote-scratchpad.js +3 -2
  93. package/dist/plugins/run-store-sql.d.ts +3 -42
  94. package/dist/plugins/run-store-sql.js +36 -5
  95. package/dist/plugins/store-backend.d.ts +1 -1
  96. package/dist/plugins/store-contracts.d.ts +70 -1
  97. package/dist/plugins/tidb-session-store.js +18 -14
  98. package/dist/plugins/workflow-run-store-sql.d.ts +5 -0
  99. package/dist/plugins/workflow-run-store-sql.js +10 -2
  100. package/dist/rules-consent.d.ts +5 -4
  101. package/dist/rules-consent.js +25 -46
  102. package/dist/run-cancel-context.d.ts +13 -0
  103. package/dist/run-cancel-context.js +15 -0
  104. package/dist/run-local.js +2 -2
  105. package/dist/runs.d.ts +4 -1
  106. package/dist/runs.js +41 -10
  107. package/dist/runtime-caps-resolver.d.ts +133 -17
  108. package/dist/runtime-caps-resolver.js +44 -4
  109. package/dist/security.d.ts +7 -0
  110. package/dist/task-settings.d.ts +57 -3
  111. package/dist/task-settings.js +78 -5
  112. package/dist/task-workflow.d.ts +32 -2
  113. package/dist/task-workflow.js +8 -3
  114. package/dist/tool-approval.d.ts +38 -63
  115. package/dist/tool-approval.js +102 -74
  116. package/dist/trace/core-keyset-guard.d.ts +2 -2
  117. package/dist/trace/ledger-sink.d.ts +13 -4
  118. package/dist/trace/ledger-sink.js +14 -6
  119. package/dist/trace/project.d.ts +13 -0
  120. package/dist/trace/project.js +10 -1
  121. package/dist/trace/redact.d.ts +22 -11
  122. package/dist/trace/redact.js +655 -20
  123. package/dist/trace/sema-provenance.d.ts +26 -0
  124. package/dist/trace/sema-provenance.js +11 -0
  125. package/dist/turn-activity.d.ts +32 -2
  126. package/dist/turn-activity.js +29 -4
  127. package/package.json +3 -3
  128. package/skills/find-skills.md +1 -1
  129. package/skills/loop.md +1 -1
@@ -195,16 +195,6 @@ export type RuleTicketRedeemResult = {
195
195
  ok: false;
196
196
  reason: "unknown" | "wrong-principal" | "wrong-purpose" | "expired" | "consumed";
197
197
  };
198
- /**
199
- * [ref]③/[ref]③ —— 票的**判定钟**覆写(记账钟永远不覆写,见 `consume` 内注)。
200
- *
201
- * 缺席 = 墙钟,既有行为逐字不变。在场 = 调用方(同意车道)按**单调锚**投影出来的那个时刻:
202
- * 铸票时刻的墙钟 + 自铸票起单调钟走过的量。它只能让判定更贴近**真实经过的时间**,给不了票额外寿命
203
- * (投影按单调钟前进,走满 TTL 就必然越过 `expires_at_ms`)。
204
- */
205
- export interface RuleTicketDecisionClock {
206
- decisionNowMs?: number;
207
- }
208
198
  /**
209
199
  * [ref] A7 —— 一张票的**三态快照**(MINTED → CLAIMED → SETTLED)。
210
200
  *
@@ -243,15 +233,18 @@ export type RuleTicketReclaimResult = {
243
233
  ok: false;
244
234
  };
245
235
  /**
246
- * [ref]③ 同族的 **GC 宽限**:`reapExpired` 只收「按墙钟至少已死一个完整 TTL」的票行。
236
+ * [ref]③ 同族的 **GC 宽限**:`reapExpired` 只收「按**数据库的钟**至少已死一个完整 TTL」的票行。
247
237
  *
248
- * 为什么需要它:判定侧改单调锚之后,一张「按墙钟已过期、按单调钟还在窗内」的票是**可兑付**的;
249
- * GC 仍按 `expires_at_ms <= now` 收行,判定修好了而行先没了 —— 用户拿到的从 `expired` 变成 `unknown`,
250
- * 缺陷只是换了个归因。宽限一个 TTL 覆盖住「铸票时钟慢了不到一个 TTL」这一族(含 [ref]③ 的真实构造)
238
+ * 🔴 立论已换([ref] / [ref]):它原本是给单调锚兜底的 —— 一张「按墙钟已过期、按单调钟还在窗内」的
239
+ * 票当年是可兑付的,GC 不能抢在判定前面把行收走。TTL 轴整条搬到数据库的钟之后,那一族**从根上没有了**
240
+ * (判定与清扫从此同一口钟、同一个不等号,不可能一个说活一个说死)。留着它的现由是另外两条,都成立:
241
+ * · **归因质量**:行还在 ⇒ 拒绝是 `expired`(服务端日志说得清「票过期了」);行被收走 ⇒ 塌成 `unknown`。
242
+ * 死票在库里多躺一个宽限,换来运维排查时一个准确的归因,而票表按设计是短命小表,代价可忽略。
243
+ * · **数据库自己的钟也会被拨**(DB 主机的 NTP 纠正、或一次主从切换换了台机器)。宽限一个 TTL 覆盖住
244
+ * 「DB 钟被往前拨了不到一个 TTL」这一族:那种时刻 `consume` 会暂时拒,但行还在,钟走回来票就活。
251
245
  *
252
- * 残余(如实登记,不假称已解):墙钟被拨慢**超过**一个 TTL 时,行仍可能在单调窗内被收。彻底解需要行上
253
- * 存单调基准,而单调基准跨进程无意义(core [ref] 的判词)。补偿是现成的:prepare 廉价,重铸一张即可
254
- * ——这正是 core 建议的姿态(「接受 + 票廉价重铸」)。
246
+ * 残余(如实登记,不假称已解):DB 钟被往前拨**超过**一个 TTL 时,行仍会被收。补偿是现成的:prepare
247
+ * 廉价,重铸一张即可(core [ref] 建议的姿态)
255
248
  */
256
249
  export declare const RULE_IMPORT_TICKET_GC_GRACE_MS: number;
257
250
  /**
@@ -262,11 +255,17 @@ export declare const RULE_IMPORT_TICKET_GC_GRACE_MS: number;
262
255
  * 生出来的权威回执下一拍就被删,而"落定之后必有一个完整的回放窗"是本批新许下的承诺 —— 它不能建立在
263
256
  * 「两台机器的钟差不多」这条没人声明过的假设上。数据库的钟是这两台副本**唯一**共享的那口钟。
264
257
  *
265
- * 🔴 射程更新(车K 2026-08-30 对账):`consumed_at_ms` 自 R8-F2 起**也走本钟**(`consume` 的 UPDATE
266
- * 直写本常量,见其行注);仍留在副本墙钟域的是 `expires_at_ms` / `created_at_ms` 两列 —— 那是
267
- * cc-import 车道的**既存已登记残余**([ref]③,收口方向逐字见
268
- * `rules-consent.ts` anchors 头注),把它一并搬走会改动两条车道共用的认领谓词,不在本批射程。
269
- * 这一句同时也是那次收口的**样板**:形已经在这里了,下一批照抄即可。
258
+ * 🔴 射程收官([ref] / [ref],2026-09-02):**这张票上的每一个时刻量都走本钟**,一个不剩 ——
259
+ * `consumed_at_ms` / `settled_at_ms`(R8-F2 起)、`expires_at_ms` / `created_at_ms`(本批)。
260
+ *
261
+ * 本批补上的是 **TTL 轴**,也是最后一条:铸票 `expires_at_ms = <本钟> + ttl`、判定
262
+ * `expires_at_ms > <本钟>`、清扫 `expires_at_ms <= <本钟> − 宽限`。修前那一形是**两台副本各出一半**的
263
+ * 比较(铸票副本的墙钟定到期时刻,兑付副本的墙钟做判定),于是 TTL 在集群上**不单调**:同一张票在钟慢
264
+ * 的那台上还活着、在钟快的那台上已经死了;极端形是「出生即过期」(铸票副本慢过一个 TTL ⇒ 票铸出来就
265
+ * 没人兑得动)。这不需要故障就能发作 —— 负载均衡把 prepare 与 redeem 分到两台副本是**常态**。
266
+ * 上一批的进程内单调锚只覆盖「同一副本铸、同一副本兑」那一格,连同它的台账在本批一并删除。
267
+ *
268
+ * 判定读不到本钟 ⇒ 语句抛错 ⇒ 调用方按不确定失败处置(fail-closed:绝不退回本地墙钟放行)。
270
269
  */
271
270
  export declare const SQL_DB_NOW_MS: {
272
271
  readonly tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)";
@@ -277,11 +276,37 @@ export declare function buildRulePayloadHash(candidates: ReadonlyArray<{
277
276
  rule: string;
278
277
  scope: RuleScope;
279
278
  }>): string;
279
+ /**
280
+ * 票面三态协议的 SQL 实现。
281
+ *
282
+ * 🔴 **这只店没有本地钟 seam**([ref]):它此前收一个 `now: () => number`,而票上的每一个时刻量现在都由
283
+ * 数据库自己出({@link SQL_DB_NOW_MS})。留着那只形参会让人以为「注一只假钟就能改票的判决」——
284
+ * 那正是本批要消灭的那条依赖。测试要制造过期,改行(把 `expires_at_ms` 相对 `<db now>` 挪)即可,
285
+ * 而那恰恰也是生产上唯一能让票过期的方式。
286
+ */
280
287
  export declare class SqlRuleImportTicketStore {
281
288
  private readonly db;
282
- private readonly now;
283
- constructor(db: SqlDriver, now?: () => number);
289
+ constructor(db: SqlDriver);
284
290
  private q;
291
+ /**
292
+ * 问一次**数据库自己的钟**(毫秒)。铸票用它当基准,于是到期时刻与后续每一次判定同域。
293
+ *
294
+ * 读不出数 ⇒ **响亮抛**([ref] fail-closed):静默兜个本地 `Date.now()` 正是本批要删掉的那条腿,
295
+ * 而一次「钟读不出来」在授权面上是运维事件,不是一个可以猜的默认值。
296
+ */
297
+ private dbNowMs;
298
+ /**
299
+ * 铸一张票。
300
+ *
301
+ * 🔴 到期时刻 = **数据库的钟** + TTL([ref] / [ref]),不是这台副本的墙钟 + TTL。理由逐字见
302
+ * {@link SQL_DB_NOW_MS} 的头注:铸造点与判定点必须同一口钟,否则 TTL 在集群上不单调。
303
+ *
304
+ * 为什么是「先问一次钟、再把绝对值写进去」而不是「INSERT 里直接写 `<db now> + ?`」:`expiresAtMs`
305
+ * 要**原样上 wire**(prepare 的响应体里有它,客户端拿它显示倒计时),而 MySQL 的 INSERT 交不回
306
+ * 计算列 —— 那一形要么再补一条 SELECT 读回来(失败时留一行孤儿),要么两方言各写一套(PG 有
307
+ * `RETURNING`、MySQL 没有,两份会各自漂)。先问钟的形只有一个失败方向:钟没问到 ⇒ 一行都没落,
308
+ * 调用方原样收错。两跳之间流逝的那点时间只会让票**略短命**,方向保守。
309
+ */
285
310
  mint(input: {
286
311
  ticketId: string;
287
312
  principal: string;
@@ -308,7 +333,7 @@ export declare class SqlRuleImportTicketStore {
308
333
  * wrong-purpose/expired/consumed 五类给服务端日志;wire 面把五类折成同一个 404)。预读说「可以」而 UPDATE 命中零行,
309
334
  * 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。
310
335
  */
311
- consume(ticketId: string, principal: string, purpose: RuleTicketPurpose, opts?: RuleTicketDecisionClock): Promise<RuleTicketRedeemResult>;
336
+ consume(ticketId: string, principal: string, purpose: RuleTicketPurpose): Promise<RuleTicketRedeemResult>;
312
337
  /**
313
338
  * **认领的释放**(codex 交叉复审 round1 [high] 一,验真后修)。
314
339
  *
@@ -325,7 +350,7 @@ export declare class SqlRuleImportTicketStore {
325
350
  * 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。
326
351
  * 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。
327
352
  */
328
- release(ticketId: string, principal: string, claimId: string, mustRemainValidMs?: number, opts?: RuleTicketDecisionClock): Promise<boolean>;
353
+ release(ticketId: string, principal: string, claimId: string, mustRemainValidMs?: number): Promise<boolean>;
329
354
  /**
330
355
  * [ref] A7 —— CLAIMED → **SETTLED**:把这一次兑付的终局写进行里。
331
356
  *
@@ -427,11 +452,16 @@ export interface PermissionRuleStores {
427
452
  * 「超帽当场拒」那一条路,不收「人改主意了」)。
428
453
  *
429
454
  * 两张表的谓词都只删**可证已死**的行,所以本腿不需要旋钮(没有可调的语义):
430
- * · 票(三支 OR,A7 交叉复审定形,逐支论证见 reapExpired 实现内注):甲=从未认领且已过期;
431
- * 乙=已落定且落定后过 GC 宽限(按 `settled_at_ms` 判,走 DB 钟);丙=CLAIMED-未结且认领戳老过
432
- * 废弃期(按 `consumed_at_ms` 判,走 DB 钟);
433
- * · 记录:`state = 'pending' AND created_at_ms < now − {@link RULE_PENDING_APPROVAL_RETENTION_MS}`,
455
+ * · 票(三支 OR,A7 交叉复审定形,逐支论证见 reapExpired 实现内注)**三支全走 DB 钟**([ref]):
456
+ * 甲=从未认领且过期已满 GC 宽限;乙=已落定且落定后过 GC 宽限(按 `settled_at_ms` );
457
+ * 丙=CLAIMED-未结且认领戳老过废弃期(按 `consumed_at_ms` );
458
+ * · 记录:`state = 'pending' AND created_at_ms < nowMs − {@link RULE_PENDING_APPROVAL_RETENTION_MS}`,
434
459
  * 再加 `NOT EXISTS`(载荷必须陪着把手活,R6-F1:仍有票引用的 pending 记录不删)。
460
+ *
461
+ * 🔴 `nowMs` 形参**只剩记录那一支在用**,如实登记:`permission_rule_approval.created_at_ms` 是这台
462
+ * 副本自己写下的(见 {@link SqlRuleApprovalRecordStore}),按同一台副本的墙钟判"落盘满 24 小时了吗"
463
+ * 是**同域比较** —— 把它单独搬到 DB 钟反而会造出一次新的跨钟比较。那张表的钟域是另一条轴上的事,
464
+ * 不在本批射程(登记为后续件)。票那一支已经不看它了。
435
465
  */
436
466
  reapExpired(nowMs: number): Promise<number>;
437
467
  }
@@ -4,6 +4,7 @@ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuar
4
4
  import { dialectProtocolJsonEncoder } from "./sql-driver.js";
5
5
  import { isMissingColumnError } from "./sql-errors.js";
6
6
  import { canonicalJson } from "../canonical-json.js";
7
+ import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, PERSISTED_RULE_TOOLS, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
7
8
  export function writerOfSqlRuleStore(store) {
8
9
  const w = Reflect.get(store, PERMISSION_RULE_WRITER);
9
10
  if (w === null || typeof w !== "object")
@@ -26,8 +27,8 @@ const RuleAddSchema = z
26
27
  const PersistedAllowRuleSchema = z
27
28
  .object({
28
29
  rule: z.string(),
29
- tool: z.literal("Bash"),
30
- match: z.enum(["exact", "prefix"]),
30
+ tool: z.enum(PERSISTED_RULE_TOOLS),
31
+ match: z.enum(PERSISTED_RULE_MATCHES),
31
32
  command: z.string(),
32
33
  scope: DurableRuleScopeSchema,
33
34
  adds: z.array(RuleAddSchema),
@@ -52,7 +53,9 @@ const QuarantinedRuleAddSchema = z
52
53
  const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe());
53
54
  const RuleCandidateSchema = z.object({ rule: z.string(), scope: DurableRuleScopeSchema }).strict();
54
55
  const RedeemedDotsSchema = z.record(z.string(), RuleDotSchema);
55
- const RuleEditedSchema = z.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string() }).strict();
56
+ const RuleEditedSchema = z
57
+ .object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string(), warnings: z.array(z.enum(EDITED_RULE_BREADTH_WARNING_CODES)).optional() })
58
+ .strict();
56
59
  const RuleOffer2Schema = z.union([
57
60
  z.object({ kind: z.literal("single"), candidate: z.number().int().nonnegative().safe() }).strict(),
58
61
  z
@@ -61,6 +64,7 @@ const RuleOffer2Schema = z.union([
61
64
  candidates: z.array(z.number().int().nonnegative().safe()),
62
65
  segments: z.array(z.string()).optional(),
63
66
  uncoveredSegments: z.number().int().nonnegative().safe().optional(),
67
+ uncoveredDetail: z.array(z.object({ segment: z.string(), reason: z.enum(UNCOVERED_SEGMENT_REASONS) }).strict()).optional(),
64
68
  })
65
69
  .strict(),
66
70
  ]);
@@ -104,8 +108,9 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
104
108
  state VARCHAR(32) NOT NULL,
105
109
  rev BIGINT NOT NULL DEFAULT 0,
106
110
  candidates_json LONGTEXT NOT NULL,
107
- -- version:**数据格式版本**(= core \`RuleApprovalRecord.schema\`,本形恒 2;OCC 守卫另在 rev,
108
- -- 两轴禁混,schema-naming 门③ 白名单登记)。读侧对非 2 的行交还 design/375 §4.5 的
111
+ -- version:**数据格式版本**(= core \`RuleApprovalRecord.schema\`,本形恒 3 —— design/382 §3.5 由 2 升,
112
+ -- 代码里的单点是 RULE_APPROVAL_RECORD_SCHEMA;OCC 守卫另在 rev,两轴禁混,schema-naming 门③ 白名单
113
+ -- 登记)。读侧对非 3 的行(含上一形的 2)交还 design/375 §4.5 的
109
114
  -- StaleRuleApprovalRecord 信封(identity 键 only)—— 本仓 SCHEMA POLICY 是删表重建,重建后的表
110
115
  -- 不该有这种行,这一臂是「未来再换形」的防御位,绝不静默 widen 成一条本形担保不了的记录。
111
116
  version INT NOT NULL,
@@ -258,7 +263,8 @@ export async function ensurePgPermissionRuleSchema(q) {
258
263
  rev BIGINT NOT NULL DEFAULT 0,
259
264
  candidates_json TEXT COLLATE "C" NOT NULL,
260
265
  -- version / offers_json / selected_offer:design/375 换形三列(MySQL 孪生的行内注写了理由:
261
- -- version=数据格式版本恒 2,非 2 的行读成 Stale 信封;offers_json=按 index 引用的 OPTION 结构,
266
+ -- version=数据格式版本恒 3(design/382 §3.5 2 升;单点 RULE_APPROVAL_RECORD_SCHEMA),非 3 的行(含 2)
267
+ -- 读成 Stale 信封;offers_json=按 index 引用的 OPTION 结构,
262
268
  -- 禁存文本副本;selected_offer=确认的 OFFER index,与 ticket/redeemed_dots 的 CANDIDATE index
263
269
  -- 两空间分离)。
264
270
  version INT NOT NULL,
@@ -525,7 +531,7 @@ export class SqlRuleApprovalRecordStore {
525
531
  const ownerKind = String(row.owner_kind);
526
532
  const principal = row.principal === null || row.principal === undefined ? undefined : String(row.principal);
527
533
  const identity = ownerKind === "principal" ? { principal: principal ?? "" } : { owner: { kind: "local-owner" } };
528
- if (Number(row.version) !== 2) {
534
+ if (Number(row.version) !== RULE_APPROVAL_RECORD_SCHEMA) {
529
535
  return { staleSchema: true, id: String(row.record_id), ...identity };
530
536
  }
531
537
  const candidates = parseColumn(z.array(RuleCandidateSchema), row.candidates_json, "candidates_json", id);
@@ -541,7 +547,7 @@ export class SqlRuleApprovalRecordStore {
541
547
  return {
542
548
  id: String(row.record_id),
543
549
  ...identity,
544
- schema: 2,
550
+ schema: RULE_APPROVAL_RECORD_SCHEMA,
545
551
  kind: kind.data,
546
552
  state: state.data,
547
553
  candidates,
@@ -584,14 +590,14 @@ export class SqlRuleApprovalRecordStore {
584
590
  ]);
585
591
  }
586
592
  async discardPendingRecord(recordId) {
587
- const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending' AND version = 2`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending' AND version = 2`), [recordId]);
593
+ const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending' AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending' AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`), [recordId]);
588
594
  return affected === 1;
589
595
  }
590
596
  async cas(id, expectRev, next) {
591
597
  if (next.rev !== expectRev + 1) {
592
598
  throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
593
599
  }
594
- const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, offers_json = ?, selected_offer = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ? AND version = 2`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, offers_json = $4, selected_offer = $5, redeemed_dots_json = $6, edited_json = $7, updated_at_ms = $8 WHERE record_id = $9 AND rev = $10 AND version = 2`), [
600
+ const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, offers_json = ?, selected_offer = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ? AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, offers_json = $4, selected_offer = $5, redeemed_dots_json = $6, edited_json = $7, updated_at_ms = $8 WHERE record_id = $9 AND rev = $10 AND version = ${RULE_APPROVAL_RECORD_SCHEMA}`), [
595
601
  next.state,
596
602
  next.rev,
597
603
  this.enc(next.candidates, "permission_rule_approval.candidates_json"),
@@ -616,16 +622,21 @@ export function buildRulePayloadHash(candidates) {
616
622
  }
617
623
  export class SqlRuleImportTicketStore {
618
624
  db;
619
- now;
620
- constructor(db, now = Date.now) {
625
+ constructor(db) {
621
626
  this.db = db;
622
- this.now = now;
623
627
  }
624
628
  q(tidbSql, pgSql) {
625
629
  return this.db.dialect === "tidb" ? tidbSql : pgSql;
626
630
  }
631
+ async dbNowMs() {
632
+ const { rows } = await this.db.query(this.q(`SELECT ${SQL_DB_NOW_MS.tidb} AS db_now_ms`, `SELECT ${SQL_DB_NOW_MS.pg} AS db_now_ms`), []);
633
+ const n = Number(rows[0]?.["db_now_ms"] ?? Number.NaN);
634
+ if (!Number.isFinite(n))
635
+ throw new Error(`${PERMISSION_RULE_TICKET_TABLE}: the database clock came back unreadable (${String(rows[0]?.["db_now_ms"])})`);
636
+ return n;
637
+ }
627
638
  async mint(input) {
628
- const nowMs = this.now();
639
+ const nowMs = await this.dbNowMs();
629
640
  const expiresAtMs = nowMs + input.ttlMs;
630
641
  const payloadHash = buildRulePayloadHash(input.candidates);
631
642
  await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_TICKET_TABLE} (ticket_id, owner_key, principal, approval_id, payload_hash, payload_json, expires_at_ms, consumed_at_ms, claim_id, purpose, created_at_ms)
@@ -643,8 +654,7 @@ export class SqlRuleImportTicketStore {
643
654
  ]);
644
655
  return { ticketId: input.ticketId, approvalId: input.approvalId, payloadHash, expiresAtMs };
645
656
  }
646
- async consume(ticketId, principal, purpose, opts) {
647
- const decisionNowMs = opts?.decisionNowMs ?? this.now();
657
+ async consume(ticketId, principal, purpose) {
648
658
  const ownerKey = buildRuleOwnerKey({ kind: "principal", principal });
649
659
  const row = await this.readRow(ticketId);
650
660
  if (row === undefined)
@@ -655,17 +665,23 @@ export class SqlRuleImportTicketStore {
655
665
  return { ok: false, reason: "wrong-purpose" };
656
666
  if (row.consumedAtMs !== undefined)
657
667
  return { ok: false, reason: "consumed" };
658
- if (row.expiresAtMs <= decisionNowMs)
668
+ if (row.expiresAtMs <= row.dbNowMs)
659
669
  return { ok: false, reason: "expired" };
660
670
  const claimId = randomUUID();
661
- const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.tidb}, claim_id = ? WHERE ticket_id = ? AND owner_key = ? AND purpose = ? AND consumed_at_ms IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.pg}, claim_id = $1 WHERE ticket_id = $2 AND owner_key = $3 AND purpose = $4 AND consumed_at_ms IS NULL AND expires_at_ms > $5`), [claimId, ticketId, ownerKey, purpose, decisionNowMs]);
671
+ const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.tidb}, claim_id = ? WHERE ticket_id = ? AND owner_key = ? AND purpose = ? AND consumed_at_ms IS NULL AND expires_at_ms > ${SQL_DB_NOW_MS.tidb}`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.pg}, claim_id = $1 WHERE ticket_id = $2 AND owner_key = $3 AND purpose = $4 AND consumed_at_ms IS NULL AND expires_at_ms > ${SQL_DB_NOW_MS.pg}`), [claimId, ticketId, ownerKey, purpose]);
662
672
  if (affected === 1)
663
673
  return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash, claimId };
674
+ const after = await this.readRow(ticketId);
675
+ if (after === undefined)
676
+ return { ok: false, reason: "unknown" };
677
+ if (after.consumedAtMs !== undefined)
678
+ return { ok: false, reason: "consumed" };
679
+ if (after.expiresAtMs <= after.dbNowMs)
680
+ return { ok: false, reason: "expired" };
664
681
  return { ok: false, reason: "consumed" };
665
682
  }
666
- async release(ticketId, principal, claimId, mustRemainValidMs = 0, opts) {
667
- const nowMs = opts?.decisionNowMs ?? this.now();
668
- const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = ? AND owner_key = ? AND claim_id = ? AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = $1 AND owner_key = $2 AND claim_id = $3 AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > $4`), [ticketId, buildRuleOwnerKey({ kind: "principal", principal }), claimId, nowMs + mustRemainValidMs]);
683
+ async release(ticketId, principal, claimId, mustRemainValidMs = 0) {
684
+ const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = ? AND owner_key = ? AND claim_id = ? AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > ${SQL_DB_NOW_MS.tidb} + ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = $1 AND owner_key = $2 AND claim_id = $3 AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > ${SQL_DB_NOW_MS.pg} + $4`), [ticketId, buildRuleOwnerKey({ kind: "principal", principal }), claimId, mustRemainValidMs]);
669
685
  return affected === 1;
670
686
  }
671
687
  async settle(ticketId, principal, claimId, outcome) {
@@ -730,9 +746,9 @@ export function createSqlPermissionRuleStores(db, now) {
730
746
  return {
731
747
  provider: new SqlPermissionRuleStoreProvider(db, now),
732
748
  approvals: new SqlRuleApprovalRecordStore(db, now),
733
- tickets: new SqlRuleImportTicketStore(db, now),
749
+ tickets: new SqlRuleImportTicketStore(db),
734
750
  reapExpired: async (nowMs) => {
735
- const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE (consumed_at_ms IS NULL AND expires_at_ms <= ?) OR (redeemed_outcome IS NOT NULL AND settled_at_ms IS NOT NULL AND settled_at_ms <= ${SQL_DB_NOW_MS.tidb} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND consumed_at_ms < ${SQL_DB_NOW_MS.tidb} - ${RULE_PENDING_APPROVAL_RETENTION_MS}) LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE (consumed_at_ms IS NULL AND expires_at_ms <= $1) OR (redeemed_outcome IS NOT NULL AND settled_at_ms IS NOT NULL AND settled_at_ms <= ${SQL_DB_NOW_MS.pg} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND consumed_at_ms < ${SQL_DB_NOW_MS.pg} - ${RULE_PENDING_APPROVAL_RETENTION_MS}) LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_IMPORT_TICKET_GC_GRACE_MS]);
751
+ const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE (consumed_at_ms IS NULL AND expires_at_ms <= ${SQL_DB_NOW_MS.tidb} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (redeemed_outcome IS NOT NULL AND settled_at_ms IS NOT NULL AND settled_at_ms <= ${SQL_DB_NOW_MS.tidb} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND consumed_at_ms < ${SQL_DB_NOW_MS.tidb} - ${RULE_PENDING_APPROVAL_RETENTION_MS}) LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE (consumed_at_ms IS NULL AND expires_at_ms <= ${SQL_DB_NOW_MS.pg} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (redeemed_outcome IS NOT NULL AND settled_at_ms IS NOT NULL AND settled_at_ms <= ${SQL_DB_NOW_MS.pg} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND consumed_at_ms < ${SQL_DB_NOW_MS.pg} - ${RULE_PENDING_APPROVAL_RETENTION_MS}) LIMIT ${RULE_REAP_BATCH})`), []);
736
752
  const orphanPending = await db.query(q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < ? AND NOT EXISTS (SELECT 1 FROM ${PERMISSION_RULE_TICKET_TABLE} t WHERE t.approval_id = ${PERMISSION_RULE_APPROVAL_TABLE}.record_id) LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE ctid IN (SELECT a.ctid FROM ${PERMISSION_RULE_APPROVAL_TABLE} a WHERE a.state = 'pending' AND a.created_at_ms < $1 AND NOT EXISTS (SELECT 1 FROM ${PERMISSION_RULE_TICKET_TABLE} t WHERE t.approval_id = a.record_id) LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_PENDING_APPROVAL_RETENTION_MS]);
737
753
  return deadTickets.affected + orphanPending.affected;
738
754
  },
@@ -310,25 +310,29 @@ export class PgSessionStore {
310
310
  where.push(`(sm.updated_at < ${a} OR (sm.updated_at = ${b} AND sm.session_id < ${sid}))`);
311
311
  }
312
312
  const sql = "SELECT sm.session_id, sm.owner, sm.updated_at AS last_activity, sm.created_at AS first_activity, sm.title, " +
313
- " COALESCE(r.run_count, 0) AS run_count, latest.objective_preview, latest.status, latest.task_id AS last_run_id " +
313
+ " COALESCE(r.run_count, 0) AS run_count, latest.objective_preview, latest.status, latest.task_id AS last_run_id, latest.last_task_run_id " +
314
314
  "FROM session_meta sm " +
315
315
  "LEFT JOIN (SELECT session_id, COUNT(*) AS run_count FROM task_run GROUP BY session_id) r ON r.session_id = sm.session_id " +
316
- "LEFT JOIN (SELECT DISTINCT ON (session_id) session_id, objective_preview, status, task_id FROM task_run " +
316
+ "LEFT JOIN (SELECT DISTINCT ON (session_id) session_id, objective_preview, status, task_id, result ->> 'runId' AS last_task_run_id FROM task_run " +
317
317
  " ORDER BY session_id, created_at DESC, task_id DESC) latest ON latest.session_id = sm.session_id " +
318
318
  (where.length ? "WHERE " + where.join(" AND ") + " " : "") +
319
319
  `ORDER BY sm.updated_at DESC, sm.session_id DESC LIMIT ${ph(opts.limit)}`;
320
320
  const res = await this.pool.query(sql, params);
321
- return res.rows.map((r) => ({
322
- sessionId: String(r.session_id),
323
- owner: r.owner ?? null,
324
- lastActivityAt: r.last_activity instanceof Date ? r.last_activity.toISOString() : String(r.last_activity),
325
- firstActivityAt: r.first_activity instanceof Date ? r.first_activity.toISOString() : String(r.first_activity),
326
- runCount: Number(r.run_count),
327
- objectivePreview: r.objective_preview ?? null,
328
- lastStatus: r.status == null ? "" : String(r.status),
329
- lastRunId: r.last_run_id ?? null,
330
- title: r.title ?? null,
331
- }));
321
+ return res.rows.map((r) => {
322
+ const runId = r.last_task_run_id;
323
+ return {
324
+ sessionId: String(r.session_id),
325
+ owner: r.owner ?? null,
326
+ lastActivityAt: r.last_activity instanceof Date ? r.last_activity.toISOString() : String(r.last_activity),
327
+ firstActivityAt: r.first_activity instanceof Date ? r.first_activity.toISOString() : String(r.first_activity),
328
+ runCount: Number(r.run_count),
329
+ objectivePreview: r.objective_preview ?? null,
330
+ lastStatus: r.status == null ? "" : String(r.status),
331
+ lastRunId: r.last_run_id ?? null,
332
+ ...(typeof runId === "string" && runId !== "" ? { lastTaskRunId: runId } : {}),
333
+ title: r.title ?? null,
334
+ };
335
+ });
332
336
  }
333
337
  async probeTitle(sessionId) {
334
338
  const res = await this.pool.query("SELECT title FROM session_meta WHERE session_id = $1", [sessionId]);
@@ -1,3 +1,4 @@
1
+ import { redactHead } from "../observability/run-terminal-log.js";
1
2
  const REMOTE_SCRATCHPAD_LANES = new Set(["e2b", "k8s", "local-docker", "ssh", "device"]);
2
3
  export function isRemoteScratchpadLane(provider) {
3
4
  return provider !== undefined && REMOTE_SCRATCHPAD_LANES.has(provider);
@@ -28,12 +29,12 @@ export function withRemoteScratchpad(factory, logger) {
28
29
  if (!r.ok || r.value.exitCode !== 0) {
29
30
  logger?.warn?.("remote_scratchpad_mkdir_failed", {
30
31
  dir,
31
- ...(r.ok ? { exitCode: r.value.exitCode, stderr: r.value.stderr.slice(0, 256) } : { error: r.error.message }),
32
+ ...(r.ok ? { exitCode: r.value.exitCode, stderr: redactHead(r.value.stderr, 256) } : { error: redactHead(r.error.message, 512) }),
32
33
  });
33
34
  }
34
35
  }
35
36
  catch (err) {
36
- logger?.warn?.("remote_scratchpad_mkdir_failed", { dir, error: err instanceof Error ? err.message : String(err) });
37
+ logger?.warn?.("remote_scratchpad_mkdir_failed", { dir, error: redactHead(err instanceof Error ? err.message : String(err), 512) });
37
38
  }
38
39
  })());
39
40
  e.exec = async (command, options) => {
@@ -1,47 +1,8 @@
1
- /**
2
- * Async run registry (S1) + replayable event log (S2) — SINGLE-FILE DUAL-DIALECT ([ref] A12 定型半场).
3
- * ONE implementation, TWO dialects; the historical `TiDBRunStore` / `PgRunStore` class names survive as thin
4
- * ctor subclasses so every consumer (store-backend.ts, the run/reaper/session/usage suites) is untouched.
5
- *
6
- * A run is the background execution of one task. `POST /v1/runs` records it `running` and returns
7
- * immediately; the background driver (`src/runs.ts`) appends events here as they happen and marks the
8
- * run terminal when done. Because runs + events are durable and shared, any stateless instance can
9
- * report status or replay the event stream — a client can reconnect (even to a different instance)
10
- * with `Last-Event-ID` and resume. The two dialects are kept in lock-step by ONE shared integration suite
11
- * (test/run-store-reaper-integration.test.ts) that runs the SAME park→resume→claim scenario against BOTH
12
- * real engines — the anti-drift guard.
13
- *
14
- * ── Dialect deltas, kept EXPLICIT (never hidden behind an abstraction) ────────────────────────────────
15
- * - `?` placeholders vs `$n` (transaction control itself is normalized by `SqlDriver`/`SqlTxConn` —
16
- * mysql2's native begin/commit/rollback vs PG's statement form; see sql-driver.ts)
17
- * - dup-key classification (createRun's task_active claim race) — the PREDICATE has a single owner
18
- * (`sql-errors.ts`, [ref] P1-①); only the dialect DISPATCH is visible here
19
- *
20
- * ⚠️ The run ACTIVE-status word list written out in the `status IN (…)` / `NOT IN (…)` fragments below
21
- * is the closed set owned by `store-contracts.ts` (`isTerminalRunStatus` / `isParkedRunStatus`). The SQL
22
- * text stays literal ON PURPOSE (A12: both dialects readable side by side), so the tie is a PARITY GATE:
23
- * `test/source-hygiene-gate.test.ts` → "[ref] P1-② run 活状态 SQL 字面 ↔ store-contracts 闭集属主 parity".
24
- * core adding a TaskStatus member turns that gate red on every fragment here — update them in the same batch.
25
- * - null-safe owner compare: `<=>` (TiDB) vs `IS NOT DISTINCT FROM` (PG) — a null-owner run still matches
26
- * `owner=null` on both, single-DB-fleet defense-in-depth (see {@link requestCancel})
27
- * - multi-table DELETE: `DELETE t FROM t JOIN j …` (TiDB) vs `DELETE FROM t USING j …` (PG)
28
- * - `UPDATE t AS a SET col=…` — PG's SET columns are NOT alias-qualified (PG rejects `SET a.col`), while
29
- * TiDB's SET clause DOES qualify (`SET tr.col = …`) — see reapSuspended / failSuspendedWithExpiredCheckpoint
30
- * - JSON binding: TiDB `JSON.stringify` verbatim vs PG `pgSafeJsonStringify` + `$n::jsonb` casts
31
- * - PG-only protocol-byte defenses on plain-TEXT columns: `pgSanitizeText` on objective_preview (createRun)
32
- * and error/error_code (setTerminal) — a raw NUL would throw 22P05 and roll back the whole terminal write,
33
- * stranding the run in a live status forever (codex R12); TiDB stores these verbatim
34
- * - dynamic multi-clause builders (listRuns/listSessions): TiDB's `?` is position-agnostic so the WHERE
35
- * array is built with plain string pushes; PG's `$n` needs numbered placeholders, so those two methods
36
- * keep an EXPLICIT per-dialect branch (a shared positional-counter abstraction would hide the very
37
- * placeholder-numbering divergence this file exists to keep visible)
38
- * - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL centrally in pg-pool.ts (neither store creates tables).
39
- */
40
- import type { TaskResult, TaskStatus } from "@sema-agent/core";
1
+ import type { TaskStatus } from "@sema-agent/core";
41
2
  import type { Pool as MySqlPool } from "mysql2/promise";
42
3
  import type { Pool as PgPool } from "pg";
43
4
  import { type UsageRow } from "../usage-analytics.js";
44
- import type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
5
+ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
45
6
  import { type SqlDriver } from "./sql-driver.js";
46
7
  import type { LedgerEventType } from "../trace/ledger-events.js";
47
8
  export type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
@@ -119,7 +80,7 @@ export declare class SqlRunStore {
119
80
  * abort text, or vice versa). Positive form ([1.207 codex M1]): the negative NOT IN missed the
120
81
  * blocked/timeout terminals — a first write of those could still be overwritten. The claim DELETE stays
121
82
  * unconditional (idempotent; the first writer already released it). */
122
- setTerminal(taskId: string, status: TaskStatus, result: TaskResult | null, error: string | null): Promise<void>;
83
+ setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise<void>;
123
84
  /**
124
85
  * Durable F4 ([ref]): a task suspended on a policy `ask`. Sets the run NON-terminal `suspended` and
125
86
  * KEEPS the `task_active` claim — the session stays locked (no new run) until an operator resumes it to a
@@ -1,7 +1,9 @@
1
+ import { redactErrorMessage, redactLedgerEventData, redactTerminalResult } from "../observability/run-terminal-log.js";
1
2
  import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
2
3
  import { escapeLike } from "./sql-escape.js";
3
4
  import { pgSanitizeText } from "./pg-safe-json.js";
4
5
  import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.js";
6
+ import { notifyRunTerminal, runTerminalLogLevel } from "../observability/run-terminal-log.js";
5
7
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
6
8
  import { isDupKeyError } from "./sql-errors.js";
7
9
  export class SqlRunStore {
@@ -105,6 +107,7 @@ export class SqlRunStore {
105
107
  await this.db.query(this.q("UPDATE task_run SET updated_at = ? WHERE task_id = ? AND owner <=> ? AND status = 'running'", "UPDATE task_run SET updated_at = $1 WHERE task_id = $2 AND owner IS NOT DISTINCT FROM $3 AND status = 'running'"), [new Date(), taskId, owner]);
106
108
  }
107
109
  async appendEvent(taskId, seq, type, data) {
110
+ data = redactLedgerEventData(type, data);
108
111
  await this.db.query(this.q("INSERT INTO task_event (task_id, seq, type, data, ts) VALUES (?,?,?,?,?)", "INSERT INTO task_event (task_id, seq, type, data, ts) VALUES ($1,$2,$3,$4::jsonb,$5)"), [taskId, seq, type, data == null ? null : this.json(data), new Date()]);
109
112
  }
110
113
  async maxSeq(taskId) {
@@ -113,9 +116,12 @@ export class SqlRunStore {
113
116
  return m == null ? 0 : Number(m);
114
117
  }
115
118
  async setTerminal(taskId, status, result, error) {
119
+ result = redactTerminalResult(result);
120
+ error = error === null ? null : (redactErrorMessage(error) ?? null);
116
121
  const errorCode = result?.errorCode ?? null;
122
+ let observed;
117
123
  await this.tx(async (conn) => {
118
- await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
124
+ const w = await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
119
125
  status,
120
126
  result == null ? null : this.json(result),
121
127
  this.db.dialect === "tidb" ? error : error == null ? null : pgSanitizeText(error),
@@ -123,8 +129,31 @@ export class SqlRunStore {
123
129
  new Date(),
124
130
  taskId,
125
131
  ]);
132
+ if (w.affected > 0 && runTerminalLogLevel(status, errorCode ?? undefined) !== undefined) {
133
+ const { rows } = await conn.query(this.q("SELECT owner, session_id, created_at FROM task_run WHERE task_id = ?", "SELECT owner, session_id, created_at FROM task_run WHERE task_id = $1"), [taskId]);
134
+ const row = rows[0];
135
+ const createdAt = row ? Date.parse(iso(row.created_at)) : NaN;
136
+ observed = {
137
+ owner: row?.owner ?? null,
138
+ sessionId: String(row?.session_id ?? result?.sessionId ?? ""),
139
+ createdAt: Number.isFinite(createdAt) ? createdAt : undefined,
140
+ };
141
+ }
126
142
  await conn.query(this.q("DELETE FROM task_active WHERE task_id = ?", "DELETE FROM task_active WHERE task_id = $1"), [taskId]);
127
143
  });
144
+ if (observed) {
145
+ notifyRunTerminal({
146
+ runId: taskId,
147
+ sessionId: observed.sessionId,
148
+ owner: observed.owner,
149
+ status,
150
+ resultTaskId: result?.taskId,
151
+ model: result?.model,
152
+ errorCode: errorCode ?? undefined,
153
+ errorMessage: result?.errorMessage ?? error ?? undefined,
154
+ elapsedMs: observed.createdAt === undefined ? undefined : Math.max(0, Date.now() - observed.createdAt),
155
+ });
156
+ }
128
157
  }
129
158
  async setSuspended(taskId) {
130
159
  await this.db.query(this.q("UPDATE task_run SET status = 'suspended', updated_at = ? WHERE task_id = ? AND status IN ('running','suspended')", "UPDATE task_run SET status = 'suspended', updated_at = $1 WHERE task_id = $2 AND status IN ('running','suspended')"), [new Date(), taskId]);
@@ -237,8 +266,8 @@ export class SqlRunStore {
237
266
  outer.push("(last_activity < ? OR (last_activity = ? AND session_id < ?))");
238
267
  params.push(new Date(opts.cursor.lastActivityAt), new Date(opts.cursor.lastActivityAt), opts.cursor.sessionId);
239
268
  }
240
- const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status FROM (" +
241
- "SELECT session_id, owner, task_id, objective_preview, status," +
269
+ const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status, last_task_run_id FROM (" +
270
+ "SELECT session_id, owner, task_id, objective_preview, status, JSON_UNQUOTE(JSON_EXTRACT(result, '$.runId')) AS last_task_run_id," +
242
271
  " ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn," +
243
272
  " MAX(created_at) OVER (PARTITION BY session_id) AS last_activity," +
244
273
  " MIN(created_at) OVER (PARTITION BY session_id) AS first_activity," +
@@ -264,8 +293,8 @@ export class SqlRunStore {
264
293
  const sid = p(opts.cursor.sessionId);
265
294
  outer.push(`(last_activity < ${a} OR (last_activity = ${b} AND session_id < ${sid}))`);
266
295
  }
267
- const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status FROM (" +
268
- "SELECT session_id, owner, task_id, objective_preview, status," +
296
+ const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status, last_task_run_id FROM (" +
297
+ "SELECT session_id, owner, task_id, objective_preview, status, result ->> 'runId' AS last_task_run_id," +
269
298
  " ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn," +
270
299
  " MAX(created_at) OVER (PARTITION BY session_id) AS last_activity," +
271
300
  " MIN(created_at) OVER (PARTITION BY session_id) AS first_activity," +
@@ -276,6 +305,7 @@ export class SqlRunStore {
276
305
  return rows.map((r) => this.rowToSession(r));
277
306
  }
278
307
  rowToSession(r) {
308
+ const lastTaskRunId = r.last_task_run_id;
279
309
  return {
280
310
  sessionId: String(r.session_id),
281
311
  owner: r.owner ?? null,
@@ -285,6 +315,7 @@ export class SqlRunStore {
285
315
  objectivePreview: r.objective_preview ?? null,
286
316
  lastStatus: String(r.status),
287
317
  lastRunId: r.task_id ?? null,
318
+ ...(typeof lastTaskRunId === "string" && lastTaskRunId !== "" ? { lastTaskRunId } : {}),
288
319
  title: null,
289
320
  };
290
321
  }
@@ -95,7 +95,7 @@ export type ImageBake = TiDBImageBake | PgImageBake;
95
95
  /** [ref] 车4 件1:durable leader-run 登记表的双方言孪生(union 同族——TS 私有字段让具体类名义上不同)。 */
96
96
  export type LeaderRunStore = TiDBLeaderRunStore | PgLeaderRunStore;
97
97
  /** The FULL checkpoint store (core CheckpointStore + the service operator-queue/ctx methods listPending/
98
- * listByScope/findPendingTokenBySession/peekPendingScope/putCtx/getCtx/reapCtx) — the SQL twins carry them,
98
+ * listByScope/findPendingTokenBySession/peekPendingScope/peekScopeByToken/putCtx/getCtx/reapCtx) — the SQL twins carry them,
99
99
  * and the LOCAL lane now does too (core FileCheckpointStore + the service half — TOC plan-mode /
100
100
  * durable HITL work on one box). */
101
101
  export type CheckpointStoreFull = TiDBCheckpointStore | PgCheckpointStore | LocalCheckpointStore;