@sema-agent/server 7.93.4 → 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.
Files changed (38) hide show
  1. package/dist/bake-runner/main.d.ts +28 -1
  2. package/dist/bake-runner/main.js +122 -38
  3. package/dist/bake-runner/runner.d.ts +85 -7
  4. package/dist/bake-runner/runner.js +116 -27
  5. package/dist/boot/exit-boundary.d.ts +22 -0
  6. package/dist/boot/exit-boundary.js +73 -0
  7. package/dist/boot/shutdown.d.ts +2 -2
  8. package/dist/boot/shutdown.js +5 -62
  9. package/dist/hooks/hook-runner.js +5 -1
  10. package/dist/observability/fail-open.d.ts +16 -0
  11. package/dist/observability/fail-open.js +16 -0
  12. package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
  13. package/dist/plugins/approval-ask-store-sql.js +36 -19
  14. package/dist/plugins/background-agent-store-sql.js +36 -16
  15. package/dist/plugins/checkpoint-store-sql.d.ts +5 -1
  16. package/dist/plugins/checkpoint-store-sql.js +111 -66
  17. package/dist/plugins/device-store-sql.d.ts +10 -0
  18. package/dist/plugins/device-store-sql.js +12 -6
  19. package/dist/plugins/host-platform.d.ts +70 -6
  20. package/dist/plugins/host-platform.js +14 -3
  21. package/dist/plugins/leader-run-store-sql.js +16 -6
  22. package/dist/plugins/one-time-migrations.d.ts +3 -0
  23. package/dist/plugins/one-time-migrations.js +16 -4
  24. package/dist/plugins/permission-rule-store-sql.d.ts +1 -0
  25. package/dist/plugins/permission-rule-store-sql.js +37 -28
  26. package/dist/plugins/remote-env-host.js +9 -21
  27. package/dist/plugins/run-store-sql.d.ts +1 -1
  28. package/dist/plugins/run-store-sql.js +41 -10
  29. package/dist/plugins/session-policy-store-sql.d.ts +24 -0
  30. package/dist/plugins/session-policy-store-sql.js +17 -18
  31. package/dist/plugins/sql-json-column.d.ts +177 -0
  32. package/dist/plugins/sql-json-column.js +74 -0
  33. package/dist/plugins/task-list-store-sql.d.ts +13 -1
  34. package/dist/plugins/task-list-store-sql.js +23 -10
  35. package/dist/plugins/workflow-run-store-sql.js +14 -4
  36. package/dist/run-local.d.ts +15 -0
  37. package/dist/run-local.js +32 -0
  38. package/package.json +1 -1
@@ -0,0 +1,177 @@
1
+ /**
2
+ * **SQL JSON 列读腿的唯一属主**(S-550 设计稿 §2)—— 一列 JSON 怎么**投影**、怎么**恰好解一次码**、
3
+ * 读不出形怎么**拒**,整仓只有这一份定义。
4
+ *
5
+ * ── 病(KM / 7.92.2 C1 的真案,三库实测两次换形之后的终形)────────────────────────────────────
6
+ * 一条 JSON 列的值到 JS 手里时,**从值本身分不出「驱动解过了」还是「没解过」**:
7
+ * · 「体是什么就直接判形」——**错**:MariaDB(README 声明支持的 MySQL 协议后端之一)的 `JSON` 是
8
+ * `LONGTEXT` 的**别名**,驱动只对真 JSON 传输类型自动解码 ⇒ 那台机器上每一条**合法**行都以文本
9
+ * 到达,会被判成坏体(一台好机器变砖);
10
+ * · 「体是串就再解一次」——**也错**:真 `JSON`/`JSONB` 列上驱动已经解过一次,于是一行体为 `"{}"`
11
+ * (迁移脚本多调了一次 `JSON.stringify` 的典型产物)会被解成 `{}` 当成**空体**端出去 —— 在授权面上
12
+ * 那就是「运行中途放宽、零披露」。
13
+ * ⇒ 让歧义在**构造上**不存在:读腿把 JSON 列**按文本**投影({@link jsonColumnText}),到 JS 恒是一只串,
14
+ * {@link decodeJsonColumn} **恰好解一次**。JSON 列里的字符串标量按文本取出**带着引号** ⇒ 解一次仍是串
15
+ * ⇒ 形读者判坏;文本承载(LONGTEXT / TEXT)取出即自身 ⇒ 解一次得对象 ⇒ 好体。
16
+ * **一条规则,两种承载,零方言分叉,不依赖驱动行为。**
17
+ *
18
+ * ── 失败方向 = 拒(fail-closed),全店同向 ────────────────────────────────────────────────────
19
+ * `decodeJsonColumnLenient` **不存在**,而且不会有:容忍必须写在店的调用点上(显式 `try/catch` +
20
+ * `recordFailOpen`),让每一处容忍都留痕、都可数。本模块只有一个失败方向。
21
+ *
22
+ * ── 机器门 ──────────────────────────────────────────────────────────────────────────────────
23
+ * `test/sql-json-column-single-owner.test.ts` 三道:①`JSON.parse(` 在 `src/plugins/**` 只许出现在本文件
24
+ * (未改调的店走显式 `PENDING_STORES` 登记 + 反向门);②JSON 承载列(清单由 `schema-ensure-legs` 的录制
25
+ * 驱动**机器派生**,不手抄)在 SELECT 投影里必须经 {@link jsonColumnText};③{@link decodeJsonColumn} 的
26
+ * `read` 实参必须是具名读者,禁内联 `(b) => b as T`。
27
+ */
28
+ import { z } from "zod";
29
+ import type { SqlDialect } from "./sql-driver.js";
30
+ /**
31
+ * 一个 JSON 承载列的 **SELECT 投影片段**(整仓唯一铸点)—— MySQL 协议 `CAST(col AS CHAR) AS col`,
32
+ * PostgreSQL `col::text AS col`。
33
+ *
34
+ * 🔴 **别名是产物的一部分,不是调用点的自由**:MySQL 协议下 `CAST(rules AS CHAR)` 的回报字段名是
35
+ * `CAST(rules AS CHAR)` 而不是 `rules` —— 把别名留给调用点写,就等于给每一处读腿留一个「忘了写别名,
36
+ * 行上于是没有这个键、读回恒 `undefined`」的静默洞。别名在这里一次钉死,行的键恒等于列名。
37
+ *
38
+ * 🔴 **`CAST` 不影响索引**:投影不在 `WHERE` 里;大体量列多一次文本拷贝,量级与现状同(驱动本来也解一次)。
39
+ */
40
+ export declare function jsonColumnText(dialect: SqlDialect, column: string): string;
41
+ /**
42
+ * 同一只投影的**裸表达式**(不带别名)—— 给 `WHERE` / `UPDATE … WHERE` 这种**没有别名位**的地方用。
43
+ *
44
+ * 🔴 存在的唯一理由是「别名位在那里不合法」,**不是**给读腿留第二条路:读腿一律用 {@link jsonColumnText}
45
+ * (别名是它的产物的一部分,理由见那只函数)。今天整仓只有一处消费方 —— `one-time-migrations.ts` 的
46
+ * M4(把 `device_audit.detail` 里 JSON 文本 `null` 的旧行改成列 NULL),它要在 `WHERE` 里判这一列的**文本**,
47
+ * 而「怎么把一列 JSON 取成文本」这件事整仓只能有一种写法:两种承载(真 JSON 列 / LONGTEXT 别名)在这条
48
+ * 表达式下合成同一只串,迁移的判等因此与读腿的判形**看同一份字节**。
49
+ */
50
+ export declare function jsonColumnTextExpr(dialect: SqlDialect, column: string): string;
51
+ /**
52
+ * 一次读回失败的**种别**(店层码,闭集):
53
+ * · `not-text` —— 拿到的不是串 = **读腿漏了投影**,那是**码的问题不是数据的问题** ⇒ 响亮的程序错,
54
+ * 绝不冒充「你的库坏了」({@link foldJsonColumn} 因此**不折**这一种);
55
+ * · `not-json` —— 解不出 JSON(撕裂写 / 手改 / 非 JSON 字节);
56
+ * · `malformed` —— 解出来了,但该店的形读者不认(`null` 字面 / 字符串标量 / 双重编码 / 缺键错型)。
57
+ */
58
+ export type SqlJsonColumnProblem = "not-text" | "not-json" | "malformed";
59
+ /**
60
+ * 店层码,**不上 wire**:各店在自己的错误映射里把它折成该店既有的 corrupt 类码(见 {@link foldJsonColumn})。
61
+ * 终局对外码经 7.92.2 的词法门,永不裸穿。
62
+ */
63
+ export declare class SqlJsonColumnError extends Error {
64
+ readonly problem: SqlJsonColumnProblem;
65
+ /** 出事的坐标(`<table>.<column> for <identity>`)—— 进错误文案,让运维知道去看哪一行。 */
66
+ readonly where: string;
67
+ constructor(problem: SqlJsonColumnProblem, where: string, detail: string);
68
+ }
69
+ /** 形读者认了:带上收窄后的值。 */
70
+ export interface JsonColumnShapeOk<T> {
71
+ readonly value: T;
72
+ readonly problem?: undefined;
73
+ }
74
+ /** 形读者不认:`problem` 是给人读的一句话(进 corrupt 文案)。 */
75
+ export interface JsonColumnShapeBad {
76
+ readonly value?: undefined;
77
+ readonly problem: string;
78
+ }
79
+ /** {@link JsonColumnReader} 的答复。 */
80
+ export type JsonColumnShape<T> = JsonColumnShapeOk<T> | JsonColumnShapeBad;
81
+ /**
82
+ * 一只**形读者** = 「解出来的体 → 这个店认不认」。判据必须是该店**既有**的那一只(core 导出的验证器 /
83
+ * 本店的 zod schema / wire 面的单一属主读者),**不新造第二份键集** —— 两份键集迟早分叉,而分叉的那一天
84
+ * 是「写得进、读不出」。
85
+ */
86
+ export type JsonColumnReader<T> = (body: unknown) => JsonColumnShape<T>;
87
+ /**
88
+ * **按文本取出的一列 JSON → 该店认得的值**(整仓唯一解码点)。
89
+ *
90
+ * @param text 该列**按 {@link jsonColumnText} 投影**后的单元格。恒是串;不是串 ⇒ `not-text`(程序错)。
91
+ * @param read 该店**既有的**形读者(见 {@link JsonColumnReader})。
92
+ * @param where 坐标文案(`<table>.<column> for <identity>`)。
93
+ */
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;
107
+ /**
108
+ * 店层折码的**唯一规则**(`catch (e) { throw foldJsonColumn(e, (m) => new XError("corrupt", m)); }`)。
109
+ *
110
+ * 🔴 `not-text` **不折**:它是读腿自己的缺陷(投影漏了),折成「这一行坏了」会把一次代码漂伪装成一次
111
+ * 数据事故 —— 这条判别只写在这里一处,五个店不各写一遍(各写一遍的下场是某天有一处把程序错也折了)。
112
+ * 非本模块的错误原样透出(连接 / 超时 / 权限本来就该照原样上抛)。
113
+ */
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;
125
+ /**
126
+ * 把一只**既有的 zod schema** 当形读者用(本仓多数店的形判据就是它自己那份 schema)。
127
+ * `create*` 而不是 `build*`:产物是一只函数([ref] N2 命名律)。
128
+ */
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>;
144
+ /**
145
+ * **自由载荷列**的最小形读者:只判「这是一只 JSON 对象」,不碰键集。
146
+ *
147
+ * 给那些**schema 属主不在本仓 / 不在本层**的列用(approval 的 `card_json`、device 的审计 `detail`):
148
+ * 持久层对它们保持透明字节存取语义,但「是不是一只对象」这一条仍然判 —— 那正是 `null` 字面 / 字符串
149
+ * 标量 / 双重编码三形的落点。**不是**不判形的借口:一列有结构属主的,必须用属主那只读者。
150
+ */
151
+ export declare const readJsonObjectColumn: JsonColumnReader<Record<string, unknown>>;
152
+ /** 「一只普通 JSON 对象」的收窄谓词(`null` / 数组 / 标量都不是)。谓词而非 `as`:边界铸形门 [ref] §2。 */
153
+ export declare function isJsonObject(v: unknown): v is Record<string, unknown>;
154
+ /**
155
+ * **任意 JSON 值**的形读者 —— 只把 `not-json` 挡下来,`null` / 标量 / 数组一律是**合法的值**。
156
+ *
157
+ * 🔴 用它的列必须满足一个硬条件:**它的 wire 契约本身就是「任意 JSON 值」,而且「值是 `null`」与
158
+ * 「这一格缺席」在该列上是两件不同的事、且由**列的 NULL** 区分。今天整仓只有一处符合:
159
+ * `approval_ask.updated_input`([ref] / [ref] 的三态读法 —— 列 NULL = 没带编辑,JSON 文本 `"null"` =
160
+ * **编辑成 null**;把 `"null"` 判成坏体会把一条合法的人类编辑抹掉)。
161
+ * 一列只要有结构,就**不许**用它 —— 那是把形判据静默关掉。
162
+ */
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>;
177
+ //# sourceMappingURL=sql-json-column.d.ts.map
@@ -0,0 +1,74 @@
1
+ import { z } from "zod";
2
+ const COLUMN_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+ export function jsonColumnText(dialect, column) {
4
+ return `${jsonColumnTextExpr(dialect, column)} AS ${column}`;
5
+ }
6
+ export function jsonColumnTextExpr(dialect, column) {
7
+ if (!COLUMN_NAME_RE.test(column)) {
8
+ throw new Error(`jsonColumnTextExpr: ${JSON.stringify(column)} is not a bare column identifier — refusing to splice it into SQL text`);
9
+ }
10
+ return dialect === "pg" ? `${column}::text` : `CAST(${column} AS CHAR)`;
11
+ }
12
+ export class SqlJsonColumnError extends Error {
13
+ problem;
14
+ where;
15
+ constructor(problem, where, detail) {
16
+ super(`${where}: ${detail}`);
17
+ this.name = "SqlJsonColumnError";
18
+ this.problem = problem;
19
+ this.where = where;
20
+ }
21
+ }
22
+ function describeBody(body) {
23
+ if (body === null)
24
+ return "the JSON literal `null`";
25
+ if (Array.isArray(body))
26
+ return "a JSON array";
27
+ if (typeof body === "string")
28
+ return `a JSON string scalar (${JSON.stringify(body.slice(0, 64))}) — the column was written pre-encoded, or double-encoded`;
29
+ return `a JSON ${typeof body}`;
30
+ }
31
+ export function decodeJsonColumn(text, read, where) {
32
+ if (typeof text !== "string") {
33
+ throw new SqlJsonColumnError("not-text", where, `the SELECT did not project this column as TEXT (got ${text === null ? "null" : typeof text}) — the read leg and decodeJsonColumn have drifted apart`);
34
+ }
35
+ let body;
36
+ try {
37
+ body = JSON.parse(text);
38
+ }
39
+ catch (err) {
40
+ throw new SqlJsonColumnError("not-json", where, `carries bytes that are not JSON (${err instanceof Error ? err.message : String(err)}) — refusing rather than serve a row nobody can read`);
41
+ }
42
+ const shape = read(body);
43
+ if (shape.problem !== undefined) {
44
+ throw new SqlJsonColumnError("malformed", where, `carries ${describeBody(body)} that this store cannot read (${shape.problem}) — refusing rather than serve it as an empty/partial value`);
45
+ }
46
+ return shape.value;
47
+ }
48
+ export function decodeNullableJsonColumn(cell, read, where) {
49
+ return cell === null ? undefined : decodeJsonColumn(cell, read, where);
50
+ }
51
+ export function foldJsonColumn(e, corrupt) {
52
+ if (e instanceof SqlJsonColumnError && e.problem !== "not-text")
53
+ return corrupt(e.message);
54
+ return e instanceof Error ? e : new Error(String(e));
55
+ }
56
+ export function isJsonColumnBodyProblem(e) {
57
+ return e instanceof SqlJsonColumnError && e.problem !== "not-text";
58
+ }
59
+ export function createZodColumnReader(schema) {
60
+ return (body) => {
61
+ const parsed = schema.safeParse(body);
62
+ return parsed.success ? { value: parsed.data } : { problem: parsed.error.message };
63
+ };
64
+ }
65
+ export function createObjectColumnReader() {
66
+ return (body) => (isJsonObject(body) ? { value: body } : { problem: "expected a JSON object" });
67
+ }
68
+ export const readJsonObjectColumn = (body) => isJsonObject(body) ? { value: body } : { problem: "expected a JSON object" };
69
+ export function isJsonObject(v) {
70
+ return typeof v === "object" && v !== null && !Array.isArray(v);
71
+ }
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 };
74
+ //# sourceMappingURL=sql-json-column.js.map
@@ -1,6 +1,7 @@
1
1
  import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
- import { type TaskListStore } from "@sema-agent/core";
3
+ import { type TaskListItem, type TaskListStore } from "@sema-agent/core";
4
+ import { type JsonColumnShape } from "./sql-json-column.js";
4
5
  export declare const TASK_LIST_META_TABLE = "task_list_meta";
5
6
  export declare const TASK_LIST_ITEM_TABLE = "task_list_item";
6
7
  /**
@@ -25,6 +26,17 @@ export declare const TASK_LIST_ITEM_TABLE = "task_list_item";
25
26
  * 批量删本表的行,两个消费点必须读同一个函数 —— 键派生有两份就是「在线删除删得掉、留存腿删不掉」。
26
27
  */
27
28
  export declare function taskListKeyFor(sessionId: string): string;
29
+ /**
30
+ * `task_list_item.item_json` 的**形读者**(S-550)。
31
+ *
32
+ * 🔴 判据刻意只到「一只 JSON 对象 + 一把串 id」为止,**不复刻 core 的 `TaskListItem` 键表**:那张表的
33
+ * 属主在 core(`tools/task-list.d.ts`),而 core 今天没有导出验证器 —— 在这里手写一份等价 schema 就是
34
+ * 设计稿明令禁止的「第二份键集」,它迟早与 core 分叉,而分叉那天是「写得进、读不出」。
35
+ * 这一条最小判据已经把四形坏体全关在门外(`null` 字面 / JSON 字符串标量 / 双重编码 / 非 JSON),
36
+ * 而本店此前是**零判据**(裸 `JSON.parse` + `as TaskListItem`):一行 `"null"` 会被当成一条 item 端给模型。
37
+ * core 哪天导出了验证器,这里换成它、判据只增不减。
38
+ */
39
+ export declare function readTaskListItemBody(body: unknown): JsonColumnShape<TaskListItem>;
28
40
  export declare function ensureTiDBTaskListSchema(pool: MySqlPool): Promise<void>;
29
41
  export declare function ensurePgTaskListSchema(q: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
30
42
  /**
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { assertJsonMetadata } from "@sema-agent/core";
3
3
  import { pgHasUnstorable, pgSafeJsonStringify, PgUnstorableError } from "./pg-safe-json.js";
4
+ import { decodeJsonColumn, foldJsonColumn, jsonColumnText } from "./sql-json-column.js";
4
5
  export const TASK_LIST_META_TABLE = "task_list_meta";
5
6
  export const TASK_LIST_ITEM_TABLE = "task_list_item";
6
7
  export function taskListKeyFor(sessionId) {
@@ -14,8 +15,20 @@ function arrayIndexOf(id) {
14
15
  const n = Number(id);
15
16
  return Number.isSafeInteger(n) && n < 2 ** 32 - 1 ? n : null;
16
17
  }
17
- function parseItem(v) {
18
- return JSON.parse(String(v));
18
+ export function readTaskListItemBody(body) {
19
+ if (typeof body !== "object" || body === null || Array.isArray(body))
20
+ return { problem: "expected a JSON object" };
21
+ if (typeof body.id !== "string")
22
+ return { problem: "the item carries no string `id`" };
23
+ return { value: body };
24
+ }
25
+ function parseItem(v, where) {
26
+ try {
27
+ return decodeJsonColumn(v, readTaskListItemBody, `${TASK_LIST_ITEM_TABLE}.item_json ${where}`);
28
+ }
29
+ catch (e) {
30
+ throw foldJsonColumn(e, (m) => new Error(`TaskListStore: ${m} — refusing rather than hand the model a row nobody can read as a task`));
31
+ }
19
32
  }
20
33
  function assertListKeyBytes(listKey) {
21
34
  if (Buffer.byteLength(listKey, "utf8") > 190) {
@@ -147,8 +160,8 @@ export function createTiDBTaskListStore(pool, listKey) {
147
160
  };
148
161
  const build = (q, inTx) => ({
149
162
  get: async (id) => {
150
- const [rows] = (await q.query(`SELECT item_json FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = ? AND id = ?${inTx ? " FOR UPDATE" : ""}`, [listKey, id]));
151
- return rows.length > 0 ? parseItem(rows[0].item_json) : undefined;
163
+ const [rows] = (await q.query(`SELECT ${jsonColumnText("tidb", "item_json")} FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = ? AND id = ?${inTx ? " FOR UPDATE" : ""}`, [listKey, id]));
164
+ return rows.length > 0 ? parseItem(rows[0].item_json, `for list ${listKey} item ${JSON.stringify(id)}`) : undefined;
152
165
  },
153
166
  set: async (id, item) => {
154
167
  if (item.metadata)
@@ -161,8 +174,8 @@ export function createTiDBTaskListStore(pool, listKey) {
161
174
  return res.affectedRows === 1;
162
175
  },
163
176
  list: async () => {
164
- const [rows] = (await q.query(`SELECT item_json FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = ? ORDER BY id_num IS NULL, id_num, sort_seq${inTx ? " FOR UPDATE" : ""}`, [listKey]));
165
- return rows.map((r) => parseItem(r.item_json));
177
+ const [rows] = (await q.query(`SELECT ${jsonColumnText("tidb", "item_json")} FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = ? ORDER BY id_num IS NULL, id_num, sort_seq${inTx ? " FOR UPDATE" : ""}`, [listKey]));
178
+ return rows.map((r) => parseItem(r.item_json, `for list ${listKey} (enumeration)`));
166
179
  },
167
180
  allocateId: async () => (inTx ? allocateOn(execOn(q), "tidb", listKey) : tx((c) => allocateOn(execOn(c), "tidb", listKey))),
168
181
  ...(inTx
@@ -203,8 +216,8 @@ export function createPgTaskListStore(pool, listKey) {
203
216
  };
204
217
  const build = (q, inTx) => ({
205
218
  get: async (id) => {
206
- const { rows } = await q.query(`SELECT item_json FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = $1 AND id = $2${inTx ? " FOR UPDATE" : ""}`, [listKey, id]);
207
- return rows.length > 0 ? parseItem(rows[0].item_json) : undefined;
219
+ const { rows } = await q.query(`SELECT ${jsonColumnText("pg", "item_json")} FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = $1 AND id = $2${inTx ? " FOR UPDATE" : ""}`, [listKey, id]);
220
+ return rows.length > 0 ? parseItem(rows[0].item_json, `for list ${listKey} item ${JSON.stringify(id)}`) : undefined;
208
221
  },
209
222
  set: async (id, item) => {
210
223
  if (pgHasUnstorable(id) ||
@@ -224,8 +237,8 @@ export function createPgTaskListStore(pool, listKey) {
224
237
  return (res.rowCount ?? 0) === 1;
225
238
  },
226
239
  list: async () => {
227
- const { rows } = await q.query(`SELECT item_json FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = $1 ORDER BY (id_num IS NULL), id_num, sort_seq${inTx ? " FOR UPDATE" : ""}`, [listKey]);
228
- return rows.map((r) => parseItem(r.item_json));
240
+ const { rows } = await q.query(`SELECT ${jsonColumnText("pg", "item_json")} FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = $1 ORDER BY (id_num IS NULL), id_num, sort_seq${inTx ? " FOR UPDATE" : ""}`, [listKey]);
241
+ return rows.map((r) => parseItem(r.item_json, `for list ${listKey} (enumeration)`));
229
242
  },
230
243
  allocateId: async () => (inTx ? allocateOn(execOn(q), "pg", listKey) : tx((c) => allocateOn(execOn(c), "pg", listKey))),
231
244
  ...(inTx
@@ -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
  });
@@ -136,4 +136,19 @@ export declare function promptYesNoOnTty(prompt: string, signal?: AbortSignal, s
136
136
  * `process.exit` so a test can assert the code; `main()` applies it.
137
137
  */
138
138
  export declare function runLocal(argv: string[], deps?: RunLocalDeps): Promise<number>;
139
+ /** {@link installRunLocalExitBoundary} 的把手(同进程内反复装配的调用方=测试必须用它收尾)。 */
140
+ export interface RunLocalExitBoundary {
141
+ /** 摘掉本次装配的两只信号处理器与退出边界钩。幂等。 */
142
+ dispose(): void;
143
+ }
144
+ /**
145
+ * 装上本进程的退出边界 + 两只信号处理器。理由与取舍逐字见上方注。
146
+ *
147
+ * `exit` 可注入**只**为让测试把「处理器真的走到了哪个码」量出来(生产恒 `process.exit`);它不是旋钮,
148
+ * 没有配置面。
149
+ */
150
+ export declare function installRunLocalExitBoundary(deps: {
151
+ logger: Logger;
152
+ exit?: (code: number) => void;
153
+ }): RunLocalExitBoundary;
139
154
  //# sourceMappingURL=run-local.d.ts.map
package/dist/run-local.js CHANGED
@@ -44,6 +44,7 @@ import { createPermissionDeniedMeter } from "./observability/tool-trace.js";
44
44
  import { createKeyResolver } from "./key-resolver.js";
45
45
  import { createLogger } from "./observability/logger.js";
46
46
  import { fatalExit } from "./boot/fatal-exit.js";
47
+ import { installProcessExitBoundary } from "./boot/exit-boundary.js";
47
48
  import { createMetrics } from "./observability/metrics.js";
48
49
  import { installSecretEnvScrubRecorder } from "./observability/secret-env-scrub.js";
49
50
  import { buildDisplaySafeUrl } from "./trace/redact.js";
@@ -561,7 +562,38 @@ export async function runLocal(argv, deps = {}) {
561
562
  await fileBackend.dispose();
562
563
  return exitCode;
563
564
  }
565
+ const RUN_LOCAL_SIGNAL_EXIT_CODE = { SIGINT: 130, SIGTERM: 143 };
566
+ export function installRunLocalExitBoundary(deps) {
567
+ const { logger } = deps;
568
+ const exit = deps.exit ?? ((code) => void process.exit(code));
569
+ const boundary = installProcessExitBoundary({ logger });
570
+ const onSignal = (signal) => {
571
+ const code = RUN_LOCAL_SIGNAL_EXIT_CODE[signal];
572
+ try {
573
+ logger.warn("run_local_signal_exit", { signal, code });
574
+ }
575
+ finally {
576
+ exit(code);
577
+ }
578
+ };
579
+ const onSigint = () => onSignal("SIGINT");
580
+ const onSigterm = () => onSignal("SIGTERM");
581
+ process.on("SIGINT", onSigint);
582
+ process.on("SIGTERM", onSigterm);
583
+ let disposed = false;
584
+ return {
585
+ dispose: () => {
586
+ if (disposed)
587
+ return;
588
+ disposed = true;
589
+ process.removeListener("SIGINT", onSigint);
590
+ process.removeListener("SIGTERM", onSigterm);
591
+ boundary.dispose();
592
+ },
593
+ };
594
+ }
564
595
  async function main() {
596
+ installRunLocalExitBoundary({ logger: createLogger(process.env.LOG_LEVEL) });
565
597
  const code = await runLocal(process.argv.slice(2));
566
598
  process.exit(code);
567
599
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.93.4",
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",