@sema-agent/server 7.93.3 → 7.93.5
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.
- package/dist/boot/exit-boundary.d.ts +22 -0
- package/dist/boot/exit-boundary.js +73 -0
- package/dist/boot/shutdown.d.ts +2 -2
- package/dist/boot/shutdown.js +5 -62
- package/dist/host-decision.d.ts +28 -1
- package/dist/http/routes/runs.js +5 -5
- package/dist/plugins/approval-ask-store-memory.js +5 -2
- package/dist/plugins/approval-ask-store-sql.d.ts +57 -1
- package/dist/plugins/approval-ask-store-sql.js +64 -23
- package/dist/plugins/device-store-sql.d.ts +10 -0
- package/dist/plugins/device-store-sql.js +12 -6
- package/dist/plugins/host-platform.d.ts +4 -2
- package/dist/plugins/one-time-migrations.d.ts +3 -0
- package/dist/plugins/one-time-migrations.js +16 -4
- package/dist/plugins/permission-rule-store-sql.d.ts +1 -0
- package/dist/plugins/permission-rule-store-sql.js +37 -28
- package/dist/plugins/session-policy-store-sql.d.ts +24 -0
- package/dist/plugins/session-policy-store-sql.js +12 -17
- package/dist/plugins/sql-json-column.d.ts +128 -0
- package/dist/plugins/sql-json-column.js +64 -0
- package/dist/plugins/task-list-store-sql.d.ts +13 -1
- package/dist/plugins/task-list-store-sql.js +23 -10
- package/dist/run-local.d.ts +15 -0
- package/dist/run-local.js +32 -0
- package/dist/tool-approval.d.ts +30 -24
- package/dist/tool-approval.js +57 -44
- package/package.json +2 -2
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { loosenReasons, SessionPolicyError, normalizeRules, stripRev } from "@sema-agent/core";
|
|
3
3
|
import { readSessionPermissionRules, describeSessionPermissionRulesProblem } from "../session-policy-wire.js";
|
|
4
|
+
import { decodeJsonColumn, foldJsonColumn, jsonColumnText } from "./sql-json-column.js";
|
|
4
5
|
import { pgHasUnstorable } from "./pg-safe-json.js";
|
|
5
6
|
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
6
7
|
import { isDeadlockError, isDupKeyError } from "./sql-errors.js";
|
|
7
8
|
export const policyKeyFor = (sessionId, principal) => createHash("sha256").update(JSON.stringify([sessionId, principal ?? null])).digest("hex");
|
|
8
9
|
const policyKey = policyKeyFor;
|
|
9
10
|
const RETRY_LIMIT = 8;
|
|
11
|
+
export function readSessionRulesBody(body) {
|
|
12
|
+
const read = readSessionPermissionRules(body);
|
|
13
|
+
return read.problem !== undefined ? { problem: describeSessionPermissionRulesProblem(read.problem) } : { value: read.rules };
|
|
14
|
+
}
|
|
10
15
|
function readRulesBody(text, where) {
|
|
11
|
-
let body;
|
|
12
16
|
try {
|
|
13
|
-
|
|
17
|
+
return decodeJsonColumn(text, readSessionRulesBody, where);
|
|
14
18
|
}
|
|
15
|
-
catch {
|
|
16
|
-
throw new SessionPolicyError("corrupt",
|
|
19
|
+
catch (e) {
|
|
20
|
+
throw foldJsonColumn(e, (m) => new SessionPolicyError("corrupt", `${m} — an unreadable rules body served as an EMPTY rule set would silently drop every tightening this session carries`));
|
|
17
21
|
}
|
|
18
|
-
const read = readSessionPermissionRules(body);
|
|
19
|
-
if (read.problem !== undefined) {
|
|
20
|
-
throw new SessionPolicyError("corrupt", `session rules row ${where} carries a malformed rules body (${describeSessionPermissionRulesProblem(read.problem)}) — refusing rather than serve it as an EMPTY rule set, which would silently drop every tightening this session carries`);
|
|
21
|
-
}
|
|
22
|
-
return read.rules;
|
|
23
22
|
}
|
|
24
23
|
function rowToStored(r, where) {
|
|
25
24
|
const rev = Number(r.rev);
|
|
@@ -30,11 +29,7 @@ function rowToStored(r, where) {
|
|
|
30
29
|
if (!Number.isFinite(gen) || !Number.isInteger(gen) || gen < 0) {
|
|
31
30
|
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`);
|
|
32
31
|
}
|
|
33
|
-
|
|
34
|
-
if (typeof raw !== "string") {
|
|
35
|
-
throw new Error(`session rules row ${where}: the read did not project the rules column as TEXT (got ${typeof raw}) — the SELECT and rowToStored have drifted apart`);
|
|
36
|
-
}
|
|
37
|
-
return { ...readRulesBody(raw, where), rev, gen };
|
|
32
|
+
return { ...readRulesBody(r.rules, `session_policy.rules ${where}`), rev, gen };
|
|
38
33
|
}
|
|
39
34
|
export class SqlSessionPolicyStore {
|
|
40
35
|
db;
|
|
@@ -48,13 +43,13 @@ export class SqlSessionPolicyStore {
|
|
|
48
43
|
return isDupKeyError(this.db.dialect, e);
|
|
49
44
|
}
|
|
50
45
|
async getRules(sessionId, principal) {
|
|
51
|
-
const { rows } = await this.db.query(this.q(
|
|
46
|
+
const { rows } = await this.db.query(this.q(`SELECT ${jsonColumnText("tidb", "rules")}, rev, gen FROM session_policy WHERE policy_key = ?`, `SELECT ${jsonColumnText("pg", "rules")}, rev, gen FROM session_policy WHERE policy_key = $1`), [policyKey(sessionId, principal)]);
|
|
52
47
|
if (!rows[0])
|
|
53
48
|
return null;
|
|
54
49
|
return rowToStored(rows[0], `for session ${JSON.stringify(sessionId)}`);
|
|
55
50
|
}
|
|
56
51
|
async listBySession(sessionId) {
|
|
57
|
-
const { rows } = await this.db.query(this.q(
|
|
52
|
+
const { rows } = await this.db.query(this.q(`SELECT principal, ${jsonColumnText("tidb", "rules")}, rev, gen FROM session_policy WHERE session_id = ?`, `SELECT principal, ${jsonColumnText("pg", "rules")}, rev, gen FROM session_policy WHERE session_id = $1`), [sessionId]);
|
|
58
53
|
return rows.map((r) => ({
|
|
59
54
|
principal: r.principal == null ? undefined : String(r.principal),
|
|
60
55
|
rules: rowToStored(r, `for session ${JSON.stringify(sessionId)} (enumeration)`),
|
|
@@ -63,7 +58,7 @@ export class SqlSessionPolicyStore {
|
|
|
63
58
|
async putRules(sessionId, principal, rules, opts) {
|
|
64
59
|
const key = policyKey(sessionId, principal);
|
|
65
60
|
return this.retrying(() => this.tx(async (conn) => {
|
|
66
|
-
const { rows } = await conn.query(this.q(
|
|
61
|
+
const { rows } = await conn.query(this.q(`SELECT ${jsonColumnText("tidb", "rules")}, rev, gen FROM session_policy WHERE policy_key = ? FOR UPDATE`, `SELECT ${jsonColumnText("pg", "rules")}, rev, gen FROM session_policy WHERE policy_key = $1 FOR UPDATE`), [key]);
|
|
67
62
|
const prior = rows[0] ? rowToStored(rows[0], `for session ${JSON.stringify(sessionId)} (pre-write snapshot)`) : undefined;
|
|
68
63
|
const priorRev = prior?.rev ?? 0;
|
|
69
64
|
if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
* 店层折码的**唯一规则**(`catch (e) { throw foldJsonColumn(e, (m) => new XError("corrupt", m)); }`)。
|
|
97
|
+
*
|
|
98
|
+
* 🔴 `not-text` **不折**:它是读腿自己的缺陷(投影漏了),折成「这一行坏了」会把一次代码漂伪装成一次
|
|
99
|
+
* 数据事故 —— 这条判别只写在这里一处,五个店不各写一遍(各写一遍的下场是某天有一处把程序错也折了)。
|
|
100
|
+
* 非本模块的错误原样透出(连接 / 超时 / 权限本来就该照原样上抛)。
|
|
101
|
+
*/
|
|
102
|
+
export declare function foldJsonColumn(e: unknown, corrupt: (message: string) => Error): Error;
|
|
103
|
+
/**
|
|
104
|
+
* 把一只**既有的 zod schema** 当形读者用(本仓多数店的形判据就是它自己那份 schema)。
|
|
105
|
+
* `create*` 而不是 `build*`:产物是一只函数([ref] N2 命名律)。
|
|
106
|
+
*/
|
|
107
|
+
export declare function createZodColumnReader<T>(schema: z.ZodType<T>): JsonColumnReader<T>;
|
|
108
|
+
/**
|
|
109
|
+
* **自由载荷列**的最小形读者:只判「这是一只 JSON 对象」,不碰键集。
|
|
110
|
+
*
|
|
111
|
+
* 给那些**schema 属主不在本仓 / 不在本层**的列用(approval 的 `card_json`、device 的审计 `detail`):
|
|
112
|
+
* 持久层对它们保持透明字节存取语义,但「是不是一只对象」这一条仍然判 —— 那正是 `null` 字面 / 字符串
|
|
113
|
+
* 标量 / 双重编码三形的落点。**不是**不判形的借口:一列有结构属主的,必须用属主那只读者。
|
|
114
|
+
*/
|
|
115
|
+
export declare const readJsonObjectColumn: JsonColumnReader<Record<string, unknown>>;
|
|
116
|
+
/** 「一只普通 JSON 对象」的收窄谓词(`null` / 数组 / 标量都不是)。谓词而非 `as`:边界铸形门 [ref] §2。 */
|
|
117
|
+
export declare function isJsonObject(v: unknown): v is Record<string, unknown>;
|
|
118
|
+
/**
|
|
119
|
+
* **任意 JSON 值**的形读者 —— 只把 `not-json` 挡下来,`null` / 标量 / 数组一律是**合法的值**。
|
|
120
|
+
*
|
|
121
|
+
* 🔴 用它的列必须满足一个硬条件:**它的 wire 契约本身就是「任意 JSON 值」,而且「值是 `null`」与
|
|
122
|
+
* 「这一格缺席」在该列上是两件不同的事、且由**列的 NULL** 区分。今天整仓只有一处符合:
|
|
123
|
+
* `approval_ask.updated_input`([ref] / [ref] 的三态读法 —— 列 NULL = 没带编辑,JSON 文本 `"null"` =
|
|
124
|
+
* **编辑成 null**;把 `"null"` 判成坏体会把一条合法的人类编辑抹掉)。
|
|
125
|
+
* 一列只要有结构,就**不许**用它 —— 那是把形判据静默关掉。
|
|
126
|
+
*/
|
|
127
|
+
export declare const readAnyJsonValueColumn: JsonColumnReader<unknown>;
|
|
128
|
+
//# sourceMappingURL=sql-json-column.d.ts.map
|
|
@@ -0,0 +1,64 @@
|
|
|
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 foldJsonColumn(e, corrupt) {
|
|
49
|
+
if (e instanceof SqlJsonColumnError && e.problem !== "not-text")
|
|
50
|
+
return corrupt(e.message);
|
|
51
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
52
|
+
}
|
|
53
|
+
export function createZodColumnReader(schema) {
|
|
54
|
+
return (body) => {
|
|
55
|
+
const parsed = schema.safeParse(body);
|
|
56
|
+
return parsed.success ? { value: parsed.data } : { problem: parsed.error.message };
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export const readJsonObjectColumn = (body) => isJsonObject(body) ? { value: body } : { problem: "expected a JSON object" };
|
|
60
|
+
export function isJsonObject(v) {
|
|
61
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
62
|
+
}
|
|
63
|
+
export const readAnyJsonValueColumn = (body) => ({ value: body });
|
|
64
|
+
//# 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
|
|
18
|
-
|
|
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
|
package/dist/run-local.d.ts
CHANGED
|
@@ -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/dist/tool-approval.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { HostDecidedBy } from "./host-decision.js";
|
|
1
|
+
import type { HostDecidedBy, HostSettledBy } from "./host-decision.js";
|
|
2
2
|
import type { IncomingMessage } from "node:http";
|
|
3
3
|
import { type AskRequest, type AskOutcome, type ReadRootGrantCandidate, type RuleOffer } from "@sema-agent/core";
|
|
4
4
|
import { type CardRulePersisted, type RuleConsentLane } from "./rules-consent.js";
|
|
5
5
|
import { type DenialLimitFallback, type RuleOffersAbsence, type ApprovalRequestFrame, type ApprovalRevokeFrame, type RuleEvidenceProjection } from "./approval-card.js";
|
|
6
|
-
import { type ApprovalAskStore } from "./plugins/approval-ask-store-sql.js";
|
|
6
|
+
import { type ApprovalAskStore, type AskRow } from "./plugins/approval-ask-store-sql.js";
|
|
7
7
|
import type { ApprovalAskAuditSink } from "./approval-ask-audit-store.js";
|
|
8
8
|
import { type GovernanceAskMarks } from "./governance-ask-marks.js";
|
|
9
9
|
/** [ref]:审批 gate kind 闭集(core gateMatch 的 `human`/`irreversible_ask` ↔ outcome.gate "policy_ask")。
|
|
@@ -613,27 +613,6 @@ export declare function parseToolApprovalResponse(body: unknown): {
|
|
|
613
613
|
ok: false;
|
|
614
614
|
error: string;
|
|
615
615
|
};
|
|
616
|
-
/**
|
|
617
|
-
* core 5.23.0([ref]/[ref]②)—— **宿主自报**的结算来源,`AskOutcome` 对象臂新开的口。本仓在这一格上有
|
|
618
|
-
* **两种身份**,合成一只座是刻意的(两只座 = 同一个 wire 位的两个写者):
|
|
619
|
-
* · **铸**(本仓自己观察到的事实):只有 `"timeout"` —— 同步 `onAsk` 腿的等待窗归**宿主**所有(引擎不为
|
|
620
|
-
* 一个它没排程的回调起表),所以「本仓的 TTL 窗走完了、没人答」这件事引擎结构上观察不到,不自报就
|
|
621
|
-
* 永远被记成「一个人拒绝了」;
|
|
622
|
-
* · **转**(调用方在回决体里自报的事实,core 7.26.2 / #1016 起):`settledBy:"policy"` = 答这张卡的是
|
|
623
|
-
* **这台部署自己的策略**,不是人。本仓**零判据、原样过境**:词的拒绝逻辑归引擎(`resolveAsk` 对
|
|
624
|
-
* 自报词表外的值是响亮 fail-closed deny),本仓抄一份词表的那天 core 加词就会被静默 400 掉。
|
|
625
|
-
* 逐词(词表属主恒是 core):
|
|
626
|
-
* · `"human"` —— 与「不铸这个键」逐字同义(core 缺席即按人处理);
|
|
627
|
-
* · `"aborted"` —— core **明令拒收**宿主自报(那是引擎自家 fail-closed 端的词,宿主自报等于把自己的
|
|
628
|
-
* 拒绝改标成引擎故障),所以它**不在** `AskOutcome` 的这一格上,本型也就够不着它。
|
|
629
|
-
* 类型从 core 的**同步 outcome 面**推导(不手抄):`AskOutcome` 的对象臂上 `settledBy` 是**宿主报的事实**,
|
|
630
|
-
* core 7.6.0([ref] S6-A)删掉的是那张**结算词表**(`ApprovalSettledBy`),不是这一格 —— 词由 core 从
|
|
631
|
-
* 这个事实铸(`human_refused` / `approval_window_expired` / `policy_refused`)。core 若改这一格的词表,
|
|
632
|
-
* 这里编译期先红。
|
|
633
|
-
*/
|
|
634
|
-
type HostSettledBy = NonNullable<Extract<AskOutcome, {
|
|
635
|
-
allow: boolean;
|
|
636
|
-
}>["settledBy"]>;
|
|
637
616
|
/**
|
|
638
617
|
* #1016 —— wire 上**开给调用方**的那一个词,从 core 的两只闭集里取**交集**。
|
|
639
618
|
*
|
|
@@ -802,6 +781,33 @@ export declare function resolveApprovalLeg(input: {
|
|
|
802
781
|
* 「窗偏短」(安全侧,§7.3 的误差方向论证)。 */
|
|
803
782
|
nowMonotonicMs: number;
|
|
804
783
|
}): ApprovalLegAssembly;
|
|
784
|
+
/** 一条 **DECIDED 行**能交还给引擎的全部形(`AskOutcome` 减掉 park 路由那一臂 —— 一条有决议的行
|
|
785
|
+
* 从定义上不是「投递面拿走了它」)。{@link ToolApprovalCoordinator.notifyExternalDecision} 收的就是它。 */
|
|
786
|
+
export type DecidedAskOutcome = Exclude<AskOutcome, "unavailable">;
|
|
787
|
+
/** 铸点的入参形 = 行上与**这条决议本身**有关的三格。整行(`AskRow`)与 claim 败方读数都满足。 */
|
|
788
|
+
type DecidedRowFacts = Pick<AskRow, "decision" | "settledBy"> & {
|
|
789
|
+
updatedInput?: unknown;
|
|
790
|
+
};
|
|
791
|
+
/**
|
|
792
|
+
* 🔴 **一条 DECIDED 行 → 交还给引擎的那个 outcome:全仓唯一铸点**(S-558)。
|
|
793
|
+
*
|
|
794
|
+
* 存在理由 = 这条投影此前被**手抄**在每个消费点上(`row.decision === "approve"` + `row.updatedInput`,
|
|
795
|
+
* 六处),于是给行加一格新事实就得记得改六处 —— `settledBy` 正是那个没人改到的格:一次**策略拒**在
|
|
796
|
+
* 重放 / 兄弟广播 / 歧义提交恢复三条路上全都退化成裸 `false`,而裸 `false` 在 core 那边逐字是
|
|
797
|
+
* 「**一个人**拒绝了」。手抄投影全面下线([ref]):消费点交行,形由这里铸。
|
|
798
|
+
*
|
|
799
|
+
* 三条读法,每条的属主注在别处,这里只落笔:
|
|
800
|
+
* · **方向** —— `decision !== "approve"` 即拒(列的二值由店的 CAS 保证);
|
|
801
|
+
* · **编辑载荷**([ref])—— `updatedInput` 键缺席 = 没带编辑 ⇒ 裸 `true`;在场(**含 `null`**)= 编辑的
|
|
802
|
+
* 字面值 ⇒ 对象臂(语义三态的唯一属主 = `AskRow.updatedInput` 顶注);
|
|
803
|
+
* · **结算来源**(S-558)—— 行上非 null 即宿主自报的那个词(`null` = 人决,与「不铸这个键」同义;
|
|
804
|
+
* 语义属主 = `AskRow.settledBy` 顶注)。
|
|
805
|
+
*
|
|
806
|
+
* 🔴 approve 臂**不读**来源位,而且那不是「静默丢」:store 的入店形门(`assertDecideSettledBy`)让
|
|
807
|
+
* 「approve 行带来源位」在**写面不可表示** —— core 对 `{allow:true, settledBy}` 是 fail-closed 拒,
|
|
808
|
+
* 一条那样的行没有任何读回路径能诚实地交还给引擎,所以它在落库那一刻就被拒掉了,而不是在这里被吞掉。
|
|
809
|
+
*/
|
|
810
|
+
export declare function outcomeOfDecidedRow(row: DecidedRowFacts): DecidedAskOutcome;
|
|
805
811
|
/**
|
|
806
812
|
* Coordinates the live tool-approval HITL for the singleton runner. Process-local + same-replica (the pending map is
|
|
807
813
|
* in memory, like QuestionCoordinator): a respond that lands on another replica finds nothing → 404. Present (passed
|
|
@@ -1194,7 +1200,7 @@ export declare class ToolApprovalCoordinator {
|
|
|
1194
1200
|
* 真决议」,重复注册必须拿到**同一个**决议(approve 就是 approve)——合并会把外部批准的重复条目错
|
|
1195
1201
|
* 结算成拒绝。已被结算过的条目再调 settle 天然无操作(幂等),所以本口对「赢家自己已 settle」安全。
|
|
1196
1202
|
*/
|
|
1197
|
-
notifyExternalDecision(askId: string,
|
|
1203
|
+
notifyExternalDecision(askId: string, outcome: DecidedAskOutcome): {
|
|
1198
1204
|
settled: number;
|
|
1199
1205
|
};
|
|
1200
1206
|
/**
|