dsh-escalation-review 0.2.0
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 +33 -0
- package/LICENSE +21 -0
- package/NOTICE.md +17 -0
- package/README.md +186 -0
- package/README.zh.md +162 -0
- package/config.json +72 -0
- package/cordis.patch.yml +11 -0
- package/icon.svg +14 -0
- package/lib/breaker.js +76 -0
- package/lib/client.js +1672 -0
- package/lib/config-schema.js +162 -0
- package/lib/config.js +210 -0
- package/lib/decisions.js +67 -0
- package/lib/dsh-packages.js +128 -0
- package/lib/facts.js +277 -0
- package/lib/index.js +543 -0
- package/lib/policy.js +766 -0
- package/lib/probes.js +337 -0
- package/lib/projection.js +114 -0
- package/lib/reviewer.js +265 -0
- package/lib/selftest-loader.js +41 -0
- package/lib/session-key.js +31 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -0
package/lib/probes.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* probes.js —— 受限只读探针(**两条通道**,由配置 `probeRunner` 切换)
|
|
3
|
+
*
|
|
4
|
+
* ① `inproc`(默认):进程内实现,**不 spawn 任何进程**
|
|
5
|
+
* —— 能力最小、无宿主契约依赖、离线可端到端测(真起监听端口 / 真查 git 树)
|
|
6
|
+
* ② `shell`:在**沙箱内**跑固定只读命令(`ctx.shell`)
|
|
7
|
+
* —— 用真契约:`shell.resolve(request)` → `shell.execute(spec)` → `handle.result()`
|
|
8
|
+
* (2026-09-27 从 @deepseek-ai/dsh-shell 的真类型确认;`resolve` 负责补齐 spec 并盖上沙箱策略)
|
|
9
|
+
* `sandboxPolicy: { mode: 'read-only', workspaceRoot }` —— 探针不需要写
|
|
10
|
+
* `run.sandbox` 会回报实际沙箱事实,所以"是否真在沙箱里"可被观察
|
|
11
|
+
* —— 拿不到 `ctx.shell` 时**自动回退**到 inproc 并在证据里记 `fellBackTo`
|
|
12
|
+
*
|
|
13
|
+
* 两条通道共守的纪律:
|
|
14
|
+
* · 参数过严格白名单(模型永远不能提供命令,只能给端口/pid/目录)
|
|
15
|
+
* · 每次评审有额度:最多 MAX_PROBES 个、总预算 PROBE_BUDGET_MS、单个超时 PROBE_TIMEOUT_MS、输出截断
|
|
16
|
+
* · **由待审动作推导该跑哪些探针**(不是模型挑)
|
|
17
|
+
* · 全程 try,失败只记 error,绝不抛
|
|
18
|
+
*
|
|
19
|
+
* 踩过的坑(别再犯):把"请求形状"的对象直接喂 `executeArgv` → 缺 `workdir`(spec 必填)→
|
|
20
|
+
* 内部抛 `Cannot read properties of undefined (reading 'includes')`;且公开入口是 `execute(spec)`。
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
23
|
+
import { connect } from 'node:net'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
export const MAX_PROBES = 4
|
|
27
|
+
export const PROBE_BUDGET_MS = 3_000
|
|
28
|
+
export const PROBE_TIMEOUT_MS = 1_200
|
|
29
|
+
export const MAX_OUTPUT_BYTES = 2_048
|
|
30
|
+
|
|
31
|
+
const IS_WINDOWS = process.platform === 'win32'
|
|
32
|
+
|
|
33
|
+
/** 参数校验器:只接受窄形状,任何越界直接拒绝(不进入任何执行路径)。 */
|
|
34
|
+
const validators = {
|
|
35
|
+
port: (value) => {
|
|
36
|
+
const text = String(value)
|
|
37
|
+
return /^\d{1,5}$/.test(text) && Number(text) >= 1 && Number(text) <= 65535 ? Number(text) : undefined
|
|
38
|
+
},
|
|
39
|
+
pid: (value) => {
|
|
40
|
+
const text = String(value)
|
|
41
|
+
return /^\d{1,7}$/.test(text) ? Number(text) : undefined
|
|
42
|
+
},
|
|
43
|
+
directory: (value) => {
|
|
44
|
+
const text = String(value)
|
|
45
|
+
if (text.length === 0 || text.length > 260 || text.includes('\u0000')) return undefined
|
|
46
|
+
// 连引号也拒:命令模板会把它插进引号里,允许引号就有拼接风险
|
|
47
|
+
return /[|&;<>$`"']/.test(text) ? undefined : text
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 向上找 git 根(最多 6 层)。 */
|
|
52
|
+
function findGitRoot(start) {
|
|
53
|
+
let current = start
|
|
54
|
+
for (let i = 0; i < 6 && current.length > 3; i += 1) {
|
|
55
|
+
try {
|
|
56
|
+
if (existsSync(join(current, '.git'))) return current
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
const parent = dirname(current)
|
|
61
|
+
if (parent === current) return undefined
|
|
62
|
+
current = parent
|
|
63
|
+
}
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 从 `.git/config` 里取 origin 的 url(有界读取,不执行 git)。 */
|
|
68
|
+
function readOriginUrl(gitRoot) {
|
|
69
|
+
const candidates = [join(gitRoot, '.git', 'config'), join(gitRoot, '.git')]
|
|
70
|
+
for (const file of candidates) {
|
|
71
|
+
try {
|
|
72
|
+
if (!existsSync(file) || statSync(file).isDirectory()) continue
|
|
73
|
+
const text = readFileSync(file, 'utf8').slice(0, 16_384)
|
|
74
|
+
const section = text.match(/\[remote "origin"\]([\s\S]{0,400}?)(\n\[|$)/)
|
|
75
|
+
if (section === null) continue
|
|
76
|
+
const url = section[1].match(/url\s*=\s*(\S+)/)
|
|
77
|
+
if (url !== null) return url[1]
|
|
78
|
+
} catch {
|
|
79
|
+
/* 读不到就换下一个候选 */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 试连本机端口(只连不发数据;连上即认为在监听)。 */
|
|
86
|
+
function tryConnect(port, timeoutMs) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
let settled = false
|
|
89
|
+
const finish = (value) => {
|
|
90
|
+
if (settled) return
|
|
91
|
+
settled = true
|
|
92
|
+
resolve(value)
|
|
93
|
+
}
|
|
94
|
+
let socket
|
|
95
|
+
try {
|
|
96
|
+
socket = connect({ host: '127.0.0.1', port })
|
|
97
|
+
} catch (error) {
|
|
98
|
+
finish({ ok: true, output: 'closed', note: String(error?.message ?? error).slice(0, 120) })
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
socket.setTimeout(timeoutMs)
|
|
102
|
+
socket.once('connect', () => {
|
|
103
|
+
finish({ ok: true, output: 'listening' })
|
|
104
|
+
socket.destroy()
|
|
105
|
+
})
|
|
106
|
+
socket.once('timeout', () => {
|
|
107
|
+
finish({ ok: true, output: 'closed', note: 'connect timeout' })
|
|
108
|
+
socket.destroy()
|
|
109
|
+
})
|
|
110
|
+
socket.once('error', (error) => {
|
|
111
|
+
const code = String(error?.code ?? '')
|
|
112
|
+
finish({ ok: true, output: code === 'ECONNREFUSED' ? 'closed' : 'unknown', note: code || String(error?.message ?? '') })
|
|
113
|
+
socket.destroy()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 探针白名单。每条声明:
|
|
120
|
+
* · arg 参数类型(决定白名单校验器)
|
|
121
|
+
* · readOnly 只读
|
|
122
|
+
* · run 进程内实现(inproc 通道)
|
|
123
|
+
* · command 沙箱内固定命令模板(shell 通道;参数已过校验,故可安全插入)
|
|
124
|
+
*/
|
|
125
|
+
export const PROBES = {
|
|
126
|
+
gitInsideWorkTree: {
|
|
127
|
+
id: 'gitInsideWorkTree',
|
|
128
|
+
arg: 'directory',
|
|
129
|
+
readOnly: true,
|
|
130
|
+
describe: '判断目录(或其祖先)是否 git 工作树',
|
|
131
|
+
run: async (directory) => ({ ok: true, output: findGitRoot(directory) === undefined ? 'false' : 'true' }),
|
|
132
|
+
command: (directory) => `git -C "${directory}" rev-parse --is-inside-work-tree`,
|
|
133
|
+
},
|
|
134
|
+
gitRemoteOrigin: {
|
|
135
|
+
id: 'gitRemoteOrigin',
|
|
136
|
+
arg: 'directory',
|
|
137
|
+
readOnly: true,
|
|
138
|
+
describe: '取 remote origin 的 url(进程内读 .git/config;命令通道用 git config --get)',
|
|
139
|
+
run: async (directory) => {
|
|
140
|
+
const root = findGitRoot(directory)
|
|
141
|
+
if (root === undefined) return { ok: true, output: 'not a git work tree' }
|
|
142
|
+
const url = readOriginUrl(root)
|
|
143
|
+
return { ok: true, output: url ?? 'no origin remote' }
|
|
144
|
+
},
|
|
145
|
+
command: (directory) => `git -C "${directory}" config --get remote.origin.url`,
|
|
146
|
+
// git config --get 在"键不存在"时退出 1 —— 那是**查询成功但没有该键**,不是失败
|
|
147
|
+
okExitCodes: [0, 1],
|
|
148
|
+
normalize: (output) => (output.length === 0 ? 'no origin remote' : output),
|
|
149
|
+
},
|
|
150
|
+
portListening: {
|
|
151
|
+
id: 'portListening',
|
|
152
|
+
arg: 'port',
|
|
153
|
+
readOnly: true,
|
|
154
|
+
describe: '本机端口是否有人在监听(进程内试连;命令通道用 netstat/ss)',
|
|
155
|
+
run: async (port, options = {}) => tryConnect(port, options.timeoutMs ?? PROBE_TIMEOUT_MS),
|
|
156
|
+
command: (port) =>
|
|
157
|
+
IS_WINDOWS
|
|
158
|
+
? `netstat -ano | findstr /R /C:":${port} .*LISTENING"`
|
|
159
|
+
: `ss -ltn | grep -c ":${port} "`,
|
|
160
|
+
// findstr / grep 在**没有匹配**时退出 1 —— 那是"没人在监听",不是命令失败
|
|
161
|
+
okExitCodes: [0, 1],
|
|
162
|
+
normalize: (output) => {
|
|
163
|
+
if (output.length > 0) return 'listening'
|
|
164
|
+
return 'closed'
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
processAlive: {
|
|
168
|
+
id: 'processAlive',
|
|
169
|
+
arg: 'pid',
|
|
170
|
+
readOnly: true,
|
|
171
|
+
describe: '进程是否存在(进程内 process.kill(pid,0);命令通道用 Get-Process/kill -0)',
|
|
172
|
+
run: async (pid) => {
|
|
173
|
+
try {
|
|
174
|
+
process.kill(pid, 0)
|
|
175
|
+
return { ok: true, output: 'alive' }
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const code = String(error?.code ?? '')
|
|
178
|
+
return { ok: true, output: code === 'ESRCH' ? 'not running' : 'unknown', note: code || String(error?.message ?? '') }
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
command: (pid) =>
|
|
182
|
+
IS_WINDOWS
|
|
183
|
+
? `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Id`
|
|
184
|
+
: `kill -0 ${pid} && echo alive`,
|
|
185
|
+
// Get-Process / kill -0 在"进程不存在"时退出 1 —— 那是"没在跑",不是命令失败
|
|
186
|
+
okExitCodes: [0, 1],
|
|
187
|
+
normalize: (output) => (output.length === 0 ? 'not running' : 'alive'),
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 走沙箱通道:resolve → execute → result(真契约,见文件头)。 */
|
|
192
|
+
async function runViaShell(probe, validated, options) {
|
|
193
|
+
const shell = options.shell
|
|
194
|
+
const workdir = options.workdir ?? process.cwd()
|
|
195
|
+
const request = {
|
|
196
|
+
command: probe.command(validated),
|
|
197
|
+
workdir,
|
|
198
|
+
timeoutMs: options.timeoutMs ?? PROBE_TIMEOUT_MS,
|
|
199
|
+
onExpiry: 'kill',
|
|
200
|
+
stdoutMaxBytes: options.maxOutputBytes ?? MAX_OUTPUT_BYTES,
|
|
201
|
+
// 探针只读:显式用 read-only 档(resolve 会据此盖上沙箱策略)
|
|
202
|
+
sandboxPolicy: { mode: 'read-only', workspaceRoot: workdir },
|
|
203
|
+
}
|
|
204
|
+
const spec = typeof shell.resolve === 'function' ? shell.resolve(request) : request
|
|
205
|
+
const handle = await shell.execute(spec)
|
|
206
|
+
const run = await handle.result()
|
|
207
|
+
const rawOutput = String(run?.stdout?.text ?? '').trim()
|
|
208
|
+
const output = typeof probe.normalize === 'function' ? String(probe.normalize(rawOutput)) : rawOutput
|
|
209
|
+
const exitCode = run?.exitCode ?? null
|
|
210
|
+
const okCodes = Array.isArray(probe.okExitCodes) ? probe.okExitCodes : [0]
|
|
211
|
+
return {
|
|
212
|
+
// 退出码按探针声明判定(例:git config --get 查不到键时退出 1,那仍算"查到了答案")
|
|
213
|
+
ok: run?.timedOut !== true && run?.aborted !== true && exitCode !== null && okCodes.includes(exitCode),
|
|
214
|
+
output,
|
|
215
|
+
...(run?.timedOut === true ? { timedOut: true } : {}),
|
|
216
|
+
...(exitCode === null ? {} : { exitCode }),
|
|
217
|
+
// run.sandbox 是执行器回报的实际沙箱事实 —— 用来确认"真的在沙箱里"
|
|
218
|
+
...(run?.sandbox === undefined ? {} : { sandbox: run.sandbox }),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 执行一个探针。
|
|
224
|
+
* @param probe - 白名单探针
|
|
225
|
+
* @param rawArg - 原始参数(必须过白名单校验)
|
|
226
|
+
* @param options - { runner: 'inproc'|'shell', shell, workdir, timeoutMs, maxOutputBytes }
|
|
227
|
+
*/
|
|
228
|
+
export async function runProbe(probe, rawArg, options = {}) {
|
|
229
|
+
const maxBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES
|
|
230
|
+
const requested = options.runner === 'shell' ? 'shell' : 'inproc'
|
|
231
|
+
const result = { id: probe.id, arg: rawArg, readOnly: probe.readOnly === true }
|
|
232
|
+
const validated = validators[probe.arg](rawArg)
|
|
233
|
+
if (validated === undefined) {
|
|
234
|
+
result.rejected = 'argument does not match the allowlist shape'
|
|
235
|
+
return result
|
|
236
|
+
}
|
|
237
|
+
result.validatedArg = validated
|
|
238
|
+
// 想要沙箱通道但没有 shell 服务 → 回退 inproc,并如实记账
|
|
239
|
+
const useShell = requested === 'shell' && options.shell !== undefined && options.shell !== null
|
|
240
|
+
const runner = useShell ? 'shell' : 'inproc'
|
|
241
|
+
const fellBackTo = requested === 'shell' && !useShell ? 'inproc' : undefined
|
|
242
|
+
const started = Date.now()
|
|
243
|
+
try {
|
|
244
|
+
const outcome = useShell
|
|
245
|
+
? await runViaShell(probe, validated, { ...options, maxOutputBytes: maxBytes })
|
|
246
|
+
: await probe.run(validated, { timeoutMs: options.timeoutMs ?? PROBE_TIMEOUT_MS })
|
|
247
|
+
return {
|
|
248
|
+
...result,
|
|
249
|
+
runner,
|
|
250
|
+
...(fellBackTo === undefined ? {} : { fellBackTo }),
|
|
251
|
+
ok: outcome?.ok === true,
|
|
252
|
+
output: String(outcome?.output ?? '').slice(0, maxBytes),
|
|
253
|
+
...(outcome?.note === undefined ? {} : { note: String(outcome.note).slice(0, 200) }),
|
|
254
|
+
...(outcome?.timedOut === true ? { timedOut: true } : {}),
|
|
255
|
+
...(outcome?.exitCode === undefined ? {} : { exitCode: outcome.exitCode }),
|
|
256
|
+
...(outcome?.sandbox === undefined ? {} : { sandboxMode: outcome.sandbox?.mode }),
|
|
257
|
+
ms: Date.now() - started,
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return {
|
|
261
|
+
...result,
|
|
262
|
+
runner,
|
|
263
|
+
...(fellBackTo === undefined ? {} : { fellBackTo }),
|
|
264
|
+
ok: false,
|
|
265
|
+
error: String(error?.message ?? error).slice(0, 200),
|
|
266
|
+
stack: String(error?.stack ?? '').split('\n').slice(0, 3).join(' | ').slice(0, 300),
|
|
267
|
+
ms: Date.now() - started,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 从待审动作里推导出"值得跑"的探针(不是模型挑)。 */
|
|
273
|
+
export function selectProbes(exec, facts) {
|
|
274
|
+
const selected = []
|
|
275
|
+
const args = exec?.arguments ?? {}
|
|
276
|
+
// 只在**命令正文**里找触发词:整个 arguments 的 JSON 里混着 description 与理由,
|
|
277
|
+
// 会把"路径里含 Git""理由里提端口"当成触发条件(实测误触发过)。
|
|
278
|
+
const command = typeof args.command === 'string' ? args.command : JSON.stringify(args)
|
|
279
|
+
// git 探针:必须真的在调 git,而不是路径里出现 Git 三个字母
|
|
280
|
+
const gitInvocation = /(^|[\s;&|("'])git(\.exe)?\s+(-C\s|status|remote|rev-parse|log|diff|ls-files|branch|show)/i.test(command)
|
|
281
|
+
if (gitInvocation) {
|
|
282
|
+
// 目录用会话 cwd:不再从命令文本里解析路径(2026-09-27 退役路径解析)
|
|
283
|
+
const cwd = (() => {
|
|
284
|
+
try {
|
|
285
|
+
return exec?.agent?.session?.header?.cwd
|
|
286
|
+
} catch {
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
})()
|
|
290
|
+
if (typeof cwd === 'string' && cwd.length > 0) {
|
|
291
|
+
selected.push({ probe: PROBES.gitInsideWorkTree, arg: cwd })
|
|
292
|
+
selected.push({ probe: PROBES.gitRemoteOrigin, arg: cwd })
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// 端口探针:端口必须出现在**监听/服务语境附近**(代理端口 7890 这类隔着半条命令的不算)
|
|
296
|
+
const portCtx = command.match(/(?:listen|serve|--port|监听)[^\n]{0,40}?(\d{2,5})\b|(\d{2,5})\b[^\n]{0,20}?(?:listen|监听)/i)
|
|
297
|
+
if (portCtx !== null) {
|
|
298
|
+
const port = portCtx[1] ?? portCtx[2]
|
|
299
|
+
if (port !== undefined) selected.push({ probe: PROBES.portListening, arg: port })
|
|
300
|
+
}
|
|
301
|
+
const pidMatch = command.match(/\bpid[=: ]+(\d{1,7})\b/i)
|
|
302
|
+
if (pidMatch !== null) selected.push({ probe: PROBES.processAlive, arg: pidMatch[1] })
|
|
303
|
+
return selected.slice(0, MAX_PROBES)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 在额度内依次执行选中的探针;超预算即停并标记。 */
|
|
307
|
+
export async function runSelectedProbes(exec, facts, options = {}) {
|
|
308
|
+
const budgetMs = options.budgetMs ?? PROBE_BUDGET_MS
|
|
309
|
+
const maxProbes = options.maxProbes ?? MAX_PROBES
|
|
310
|
+
const runner = options.runner === 'shell' ? 'shell' : 'inproc'
|
|
311
|
+
const selected = selectProbes(exec, facts).slice(0, maxProbes)
|
|
312
|
+
const probes = []
|
|
313
|
+
const notes = []
|
|
314
|
+
const started = Date.now()
|
|
315
|
+
let budgetExceeded = false
|
|
316
|
+
if (runner === 'shell' && (options.shell === undefined || options.shell === null)) {
|
|
317
|
+
notes.push('shell runner requested but ctx.shell unavailable; fell back to in-process probes')
|
|
318
|
+
}
|
|
319
|
+
for (const item of selected) {
|
|
320
|
+
if (Date.now() - started > budgetMs) {
|
|
321
|
+
budgetExceeded = true
|
|
322
|
+
notes.push('probe budget exhausted; remaining probes skipped')
|
|
323
|
+
break
|
|
324
|
+
}
|
|
325
|
+
const remaining = Math.max(200, budgetMs - (Date.now() - started))
|
|
326
|
+
probes.push(
|
|
327
|
+
await runProbe(item.probe, item.arg, {
|
|
328
|
+
runner,
|
|
329
|
+
shell: options.shell,
|
|
330
|
+
workdir: options.workdir,
|
|
331
|
+
timeoutMs: Math.min(PROBE_TIMEOUT_MS, remaining),
|
|
332
|
+
}),
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
if (selected.length === 0) notes.push('no probe was warranted by this action')
|
|
336
|
+
return { probes, notes, budgetExceeded, budgetMs, maxProbes, runner }
|
|
337
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projection.js —— 把"每次提权的评审判定与理由"送到客户端卡片。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:放行那次的评审理由**没有事件通道** —— 官方 `user-approval` 是先
|
|
5
|
+
* `session.append('approval/asked', …)` 再 await decide(),所以往 `req.reason` 里追加
|
|
6
|
+
* 判定根本进不了会话日志(2026-09-28 实测确认)。而会话事件又写不了自定义类型
|
|
7
|
+
* (第三方插件拿不到 `ignorable` 标记)。官方为此准备的通道就是 `sessionProjections`:
|
|
8
|
+
* 域侧保留状态、客户端直接读 `projectionValues`(官方 `agent-preset` 就是这么用的)。
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ 诚实说明:`wire.view` 里的判定来自**本插件的内存表**,不是从日志重放出来的。
|
|
11
|
+
* 因此它能反映**本次运行**里发生的评审;重载会话后旧调用只剩工具结果派生出的状态,
|
|
12
|
+
* 理由会消失(不会出错,只是退化)。这一点与"投影应当可重放"的常规要求不一致,
|
|
13
|
+
* 是有意的取舍:不然就得把整条 typert Remote 通道铺起来。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 客户端读这个键:`session.projectionValues['escalation-review']`。 */
|
|
17
|
+
export const PROJECTION_KEY = 'escalation-review'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 建立投影定义。`stateSchema`/`viewSchema` 用宿主自带的 schemastery(`z`)构建,
|
|
21
|
+
* 形状保持宽松(键是 callId,值是判定对象)—— 校验失败会被注册表拒绝,所以宁可宽。
|
|
22
|
+
* @param {object} input - { z, store }
|
|
23
|
+
* @returns {object} ProjectionDefinition
|
|
24
|
+
*/
|
|
25
|
+
export function createProjectionDefinition({ z, store }) {
|
|
26
|
+
// ⚠️ 这里必须是 **zod**(官方类型:`viewSchema: ZodType<…>`,session-projection/src/index.ts:75;
|
|
27
|
+
// gateway 第 466 行直接调 `wire.viewSchema.parse(...)`)。踩过的两个坑:
|
|
28
|
+
// ① 先用 schemastery 的 z.any() —— 产出物没有 .parse → 每次投影会话都抛
|
|
29
|
+
// "failed to project session … is not a function",**整个会话历史加载不出来**;
|
|
30
|
+
// ② 换成 schemastery 的 z.dict(z.object(...)) 同样没有 .parse。
|
|
31
|
+
// zod 4 的 record 签名是 z.record(keyType, valueType)。
|
|
32
|
+
const verdictSchema = z.object({
|
|
33
|
+
risk: z.string(),
|
|
34
|
+
decision: z.string(),
|
|
35
|
+
rationale: z.string(),
|
|
36
|
+
reason: z.string(),
|
|
37
|
+
at: z.number(),
|
|
38
|
+
})
|
|
39
|
+
const viewSchema = z.record(z.string(), verdictSchema)
|
|
40
|
+
|
|
41
|
+
const stateSchema = z.object({
|
|
42
|
+
revision: z.number(),
|
|
43
|
+
view: z.record(z.string(), verdictSchema),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// 注册前自检:schema 必须真的能 parse —— 不满足就抛,让调用方跳过注册(历史优先)
|
|
47
|
+
if (viewSchema === null || viewSchema === undefined || typeof viewSchema.parse !== 'function') {
|
|
48
|
+
throw new Error('escalation-review: projection view schema is unusable (no parse); refusing to register')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
key: PROJECTION_KEY,
|
|
53
|
+
stateVersion: 1,
|
|
54
|
+
stateSchema,
|
|
55
|
+
// 状态只需一个"已发布版本号":内容本身每次从 store 现取(见 apply)
|
|
56
|
+
init: () => ({ revision: 0, view: {} }),
|
|
57
|
+
apply: (state, _event) => {
|
|
58
|
+
// 未发生变化时必须返回**同一个引用**(官方契约:引用不变 = 零下游工作量)
|
|
59
|
+
const revision = store.revision()
|
|
60
|
+
if (revision === state.revision) return state
|
|
61
|
+
return { revision, view: store.snapshot() }
|
|
62
|
+
},
|
|
63
|
+
wire: {
|
|
64
|
+
viewSchema,
|
|
65
|
+
view: (state) => (state !== null && state !== undefined && typeof state === 'object' ? state.view : {}),
|
|
66
|
+
},
|
|
67
|
+
// 保留字段供将来做严格校验(当前 viewSchema 宽松)
|
|
68
|
+
_verdictSchema: verdictSchema,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 判定仓库:进程内、按 callId 索引。只保留最近 N 条,避免长会话无限增长。
|
|
74
|
+
* @param {number} limit - 最多保留多少条(默认 200)
|
|
75
|
+
*/
|
|
76
|
+
export function createVerdictStore(limit = 200) {
|
|
77
|
+
/** @type {Map<string, object>} */
|
|
78
|
+
const byCallId = new Map()
|
|
79
|
+
let revision = 0
|
|
80
|
+
return {
|
|
81
|
+
/** 记一次评审结果;callId 缺失时忽略(无法与卡片关联)。 */
|
|
82
|
+
remember(callId, verdict) {
|
|
83
|
+
if (typeof callId !== 'string' || callId.length === 0) return
|
|
84
|
+
const entry = {
|
|
85
|
+
risk: String(verdict?.risk ?? 'unknown'),
|
|
86
|
+
decision: String(verdict?.decision ?? 'unknown'),
|
|
87
|
+
rationale: typeof verdict?.rationale === 'string' ? verdict.rationale : '',
|
|
88
|
+
reason: typeof verdict?.reason === 'string' ? verdict.reason : '',
|
|
89
|
+
at: Date.now(),
|
|
90
|
+
}
|
|
91
|
+
byCallId.set(callId, entry)
|
|
92
|
+
while (byCallId.size > limit) {
|
|
93
|
+
const oldest = byCallId.keys().next()
|
|
94
|
+
if (oldest.done === true) break
|
|
95
|
+
byCallId.delete(oldest.value)
|
|
96
|
+
}
|
|
97
|
+
revision += 1
|
|
98
|
+
},
|
|
99
|
+
/** 单调递增的版本号:投影据此判断"有没有新东西"。 */
|
|
100
|
+
revision() {
|
|
101
|
+
return revision
|
|
102
|
+
},
|
|
103
|
+
/** 客户端可见的纯数据快照(每次都新建对象,避免与 React 引用比较打架)。 */
|
|
104
|
+
snapshot() {
|
|
105
|
+
const out = {}
|
|
106
|
+
for (const [callId, entry] of byCallId) out[callId] = { ...entry }
|
|
107
|
+
return out
|
|
108
|
+
},
|
|
109
|
+
/** 诊断用 */
|
|
110
|
+
size() {
|
|
111
|
+
return byCallId.size
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|