@sema-agent/server 7.93.4 → 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/plugins/approval-ask-store-sql.d.ts +1 -1
- package/dist/plugins/approval-ask-store-sql.js +36 -19
- 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/package.json +1 -1
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Logger } from "../observability/logger.js";
|
|
2
|
+
/** {@link installProcessExitBoundary} 的把手。 */
|
|
3
|
+
export interface ProcessExitBoundary {
|
|
4
|
+
/**
|
|
5
|
+
* **优化点**,不承担不变量:受控退的收尾链在它自己的终点早按一次子进程腿(理由逐字写在
|
|
6
|
+
* `boot/shutdown.ts` 的调用点上 —— 那之后进程还活十秒量级,后台腿的软→硬梯子跑得完)。
|
|
7
|
+
* 工作区腿**刻意不给**这条早按:停机中段 `rm -rf` 会把在飞 turn 正在写的目录从它脚下抽掉。
|
|
8
|
+
*/
|
|
9
|
+
reapChildProcessesEarly(): void;
|
|
10
|
+
/** 摘掉本次装配的 `exit` 钩。幂等(同进程内反复装配的调用方=测试必须用它收尾)。 */
|
|
11
|
+
dispose(): void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 装上本进程的退出边界(`process.once("exit")`)。返回 {@link ProcessExitBoundary}。
|
|
15
|
+
*
|
|
16
|
+
* **进程级注册表是全局单例** ⇒ 装两次就是两只钩(两个写者)。生产里每条车道只装一次、装到死;
|
|
17
|
+
* 同进程内反复装配的调用方(测试)必须 `dispose()`。
|
|
18
|
+
*/
|
|
19
|
+
export declare function installProcessExitBoundary({ logger }: {
|
|
20
|
+
logger: Logger;
|
|
21
|
+
}): ProcessExitBoundary;
|
|
22
|
+
//# sourceMappingURL=exit-boundary.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { defaultTaskRegistry } from "@sema-agent/core";
|
|
2
|
+
import { liveHostForegroundChildCount, reapHostForegroundChildren } from "../plugins/host-platform.js";
|
|
3
|
+
import { liveEphemeralHostWorkspaceCount, reapEphemeralHostWorkspaces } from "../plugins/host-workspace-registry.js";
|
|
4
|
+
export function installProcessExitBoundary({ logger }) {
|
|
5
|
+
const reapOwnChildProcesses = () => {
|
|
6
|
+
try {
|
|
7
|
+
const reaped = defaultTaskRegistry.reapAllSessionBackground();
|
|
8
|
+
if (reaped > 0)
|
|
9
|
+
logger.info("shutdown_background_reaped", { reaped });
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
logger.warn("shutdown_background_reap_failed", { err: String(err) });
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const fg = reapHostForegroundChildren();
|
|
16
|
+
if (fg.signalled > 0 || fg.alreadyDead > 0)
|
|
17
|
+
logger.info("shutdown_foreground_reaped", { signalled: fg.signalled, alreadyDead: fg.alreadyDead });
|
|
18
|
+
if (fg.failed.length > 0)
|
|
19
|
+
logger.warn("shutdown_foreground_reap_incomplete", { groups: fg.failed });
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
logger.warn("shutdown_foreground_reap_failed", { err: String(err) });
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const reapOwnHostWorkspaces = () => {
|
|
26
|
+
try {
|
|
27
|
+
const { removed, failed } = reapEphemeralHostWorkspaces();
|
|
28
|
+
if (removed.length > 0)
|
|
29
|
+
logger.info("shutdown_host_workspaces_reaped", { removed: removed.length });
|
|
30
|
+
if (failed.length > 0)
|
|
31
|
+
logger.warn("shutdown_host_workspace_reap_incomplete", { dirs: failed.map((f) => f.dir) });
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
logger.warn("shutdown_host_workspace_reap_failed", { err: String(err) });
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const onProcessExit = () => {
|
|
38
|
+
const onBooks = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
|
|
39
|
+
try {
|
|
40
|
+
reapOwnChildProcesses();
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
console.error("shutdown_exit_boundary_child_reap_threw", err);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
reapOwnHostWorkspaces();
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
console.error("shutdown_exit_boundary_workspace_reap_threw", err);
|
|
50
|
+
}
|
|
51
|
+
const left = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
|
|
52
|
+
try {
|
|
53
|
+
logger.info("shutdown_exit_boundary", { ...onBooks, leftForegroundChildren: left.foregroundChildren, leftHostWorkspaces: left.hostWorkspaces });
|
|
54
|
+
if (left.foregroundChildren > 0 || left.hostWorkspaces > 0)
|
|
55
|
+
logger.warn("shutdown_exit_boundary_incomplete", left);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.error("shutdown_exit_boundary_log_threw", err);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
process.once("exit", onProcessExit);
|
|
62
|
+
let disposed = false;
|
|
63
|
+
return {
|
|
64
|
+
reapChildProcessesEarly: reapOwnChildProcesses,
|
|
65
|
+
dispose: () => {
|
|
66
|
+
if (disposed)
|
|
67
|
+
return;
|
|
68
|
+
disposed = true;
|
|
69
|
+
process.removeListener("exit", onProcessExit);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=exit-boundary.js.map
|
package/dist/boot/shutdown.d.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* "第二个信号"的语义读数。
|
|
13
13
|
* 3. `clearInterval(reaper)` 必须是 hardShutdown 的第一件事(在 `server.close()` 之前),否则
|
|
14
14
|
* 收尾期还会有 reaper tick 打向正在关闭的池。
|
|
15
|
-
* 4. **进程级监听一律成对**([ref] 二轮件4):本函数装的六只(三信号 + [ref]① 双钩 + S-557 退出边界钩
|
|
16
|
-
* 只能经返回的
|
|
15
|
+
* 4. **进程级监听一律成对**([ref] 二轮件4):本函数装的六只(三信号 + [ref]① 双钩 + S-557 退出边界钩
|
|
16
|
+
* —— S-560 起后者由 {@link installProcessExitBoundary} 代装,摘除仍并进同一把手)只能经返回的
|
|
17
17
|
* {@link ShutdownHandle} 摘 —— 调用方手写「摘三信号」的收尾会把双钩漏在进程上,而陈旧的那只
|
|
18
18
|
* rejection 钩仍握着 `process.exit(1)`,会对后续无关的 rejection 把进程打掉。
|
|
19
19
|
*/
|
package/dist/boot/shutdown.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { Runner
|
|
1
|
+
import { Runner } from "@sema-agent/core";
|
|
2
2
|
import { writeCrashLast, clearCrashLast, readCrashLast } from "./crash-last.js";
|
|
3
3
|
import { createSighupIdleHandler } from "../sighup-idle.js";
|
|
4
4
|
import { createParentWatch } from "../parent-watch.js";
|
|
5
|
-
import {
|
|
6
|
-
import { liveEphemeralHostWorkspaceCount, reapEphemeralHostWorkspaces } from "../plugins/host-workspace-registry.js";
|
|
5
|
+
import { installProcessExitBoundary } from "./exit-boundary.js";
|
|
7
6
|
import { createEngineLeaseTracker } from "./engine-lease.js";
|
|
8
7
|
import { writeExitLast, readExitLast, clearExitLast } from "./exit-last.js";
|
|
9
8
|
import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
|
|
@@ -32,63 +31,7 @@ export function installShutdownHandlers(ctx) {
|
|
|
32
31
|
if (dataRoot)
|
|
33
32
|
writeCrashLast(dataRoot, { version, inflight: drainState.inflight?.(), err });
|
|
34
33
|
};
|
|
35
|
-
const
|
|
36
|
-
try {
|
|
37
|
-
const reaped = defaultTaskRegistry.reapAllSessionBackground();
|
|
38
|
-
if (reaped > 0)
|
|
39
|
-
logger.info("shutdown_background_reaped", { reaped });
|
|
40
|
-
}
|
|
41
|
-
catch (err) {
|
|
42
|
-
logger.warn("shutdown_background_reap_failed", { err: String(err) });
|
|
43
|
-
}
|
|
44
|
-
try {
|
|
45
|
-
const fg = reapHostForegroundChildren();
|
|
46
|
-
if (fg.signalled > 0 || fg.alreadyDead > 0)
|
|
47
|
-
logger.info("shutdown_foreground_reaped", { signalled: fg.signalled, alreadyDead: fg.alreadyDead });
|
|
48
|
-
if (fg.failed.length > 0)
|
|
49
|
-
logger.warn("shutdown_foreground_reap_incomplete", { groups: fg.failed });
|
|
50
|
-
}
|
|
51
|
-
catch (err) {
|
|
52
|
-
logger.warn("shutdown_foreground_reap_failed", { err: String(err) });
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
const reapOwnHostWorkspaces = () => {
|
|
56
|
-
try {
|
|
57
|
-
const { removed, failed } = reapEphemeralHostWorkspaces();
|
|
58
|
-
if (removed.length > 0)
|
|
59
|
-
logger.info("shutdown_host_workspaces_reaped", { removed: removed.length });
|
|
60
|
-
if (failed.length > 0)
|
|
61
|
-
logger.warn("shutdown_host_workspace_reap_incomplete", { dirs: failed.map((f) => f.dir) });
|
|
62
|
-
}
|
|
63
|
-
catch (err) {
|
|
64
|
-
logger.warn("shutdown_host_workspace_reap_failed", { err: String(err) });
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
const onProcessExit = () => {
|
|
68
|
-
const onBooks = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
|
|
69
|
-
try {
|
|
70
|
-
reapOwnChildProcesses();
|
|
71
|
-
}
|
|
72
|
-
catch (err) {
|
|
73
|
-
console.error("shutdown_exit_boundary_child_reap_threw", err);
|
|
74
|
-
}
|
|
75
|
-
try {
|
|
76
|
-
reapOwnHostWorkspaces();
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
console.error("shutdown_exit_boundary_workspace_reap_threw", err);
|
|
80
|
-
}
|
|
81
|
-
const left = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
|
|
82
|
-
try {
|
|
83
|
-
logger.info("shutdown_exit_boundary", { ...onBooks, leftForegroundChildren: left.foregroundChildren, leftHostWorkspaces: left.hostWorkspaces });
|
|
84
|
-
if (left.foregroundChildren > 0 || left.hostWorkspaces > 0)
|
|
85
|
-
logger.warn("shutdown_exit_boundary_incomplete", left);
|
|
86
|
-
}
|
|
87
|
-
catch (err) {
|
|
88
|
-
console.error("shutdown_exit_boundary_log_threw", err);
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
process.once("exit", onProcessExit);
|
|
34
|
+
const exitBoundary = installProcessExitBoundary({ logger });
|
|
92
35
|
const onUncaughtException = (err) => {
|
|
93
36
|
try {
|
|
94
37
|
writeLastWords(err);
|
|
@@ -151,7 +94,7 @@ export function installShutdownHandlers(ctx) {
|
|
|
151
94
|
subRunner.sessions.dispose();
|
|
152
95
|
void lspManager?.dispose?.();
|
|
153
96
|
void workflowNotifyJournal?.close?.();
|
|
154
|
-
|
|
97
|
+
exitBoundary.reapChildProcessesEarly();
|
|
155
98
|
void fleetClient?.stop();
|
|
156
99
|
server.close(() => {
|
|
157
100
|
void retentionTeardown.then(() => (backend ? backend.close() : Promise.resolve())).finally(() => process.exit(0));
|
|
@@ -248,7 +191,7 @@ export function installShutdownHandlers(ctx) {
|
|
|
248
191
|
if (disposed)
|
|
249
192
|
return;
|
|
250
193
|
disposed = true;
|
|
251
|
-
|
|
194
|
+
exitBoundary.dispose();
|
|
252
195
|
process.removeListener("uncaughtException", onUncaughtException);
|
|
253
196
|
process.removeListener("unhandledRejection", onUnhandledRejection);
|
|
254
197
|
process.removeListener("SIGTERM", drainThenShutdown);
|
|
@@ -515,7 +515,7 @@ export declare function ensurePgApprovalAskSchema(q: PgQueryFn): Promise<void>;
|
|
|
515
515
|
*
|
|
516
516
|
* 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升上来
|
|
517
517
|
* 的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `approval_ask` 是空操作 —— 本车新加的
|
|
518
|
-
* `rule_command` / `rule_scope_root` 两列不在,而 `ensureAsk` 的 INSERT 与 `
|
|
518
|
+
* `rule_command` / `rule_scope_root` 两列不在,而 `ensureAsk` 的 INSERT 与 `askCols` 的每一条读
|
|
519
519
|
* **无条件**引用它们。后果不是「少一个新功能」,是**整条流内审批协议**在那台机器上是坏的:
|
|
520
520
|
* · 每一只 ask 的 `ensureAsk` 撞 unknown column ⇒ 被 `noteStoreError` 吞掉 ⇒ 整只 ask 退回 park 路由
|
|
521
521
|
* (卡不落库、`GET /v1/approvals` 的重放面恒空、迟到决议腿恒 404);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
2
2
|
import { canAskTransition } from "../approval-ask-machine.js";
|
|
3
3
|
import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
|
|
4
|
+
import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readAnyJsonValueColumn, readJsonObjectColumn } from "./sql-json-column.js";
|
|
4
5
|
import { ensureIndexes } from "./ensure-index.js";
|
|
5
6
|
export function assertIntegralEpochMs(field, value) {
|
|
6
7
|
if (!Number.isSafeInteger(value)) {
|
|
@@ -211,7 +212,7 @@ export async function ensurePgApprovalAskSchema(q) {
|
|
|
211
212
|
}
|
|
212
213
|
export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
|
|
213
214
|
try {
|
|
214
|
-
await query(`SELECT rule_command, rule_scope_root, updated_input FROM ${APPROVAL_ASK_TABLE} WHERE 1=0`);
|
|
215
|
+
await query(`SELECT rule_command, rule_scope_root, ${jsonColumnText(dialect, "updated_input")} FROM ${APPROVAL_ASK_TABLE} WHERE 1=0`);
|
|
215
216
|
}
|
|
216
217
|
catch (err) {
|
|
217
218
|
if (!isMissingColumnError(err, dialect))
|
|
@@ -231,14 +232,21 @@ export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
|
|
|
231
232
|
`Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
|
|
232
233
|
}
|
|
233
234
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
235
|
+
function askCols(dialect) {
|
|
236
|
+
return [
|
|
237
|
+
"ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state",
|
|
238
|
+
"provisional, rev, decision",
|
|
239
|
+
jsonColumnText(dialect, "decision_actor"),
|
|
240
|
+
"decision_note, settled_by",
|
|
241
|
+
jsonColumnText(dialect, "updated_input"),
|
|
242
|
+
"decided_at_ms, denied_reason, gate_token",
|
|
243
|
+
"gate_bound_call_id, gate_bound_input_hash, idempotency_key",
|
|
244
|
+
jsonColumnText(dialect, "card_json"),
|
|
245
|
+
"rule_command, rule_scope_root, schema_version, expires_at_ms",
|
|
246
|
+
"created_at_ms, updated_at_ms",
|
|
247
|
+
].join(", ");
|
|
241
248
|
}
|
|
249
|
+
const BATCH_COLS = "batch_id, task_id, state, bound_ask_id, rev, created_at_ms, updated_at_ms";
|
|
242
250
|
function toBool(v) {
|
|
243
251
|
return Boolean(Number(v));
|
|
244
252
|
}
|
|
@@ -249,6 +257,15 @@ function strOrNull(v) {
|
|
|
249
257
|
return v === null || v === undefined ? null : String(v);
|
|
250
258
|
}
|
|
251
259
|
function mapAskRow(r) {
|
|
260
|
+
const askId = String(r.ask_id);
|
|
261
|
+
try {
|
|
262
|
+
return mapAskRowBody(r, askId);
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
throw foldJsonColumn(e, (m) => new Error(`approval-ask-store: ${m} — refusing rather than serve an ask row nobody can read`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function mapAskRowBody(r, askId) {
|
|
252
269
|
return {
|
|
253
270
|
askId: String(r.ask_id),
|
|
254
271
|
taskId: String(r.task_id),
|
|
@@ -264,17 +281,17 @@ function mapAskRow(r) {
|
|
|
264
281
|
provisional: toBool(r.provisional),
|
|
265
282
|
rev: Number(r.rev),
|
|
266
283
|
decision: strOrNull(r.decision) ?? null,
|
|
267
|
-
decisionActor: r.decision_actor == null ? null :
|
|
284
|
+
decisionActor: r.decision_actor == null ? null : decodeJsonColumn(r.decision_actor, readJsonObjectColumn, `${APPROVAL_ASK_TABLE}.decision_actor for ask ${askId}`),
|
|
268
285
|
decisionNote: strOrNull(r.decision_note),
|
|
269
286
|
settledBy: strOrNull(r.settled_by),
|
|
270
|
-
...(r.updated_input == null ? {} : { updatedInput:
|
|
287
|
+
...(r.updated_input == null ? {} : { updatedInput: decodeJsonColumn(r.updated_input, readAnyJsonValueColumn, `${APPROVAL_ASK_TABLE}.updated_input for ask ${askId}`) }),
|
|
271
288
|
decidedAtMs: numOrNull(r.decided_at_ms),
|
|
272
289
|
deniedReason: strOrNull(r.denied_reason),
|
|
273
290
|
gateToken: strOrNull(r.gate_token),
|
|
274
291
|
gateBoundCallId: strOrNull(r.gate_bound_call_id),
|
|
275
292
|
gateBoundInputHash: strOrNull(r.gate_bound_input_hash),
|
|
276
293
|
idempotencyKey: strOrNull(r.idempotency_key),
|
|
277
|
-
cardJson:
|
|
294
|
+
cardJson: decodeJsonColumn(r.card_json, readJsonObjectColumn, `${APPROVAL_ASK_TABLE}.card_json for ask ${askId}`),
|
|
278
295
|
ruleCommand: strOrNull(r.rule_command),
|
|
279
296
|
ruleScopeRoot: strOrNull(r.rule_scope_root),
|
|
280
297
|
schemaVersion: Number(r.schema_version),
|
|
@@ -337,7 +354,7 @@ export class SqlApprovalAskStore {
|
|
|
337
354
|
return isDupKeyError(this.db.dialect, e);
|
|
338
355
|
}
|
|
339
356
|
async getAskOn(conn, askId) {
|
|
340
|
-
const { rows } = await conn.query(this.q(`SELECT ${
|
|
357
|
+
const { rows } = await conn.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [askId]);
|
|
341
358
|
return rows[0] ? mapAskRow(rows[0]) : null;
|
|
342
359
|
}
|
|
343
360
|
async getBatchOn(conn, batchId) {
|
|
@@ -485,7 +502,7 @@ export class SqlApprovalAskStore {
|
|
|
485
502
|
}
|
|
486
503
|
const askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state = ${ph(dialect, 1)}, rev = rev + 1, updated_at_ms = ${ph(dialect, 2)} WHERE ${pendingClaimWhere(dialect, 3)}`, [to, now, askId, batchId]);
|
|
487
504
|
if (askRes.affected !== 1) {
|
|
488
|
-
const sel = await conn.query(`SELECT ${
|
|
505
|
+
const sel = await conn.query(`SELECT ${askCols(dialect)} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ${ph(dialect, 1)} AND batch_id = ${ph(dialect, 2)} FOR UPDATE`, [askId, batchId]);
|
|
489
506
|
await conn.rollback();
|
|
490
507
|
return { claimed: false, current: projectAskTerminalClaimTruth(sel.rows[0] ? mapAskRow(sel.rows[0]) : null) };
|
|
491
508
|
}
|
|
@@ -614,32 +631,32 @@ export class SqlApprovalAskStore {
|
|
|
614
631
|
}
|
|
615
632
|
}
|
|
616
633
|
async listByState(state, limit) {
|
|
617
|
-
const { rows } = await this.db.query(this.q(`SELECT ${
|
|
634
|
+
const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE state = ? ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE state = $1 ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT $2`), [state, limit]);
|
|
618
635
|
return rows.map(mapAskRow);
|
|
619
636
|
}
|
|
620
637
|
async listPendingByTask(taskId, signal) {
|
|
621
638
|
if (signal?.aborted === true)
|
|
622
639
|
throw abortedReadError("listPendingByTask");
|
|
623
|
-
const { rows } = await withReadSignal(this.db.query(this.q(`SELECT ${
|
|
640
|
+
const { rows } = await withReadSignal(this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`), [taskId]), signal, "listPendingByTask");
|
|
624
641
|
return rows.map(mapAskRow);
|
|
625
642
|
}
|
|
626
643
|
async listPendingBySession(sessionId, owner, signal) {
|
|
627
644
|
if (signal?.aborted === true)
|
|
628
645
|
throw abortedReadError("listPendingBySession");
|
|
629
646
|
const { rows } = await withReadSignal(this.db.query(owner === null
|
|
630
|
-
? this.q(`SELECT ${
|
|
631
|
-
: this.q(`SELECT ${
|
|
647
|
+
? this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`)
|
|
648
|
+
: this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner = ? ORDER BY created_at_ms ASC`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner = $2 ORDER BY created_at_ms ASC`), owner === null ? [sessionId] : [sessionId, owner]), signal, "listPendingBySession");
|
|
632
649
|
return rows.map(mapAskRow);
|
|
633
650
|
}
|
|
634
651
|
async getAsk(askId) {
|
|
635
|
-
const { rows } = await this.db.query(this.q(`SELECT ${
|
|
652
|
+
const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE ask_id = $1`), [
|
|
636
653
|
askId,
|
|
637
654
|
]);
|
|
638
655
|
return rows[0] ? mapAskRow(rows[0]) : null;
|
|
639
656
|
}
|
|
640
657
|
async getByIdempotencyKey(taskId, key) {
|
|
641
658
|
assertIdempotencyKeyShape(key);
|
|
642
|
-
const { rows } = await this.db.query(this.q(`SELECT ${
|
|
659
|
+
const { rows } = await this.db.query(this.q(`SELECT ${askCols("tidb")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = ? AND idempotency_key = ?`, `SELECT ${askCols("pg")} FROM ${APPROVAL_ASK_TABLE} WHERE task_id = $1 AND idempotency_key = $2`), [taskId, key]);
|
|
643
660
|
return rows[0] ? mapAskRow(rows[0]) : null;
|
|
644
661
|
}
|
|
645
662
|
async deferReconcile(askId, expectedState, expectedRev, nowMs) {
|
|
@@ -186,6 +186,16 @@ export declare class SqlDeviceStore implements DeviceStore {
|
|
|
186
186
|
appendAudit(input: AppendAuditInput): Promise<void>;
|
|
187
187
|
/** 审计写的**唯一**语句(池级与事务内共用;`SqlExec` 是两者的公共面)。 */
|
|
188
188
|
private insertAuditOn;
|
|
189
|
+
/**
|
|
190
|
+
* 审计列表。
|
|
191
|
+
*
|
|
192
|
+
* 🔴 **一行读不出形 ⇒ 整页拒,刻意不做逐行隔离**(S-550 修复验证轮,主会话裁定后入档):方向由设计
|
|
193
|
+
* §2「失败方向 = 拒(fail-closed),全店同向」定。逐行隔离(坏行跳过 / 占位)在**审计**面上尤其危险 ——
|
|
194
|
+
* 它把「这一页少了一条」变得和「本来就没有那一条」同形,而审计面的全部意义就是「发生过的事都在」。
|
|
195
|
+
* 一页 5xx 是响亮的运维事件,一条静默消失不是。
|
|
196
|
+
*
|
|
197
|
+
* ⇒ 存量库里旧写腿留下的坏字节由**一次性迁移**清掉(`one-time-migrations.ts` M4),不靠读侧容忍。
|
|
198
|
+
*/
|
|
189
199
|
listAudit(filter?: ListAuditFilter): Promise<DeviceAuditRow[]>;
|
|
190
200
|
}
|
|
191
201
|
/** 方言绑定薄壳(A12 形:ctor 收池,行为全在基类)。 */
|
|
@@ -2,6 +2,7 @@ import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
|
2
2
|
import { isDupKeyError, isCheckViolationError } from "./sql-errors.js";
|
|
3
3
|
import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRebindShape, assertRevokeShape, assertRootSessionIdShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
|
|
4
4
|
import { ensureIndexes } from "./ensure-index.js";
|
|
5
|
+
import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
|
|
5
6
|
export const DEVICES_TABLE = "device";
|
|
6
7
|
export const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token";
|
|
7
8
|
export const DEVICE_SESSIONS_TABLE = "device_session";
|
|
@@ -237,7 +238,7 @@ function sessionCols(dialect) {
|
|
|
237
238
|
return ["root_session_id", "device_id", "owner_tenant", "owner_subject", ms(dialect, "bound_at", "bound_at_ms"), "rev"].join(", ");
|
|
238
239
|
}
|
|
239
240
|
function auditCols(dialect) {
|
|
240
|
-
return ["id", ms(dialect, "at", "at_ms"), "event", "device_id", "owner_tenant", "owner_subject", "detail"].join(", ");
|
|
241
|
+
return ["id", ms(dialect, "at", "at_ms"), "event", "device_id", "owner_tenant", "owner_subject", jsonColumnText(dialect, "detail")].join(", ");
|
|
241
242
|
}
|
|
242
243
|
function bufToStr(v) {
|
|
243
244
|
return Buffer.isBuffer(v) ? v.toString("utf8") : String(v);
|
|
@@ -295,9 +296,6 @@ function mapSessionRow(r) {
|
|
|
295
296
|
rev: Number(r.rev),
|
|
296
297
|
};
|
|
297
298
|
}
|
|
298
|
-
function parseJsonColumn(v) {
|
|
299
|
-
return JSON.parse(v);
|
|
300
|
-
}
|
|
301
299
|
function mapAuditRow(r) {
|
|
302
300
|
const tenant = strOrNull(r.owner_tenant);
|
|
303
301
|
const subject = strOrNull(r.owner_subject);
|
|
@@ -308,9 +306,17 @@ function mapAuditRow(r) {
|
|
|
308
306
|
event: parseDeviceAuditEvent(bufToStr(r.event)),
|
|
309
307
|
deviceId: strOrNull(r.device_id),
|
|
310
308
|
owner: tenant !== null && subject !== null ? { tenant, subject } : null,
|
|
311
|
-
detail: rawDetail === null || rawDetail === undefined ? null :
|
|
309
|
+
detail: rawDetail === null || rawDetail === undefined ? null : readAuditDetail(rawDetail, String(r.id)),
|
|
312
310
|
};
|
|
313
311
|
}
|
|
312
|
+
function readAuditDetail(raw, id) {
|
|
313
|
+
try {
|
|
314
|
+
return decodeJsonColumn(raw, readJsonObjectColumn, `${DEVICE_AUDIT_TABLE}.detail for audit row ${id}`);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
throw foldJsonColumn(e, (m) => new Error(`device-store: ${m} — refusing rather than serve an audit row whose detail nobody can read`));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
314
320
|
function ph(dialect, n) {
|
|
315
321
|
return dialect === "tidb" ? "?" : `$${n}`;
|
|
316
322
|
}
|
|
@@ -637,7 +643,7 @@ export class SqlDeviceStore {
|
|
|
637
643
|
assertDeviceIdShape(input.deviceId);
|
|
638
644
|
if (input.owner)
|
|
639
645
|
assertDeviceOwnerShape(input.owner);
|
|
640
|
-
await exec.query(this.q(`INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, ?, ?, ?, ?)`, `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, $2, $3, $4, $5::jsonb)`), [input.event, input.deviceId ?? null, input.owner?.tenant ?? null, input.owner?.subject ?? null, input.detail
|
|
646
|
+
await exec.query(this.q(`INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (NOW(3), ?, ?, ?, ?, ?)`, `INSERT INTO ${DEVICE_AUDIT_TABLE} (at, event, device_id, owner_tenant, owner_subject, detail) VALUES (now(), $1, $2, $3, $4, $5::jsonb)`), [input.event, input.deviceId ?? null, input.owner?.tenant ?? null, input.owner?.subject ?? null, input.detail == null ? null : this.json(input.detail)]);
|
|
641
647
|
}
|
|
642
648
|
async listAudit(filter) {
|
|
643
649
|
const dialect = this.db.dialect;
|
|
@@ -75,8 +75,10 @@ export interface ForegroundReapReading {
|
|
|
75
75
|
* 收割簿上全部前台子进程组。返回**发出**信号的读数,不是确认死亡数 —— 确认要等,而本函数的调用点之一
|
|
76
76
|
* 是 uncaught 出口。
|
|
77
77
|
*
|
|
78
|
-
* **同步形、一次到位、不等宽限**(S-557 起自述与实现同一句)
|
|
79
|
-
* `process.once("exit")` 钩 ——
|
|
78
|
+
* **同步形、一次到位、不等宽限**(S-557 起自述与实现同一句):承重的调用点是退出边界装配器
|
|
79
|
+
* (`boot/exit-boundary.ts`)那只 `process.once("exit")` 钩 —— S-560 起它由**两条车道共用**(HTTP 服务的
|
|
80
|
+
* 收尾链 + `run-local` 一次性 CLI;后者另装 SIGINT/SIGTERM 处理器,因为前台 CLI 的 Ctrl-C 若走默认终止,
|
|
81
|
+
* `exit` 钩不触发)。`exit` 之后**没有续段**,`await` 一个宽限梯就是把收割赌在一个永远等不到的
|
|
80
82
|
* 时刻上(`hardShutdown` 终点那次早按只是优化,见 `boot/shutdown.ts` 的两处注)。上界 = 簿上条数 ×
|
|
81
83
|
* 一次 `kill(-pid)` syscall(簿条数被 host lane 的在飞 turn 数界住),没有定时器、没有 promise。
|
|
82
84
|
*
|
|
@@ -13,6 +13,9 @@ export interface OneTimeMigrationReport {
|
|
|
13
13
|
readonly retiredSteerSeatStaleRows: number;
|
|
14
14
|
/** M3:改名的行数(账本 `tool_end` 行 + 已决 checkpoint 的执行判词列,两者相加)。 */
|
|
15
15
|
readonly askOriginRowsMigrated: number;
|
|
16
|
+
/** M4:`device_audit.detail` 里 JSON 文本 `null` 被改成列 NULL 的行数(表不在 ⇒ 恒 0)。
|
|
17
|
+
* 非零 = 这次迁移真改写了存量字节,必须让运维看见(与 M1 的 `retiredSnapshotRows` 同一条纪律)。 */
|
|
18
|
+
readonly auditDetailJsonNullRows: number;
|
|
16
19
|
}
|
|
17
20
|
/** 三条迁移都不适用的载体(local 车道没有 SQL 表)。 */
|
|
18
21
|
export declare const NO_SQL_MIGRATIONS: Omit<OneTimeMigrationReport, "askOriginRowsMigrated">;
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { acquireEnsureSchemaLock, releaseEnsureSchemaLock } from "./tidb-pool.js";
|
|
2
2
|
import { acquirePgEnsureSchemaLock, releasePgEnsureSchemaLock } from "./pg-pool.js";
|
|
3
|
+
import { jsonColumnTextExpr } from "./sql-json-column.js";
|
|
3
4
|
export const NO_SQL_MIGRATIONS = {
|
|
4
5
|
retiredSnapshotTable: "absent",
|
|
5
6
|
retiredSnapshotRows: 0,
|
|
6
|
-
retiredSteerSeatColumn: "absent",
|
|
7
7
|
retiredSteerSeatStaleRows: 0,
|
|
8
|
+
retiredSteerSeatColumn: "absent",
|
|
9
|
+
auditDetailJsonNullRows: 0,
|
|
8
10
|
};
|
|
9
11
|
const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
|
|
10
12
|
const RETIRED_STEER_SEAT_COLUMN = "pending_steer";
|
|
11
13
|
const DELIVERABLE_STEER_SEAT_WHERE = "(status = 'pending' OR (status = 'resolved' AND execution_outcome IS NULL))";
|
|
12
14
|
const RETIRED_ASK_ORIGIN = { from: "unresolvable", to: "ancestor_marked" };
|
|
13
15
|
const GATE_BEARING_LEDGER_EVENT_TYPE = "tool_end";
|
|
16
|
+
const AUDIT_TABLE = "device_audit";
|
|
17
|
+
const AUDIT_DETAIL_COLUMN = "detail";
|
|
14
18
|
export class RetiredSteerSeatError extends Error {
|
|
15
19
|
rows;
|
|
16
20
|
code = "checkpoint.pending_steer_seat_retired";
|
|
@@ -130,6 +134,11 @@ async function migrateOn(db, dialect) {
|
|
|
130
134
|
`WHERE type = '${GATE_BEARING_LEDGER_EVENT_TYPE}' AND data->'gate'->>'origin' = '${RETIRED_ASK_ORIGIN.from}'`)) +
|
|
131
135
|
(await db.exec(`UPDATE checkpoint SET execution_outcome = jsonb_set(execution_outcome, '{origin}', '"${RETIRED_ASK_ORIGIN.to}"') ` +
|
|
132
136
|
`WHERE execution_outcome->>'origin' = '${RETIRED_ASK_ORIGIN.from}'`));
|
|
137
|
+
const auditTablePresent = await tableExists(db, dialect, AUDIT_TABLE);
|
|
138
|
+
const auditDetailJsonNullRows = !auditTablePresent
|
|
139
|
+
? 0
|
|
140
|
+
: await db.exec(`UPDATE ${AUDIT_TABLE} SET ${AUDIT_DETAIL_COLUMN} = NULL ` +
|
|
141
|
+
`WHERE ${AUDIT_DETAIL_COLUMN} IS NOT NULL AND ${jsonColumnTextExpr(dialect, AUDIT_DETAIL_COLUMN)} = 'null'`);
|
|
133
142
|
let retiredSnapshotTable = "absent";
|
|
134
143
|
let retiredSnapshotRows = 0;
|
|
135
144
|
let retiredSteerSeatColumn = "absent";
|
|
@@ -160,7 +169,7 @@ async function migrateOn(db, dialect) {
|
|
|
160
169
|
throw new Error(`one-time migration failed AFTER completing destructive steps — record these before retrying, a retry cannot ` +
|
|
161
170
|
`report them again: ${destroyed.join("; ")}. Underlying failure: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
162
171
|
}
|
|
163
|
-
return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated };
|
|
172
|
+
return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated, auditDetailJsonNullRows };
|
|
164
173
|
}
|
|
165
174
|
export function migrateRetiredAskOrigin(type, data) {
|
|
166
175
|
if (type !== GATE_BEARING_LEDGER_EVENT_TYPE)
|
|
@@ -177,7 +186,7 @@ export function migrateRetiredAskOrigin(type, data) {
|
|
|
177
186
|
return { ...d, gate: { ...g, origin: RETIRED_ASK_ORIGIN.to } };
|
|
178
187
|
}
|
|
179
188
|
export function logOneTimeMigrations(log, backend, report) {
|
|
180
|
-
const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0;
|
|
189
|
+
const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0 || report.auditDetailJsonNullRows > 0;
|
|
181
190
|
(changed ? log.warn : log.info).call(log, "one_time_migrations_applied", {
|
|
182
191
|
backend,
|
|
183
192
|
retiredSnapshotTable: report.retiredSnapshotTable,
|
|
@@ -185,8 +194,11 @@ export function logOneTimeMigrations(log, backend, report) {
|
|
|
185
194
|
retiredSteerSeatColumn: report.retiredSteerSeatColumn,
|
|
186
195
|
retiredSteerSeatStaleRows: report.retiredSteerSeatStaleRows,
|
|
187
196
|
askOriginRowsMigrated: report.askOriginRowsMigrated,
|
|
197
|
+
auditDetailJsonNullRows: report.auditDetailJsonNullRows,
|
|
188
198
|
note: "one-time persisted-data migrations for this version's retirements (retired whole-tree snapshot index table " +
|
|
189
|
-
"dropped; retired single-seat steering column dropped; retired ask-origin spelling rewritten in place
|
|
199
|
+
"dropped; retired single-seat steering column dropped; retired ask-origin spelling rewritten in place; audit " +
|
|
200
|
+
"details written as the JSON text `null` by an earlier version folded to a SQL NULL, so \"no detail\" has exactly " +
|
|
201
|
+
"one on-disk spelling). This " +
|
|
190
202
|
"whole migration step is removed in the next minor — start this version at least once before upgrading past it.",
|
|
191
203
|
});
|
|
192
204
|
}
|
|
@@ -157,6 +157,7 @@ export declare class SqlRuleApprovalRecordStore implements RuleApprovalRecordSto
|
|
|
157
157
|
private q;
|
|
158
158
|
private enc;
|
|
159
159
|
get(id: string): Promise<RuleApprovalRecord | StaleRuleApprovalRecord | undefined>;
|
|
160
|
+
private readApprovalRow;
|
|
160
161
|
create(record: RuleApprovalRecord): Promise<void>;
|
|
161
162
|
/**
|
|
162
163
|
* 丢弃一条**从未被确认**的记录(codex 交叉复审 round8 [medium],可控半场)。
|
|
@@ -6,6 +6,7 @@ import { isMissingColumnError } from "./sql-errors.js";
|
|
|
6
6
|
import { canonicalJson } from "../canonical-json.js";
|
|
7
7
|
import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, isPersistedRuleTool, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
|
|
8
8
|
import { ensureIndexes } from "./ensure-index.js";
|
|
9
|
+
import { createZodColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
|
|
9
10
|
export function writerOfSqlRuleStore(store) {
|
|
10
11
|
const w = Reflect.get(store, PERMISSION_RULE_WRITER);
|
|
11
12
|
if (w === null || typeof w !== "object")
|
|
@@ -211,7 +212,7 @@ export async function ensureTiDBPermissionRuleSchema(pool) {
|
|
|
211
212
|
}
|
|
212
213
|
export async function assertPermissionRuleApprovalSchema(query, dialect) {
|
|
213
214
|
try {
|
|
214
|
-
await query(`SELECT command, edited_json, version, offers_json, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
|
|
215
|
+
await query(`SELECT command, ${jsonColumnText(dialect, "edited_json")}, version, ${jsonColumnText(dialect, "offers_json")}, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
|
|
215
216
|
}
|
|
216
217
|
catch (err) {
|
|
217
218
|
if (!isMissingColumnError(err, dialect))
|
|
@@ -231,7 +232,7 @@ export async function assertPermissionRuleApprovalSchema(query, dialect) {
|
|
|
231
232
|
}
|
|
232
233
|
export async function assertPermissionRuleTicketSchema(query, dialect) {
|
|
233
234
|
try {
|
|
234
|
-
await query(`SELECT redeemed_outcome, claim_id, purpose, settled_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE 1=0`);
|
|
235
|
+
await query(`SELECT ${jsonColumnText(dialect, "redeemed_outcome")}, claim_id, purpose, settled_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE 1=0`);
|
|
235
236
|
}
|
|
236
237
|
catch (err) {
|
|
237
238
|
if (!isMissingColumnError(err, dialect))
|
|
@@ -332,21 +333,21 @@ export function buildRuleBucketChecksum(body) {
|
|
|
332
333
|
}
|
|
333
334
|
const NEXT_DOT_MAX_ATTEMPTS = 8;
|
|
334
335
|
const EMPTY_BUCKET = { actor: "", counter: 0, rev: 0, rules: [], tombstones: [], quarantined: [] };
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
336
|
+
const readRulesJson = createZodColumnReader(z.array(PersistedRuleRowSchema));
|
|
337
|
+
const readTombstonesJson = createZodColumnReader(z.array(RuleTombstoneSchema));
|
|
338
|
+
const readQuarantinedJson = createZodColumnReader(z.array(QuarantinedRuleAddRowSchema));
|
|
339
|
+
const readObservedVectorJson = createZodColumnReader(FrontierSchema);
|
|
340
|
+
const readCandidatesJson = createZodColumnReader(z.array(RuleCandidateSchema));
|
|
341
|
+
const readOffersJson = createZodColumnReader(z.array(RuleOffer2Schema));
|
|
342
|
+
const readRedeemedDotsJson = createZodColumnReader(RedeemedDotsSchema);
|
|
343
|
+
const readEditedJson = createZodColumnReader(RuleEditedSchema);
|
|
344
|
+
async function refusingUnreadableColumns(what, fn) {
|
|
340
345
|
try {
|
|
341
|
-
|
|
346
|
+
return await fn();
|
|
342
347
|
}
|
|
343
|
-
catch (
|
|
344
|
-
throw new Error(
|
|
348
|
+
catch (e) {
|
|
349
|
+
throw foldJsonColumn(e, (m) => new Error(`${m} — the ${what} does not carry a readable shape; refusing the whole of it rather than report a partial one`));
|
|
345
350
|
}
|
|
346
|
-
const parsed = schema.safeParse(json);
|
|
347
|
-
if (!parsed.success)
|
|
348
|
-
throw new Error(`permission_rule.${column} for bucket ${ownerKey} does not carry a readable shape: ${parsed.error.message}`);
|
|
349
|
-
return parsed.data;
|
|
350
351
|
}
|
|
351
352
|
class SqlDurableRulePartition {
|
|
352
353
|
db;
|
|
@@ -368,14 +369,18 @@ class SqlDurableRulePartition {
|
|
|
368
369
|
return this.db.dialect === "tidb" ? tidbSql : pgSql;
|
|
369
370
|
}
|
|
370
371
|
async read() {
|
|
371
|
-
|
|
372
|
+
return refusingUnreadableColumns(`bucket ${this.ownerKey}`, () => this.readBucketRow());
|
|
373
|
+
}
|
|
374
|
+
async readBucketRow() {
|
|
375
|
+
const { rows } = await this.db.query(this.q(`SELECT actor, counter, rev, ${jsonColumnText("tidb", "rules_json")}, ${jsonColumnText("tidb", "tombstones_json")}, ${jsonColumnText("tidb", "quarantined_json")}, ${jsonColumnText("tidb", "observed_vector_json")}, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = ?`, `SELECT actor, counter, rev, ${jsonColumnText("pg", "rules_json")}, ${jsonColumnText("pg", "tombstones_json")}, ${jsonColumnText("pg", "quarantined_json")}, ${jsonColumnText("pg", "observed_vector_json")}, checksum FROM ${PERMISSION_RULE_TABLE} WHERE owner_key = $1`), [this.ownerKey]);
|
|
372
376
|
const row = rows[0];
|
|
373
377
|
if (row === undefined)
|
|
374
378
|
return { ...EMPTY_BUCKET, rules: [], tombstones: [], quarantined: [] };
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
+
const at = `${PERMISSION_RULE_TABLE} bucket ${this.ownerKey}`;
|
|
380
|
+
const rulesRaw = decodeJsonColumn(row.rules_json, readRulesJson, `${at}.rules_json`);
|
|
381
|
+
const tombstonesRaw = decodeJsonColumn(row.tombstones_json, readTombstonesJson, `${at}.tombstones_json`);
|
|
382
|
+
const quarantinedRaw = decodeJsonColumn(row.quarantined_json, readQuarantinedJson, `${at}.quarantined_json`);
|
|
383
|
+
const observedVector = row.observed_vector_json === null || row.observed_vector_json === undefined ? undefined : decodeJsonColumn(row.observed_vector_json, readObservedVectorJson, `${at}.observed_vector_json`);
|
|
379
384
|
const expected = buildRuleBucketChecksum({ rules: rulesRaw, tombstones: tombstonesRaw, quarantined: quarantinedRaw, ...(observedVector !== undefined ? { observedVector } : {}) });
|
|
380
385
|
if (String(row.checksum ?? "") !== expected) {
|
|
381
386
|
throw new Error(`permission_rule bucket ${this.ownerKey} failed its integrity check (stored ${String(row.checksum)}, computed ${expected}) — ` +
|
|
@@ -548,7 +553,10 @@ export class SqlRuleApprovalRecordStore {
|
|
|
548
553
|
return dialectProtocolJsonEncoder(this.db.dialect)(v, label);
|
|
549
554
|
}
|
|
550
555
|
async get(id) {
|
|
551
|
-
|
|
556
|
+
return refusingUnreadableColumns(`approval record ${id}`, () => this.readApprovalRow(id));
|
|
557
|
+
}
|
|
558
|
+
async readApprovalRow(id) {
|
|
559
|
+
const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, version, rev, ${jsonColumnText("tidb", "candidates_json")}, ${jsonColumnText("tidb", "offers_json")}, selected_offer, ${jsonColumnText("tidb", "redeemed_dots_json")}, tool_call_id, bound_input_hash, command, ${jsonColumnText("tidb", "edited_json")}, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, version, rev, ${jsonColumnText("pg", "candidates_json")}, ${jsonColumnText("pg", "offers_json")}, selected_offer, ${jsonColumnText("pg", "redeemed_dots_json")}, tool_call_id, bound_input_hash, command, ${jsonColumnText("pg", "edited_json")}, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
|
|
552
560
|
const row = rows[0];
|
|
553
561
|
if (row === undefined)
|
|
554
562
|
return undefined;
|
|
@@ -558,10 +566,11 @@ export class SqlRuleApprovalRecordStore {
|
|
|
558
566
|
if (Number(row.version) !== RULE_APPROVAL_RECORD_SCHEMA) {
|
|
559
567
|
return { staleSchema: true, id: String(row.record_id), ...identity };
|
|
560
568
|
}
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
const
|
|
569
|
+
const at = `${PERMISSION_RULE_APPROVAL_TABLE} row ${id}`;
|
|
570
|
+
const candidates = decodeJsonColumn(row.candidates_json, readCandidatesJson, `${at}.candidates_json`);
|
|
571
|
+
const offers = decodeJsonColumn(row.offers_json, readOffersJson, `${at}.offers_json`);
|
|
572
|
+
const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : decodeJsonColumn(row.redeemed_dots_json, readRedeemedDotsJson, `${at}.redeemed_dots_json`);
|
|
573
|
+
const edited = row.edited_json === null || row.edited_json === undefined ? undefined : decodeJsonColumn(row.edited_json, readEditedJson, `${at}.edited_json`);
|
|
565
574
|
const kind = z.enum(["card", "import", "starter"]).safeParse(row.kind);
|
|
566
575
|
const state = z.enum(["pending", "approved", "redeemed"]).safeParse(row.state);
|
|
567
576
|
if (!kind.success || !state.success) {
|
|
@@ -716,10 +725,10 @@ export class SqlRuleImportTicketStore {
|
|
|
716
725
|
if (row.redeemedOutcome !== undefined) {
|
|
717
726
|
let outcome;
|
|
718
727
|
try {
|
|
719
|
-
outcome =
|
|
728
|
+
outcome = decodeJsonColumn(row.redeemedOutcome, readJsonObjectColumn, `${PERMISSION_RULE_TICKET_TABLE}.redeemed_outcome for ticket ${ticketId}`);
|
|
720
729
|
}
|
|
721
|
-
catch (
|
|
722
|
-
throw new Error(`${
|
|
730
|
+
catch (e) {
|
|
731
|
+
throw foldJsonColumn(e, (m) => new Error(`${m} — refusing rather than read a settled ticket as still open (which re-runs a redemption that already ran)`));
|
|
723
732
|
}
|
|
724
733
|
return { state: "settled", outcome };
|
|
725
734
|
}
|
|
@@ -738,7 +747,7 @@ export class SqlRuleImportTicketStore {
|
|
|
738
747
|
return affected === 1 ? { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash, claimId } : { ok: false };
|
|
739
748
|
}
|
|
740
749
|
async readRow(ticketId) {
|
|
741
|
-
const { rows } = await this.db.query(this.q(`SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, redeemed_outcome, ${SQL_DB_NOW_MS.tidb} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = ?`, `SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, redeemed_outcome, ${SQL_DB_NOW_MS.pg} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = $1`), [ticketId]);
|
|
750
|
+
const { rows } = await this.db.query(this.q(`SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, ${jsonColumnText("tidb", "redeemed_outcome")}, ${SQL_DB_NOW_MS.tidb} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = ?`, `SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, ${jsonColumnText("pg", "redeemed_outcome")}, ${SQL_DB_NOW_MS.pg} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = $1`), [ticketId]);
|
|
742
751
|
const row = rows[0];
|
|
743
752
|
if (row === undefined)
|
|
744
753
|
return undefined;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SessionPolicyStore, type SessionPermissionRules, type StoredSessionRules, type SessionRulesRecord, type PutRulesOptions } from "@sema-agent/core";
|
|
2
2
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
3
3
|
import type { Pool as PgPool } from "pg";
|
|
4
|
+
import { type JsonColumnShape } from "./sql-json-column.js";
|
|
4
5
|
import { type SqlDriver } from "./sql-driver.js";
|
|
5
6
|
/** `session_policy.policy_key` 的**唯一铸造点**(core 复合键的 sha256)。
|
|
6
7
|
*
|
|
@@ -8,6 +9,29 @@ import { type SqlDriver } from "./sql-driver.js";
|
|
|
8
9
|
* 逐行重算这把键(身份在 hash 的**原像**里,等值 UPDATE 够不着)。两处各写一遍 sha256 表达式的下场是
|
|
9
10
|
* 某天有人在这里改了 key 公式而收编腿还在按旧式算 —— 迁完的行从此谁也读不到,且**静默**。 */
|
|
10
11
|
export declare const policyKeyFor: (sessionId: string, principal: string | undefined) => string;
|
|
12
|
+
/**
|
|
13
|
+
* 一行的 **`rules` 体**(已按文本取出的那一串)→ 收窄好的 `SessionPermissionRules`(C1)。读不出形 ⇒
|
|
14
|
+
* `SessionPolicyError("corrupt")`,与 rev / gen 两道拒同口径、同一个码。
|
|
15
|
+
*
|
|
16
|
+
* 形判据复用**现成的那一只**读者 `readSessionPermissionRules`(五桶 `string[]` 的单一属主;未知键照旧忽略,
|
|
17
|
+
* 与 core 的 `normalizeRules` 同口径),不在这里铸第二份键集。
|
|
18
|
+
*
|
|
19
|
+
* 🔴 **恰好解一次码,而这是由读腿的投影保证的**(codex 对抗复审两轮,两条读法各自实测被推翻后的终形):
|
|
20
|
+
* · 「体是什么就直接判形」——**错**:MariaDB(本仓 README 声明支持的 MySQL 协议后端之一)的 `JSON` 是
|
|
21
|
+
* `LONGTEXT` 的**别名**,驱动只对真 JSON 传输类型自动解码 ⇒ 那台机器上每一条**合法**规则都以文本到达,
|
|
22
|
+
* 会被判成 `corrupt`(三个读点全挂,一台好机器变砖);
|
|
23
|
+
* · 「体是串就再解一次」——**也错**:`JSON`/`JSONB` 列上驱动已经解过一次,于是一行体为 `"{}"`(一个把
|
|
24
|
+
* `JSON.stringify` 多调了一次的迁移脚本的典型产物)会被解成 `{}` 并当作**空规则**端出去 —— 与本道拒
|
|
25
|
+
* 要关的 `null` 洞逐字同形,只是换了个入口。
|
|
26
|
+
* 两条读法错在**同一个地方**:从值本身分不出「驱动解过了」还是「没解过」。⇒ 让这个歧义在**构造上**不存在:
|
|
27
|
+
* 三个读点都把这一列**按文本**投影(`CAST(rules AS CHAR)` / `::text`),于是到这里恒是一只串、恰好解一次。
|
|
28
|
+
* JSON 列里的字符串标量按文本取出来**带着引号**(真库实测两方言同形:`"\"{}\""`)⇒ 解一次得到一只串 ⇒ 判坏;
|
|
29
|
+
* 文本承载的列取出来就是它自己 ⇒ 解一次得到对象 ⇒ 好体。**一条规则,两种承载,零方言分叉,不依赖驱动行为。**
|
|
30
|
+
*
|
|
31
|
+
* 🔴 S-550:上面这一整段现在是**整仓**的口径,实现搬进唯一属主 `sql-json-column.ts`(投影 `jsonColumnText`
|
|
32
|
+
* + 恰好解一次 `decodeJsonColumn` + 折码 `foldJsonColumn`);本店只留「形判据是哪一只」与「坏体折成哪个码」。
|
|
33
|
+
*/
|
|
34
|
+
export declare function readSessionRulesBody(body: unknown): JsonColumnShape<SessionPermissionRules>;
|
|
11
35
|
/** Dual-dialect durable SessionPolicyStore. See the file header for the dialect-delta ledger. */
|
|
12
36
|
export declare class SqlSessionPolicyStore implements SessionPolicyStore {
|
|
13
37
|
private readonly db;
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "7.93.
|
|
3
|
+
"version": "7.93.5",
|
|
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",
|