@jayyuen66/dsh-quality-gate 0.1.1 → 0.1.3
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/CHANGELOG.md +79 -0
- package/LICENSE +1 -1
- package/README.md +178 -93
- package/README.zh-CN.md +180 -91
- package/client.js +46 -27
- package/cordis.patch.yml +3 -1
- package/host.js +292 -114
- package/package.json +47 -38
package/host.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// GENERATED by build-host.mjs — edit host.ts / lib/*.ts, then run `node build-host.mjs`.
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import path from "node:path";
|
|
@@ -9,7 +10,7 @@ import { deriveProjectKey } from "@jayyuen66/dsh-plugin-shared/lib/project-key";
|
|
|
9
10
|
import { editPathOf, scanToolEvents } from "@jayyuen66/dsh-plugin-shared/lib/tool-events";
|
|
10
11
|
import { settleLessonCall } from "@jayyuen66/dsh-plugin-shared/lib/lesson-bus";
|
|
11
12
|
import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
|
|
12
|
-
|
|
13
|
+
// lib/accumulator.ts
|
|
13
14
|
/**
|
|
14
15
|
* 需要过门禁的扩展名(代码与配置;md/txt/图片等免检)。
|
|
15
16
|
*
|
|
@@ -19,7 +20,11 @@ import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
|
|
|
19
20
|
*/
|
|
20
21
|
const GATED_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|json|ya?ml|toml)$/iu;
|
|
21
22
|
/** 编辑类工具名。str_replace_editor 是 dsh-base 并存的另一套编辑器
|
|
22
|
-
* (host 侧只对 create/str_replace/insert 记账,view 只读不入这里)。
|
|
23
|
+
* (host 侧只对 create/str_replace/insert 记账,view 只读不入这里)。
|
|
24
|
+
*
|
|
25
|
+
* 这是**唯一真源**:host 侧的同名判定(isEditTool)也用这一份,两边各写一份时新增
|
|
26
|
+
* 编辑器工具只改一侧就会静默分叉——只改 host 则累加器静默丢弃记账,只改这里则 host
|
|
27
|
+
* 根本不会调用 note。 */
|
|
23
28
|
const EDIT_TOOLS = /* @__PURE__ */ new Set([
|
|
24
29
|
"edit",
|
|
25
30
|
"write",
|
|
@@ -30,28 +35,29 @@ const DEFAULT_MAX_FILES = 500;
|
|
|
30
35
|
var EditAccumulator = class {
|
|
31
36
|
files = /* @__PURE__ */ new Set();
|
|
32
37
|
maxFiles;
|
|
33
|
-
/** 容量淘汰累计丢弃数:drain
|
|
38
|
+
/** 容量淘汰累计丢弃数:drain 时清零(静默淘汰要可观测)。 */
|
|
34
39
|
dropped = 0;
|
|
35
40
|
constructor(options = {}) {
|
|
36
|
-
|
|
41
|
+
const configured = options.maxFiles;
|
|
42
|
+
this.maxFiles = configured !== void 0 && Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_MAX_FILES;
|
|
37
43
|
}
|
|
38
44
|
/** 记一笔:编辑类工具 + 代码/配置扩展名 + 非空路径才收;容量淘汰时计数。 */
|
|
39
45
|
note(name, filePath) {
|
|
40
46
|
if (name === void 0 || !EDIT_TOOLS.has(name)) return;
|
|
41
47
|
if (typeof filePath !== "string" || filePath.length === 0) return;
|
|
42
48
|
if (!GATED_EXT.test(filePath)) return;
|
|
43
|
-
|
|
49
|
+
this.files.delete(filePath);
|
|
50
|
+
if (this.files.size >= this.maxFiles) this.evictOldest();
|
|
44
51
|
this.files.add(filePath);
|
|
45
52
|
}
|
|
46
53
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* 判的「循环体只允许一次迭代」:循环结构在这里没有承载任何语义。改成直接取首个元素,
|
|
51
|
-
* 单次淘汰的行为(删最旧 + 计数)与原样一致。
|
|
54
|
+
* 淘汰顺序最旧的一项并计数。
|
|
55
|
+
*
|
|
56
|
+
* 归一化后 `maxFiles >= 1`,而调用点只在 `size >= maxFiles` 时进来,所以集合必有可取首项。
|
|
52
57
|
*/
|
|
53
58
|
evictOldest() {
|
|
54
59
|
const [oldest] = this.files;
|
|
60
|
+
/* v8 ignore next 3 —— 上面那条不变量的直接推论:这一支只为类型收窄而写,正常配置下不可达。 */
|
|
55
61
|
if (oldest === void 0) return;
|
|
56
62
|
this.files.delete(oldest);
|
|
57
63
|
this.dropped += 1;
|
|
@@ -68,8 +74,7 @@ var EditAccumulator = class {
|
|
|
68
74
|
return out;
|
|
69
75
|
}
|
|
70
76
|
};
|
|
71
|
-
|
|
72
|
-
//#region lib/gate-detect.ts
|
|
77
|
+
// lib/gate-detect.ts
|
|
73
78
|
/**
|
|
74
79
|
* `pnpm check` 是否可用:必须有已声明的 check 脚本,**信息缺失时一律判否**。
|
|
75
80
|
*
|
|
@@ -83,9 +88,25 @@ var EditAccumulator = class {
|
|
|
83
88
|
function pnpmCheckReady(scripts) {
|
|
84
89
|
return scripts?.includes("check") === true;
|
|
85
90
|
}
|
|
86
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* `ruff check .` 是否可用:必须有 ruff 自己的配置面,**信息缺失时一律判否**。
|
|
93
|
+
*
|
|
94
|
+
* 为什么 pyproject.toml 单独不算证据:它不是 ruff 的配置标记,而是 Python 工程最常见的
|
|
95
|
+
* 元数据文件——只写 `[project]`、lint 交给别的工具(flake8 / pylint / ruff 的编辑器集成)
|
|
96
|
+
* 的工程大量存在。对这类工程发 `ruff check .`,一旦宿主 PATH 上有 ruff,就会拿 ruff 的
|
|
97
|
+
* 默认规则集去判一个从未声明用 ruff 的项目,产出与使用者意图无关的告警,再经同一个注入
|
|
98
|
+
* 通道被当成真实缺陷。这正是本模块要压住的假失败,方向与 pnpmCheckReady 一致。
|
|
99
|
+
*
|
|
100
|
+
* 判据收在两处**声明**上:`ruff.toml`(ruff 专属文件名,在场即声明)与 pyproject 里真有
|
|
101
|
+
* 的 `[tool.ruff]` 一族段(由 host 读盘后经 ruffConfigured 传入)。二者都缺=没有信息,
|
|
102
|
+
* 按保守口径不给门禁——代价只是这一轮少一次自动核验,用户可以自己跑。
|
|
103
|
+
*/
|
|
104
|
+
function ruffGateReady(files, ruffConfigured) {
|
|
105
|
+
return files.includes("ruff.toml") || ruffConfigured === true;
|
|
106
|
+
}
|
|
107
|
+
/** 从 ProjectRoot(根目录相对名清单 + 已声明脚本 + ruff 配置事实)判定单根门禁。 */
|
|
87
108
|
function gateForRoot(entry) {
|
|
88
|
-
const { root, files, scripts } = entry;
|
|
109
|
+
const { root, files, scripts, ruffConfigured } = entry;
|
|
89
110
|
const has = (name) => files.includes(name);
|
|
90
111
|
let gate;
|
|
91
112
|
if (has("pnpm-workspace.yaml") && has("package.json") && pnpmCheckReady(scripts)) gate = {
|
|
@@ -96,7 +117,7 @@ function gateForRoot(entry) {
|
|
|
96
117
|
root,
|
|
97
118
|
command: ["cargo", "check"]
|
|
98
119
|
};
|
|
99
|
-
else if (
|
|
120
|
+
else if (ruffGateReady(files, ruffConfigured)) gate = {
|
|
100
121
|
root,
|
|
101
122
|
command: [
|
|
102
123
|
"ruff",
|
|
@@ -106,11 +127,7 @@ function gateForRoot(entry) {
|
|
|
106
127
|
};
|
|
107
128
|
else if (has("package.json") && has("tsconfig.json")) gate = {
|
|
108
129
|
root,
|
|
109
|
-
command: [
|
|
110
|
-
"npx",
|
|
111
|
-
"tsc",
|
|
112
|
-
"--noEmit"
|
|
113
|
-
]
|
|
130
|
+
command: ["tsc", "--noEmit"]
|
|
114
131
|
};
|
|
115
132
|
return gate;
|
|
116
133
|
}
|
|
@@ -123,8 +140,7 @@ function detectGate(roots) {
|
|
|
123
140
|
}
|
|
124
141
|
return out;
|
|
125
142
|
}
|
|
126
|
-
|
|
127
|
-
//#region lib/messages.ts
|
|
143
|
+
// lib/messages.ts
|
|
128
144
|
const MESSAGES = {
|
|
129
145
|
zh: {
|
|
130
146
|
evidenceTruncated: "[证据截断] {label} 内存里只保留尾部(首个错误可能在被丢弃的头部)。完整输出:{spill}",
|
|
@@ -144,7 +160,6 @@ const MESSAGES = {
|
|
|
144
160
|
toolMissing: "命令不存在(工具未安装)",
|
|
145
161
|
toolNotExecutable: "命令不可执行",
|
|
146
162
|
toolNotRun: "{cause},门禁没有运行。",
|
|
147
|
-
toolStub: "门禁工具本身不可用(npx 抓到的是 stub 包,不是真工具)。",
|
|
148
163
|
policyUnresolvable: "沙箱策略无法解析({message}),门禁拒绝在未确认的沙箱边界下执行。",
|
|
149
164
|
sandboxUnavailable: "沙箱在本机不可用({message}):命令被拒绝执行,未做任何检查。",
|
|
150
165
|
gateStartFailed: "门禁未能启动({message})。",
|
|
@@ -168,7 +183,6 @@ const MESSAGES = {
|
|
|
168
183
|
toolMissing: "command not found (tool not installed)",
|
|
169
184
|
toolNotExecutable: "command not executable",
|
|
170
185
|
toolNotRun: "{cause} — the gate did not run.",
|
|
171
|
-
toolStub: "The gate tool itself is unavailable (npx fetched a stub package, not the real tool).",
|
|
172
186
|
policyUnresolvable: "The sandbox policy could not be resolved ({message}); the gate refuses to run under an unconfirmed sandbox boundary.",
|
|
173
187
|
sandboxUnavailable: "The sandbox is unavailable on this machine ({message}): the command was denied and no check was performed.",
|
|
174
188
|
gateStartFailed: "The gate failed to start ({message}).",
|
|
@@ -177,33 +191,41 @@ const MESSAGES = {
|
|
|
177
191
|
};
|
|
178
192
|
/** 渲染模板里的 {name} 占位符;未提供的名字留空串(宁可少一段说明,也不给模型留裸花括号)。
|
|
179
193
|
* 与 client 半官方 `@deepseek-ai/dsh-client-locale` 的 Translate 插值同语义(宿主同规则)。
|
|
194
|
+
*
|
|
195
|
+
* 查参数用 `Object.hasOwn` 而不是直接下标:模板里若出现 `{constructor}` / `{toString}` 这类被
|
|
196
|
+
* Object.prototype 覆盖的名字,裸下标取到的是函数对象、`String()` 之后会渲染成源码串,
|
|
197
|
+
* 与"未提供的名字留空串"这条契约相反。
|
|
180
198
|
* @param text - 字典里的整行模板。
|
|
181
199
|
* @param params - 占位符名 → 值(数字会转成字符串)。
|
|
182
200
|
* @returns 替换后的文本。
|
|
183
201
|
*/
|
|
184
202
|
function renderTemplate(text, params) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
203
|
+
const provided = new Map(Object.entries(params));
|
|
204
|
+
return text.replaceAll(/\{(?<key>\w+)\}/gu, (_all, key) => provided.get(key)?.toString() ?? "");
|
|
205
|
+
}
|
|
206
|
+
// lib/feedback-content.ts
|
|
207
|
+
/** 空白折叠成单个空格并去首尾:让副本身处"一行正文"的契约之内。
|
|
208
|
+
* 参数收 `unknown` 而不是 `string`:这是运行时边界(门禁执行结果由 spawn 与上游透传拼出来),
|
|
209
|
+
* 类型注释拦不住 undefined/null。原先直接 `text.replaceAll` 时,异常会冒到
|
|
210
|
+
* pushFeedbackToMemory 的 catch 里 → "门禁失败却一条记忆都没写",且日志里只有一句
|
|
211
|
+
* "memory feedback skipped",看起来像网关挂了。 */
|
|
212
|
+
function oneLine(text) {
|
|
213
|
+
return typeof text === "string" ? text.replaceAll(/\s+/gu, " ").trim() : "";
|
|
189
214
|
}
|
|
190
|
-
//#endregion
|
|
191
|
-
//#region lib/feedback-content.ts
|
|
192
215
|
/** 门禁失败 → 记忆正文:单行化(防换行噪音)。
|
|
216
|
+
* 三处插值都要折叠:`failure` 来自被检查仓库的自由文本,`command` 来自配置里的自定义命令,
|
|
217
|
+
* `root` 可以是任何路径——任一处带换行/制表符,这条"一条记忆正文"就会被 auto-recall 拆成多段。
|
|
193
218
|
* 不截断:截尾会吃掉多根错误里的中间根因;记忆网关的入库限额由网关侧把关,这里送全文。
|
|
194
219
|
* 正文语言随官方 locale 偏好走(messages 由调用方按回合取出后传进来)——这条文本会被
|
|
195
220
|
* auto-recall 注入后续会话的上下文,是模型读的,不是日志。 */
|
|
196
221
|
function buildFeedbackText({ command, root, failure }, messages) {
|
|
197
|
-
const cmd = command.join(" ");
|
|
198
|
-
const tail = failure.replaceAll(/\s+/gu, " ").trim();
|
|
199
222
|
return renderTemplate(messages.memoryFeedback, {
|
|
200
|
-
command:
|
|
201
|
-
root,
|
|
202
|
-
failure:
|
|
223
|
+
command: oneLine(command.join(" ")),
|
|
224
|
+
root: oneLine(root),
|
|
225
|
+
failure: oneLine(failure)
|
|
203
226
|
});
|
|
204
227
|
}
|
|
205
|
-
|
|
206
|
-
//#region lib/gateway-target.ts
|
|
228
|
+
// lib/gateway-target.ts
|
|
207
229
|
/** env 值 trim 后非空则用之,否则回到 fallback(保留原 `||` 语义,
|
|
208
230
|
* 又以显式比较规避 prefer-nullish-coalescing 与 strict-boolean-expressions)。 */
|
|
209
231
|
function envOr(key, fallback) {
|
|
@@ -216,28 +238,98 @@ function envOr(key, fallback) {
|
|
|
216
238
|
* 端口写死成默认,等于让别人的每次门禁失败都去连一个不存在的服务。设置优先于 env 的理由:
|
|
217
239
|
* 设置卡是用户显式配置面,env 只能兜底,
|
|
218
240
|
* 否则用户改了设置卡仍被一个看不见的环境变量越权。
|
|
241
|
+
*
|
|
242
|
+
* 解析不出合法 URL 时同样返回空串(按"未配置"处理):`http://` 这类半截串裁掉斜杠后剩
|
|
243
|
+
* `http:`,如果原样透给调用方,凭据安全判据会把它报成"明文 http 且非回环"——把配置写错
|
|
244
|
+
* 误报成协议不安全,用户照提示改成 https 也修不好。
|
|
219
245
|
*/
|
|
220
246
|
function resolveGatewayUrl(settingUrl) {
|
|
221
247
|
const configured = settingUrl.trim();
|
|
222
|
-
|
|
248
|
+
const candidate = configured === "" ? envOr("TDAI_GATEWAY_URL", "") : configured;
|
|
249
|
+
if (candidate === "") return "";
|
|
250
|
+
const normalized = candidate.replace(/\/+$/u, "");
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = new URL(normalized);
|
|
254
|
+
} catch {
|
|
255
|
+
return "";
|
|
256
|
+
}
|
|
257
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
|
|
258
|
+
return normalized;
|
|
259
|
+
}
|
|
260
|
+
/** 是否为 IPv4 回环(127.0.0.0/8 整段,不只是 127.0.0.1)。 */
|
|
261
|
+
function isIpv4Loopback(host) {
|
|
262
|
+
const parts = host.split(".");
|
|
263
|
+
if (parts.length !== 4 || parts[0] !== "127") return false;
|
|
264
|
+
return parts.every((part) => /^\d{1,3}$/u.test(part) && Number(part) <= 255);
|
|
223
265
|
}
|
|
224
|
-
/**
|
|
266
|
+
/**
|
|
267
|
+
* 凭据只允许发往 https 或回环地址:明文 http 到远端会把 Bearer 令牌暴露给同网段
|
|
268
|
+
* (设置卡默认值就是本机 http,属有意用法)。返回 false 时调用方按"未配置"处理,
|
|
269
|
+
* 不解析、不发送凭据。
|
|
270
|
+
*
|
|
271
|
+
* 回环按整段判定而不是三个字面量:IPv4 回环是 127.0.0.0/8,网关绑在 127.0.0.2 /
|
|
272
|
+
* 127.0.1.1 时同样只在本机,判成"不安全"会让记忆反馈静默停掉、而用户从提示里看不出
|
|
273
|
+
* 自己其实就在本机;IPv6 的 IPv4-mapped 写法(URL 把 `[::ffff:127.0.0.1]` 归一成
|
|
274
|
+
* `[::ffff:7f00:1]`)同理。
|
|
275
|
+
*
|
|
276
|
+
* `localhost` 这一支是**有意保留的 fail-open**:它依赖 hosts/DNS 解析,hosts 被指向远端时
|
|
277
|
+
* 本函数仍判安全。保留它是为了让设置卡的默认写法可用;要彻底关掉这个口子只能改成只认 IP
|
|
278
|
+
* 字面量,那会让"本机网关"这条最常见用法需要用户自己去查 IP。
|
|
279
|
+
*/
|
|
280
|
+
function isCredentialSafeUrl(url) {
|
|
281
|
+
let parsed;
|
|
282
|
+
try {
|
|
283
|
+
parsed = new URL(url);
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
if (parsed.protocol === "https:") return true;
|
|
288
|
+
if (parsed.protocol !== "http:") return false;
|
|
289
|
+
const host = parsed.hostname.replaceAll(/^\[|\]$/gu, "");
|
|
290
|
+
return host === "localhost" || isIpv4Loopback(host) || host === "::1" || host.startsWith("::ffff:7f");
|
|
291
|
+
}
|
|
292
|
+
/** `x-tdai-service-id` 的合法形状:网关侧按服务名路由,空白与控制字符会让请求头
|
|
293
|
+
* 在 fetch 处直接抛 TypeError(undici 拒收含换行的头值),把一次投递变成一条异常。
|
|
294
|
+
* 不合规的值一律回落到默认服务名,而不是把用户的笔误送上网关。 */
|
|
295
|
+
const SERVICE_ID_RE = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
296
|
+
/** 默认服务名:网关协议的匿名服务名(不含任何机器信息)。 */
|
|
297
|
+
const DEFAULT_SERVICE_ID = "default";
|
|
298
|
+
/** x-tdai-service-id:env 可覆盖,形状不合规时回落默认值。 */
|
|
225
299
|
function resolveServiceId() {
|
|
226
|
-
|
|
300
|
+
const configured = envOr("TDAI_SERVICE_ID", "");
|
|
301
|
+
return SERVICE_ID_RE.test(configured) ? configured : DEFAULT_SERVICE_ID;
|
|
227
302
|
}
|
|
228
|
-
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
303
|
+
/**
|
|
304
|
+
* 作用:cwd → agent_id,'尾目录名-<cwd 前 8 位 sha256>'(与网关插件 workspace-peer 同分桶
|
|
305
|
+
* 语义,读/写同键,保证反馈沉淀进 auto-recall 同一记忆桶)。
|
|
306
|
+
*
|
|
307
|
+
* 条件:派生本体在 shared/lib/project-key.ts —— lesson-loop 的 project 与这里同源一份实现,
|
|
308
|
+
* 两处各存一份时改一处漏一处,把"教训 ↔ 记忆"的跨系统对照拆断而不报错。
|
|
309
|
+
*
|
|
310
|
+
* 子功能:参数保留 `unknown`(调用方的 cwd 来自会话事件,形状不由本包保证);非字符串、
|
|
311
|
+
* 空串、根路径一律回落 'default'。
|
|
312
|
+
*
|
|
313
|
+
* 执行:直接委托 deriveProjectKey。
|
|
314
|
+
*
|
|
315
|
+
* 验证方式:test/gateway-feedback.test.ts 的 deriveAgentId 组逐条钉住回落面,并有一例
|
|
316
|
+
* 断言它与 deriveProjectKey 逐值同源。
|
|
317
|
+
*
|
|
318
|
+
* 不适用条件:'default' 是所有项目共享的记忆桶 —— 回落因此**可能**把反馈混进别人的桶。
|
|
319
|
+
* 这里不为此加日志:本模块被 host 的 catch 包着、失败只 warn 一次,再往 lib 里塞一条
|
|
320
|
+
* console 会污染测试日志账本(test/setup-logs.ts 要求每条日志都有模板认领);而实际
|
|
321
|
+
* 调用点传的是会话 cwd(host.ts 里由事件流给出,必为字符串),回落是给未来调用方的护栏。
|
|
322
|
+
*/
|
|
232
323
|
function deriveAgentId(cwd) {
|
|
233
324
|
return deriveProjectKey(cwd);
|
|
234
325
|
}
|
|
235
|
-
|
|
236
|
-
//#region lib/gateway-feedback.ts
|
|
326
|
+
// lib/gateway-feedback.ts
|
|
237
327
|
/** 反馈尽力而为:5s 封顶,超时/失败只 warn(不吃掉门禁主流程)。 */
|
|
238
328
|
const FEEDBACK_TIMEOUT_MS = 5e3;
|
|
239
329
|
/** 沉淀门禁失败到 L0 记忆(role=assistant:这是 agent 自身行为教训,归顺 assistant 提炼面)。 */
|
|
240
330
|
async function pushGateFeedback(input, target, messages) {
|
|
331
|
+
const base = target.url.trim().replace(/\/+$/u, "");
|
|
332
|
+
if (base === "" || target.key.trim() === "") throw new Error("投递目标不完整:url 与 key 都必须非空(目标面应由调用方解析后传入)");
|
|
241
333
|
const agentId = deriveAgentId(input.cwd);
|
|
242
334
|
const content = buildFeedbackText(input, messages);
|
|
243
335
|
const payload = JSON.stringify({
|
|
@@ -248,31 +340,36 @@ async function pushGateFeedback(input, target, messages) {
|
|
|
248
340
|
content
|
|
249
341
|
}]
|
|
250
342
|
});
|
|
251
|
-
const res = await fetch(`${
|
|
343
|
+
const res = await fetch(`${base}/v2/conversation/add`, {
|
|
252
344
|
method: "POST",
|
|
253
345
|
headers: {
|
|
254
346
|
Authorization: `Bearer ${target.key}`,
|
|
255
|
-
"x-tdai-service-id":
|
|
347
|
+
"x-tdai-service-id": target.serviceId,
|
|
256
348
|
"Content-Type": "application/json"
|
|
257
349
|
},
|
|
258
350
|
body: payload,
|
|
259
351
|
signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS)
|
|
260
352
|
});
|
|
261
|
-
|
|
262
|
-
|
|
353
|
+
const drained = await res.arrayBuffer().catch(() => null);
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
const detail = drained === null ? "" : new TextDecoder().decode(drained).trim().slice(0, 200);
|
|
356
|
+
throw new Error(detail === "" ? `gateway feedback HTTP ${res.status}` : `gateway feedback HTTP ${res.status}:${detail}`, { cause: drained });
|
|
357
|
+
}
|
|
263
358
|
}
|
|
264
|
-
|
|
265
|
-
//#region host.ts
|
|
359
|
+
// host.ts
|
|
266
360
|
/**
|
|
267
361
|
* 常见项目根标记文件:向上找最近的含清单目录。
|
|
268
362
|
* ruff.toml 必须在列:只有 ruff 配置、无 pyproject.toml 的 Python
|
|
269
363
|
* 工程若不在此登记,gate-detect 的 has("ruff.toml") 就是死码——那类工程根本得不到根,
|
|
270
364
|
* 也就永远没有门禁。
|
|
271
365
|
*/
|
|
366
|
+
const PKG_MANIFEST = "package.json";
|
|
367
|
+
const TSCONFIG_MANIFEST = "tsconfig.json";
|
|
368
|
+
const PYPROJECT_MANIFEST = "pyproject.toml";
|
|
272
369
|
const MANIFESTS = [
|
|
273
|
-
|
|
370
|
+
PKG_MANIFEST,
|
|
274
371
|
"Cargo.toml",
|
|
275
|
-
|
|
372
|
+
PYPROJECT_MANIFEST,
|
|
276
373
|
"pnpm-workspace.yaml",
|
|
277
374
|
"ruff.toml"
|
|
278
375
|
];
|
|
@@ -330,8 +427,8 @@ function messageOf(error) {
|
|
|
330
427
|
return message;
|
|
331
428
|
}
|
|
332
429
|
/**
|
|
333
|
-
* 会话级 Map 淘汰:把 keep 先删后插移到队尾(JS Map
|
|
334
|
-
*
|
|
430
|
+
* 会话级 Map 淘汰:把 keep 先删后插移到队尾(JS Map 迭代按插入序),再淘汰队首超出
|
|
431
|
+
* `SESSION_BUDGET` 的项 → keep 永不被淘汰,且总大小收敛。泛型复用:注入配额 Map(InjectState)、
|
|
335
432
|
* 事件游标 Map(number)与编辑累加器 Map(EditAccumulator)共用同一策略。
|
|
336
433
|
* keep 不在表内 → 直接返回(inbox-busy 分支的 injectState 就会命中:该会话从未失败过)。
|
|
337
434
|
*/
|
|
@@ -391,17 +488,34 @@ function preInjectCheck(agent, signal) {
|
|
|
391
488
|
};
|
|
392
489
|
}
|
|
393
490
|
/**
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* `
|
|
397
|
-
* 它打印横幅并以 exit 1 退出——不是 126/127,所以只按退出码判定会误报为代码失败。
|
|
398
|
-
* 实测:`npx --no-install` 在本机拦不住 stub(已从 npx 缓存解析),
|
|
399
|
-
* 所以必须在输出侧识别,不能只靠加 flag。
|
|
491
|
+
* 本次门禁要跑的 tsc 入口:优先工程自己装的 typescript(与工程惯用的编译器版本一致),
|
|
492
|
+
* 装不到时用本包依赖的那份(随包安装,运行期无网络动作)。两处都解析不到时回落到 PATH 上的
|
|
493
|
+
* `tsc`,由 126/127 那条「工具不可用」出口报成未执行——绝不走 `npx` 的自动下载。
|
|
400
494
|
*/
|
|
401
|
-
function
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
495
|
+
function tscEntry(root) {
|
|
496
|
+
let entry;
|
|
497
|
+
for (const from of [path.join(root, PKG_MANIFEST), import.meta.url]) try {
|
|
498
|
+
const manifest = createRequire(from).resolve("typescript/package.json");
|
|
499
|
+
entry = path.join(path.dirname(manifest), "bin", "tsc");
|
|
500
|
+
break;
|
|
501
|
+
} catch {}
|
|
502
|
+
return entry;
|
|
503
|
+
}
|
|
504
|
+
/** 门禁命令的实际 argv:tsc 的门禁只带逻辑名,入口在运行时按工程根解析。 */
|
|
505
|
+
function gateCommandArgs(gate) {
|
|
506
|
+
if (gate.command[0] !== "tsc") return gate.command;
|
|
507
|
+
/* v8 ignore next 1 —— 本包自己的 typescript 解析不到(安装面坏了)时才走 PATH 回落;
|
|
508
|
+
正常安装不可达,而那一路由 126/127 报成未执行。 */
|
|
509
|
+
return [tscEntry(gate.root) ?? "tsc", "--noEmit"];
|
|
510
|
+
}
|
|
511
|
+
/** 拼 shell 串:路径可能带空格,含空白或元字符的参数加单引号。
|
|
512
|
+
* 导出只为直接测这段引号处理:tsc 入口是绝对路径,它所在目录未必没有空格。 */
|
|
513
|
+
function shellArg(part) {
|
|
514
|
+
return /^[\w./:=@+-]+$/u.test(part) ? part : `'${part.replaceAll("'", `'\\''`)}'`;
|
|
515
|
+
}
|
|
516
|
+
/** 门禁命令的展示/执行串(与 gateCommandArgs 同源)。 */
|
|
517
|
+
function gateCommandText(gate) {
|
|
518
|
+
return gateCommandArgs(gate).map((part) => shellArg(part)).join(" ");
|
|
405
519
|
}
|
|
406
520
|
/** harness run() 抛出的 SandboxUnavailableError(code 或 name 命中即可)。 */
|
|
407
521
|
function isSandboxUnavailable(error) {
|
|
@@ -454,7 +568,7 @@ function evidenceText(messages, stream, label) {
|
|
|
454
568
|
return out;
|
|
455
569
|
}
|
|
456
570
|
const gateText = (messages, gate, out) => renderTemplate(messages.gateFailed, {
|
|
457
|
-
command: gate
|
|
571
|
+
command: gateCommandText(gate),
|
|
458
572
|
root: gate.root,
|
|
459
573
|
output: out
|
|
460
574
|
});
|
|
@@ -463,19 +577,19 @@ const gateText = (messages, gate, out) => renderTemplate(messages.gateFailed, {
|
|
|
463
577
|
* 让模型去修一个装不上的工具/越不过去的沙箱,只会配着配额环烧 token。
|
|
464
578
|
*/
|
|
465
579
|
const notRunText = (messages, gate, reason) => renderTemplate(messages.gateNotRun, {
|
|
466
|
-
command: gate
|
|
580
|
+
command: gateCommandText(gate),
|
|
467
581
|
root: gate.root,
|
|
468
582
|
reason
|
|
469
583
|
});
|
|
470
584
|
/** 配额耗尽后的"放行说明"(用户可见,不再静默 return)。 */
|
|
471
585
|
const giveUpText = (messages, gate, attempts) => renderTemplate(messages.gaveUp, {
|
|
472
586
|
attempts,
|
|
473
|
-
command: gate
|
|
587
|
+
command: gateCommandText(gate),
|
|
474
588
|
root: gate.root
|
|
475
589
|
});
|
|
476
590
|
/** 未执行清单的一行(模块级:不让 renderTemplate 套进 map 再套进 renderTemplate)。 */
|
|
477
591
|
const skippedGateLine = (messages, gate) => renderTemplate(messages.skippedGateItem, {
|
|
478
|
-
command: gate
|
|
592
|
+
command: gateCommandText(gate),
|
|
479
593
|
root: gate.root
|
|
480
594
|
});
|
|
481
595
|
/** 预算耗尽后剩余门禁的"未执行"清单:只 warn 等于静默通过,所以要注入给模型与用户。 */
|
|
@@ -539,7 +653,7 @@ function interruptedRunOutcome(messages, budgetMs, res) {
|
|
|
539
653
|
function unavailableToolOutcome(messages, gate, res, log) {
|
|
540
654
|
let outcome;
|
|
541
655
|
if (res.exitCode === 126 || res.exitCode === 127) {
|
|
542
|
-
log.warn(`[quality-gate] gate tool unavailable (exit ${res.exitCode}): ${gate
|
|
656
|
+
log.warn(`[quality-gate] gate tool unavailable (exit ${res.exitCode}): ${gateCommandText(gate)} — reported as not-run, not as pass`);
|
|
543
657
|
const cause = res.exitCode === 127 ? messages.toolMissing : messages.toolNotExecutable;
|
|
544
658
|
outcome = {
|
|
545
659
|
kind: "not-run",
|
|
@@ -558,7 +672,7 @@ function unavailableToolOutcome(messages, gate, res, log) {
|
|
|
558
672
|
* 策略拒绝当成代码失败注入"修这些错"。
|
|
559
673
|
*
|
|
560
674
|
* 判定序(三支各自成函数,顺序与 warn 次数都不变):沙箱面拦截 → 进程没跑完 →
|
|
561
|
-
* 工具装不上(126/127)→ exit 0(部分生效沙箱只 warn)→
|
|
675
|
+
* 工具装不上(126/127)→ exit 0(部分生效沙箱只 warn)→ 代码失败。
|
|
562
676
|
*/
|
|
563
677
|
function classifyRun(messages, gate, budgetMs, res, log) {
|
|
564
678
|
const facts = res.sandbox;
|
|
@@ -571,24 +685,19 @@ function classifyRun(messages, gate, budgetMs, res, log) {
|
|
|
571
685
|
const unusable = unavailableToolOutcome(messages, gate, res, log);
|
|
572
686
|
if (unusable !== void 0) return unusable;
|
|
573
687
|
if (res.exitCode === 0) {
|
|
574
|
-
if (facts?.enforcement === "partial") log.warn(`[quality-gate] gate passed under PARTIAL sandbox enforcement (${sandboxNote(messages, facts)}): ${gate
|
|
688
|
+
if (facts?.enforcement === "partial") log.warn(`[quality-gate] gate passed under PARTIAL sandbox enforcement (${sandboxNote(messages, facts)}): ${gateCommandText(gate)} @ ${gate.root}`);
|
|
575
689
|
return { kind: "pass" };
|
|
576
690
|
}
|
|
577
|
-
if (gateToolUnavailable(res.stdout.text, res.stderr.text)) {
|
|
578
|
-
log.warn(`[quality-gate] gate tool unavailable (npx fetched a stub instead of the real tool): ${gate.command.join(" ")} in ${gate.root} — reported as not-run`);
|
|
579
|
-
return {
|
|
580
|
-
kind: "not-run",
|
|
581
|
-
text: `${messages.toolStub}\n${evidenceText(messages, res.stderr, "stderr")}`
|
|
582
|
-
};
|
|
583
|
-
}
|
|
584
691
|
return {
|
|
585
692
|
kind: GATE_KIND_CODE_FAILURE,
|
|
586
693
|
text: `exit=${res.exitCode}\n${evidenceText(messages, res.stdout, "stdout")}${evidenceText(messages, res.stderr, "stderr")}`
|
|
587
694
|
};
|
|
588
695
|
}
|
|
589
|
-
/** 是否是会被记账的编辑类工具(edit/write + str_replace_editor
|
|
696
|
+
/** 是否是会被记账的编辑类工具(edit/write + str_replace_editor)。名单的真源在
|
|
697
|
+
* lib/accumulator.ts 的 EDIT_TOOLS:host 先用它过滤、累加器再用同一集合过滤一次,
|
|
698
|
+
* 两边各写一份时新增编辑器工具只改一侧就会静默分叉。 */
|
|
590
699
|
function isEditTool(name) {
|
|
591
|
-
return name
|
|
700
|
+
return EDIT_TOOLS.has(name);
|
|
592
701
|
}
|
|
593
702
|
/**
|
|
594
703
|
* 根 package.json 已声明的 scripts 名清单(值是非空字符串才算"声明了这条脚本")。
|
|
@@ -599,7 +708,7 @@ function isEditTool(name) {
|
|
|
599
708
|
function declaredScriptsOf(root) {
|
|
600
709
|
let names;
|
|
601
710
|
try {
|
|
602
|
-
const raw = JSON.parse(readFileSync(
|
|
711
|
+
const raw = JSON.parse(readFileSync(path.join(root, PKG_MANIFEST), "utf8"));
|
|
603
712
|
const scripts = fieldOf(raw, "scripts");
|
|
604
713
|
if (isRecord(scripts)) names = Object.entries(scripts).filter(([, body]) => typeof body === "string" && body.length > 0).map(([name]) => name);
|
|
605
714
|
} catch {
|
|
@@ -612,23 +721,50 @@ function isArrayDelivery(value) {
|
|
|
612
721
|
return Array.isArray(value);
|
|
613
722
|
}
|
|
614
723
|
/**
|
|
724
|
+
* pyproject 里 ruff 段头的字面形状:`[tool.ruff]` 或它的子表 `[tool.ruff.lint]` 等。
|
|
725
|
+
*
|
|
726
|
+
* `^` 加 `m` 旗标限定为行首;`[tool.ruff]` 与 `[tool.ruff.<子表>]` 都要认——只写子表、
|
|
727
|
+
* 不写父表的 pyproject 是常见写法。数组表(`[[tool.ruff...]]`)同样命中段名,一并算声明。
|
|
728
|
+
*/
|
|
729
|
+
const RUFF_SECTION = /^\[\[?tool\.(?:"ruff"|ruff)[\].]/mu;
|
|
730
|
+
/**
|
|
731
|
+
* 根 pyproject.toml 里是否声明了 ruff 配置段(段头形状见 RUFF_SECTION)。
|
|
732
|
+
*
|
|
733
|
+
* 只按行首段头字面匹配,不引 TOML 解析器:判据只需要回答"有没有这一段",而段头在
|
|
734
|
+
* TOML 里必须独占一行(行内写法 `[a.b] c = 1` 非法),故行首匹配与解析器同解。带引号的
|
|
735
|
+
* 键形式(`[tool."ruff"]`)在 TOML 里合法且等价,一并认。
|
|
736
|
+
*
|
|
737
|
+
* 读不到 / 解码失败 → undefined("没有信息"),由 gate-detect 按保守口径处理:不发 ruff
|
|
738
|
+
* 门禁。这里与 declaredScriptsOf 同一方向,理由见 gate-detect 的 ruffGateReady。
|
|
739
|
+
*/
|
|
740
|
+
function ruffConfiguredOf(root) {
|
|
741
|
+
let configured;
|
|
742
|
+
try {
|
|
743
|
+
const text = readFileSync(path.join(root, PYPROJECT_MANIFEST), "utf8");
|
|
744
|
+
configured = RUFF_SECTION.test(text);
|
|
745
|
+
} catch {
|
|
746
|
+
configured = void 0;
|
|
747
|
+
}
|
|
748
|
+
return configured;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
615
751
|
* 解析事件游标窗口:fork 切点下限 vs 上次已读 seq 取大,返回起始 seq 与本批事件。
|
|
616
752
|
* 事件流取自官方 `ctx.sessionQuery.observeSession(sessionId)`(见上面 SessionQueryFace:
|
|
617
|
-
* 一次交回 `events` 与 `inheritedEventCount
|
|
618
|
-
* 返回,**游标不推进**、不误报):① `
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
753
|
+
* 一次交回 `events` 与 `inheritedEventCount`)。两条降级路径一律 `undefined`(调用方直接
|
|
754
|
+
* 返回,**游标不推进**、不误报):① `observeSession` 抛错/拒绝(会话已卸载、存储读不出);
|
|
755
|
+
* ② 交回的 events 不是数组(契约外交付,由 `isArrayDelivery` 那道**布尔**判据挡——不用
|
|
756
|
+
* `Array.isArray` 作守卫收窄,它会把官方 `readonly SessionEvent[]` 塌成 `any[]`,类型面
|
|
757
|
+
* 当场作废并把 any 传进下游记账)。`sessionQuery` 服务本身缺席是第三条降级,判定在
|
|
758
|
+
* `readEditedFiles`(那里才能发 warn-once)。
|
|
623
759
|
* 窗口仍由**本地按 from 切片**:观察面给的是自日志开头的连续切片(`seq = log.length`
|
|
624
|
-
* 连续性契约,installed dsh-session/lib/index.js:
|
|
760
|
+
* 连续性契约,installed dsh-session/lib/index.js:1476 的注记),而 `from` 是本包游标算出的
|
|
625
761
|
* 裸 number——这样连 `SessionLogOffset` 的品牌都不必造(官方只要 `SessionId`,那一位从
|
|
626
762
|
* `Session.id` 上拿回来就是品牌的)。
|
|
627
763
|
*/
|
|
628
764
|
async function resolveEventWindow(query, sessionId, lastSeq) {
|
|
629
765
|
let window;
|
|
630
|
-
|
|
631
|
-
const observation = await query.observeSession(sessionId);
|
|
766
|
+
try {
|
|
767
|
+
const observation = await query.observeSession(sessionId, { projectionMode: "none" });
|
|
632
768
|
const forkCut = observation.inheritedEventCount ?? 0;
|
|
633
769
|
const from = Math.max(forkCut, lastSeq.get(sessionId) ?? forkCut);
|
|
634
770
|
if (isArrayDelivery(observation.events)) window = {
|
|
@@ -682,7 +818,7 @@ function findManifestRoot(file, maxDepth) {
|
|
|
682
818
|
let found;
|
|
683
819
|
let hitDepthLimit = false;
|
|
684
820
|
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
685
|
-
for (const manifest of MANIFESTS) if (existsSync(
|
|
821
|
+
for (const manifest of MANIFESTS) if (existsSync(path.join(dir, manifest))) {
|
|
686
822
|
found = dir;
|
|
687
823
|
break;
|
|
688
824
|
}
|
|
@@ -704,7 +840,7 @@ function findManifestRoot(file, maxDepth) {
|
|
|
704
840
|
function workspaceRootUpgrade(root, maxDepth) {
|
|
705
841
|
let up = path.dirname(root);
|
|
706
842
|
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
707
|
-
if (existsSync(
|
|
843
|
+
if (existsSync(path.join(up, "pnpm-workspace.yaml"))) return up;
|
|
708
844
|
const parent = path.dirname(up);
|
|
709
845
|
if (parent === up) break;
|
|
710
846
|
up = parent;
|
|
@@ -718,20 +854,22 @@ function workspaceRootUpgrade(root, maxDepth) {
|
|
|
718
854
|
* - tsconfig.json 不作为"根标记"(不进 MANIFESTS,避免改动根向上探测的语义),
|
|
719
855
|
* 但要进清单集合——gate-detect 用它区分"真 TS 项目"与"只有 package.json 的空壳";
|
|
720
856
|
* - scripts:根 package.json 已声明的脚本名清单,交给 gate-detect 判定 pnpm check
|
|
721
|
-
*
|
|
857
|
+
* 是否可用。**这里只做如实登记,不做取舍**(探测策略集中在纯函数里);
|
|
858
|
+
* - ruffConfigured:根 pyproject.toml 里是否真有 `[tool.ruff]` 一族段,交给 gate-detect
|
|
859
|
+
* 判定 ruff 门禁是否可用。理由同上——只登记,不取舍。
|
|
722
860
|
*/
|
|
723
861
|
function probeProjectRoot(root) {
|
|
724
|
-
const manifests = MANIFESTS.filter((manifest) => existsSync(
|
|
725
|
-
if (existsSync(
|
|
862
|
+
const manifests = MANIFESTS.filter((manifest) => existsSync(path.join(root, manifest)));
|
|
863
|
+
if (existsSync(path.join(root, TSCONFIG_MANIFEST))) manifests.push(TSCONFIG_MANIFEST);
|
|
726
864
|
const scripts = manifests.includes("package.json") ? declaredScriptsOf(root) : void 0;
|
|
727
|
-
|
|
865
|
+
const ruffConfigured = manifests.includes(PYPROJECT_MANIFEST) ? ruffConfiguredOf(root) : void 0;
|
|
866
|
+
const entry = {
|
|
728
867
|
root,
|
|
729
868
|
files: manifests
|
|
730
|
-
} : {
|
|
731
|
-
root,
|
|
732
|
-
files: manifests,
|
|
733
|
-
scripts
|
|
734
869
|
};
|
|
870
|
+
if (scripts !== void 0) entry.scripts = scripts;
|
|
871
|
+
if (ruffConfigured !== void 0) entry.ruffConfigured = ruffConfigured;
|
|
872
|
+
return entry;
|
|
735
873
|
}
|
|
736
874
|
/** 注入载荷组装(id 唯一,同一毫秒多次注入也不碰撞)。 */
|
|
737
875
|
const injectMessage = (text) => ({
|
|
@@ -758,7 +896,7 @@ function reportProblemToBus(get, attribution, gate, problem, log) {
|
|
|
758
896
|
cwd: attribution.cwd,
|
|
759
897
|
sessionId: attribution.sessionId,
|
|
760
898
|
turn: attribution.turn,
|
|
761
|
-
signature: gate
|
|
899
|
+
signature: gateCommandText(gate),
|
|
762
900
|
detail: problem.text,
|
|
763
901
|
evidence: {
|
|
764
902
|
root: gate.root,
|
|
@@ -785,6 +923,10 @@ async function resolveFeedbackTarget(cfg, credentials) {
|
|
|
785
923
|
ok: false,
|
|
786
924
|
reason: "memoryGatewayUrl 未配置(留空 = 不启用记忆反馈)"
|
|
787
925
|
};
|
|
926
|
+
if (!isCredentialSafeUrl(url)) return {
|
|
927
|
+
ok: false,
|
|
928
|
+
reason: `memoryGatewayUrl ${url} 是明文 http 且非回环:Bearer 凭据只发往 https 或本机`
|
|
929
|
+
};
|
|
788
930
|
if (!isCredentialRefName(cfg.memoryGatewayKeyRef)) return {
|
|
789
931
|
ok: false,
|
|
790
932
|
reason: `memoryGatewayKeyRef "${cfg.memoryGatewayKeyRef}" 不是合法凭据引用名`
|
|
@@ -802,7 +944,8 @@ async function resolveFeedbackTarget(cfg, credentials) {
|
|
|
802
944
|
ok: true,
|
|
803
945
|
target: {
|
|
804
946
|
url,
|
|
805
|
-
key: hit.value
|
|
947
|
+
key: hit.value,
|
|
948
|
+
serviceId: resolveServiceId()
|
|
806
949
|
}
|
|
807
950
|
};
|
|
808
951
|
}
|
|
@@ -857,20 +1000,40 @@ function isGateHost(value) {
|
|
|
857
1000
|
return typeof get === "function" && typeof on === "function" && typeof effect === "function" && typeof inject === "function" && isRecord(fiber) && typeof fieldOf(settings, "describe") === "function" && typeof fieldOf(settings, "configure") === "function";
|
|
858
1001
|
}
|
|
859
1002
|
/**
|
|
1003
|
+
* 一个文件的最近清单根 → 实际要跑的根:workspace 根优先,**但只在外层真能产出门禁时**才升级。
|
|
1004
|
+
*
|
|
1005
|
+
* 只判「上面有没有 pnpm-workspace.yaml」就升级是漏门禁的一条路:外层若是"有 workspace 清单、
|
|
1006
|
+
* 却没有 check 脚本也没有 tsconfig"的根,gate-detect 对它返回空表,子包本来有的
|
|
1007
|
+
* `npx tsc --noEmit` / `ruff check` 会被这次上溯抹掉,且没有任何输出。
|
|
1008
|
+
*/
|
|
1009
|
+
function rootForFile(useRoot, maxDepth) {
|
|
1010
|
+
const inner = probeProjectRoot(useRoot);
|
|
1011
|
+
const upgraded = workspaceRootUpgrade(useRoot, maxDepth);
|
|
1012
|
+
if (upgraded === useRoot) return inner;
|
|
1013
|
+
const outer = probeProjectRoot(upgraded);
|
|
1014
|
+
if (detectGate([outer]).length > 0) return outer;
|
|
1015
|
+
return detectGate([inner]).length > 0 ? inner : outer;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
860
1018
|
* 从编辑文件路径向上找最近的清单根(层数上限 = Config 的 maxRootDepth,非 volatile
|
|
861
1019
|
* 部署值),每个根探一次真实盘上事实(清单名 + 已声明脚本)。找不到清单根的文件跳过
|
|
862
|
-
*
|
|
1020
|
+
* (无门禁面)。同一内层根只判一次盘:一轮改几百个文件时逐文件重复探盘没有意义。
|
|
863
1021
|
*/
|
|
864
1022
|
function projectRootsOf(files, maxDepth, log) {
|
|
865
1023
|
const roots = /* @__PURE__ */ new Map();
|
|
1024
|
+
const chosenByInner = /* @__PURE__ */ new Map();
|
|
866
1025
|
for (const file of files) {
|
|
867
1026
|
const hit = findManifestRoot(file, maxDepth);
|
|
868
1027
|
const useRoot = hit.root;
|
|
869
1028
|
if (useRoot === void 0) {
|
|
870
1029
|
if (hit.hitDepthLimit) log.warn(`[quality-gate] no manifest within ${maxDepth} levels of ${file}; gate skipped for it`);
|
|
871
1030
|
} else {
|
|
872
|
-
|
|
873
|
-
if (
|
|
1031
|
+
let chosen = chosenByInner.get(useRoot);
|
|
1032
|
+
if (chosen === void 0) {
|
|
1033
|
+
chosen = rootForFile(useRoot, maxDepth);
|
|
1034
|
+
chosenByInner.set(useRoot, chosen);
|
|
1035
|
+
}
|
|
1036
|
+
if (!roots.has(chosen.root)) roots.set(chosen.root, chosen);
|
|
874
1037
|
}
|
|
875
1038
|
}
|
|
876
1039
|
return [...roots.values()];
|
|
@@ -886,7 +1049,7 @@ async function runGate(messages, shell, gate, budgetMs, context) {
|
|
|
886
1049
|
let outcome;
|
|
887
1050
|
try {
|
|
888
1051
|
const spec = shell.resolve({
|
|
889
|
-
command: gate
|
|
1052
|
+
command: gateCommandText(gate),
|
|
890
1053
|
workdir: gate.root,
|
|
891
1054
|
stdoutMaxBytes,
|
|
892
1055
|
timeoutMs: budgetMs,
|
|
@@ -895,7 +1058,7 @@ async function runGate(messages, shell, gate, budgetMs, context) {
|
|
|
895
1058
|
});
|
|
896
1059
|
outcome = classifyRun(messages, gate, budgetMs, await (await shell.execute(spec)).result(), log);
|
|
897
1060
|
} catch (error) {
|
|
898
|
-
log.error(`[quality-gate] gate did not run: ${gate
|
|
1061
|
+
log.error(`[quality-gate] gate did not run: ${gateCommandText(gate)} @ ${gate.root} — ${messageOf(error)}`);
|
|
899
1062
|
const cause = messageOf(error);
|
|
900
1063
|
outcome = isSandboxUnavailable(error) ? {
|
|
901
1064
|
kind: "not-run",
|
|
@@ -970,7 +1133,7 @@ function accumulatorOf(rt, sessionId) {
|
|
|
970
1133
|
* tool/result 的 isError 标记失败 callId,只记成功的。
|
|
971
1134
|
* turn-stopping 时本回合 call+result 同处一个增量窗,单次扫描即可关联;无 callId 的事件
|
|
972
1135
|
* (异常/旧形状/测试 mock)保守直记,避免漏门禁。
|
|
973
|
-
* fork
|
|
1136
|
+
* fork 前缀:子代理会话 fork 自父会话时,
|
|
974
1137
|
* 事件流含父事件前缀,游标从 0 首读会把父会话的历史编辑一并计入、多跑门禁。
|
|
975
1138
|
* Session.inheritedEventCount(0.1.2-rc.1 起公开只读)给出 fork 切点——首读下限
|
|
976
1139
|
* 取切点而非 0,父前缀天然跳过;缺属性退回 0(旧行为,保守不漏门禁)。
|
|
@@ -978,8 +1141,22 @@ function accumulatorOf(rt, sessionId) {
|
|
|
978
1141
|
async function readEditedFiles(rt, agent, acc) {
|
|
979
1142
|
const { session } = agent;
|
|
980
1143
|
const sessionId = session.id;
|
|
981
|
-
const
|
|
982
|
-
if (
|
|
1144
|
+
const query = rt.svc.get("sessionQuery");
|
|
1145
|
+
if (query === void 0) {
|
|
1146
|
+
if (!rt.queryMissingWarned) {
|
|
1147
|
+
rt.queryMissingWarned = true;
|
|
1148
|
+
rt.log.warn("[quality-gate] sessionQuery service unavailable; edit accounting is off, so no gate will run");
|
|
1149
|
+
}
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const window = await resolveEventWindow(query, sessionId, rt.lastSeqBySession);
|
|
1153
|
+
if (window === void 0) {
|
|
1154
|
+
if (!rt.queryReadWarned) {
|
|
1155
|
+
rt.queryReadWarned = true;
|
|
1156
|
+
rt.log.warn("[quality-gate] session event read returned no window; this turn's gate is skipped (further read failures stay silent)");
|
|
1157
|
+
}
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
983
1160
|
rt.lastSeqBySession.set(sessionId, window.from + window.events.length);
|
|
984
1161
|
pruneToSessionBudget(rt.lastSeqBySession, sessionId);
|
|
985
1162
|
const edits = collectEdits(window.events);
|
|
@@ -1219,6 +1396,8 @@ function apply(ctx, config) {
|
|
|
1219
1396
|
gateAbort: new AbortController(),
|
|
1220
1397
|
inFlightGates: /* @__PURE__ */ new Set(),
|
|
1221
1398
|
shellWarned: false,
|
|
1399
|
+
queryMissingWarned: false,
|
|
1400
|
+
queryReadWarned: false,
|
|
1222
1401
|
disposed: false
|
|
1223
1402
|
};
|
|
1224
1403
|
svc.on("agent/turn-stopping", async (payload) => {
|
|
@@ -1242,5 +1421,4 @@ var host_default = {
|
|
|
1242
1421
|
Config,
|
|
1243
1422
|
apply
|
|
1244
1423
|
};
|
|
1245
|
-
|
|
1246
|
-
export { Config, apply, host_default as default };
|
|
1424
|
+
export { Config, apply, host_default as default, shellArg };
|