@sema-agent/server 7.93.5 → 7.93.6

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.
@@ -56,12 +56,23 @@ export function killTreeSoft(pid) {
56
56
  export { killProcessTree };
57
57
  const liveHostForegroundGroups = new Set();
58
58
  export function trackHostForegroundChild(pid) {
59
- const ticket = { pid };
59
+ const ticket = {
60
+ pid,
61
+ release() {
62
+ liveHostForegroundGroups.delete(ticket);
63
+ },
64
+ };
60
65
  liveHostForegroundGroups.add(ticket);
61
66
  return ticket;
62
67
  }
63
- export function untrackHostForegroundChild(ticket) {
64
- liveHostForegroundGroups.delete(ticket);
68
+ export function adoptHostForegroundChild(child) {
69
+ const pid = child.pid;
70
+ if (pid == null)
71
+ return undefined;
72
+ const ticket = trackHostForegroundChild(pid);
73
+ child.once("exit", () => ticket.release());
74
+ child.once("error", () => ticket.release());
75
+ return ticket;
65
76
  }
66
77
  export function reapHostForegroundChildren() {
67
78
  let signalled = 0;
@@ -2,7 +2,7 @@ import { uuidv7 } from "@sema-agent/core";
2
2
  import { createHash } from "node:crypto";
3
3
  import { isDupKeyError } from "./sql-errors.js";
4
4
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
5
- import { parseJsonOr } from "./sql-row-helpers.js";
5
+ import { decodeNullableJsonColumn, foldJsonColumn, jsonColumnText, readNonNullJsonValueColumn } from "./sql-json-column.js";
6
6
  import { ensureIndexes } from "./ensure-index.js";
7
7
  export const LEADER_RUN_TABLE = "leader_run";
8
8
  const LEADER_RUN_STATUSES = new Set(["running", "completed", "failed", "needs_human"]);
@@ -59,7 +59,17 @@ export async function ensurePgLeaderRunSchema(q) {
59
59
  }
60
60
  export const STALE_SWEEP_REASON = "leader run went stale: no replica settled it within LEADER_RUN_STALE_MS (the replica driving it most likely died). " +
61
61
  "The run was NOT taken over — re-submit with a NEW Idempotency-Key to run it again.";
62
- const SELECT_COLS = "leader_run_id, owner, objective_preview, status, result, error, started_at_ms, finished_at_ms";
62
+ function selectCols(dialect) {
63
+ return `leader_run_id, owner, objective_preview, status, ${jsonColumnText(dialect, "result")}, error, started_at_ms, finished_at_ms`;
64
+ }
65
+ function readResultColumn(text, id) {
66
+ try {
67
+ return decodeNullableJsonColumn(text, readNonNullJsonValueColumn, `${LEADER_RUN_TABLE}.result for leader run ${id}`);
68
+ }
69
+ catch (e) {
70
+ throw foldJsonColumn(e, (m) => new Error(`leader_run: ${m} — refusing rather than serve this run as if it had finished empty-handed`));
71
+ }
72
+ }
63
73
  function statusOf(raw, id) {
64
74
  const s = String(raw);
65
75
  if (!LEADER_RUN_STATUSES.has(s)) {
@@ -69,7 +79,7 @@ function statusOf(raw, id) {
69
79
  }
70
80
  function mapRow(r) {
71
81
  const id = String(r.leader_run_id);
72
- const result = r.result == null ? undefined : parseJsonOr(r.result, undefined);
82
+ const result = readResultColumn(r.result, id);
73
83
  const error = r.error == null ? undefined : String(r.error);
74
84
  const finished = r.finished_at_ms == null ? undefined : Number(r.finished_at_ms);
75
85
  return {
@@ -113,12 +123,12 @@ export class SqlLeaderRunStore {
113
123
  return { created: true, run };
114
124
  }
115
125
  async getRun(id) {
116
- const res = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = ?`, `SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = $1`), [id]);
126
+ const res = await this.db.query(this.q(`SELECT ${selectCols("tidb")} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = ?`, `SELECT ${selectCols("pg")} FROM ${LEADER_RUN_TABLE} WHERE leader_run_id = $1`), [id]);
117
127
  const row = res.rows[0];
118
128
  return row ? mapRow(row) : undefined;
119
129
  }
120
130
  async getByIdem(idemKey) {
121
- const res = await this.db.query(this.q(`SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE idem_key = ?`, `SELECT ${SELECT_COLS} FROM ${LEADER_RUN_TABLE} WHERE idem_key = $1`), [idemKey]);
131
+ const res = await this.db.query(this.q(`SELECT ${selectCols("tidb")} FROM ${LEADER_RUN_TABLE} WHERE idem_key = ?`, `SELECT ${selectCols("pg")} FROM ${LEADER_RUN_TABLE} WHERE idem_key = $1`), [idemKey]);
122
132
  const row = res.rows[0];
123
133
  return row ? mapRow(row) : undefined;
124
134
  }
@@ -132,7 +142,7 @@ export class SqlLeaderRunStore {
132
142
  }
133
143
  async finishRun(id, patch) {
134
144
  const json = dialectJsonEncoder(this.db.dialect);
135
- const res = await this.db.query(this.q(`UPDATE ${LEADER_RUN_TABLE} SET status = ?, result = CAST(? AS JSON), error = ?, finished_at_ms = ? WHERE leader_run_id = ? AND status = 'running'`, `UPDATE ${LEADER_RUN_TABLE} SET status = $1, result = $2::jsonb, error = $3, finished_at_ms = $4 WHERE leader_run_id = $5 AND status = 'running'`), [patch.status, patch.result === undefined ? null : json(patch.result), patch.error ?? null, Date.now(), id]);
145
+ const res = await this.db.query(this.q(`UPDATE ${LEADER_RUN_TABLE} SET status = ?, result = CAST(? AS JSON), error = ?, finished_at_ms = ? WHERE leader_run_id = ? AND status = 'running'`, `UPDATE ${LEADER_RUN_TABLE} SET status = $1, result = $2::jsonb, error = $3, finished_at_ms = $4 WHERE leader_run_id = $5 AND status = 'running'`), [patch.status, patch.result == null ? null : json(patch.result), patch.error ?? null, Date.now(), id]);
136
146
  return res.affected > 0;
137
147
  }
138
148
  }
@@ -7,7 +7,7 @@ import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdi
7
7
  import { StringDecoder } from "node:string_decoder";
8
8
  import { armPipeDestroyGrace, numEnvOr, SYMLINK_CHAIN_MAX_HOPS } from "./remote-shell.js";
9
9
  import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
10
- import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys, trackHostForegroundChild, untrackHostForegroundChild } from "./host-platform.js";
10
+ import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys, adoptHostForegroundChild } from "./host-platform.js";
11
11
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
12
12
  import { FileError, ExecutionError, RemoteExecutionError, scrubSecretEnv, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
13
13
  import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
@@ -403,15 +403,12 @@ export class RemoteHostExecutionEnv {
403
403
  env: this.mergeEnv(options?.env),
404
404
  ...spawnGroupOptions(),
405
405
  });
406
- const ticket = child.pid != null ? trackHostForegroundChild(child.pid) : undefined;
407
- if (ticket)
408
- child.once("exit", () => untrackHostForegroundChild(ticket));
406
+ const ticket = adoptHostForegroundChild(child);
409
407
  const finish = (r) => {
410
408
  if (settled)
411
409
  return;
412
410
  settled = true;
413
- if (ticket)
414
- untrackHostForegroundChild(ticket);
411
+ ticket?.release();
415
412
  clearTimeout(timer);
416
413
  options?.abortSignal?.removeEventListener("abort", onAbort);
417
414
  removeDetachListener?.();
@@ -460,8 +457,7 @@ export class RemoteHostExecutionEnv {
460
457
  if (forceSettleTimer)
461
458
  clearTimeout(forceSettleTimer);
462
459
  options?.abortSignal?.removeEventListener("abort", onAbort);
463
- if (ticket)
464
- untrackHostForegroundChild(ticket);
460
+ ticket?.release();
465
461
  settled = true;
466
462
  resolve(ok({ stdout: seedOut, stderr: seedErr, exitCode: 0, detached: { shellId } }));
467
463
  };
@@ -621,8 +617,7 @@ export class RemoteHostExecutionEnv {
621
617
  clearInterval(pump);
622
618
  pump = undefined;
623
619
  }
624
- if (ticket)
625
- untrackHostForegroundChild(ticket);
620
+ ticket?.release();
626
621
  if (drainTimer) {
627
622
  clearTimeout(drainTimer);
628
623
  drainTimer = undefined;
@@ -675,8 +670,7 @@ export class RemoteHostExecutionEnv {
675
670
  cleanupSpool();
676
671
  throw e;
677
672
  }
678
- if (child.pid != null)
679
- ticket = trackHostForegroundChild(child.pid);
673
+ ticket = adoptHostForegroundChild(child);
680
674
  closeSpoolFds();
681
675
  child.on("error", (e) => {
682
676
  finish({ ok: false, error: new ExecutionError("spawn_error", `host spawn failed: ${e.message}`) });
@@ -765,8 +759,6 @@ export class RemoteHostExecutionEnv {
765
759
  settleFromTermination(code, signal);
766
760
  });
767
761
  child.on("exit", (code, signal) => {
768
- if (ticket)
769
- untrackHostForegroundChild(ticket);
770
762
  exited = true;
771
763
  drainTimer = setTimeout(() => settleFromTermination(code, signal), EXIT_SETTLE_DRAIN_MS);
772
764
  drainTimer.unref?.();
@@ -811,9 +803,7 @@ export class RemoteHostExecutionEnv {
811
803
  const signalReady = () => { const w = wake; wake = undefined; w?.(); };
812
804
  const sh = hostShell();
813
805
  const child = spawn(sh.shell, [...sh.args, command], { cwd, env: this.mergeEnv(options?.env), ...spawnGroupOptions() });
814
- const ticket = child.pid != null ? trackHostForegroundChild(child.pid) : undefined;
815
- if (ticket)
816
- child.once("exit", () => untrackHostForegroundChild(ticket));
806
+ const ticket = adoptHostForegroundChild(child);
817
807
  let pipeDestroyTimer;
818
808
  const kill = () => {
819
809
  try {
@@ -848,8 +838,7 @@ export class RemoteHostExecutionEnv {
848
838
  child.on("close", (code, signal) => {
849
839
  if (pipeDestroyTimer)
850
840
  clearTimeout(pipeDestroyTimer);
851
- if (ticket)
852
- untrackHostForegroundChild(ticket);
841
+ ticket?.release();
853
842
  if (finished)
854
843
  return;
855
844
  exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 0) : 1);
@@ -925,8 +914,7 @@ export class RemoteHostExecutionEnv {
925
914
  if (wallTimer)
926
915
  clearTimeout(wallTimer);
927
916
  options?.signal?.removeEventListener("abort", onAbort);
928
- if (ticket)
929
- untrackHostForegroundChild(ticket);
917
+ ticket?.release();
930
918
  }
931
919
  }
932
920
  async absolutePath(p) {
@@ -124,7 +124,7 @@ export declare class SqlRunStore {
124
124
  getActiveTaskId(sessionId: string): Promise<string | undefined>;
125
125
  /** Shared row→record mapper — byte-identical between dialects (both drivers hand back the same column
126
126
  * names + comparable JS types after SqlDriver's result normalization).
127
- * 🔴 **读它的每一条查询都必须选 {@link RUN_ROW_COLS}**(S-230):映射器对「没被 SELECT 的列」是
127
+ * 🔴 **读它的每一条查询都必须选 {@link runRowCols}**(S-230):映射器对「没被 SELECT 的列」是
128
128
  * 结构性失明的 —— `undefined ?? null` 与 `Boolean(undefined)` 都会安静地铸出一个**看上去合法的假值**。
129
129
  * 修前 `listRuns` 的两条方言臂就漏选 `instance_id`,于是列表面的每一行 `instanceId` 恒 `null`
130
130
  * (今天没有消费点,所以没人发现)。列表收成一个常量 = 这类漏选从此不可能发生。 */
@@ -3,11 +3,42 @@ import { outwardPlane } from "../terminal.js";
3
3
  import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
4
4
  import { escapeLike } from "./sql-escape.js";
5
5
  import { pgSanitizeText } from "./pg-safe-json.js";
6
- import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.js";
6
+ import { toIso as iso } from "./sql-row-helpers.js";
7
+ import { recordFailOpen } from "../observability/fail-open.js";
8
+ import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readJsonObjectColumn, readNonNullJsonValueColumn } from "./sql-json-column.js";
7
9
  import { notifyRunTerminal, runTerminalLogLevel } from "../observability/run-terminal.js";
8
10
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
9
11
  import { isDupKeyError } from "./sql-errors.js";
10
- const RUN_ROW_COLS = "task_id, session_id, owner, status, result, error, error_code, job_id, source, instance_id, cancel_requested, objective_preview, created_at, updated_at";
12
+ function runRowCols(dialect) {
13
+ return (`task_id, session_id, owner, status, ${jsonColumnText(dialect, "result")}, error, error_code, job_id, source, ` +
14
+ "instance_id, cancel_requested, objective_preview, created_at, updated_at");
15
+ }
16
+ const readPersistedTaskResultBody = createObjectColumnReader();
17
+ function decodeResultColumn(v, taskId) {
18
+ return decodeNullableJsonColumn(v, readPersistedTaskResultBody, `task_run.result for run ${taskId}`);
19
+ }
20
+ function refusing(what, read) {
21
+ try {
22
+ return read();
23
+ }
24
+ catch (e) {
25
+ throw foldJsonColumn(e, (m) => new Error(`SqlRunStore: ${m} — refusing rather than ${what}`));
26
+ }
27
+ }
28
+ function usageResultBody(v) {
29
+ try {
30
+ return decodeJsonColumn(v, readJsonObjectColumn, "task_run.result (usage scan row)");
31
+ }
32
+ catch (e) {
33
+ if (!isJsonColumnBodyProblem(e))
34
+ throw e;
35
+ recordFailOpen("server.sql-json.task-run.usage-result-unreadable");
36
+ return undefined;
37
+ }
38
+ }
39
+ function decodeEventDataColumn(v, taskId, seq) {
40
+ return decodeNullableJsonColumn(v, readNonNullJsonValueColumn, `task_event.data for run ${taskId} seq ${seq}`);
41
+ }
11
42
  export class SqlRunStore {
12
43
  db;
13
44
  constructor(db) {
@@ -178,7 +209,7 @@ export class SqlRunStore {
178
209
  sessionId: String(r.session_id),
179
210
  owner: r.owner ?? null,
180
211
  status: r.status,
181
- result: parseJson(r.result),
212
+ result: refusing("serve a finished run as if it had produced nothing", () => decodeResultColumn(r.result, String(r.task_id))) ?? null,
182
213
  error: r.error ?? null,
183
214
  errorCode: r.error_code ?? null,
184
215
  jobId: r.job_id ?? null,
@@ -191,7 +222,7 @@ export class SqlRunStore {
191
222
  };
192
223
  }
193
224
  async getRun(taskId) {
194
- const { rows } = await this.db.query(this.q(`SELECT ${RUN_ROW_COLS} FROM task_run WHERE task_id = ?`, `SELECT ${RUN_ROW_COLS} FROM task_run WHERE task_id = $1`), [taskId]);
225
+ const { rows } = await this.db.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE task_id = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE task_id = $1`), [taskId]);
195
226
  return rows[0] ? this.rowToRun(rows[0]) : undefined;
196
227
  }
197
228
  async listRuns(opts) {
@@ -220,7 +251,7 @@ export class SqlRunStore {
220
251
  where.push("(created_at < ? OR (created_at = ? AND task_id < ?))");
221
252
  params.push(new Date(opts.cursor.createdAt), new Date(opts.cursor.createdAt), opts.cursor.taskId);
222
253
  }
223
- const sql = `SELECT ${RUN_ROW_COLS} FROM task_run` +
254
+ const sql = `SELECT ${runRowCols("tidb")} FROM task_run` +
224
255
  (where.length ? " WHERE " + where.join(" AND ") : "") +
225
256
  " ORDER BY created_at DESC, task_id DESC LIMIT ?";
226
257
  params.push(opts.limit);
@@ -247,7 +278,7 @@ export class SqlRunStore {
247
278
  const t = p(opts.cursor.taskId);
248
279
  where.push(`(created_at < ${a} OR (created_at = ${b} AND task_id < ${t}))`);
249
280
  }
250
- const sql = `SELECT ${RUN_ROW_COLS} FROM task_run` +
281
+ const sql = `SELECT ${runRowCols("pg")} FROM task_run` +
251
282
  (where.length ? " WHERE " + where.join(" AND ") : "") +
252
283
  ` ORDER BY created_at DESC, task_id DESC LIMIT ${p(opts.limit)}`;
253
284
  const { rows } = await this.db.query(sql, params);
@@ -359,7 +390,7 @@ export class SqlRunStore {
359
390
  where.push(this.q("owner = ?", "owner = $3"));
360
391
  params.push(opts.owner);
361
392
  }
362
- const { rows } = await this.db.query(`SELECT owner, created_at, status, result FROM task_run WHERE ${where.join(" AND ")} ORDER BY created_at LIMIT ${limit + 1}`, params);
393
+ const { rows } = await this.db.query(`SELECT owner, created_at, status, ${jsonColumnText(this.db.dialect, "result")} FROM task_run WHERE ${where.join(" AND ")} ORDER BY created_at LIMIT ${limit + 1}`, params);
363
394
  const truncated = rows.length > limit;
364
395
  const page = truncated ? rows.slice(0, limit) : rows;
365
396
  return {
@@ -368,7 +399,7 @@ export class SqlRunStore {
368
399
  owner: r.owner ?? null,
369
400
  createdAtMs: new Date(r.created_at).getTime(),
370
401
  status: String(r.status),
371
- ...(r.result != null ? { stats: projectUsageStats(r.result) } : {}),
402
+ ...(r.result === null ? {} : { stats: projectUsageStats(usageResultBody(r.result)) }),
372
403
  })),
373
404
  };
374
405
  }
@@ -378,11 +409,11 @@ export class SqlRunStore {
378
409
  return m == null ? 0 : Number(m);
379
410
  }
380
411
  async getEvents(taskId, afterSeq) {
381
- const { rows } = await this.db.query(this.q("SELECT seq, type, data, ts FROM task_event WHERE task_id = ? AND seq > ? ORDER BY seq ASC", "SELECT seq, type, data, ts FROM task_event WHERE task_id = $1 AND seq > $2 ORDER BY seq ASC"), [taskId, afterSeq]);
412
+ const { rows } = await this.db.query(this.q(`SELECT seq, type, ${jsonColumnText("tidb", "data")}, ts FROM task_event WHERE task_id = ? AND seq > ? ORDER BY seq ASC`, `SELECT seq, type, ${jsonColumnText("pg", "data")}, ts FROM task_event WHERE task_id = $1 AND seq > $2 ORDER BY seq ASC`), [taskId, afterSeq]);
382
413
  return rows.map((r) => ({
383
414
  seq: Number(r.seq),
384
415
  type: String(r.type),
385
- data: parseJson(r.data),
416
+ data: refusing("replay it as an empty ledger frame", () => decodeEventDataColumn(r.data, taskId, Number(r.seq))) ?? null,
386
417
  ts: iso(r.ts),
387
418
  }));
388
419
  }
@@ -25,7 +25,11 @@ function rowToStored(r, where) {
25
25
  if (!Number.isFinite(rev) || !Number.isInteger(rev) || rev < 1) {
26
26
  throw new SessionPolicyError("corrupt", `session rules row ${where} carries rev ${String(r.rev)} — a stored row's rev is always ≥ 1 (the stamp is floor+1), and rev 0 is the live reader's synthetic empty seed; refusing rather than serve it as rules`);
27
27
  }
28
- const gen = Number(r.gen ?? 0);
28
+ if (r.gen === undefined || r.gen === null) {
29
+ throw new Error(`session_policy row ${where} came back without a \`gen\` column — a read leg did not project it (the column is BIGINT NOT NULL on both dialects). ` +
30
+ "Refusing rather than judge this row's lineage as generation 0, which is a value nothing minted.");
31
+ }
32
+ const gen = Number(r.gen);
29
33
  if (!Number.isFinite(gen) || !Number.isInteger(gen) || gen < 0) {
30
34
  throw new SessionPolicyError("corrupt", `session rules row ${where} carries lineage gen ${String(r.gen)} — a generation is a whole number ≥ 0; refusing rather than let a reader judge lineage by a value nothing minted`);
31
35
  }
@@ -92,6 +92,18 @@ export type JsonColumnReader<T> = (body: unknown) => JsonColumnShape<T>;
92
92
  * @param where 坐标文案(`<table>.<column> for <identity>`)。
93
93
  */
94
94
  export declare function decodeJsonColumn<T>(text: unknown, read: JsonColumnReader<T>, where: string): T;
95
+ /**
96
+ * **可空列**的解码(整仓唯一入口)—— 把「这一格是空的」与「这一行根本没有这个键」**分开**。
97
+ *
98
+ * 🔴 两者在 JS 里长得几乎一样,后果却相反(codex 对抗复审第二轮 [high],验真后按类改;这条 `undefined
99
+ * ≡ 缺席` 的读法是**本批之前就有的**,不是本批引入 —— 但属主一落地它就变得可以一次修干净):
100
+ * · `null` —— **SQL NULL**,两个驱动对空单元格都给它 ⇒ 这一格真的是空的(缺席),合法;
101
+ * · `undefined` —— 行上**没有这个键** ⇒ SELECT 漏投影了这一列(或别名漂了)。那是**读腿的缺陷**,
102
+ * 而把它读成「空」在 steer / 决议 / 执行记录这些列上等于**替一条读不出的行编一个「什么都没有」**:
103
+ * `setPendingSteer` 会在一条 rev 合法的 CAS 上把别人 park 的指令整段覆盖掉(实测复现过)。
104
+ * ⇒ `undefined` 一律落到 {@link decodeJsonColumn} 的 `not-text` 臂上,响亮抛、不折码、不被任何容忍臂吞。
105
+ */
106
+ export declare function decodeNullableJsonColumn<T>(cell: unknown, read: JsonColumnReader<T>, where: string): T | undefined;
95
107
  /**
96
108
  * 店层折码的**唯一规则**(`catch (e) { throw foldJsonColumn(e, (m) => new XError("corrupt", m)); }`)。
97
109
  *
@@ -100,11 +112,35 @@ export declare function decodeJsonColumn<T>(text: unknown, read: JsonColumnReade
100
112
  * 非本模块的错误原样透出(连接 / 超时 / 权限本来就该照原样上抛)。
101
113
  */
102
114
  export declare function foldJsonColumn(e: unknown, corrupt: (message: string) => Error): Error;
115
+ /**
116
+ * 折码的**容忍侧孪生**(与 {@link foldJsonColumn} 同一条判别,只写这一处):这一次失败是不是
117
+ * **数据体**的问题(`not-json` / `malformed`)。
118
+ *
119
+ * 🔴 用在那些**登记过的 F 类容忍面**上(设计 §2:容忍写在店的调用点,显式 `try/catch` +
120
+ * `recordFailOpen`)。`not-text` 与任何非本模块的错误(连接 / 超时 / 权限)在这里一律判**假** ⇒
121
+ * 调用点原样上抛:一条读腿漏了投影、或库连不上,绝不许被一条「这一格坏了,跳过它」的容忍臂吞掉 ——
122
+ * 那会让一次代码漂 / 一次故障伪装成「几行数据坏了」,而两者的处置完全不同。
123
+ */
124
+ export declare function isJsonColumnBodyProblem(e: unknown): e is SqlJsonColumnError;
103
125
  /**
104
126
  * 把一只**既有的 zod schema** 当形读者用(本仓多数店的形判据就是它自己那份 schema)。
105
127
  * `create*` 而不是 `build*`:产物是一只函数([ref] N2 命名律)。
106
128
  */
107
129
  export declare function createZodColumnReader<T>(schema: z.ZodType<T>): JsonColumnReader<T>;
130
+ /**
131
+ * **载荷形属主不在本仓 / 本层**的那一族列的形读者 —— 判「这一格是不是一只 JSON 对象」,然后把它当成
132
+ * 该店的域型交出去。整仓**只有这一处**做这一跳断言。
133
+ *
134
+ * 🔴 什么时候用它(硬条件,别扩大):这一列的键表属主在 **core**(`Checkpoint` / `ResumeOutcome` /
135
+ * `GateOutcome` / `WorkflowRun` / `BackgroundAgentRecord` …),而 core **没有导出验证器**。在 server 手写
136
+ * 一份等价 zod 就是设计稿明令禁止的**第二份键集**,它迟早与 core 分叉,而分叉那天是「写得进、读不出」。
137
+ * 一列只要有本仓自己的 schema 属主,就必须用 {@link createZodColumnReader} —— 用本函数等于把形判据关小。
138
+ *
139
+ * 🔴 为什么是一只工厂而不是让每个店各写一遍 `typeof body !== "object"`:那是同一条判据的 N 份拷贝
140
+ * (S-550 车② 初版真的写了六份),而且每一份都要自己那一跳 `as`。收成一处之后,判据只有一个写者,
141
+ * 断言也只发生在这一行 —— 类型卫生门因此在每个店里都读到零。
142
+ */
143
+ export declare function createObjectColumnReader<T extends object>(): JsonColumnReader<T>;
108
144
  /**
109
145
  * **自由载荷列**的最小形读者:只判「这是一只 JSON 对象」,不碰键集。
110
146
  *
@@ -125,4 +161,17 @@ export declare function isJsonObject(v: unknown): v is Record<string, unknown>;
125
161
  * 一列只要有结构,就**不许**用它 —— 那是把形判据静默关掉。
126
162
  */
127
163
  export declare const readAnyJsonValueColumn: JsonColumnReader<unknown>;
164
+ /**
165
+ * **任意 JSON 值,但缺席由「列 NULL」表达**的列的形读者 —— `null` 字面判坏,其余一律是值。
166
+ *
167
+ * 🔴 这是**写腿吃 `unknown` 的那一族列**的正解(codex 对抗复审 [high] 验真后定形,S-550 车②):
168
+ * 写腿接的是 `unknown`(`PendingAction.args` / 账本行载荷 / leader 结果……),于是**数组 / 串 / 数 /
169
+ * 布尔实参都写得进去** —— 拿 {@link readJsonObjectColumn} 判它们就是「写腿收得下、读腿读不出」,而且
170
+ * 后果往往不是一行坏:`rows.map` 里一条这样的合法行会让整只读面抛。
171
+ * 与 {@link readAnyJsonValueColumn} 的**唯一**区别是这一形:这族列的写腿把缺席(`undefined` / `null`)
172
+ * 统一落成 **SQL NULL**,所以列里的 `null` 字面按构造写不进来 —— 它只可能来自手改 / 迁移脚本,判坏。
173
+ * 用它之前先亲读写腿:缺席若**不是**落 SQL NULL(`approval_ask.updated_input` 的三态),用
174
+ * {@link readAnyJsonValueColumn}。
175
+ */
176
+ export declare const readNonNullJsonValueColumn: JsonColumnReader<unknown>;
128
177
  //# sourceMappingURL=sql-json-column.d.ts.map
@@ -45,20 +45,30 @@ export function decodeJsonColumn(text, read, where) {
45
45
  }
46
46
  return shape.value;
47
47
  }
48
+ export function decodeNullableJsonColumn(cell, read, where) {
49
+ return cell === null ? undefined : decodeJsonColumn(cell, read, where);
50
+ }
48
51
  export function foldJsonColumn(e, corrupt) {
49
52
  if (e instanceof SqlJsonColumnError && e.problem !== "not-text")
50
53
  return corrupt(e.message);
51
54
  return e instanceof Error ? e : new Error(String(e));
52
55
  }
56
+ export function isJsonColumnBodyProblem(e) {
57
+ return e instanceof SqlJsonColumnError && e.problem !== "not-text";
58
+ }
53
59
  export function createZodColumnReader(schema) {
54
60
  return (body) => {
55
61
  const parsed = schema.safeParse(body);
56
62
  return parsed.success ? { value: parsed.data } : { problem: parsed.error.message };
57
63
  };
58
64
  }
65
+ export function createObjectColumnReader() {
66
+ return (body) => (isJsonObject(body) ? { value: body } : { problem: "expected a JSON object" });
67
+ }
59
68
  export const readJsonObjectColumn = (body) => isJsonObject(body) ? { value: body } : { problem: "expected a JSON object" };
60
69
  export function isJsonObject(v) {
61
70
  return typeof v === "object" && v !== null && !Array.isArray(v);
62
71
  }
63
72
  export const readAnyJsonValueColumn = (body) => ({ value: body });
73
+ export const readNonNullJsonValueColumn = (body) => body === null ? { problem: "the JSON literal `null` — absence is SQL NULL on this column, so a written `null` can only be a hand edit" } : { value: body };
64
74
  //# sourceMappingURL=sql-json-column.js.map
@@ -3,6 +3,7 @@ import { summarizeWorkflowRun, isTerminalWorkflowStatus, WorkflowRunStoreError }
3
3
  import { foldKeyFamily, MAX_PENDING_PER_SESSION, PURGE_FENCE_MS, SERVED_FENCE_MS, } from "../orchestration/workflow-completion-inbox.js";
4
4
  import {} from "../orchestration/workflow-agent-session-index.js";
5
5
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
6
+ import { createObjectColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText } from "./sql-json-column.js";
6
7
  import { isDupKeyError } from "./sql-errors.js";
7
8
  export const MAX_RUN_BLOB_BYTES = 4 * 1024 * 1024;
8
9
  const SLIM_HEADER_MAX = 200;
@@ -39,6 +40,15 @@ export function slimOversizeRun(run, maxBytes) {
39
40
  blob = JSON.stringify(step3);
40
41
  return Buffer.byteLength(blob) <= maxBytes ? blob : null;
41
42
  }
43
+ const readWorkflowRunBody = createObjectColumnReader();
44
+ function readRunColumn(v, id) {
45
+ try {
46
+ return decodeJsonColumn(v, readWorkflowRunBody, `workflow_run.run for run ${id}`);
47
+ }
48
+ catch (e) {
49
+ throw foldJsonColumn(e, (m) => new Error(`SqlWorkflowRunStore: ${m} — refusing rather than resume a hollow run`));
50
+ }
51
+ }
42
52
  export class SqlWorkflowRunStore {
43
53
  db;
44
54
  onWarn;
@@ -62,11 +72,11 @@ export class SqlWorkflowRunStore {
62
72
  }
63
73
  }
64
74
  async get(id) {
65
- const { rows } = await this.db.query(this.q("SELECT run, rev FROM workflow_run WHERE id = ?", "SELECT run, rev FROM workflow_run WHERE id = $1"), [id]);
75
+ const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "run")}, rev FROM workflow_run WHERE id = ?`, `SELECT ${jsonColumnText("pg", "run")}, rev FROM workflow_run WHERE id = $1`), [id]);
66
76
  const r = rows[0];
67
77
  if (!r)
68
78
  return null;
69
- const run = JSON.parse(String(r.run));
79
+ const run = readRunColumn(r.run, id);
70
80
  run.rev = Number(r.rev);
71
81
  return run;
72
82
  }
@@ -119,9 +129,9 @@ export class SqlWorkflowRunStore {
119
129
  limit = ` LIMIT $${params.length}`;
120
130
  }
121
131
  }
122
- const { rows } = await this.db.query(`SELECT run, rev FROM workflow_run WHERE ${where} ORDER BY created_at_ms DESC, id DESC${limit}`, params);
132
+ const { rows } = await this.db.query(`SELECT id, ${jsonColumnText(this.db.dialect, "run")}, rev FROM workflow_run WHERE ${where} ORDER BY created_at_ms DESC, id DESC${limit}`, params);
123
133
  return rows.map((r) => {
124
- const run = JSON.parse(String(r.run));
134
+ const run = readRunColumn(r.run, String(r.id));
125
135
  run.rev = Number(r.rev);
126
136
  return summarizeWorkflowRun(run);
127
137
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.93.5",
3
+ "version": "7.93.6",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",