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.
@@ -11,15 +11,21 @@
11
11
  *
12
12
  * Импортируются только встроенные модули Node.
13
13
  */
14
- import { readFile, rename, writeFile } from 'node:fs/promises'
14
+ import { KNOWN } from './reviewers.js'
15
+ import { randomBytes } from 'node:crypto'
16
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises'
15
17
  import { existsSync } from 'node:fs'
16
18
  import { dirname, join } from 'node:path'
17
19
  import { fileURLToPath } from 'node:url'
18
20
 
19
21
  const FILE_NAME = 'tensorgrid-review.json'
20
22
 
21
- /** Ревизоры, которых мы умеем звать. Порядок — порядок показа. */
22
- export const KNOWN = ['claude-code', 'codex']
23
+ /**
24
+ * Ревизоры берутся из `reviewers.js` — единственного источника.
25
+ * Свой литерал здесь был третьим по счёту и расходился бы молча.
26
+ */
27
+ export { KNOWN } from './reviewers.js'
28
+ import { REVIEWERS } from './reviewers.js'
23
29
 
24
30
  /**
25
31
  * Что допустимо в модели и усилии.
@@ -31,20 +37,110 @@ export const KNOWN = ['claude-code', 'codex']
31
37
  * показывает пустое поле, неотличимое от «как настроено».
32
38
  */
33
39
  const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
40
+ const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-\/]{0,95}$/
41
+
42
+ /**
43
+ * Режимы обзора — то, чем пользуются, не вникая.
44
+ *
45
+ * Сотруднику незачем знать разницу между именами моделей. Ему нужно решить
46
+ * одно: насколько тщательно проверить. Режим задаёт состав ревизоров и
47
+ * усилие; кто хочет — открывает подробные настройки и меняет поштучно.
48
+ *
49
+ * Модель режимы НЕ задают намеренно: у каждого продукта своя настроена, и
50
+ * навязывать поверх значило бы ломать то, что человек уже выбрал.
51
+ */
52
+ export const MODES = {
53
+ fast: {
54
+ label: 'Быстрый',
55
+ hint: 'Один ревизор, обычное усилие. Минуты. Для рядовой работы.',
56
+ enabled: ['internal'],
57
+ effort: null,
58
+ },
59
+ normal: {
60
+ label: 'Обычный',
61
+ hint: 'Две разные модели. Они находят разное — одна упускает половину.',
62
+ // Два НАШИХ ревизора на разных семействах моделей.
63
+ //
64
+ // Раньше здесь стояли два чужих продукта. Чистый опыт показал, что
65
+ // обвязка почти ничего не решает — совпадение 13 находок из 14, — а
66
+ // разное находят разные модели. Обе подписки работают и у нас, так что
67
+ // тот же результат достаётся без двух лишних установок сотруднику.
68
+ enabled: ['internal', 'internal-alt'],
69
+ effort: null,
70
+ },
71
+ deep: {
72
+ label: 'Глубокий',
73
+ hint: 'Все доступные ревизоры на полном усилии. Долго и дорого — для того, что увидят люди.',
74
+ // «Глубокий» означает ВСЕ, а не перечисленных поимённо: иначе добавление
75
+ // четвёртого ревизора молча оставило бы самый тщательный режим без него.
76
+ // Остальные режимы называют состав явно — в этом и есть их смысл.
77
+ //
78
+ // Сюда попадают и внешние продукты. Если они не установлены, обзор
79
+ // прямо скажет «не найден» — это лучше, чем молча пропустить.
80
+ enabled: [...KNOWN],
81
+ effort: 'high',
82
+ },
83
+ }
84
+
85
+ /** Режим по умолчанию: две модели — проверенная середина. */
86
+ const DEFAULT_MODE = 'normal'
34
87
 
35
88
  /**
36
89
  * Что действует, пока пользователь ничего не выбрал.
37
90
  *
38
- * Оба ревизора включены намеренно: они находят РАЗНОЕ, и один по умолчанию
91
+ * Два ревизора включены намеренно: они находят РАЗНОЕ, и один по умолчанию
39
92
  * означал бы, что половина находок не появится у того, кто в настройки не
40
93
  * заглядывал. Модель и усилие пустые — берутся собственные настройки
41
94
  * продукта, то есть то, чем человек уже пользуется.
42
95
  */
43
96
  export function defaults() {
44
97
  return {
45
- enabled: [...KNOWN],
46
- choices: { 'claude-code': { model: null, effort: null }, codex: { model: null, effort: null } },
98
+ mode: DEFAULT_MODE,
99
+ enabled: [...MODES[DEFAULT_MODE].enabled],
100
+ // Записи строятся ПО СПИСКУ, а не перечисляются вручную: перечисление
101
+ // и было тем третьим источником правды, из-за которого добавление
102
+ // продукта оставило бы выбор неопределённым, а раздел настроек — упавшим.
103
+ //
104
+ // Нашим ревизорам модель проставляется СРАЗУ, и это важно: пустая
105
+ // означала бы умолчание маршрута, то есть одну и ту же модель у обоих —
106
+ // и второй ревизор стал бы дорогой копией первого. Внешним продуктам
107
+ // остаётся пусто: у них своя настроена, навязывать поверх нечего.
108
+ choices: Object.fromEntries(KNOWN.map((kind) => [kind, {
109
+ provider: REVIEWERS[kind]?.defaultProvider ?? null,
110
+ model: REVIEWERS[kind]?.defaultModel ?? null,
111
+ effort: null,
112
+ }])),
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Применить режим, сохранив поштучный выбор моделей.
118
+ *
119
+ * Переключение режима меняет СОСТАВ и усилие, но не трогает выбранные
120
+ * модели: человек, настроивший модель для Codex, не должен терять её
121
+ * оттого, что разок проверил быстрым режимом.
122
+ */
123
+ export function applyMode(settings, mode) {
124
+ // Принадлежность проверяется по СОБСТВЕННЫМ ключам.
125
+ //
126
+ // `MODES[mode] !== undefined` пропускало унаследованные имена: у обычного
127
+ // объекта есть `constructor`, `toString` и прочие. Строка `"mode":
128
+ // "constructor"` в правленом файле принималась как допустимый режим, а
129
+ // нажатие любой кнопки уходило сюда и роняло маршрут: `spec.enabled` у
130
+ // функции-конструктора не перебирается.
131
+ const spec = Object.hasOwn(MODES, mode) ? MODES[mode] : undefined
132
+ if (spec === undefined) return settings
133
+
134
+ const choices = {}
135
+ for (const kind of KNOWN) {
136
+ const previous = settings.choices?.[kind] ?? {}
137
+ choices[kind] = {
138
+ provider: previous.provider ?? null,
139
+ model: previous.model ?? null,
140
+ effort: spec.effort,
141
+ }
47
142
  }
143
+ return { mode, enabled: [...spec.enabled], choices }
48
144
  }
49
145
 
50
146
  /**
@@ -71,10 +167,17 @@ function findProfileRoot(startDir) {
71
167
  *
72
168
  * @param cwd - рабочая папка сессии, запасной ориентир.
73
169
  */
74
- export function settingsPath(cwd) {
75
- const here = dirname(fileURLToPath(import.meta.url))
76
- const root = findProfileRoot(here) ?? (typeof cwd === 'string' && cwd !== '' ? findProfileRoot(cwd) : null)
77
- return root === null ? null : join(root, FILE_NAME)
170
+ export function settingsPath(cwd, root = null) {
171
+ if (root !== null) return join(root, FILE_NAME)
172
+ // Каталог ищется ТОЛЬКО от собственного расположения модуля.
173
+ //
174
+ // Запасной подъём от рабочей папки убран намеренно: журнал рядом прямо
175
+ // объявляет его опасным, и справедливо — подложенная в папку чата отметка
176
+ // об установке увела бы настройки в чужое дерево, то есть в тот самый
177
+ // непроверенный код, который отдан на обзор. Две половины одного продукта
178
+ // не могут искать свой файл по разным правилам.
179
+ const found = findProfileRoot(dirname(fileURLToPath(import.meta.url)))
180
+ return found === null ? null : join(found, FILE_NAME)
78
181
  }
79
182
 
80
183
  /** Привести прочитанное к ожидаемой форме: файл правит человек, и правит с ошибками. */
@@ -94,25 +197,107 @@ export function normalise(raw) {
94
197
  // Недопустимое значение отбрасывается молча и превращается в «как
95
198
  // настроено»: сохранить его значило бы выключить обзор до тех пор, пока
96
199
  // кто-нибудь не догадается открыть файл.
200
+ //
201
+ // Наш ревизор ходит по маршруту, где имена содержат косую черту
202
+ // (`x-ai/grok-4.6`), внешние — по командной строке, где её быть не
203
+ // должно. Одна общая проверка отвергала бы половину моделей.
204
+ const allowed = kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
97
205
  const pick = (value) => {
98
206
  if (typeof value !== 'string') return null
99
207
  const trimmed = value.trim()
100
- return trimmed !== '' && SAFE_VALUE.test(trimmed) ? trimmed : null
208
+ return trimmed !== '' && allowed.test(trimmed) ? trimmed : null
101
209
  }
102
210
  choices[kind] = {
211
+ provider: pick(source?.provider),
103
212
  model: pick(source?.model),
104
213
  effort: pick(source?.effort),
105
214
  }
106
215
  }
107
216
 
108
- // Пустой список допустим только как осознанный выбор «никого не звать»;
109
- // отличить его от испорченного файла нельзя, поэтому возвращаем как есть.
110
- return { enabled, choices }
217
+ // Режим ВСЕГДА выводится из состава, а не берётся из файла.
218
+ //
219
+ // Прежде сохранённое имя принималось на веру и никогда не сверялось с
220
+ // `enabled`, хотя соседний комментарий обещал ровно эту сверку. После
221
+ // ручной правки состава человек видел подсвеченным «Быстрый — один
222
+ // ревизор, минуты», а запускались трое на полном усилии: счёт и время
223
+ // втрое против показанного. Состав — факт, режим — его имя; имя не может
224
+ // противоречить факту.
225
+ return { mode: matchMode(enabled), enabled, choices }
226
+ }
227
+
228
+ /**
229
+ * Узнать режим по составу ревизоров.
230
+ *
231
+ * Нужно, когда состав правили поштучно: режим в файле мог остаться от
232
+ * прошлого выбора и врал бы в интерфейсе. Не совпал ни с одним — значит
233
+ * набор свой, так и покажем.
234
+ */
235
+ function matchMode(enabled) {
236
+ const same = (a, b) => a.length === b.length && a.every((item) => b.includes(item))
237
+ for (const [name, spec] of Object.entries(MODES)) {
238
+ if (same(spec.enabled, enabled)) return name
239
+ }
240
+ return null
241
+ }
242
+
243
+ /** Есть ли такой режим. Только собственные ключи — см. `applyMode`. */
244
+ export function isMode(name) {
245
+ return typeof name === 'string' && Object.hasOwn(MODES, name)
111
246
  }
112
247
 
248
+ /**
249
+ * Очередь записи на каждый файл.
250
+ *
251
+ * Запись идёт по схеме «прочитать целиком → изменить → записать целиком»,
252
+ * и без очереди одновременные вызовы читают ОДНО состояние, а пишут каждый
253
+ * своё: побеждает последний, остальные исчезают бесследно. Проверено —
254
+ * двенадцать одновременных записей давали одну.
255
+ *
256
+ * Случай не выдуманный: журнал пишут две стороны — Host в конце обзора и
257
+ * браузер по нажатию в панели. Человек подтверждает находку ровно в тот
258
+ * момент, когда завершается второй обзор.
259
+ *
260
+ * Очередь внутрипроцессная, и этого достаточно: оба пути проходят через
261
+ * один Host. От двух ОДНОВРЕМЕННО ЗАПУЩЕННЫХ приложений она не спасает —
262
+ * такой случай потребовал бы файловой блокировки, а он у нас невозможен:
263
+ * второй экземпляр не поднимется на занятом порту.
264
+ */
265
+ const queues = new Map()
266
+
267
+ function serialise(path, work) {
268
+ const previous = queues.get(path) ?? Promise.resolve()
269
+ const next = previous.then(work, work)
270
+ // В очереди держим ТОЛЬКО факт завершения, без результата и без отказа:
271
+ // иначе одна неудачная запись уронила бы все следующие.
272
+ queues.set(path, next.then(() => undefined, () => undefined))
273
+ return next
274
+ }
275
+ /**
276
+ * Переименовать с повтором.
277
+ *
278
+ * На Windows одновременное переименование двух временных файлов в одну цель
279
+ * изредка отказывает с EPERM или EBUSY: файл в этот момент держит чужая
280
+ * операция. Это не ошибка вызывающего и не повод терять запись — достаточно
281
+ * повторить через несколько миллисекунд.
282
+ *
283
+ * Повторяются только эти два кода. Всё прочее — настоящая ошибка (нет прав
284
+ * на каталог, кончилось место), и глотать её нельзя.
285
+ */
286
+ async function renameWithRetry(from, to, attempts = 5) {
287
+ for (let attempt = 1; ; attempt += 1) {
288
+ try {
289
+ await rename(from, to)
290
+ return
291
+ } catch (error) {
292
+ const retriable = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
293
+ if (!retriable || attempt >= attempts) throw error
294
+ await new Promise((resolve) => setTimeout(resolve, attempt * 15))
295
+ }
296
+ }
297
+ }
113
298
  /** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */
114
- export async function readSettings(cwd) {
115
- const path = settingsPath(cwd)
299
+ export async function readSettings(cwd, root = null) {
300
+ const path = settingsPath(cwd, root)
116
301
  if (path === null) return defaults()
117
302
  try {
118
303
  const text = await readFile(path, 'utf8')
@@ -130,12 +315,51 @@ export async function readSettings(cwd) {
130
315
  * или обрывок — а пустота у нас означает «включить обоих ревизоров», то есть
131
316
  * запустить продукт, который человек только что выключил.
132
317
  */
133
- export async function writeSettings(cwd, value) {
134
- const path = settingsPath(cwd)
318
+ export async function writeSettings(cwd, value, root = null) {
319
+ return serialise(settingsPath(cwd, root) ?? 'нет-пути', () => writeSettingsNow(cwd, value, root))
320
+ }
321
+
322
+ async function writeSettingsNow(cwd, value, root) {
323
+ const path = settingsPath(cwd, root)
135
324
  if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
136
- const normalised = normalise(value)
137
- const temporary = `${path}.${process.pid}.tmp`
138
- await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
139
- await rename(temporary, path)
325
+
326
+ // Присланное СЛИВАЕТСЯ с тем, что на диске, а не кладётся поверх.
327
+ //
328
+ // Прежде маршрут писал объект целиком. Две открытые вкладки настроек — и
329
+ // та, что открыта дольше, одним нажатием галочки возвращала старые модели
330
+ // и усилия: она шлёт свой устаревший снимок целиком. Любой отправитель,
331
+ // приславший только `enabled`, стирал ВЕСЬ выбор моделей без единого слова.
332
+ //
333
+ // Сливаем поштучно: пришло поле — берём пришедшее, не пришло — оставляем
334
+ // сохранённое.
335
+ const current = await readSettings(cwd, root)
336
+ const merged = {
337
+ enabled: Array.isArray(value?.enabled) ? value.enabled : current.enabled,
338
+ choices: Object.fromEntries(
339
+ KNOWN.map((kind) => {
340
+ const was = current.choices[kind] ?? { provider: null, model: null, effort: null }
341
+ const came = value?.choices?.[kind]
342
+ if (came === undefined || came === null || typeof came !== 'object') return [kind, was]
343
+ return [kind, {
344
+ provider: 'provider' in came ? came.provider : was.provider,
345
+ model: 'model' in came ? came.model : was.model,
346
+ effort: 'effort' in came ? came.effort : was.effort,
347
+ }]
348
+ }),
349
+ ),
350
+ }
351
+
352
+ const normalised = normalise(merged)
353
+ // Уникальное имя временного файла: у двух одновременных записей прежнее
354
+ // совпадало, и вторая получала отказ на переименовании — первая уже
355
+ // унесла общий файл.
356
+ const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
357
+ try {
358
+ await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
359
+ await renameWithRetry(temporary, path)
360
+ } catch (error) {
361
+ await rm(temporary, { force: true }).catch(() => {})
362
+ throw error
363
+ }
140
364
  return normalised
141
365
  }