@sema-agent/server 7.91.2 → 7.92.1
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/README.md +1 -1
- package/USAGE.md +8 -0
- package/dist/approval-ask-machine.d.ts +1 -1
- package/dist/approval-reconciler.d.ts +1 -1
- package/dist/approval.d.ts +1 -1
- package/dist/bench/s1/repair-oracle-adapter.d.ts +1 -1
- package/dist/bench/s1/runner-ctx.d.ts +1 -1
- package/dist/boot/runner-deps.d.ts +9 -9
- package/dist/capabilities/memory-notice.d.ts +27 -10
- package/dist/capabilities/memory-notice.js +21 -2
- package/dist/config-catalog.js +3 -1
- package/dist/config-center/types.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/env-facts.d.ts +1 -1
- package/dist/execution-lane-caps.d.ts +1 -1
- package/dist/file-history-disclosure.d.ts +2 -2
- package/dist/fleet/fleet-bus.d.ts +7 -1
- package/dist/fleet/fleet-bus.js +5 -3
- package/dist/http/admission.d.ts +1 -1
- package/dist/http/idempotency.d.ts +27 -0
- package/dist/http/route-ctx.d.ts +2 -5
- package/dist/http/routes/memory-compliance.d.ts +1 -1
- package/dist/http/routes/memory-origin.d.ts +2 -2
- package/dist/http/routes/rules.js +3 -1
- package/dist/http/routes/runs.js +3 -6
- package/dist/http/routes/session-sync.js +3 -2
- package/dist/http/routes/sessions.js +3 -2
- package/dist/http/routes/tasks.js +35 -19
- package/dist/http/wire-types.d.ts +29 -2
- package/dist/leader/wire.d.ts +1 -1
- package/dist/lsp/manager.d.ts +2 -2
- package/dist/memory-operator-faces.d.ts +13 -13
- package/dist/memory-scope.d.ts +2 -2
- package/dist/observability/fail-open.d.ts +1 -1
- package/dist/observability/fail-open.js +1 -1
- package/dist/observability/metrics.js +2 -1
- package/dist/orchestration/workflow-agent-session-index.d.ts +2 -2
- package/dist/peer-directory.d.ts +3 -2
- package/dist/permission-rule-vocab.d.ts +16 -1
- package/dist/plugins/file-run-store.d.ts +1 -1
- package/dist/plugins/local-session-store.d.ts +1 -1
- package/dist/plugins/memory-session-policy-store.d.ts +22 -3
- package/dist/plugins/memory-session-policy-store.js +11 -4
- package/dist/plugins/permission-rule-store-sql.js +16 -7
- package/dist/plugins/pg-pool.js +11 -0
- package/dist/plugins/remote-env-host.d.ts +65 -0
- package/dist/plugins/remote-env-host.js +155 -100
- package/dist/plugins/session-policy-store-sql.d.ts +51 -3
- package/dist/plugins/session-policy-store-sql.js +77 -38
- package/dist/plugins/sql-errors.d.ts +25 -0
- package/dist/plugins/sql-errors.js +10 -0
- package/dist/plugins/store-backend.d.ts +5 -5
- package/dist/plugins/store-contracts.d.ts +2 -2
- package/dist/plugins/tidb-pool.d.ts +1 -1
- package/dist/plugins/tidb-pool.js +19 -0
- package/dist/rules-consent.d.ts +16 -9
- package/dist/rules-consent.js +14 -35
- package/dist/run-cancel-context.d.ts +1 -1
- package/dist/run-local.d.ts +1 -1
- package/dist/runtime-caps-resolver.d.ts +10 -10
- package/dist/runtime-governance.d.ts +2 -2
- package/dist/session-policy-wire.d.ts +25 -1
- package/dist/session-policy-wire.js +11 -0
- package/dist/spec-fields.d.ts +4 -4
- package/dist/task-cwd.d.ts +1 -1
- package/dist/task-settings.d.ts +2 -2
- package/dist/tool-approval.d.ts +1 -1
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/dist/trace/project.d.ts +25 -1
- package/dist/trace/project.js +4 -0
- package/dist/trace/redact.d.ts +1 -1
- package/dist/trace/redact.js +8 -3
- package/package.json +3 -3
|
@@ -103,6 +103,55 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
|
|
|
103
103
|
* `exitCode 0`。对模型而言那与「成功且无输出」**不可区分**。(与 core [ref] 同形:写已释放的 inode。)
|
|
104
104
|
* 判据与 `hasShells()` 并列:**只要还有人在读写这个根下的文件,就不删它**。 */
|
|
105
105
|
private readonly liveFgSpools;
|
|
106
|
+
/** 🔴 S-539 在飞的 fs **写**登记表(`liveFgSpools` 同律:「还有人在动这棵树,就别删它」)。
|
|
107
|
+
* 为什么按 promise 存而不是计数:只有 promise 才能被 `destroy()` **await**;计数只能轮询。 */
|
|
108
|
+
private readonly inflightFs;
|
|
109
|
+
/** 一次性:慢 drain 只抱怨一次(一条按次数刷屏的 teardown 读数没人会看)。 */
|
|
110
|
+
private inflightFsDrainWarned;
|
|
111
|
+
/**
|
|
112
|
+
* 🔴 S-539 —— 把一只**会在 workspace / spool 根里建路径或改路径**的 fs 动作登记为「在飞」,直到它 settle。
|
|
113
|
+
*
|
|
114
|
+
* ── 病(旧 `destroy()` 的自称与实情)──────────────────────────────────────────────────────────
|
|
115
|
+
* `destroy()` 只 await 了 `ensurePrepared()` 那只 memo(`preparing`),于是它顶注里的
|
|
116
|
+
* 「eliminating the prepared-flag race entirely」只对 **prepare 那一段**成立。写面在
|
|
117
|
+
* `await this.fsReady(p)` 放行**之后**还会自己碰一次磁盘(`writeFile`/`appendFile` 的
|
|
118
|
+
* `mkdir(dirname)`、`createDir` 的 `mkdir`、`createTempDir`/`createTempFile` 在 spool 根下建),
|
|
119
|
+
* 那一次调用与 destroy 的 `fs.rm` 是**两条独立 fs 调用在 libuv 线程池里赛跑**:rm 先 ⇒ `mkdir -p` 把已拆除
|
|
120
|
+
* 的工作区重建成**孤儿**(而 rm 成功后 `untrackEphemeralHostWorkspace()` 已注销 ⇒ 兜底腿也捞不回);
|
|
121
|
+
* mkdir 先 ⇒ 随后 rm 删掉,调用方拿到 `EINVAL`/`ENOENT` 而不是 "already destroyed"。
|
|
122
|
+
*
|
|
123
|
+
* ── 治(一处定义,十几个面共用)────────────────────────────────────────────────────────────────
|
|
124
|
+
* 调用点必须在方法体的**第一个同步段**里调它(第一个 `await` 之前),登记才与 `destroy()` 置 `destroyed`
|
|
125
|
+
* 的同步段互斥。于是二者只有两种相对次序,**两种都安全**:
|
|
126
|
+
* · 先登记 ⇒ destroy 的快照看得到它,`rm` 等它 settle 之后才发;
|
|
127
|
+
* · 后登记 ⇒ 这一面自己的 `fsReady` 看得到 `destroyed`,一个字节都不碰磁盘。
|
|
128
|
+
* 登记放在 `await this.fsReady(...)` **之后**就没有这个性质:那一刻已经跨过一个 microtask,destroy 可能刚好
|
|
129
|
+
* 在这中间把快照取完了。所以形状是 `return this.withInflightFs(async () => { …原方法体… })`,
|
|
130
|
+
* 而不是在方法体里补一句 `if (this.destroyed)` —— 后者仍然不原子(查完到动手之间还有让出点)。
|
|
131
|
+
*
|
|
132
|
+
* ── 读面刻意不进来 ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
* ① 读不建路径:拆除之后一次在飞的读拿到 ENOENT 是**诚实**的,复活目录这条病与它无关;
|
|
134
|
+
* ② 把读拉进来会让 teardown 去等一次可能**永不 settle** 的读(workspace 里的 FIFO / 特殊文件),
|
|
135
|
+
* 把 destroy 挂死 —— 拿一条新的可用性病换一条不存在的正确性收益。
|
|
136
|
+
* 判据因此是一句话:**「可能建路径或改路径的那些面」进在飞集合**,不是「过了 fsReady 的都进」。
|
|
137
|
+
*/
|
|
138
|
+
private withInflightFs;
|
|
139
|
+
/**
|
|
140
|
+
* 等 `destroy()` 取到的那批在飞写全部 settle。**永不抛**;答「在时限内清空了吗」。
|
|
141
|
+
*
|
|
142
|
+
* 🔴 两条都不能要,所以有第三条(合并复审 [high] 打出来的):
|
|
143
|
+
* ① 无界等 —— workspace 里的 FIFO / 特殊文件会让一次 `writeFile` **永不 settle**,而拆除在 run 收尾路径上
|
|
144
|
+
* ⇒ 整条收尾停摆。用一条新的可用性病换一条正确性收益,不划算。
|
|
145
|
+
* ② 到点就照删 —— 把 S-539 的竞态**原样**搬回来(rm 与在飞 mkdir 再次在线程池里赛跑),只是把窗口从
|
|
146
|
+
* 「总是」改成「慢的时候」;一条确定的病换一条间歇的病,更糟。
|
|
147
|
+
* ③ **到点就不再占着调用方,但 rm 依旧排在那批写之后**(`destroy()` 里的 `void …then(removeOwnedTrees)`)。
|
|
148
|
+
* 次序保证一个字节不松,teardown 变成「有界返回」。teardown 契约(core 1.258.2)禁的是**抛**,不是**慢**;
|
|
149
|
+
* 这一条连慢都不欠。
|
|
150
|
+
*
|
|
151
|
+
* 快照是 `destroy()` 在同步段取的,所以 destroy **之后**才登记的那些面(唯一一族:`appendFile` 的
|
|
152
|
+
* `lingeringBgWrite` 残余镜像写)不在这一批里 —— 它们按设计就是跨 destroy 存活的,等它们等于等后台 shell。
|
|
153
|
+
*/
|
|
154
|
+
private drainInflightFs;
|
|
106
155
|
private ensureSpoolRoot;
|
|
107
156
|
/** True when `workspaceDir` was given (user-owned persistent dir) → destroy() must NOT delete it. */
|
|
108
157
|
private readonly persistent;
|
|
@@ -155,6 +204,10 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
|
|
|
155
204
|
* core 留活过的驻留 watcher 又杀了一遍(run 尾 dispose→destroy 顺序,destroy 是第二刀)。隔离 env(容器/VM)
|
|
156
205
|
* 整体拆机自然带走进程,与本契约不冲突(k8s/e2b 各自 destroy 不走这条路径)。 */
|
|
157
206
|
destroy(): Promise<void>;
|
|
207
|
+
/** `destroy()` 真正动手删的那一段(本 env **自有**的两棵树:spool 根 + ephemeral workspace)。
|
|
208
|
+
* 抽成一只是因为它有两个调用时机 —— 在飞写按时清空时由 `destroy()` 直接 await,超时时挂在那批写后面异步跑
|
|
209
|
+
* (见 {@link drainInflightFs} 头注③)。永不抛(teardown 契约)。 */
|
|
210
|
+
private removeOwnedTrees;
|
|
158
211
|
cleanup(): Promise<void>;
|
|
159
212
|
get backgroundCapabilities(): BackgroundShellCapability["backgroundCapabilities"];
|
|
160
213
|
private _bgManager?;
|
|
@@ -210,7 +263,12 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
|
|
|
210
263
|
abortSignal?: AbortSignal;
|
|
211
264
|
}): Promise<Result<string[], FileError>>;
|
|
212
265
|
readBinaryFile(p: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
|
|
266
|
+
/** S-539:整只方法体(**含** `fsReady` 那道门)走 `withInflightFs` —— 登记必须与调用方同一同步段,
|
|
267
|
+
* 否则 destroy 的快照可能刚好在 `await this.fsReady(...)` 的让出点上取完。见那处头注。 */
|
|
213
268
|
writeFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
|
|
269
|
+
/** S-539(写面一律 `withInflightFs`,见那处头注)。本面是唯一能在 `destroyed` **之后**过门的写面
|
|
270
|
+
* (`lingeringBgWrite`):那一族登记在 destroy 的快照**之后**,所以 destroy 不会去等它们 —— 正确,
|
|
271
|
+
* 它们按设计跨 destroy 存活(等它们等于等后台 shell)。 */
|
|
214
272
|
appendFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
|
|
215
273
|
fileInfo(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
|
|
216
274
|
listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
|
|
@@ -231,10 +289,14 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
|
|
|
231
289
|
*/
|
|
232
290
|
canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
|
|
233
291
|
exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
|
292
|
+
/** S-539(写面一律 `withInflightFs`,见那处头注)。 */
|
|
234
293
|
createDir(p: string, options?: {
|
|
235
294
|
recursive?: boolean;
|
|
236
295
|
abortSignal?: AbortSignal;
|
|
237
296
|
}): Promise<Result<void, FileError>>;
|
|
297
|
+
/** S-539(写面一律 `withInflightFs`,见那处头注)。本面**不会**复活目录(`fs.rm` 只删),进在飞集合的理由是
|
|
298
|
+
* 同一条:destroy 的 rm 把树从它脚下抽走 ⇒ 一次本该 ok 的删返回 `not_found`。判据是「可能建或改路径」,
|
|
299
|
+
* 不是「可能复活目录」—— 少一条例外就少一处以后要重新想清楚的地方。 */
|
|
238
300
|
remove(p: string, options?: {
|
|
239
301
|
recursive?: boolean;
|
|
240
302
|
force?: boolean;
|
|
@@ -252,7 +314,10 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
|
|
|
252
314
|
* · 镜像写仍然可用 —— `isLingeringMirrorTarget` 同批放行这个根(见那里的旁注);
|
|
253
315
|
* · ephemeral 形反而更好:`destroy()` 在**有后台 shell 在册**时不删这个根,而 workspace 是会被 rm 的。
|
|
254
316
|
* ⚠️ 这是 **host-only**:k8s/local-docker 的临时文件必须落在**容器内**可见的 cwd,ssh/e2b 本来就用远端系统 tmp。 */
|
|
317
|
+
/** S-539(写面一律 `withInflightFs`,见那处头注)。这一面建在 **spool 根**下,而 destroy 也会整树删那个根 ——
|
|
318
|
+
* `fs.mkdtemp` 不建父目录,所以它不会复活什么,坏处是一次本该成功的调用返回 `not_found`。 */
|
|
255
319
|
createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
|
320
|
+
/** S-539(写面一律 `withInflightFs`,见那处头注;spool 根同 `createTempDir`)。 */
|
|
256
321
|
createTempFile(options?: {
|
|
257
322
|
prefix?: string;
|
|
258
323
|
suffix?: string;
|
|
@@ -14,6 +14,7 @@ import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
|
|
|
14
14
|
import { trackEphemeralHostWorkspace, untrackEphemeralHostWorkspace } from "./host-workspace-registry.js";
|
|
15
15
|
const PROVIDER = "host";
|
|
16
16
|
const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
|
17
|
+
const inflightFsDrainWaitMs = () => numEnvOr("HOST_FS_DRAIN_WAIT_MS", 10_000, 1);
|
|
17
18
|
const HOST_BG_MAX_CONCURRENT = numEnvOr("HOST_BG_MAX_CONCURRENT", 8, 1);
|
|
18
19
|
const HOST_BG_DEFAULT_TIMEOUT_SEC = numEnvOr("HOST_BG_DEFAULT_TIMEOUT_SEC", 300, 1);
|
|
19
20
|
const HOST_BG_MAX_TIMEOUT_SEC = numEnvOr("HOST_BG_MAX_TIMEOUT_SEC", 1800, HOST_BG_DEFAULT_TIMEOUT_SEC);
|
|
@@ -62,6 +63,41 @@ export class RemoteHostExecutionEnv {
|
|
|
62
63
|
destroyed = false;
|
|
63
64
|
spoolRootDir;
|
|
64
65
|
liveFgSpools = new Set();
|
|
66
|
+
inflightFs = new Set();
|
|
67
|
+
inflightFsDrainWarned = false;
|
|
68
|
+
withInflightFs(fn) {
|
|
69
|
+
const p = fn();
|
|
70
|
+
this.inflightFs.add(p);
|
|
71
|
+
const forget = () => void this.inflightFs.delete(p);
|
|
72
|
+
void p.then(forget, forget);
|
|
73
|
+
return p;
|
|
74
|
+
}
|
|
75
|
+
async drainInflightFs(inflight) {
|
|
76
|
+
if (inflight.length === 0)
|
|
77
|
+
return true;
|
|
78
|
+
const settled = Promise.allSettled(inflight);
|
|
79
|
+
const waitMs = inflightFsDrainWaitMs();
|
|
80
|
+
const timedOut = Symbol("inflight-fs-drain-timeout");
|
|
81
|
+
let timer;
|
|
82
|
+
const deadline = new Promise((resolve) => {
|
|
83
|
+
timer = setTimeout(() => resolve(timedOut), waitMs);
|
|
84
|
+
timer.unref?.();
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const drained = (await Promise.race([settled, deadline])) !== timedOut;
|
|
88
|
+
if (!drained && !this.inflightFsDrainWarned) {
|
|
89
|
+
this.inflightFsDrainWarned = true;
|
|
90
|
+
console.warn(`RemoteHostExecutionEnv.destroy: ${this.inflightFs.size} in-flight filesystem write(s) did not settle within ` +
|
|
91
|
+
`${waitMs}ms — returning from teardown now; removal of ${this.workspaceDir} stays ordered AFTER those writes ` +
|
|
92
|
+
`and will land when they finish (a write that never settles, e.g. a FIFO target inside the workspace, defers it)`);
|
|
93
|
+
}
|
|
94
|
+
return drained;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (timer)
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
65
101
|
ensureSpoolRoot() {
|
|
66
102
|
if (!this.spoolRootDir)
|
|
67
103
|
this.spoolRootDir = mkdtempSync(path.join(os.tmpdir(), "sema-host-spool-"));
|
|
@@ -130,25 +166,32 @@ export class RemoteHostExecutionEnv {
|
|
|
130
166
|
this.destroyed = true;
|
|
131
167
|
const p = this.preparing;
|
|
132
168
|
this.preparing = undefined;
|
|
169
|
+
const inflight = [...this.inflightFs];
|
|
133
170
|
try {
|
|
134
171
|
await p?.catch(() => { });
|
|
135
172
|
}
|
|
136
173
|
finally {
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return;
|
|
174
|
+
if (await this.drainInflightFs(inflight))
|
|
175
|
+
await this.removeOwnedTrees();
|
|
176
|
+
else
|
|
177
|
+
void Promise.allSettled(inflight).then(() => this.removeOwnedTrees());
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async removeOwnedTrees() {
|
|
181
|
+
if (this.spoolRootDir && !this.bgManager.hasShells() && this.liveFgSpools.size === 0) {
|
|
146
182
|
try {
|
|
147
|
-
await fs.rm(this.
|
|
148
|
-
untrackEphemeralHostWorkspace(this.workspaceDir);
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
183
|
+
await fs.rm(this.spoolRootDir, { recursive: true, force: true });
|
|
151
184
|
}
|
|
185
|
+
catch { }
|
|
186
|
+
this.spoolRootDir = undefined;
|
|
187
|
+
}
|
|
188
|
+
if (this.persistent)
|
|
189
|
+
return;
|
|
190
|
+
try {
|
|
191
|
+
await fs.rm(this.workspaceDir, { recursive: true, force: true });
|
|
192
|
+
untrackEphemeralHostWorkspace(this.workspaceDir);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
152
195
|
}
|
|
153
196
|
}
|
|
154
197
|
async cleanup() {
|
|
@@ -905,37 +948,41 @@ export class RemoteHostExecutionEnv {
|
|
|
905
948
|
return { ok: false, error: this.fileError(e, abs, abortSignal) };
|
|
906
949
|
}
|
|
907
950
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
951
|
+
writeFile(p, content, abortSignal) {
|
|
952
|
+
return this.withInflightFs(async () => {
|
|
953
|
+
const ready = await this.fsReady(p);
|
|
954
|
+
if (!ready.ok)
|
|
955
|
+
return ready;
|
|
956
|
+
const abs = this.resolve(p);
|
|
957
|
+
try {
|
|
958
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
959
|
+
const data = typeof content === "string" ? content : Buffer.from(content);
|
|
960
|
+
await fs.writeFile(abs, data, { signal: abortSignal });
|
|
961
|
+
return ok(undefined);
|
|
962
|
+
}
|
|
963
|
+
catch (e) {
|
|
964
|
+
return { ok: false, error: this.fileError(e, abs, abortSignal) };
|
|
965
|
+
}
|
|
966
|
+
});
|
|
922
967
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
968
|
+
appendFile(p, content, abortSignal) {
|
|
969
|
+
return this.withInflightFs(async () => {
|
|
970
|
+
const ready = await this.fsReady(p, { lingeringBgWrite: true });
|
|
971
|
+
if (!ready.ok)
|
|
972
|
+
return ready;
|
|
973
|
+
const abs = this.resolve(p);
|
|
974
|
+
try {
|
|
975
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
976
|
+
if (abortSignal?.aborted)
|
|
977
|
+
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
978
|
+
const data = typeof content === "string" ? content : Buffer.from(content);
|
|
979
|
+
await fs.appendFile(abs, data);
|
|
980
|
+
return ok(undefined);
|
|
981
|
+
}
|
|
982
|
+
catch (e) {
|
|
983
|
+
return { ok: false, error: this.fileError(e, abs, abortSignal) };
|
|
984
|
+
}
|
|
985
|
+
});
|
|
939
986
|
}
|
|
940
987
|
async fileInfo(p, abortSignal) {
|
|
941
988
|
const ready = await this.fsReady(p);
|
|
@@ -1058,66 +1105,74 @@ export class RemoteHostExecutionEnv {
|
|
|
1058
1105
|
return { ok: false, error: fe };
|
|
1059
1106
|
}
|
|
1060
1107
|
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
catch (e) {
|
|
1071
|
-
return { ok: false, error: this.fileError(e, abs, options?.abortSignal) };
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
async remove(p, options) {
|
|
1075
|
-
const ready = await this.fsReady(p);
|
|
1076
|
-
if (!ready.ok)
|
|
1077
|
-
return ready;
|
|
1078
|
-
const abs = this.resolve(p);
|
|
1079
|
-
try {
|
|
1080
|
-
if (!options?.force) {
|
|
1081
|
-
await fs.lstat(abs);
|
|
1108
|
+
createDir(p, options) {
|
|
1109
|
+
return this.withInflightFs(async () => {
|
|
1110
|
+
const ready = await this.fsReady(p);
|
|
1111
|
+
if (!ready.ok)
|
|
1112
|
+
return ready;
|
|
1113
|
+
const abs = this.resolve(p);
|
|
1114
|
+
try {
|
|
1115
|
+
await fs.mkdir(abs, { recursive: options?.recursive ?? true });
|
|
1116
|
+
return ok(undefined);
|
|
1082
1117
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1118
|
+
catch (e) {
|
|
1119
|
+
return { ok: false, error: this.fileError(e, abs, options?.abortSignal) };
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
remove(p, options) {
|
|
1124
|
+
return this.withInflightFs(async () => {
|
|
1125
|
+
const ready = await this.fsReady(p);
|
|
1126
|
+
if (!ready.ok)
|
|
1127
|
+
return ready;
|
|
1128
|
+
const abs = this.resolve(p);
|
|
1129
|
+
try {
|
|
1130
|
+
if (!options?.force) {
|
|
1131
|
+
await fs.lstat(abs);
|
|
1132
|
+
}
|
|
1133
|
+
await fs.rm(abs, { recursive: options?.recursive ?? false, force: options?.force ?? false });
|
|
1089
1134
|
return ok(undefined);
|
|
1090
|
-
|
|
1091
|
-
|
|
1135
|
+
}
|
|
1136
|
+
catch (e) {
|
|
1137
|
+
const fe = this.fileError(e, abs, options?.abortSignal);
|
|
1138
|
+
if (options?.force && fe.code === "not_found")
|
|
1139
|
+
return ok(undefined);
|
|
1140
|
+
return { ok: false, error: fe };
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1092
1143
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1144
|
+
createTempDir(prefix = "tmp-", abortSignal) {
|
|
1145
|
+
return this.withInflightFs(async () => {
|
|
1146
|
+
const ready = await this.fsReady(".");
|
|
1147
|
+
if (!ready.ok)
|
|
1148
|
+
return ready;
|
|
1149
|
+
try {
|
|
1150
|
+
return ok(await fs.mkdtemp(path.join(this.ensureSpoolRoot(), prefix)));
|
|
1151
|
+
}
|
|
1152
|
+
catch (e) {
|
|
1153
|
+
return { ok: false, error: this.fileError(e, this.workspaceDir, abortSignal) };
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1103
1156
|
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1157
|
+
createTempFile(options) {
|
|
1158
|
+
return this.withInflightFs(async () => {
|
|
1159
|
+
const ready = await this.fsReady(".");
|
|
1160
|
+
if (!ready.ok)
|
|
1161
|
+
return ready;
|
|
1162
|
+
const prefix = options?.prefix ?? "";
|
|
1163
|
+
const suffix = options?.suffix ?? "";
|
|
1164
|
+
try {
|
|
1165
|
+
const name = `${prefix}${randomBytes(8).toString("hex")}${suffix}`;
|
|
1166
|
+
const abs = path.join(this.ensureSpoolRoot(), name);
|
|
1167
|
+
if (options?.abortSignal?.aborted)
|
|
1168
|
+
throw Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
1169
|
+
await fs.writeFile(abs, "", { flag: "wx" });
|
|
1170
|
+
return ok(abs);
|
|
1171
|
+
}
|
|
1172
|
+
catch (e) {
|
|
1173
|
+
return { ok: false, error: this.fileError(e, this.workspaceDir, options?.abortSignal) };
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1121
1176
|
}
|
|
1122
1177
|
resolve(p) {
|
|
1123
1178
|
return path.isAbsolute(p) ? path.normalize(p) : path.normalize(path.resolve(this.cwd, p));
|
|
@@ -1207,7 +1262,7 @@ export class RemoteHostExecutionEnv {
|
|
|
1207
1262
|
const rel = path.relative(real(root), real(abs));
|
|
1208
1263
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
1209
1264
|
};
|
|
1210
|
-
return inside(this.
|
|
1265
|
+
return (this.spoolRootDir !== undefined && inside(this.spoolRootDir)) || (this.persistent && inside(this.workspaceDir));
|
|
1211
1266
|
}
|
|
1212
1267
|
async fsReady(p, opts) {
|
|
1213
1268
|
if (this.destroyed) {
|
|
@@ -25,9 +25,57 @@ export declare class SqlSessionPolicyStore implements SessionPolicyStore {
|
|
|
25
25
|
listBySession(sessionId: string): Promise<SessionRulesRecord[]>;
|
|
26
26
|
/** Operator/owner write: CAS + tighten-only (unless operator) + rev bump, atomic in one txn; first-write race retried. */
|
|
27
27
|
putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
/**
|
|
29
|
+
* The store's ONE retry arm: re-run the whole read-check-write transaction while the failure is an engine
|
|
30
|
+
* ARBITRATION artifact rather than an answer. Two such artifacts, and only two:
|
|
31
|
+
* · **dup-key** — a concurrent FIRST write won the PK (no row ⇒ `FOR UPDATE` locked nothing). The re-read takes
|
|
32
|
+
* the UPDATE/CAS path, which is core's serialized outcome; converting it to `conflict` in place would be a
|
|
33
|
+
* spurious answer the caller can't tell from a real CAS loss.
|
|
34
|
+
* · **deadlock** — the engine picked THIS transaction as the victim and already rolled it back. Which side gets
|
|
35
|
+
* picked is arbitrary, so propagating it hands the caller an internal coin flip. 🔴 This arm is NOT a licence
|
|
36
|
+
* for sloppy lock order: the order is still policy row → mark row on BOTH methods, and the oracle for it is
|
|
37
|
+
* `test/sql-lock-shape-innodb-integration.test.ts` (site ⑩, three engines). It exists because on REAL InnoDB
|
|
38
|
+
* (REPEATABLE READ) a `FOR UPDATE` read of a MISSING primary key takes a GAP lock, and gap locks are mutually
|
|
39
|
+
* compatible while both block the other side's INSERT — so N concurrent first writes on one key deadlock NO
|
|
40
|
+
* MATTER what order any table is touched in. Measured on the real engine (2026-09-20, S-529, 8-way race on
|
|
41
|
+
* `main`'s statement sequence, zero mark table involved): InnoDB 1 winner / 7 `ER_LOCK_DEADLOCK`, TiDB 0,
|
|
42
|
+
* PG 0 — i.e. this is a PRE-EXISTING MySQL-leg defect that S-529's lock-shape site made visible, not a new one.
|
|
43
|
+
* After the first insert commits, the retried writers find the row and take the UPDATE path (record lock,
|
|
44
|
+
* no gap) ⇒ the loop converges in one extra round per missing-row window; the bound is the suite's concurrency.
|
|
45
|
+
* Lock-WAIT-TIMEOUT is deliberately NOT retried ({@link isDeadlockError}'s own note: it is a latency signal,
|
|
46
|
+
* and retrying it multiplies an already-long wait).
|
|
47
|
+
*/
|
|
48
|
+
private retrying;
|
|
49
|
+
/** E21 purge — drop ALL policy rows for a session (every principal), KEEPING each key's rev high-water mark.
|
|
50
|
+
* Scoped by session_id (the route owner-gates). Return shape aligned to core 1.423's optional interface seam
|
|
51
|
+
* (`Promise<void>`) — the affectedRows count was incidental and unconsumed; the integration test asserts the
|
|
52
|
+
* post-state (listBySession empty) instead.
|
|
53
|
+
*
|
|
54
|
+
* S-529 (`@contract session_policy.rev_survives_delete`): the rules ROW dies, the key's REV LINE does not, and the
|
|
55
|
+
* erase ALSO ENDS THE ROW'S LINEAGE (`gen` + 1 — the next write on this key belongs to another generation, which no
|
|
56
|
+
* reader treats as a continuation of the erased row whatever rev it wears) — one
|
|
57
|
+
* transaction, three statements, and BOTH the order and the locking verb are load-bearing:
|
|
58
|
+
* ① `SELECT … ORDER BY policy_key FOR UPDATE` — the rows to erase are read and X-LOCKED first, in a DETERMINISTIC
|
|
59
|
+
* order. This is the SAME lock order putRules uses (policy row → mark row), which is the whole point: the
|
|
60
|
+
* obvious shape (`INSERT … SELECT` the marks, then DELETE) locks the MARK first and takes only a SHARED lock
|
|
61
|
+
* on the policy rows it reads, i.e. a reversed order PLUS an S→X upgrade — the textbook deadlock this repo
|
|
62
|
+
* 已经吃过两次(retention-store-sql.ts ④a/④b 的 S-131 案:一边持 boundary 等哨兵,一边持哨兵等 boundary)。
|
|
63
|
+
* ② the marks (their revs come from ①'s already-locked rows) — `GREATEST` merge, so a mark only ever RISES and
|
|
64
|
+
* repeated erases never walk the line back.
|
|
65
|
+
* ③ the DELETE — scoped to ①'s POLICY_KEY SET, never re-scanned by `session_id`. 🔴 This is the whole of
|
|
66
|
+
* codex r1 [high] #1 (verified, not rebutted): both engines re-evaluate a `WHERE session_id = ?` DELETE
|
|
67
|
+
* against a CURRENT read (PG is pinned to READ COMMITTED, TiDB's DML is a current read), so a row for a
|
|
68
|
+
* DIFFERENT principal that a concurrent putRules commits BETWEEN ① and ③ would be deleted by a session-wide
|
|
69
|
+
* DELETE while ② never marked it — a silently reset rev line, exactly the defect this batch exists to close.
|
|
70
|
+
* Scoped to the key set, the invariant is exact and checkable: **every row this seam deletes leaves a mark**.
|
|
71
|
+
* A row born after ① is simply not part of this erase (same as one born a microsecond after ③ — the caller
|
|
72
|
+
* that needs "and nothing came back" re-issues; the seam is idempotent).
|
|
73
|
+
* No rows ⇒ return early: an erase of a session that never had rules writes NO mark (no line, nothing to keep),
|
|
74
|
+
* which is what makes a repeated/empty delete idempotent instead of resurrecting a mark.
|
|
75
|
+
* Why the mark is a separate table and not a tombstone row: see the DDL comment in tidb-pool.ts — a tombstone
|
|
76
|
+
* would keep `session_id` + the DENORMALIZED plaintext `principal` in the table after an E21 purge, i.e. the
|
|
77
|
+
* right-to-delete would degrade to buy rev ordering. The mark keeps only the one-way hash, the session id and
|
|
78
|
+
* an integer. */
|
|
31
79
|
deleteBySession(sessionId: string): Promise<void>;
|
|
32
80
|
/**
|
|
33
81
|
* The store's one read-check-write transaction. Its load-bearing read is line ~106's
|
|
@@ -2,10 +2,22 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { loosenReasons, SessionPolicyError, normalizeRules, stripRev } from "@sema-agent/core";
|
|
3
3
|
import { pgHasUnstorable } from "./pg-safe-json.js";
|
|
4
4
|
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
5
|
-
import { isDupKeyError } from "./sql-errors.js";
|
|
5
|
+
import { isDeadlockError, isDupKeyError } from "./sql-errors.js";
|
|
6
6
|
export const policyKeyFor = (sessionId, principal) => createHash("sha256").update(JSON.stringify([sessionId, principal ?? null])).digest("hex");
|
|
7
7
|
const policyKey = policyKeyFor;
|
|
8
|
+
const RETRY_LIMIT = 8;
|
|
8
9
|
const parseRules = (v) => (typeof v === "string" ? JSON.parse(v) : v);
|
|
10
|
+
function rowToStored(r, where) {
|
|
11
|
+
const rev = Number(r.rev);
|
|
12
|
+
if (!Number.isFinite(rev) || !Number.isInteger(rev) || rev < 1) {
|
|
13
|
+
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`);
|
|
14
|
+
}
|
|
15
|
+
const gen = Number(r.gen ?? 0);
|
|
16
|
+
if (!Number.isFinite(gen) || !Number.isInteger(gen) || gen < 0) {
|
|
17
|
+
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`);
|
|
18
|
+
}
|
|
19
|
+
return { ...parseRules(r.rules), rev, gen };
|
|
20
|
+
}
|
|
9
21
|
export class SqlSessionPolicyStore {
|
|
10
22
|
db;
|
|
11
23
|
constructor(db) {
|
|
@@ -18,62 +30,89 @@ export class SqlSessionPolicyStore {
|
|
|
18
30
|
return isDupKeyError(this.db.dialect, e);
|
|
19
31
|
}
|
|
20
32
|
async getRules(sessionId, principal) {
|
|
21
|
-
const { rows } = await this.db.query(this.q("SELECT rules, rev FROM session_policy WHERE policy_key = ?", "SELECT rules, rev FROM session_policy WHERE policy_key = $1"), [policyKey(sessionId, principal)]);
|
|
33
|
+
const { rows } = await this.db.query(this.q("SELECT rules, rev, gen FROM session_policy WHERE policy_key = ?", "SELECT rules, rev, gen FROM session_policy WHERE policy_key = $1"), [policyKey(sessionId, principal)]);
|
|
22
34
|
if (!rows[0])
|
|
23
35
|
return null;
|
|
24
|
-
return
|
|
36
|
+
return rowToStored(rows[0], `for session ${JSON.stringify(sessionId)}`);
|
|
25
37
|
}
|
|
26
38
|
async listBySession(sessionId) {
|
|
27
|
-
const { rows } = await this.db.query(this.q("SELECT principal, rules, rev FROM session_policy WHERE session_id = ?", "SELECT principal, rules, rev FROM session_policy WHERE session_id = $1"), [sessionId]);
|
|
39
|
+
const { rows } = await this.db.query(this.q("SELECT principal, rules, rev, gen FROM session_policy WHERE session_id = ?", "SELECT principal, rules, rev, gen FROM session_policy WHERE session_id = $1"), [sessionId]);
|
|
28
40
|
return rows.map((r) => ({
|
|
29
41
|
principal: r.principal == null ? undefined : String(r.principal),
|
|
30
|
-
rules:
|
|
42
|
+
rules: rowToStored(r, `for session ${JSON.stringify(sessionId)} (enumeration)`),
|
|
31
43
|
}));
|
|
32
44
|
}
|
|
33
45
|
async putRules(sessionId, principal, rules, opts) {
|
|
34
46
|
const key = policyKey(sessionId, principal);
|
|
35
|
-
|
|
47
|
+
return this.retrying(() => this.tx(async (conn) => {
|
|
48
|
+
const { rows } = await conn.query(this.q("SELECT rules, rev, gen FROM session_policy WHERE policy_key = ? FOR UPDATE", "SELECT rules, rev, gen FROM session_policy WHERE policy_key = $1 FOR UPDATE"), [key]);
|
|
49
|
+
const prior = rows[0] ? rowToStored(rows[0], `for session ${JSON.stringify(sessionId)} (pre-write snapshot)`) : undefined;
|
|
50
|
+
const priorRev = prior?.rev ?? 0;
|
|
51
|
+
if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {
|
|
52
|
+
throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${priorRev})`);
|
|
53
|
+
}
|
|
54
|
+
const { rows: markRows } = await conn.query(this.q("SELECT rev, gen FROM session_policy_rev_mark WHERE policy_key = ? FOR UPDATE", "SELECT rev, gen FROM session_policy_rev_mark WHERE policy_key = $1 FOR UPDATE"), [key]);
|
|
55
|
+
const markRev = markRows[0] ? Number(markRows[0].rev) : 0;
|
|
56
|
+
const markGen = markRows[0] ? Number(markRows[0].gen) : 0;
|
|
57
|
+
if (markRows[0] !== undefined && (!Number.isInteger(markGen) || markGen < 0 || !Number.isFinite(markRev) || markRev < 0)) {
|
|
58
|
+
throw new SessionPolicyError("corrupt", `the session rules rev mark for session ${JSON.stringify(sessionId)} is unreadable (rev ${String(markRows[0].rev)}, gen ${String(markRows[0].gen)}) — refusing rather than stamp a rev/lineage nothing proves`);
|
|
59
|
+
}
|
|
60
|
+
const clean = normalizeRules(stripRev(rules));
|
|
61
|
+
if (prior && opts?.operator !== true) {
|
|
62
|
+
const loosens = loosenReasons(prior, clean);
|
|
63
|
+
if (loosens.length > 0) {
|
|
64
|
+
throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (this.db.dialect === "pg" && pgHasUnstorable(clean)) {
|
|
68
|
+
throw new SessionPolicyError("loosen_forbidden", "rules contain unstorable bytes (NUL / lone surrogate) — refused; strip them at the source (authorization rules are never rewritten by the store)");
|
|
69
|
+
}
|
|
70
|
+
const nextRev = Math.max(priorRev, markRev) + 1;
|
|
71
|
+
const nextGen = prior?.gen ?? markGen;
|
|
72
|
+
const json = JSON.stringify(clean);
|
|
73
|
+
const now = new Date();
|
|
74
|
+
if (prior) {
|
|
75
|
+
const upd = await conn.query(this.q("UPDATE session_policy SET principal = ?, rules = ?, rev = ?, gen = ?, updated_at = ? WHERE policy_key = ? AND rev = ?", "UPDATE session_policy SET principal = $1, rules = $2::jsonb, rev = $3, gen = $4, updated_at = $5 WHERE policy_key = $6 AND rev = $7"), [principal ?? null, json, nextRev, nextGen, now, key, priorRev]);
|
|
76
|
+
if (upd.affected !== 1)
|
|
77
|
+
throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts?.expectedRev ?? priorRev}, have ${priorRev})`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
await conn.query(this.q("INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, gen, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)", "INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, gen, created_at, updated_at) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8)"), [key, sessionId, principal ?? null, json, nextRev, nextGen, now, now]);
|
|
81
|
+
}
|
|
82
|
+
return structuredClone({ ...clean, rev: nextRev, gen: nextGen });
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
async retrying(attempt) {
|
|
86
|
+
for (let tries = 0;; tries++) {
|
|
36
87
|
try {
|
|
37
|
-
return await
|
|
38
|
-
const { rows } = await conn.query(this.q("SELECT rules, rev FROM session_policy WHERE policy_key = ? FOR UPDATE", "SELECT rules, rev FROM session_policy WHERE policy_key = $1 FOR UPDATE"), [key]);
|
|
39
|
-
const prior = rows[0] ? { rules: parseRules(rows[0].rules), rev: Number(rows[0].rev) } : undefined;
|
|
40
|
-
const priorRev = prior?.rev ?? 0;
|
|
41
|
-
if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {
|
|
42
|
-
throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${priorRev})`);
|
|
43
|
-
}
|
|
44
|
-
const clean = normalizeRules(stripRev(rules));
|
|
45
|
-
if (prior && opts?.operator !== true) {
|
|
46
|
-
const loosens = loosenReasons(prior.rules, clean);
|
|
47
|
-
if (loosens.length > 0) {
|
|
48
|
-
throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (this.db.dialect === "pg" && pgHasUnstorable(clean)) {
|
|
52
|
-
throw new SessionPolicyError("loosen_forbidden", "rules contain unstorable bytes (NUL / lone surrogate) — refused; strip them at the source (authorization rules are never rewritten by the store)");
|
|
53
|
-
}
|
|
54
|
-
const nextRev = priorRev + 1;
|
|
55
|
-
const json = JSON.stringify(clean);
|
|
56
|
-
const now = new Date();
|
|
57
|
-
if (prior) {
|
|
58
|
-
const upd = await conn.query(this.q("UPDATE session_policy SET principal = ?, rules = ?, rev = ?, updated_at = ? WHERE policy_key = ? AND rev = ?", "UPDATE session_policy SET principal = $1, rules = $2::jsonb, rev = $3, updated_at = $4 WHERE policy_key = $5 AND rev = $6"), [principal ?? null, json, nextRev, now, key, priorRev]);
|
|
59
|
-
if (upd.affected !== 1)
|
|
60
|
-
throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts?.expectedRev ?? priorRev}, have ${priorRev})`);
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
await conn.query(this.q("INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", "INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, created_at, updated_at) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7)"), [key, sessionId, principal ?? null, json, nextRev, now, now]);
|
|
64
|
-
}
|
|
65
|
-
return structuredClone({ ...clean, rev: nextRev });
|
|
66
|
-
});
|
|
88
|
+
return await attempt();
|
|
67
89
|
}
|
|
68
90
|
catch (e) {
|
|
69
|
-
if (this.isDupKey(e)
|
|
91
|
+
if (tries < RETRY_LIMIT && (this.isDupKey(e) || isDeadlockError(this.db.dialect, e)))
|
|
70
92
|
continue;
|
|
71
93
|
throw e;
|
|
72
94
|
}
|
|
73
95
|
}
|
|
74
96
|
}
|
|
75
97
|
async deleteBySession(sessionId) {
|
|
76
|
-
await this.
|
|
98
|
+
await this.retrying(() => this.tx(async (conn) => {
|
|
99
|
+
const { rows } = await conn.query(this.q("SELECT policy_key, rev, gen FROM session_policy WHERE session_id = ? ORDER BY policy_key FOR UPDATE", "SELECT policy_key, rev, gen FROM session_policy WHERE session_id = $1 ORDER BY policy_key FOR UPDATE"), [sessionId]);
|
|
100
|
+
if (rows.length === 0)
|
|
101
|
+
return;
|
|
102
|
+
const now = new Date();
|
|
103
|
+
const keys = rows.map((r) => String(r.policy_key));
|
|
104
|
+
const params = [];
|
|
105
|
+
const tuples = rows.map((r) => {
|
|
106
|
+
params.push(String(r.policy_key), sessionId, Number(r.rev), Number(r.gen ?? 0) + 1, now);
|
|
107
|
+
const base = params.length - 5;
|
|
108
|
+
return this.db.dialect === "tidb" ? "(?,?,?,?,?)" : `($${base + 1},$${base + 2},$${base + 3},$${base + 4},$${base + 5})`;
|
|
109
|
+
});
|
|
110
|
+
await conn.query(this.q(`INSERT INTO session_policy_rev_mark (policy_key, session_id, rev, gen, erased_at) VALUES ${tuples.join(",")}` +
|
|
111
|
+
" ON DUPLICATE KEY UPDATE rev = GREATEST(session_policy_rev_mark.rev, VALUES(rev)), gen = GREATEST(session_policy_rev_mark.gen + 1, VALUES(gen)), erased_at = VALUES(erased_at)", `INSERT INTO session_policy_rev_mark (policy_key, session_id, rev, gen, erased_at) VALUES ${tuples.join(",")}` +
|
|
112
|
+
" ON CONFLICT (policy_key) DO UPDATE SET rev = GREATEST(session_policy_rev_mark.rev, EXCLUDED.rev), gen = GREATEST(session_policy_rev_mark.gen + 1, EXCLUDED.gen), erased_at = EXCLUDED.erased_at"), params);
|
|
113
|
+
const holes = keys.map((_, i) => (this.db.dialect === "tidb" ? "?" : `$${i + 1}`)).join(",");
|
|
114
|
+
await conn.query(`DELETE FROM session_policy WHERE policy_key IN (${holes})`, keys);
|
|
115
|
+
}));
|
|
77
116
|
}
|
|
78
117
|
async tx(fn) {
|
|
79
118
|
const conn = await this.db.connect();
|