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