@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,3 +1,28 @@
1
+ /**
2
+ * Host-lane PLATFORM seam (DESIGN-windows-native.md, FINAL r3) — the ONE place the host exec adapter's
3
+ * POSIX/win32 differences live, so `remote-env-host.ts` stays a single code path with platform-gated leaves.
4
+ *
5
+ * 🔴 Iron invariant (design §4.1): the POSIX path is BYTE-IDENTICAL to the pre-Windows code — every helper is
6
+ * `win32 ? new : exactly-what-the-inline-code-did` (same syscall, same throw behavior). Never "improve" POSIX
7
+ * here; the existing full test suite is the regression net.
8
+ *
9
+ * win32 semantics (design D1-D4):
10
+ * - shell = Git Bash via core 1.224 `getShellConfig` (WSL-launcher-filtered — the `System32\bash.exe`
11
+ * trap). Fail-LOUD when absent; never silently degrade to cmd (D1).
12
+ * - kill = core 1.224 `signalProcessTree` (taskkill /T, /F for hard). Soft is a no-op for console trees
13
+ * (taskkill errors "can only be terminated forcefully") — the SIGTERM→grace→SIGKILL ladder is
14
+ * effectively delay→hard-kill on win32; accepted, CC-identical (D2).
15
+ * - spawn = `detached:false` + `windowsHide:true` (no console window; no POSIX process group — the kill
16
+ * side uses the tree, not the group) (D3).
17
+ * - env = case-insensitive key collapse before spawn (win32 env keys are case-insensitive; a `Path`+`PATH`
18
+ * pair from case-sensitive Object.assign reaches CreateProcess as ONE undefined-which
19
+ * entry). Canonical casing = the first-seen key (process.env's native casing wins since the
20
+ * inherit base is spread first).
21
+ *
22
+ * ⚠️ The win32 branches are UNVERIFIED on a real machine until S5 (Windows CI runner) — design D5 discipline:
23
+ * structural tests only on mac/Linux; behavior-level bite happens on the first Windows-runner green.
24
+ */
25
+ import type { ChildProcess } from "node:child_process";
1
26
  import { killProcessTree } from "@sema-agent/core";
2
27
  export declare const IS_WIN32: boolean;
3
28
  export interface HostShell {
@@ -48,15 +73,52 @@ export declare function killTreeSoft(pid: number): void;
48
73
  /** Graceful tree kill where the CALLER has no escalation timer of its own (currently unused by the host lane —
49
74
  * exported for parity with core's surface; the bg driver keeps its own HOST_BG_KILL_GRACE_MS ladder). */
50
75
  export { killProcessTree };
51
- /** 登记回执 = 注销的唯一凭据(纯数据;身份即凭据,见簿注)。 */
76
+ /**
77
+ * 登记回执 = 注销的**唯一**凭据(身份即凭据,见簿注)+ 一只幂等的注销把手。
78
+ *
79
+ * S-566 归一轮:注销**只有这一张脸**(`release()`)。此前另有一只 `untrackHostForegroundChild(ticket)`
80
+ * 自由函数,于是「把这只回执从簿上删掉」有两种写法 —— 零别名纪律下这属于歧义面,已**整个删除**
81
+ * (硬 breaking 三句:没有别名、没有过渡方法,调用方的 tsc 红就是通知)。
82
+ */
52
83
  export interface ForegroundChildTicket {
53
84
  /** 组领袖 pid = pgid(POSIX `detached:true` 的直接后果)。 */
54
85
  readonly pid: number;
86
+ /**
87
+ * 提前注销 —— 给各腿**特有**的兜底时点用(settle / `close` / 领养移交 / 生成器 `finally`)。
88
+ * 幂等(同一只回执 release 多少次都一样),且按**身份**删 ⇒ 迟到的那一次**绝不**碰内核复用同号之后
89
+ * 的新登记。主时点(`exit` / `error`)由 {@link adoptHostForegroundChild} 自己挂,调用点不必重复写。
90
+ */
91
+ release(): void;
55
92
  }
56
- /** 登记一只自起的前台子进程,返回注销用的回执。 */
93
+ /**
94
+ * 登记一只自起的前台子进程(**低层原语**:只有 pid 在手时用它 —— 产品码一律走
95
+ * {@link adoptHostForegroundChild},见那条形状钉)。
96
+ */
57
97
  export declare function trackHostForegroundChild(pid: number): ForegroundChildTicket;
58
- /** 注销(`exit` / settle / 领养移交)。按回执身份删 ⇒ 幂等,且**绝不**碰同号的其它登记。 */
59
- export declare function untrackHostForegroundChild(ticket: ForegroundChildTicket): void;
98
+ /**
99
+ * 🔴 **本簿在 `src/` 的唯一登记口**(S-566 归一轮)—— 一只自起的 detached 前台子进程「进簿 + 两只主注销」
100
+ * 的**全部**语义收在这里。
101
+ *
102
+ * ── 为什么是一只函数而不是五处同一句 ────────────────────────────────────────────────────────────
103
+ * 归一前,这一句在仓里**手抄五份**(`remote-env-host.ts` 三条 fg 腿 + `hook-runner.ts` 两条):
104
+ * 「`pid != null` 才登记」「`exit` 主注销」「`error` 之后 `exit` 可能不来,所以也要注销」——
105
+ * 每多一条 detached 腿就要再抄一遍,而**第六处只要漏抄一臂就是一个静默的孤儿洞**(S-566 的病正是
106
+ * hook 那两条腿整份没抄)。一条规则答完所有腿,规则集因此变小而不是变大。
107
+ *
108
+ * ── 语义(三条,逐条有理由)────────────────────────────────────────────────────────────────────
109
+ * · **无 pid ⇒ `undefined`,不登记**:spawn 起手失败那一族根本没有组领袖,簿上放一个 `undefined` 的坐标
110
+ * 等于给收割腿一条假线索;调用点因此拿到的是一个「可能不在」的把手,漏判会被类型挡住。
111
+ * · **`exit` = 主注销时点**(不是 `close` / settle):`close` 是**管道**驱动的,一个继承了 stdout 的孙进程
112
+ * 能把它按住任意久;而一条已退的 pid 留在簿里 = 给收割腿一个**过期坐标** —— 内核复用那个号之后
113
+ * `kill(-pid)` 打的是**别人**的组。
114
+ * · **`error` 也注销**:Node 契约里 `error` 之后 `exit` **可能不来**(异步起手失败),那一形没有第二次机会。
115
+ * 本函数只挂注销,**不**吞 `error` —— 五条腿各自的 `error` 处置(记账 / settle / 铸故障帧)原样在位,
116
+ * 本监听是第 N 只监听器,不改变「`error` 有没有人接」这件事(亲核:五处在归一前就都已有 `error` 监听)。
117
+ *
118
+ * 各腿**特有**的兜底(settle / `close` / 领养移交 / 生成器 `finally`)留在调用点,写成一行
119
+ * `ticket?.release()` —— 它们是**那条腿自己的**生命周期事实,不是本簿的语义。
120
+ */
121
+ export declare function adoptHostForegroundChild(child: ChildProcess): ForegroundChildTicket | undefined;
60
122
  /** 一次前台收割的读数。三格分开是**刻意**的:`alreadyDead` 是想要的结果(不是失败),`failed` 是**真的**
61
123
  * 没收上来 —— 两者混成一个数就等于把后者静默掉([ref]:这条腿上不许有安静的兜底)。
62
124
  *
@@ -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.7",
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",