tensorgrid-ui 1.4.1 → 1.10.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/LICENSE +15 -0
- package/brand/tensorgrid.ico +0 -0
- package/env/snapshot.json +916 -0
- package/install.ps1 +457 -0
- package/lib/cli.mjs +67 -0
- package/lib/client.js +316 -35
- package/lib/http-runtime.js +104 -4
- package/lib/index.js +75 -1
- package/lib/review-log.js +538 -0
- package/lib/review-settings.js +246 -22
- package/lib/review.js +212 -39
- package/lib/reviewers.js +235 -17
- package/package.json +12 -2
- package/preset/agent.cordis.yml +304 -0
- package/preset/preset.yml +5 -0
- package/profile/cordis.patch.yml +65 -0
- package/profile/package.json +14 -0
- package/tools/round-ico-corners.mjs +113 -0
package/lib/index.js
CHANGED
|
@@ -220,10 +220,81 @@ export function apply(ctx) {
|
|
|
220
220
|
})
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
+
// Служба моделей нужна отдельным рядом: она отдаёт живой список
|
|
224
|
+
// подключённых моделей для выбора ревизора. Отдельным — потому что
|
|
225
|
+
// объединение с парой выше связало бы настройки обзора со стойкой
|
|
226
|
+
// входа: нет одной — пропала бы и другая, хотя они независимы.
|
|
227
|
+
const modelServices = { llm: null, generation: null }
|
|
228
|
+
web.inject(['llm'], (bound) => {
|
|
229
|
+
const generation = {}
|
|
230
|
+
modelServices.llm = bound.llm
|
|
231
|
+
modelServices.generation = generation
|
|
232
|
+
bound.effect(() => () => {
|
|
233
|
+
if (modelServices.generation !== generation) return
|
|
234
|
+
modelServices.llm = null
|
|
235
|
+
modelServices.generation = null
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// ── ЗАСЛОН ДЛЯ НАШИХ МАРШРУТОВ ──────────────────────────────────────
|
|
240
|
+
//
|
|
241
|
+
// Берётся ШТАТНЫЙ, а не пишется свой. Это не перестраховка, а починка
|
|
242
|
+
// настоящей дыры, найденной обзором и подтверждённой на живом
|
|
243
|
+
// приложении:
|
|
244
|
+
//
|
|
245
|
+
// / 401 защищено
|
|
246
|
+
// /api/любой-другой 401 защищено
|
|
247
|
+
// /api/tensorgrid.auth 200 НАШ, без токена
|
|
248
|
+
//
|
|
249
|
+
// Приложение регистрирует на префикс `/api` обработчик с проверкой, а
|
|
250
|
+
// точное совпадение пути выигрывает у префикса. Наши точные маршруты не
|
|
251
|
+
// просто «не добавляли токен» — они ВЫРЕЗАЛИ из-под себя весь заслон.
|
|
252
|
+
//
|
|
253
|
+
// Штатная проверка делает три вещи, которых моя самодельная не делала:
|
|
254
|
+
// требует петлевой или явно доверенный Host (это и закрывает подмену
|
|
255
|
+
// имени), отклоняет запрос с `sec-fetch-site: cross-site` и лишь затем
|
|
256
|
+
// сверяет Origin. Плюс проверяет токен браузерной сессии.
|
|
257
|
+
const guard = { connection: null, generation: null }
|
|
258
|
+
web.inject(['connection'], (bound) => {
|
|
259
|
+
const generation = {}
|
|
260
|
+
guard.connection = bound.connection
|
|
261
|
+
guard.generation = generation
|
|
262
|
+
bound.effect(() => () => {
|
|
263
|
+
if (guard.generation !== generation) return
|
|
264
|
+
guard.connection = null
|
|
265
|
+
guard.generation = null
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Отклонить запрос, если штатный заслон против.
|
|
271
|
+
*
|
|
272
|
+
* @returns true, если ответ уже отправлен и обработчику делать нечего.
|
|
273
|
+
*/
|
|
274
|
+
function rejected(req, res) {
|
|
275
|
+
const connection = guard.connection
|
|
276
|
+
if (connection === null) {
|
|
277
|
+
// Заслона нет — значит браузерная часть не поднята. Открывать
|
|
278
|
+
// маршрут «пока суд да дело» нельзя: это ровно та дыра, что чиним.
|
|
279
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' })
|
|
280
|
+
res.end(JSON.stringify({ problem: 'проверка доступа недоступна' }))
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
const rejection = connection.requestRejection(req)
|
|
284
|
+
if (rejection === undefined) return false
|
|
285
|
+
res.writeHead(rejection, { 'content-type': 'application/json; charset=utf-8' })
|
|
286
|
+
res.end(JSON.stringify({ problem: rejection === 401 ? 'требуется вход в приложение' : 'запрос отклонён' }))
|
|
287
|
+
return true
|
|
288
|
+
}
|
|
289
|
+
|
|
223
290
|
// Манифест собирается в горячем модуле: он ЧИТАЕТ поставочный файл и
|
|
224
291
|
// дополняет его нашими полями, а значит зависит от чужой формы, которая
|
|
225
292
|
// может поменяться. Такую логику правильнее держать там, где её можно
|
|
226
293
|
// поправить без перезапуска.
|
|
294
|
+
//
|
|
295
|
+
// Заслон сюда НЕ ставится намеренно: манифест и иконку браузер просит до
|
|
296
|
+
// всякого входа, и в поставке они тоже отдаются открыто. Секретов в них
|
|
297
|
+
// нет — только имя и цвета.
|
|
227
298
|
web.effect(() =>
|
|
228
299
|
web.webServer.register({
|
|
229
300
|
kind: 'exact',
|
|
@@ -246,8 +317,9 @@ export function apply(ctx) {
|
|
|
246
317
|
path: '/api/tensorgrid.review',
|
|
247
318
|
async handler(req, res) {
|
|
248
319
|
try {
|
|
320
|
+
if (rejected(req, res)) return
|
|
249
321
|
const runtime = await loadModule('./http-runtime.js')
|
|
250
|
-
await runtime.handleReview(req, res, loadModule)
|
|
322
|
+
await runtime.handleReview(req, res, loadModule, modelServices)
|
|
251
323
|
} catch (error) {
|
|
252
324
|
fail(res, error)
|
|
253
325
|
}
|
|
@@ -261,6 +333,7 @@ export function apply(ctx) {
|
|
|
261
333
|
path: '/api/tensorgrid.auth',
|
|
262
334
|
async handler(req, res) {
|
|
263
335
|
try {
|
|
336
|
+
if (rejected(req, res)) return
|
|
264
337
|
const runtime = await loadModule('./http-runtime.js')
|
|
265
338
|
await runtime.handleAuth(req, res, authServices, loadModule)
|
|
266
339
|
} catch (error) {
|
|
@@ -276,6 +349,7 @@ export function apply(ctx) {
|
|
|
276
349
|
path: '/api/tensorgrid.update',
|
|
277
350
|
async handler(req, res) {
|
|
278
351
|
try {
|
|
352
|
+
if (rejected(req, res)) return
|
|
279
353
|
const runtime = await loadModule('./http-runtime.js')
|
|
280
354
|
await runtime.handleUpdate(req, res, updateState, loadModule)
|
|
281
355
|
} catch (error) {
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Журнал обзоров: что нашли ревизоры и что из этого подтвердилось.
|
|
3
|
+
*
|
|
4
|
+
* ЗАЧЕМ ЭТО НУЖНО
|
|
5
|
+
*
|
|
6
|
+
* Ревизор ошибается примерно в трети случаев. Сегодня это проверено трижды:
|
|
7
|
+
* из четырёх выборочно проверенных находок одна оказалась ложной, причём
|
|
8
|
+
* обоснованной — ревизор запустил проверку, получил верный результат и
|
|
9
|
+
* сделал из него неверный вывод, честно пометив, чего не смог проверить.
|
|
10
|
+
*
|
|
11
|
+
* Пока приговор выносится в переписке и там же теряется, происходят две
|
|
12
|
+
* плохие вещи. Пользователь видит только пересказ агента, то есть его
|
|
13
|
+
* фильтрацию вместе с его ошибками. И никто не знает, какой ревизор на какой
|
|
14
|
+
* модели чаще прав — а это единственная величина, по которой можно судить,
|
|
15
|
+
* стоит ли он своих денег.
|
|
16
|
+
*
|
|
17
|
+
* КТО СУДИТ
|
|
18
|
+
*
|
|
19
|
+
* Не тот, кто смотрит, а тот, кто делал работу. Просить человека, не
|
|
20
|
+
* разбирающегося в коде, решить «настоящая это ошибка или нет» — значит
|
|
21
|
+
* просить невозможного и получить в ответ случайные нажатия, которые ещё и
|
|
22
|
+
* будут выглядеть как данные.
|
|
23
|
+
*
|
|
24
|
+
* Поэтому приговор выносит агент и обязан приложить ДОКАЗАТЕЛЬСТВО: что
|
|
25
|
+
* именно он запустил или прочитал. Человек может возразить, но не обязан
|
|
26
|
+
* ничего решать.
|
|
27
|
+
*
|
|
28
|
+
* Импортируются только встроенные модули Node.
|
|
29
|
+
*/
|
|
30
|
+
import { readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
31
|
+
import { existsSync } from 'node:fs'
|
|
32
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
33
|
+
import { dirname, join } from 'node:path'
|
|
34
|
+
import { fileURLToPath } from 'node:url'
|
|
35
|
+
|
|
36
|
+
const FILE_NAME = 'tensorgrid-review-log.json'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Сколько обзоров храним.
|
|
40
|
+
*
|
|
41
|
+
* Журнал нужен для статистики, а не для архива. Двести обзоров — это месяцы
|
|
42
|
+
* работы, и по ним уже видно, какой ревизор чаще прав. Расти бесконечно
|
|
43
|
+
* файлу нельзя: его читают при каждом открытии настроек.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_RUNS = 200
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Пределы на объём.
|
|
49
|
+
*
|
|
50
|
+
* Ограничения числа обзоров недостаточно: в каждой находке до пяти полей, в
|
|
51
|
+
* обзоре до шестидесяти находок на ревизора, ревизоров трое. Без предела на
|
|
52
|
+
* ДЛИНУ полей двести обзоров дают сотни мегабайт, а файл читается целиком
|
|
53
|
+
* при каждом открытии настроек и при каждом нажатии в панели.
|
|
54
|
+
*/
|
|
55
|
+
const FIELD_CHARS = 1200
|
|
56
|
+
const REQUEST_CHARS = 400
|
|
57
|
+
const EVIDENCE_CHARS = 800
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Сколько знаков доказательства считается доказательством.
|
|
61
|
+
*
|
|
62
|
+
* Порог намеренно низкий: это заслон от ПУСТОГО поля, а не попытка судить
|
|
63
|
+
* о качестве проверки. Судить о качестве может только человек, читающий
|
|
64
|
+
* улику, — для того она и показывается в панели рядом с находкой.
|
|
65
|
+
*/
|
|
66
|
+
const MIN_EVIDENCE_CHARS = 12
|
|
67
|
+
|
|
68
|
+
/** Допустимые приговоры. Четвёртого состояния нет: неразобранное — «ждёт». */
|
|
69
|
+
export const VERDICTS = ['pending', 'confirmed', 'false', 'deferred']
|
|
70
|
+
|
|
71
|
+
/** Каталог профиля: поднимаемся, пока не найдём отметку об установке. */
|
|
72
|
+
function findProfileRoot(startDir) {
|
|
73
|
+
let dir = startDir
|
|
74
|
+
for (let depth = 0; depth < 10; depth += 1) {
|
|
75
|
+
if (existsSync(join(dir, 'tensorgrid-installed.json'))) return dir
|
|
76
|
+
const parent = dirname(dir)
|
|
77
|
+
if (parent === dir) break
|
|
78
|
+
dir = parent
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Где лежит журнал. Рядом с настройками обзора.
|
|
85
|
+
*
|
|
86
|
+
* Каталог ищется ОТ СОБСТВЕННОГО расположения модуля, а не от рабочей
|
|
87
|
+
* папки. Это важно: рабочую папку задаёт чат, и подложенная в неё отметка
|
|
88
|
+
* об установке увела бы журнал в чужое дерево. Читают и пишут журнал
|
|
89
|
+
* разные стороны — агент и браузер, — и указывать они обязаны на один файл.
|
|
90
|
+
*
|
|
91
|
+
* @param root - явный каталог; только для проверок, где установленного
|
|
92
|
+
* профиля нет вовсе.
|
|
93
|
+
*/
|
|
94
|
+
export function logPath(root = null) {
|
|
95
|
+
const found = root ?? findProfileRoot(dirname(fileURLToPath(import.meta.url)))
|
|
96
|
+
return found === null ? null : join(found, FILE_NAME)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Устойчивый признак находки.
|
|
101
|
+
*
|
|
102
|
+
* Считается по содержанию, а не по порядковому номеру: тот же дефект,
|
|
103
|
+
* найденный повторно в следующем обзоре, получит тот же признак, и прежний
|
|
104
|
+
* приговор к нему подойдёт. Нумерация этого не дала бы — она сдвигается от
|
|
105
|
+
* любой новой находки выше по списку.
|
|
106
|
+
*/
|
|
107
|
+
export function findingId(finding) {
|
|
108
|
+
const parts = [finding.reviewer, finding.title, finding.where ?? '']
|
|
109
|
+
return createHash('sha1').update(parts.join('\u0000')).digest('hex').slice(0, 12)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Пустой журнал. Отсутствие файла — не ошибка, а «обзоров ещё не было». */
|
|
113
|
+
export function empty() {
|
|
114
|
+
return { runs: [], verdicts: {} }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Отрезать текст, не притворяясь, что ничего не отрезали. */
|
|
118
|
+
function clip(value, limit) {
|
|
119
|
+
if (typeof value !== 'string') return null
|
|
120
|
+
return value.length <= limit ? value : `${value.slice(0, limit)}… (обрезано)`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Привести прочитанное к ожидаемой форме.
|
|
125
|
+
*
|
|
126
|
+
* Внутрь обзора смотрим ТАК ЖЕ придирчиво, как наружу. Раньше проверялось
|
|
127
|
+
* только наличие `id`, а `findings` и `reviewers` принимались любого типа —
|
|
128
|
+
* и один криво отредактированный обзор ронял `latest()` на `.map`, то есть
|
|
129
|
+
* весь раздел настроек целиком: состав, режимы, выбор моделей, запись
|
|
130
|
+
* приговоров. Починить из интерфейса было нельзя.
|
|
131
|
+
*/
|
|
132
|
+
export function normalise(raw) {
|
|
133
|
+
if (raw === null || typeof raw !== 'object') return empty()
|
|
134
|
+
|
|
135
|
+
const text = (value, limit) => (typeof value === 'string' ? clip(value, limit) : null)
|
|
136
|
+
|
|
137
|
+
const runs = Array.isArray(raw.runs)
|
|
138
|
+
? raw.runs
|
|
139
|
+
.filter((run) => run !== null && typeof run === 'object' && typeof run.id === 'string')
|
|
140
|
+
.map((run) => ({
|
|
141
|
+
id: run.id,
|
|
142
|
+
at: text(run.at, 40) ?? null,
|
|
143
|
+
request: text(run.request, REQUEST_CHARS) ?? '',
|
|
144
|
+
reviewers: Array.isArray(run.reviewers)
|
|
145
|
+
? run.reviewers
|
|
146
|
+
.filter((r) => r !== null && typeof r === 'object')
|
|
147
|
+
.map((r) => ({
|
|
148
|
+
provider: text(r.provider, 64),
|
|
149
|
+
model: text(r.model, 96),
|
|
150
|
+
effort: text(r.effort, 32),
|
|
151
|
+
failed: text(r.failed, 200),
|
|
152
|
+
found: Number.isFinite(r.found) ? r.found : 0,
|
|
153
|
+
}))
|
|
154
|
+
: [],
|
|
155
|
+
findings: Array.isArray(run.findings)
|
|
156
|
+
? run.findings
|
|
157
|
+
.filter((f) => f !== null && typeof f === 'object' && typeof f.id === 'string')
|
|
158
|
+
.map((f) => ({
|
|
159
|
+
id: f.id,
|
|
160
|
+
reviewer: text(f.reviewer, 64) ?? '?',
|
|
161
|
+
severity: text(f.severity, 32) ?? 'не указана',
|
|
162
|
+
title: text(f.title, FIELD_CHARS) ?? '',
|
|
163
|
+
where: text(f.where, FIELD_CHARS),
|
|
164
|
+
what: text(f.what, FIELD_CHARS),
|
|
165
|
+
why: text(f.why, FIELD_CHARS),
|
|
166
|
+
howToCheck: text(f.howToCheck, FIELD_CHARS),
|
|
167
|
+
}))
|
|
168
|
+
: [],
|
|
169
|
+
}))
|
|
170
|
+
.slice(-MAX_RUNS)
|
|
171
|
+
: []
|
|
172
|
+
|
|
173
|
+
// Признаки находок, которые ещё существуют. По ним отсеиваются
|
|
174
|
+
// приговоры-сироты: обзор выпал за предел, а приговор по нему остался бы
|
|
175
|
+
// в файле навсегда и продолжал бы влиять на статистику.
|
|
176
|
+
const alive = new Set()
|
|
177
|
+
for (const run of runs) for (const finding of run.findings) alive.add(finding.id)
|
|
178
|
+
|
|
179
|
+
// Объект БЕЗ прототипа: иначе ключ `__proto__` из правленого файла не
|
|
180
|
+
// записывается вовсе, а `recordVerdict` при этом отвечает «применено» —
|
|
181
|
+
// приговор человека исчезал при успешном ответе.
|
|
182
|
+
const verdicts = Object.create(null)
|
|
183
|
+
if (raw.verdicts !== null && typeof raw.verdicts === 'object') {
|
|
184
|
+
for (const [id, entry] of Object.entries(raw.verdicts)) {
|
|
185
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
186
|
+
if (!VERDICTS.includes(entry.verdict)) continue
|
|
187
|
+
if (!alive.has(id)) continue
|
|
188
|
+
verdicts[id] = {
|
|
189
|
+
verdict: entry.verdict,
|
|
190
|
+
evidence: text(entry.evidence, EVIDENCE_CHARS) ?? '',
|
|
191
|
+
by: entry.by === 'human' ? 'human' : 'agent',
|
|
192
|
+
at: text(entry.at, 40),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { runs, verdicts }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Прочитать журнал.
|
|
202
|
+
*
|
|
203
|
+
* Возвращает ещё и признак `intact`: удалось ли разобрать файл. Без него
|
|
204
|
+
* повреждённый журнал выглядел как пустой, а следующая запись накрывала им
|
|
205
|
+
* настоящие данные — двести обзоров и все приговоры, включая возражения
|
|
206
|
+
* человека, исчезали от одной лишней запятой, молча и безвозвратно.
|
|
207
|
+
*/
|
|
208
|
+
export async function read(root = null) {
|
|
209
|
+
const path = logPath(root)
|
|
210
|
+
if (path === null) return { ...empty(), intact: true, missing: true }
|
|
211
|
+
let text
|
|
212
|
+
try {
|
|
213
|
+
text = await readFile(path, 'utf8')
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Файла нет — это не повреждение, а «обзоров ещё не было».
|
|
216
|
+
if (error?.code === 'ENOENT') return { ...empty(), intact: true, missing: true }
|
|
217
|
+
return { ...empty(), intact: false, problem: String(error?.message ?? error) }
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
return { ...normalise(JSON.parse(text.replace(/^\uFEFF/, ''))), intact: true, missing: false }
|
|
221
|
+
} catch (error) {
|
|
222
|
+
return { ...empty(), intact: false, problem: `журнал не разобран: ${String(error?.message ?? error)}` }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Очередь записи на каждый файл.
|
|
228
|
+
*
|
|
229
|
+
* Запись идёт по схеме «прочитать целиком → изменить → записать целиком»,
|
|
230
|
+
* и без очереди одновременные вызовы читают ОДНО состояние, а пишут каждый
|
|
231
|
+
* своё: побеждает последний, остальные исчезают бесследно. Проверено —
|
|
232
|
+
* двенадцать одновременных записей давали одну.
|
|
233
|
+
*
|
|
234
|
+
* Случай не выдуманный: журнал пишут две стороны — Host в конце обзора и
|
|
235
|
+
* браузер по нажатию в панели. Человек подтверждает находку ровно в тот
|
|
236
|
+
* момент, когда завершается второй обзор.
|
|
237
|
+
*
|
|
238
|
+
* Очередь внутрипроцессная, и этого достаточно: оба пути проходят через
|
|
239
|
+
* один Host. От двух ОДНОВРЕМЕННО ЗАПУЩЕННЫХ приложений она не спасает —
|
|
240
|
+
* такой случай потребовал бы файловой блокировки, а он у нас невозможен:
|
|
241
|
+
* второй экземпляр не поднимется на занятом порту.
|
|
242
|
+
*/
|
|
243
|
+
const queues = new Map()
|
|
244
|
+
|
|
245
|
+
function serialise(path, work) {
|
|
246
|
+
const previous = queues.get(path) ?? Promise.resolve()
|
|
247
|
+
const next = previous.then(work, work)
|
|
248
|
+
// В очереди держим ТОЛЬКО факт завершения, без результата и без отказа:
|
|
249
|
+
// иначе одна неудачная запись уронила бы все следующие.
|
|
250
|
+
queues.set(path, next.then(() => undefined, () => undefined))
|
|
251
|
+
return next
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Переименовать с повтором.
|
|
255
|
+
*
|
|
256
|
+
* На Windows одновременное переименование двух временных файлов в одну цель
|
|
257
|
+
* изредка отказывает с EPERM или EBUSY: файл в этот момент держит чужая
|
|
258
|
+
* операция. Это не ошибка вызывающего и не повод терять запись — достаточно
|
|
259
|
+
* повторить через несколько миллисекунд.
|
|
260
|
+
*
|
|
261
|
+
* Повторяются только эти два кода. Всё прочее — настоящая ошибка (нет прав
|
|
262
|
+
* на каталог, кончилось место), и глотать её нельзя.
|
|
263
|
+
*/
|
|
264
|
+
async function renameWithRetry(from, to, attempts = 5) {
|
|
265
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
266
|
+
try {
|
|
267
|
+
await rename(from, to)
|
|
268
|
+
return
|
|
269
|
+
} catch (error) {
|
|
270
|
+
const retriable = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
|
|
271
|
+
if (!retriable || attempt >= attempts) throw error
|
|
272
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 15))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Записать журнал.
|
|
278
|
+
*
|
|
279
|
+
* Запись идёт через временный файл с УНИКАЛЬНЫМ именем и переименованием.
|
|
280
|
+
* Прежнее имя складывалось из номера процесса и времени в миллисекундах —
|
|
281
|
+
* у двух одновременных записей оно совпадало, и вторая получала отказ на
|
|
282
|
+
* переименовании, потому что первая уже унесла общий временный файл.
|
|
283
|
+
*/
|
|
284
|
+
async function write(value, root = null) {
|
|
285
|
+
const path = logPath(root)
|
|
286
|
+
if (path === null) throw new Error('не найден каталог профиля — некуда писать журнал обзоров')
|
|
287
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
288
|
+
try {
|
|
289
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
290
|
+
await renameWithRetry(temporary, path)
|
|
291
|
+
} catch (error) {
|
|
292
|
+
// Неудавшаяся запись не должна оставлять мусор рядом с настройками.
|
|
293
|
+
await rm(temporary, { force: true }).catch(() => {})
|
|
294
|
+
throw error
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Записать состоявшийся обзор.
|
|
300
|
+
*
|
|
301
|
+
* @param run - что спрашивали, кого звали и что нашли.
|
|
302
|
+
* @returns признак записи и признаки находок — по ним потом выносится приговор.
|
|
303
|
+
*/
|
|
304
|
+
export async function recordRun(run, root = null) {
|
|
305
|
+
return serialise(logPath(root) ?? 'нет-пути', () => recordRunNow(run, root))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function recordRunNow(run, root) {
|
|
309
|
+
const log = await read(root)
|
|
310
|
+
|
|
311
|
+
// Повреждённый журнал НЕ затирается.
|
|
312
|
+
//
|
|
313
|
+
// Раньше любая ошибка чтения давала пустой журнал, и следующая же запись
|
|
314
|
+
// накрывала им настоящие данные: двести обзоров и все приговоры, включая
|
|
315
|
+
// возражения человека, исчезали от одной лишней запятой — молча, с
|
|
316
|
+
// обычным ответом об успехе. Файл правит и человек руками, это заявлено
|
|
317
|
+
// как штатное, так что случай не выдуманный.
|
|
318
|
+
if (log.intact === false) {
|
|
319
|
+
throw new Error(`журнал обзоров повреждён, запись отменена, чтобы не потерять прежние: ${log.problem ?? ''}`.trim())
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const findings = []
|
|
323
|
+
const seen = new Set()
|
|
324
|
+
for (const finding of run.findings ?? []) {
|
|
325
|
+
let id = findingId(finding)
|
|
326
|
+
// Признак считается по содержанию, и две находки с одинаковыми
|
|
327
|
+
// ревизором, названием и местом получали ОДИН признак: приговор по
|
|
328
|
+
// первой молча становился приговором по второй, а вторая исчезала из
|
|
329
|
+
// разбора. Различаем порядковым номером в пределах обзора.
|
|
330
|
+
if (seen.has(id)) {
|
|
331
|
+
let suffix = 2
|
|
332
|
+
while (seen.has(`${id}-${suffix}`)) suffix += 1
|
|
333
|
+
id = `${id}-${suffix}`
|
|
334
|
+
}
|
|
335
|
+
seen.add(id)
|
|
336
|
+
findings.push({
|
|
337
|
+
id,
|
|
338
|
+
reviewer: finding.reviewer,
|
|
339
|
+
severity: finding.severity,
|
|
340
|
+
title: clip(finding.title, FIELD_CHARS) ?? '',
|
|
341
|
+
where: clip(finding.where, FIELD_CHARS),
|
|
342
|
+
what: clip(finding.what, FIELD_CHARS),
|
|
343
|
+
why: clip(finding.why, FIELD_CHARS),
|
|
344
|
+
howToCheck: clip(finding.howToCheck, FIELD_CHARS),
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const entry = {
|
|
349
|
+
id: `run-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`,
|
|
350
|
+
at: new Date().toISOString(),
|
|
351
|
+
request: clip(run.request, REQUEST_CHARS) ?? '',
|
|
352
|
+
// Модель и усилие сохраняются вместе с находками: без них статистика
|
|
353
|
+
// точности бессмысленна — «codex ошибается» ничего не значит, если
|
|
354
|
+
// неизвестно, на какой модели и с каким усилием.
|
|
355
|
+
reviewers: (run.reviewers ?? []).map((r) => ({
|
|
356
|
+
provider: r.provider,
|
|
357
|
+
model: r.model ?? null,
|
|
358
|
+
effort: r.effort ?? null,
|
|
359
|
+
failed: r.failed ?? null,
|
|
360
|
+
found: r.found ?? 0,
|
|
361
|
+
})),
|
|
362
|
+
findings,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
log.runs.push(entry)
|
|
366
|
+
if (log.runs.length > MAX_RUNS) log.runs = log.runs.slice(-MAX_RUNS)
|
|
367
|
+
|
|
368
|
+
await write({ runs: log.runs, verdicts: log.verdicts }, root)
|
|
369
|
+
return { runId: entry.id, findingIds: findings.map((f) => f.id) }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Вынести приговор по находке.
|
|
374
|
+
*
|
|
375
|
+
* `evidence` не формальность: приговор без доказательства — это мнение, а
|
|
376
|
+
* инструмент затевался как раз против мнений, выдаваемых за факты.
|
|
377
|
+
*
|
|
378
|
+
* @param id - признак находки.
|
|
379
|
+
* @param verdict - подтверждено, ложная или отложено.
|
|
380
|
+
* @param evidence - что именно проверено: команда, файл, наблюдение.
|
|
381
|
+
* @param by - агент или человек; человек перекрывает агента.
|
|
382
|
+
*/
|
|
383
|
+
export async function recordVerdict(id, verdict, evidence, by = 'agent', root = null) {
|
|
384
|
+
if (!VERDICTS.includes(verdict)) throw new Error(`неизвестный приговор: ${String(verdict)}`)
|
|
385
|
+
return serialise(logPath(root) ?? 'нет-пути', () => recordVerdictNow(id, verdict, evidence, by, root))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function recordVerdictNow(id, verdict, evidence, by, root) {
|
|
389
|
+
|
|
390
|
+
// ДОКАЗАТЕЛЬСТВО ТРЕБУЕТСЯ, а не просто объявлено обязательным.
|
|
391
|
+
//
|
|
392
|
+
// Весь замысел держится на фразе «приговор без доказательства — это
|
|
393
|
+
// мнение», и я написал её в комментарии, не применив в коде: пустая
|
|
394
|
+
// улика превращалась в пустую строку, приговор записывался и входил в
|
|
395
|
+
// статистику наравне с проверенным. То есть единственной защитой была
|
|
396
|
+
// просьба к тому, кого этот механизм и должен проверять.
|
|
397
|
+
//
|
|
398
|
+
// Отложенное — исключение: «настоящая, но чиним не сейчас» доказывать
|
|
399
|
+
// нечем, это решение о приоритете, а не о факте.
|
|
400
|
+
const proof = typeof evidence === 'string' ? evidence.trim() : ''
|
|
401
|
+
if (verdict !== 'deferred' && proof.length < MIN_EVIDENCE_CHARS) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`приговор «${verdict}» требует доказательства: что именно проверено — ` +
|
|
404
|
+
`запущенная команда и её результат, прочитанные файл и строка, наблюдённое поведение`,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const log = await read(root)
|
|
409
|
+
if (log.intact === false) {
|
|
410
|
+
throw new Error(`журнал обзоров повреждён, приговор не записан: ${log.problem ?? ''}`.trim())
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Приговор по НЕСУЩЕСТВУЮЩЕЙ находке отклоняется.
|
|
414
|
+
//
|
|
415
|
+
// Раньше он принимался, объявлялся применённым и терялся: агент мог дать
|
|
416
|
+
// признак из прошлого обзора или просто ошибиться, получить «Записано» —
|
|
417
|
+
// а в панели находка так и оставалась ждущей.
|
|
418
|
+
const known = new Set()
|
|
419
|
+
for (const run of log.runs) for (const finding of run.findings) known.add(finding.id)
|
|
420
|
+
if (!known.has(id)) {
|
|
421
|
+
return { id, applied: false, problem: 'находки с таким признаком нет ни в одном сохранённом обзоре' }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const existing = log.verdicts[id]
|
|
425
|
+
|
|
426
|
+
// Человек перекрывает агента, агент человека — нет. Иначе следующий обзор
|
|
427
|
+
// молча отменил бы возражение, ради которого всё и затевалось.
|
|
428
|
+
if (existing !== undefined && existing.by === 'human' && by === 'agent') {
|
|
429
|
+
return { id, kept: existing, applied: false }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const entry = {
|
|
433
|
+
verdict,
|
|
434
|
+
evidence: clip(proof, EVIDENCE_CHARS) ?? '',
|
|
435
|
+
by: by === 'human' ? 'human' : 'agent',
|
|
436
|
+
at: new Date().toISOString(),
|
|
437
|
+
}
|
|
438
|
+
log.verdicts[id] = entry
|
|
439
|
+
await write({ runs: log.runs, verdicts: log.verdicts }, root)
|
|
440
|
+
return { id, kept: entry, applied: true }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Точность ревизоров по накопленному журналу.
|
|
445
|
+
*
|
|
446
|
+
* Считаются ТОЛЬКО находки с вынесенным приговором: непроверенные не значат
|
|
447
|
+
* ни «правда», ни «ложь», и включать их значило бы врать в обе стороны.
|
|
448
|
+
* Поэтому рядом с долей всегда стоит, на скольких находках она посчитана —
|
|
449
|
+
* доля по трём находкам не значит ничего.
|
|
450
|
+
*/
|
|
451
|
+
export function accuracy(log) {
|
|
452
|
+
const byReviewer = new Map()
|
|
453
|
+
|
|
454
|
+
const key = (finding, run) => {
|
|
455
|
+
const used = (run.reviewers ?? []).find((r) => r.provider === finding.reviewer)
|
|
456
|
+
const model = used?.model ?? null
|
|
457
|
+
const effort = used?.effort ?? null
|
|
458
|
+
// Усилие входит в разрез наравне с моделью: быстрый прогон и глубокий
|
|
459
|
+
// — разные условия, и складывать их в одну долю значит мерить среднюю
|
|
460
|
+
// температуру. Без модели остаётся голое имя ревизора — так и пишем,
|
|
461
|
+
// честнее, чем выдумывать.
|
|
462
|
+
if (model === null && effort === null) return finding.reviewer
|
|
463
|
+
return `${finding.reviewer} · ${model ?? 'по умолчанию'}${effort === null ? '' : ` · ${effort}`}`
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// КАЖДАЯ находка считается ОДИН раз, сколько бы обзоров её ни повторили.
|
|
467
|
+
//
|
|
468
|
+
// Признак считается по содержанию, поэтому та же находка в следующем
|
|
469
|
+
// обзоре получает тот же признак и ссылается на тот же единственный
|
|
470
|
+
// приговор. Перебор по всем обзорам засчитывал каждое вхождение отдельно:
|
|
471
|
+
// пять повторов одной находки и один разбор давали ревизору пять ошибок.
|
|
472
|
+
// Особенно едко с «ответом не по формату» — у него постоянное название,
|
|
473
|
+
// и пять сорванных запусков превращались в пять ошибок ревизора.
|
|
474
|
+
const counted = new Set()
|
|
475
|
+
|
|
476
|
+
// Идём с конца: разрез берётся по САМОМУ СВЕЖЕМУ обзору, где находка
|
|
477
|
+
// встретилась, — там модель и усилие ближе всего к тому, что оценивают.
|
|
478
|
+
for (let index = log.runs.length - 1; index >= 0; index -= 1) {
|
|
479
|
+
const run = log.runs[index]
|
|
480
|
+
for (const finding of run.findings ?? []) {
|
|
481
|
+
if (counted.has(finding.id)) continue
|
|
482
|
+
const verdict = log.verdicts[finding.id]
|
|
483
|
+
if (verdict === undefined || verdict.verdict === 'pending' || verdict.verdict === 'deferred') continue
|
|
484
|
+
counted.add(finding.id)
|
|
485
|
+
const name = key(finding, run)
|
|
486
|
+
const stat = byReviewer.get(name) ?? { reviewer: name, confirmed: 0, wrong: 0 }
|
|
487
|
+
if (verdict.verdict === 'confirmed') stat.confirmed += 1
|
|
488
|
+
else stat.wrong += 1
|
|
489
|
+
byReviewer.set(name, stat)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Сколько находок ревизор ВООБЩЕ выдал — вторая половина честной картины.
|
|
494
|
+
//
|
|
495
|
+
// Без неё ревизор, чьи находки никто не разбирал, выглядит наравне с тем,
|
|
496
|
+
// у кого разобрано всё, а агент, склонный подтверждать своё и откладывать
|
|
497
|
+
// чужое, сам себе рисует оценку. Пара «рассужено из выданных» этот перекос
|
|
498
|
+
// и показывает.
|
|
499
|
+
const produced = new Map()
|
|
500
|
+
const seenFindings = new Set()
|
|
501
|
+
for (const run of log.runs) {
|
|
502
|
+
for (const finding of run.findings ?? []) {
|
|
503
|
+
if (seenFindings.has(finding.id)) continue
|
|
504
|
+
seenFindings.add(finding.id)
|
|
505
|
+
const name = key(finding, run)
|
|
506
|
+
produced.set(name, (produced.get(name) ?? 0) + 1)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return [...byReviewer.values()]
|
|
511
|
+
.map((stat) => ({
|
|
512
|
+
...stat,
|
|
513
|
+
judged: stat.confirmed + stat.wrong,
|
|
514
|
+
produced: produced.get(stat.reviewer) ?? stat.confirmed + stat.wrong,
|
|
515
|
+
// Доля не округляется до целых процентов в источнике: пусть показ
|
|
516
|
+
// решает сам, а данные остаются точными.
|
|
517
|
+
rate: stat.confirmed + stat.wrong === 0 ? null : stat.confirmed / (stat.confirmed + stat.wrong),
|
|
518
|
+
}))
|
|
519
|
+
.sort((a, b) => b.judged - a.judged)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Находки последнего обзора вместе с приговорами — то, что показывает панель. */
|
|
523
|
+
export function latest(log) {
|
|
524
|
+
const run = log.runs[log.runs.length - 1]
|
|
525
|
+
if (run === undefined) return null
|
|
526
|
+
return {
|
|
527
|
+
id: run.id,
|
|
528
|
+
at: run.at,
|
|
529
|
+
request: run.request,
|
|
530
|
+
reviewers: run.reviewers,
|
|
531
|
+
findings: (run.findings ?? []).map((finding) => ({
|
|
532
|
+
...finding,
|
|
533
|
+
verdict: log.verdicts[finding.id]?.verdict ?? 'pending',
|
|
534
|
+
evidence: log.verdicts[finding.id]?.evidence ?? '',
|
|
535
|
+
by: log.verdicts[finding.id]?.by ?? null,
|
|
536
|
+
})),
|
|
537
|
+
}
|
|
538
|
+
}
|