@sema-agent/server 7.93.5 → 7.93.7

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.
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { hostShell, resolveHostShell } from "../plugins/host-platform.js";
2
+ import { hostShell, resolveHostShell, adoptHostForegroundChild } from "../plugins/host-platform.js";
3
3
  import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, } from "@sema-agent/settings-schema/hooks";
4
4
  import { boundedNoticeText } from "../fleet/fleet-bus.js";
5
5
  import { recordFailOpen } from "../observability/fail-open.js";
@@ -271,6 +271,7 @@ function runCommandHook(entry, payload, ctx) {
271
271
  resolve({ code: null, stdout: "", stderr: "", timedOut: false, spawnError: String(e) });
272
272
  return;
273
273
  }
274
+ const ticket = adoptHostForegroundChild(child);
274
275
  let stdout = "";
275
276
  let stderr = "";
276
277
  let timedOut = false;
@@ -280,6 +281,7 @@ function runCommandHook(entry, payload, ctx) {
280
281
  return;
281
282
  settled = true;
282
283
  clearTimeout(timer);
284
+ ticket?.release();
283
285
  resolve({ code, stdout, stderr, timedOut });
284
286
  };
285
287
  const timer = setTimeout(() => {
@@ -326,6 +328,7 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
326
328
  stdio: ["pipe", "ignore", rewake ? "pipe" : "ignore"],
327
329
  detached: true,
328
330
  });
331
+ const ticket = adoptHostForegroundChild(child);
329
332
  let killedByTimeout = false;
330
333
  const timer = setTimeout(() => {
331
334
  killedByTimeout = true;
@@ -355,6 +358,7 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
355
358
  });
356
359
  child.on("close", (code) => {
357
360
  clearTimeout(timer);
361
+ ticket?.release();
358
362
  noteAsyncOutcome(entry, ctx, event, payload, { code, rewake, killedByTimeout });
359
363
  if (!rewake || code !== 2)
360
364
  return;
@@ -121,6 +121,22 @@ export declare const FAIL_OPEN_TAGS: {
121
121
  readonly cls: "F";
122
122
  readonly note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。";
123
123
  };
124
+ readonly "server.sql-json.checkpoint.pending-steer-queue-lossy": {
125
+ readonly cls: "F";
126
+ readonly note: "S-550(`plugins/checkpoint-store-sql.ts` 的 `get()`,core 5.14.0 #147 的耐久转向队列):一条挂起 run 的 `checkpoint.pending_steer_queue` 这一格**没能完整读出来**(不是合法 JSON 文本 / 不是数组 / 某一条目的四个必备键不合形)⇒ 读不出的那部分**丢掉**,run 照常 resume。放行的最坏后果 = 一条已经 park 的操作员转向指令**没有**在这次 resume 上注入(人以为说过的话没送到);不放行的代价是这条挂起的 run **永远不可 resume** —— resume 是它唯一的出路,为一格读不出的转向把整条 run 变成砖是错误的失败方向(写侧刻意相反:`setPendingSteer` 往一份读不全的队列上追加会把读不出的条目永久抹掉,那里 fail-loud)。故 F 类。留痕从一条 `logger.info` 升成登记 tag 的理由:日志行在**遥测轴**上是零信号,「这台机器的转向队列一直读不出来」与「这台机器本来就没人 steer」在指标上完全同形。计数非零 = 去按日志里的 token 指纹查那一行。";
127
+ };
128
+ readonly "server.sql-json.checkpoint.outcome-unreadable": {
129
+ readonly cls: "F";
130
+ readonly note: "S-550(`plugins/checkpoint-store-sql.ts` 的 `findDecidedApprovalsForBinding`,`/decide` 的**幂等回放**腿):一条已决 checkpoint 行的 `outcome` 这一格读不出形(手改 / 撕裂写 / 迁移脚本改过)⇒ 这一行交出 `decision: null`(= 判别不出),调用方按「整次拒绝」处置。放行的最坏后果 = 一次 decide **重试**拿不到上次的判词,退回按新请求处理(而不是回放)—— 回放是锦上添花的腿,让一条读不出的 JSON 把一次重试变成 500 比这更坏;方向上没有任何批准被放宽(判不出就是判不出,调用方拒绝)。故 F 类。**只容忍体的问题**:`not-text`(读腿漏了投影)与连接/超时一律原样上抛 —— 那是码坏或故障,不是数据坏。";
131
+ };
132
+ readonly "server.sql-json.checkpoint.execution-outcome-unreadable": {
133
+ readonly cls: "F";
134
+ readonly note: "S-550(同上那条回放腿的第二格,`checkpoint.execution_outcome`,S-160 的执行判词):这一格读不出形 ⇒ 本行的 `executionOutcome` 交出 `null` = 「不知道这次批准有没有真的执行过」。放行的最坏后果 = 回放腿少一条「已执行」的证据,消费端据此**保守**处理(缺席在 core 的 `executionVerdict` 里读作 `unknown`,从来不读作「放行过」);不放行的代价与上一格同(一次 decide 重试 500)。⚠️ 同一格在 `get()` / `recordExecutionOutcome` 两条腿上**刻意相反**(抛,不容忍):那两条是结算记录的读写口,静默当缺席会让一份不同的记录覆盖已经在案的那份。tag 与上一格**分开计**:并计会让「哪一格在坏」在遥测里读不出来。";
135
+ };
136
+ readonly "server.sql-json.task-run.usage-result-unreadable": {
137
+ readonly cls: "F";
138
+ readonly note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。";
139
+ };
124
140
  readonly "server.retention.sweep-report-dropped": {
125
141
  readonly cls: "F";
126
142
  readonly note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。";
@@ -112,6 +112,22 @@ export const FAIL_OPEN_TAGS = {
112
112
  cls: "F",
113
113
  note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。",
114
114
  },
115
+ "server.sql-json.checkpoint.pending-steer-queue-lossy": {
116
+ cls: "F",
117
+ note: "S-550(`plugins/checkpoint-store-sql.ts` 的 `get()`,core 5.14.0 #147 的耐久转向队列):一条挂起 run 的 `checkpoint.pending_steer_queue` 这一格**没能完整读出来**(不是合法 JSON 文本 / 不是数组 / 某一条目的四个必备键不合形)⇒ 读不出的那部分**丢掉**,run 照常 resume。放行的最坏后果 = 一条已经 park 的操作员转向指令**没有**在这次 resume 上注入(人以为说过的话没送到);不放行的代价是这条挂起的 run **永远不可 resume** —— resume 是它唯一的出路,为一格读不出的转向把整条 run 变成砖是错误的失败方向(写侧刻意相反:`setPendingSteer` 往一份读不全的队列上追加会把读不出的条目永久抹掉,那里 fail-loud)。故 F 类。留痕从一条 `logger.info` 升成登记 tag 的理由:日志行在**遥测轴**上是零信号,「这台机器的转向队列一直读不出来」与「这台机器本来就没人 steer」在指标上完全同形。计数非零 = 去按日志里的 token 指纹查那一行。",
118
+ },
119
+ "server.sql-json.checkpoint.outcome-unreadable": {
120
+ cls: "F",
121
+ note: "S-550(`plugins/checkpoint-store-sql.ts` 的 `findDecidedApprovalsForBinding`,`/decide` 的**幂等回放**腿):一条已决 checkpoint 行的 `outcome` 这一格读不出形(手改 / 撕裂写 / 迁移脚本改过)⇒ 这一行交出 `decision: null`(= 判别不出),调用方按「整次拒绝」处置。放行的最坏后果 = 一次 decide **重试**拿不到上次的判词,退回按新请求处理(而不是回放)—— 回放是锦上添花的腿,让一条读不出的 JSON 把一次重试变成 500 比这更坏;方向上没有任何批准被放宽(判不出就是判不出,调用方拒绝)。故 F 类。**只容忍体的问题**:`not-text`(读腿漏了投影)与连接/超时一律原样上抛 —— 那是码坏或故障,不是数据坏。",
122
+ },
123
+ "server.sql-json.checkpoint.execution-outcome-unreadable": {
124
+ cls: "F",
125
+ note: "S-550(同上那条回放腿的第二格,`checkpoint.execution_outcome`,S-160 的执行判词):这一格读不出形 ⇒ 本行的 `executionOutcome` 交出 `null` = 「不知道这次批准有没有真的执行过」。放行的最坏后果 = 回放腿少一条「已执行」的证据,消费端据此**保守**处理(缺席在 core 的 `executionVerdict` 里读作 `unknown`,从来不读作「放行过」);不放行的代价与上一格同(一次 decide 重试 500)。⚠️ 同一格在 `get()` / `recordExecutionOutcome` 两条腿上**刻意相反**(抛,不容忍):那两条是结算记录的读写口,静默当缺席会让一份不同的记录覆盖已经在案的那份。tag 与上一格**分开计**:并计会让「哪一格在坏」在遥测里读不出来。",
126
+ },
127
+ "server.sql-json.task-run.usage-result-unreadable": {
128
+ cls: "F",
129
+ note: "S-550(`plugins/run-store-sql.ts` 的 `usageScan`,`/v1/usage/*` 的扫窗腿):窗内某一行的 `task_run.result` 这一格读不出形 ⇒ 这一行**计 tasks、不计 token/cost**(`stats` 键缺席),其余行照常。放行的最坏后果 = 用量总数偏低一行的量(`projectUsageStats` 的成文降级口径逐字如此,三个后端共用);不放行的代价是**一行坏 cell 打掉整个租户的用量页**(扫窗上限 50k 行)。⚠️ 同一列在 `getRun` / `listRuns` 两条腿上**刻意相反**(抛,不容忍):那两条腿上这一格是提交方要的**答案**,把它折成「跑完了没有结果」是在替一次不知道的读编一个答案;而这里它只是一个加数。故 F 类;**只容忍体的问题**,`not-text`(读腿漏了投影)与连接 / 超时原样上抛。",
130
+ },
115
131
  "server.retention.sweep-report-dropped": {
116
132
  cls: "F",
117
133
  note: "留存 sweep 的**失败上报**自己抛了(poison error 的 toString / 一条抛异常的 logger transport)⇒ 那一条 warn 与 stuck 指标发不出去。放行的最坏后果是**可观测性**损失而不是数据面损失:删除与审计的正确性完全在店事务里,本臂只影响运维看不看得见「这个域连败了」。不放行的后果反而更坏 —— 一个从上报里逃出去的异常会打断 per-domain 循环、饿死后面每一个域(reapers 的同款保护逐字同理由)。",
@@ -2,8 +2,30 @@ import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, } from "@sem
2
2
  import { pgSafeJsonStringify, pgSanitizeText } from "./pg-safe-json.js";
3
3
  import { isMysqlDupKeyError } from "./sql-errors.js";
4
4
  import { ensureIndexes } from "./ensure-index.js";
5
+ import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, jsonColumnText } from "./sql-json-column.js";
5
6
  export const BACKGROUND_AGENT_TABLE = "background_agent";
6
- const SUMMARY_COLS = "handle, owner, session_scoped, session_id, parent_session_id, root_session_id, name, agent_type, status, spawned_at_ms, updated_at_ms, settled_at_ms, usage_json";
7
+ function summaryCols(dialect) {
8
+ return ("handle, owner, session_scoped, session_id, parent_session_id, root_session_id, name, agent_type, status, " +
9
+ `spawned_at_ms, updated_at_ms, settled_at_ms, ${jsonColumnText(dialect, "usage_json")}`);
10
+ }
11
+ const readAgentRecordBody = createObjectColumnReader();
12
+ const readUsageBody = createObjectColumnReader();
13
+ function readRecordColumn(v, handle) {
14
+ try {
15
+ return decodeJsonColumn(v, readAgentRecordBody, `${BACKGROUND_AGENT_TABLE}.record_json for handle ${handle}`);
16
+ }
17
+ catch (e) {
18
+ throw foldJsonColumn(e, (m) => new Error(`BackgroundAgentStore: ${m} — refusing rather than serve a hollow agent record`));
19
+ }
20
+ }
21
+ function readUsageColumn(v, handle) {
22
+ try {
23
+ return decodeNullableJsonColumn(v, readUsageBody, `${BACKGROUND_AGENT_TABLE}.usage_json for handle ${handle}`);
24
+ }
25
+ catch (e) {
26
+ throw foldJsonColumn(e, (m) => new Error(`BackgroundAgentStore: ${m} — refusing rather than report a usage figure nobody can read`));
27
+ }
28
+ }
7
29
  const PROJ_COLS = "scope, owner, session_scoped, session_id, parent_session_id, root_session_id, session_anchor, name, agent_type, status, spawned_at_ms, updated_at_ms, settled_at_ms, usage_json, rev, parked_checkpoint_token, park_claim_id, admitted_org_scopes, admitted_org_write_scope, record_json";
8
30
  const PROJ_N = PROJ_COLS.split(", ").length;
9
31
  function assertScope(record) {
@@ -52,9 +74,7 @@ function rowToSummary(r) {
52
74
  spawnedAt: Number(r.spawned_at_ms),
53
75
  updatedAt: Number(r.updated_at_ms),
54
76
  ...(r.settled_at_ms !== null && r.settled_at_ms !== undefined ? { settledAt: Number(r.settled_at_ms) } : {}),
55
- ...(r.usage_json !== null && r.usage_json !== undefined
56
- ? { usage: JSON.parse(String(r.usage_json)) }
57
- : {}),
77
+ ...((u) => (u === undefined ? {} : { usage: u }))(readUsageColumn(r.usage_json, String(r.handle))),
58
78
  };
59
79
  }
60
80
  function flippedForm(live, now) {
@@ -192,8 +212,8 @@ export class TiDBBackgroundAgentStore {
192
212
  }
193
213
  }
194
214
  async get(handle, scope) {
195
- const [rows] = (await this.pool.query(`SELECT record_json FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND handle = ?`, [scope, handle]));
196
- return rows.length > 0 ? JSON.parse(String(rows[0].record_json)) : null;
215
+ const [rows] = (await this.pool.query(`SELECT ${jsonColumnText("tidb", "record_json")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND handle = ?`, [scope, handle]));
216
+ return rows.length > 0 ? readRecordColumn(rows[0].record_json, handle) : null;
197
217
  }
198
218
  async update(handle, scope, record, expect) {
199
219
  const stored = storedForm(record, handle, scope, expect.rev + 1);
@@ -211,12 +231,12 @@ export class TiDBBackgroundAgentStore {
211
231
  return res.affectedRows === 1;
212
232
  }
213
233
  async listBySession(scope, sessionId, opts) {
214
- const [rows] = (await this.pool.query(`SELECT ${SUMMARY_COLS} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND (session_anchor = ? OR root_session_id = ?)${opts?.status !== undefined ? " AND status = ?" : ""}
234
+ const [rows] = (await this.pool.query(`SELECT ${summaryCols("tidb")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND (session_anchor = ? OR root_session_id = ?)${opts?.status !== undefined ? " AND status = ?" : ""}
215
235
  ORDER BY spawned_at_ms DESC, handle DESC${limitClause(opts?.limit)}`, opts?.status !== undefined ? [scope, sessionId, sessionId, opts.status] : [scope, sessionId, sessionId]));
216
236
  return rows.map(rowToSummary);
217
237
  }
218
238
  async listByScope(scope, opts) {
219
- const [rows] = (await this.pool.query(`SELECT ${SUMMARY_COLS} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ?${opts?.status !== undefined ? " AND status = ?" : ""}
239
+ const [rows] = (await this.pool.query(`SELECT ${summaryCols("tidb")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ?${opts?.status !== undefined ? " AND status = ?" : ""}
220
240
  ORDER BY spawned_at_ms DESC, handle DESC${limitClause(opts?.limit)}`, opts?.status !== undefined ? [scope, opts.status] : [scope]));
221
241
  return rows.map(rowToSummary);
222
242
  }
@@ -229,9 +249,9 @@ export class TiDBBackgroundAgentStore {
229
249
  return 0;
230
250
  const flipped = new Set();
231
251
  if (opts.staleRunningMaxAgeMs !== undefined) {
232
- const [stale] = (await this.pool.query(`SELECT handle, record_json FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND status = 'running' AND updated_at_ms < ?`, [scope, now - opts.staleRunningMaxAgeMs]));
252
+ const [stale] = (await this.pool.query(`SELECT handle, ${jsonColumnText("tidb", "record_json")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = ? AND status = 'running' AND updated_at_ms < ?`, [scope, now - opts.staleRunningMaxAgeMs]));
233
253
  for (const row of stale) {
234
- const live = JSON.parse(String(row.record_json));
254
+ const live = readRecordColumn(row.record_json, String(row.handle));
235
255
  if (await this.update(String(row.handle), scope, flippedForm(live, now), { rev: live.rev }))
236
256
  flipped.add(String(row.handle));
237
257
  }
@@ -272,8 +292,8 @@ export class PgBackgroundAgentStore {
272
292
  }
273
293
  }
274
294
  async get(handle, scope) {
275
- const { rows } = await this.q(`SELECT record_json FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND handle = $2`, [scope, handle]);
276
- return rows.length > 0 ? JSON.parse(String(rows[0].record_json)) : null;
295
+ const { rows } = await this.q(`SELECT ${jsonColumnText("pg", "record_json")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND handle = $2`, [scope, handle]);
296
+ return rows.length > 0 ? readRecordColumn(rows[0].record_json, handle) : null;
277
297
  }
278
298
  async update(handle, scope, record, expect) {
279
299
  const stored = storedForm(record, handle, scope, expect.rev + 1);
@@ -293,12 +313,12 @@ export class PgBackgroundAgentStore {
293
313
  return rows.length === 1;
294
314
  }
295
315
  async listBySession(scope, sessionId, opts) {
296
- const { rows } = await this.q(`SELECT ${SUMMARY_COLS} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND (session_anchor = $2 OR root_session_id = $3)${opts?.status !== undefined ? " AND status = $4" : ""}
316
+ const { rows } = await this.q(`SELECT ${summaryCols("pg")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND (session_anchor = $2 OR root_session_id = $3)${opts?.status !== undefined ? " AND status = $4" : ""}
297
317
  ORDER BY spawned_at_ms DESC, handle DESC${limitClause(opts?.limit)}`, opts?.status !== undefined ? [scope, sessionId, sessionId, opts.status] : [scope, sessionId, sessionId]);
298
318
  return rows.map(rowToSummary);
299
319
  }
300
320
  async listByScope(scope, opts) {
301
- const { rows } = await this.q(`SELECT ${SUMMARY_COLS} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1${opts?.status !== undefined ? " AND status = $2" : ""}
321
+ const { rows } = await this.q(`SELECT ${summaryCols("pg")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1${opts?.status !== undefined ? " AND status = $2" : ""}
302
322
  ORDER BY spawned_at_ms DESC, handle DESC${limitClause(opts?.limit)}`, opts?.status !== undefined ? [scope, opts.status] : [scope]);
303
323
  return rows.map(rowToSummary);
304
324
  }
@@ -311,9 +331,9 @@ export class PgBackgroundAgentStore {
311
331
  return 0;
312
332
  const affected = new Set();
313
333
  if (opts.staleRunningMaxAgeMs !== undefined) {
314
- const { rows: stale } = await this.q(`SELECT handle, record_json FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND status = 'running' AND updated_at_ms < $2`, [scope, now - opts.staleRunningMaxAgeMs]);
334
+ const { rows: stale } = await this.q(`SELECT handle, ${jsonColumnText("pg", "record_json")} FROM ${BACKGROUND_AGENT_TABLE} WHERE scope_key = $1 AND status = 'running' AND updated_at_ms < $2`, [scope, now - opts.staleRunningMaxAgeMs]);
315
335
  for (const row of stale) {
316
- const live = JSON.parse(String(row.record_json));
336
+ const live = readRecordColumn(row.record_json, String(row.handle));
317
337
  if (await this.update(String(row.handle), scope, flippedForm(live, now), { rev: live.rev }))
318
338
  affected.add(String(row.handle));
319
339
  }
@@ -381,8 +381,12 @@ export declare function approvalDecisionOfWinner(winner: ResolvedOutcome | undef
381
381
  * - `{ readable: true, toolCallId: "…" }` —— 读出来了,拿去比。
382
382
  *
383
383
  * 逐层 `typeof` 收窄,不做裸 `as` 断言:blob 是持久层读回来的 `unknown`,是信任边界(宪法 [ref])。
384
+ *
385
+ * 🔴 S-550:入参是**已经解过码的体**,不是一格列文本 —— 本函数不再自己 `JSON.parse`(SQL 车道的解码
386
+ * 唯一属主是 `sql-json-column.ts`,见 {@link pendingActionOfColumn};local 孪生本来就递一只对象进来)。
387
+ * 于是「串就再解一次」这条读法在这里按构造消失,两条车道递进来的东西同形。
384
388
  */
385
- export declare function pendingActionToolCallId(blob: unknown): {
389
+ export declare function pendingActionToolCallId(body: unknown): {
386
390
  readable: true;
387
391
  toolCallId: string | null;
388
392
  boundInputHash: string | null;
@@ -4,7 +4,7 @@ import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
4
4
  import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, ExecutionOutcomeConflictError, executionRecordDisposition, executionVerdict, } from "@sema-agent/core";
5
5
  import { redactDeep, redactSecrets } from "../trace/redact.js";
6
6
  import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
7
- import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
7
+ import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readAnyJsonValueColumn, readJsonObjectColumn, readNonNullJsonValueColumn, } from "./sql-json-column.js";
8
8
  import { recordFailOpen } from "../observability/fail-open.js";
9
9
  import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
10
10
  import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
@@ -113,22 +113,61 @@ export function boundedRuleOffers(raw, opts = {}) {
113
113
  }
114
114
  return out.length > 0 ? out : undefined;
115
115
  }
116
+ const readCheckpointBlobBody = createObjectColumnReader();
117
+ const readResumeOutcomeBody = createObjectColumnReader();
118
+ const readGateOutcomeBody = createObjectColumnReader();
119
+ const readRiskDescriptorBody = createObjectColumnReader();
120
+ function readJsonArrayBody(body) {
121
+ return Array.isArray(body) ? { value: body } : { problem: "expected a JSON array" };
122
+ }
123
+ function decodeCheckpointColumn(cell, where) {
124
+ return decodeJsonColumn(cell, readCheckpointBlobBody, `checkpoint.checkpoint ${where}`);
125
+ }
126
+ function decodeOutcomeColumn(cell, where) {
127
+ return decodeNullableJsonColumn(cell, readResumeOutcomeBody, `checkpoint.outcome ${where}`);
128
+ }
129
+ function decodeExecutionOutcomeColumn(cell, where) {
130
+ return decodeNullableJsonColumn(cell, readGateOutcomeBody, `checkpoint.execution_outcome ${where}`);
131
+ }
132
+ function decodeToolInputColumn(cell) {
133
+ return decodeNullableJsonColumn(cell, readNonNullJsonValueColumn, "checkpoint.tool_input (operator queue row)");
134
+ }
135
+ function decodeCtxColumn(cell, where) {
136
+ const body = decodeJsonColumn(cell, readAnyJsonValueColumn, `checkpoint_ctx.ctx ${where}`);
137
+ return body;
138
+ }
139
+ function refusing(what, read) {
140
+ try {
141
+ return read();
142
+ }
143
+ catch (e) {
144
+ throw foldJsonColumn(e, (m) => new Error(`SqlCheckpointStore: ${m} — refusing rather than serve ${what}`));
145
+ }
146
+ }
116
147
  function readRuleOffersCell(cell) {
148
+ if (cell === null)
149
+ return undefined;
117
150
  let parsed;
118
151
  try {
119
- parsed = parseJson(cell);
152
+ parsed = decodeJsonColumn(cell, readJsonArrayBody, "checkpoint.rule_suggestions (operator queue row)");
120
153
  }
121
- catch {
154
+ catch (e) {
155
+ if (!isJsonColumnBodyProblem(e))
156
+ throw e;
122
157
  recordFailOpen("server.approvals.rule-suggestions-cell-unreadable");
123
158
  return undefined;
124
159
  }
125
160
  return boundedRuleOffers(parsed, { countRedactions: false });
126
161
  }
127
162
  function readRiskDescriptorCell(cell) {
163
+ if (cell === null)
164
+ return null;
128
165
  try {
129
- return parseJson(cell);
166
+ return decodeJsonColumn(cell, readRiskDescriptorBody, "checkpoint.risk_descriptor (operator queue row)");
130
167
  }
131
- catch {
168
+ catch (e) {
169
+ if (!isJsonColumnBodyProblem(e))
170
+ throw e;
132
171
  recordFailOpen("server.approvals.risk-descriptor-cell-unreadable");
133
172
  return null;
134
173
  }
@@ -166,23 +205,16 @@ export function approvalDecisionOfWinner(winner, boundCallId) {
166
205
  }
167
206
  }
168
207
  }
169
- export function pendingActionToolCallId(blob) {
170
- let parsed;
171
- try {
172
- parsed = typeof blob === "string" ? JSON.parse(blob) : blob;
173
- }
174
- catch {
175
- return { readable: false };
176
- }
177
- if (typeof parsed !== "object" || parsed === null)
208
+ export function pendingActionToolCallId(body) {
209
+ if (typeof body !== "object" || body === null)
178
210
  return { readable: false };
179
- const rawSource = "sourceTaskId" in parsed ? parsed.sourceTaskId : null;
211
+ const rawSource = "sourceTaskId" in body ? body.sourceTaskId : null;
180
212
  if (rawSource !== null && rawSource !== undefined && typeof rawSource !== "string")
181
213
  return { readable: false };
182
214
  const sourceTaskId = typeof rawSource === "string" ? rawSource : null;
183
- if (!("pendingAction" in parsed))
215
+ if (!("pendingAction" in body))
184
216
  return { readable: true, toolCallId: null, boundInputHash: null, kind: null, sourceTaskId };
185
- const pa = parsed.pendingAction;
217
+ const pa = body.pendingAction;
186
218
  if (typeof pa !== "object" || pa === null)
187
219
  return { readable: false };
188
220
  const kind = "kind" in pa ? pa.kind : null;
@@ -200,6 +232,18 @@ export function pendingActionToolCallId(blob) {
200
232
  return { readable: false };
201
233
  return { readable: true, toolCallId: id, boundInputHash: bih, kind, sourceTaskId };
202
234
  }
235
+ function pendingActionOfColumn(cell, where) {
236
+ let body;
237
+ try {
238
+ body = decodeJsonColumn(cell, readJsonObjectColumn, `checkpoint.checkpoint ${where}`);
239
+ }
240
+ catch (e) {
241
+ if (!isJsonColumnBodyProblem(e))
242
+ throw e;
243
+ return { readable: false };
244
+ }
245
+ return pendingActionToolCallId(body);
246
+ }
203
247
  function readActorAssertion(v) {
204
248
  if (typeof v !== "object" || v === null)
205
249
  return undefined;
@@ -236,33 +280,24 @@ function readQueueEntry(v) {
236
280
  }
237
281
  export function readPendingSteerQueueColumn(queueRaw) {
238
282
  let lossy = false;
239
- const parse = (raw) => {
240
- if (raw === null || raw === undefined)
241
- return undefined;
242
- if (typeof raw !== "string")
243
- return raw;
244
- try {
245
- return JSON.parse(raw);
246
- }
247
- catch {
248
- lossy = true;
249
- return undefined;
250
- }
251
- };
252
- const queueParsed = parse(queueRaw);
283
+ let items;
284
+ try {
285
+ items = decodeNullableJsonColumn(queueRaw, readJsonArrayBody, "checkpoint.pending_steer_queue");
286
+ }
287
+ catch (e) {
288
+ if (!isJsonColumnBodyProblem(e))
289
+ throw e;
290
+ lossy = true;
291
+ }
253
292
  let queue;
254
- if (queueParsed !== undefined) {
255
- if (!Array.isArray(queueParsed))
256
- lossy = true;
257
- else {
258
- queue = [];
259
- for (const item of queueParsed) {
260
- const entry = readQueueEntry(item);
261
- if (entry === undefined)
262
- lossy = true;
263
- else
264
- queue.push(entry);
265
- }
293
+ if (items !== undefined) {
294
+ queue = [];
295
+ for (const item of items) {
296
+ const entry = readQueueEntry(item);
297
+ if (entry === undefined)
298
+ lossy = true;
299
+ else
300
+ queue.push(entry);
266
301
  }
267
302
  }
268
303
  return { state: { ...(queue !== undefined ? { pendingSteerQueue: queue } : {}) }, lossy };
@@ -359,7 +394,9 @@ export class SqlCheckpointStore {
359
394
  }
360
395
  }
361
396
  async get(token) {
362
- const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer_queue, execution_outcome, execution_at_ms FROM checkpoint WHERE token = $1"), [token]);
397
+ const { rows } = await this.db.query(this.q(`SELECT version, status, ${jsonColumnText("tidb", "checkpoint")}, ${jsonColumnText("tidb", "outcome")}, reopen_reason, rev, ` +
398
+ `${jsonColumnText("tidb", "pending_steer_queue")}, ${jsonColumnText("tidb", "execution_outcome")}, execution_at_ms FROM checkpoint WHERE token = ?`, `SELECT version, status, ${jsonColumnText("pg", "checkpoint")}, ${jsonColumnText("pg", "outcome")}, reopen_reason, rev, ` +
399
+ `${jsonColumnText("pg", "pending_steer_queue")}, ${jsonColumnText("pg", "execution_outcome")}, execution_at_ms FROM checkpoint WHERE token = $1`), [token]);
363
400
  const r = rows[0];
364
401
  if (!r)
365
402
  return null;
@@ -367,21 +404,22 @@ export class SqlCheckpointStore {
367
404
  if (version > MAX_SUPPORTED_CHECKPOINT_VERSION) {
368
405
  throw new Error(`checkpoint format version ${version} is newer than this build supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION}) — upgrade the service to resume it`);
369
406
  }
370
- const cp = parseJson(r.checkpoint);
407
+ const cp = refusing("a checkpoint nobody can resume", () => decodeCheckpointColumn(r.checkpoint, `for token ${tokenFingerprint(token)}`));
371
408
  cp.status = r.status;
372
- const outcomeRaw = parseJson(r.outcome);
409
+ const outcomeRaw = refusing("a decision record nobody can read", () => decodeOutcomeColumn(r.outcome, `for token ${tokenFingerprint(token)}`));
373
410
  if (outcomeRaw)
374
411
  cp.resolvedOutcome = winnerFromOutcome(outcomeRaw);
375
412
  cp.reopenReason = r.reopen_reason ?? undefined;
376
413
  cp.rev = r.rev == null ? 0 : Number(r.rev);
377
- const executionOutcome = parseJson(r.execution_outcome);
378
- if (executionOutcome !== null) {
414
+ const executionOutcome = refusing("an execution record nobody can read", () => decodeExecutionOutcomeColumn(r.execution_outcome, `for token ${tokenFingerprint(token)}`));
415
+ if (executionOutcome !== undefined) {
379
416
  cp.executionOutcome = executionOutcome;
380
417
  if (r.execution_at_ms != null)
381
418
  cp.executionAtMs = Number(r.execution_at_ms);
382
419
  }
383
420
  const steer = readPendingSteerQueueColumn(r.pending_steer_queue);
384
421
  if (steer.lossy) {
422
+ recordFailOpen("server.sql-json.checkpoint.pending-steer-queue-lossy");
385
423
  this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
386
424
  }
387
425
  if (cp.state) {
@@ -412,14 +450,14 @@ export class SqlCheckpointStore {
412
450
  }
413
451
  async recordExecutionOutcome(token, scope, gate) {
414
452
  for (let attempt = 0; attempt < EXECUTION_RECORD_CAS_ATTEMPTS; attempt++) {
415
- const { rows } = await this.db.query(this.q("SELECT scope, status, execution_outcome FROM checkpoint WHERE token = ?", "SELECT scope, status, execution_outcome FROM checkpoint WHERE token = $1"), [token]);
453
+ const { rows } = await this.db.query(this.q(`SELECT scope, status, ${jsonColumnText("tidb", "execution_outcome")} FROM checkpoint WHERE token = ?`, `SELECT scope, status, ${jsonColumnText("pg", "execution_outcome")} FROM checkpoint WHERE token = $1`), [token]);
416
454
  const row = rows[0];
417
455
  const facts = row === undefined
418
456
  ? undefined
419
457
  : {
420
458
  scope: String(row.scope),
421
459
  status: row.status,
422
- ...((v) => (v === null ? {} : { executionOutcome: v }))(parseJson(row.execution_outcome)),
460
+ ...((v) => (v === undefined ? {} : { executionOutcome: v }))(refusing("an execution record nobody can read", () => decodeExecutionOutcomeColumn(row.execution_outcome, `for token ${tokenFingerprint(token)}`))),
423
461
  };
424
462
  const disposition = executionRecordDisposition(facts, scope, gate);
425
463
  if (disposition === "conflict")
@@ -436,7 +474,7 @@ export class SqlCheckpointStore {
436
474
  async setPendingSteer(token, scope, steer) {
437
475
  const clean = validatePendingSteer(steer);
438
476
  for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
439
- const { rows } = await this.db.query(this.q("SELECT status, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?", "SELECT status, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2"), [token, scope]);
477
+ const { rows } = await this.db.query(this.q(`SELECT status, ${jsonColumnText("tidb", "pending_steer_queue")}, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?`, `SELECT status, ${jsonColumnText("pg", "pending_steer_queue")}, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2`), [token, scope]);
440
478
  const row = rows[0];
441
479
  if (!row || row.status !== "pending")
442
480
  return false;
@@ -480,7 +518,9 @@ export class SqlCheckpointStore {
480
518
  return rows.map((r) => ({ sessionId: String(r.session_id), scope: String(r.scope) }));
481
519
  }
482
520
  async listPending(scope) {
483
- const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.rule_suggestions, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id " +
521
+ const d = this.db.dialect;
522
+ const base = `SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, ${jsonColumnText(d, "tool_input")}, c.bound_input_hash, ` +
523
+ `${jsonColumnText(d, "risk_descriptor")}, ${jsonColumnText(d, "rule_suggestions")}, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id ` +
484
524
  "FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
485
525
  const { rows } = scope
486
526
  ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
@@ -500,7 +540,7 @@ export class SqlCheckpointStore {
500
540
  ...(boundInputHash !== null ? { boundInputHash } : {}),
501
541
  ...(gateKind !== null ? { gateKind } : {}),
502
542
  taskId: r.task_id ?? null,
503
- input: r.tool_input ?? null,
543
+ input: refusing("a tool input the operator would be approving unseen", () => decodeToolInputColumn(r.tool_input)) ?? null,
504
544
  createdAt: Number(r.created_at_ms),
505
545
  deadline: r.deadline == null ? null : Number(r.deadline),
506
546
  riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
@@ -512,12 +552,10 @@ export class SqlCheckpointStore {
512
552
  return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
513
553
  }
514
554
  async listByScope(scope) {
515
- const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500"), [scope]);
555
+ const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "checkpoint")}, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500`, `SELECT ${jsonColumnText("pg", "checkpoint")}, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500`), [scope]);
516
556
  const out = [];
517
557
  for (const r of rows) {
518
- const cp = parseJson(r.checkpoint);
519
- if (!cp)
520
- continue;
558
+ const cp = refusing("a checkpoint nobody can resume", () => decodeCheckpointColumn(r.checkpoint, `in the ${scope} scheduler view`));
521
559
  cp.status = r.status;
522
560
  out.push(summarizeCheckpoint(cp));
523
561
  }
@@ -529,8 +567,8 @@ export class SqlCheckpointStore {
529
567
  return rows[0] ? String(rows[0].token) : null;
530
568
  }
531
569
  async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
532
- const { rows } = await this.db.query(this.q("SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
533
- "WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", "SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
570
+ const { rows } = await this.db.query(this.q(`SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, ${jsonColumnText("tidb", "checkpoint")} FROM checkpoint ` +
571
+ "WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", `SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, ${jsonColumnText("pg", "checkpoint")} FROM checkpoint ` +
534
572
  "WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
535
573
  const out = [];
536
574
  for (const r of rows) {
@@ -545,7 +583,7 @@ export class SqlCheckpointStore {
545
583
  out.push({ ...base, boundCallId: null, unparseable: true });
546
584
  continue;
547
585
  }
548
- const derived = pendingActionToolCallId(r.checkpoint);
586
+ const derived = pendingActionOfColumn(r.checkpoint, `for token ${tokenFingerprint(String(r.token))}`);
549
587
  if (!derived.readable) {
550
588
  out.push({ ...base, boundCallId: null, unparseable: true });
551
589
  continue;
@@ -567,24 +605,31 @@ export class SqlCheckpointStore {
567
605
  return out;
568
606
  }
569
607
  async findDecidedApprovalsForBinding(sessionId, boundCallId) {
570
- const { rows } = await this.db.query(this.q("SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
571
- `WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, "SELECT scope, bound_input_hash, outcome, execution_outcome FROM checkpoint " +
608
+ const { rows } = await this.db.query(this.q(`SELECT scope, bound_input_hash, ${jsonColumnText("tidb", "outcome")}, ${jsonColumnText("tidb", "execution_outcome")} FROM checkpoint ` +
609
+ `WHERE session_id=? AND tool_call_id=? AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`, `SELECT scope, bound_input_hash, ${jsonColumnText("pg", "outcome")}, ${jsonColumnText("pg", "execution_outcome")} FROM checkpoint ` +
572
610
  `WHERE session_id=$1 AND tool_call_id=$2 AND status='resolved' AND decided_at_ms IS NOT NULL ORDER BY decided_at_ms DESC LIMIT ${DECIDED_REPLAY_SCAN_CAP}`), [sessionId, boundCallId]);
573
611
  return rows.map((r) => {
574
612
  let winner;
575
613
  try {
576
- const outcome = parseJson(r.outcome);
614
+ const outcome = decodeOutcomeColumn(r.outcome, `for the decided row of session ${sessionId}`);
577
615
  winner = outcome ? winnerFromOutcome(outcome) : undefined;
578
616
  }
579
617
  catch (e) {
618
+ if (!isJsonColumnBodyProblem(e))
619
+ throw e;
620
+ recordFailOpen("server.sql-json.checkpoint.outcome-unreadable");
580
621
  this.logger?.info?.("checkpoint_decided_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
581
622
  }
582
623
  let executed = null;
583
624
  try {
584
- const verdict = executionVerdict({ ...((v) => (v === null ? {} : { executionOutcome: v }))(parseJson(r.execution_outcome)) });
625
+ const cell = decodeExecutionOutcomeColumn(r.execution_outcome, `for the decided row of session ${sessionId}`);
626
+ const verdict = executionVerdict({ ...((v) => (v === undefined ? {} : { executionOutcome: v }))(cell) });
585
627
  executed = verdict.kind === "executed" ? verdict.gate : null;
586
628
  }
587
629
  catch (e) {
630
+ if (!isJsonColumnBodyProblem(e))
631
+ throw e;
632
+ recordFailOpen("server.sql-json.checkpoint.execution-outcome-unreadable");
588
633
  this.logger?.info?.("checkpoint_execution_outcome_unreadable", { sessionId, error: e instanceof Error ? e.message : String(e) });
589
634
  }
590
635
  return {
@@ -615,8 +660,8 @@ export class SqlCheckpointStore {
615
660
  "ON CONFLICT (session_id) DO UPDATE SET ctx = EXCLUDED.ctx, updated_at_ms = EXCLUDED.updated_at_ms"), [sessionId, this.json(ctx, "checkpoint ctx"), Date.now()]);
616
661
  }
617
662
  async getCtx(sessionId) {
618
- const { rows } = await this.db.query(this.q("SELECT ctx FROM checkpoint_ctx WHERE session_id=?", "SELECT ctx FROM checkpoint_ctx WHERE session_id=$1"), [sessionId]);
619
- return rows[0] ? parseJson(rows[0].ctx) : null;
663
+ const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "ctx")} FROM checkpoint_ctx WHERE session_id=?`, `SELECT ${jsonColumnText("pg", "ctx")} FROM checkpoint_ctx WHERE session_id=$1`), [sessionId]);
664
+ return rows[0] ? refusing("a resume context nobody can read", () => decodeCtxColumn(rows[0].ctx, `for session ${JSON.stringify(sessionId)}`)) : null;
620
665
  }
621
666
  async reapCtx(cutoff) {
622
667
  const res = await this.db.query(this.q("DELETE FROM checkpoint_ctx WHERE updated_at_ms < ? " +