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