@dr-co/dsh-node-client 0.0.0-stage → 0.0.60

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/lib/jobs.mjs ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Долгоиграющие процессы: запустил, получил id, читаешь вывод по смещению.
3
+ *
4
+ * Отдельно от `exec`, потому что контракт другой: `exec` отвечает результатом,
5
+ * а здесь ответ — идентификатор, и дальше идут `output`, `kill`, `list`.
6
+ * Смешивать это в одном методе пришлось бы аргументами вроде `background`,
7
+ * половина которых бессмысленна в другом режиме.
8
+ *
9
+ * Модель повторяет поведение jobs в DSH: статус (`running`/`stopping`/
10
+ * `completed`/`killed`/`failed`), кольцевой буфер вывода и убийство всей
11
+ * группы процессов, а не одного pid.
12
+ *
13
+ * @module dsh-node-client/jobs
14
+ */
15
+ import { spawn } from 'node:child_process'
16
+ import { randomBytes } from 'node:crypto'
17
+
18
+ import { commandArgv, killTree, spawnCommand, platform } from './shell.mjs'
19
+
20
+ /** Сколько байт вывода держим на job: старое вытесняется. */
21
+ const RING_BYTES = 256 * 1024
22
+ /** Потолок одновременных job: иначе узел завалит сам себя. */
23
+ const MAX_JOBS = 8
24
+ /** Сколько завершённых помним: по ним смотрят, что было, но не бесконечно. */
25
+ const MAX_FINISHED = 32
26
+
27
+ /**
28
+ * Реестр фоновых процессов клиента.
29
+ * @param {object} [options]
30
+ * @param {number} [options.ringBytes]
31
+ * @param {number} [options.maxJobs]
32
+ */
33
+ export function createJobRegistry ({ ringBytes = RING_BYTES, maxJobs = MAX_JOBS } = {}) {
34
+ /** @type {Map<string, object>} */
35
+ const jobs = new Map()
36
+
37
+ /** Убрать старые завершённые: демон живёт неделями, память не резиновая. */
38
+ function evictFinished () {
39
+ const finished = [...jobs.values()]
40
+ .filter((job) => job.status !== 'running' && job.status !== 'stopping')
41
+ .sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))
42
+ while (finished.length > MAX_FINISHED) {
43
+ const job = finished.shift()
44
+ jobs.delete(job.id)
45
+ }
46
+ }
47
+
48
+ function running () {
49
+ let count = 0
50
+ for (const job of jobs.values()) if (job.status === 'running' || job.status === 'stopping') count++
51
+ return count
52
+ }
53
+
54
+ /**
55
+ * Запустить команду в фоне.
56
+ * @param {object} options
57
+ * @param {string} options.command
58
+ * @param {string} [options.cwd]
59
+ * @param {string} [options.shell]
60
+ * @param {number} [options.timeout] секунды; 0 или пусто — без ограничения
61
+ * @returns {{ok: boolean, results?: object[], code?: string, message?: string}}
62
+ */
63
+ function start ({ command, cwd, shell, timeout, sandbox = null }) {
64
+ if (typeof command !== 'string' || !command.trim()) {
65
+ return { ok: false, code: 'bad_args', message: 'нужна команда' }
66
+ }
67
+ if (running() >= maxJobs) {
68
+ return { ok: false, code: 'too_many_jobs', message: `уже работает ${running()} процессов, потолок ${maxJobs}` }
69
+ }
70
+ const id = `job-${randomBytes(8).toString('hex')}`
71
+ // Задание идёт тем же путём, что и обычная команда: если политика узла
72
+ // требует песочницу, процесс запускается в ней. Иначе фоновый процесс был бы
73
+ // способом обойти политику одной строкой.
74
+ const prepared = commandArgv({ command, shell, sandbox })
75
+ if (prepared.error) return prepared.error
76
+ const child = spawn(prepared.argv[0], prepared.argv.slice(1), {
77
+ cwd: prepared.enforcement === 'bwrap' ? undefined : (cwd || process.cwd()),
78
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
79
+ stdio: ['pipe', 'pipe', 'pipe'],
80
+ detached: platform !== 'win32',
81
+ })
82
+ const job = {
83
+ id,
84
+ command,
85
+ cwd: cwd ?? null,
86
+ status: 'running',
87
+ sandbox: prepared.enforcement,
88
+ startedAt: Date.now(),
89
+ endedAt: null,
90
+ code: null,
91
+ chunks: [],
92
+ bytes: 0,
93
+ dropped: 0,
94
+ cursor: 0,
95
+ child,
96
+ timer: null,
97
+ }
98
+ const push = (type, text) => {
99
+ if (!text) return
100
+ const chunk = { at: job.bytes, type, text }
101
+ job.chunks.push(chunk)
102
+ job.bytes += Buffer.byteLength(text)
103
+ // вытесняем старое, но так, чтобы смещения оставались абсолютными
104
+ while (job.chunks.length > 1 && job.bytes - job.chunks[0].at > ringBytes) {
105
+ const removed = job.chunks.shift()
106
+ job.dropped += Buffer.byteLength(removed.text)
107
+ }
108
+ }
109
+ child.stdout?.on('data', (chunk) => push('stdout', String(chunk)))
110
+ child.stderr?.on('data', (chunk) => push('stderr', String(chunk)))
111
+ child.on('error', (error) => {
112
+ job.status = 'failed'
113
+ job.message = error.message
114
+ job.endedAt = Date.now()
115
+ })
116
+ child.on('close', (code, signal) => {
117
+ if (job.timer) clearTimeout(job.timer)
118
+ if (job.status === 'stopping') job.status = 'killed'
119
+ else if (job.status !== 'failed') job.status = 'completed'
120
+ job.code = code
121
+ job.signal = signal ?? null
122
+ job.endedAt = Date.now()
123
+ evictFinished()
124
+ })
125
+ if (Number(timeout) > 0) {
126
+ job.timer = setTimeout(() => killTree(child, 'SIGKILL'), Number(timeout) * 1000)
127
+ if (typeof job.timer.unref === 'function') job.timer.unref()
128
+ }
129
+ jobs.set(id, job)
130
+ return { ok: true, results: [{ type: 'text', text: JSON.stringify({ id, status: job.status }) }] }
131
+ }
132
+
133
+ /**
134
+ * Прочитать вывод начиная с абсолютного смещения.
135
+ * @param {string} id
136
+ * @param {number} [offset] по умолчанию — с того места, где модель остановилась
137
+ * @returns {{ok: boolean, results?: object[], code?: string, message?: string}}
138
+ */
139
+ function output (id, offset) {
140
+ const job = jobs.get(id)
141
+ if (!job) return { ok: false, code: 'no_such_job', message: `нет процесса ${id}` }
142
+ const from = Number.isFinite(Number(offset)) ? Number(offset) : job.cursor
143
+ const parts = job.chunks.filter((chunk) => chunk.at >= from)
144
+ const text = parts.map((chunk) => chunk.text).join('')
145
+ job.cursor = job.bytes
146
+ return {
147
+ ok: true,
148
+ results: [{
149
+ type: 'text',
150
+ text: JSON.stringify({
151
+ id: job.id,
152
+ status: job.status,
153
+ code: job.code ?? null,
154
+ signal: job.signal ?? null,
155
+ offset: job.bytes,
156
+ dropped: job.dropped,
157
+ tookMs: (job.endedAt ?? Date.now()) - job.startedAt,
158
+ sandbox: job.sandbox ?? 'none',
159
+ stdout: parts.filter((chunk) => chunk.type === 'stdout').map((chunk) => chunk.text).join(''),
160
+ stderr: parts.filter((chunk) => chunk.type === 'stderr').map((chunk) => chunk.text).join(''),
161
+ }),
162
+ }],
163
+ }
164
+ }
165
+
166
+ /** Попросить процесс завершиться, всей группой. */
167
+ function kill (id, signal = 'SIGTERM') {
168
+ const job = jobs.get(id)
169
+ if (!job) return { ok: false, code: 'no_such_job', message: `нет процесса ${id}` }
170
+ if (job.status !== 'running') {
171
+ return { ok: true, results: [{ type: 'text', text: JSON.stringify({ id, status: job.status, changed: false }) }] }
172
+ }
173
+ job.status = 'stopping'
174
+ killTree(job.child, signal)
175
+ return { ok: true, results: [{ type: 'text', text: JSON.stringify({ id, status: job.status, signal }) }] }
176
+ }
177
+
178
+ /** Что сейчас запущено и чем кончилось (последние завершённые тоже видны). */
179
+ function list () {
180
+ const items = [...jobs.values()].map((job) => ({
181
+ id: job.id,
182
+ status: job.status,
183
+ code: job.code ?? null,
184
+ command: job.command.slice(0, 200),
185
+ sandbox: job.sandbox,
186
+ bytes: job.bytes,
187
+ tookMs: (job.endedAt ?? Date.now()) - job.startedAt,
188
+ }))
189
+ return { ok: true, results: [{ type: 'text', text: JSON.stringify({ jobs: items }) }] }
190
+ }
191
+
192
+ /** Убить всё: нужно при остановке демона, иначе процессы переживут его. */
193
+ function stopAll () {
194
+ for (const job of jobs.values()) {
195
+ if (job.status === 'running' || job.status === 'stopping') {
196
+ job.status = 'stopping'
197
+ killTree(job.child, 'SIGTERM')
198
+ }
199
+ }
200
+ }
201
+
202
+ return { start, output, kill, list, stopAll, get size () { return jobs.size } }
203
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Мастер первичной настройки: `dshnc onboard`.
3
+ *
4
+ * Меню, а не поток вопросов, потому что настраивать приходится разное и в
5
+ * разном порядке: кто-то пришёл за политикой доступа, кто-то за автозапуском.
6
+ *
7
+ * Главное правило: **текущее значение — предложение по умолчанию**, а пустой
8
+ * ответ его сохраняет. Так мастер не ломает уже настроенный узел и не требует
9
+ * вспоминать, что там было.
10
+ *
11
+ * @module dsh-node-client/onboard
12
+ */
13
+
14
+ /**
15
+ * Взять правила того сервера, к которому узел подключён.
16
+ *
17
+ * Политика хранится по адресам серверов, поэтому «текущий потолок» — это не
18
+ * `config.policy`, а `config.policy[url]`. Если серверов несколько, берём
19
+ * первый: мастер правит один узел за раз, а переносить правила между серверами
20
+ * он не умеет и не должен делать вид, что умеет.
21
+ *
22
+ * @param {object} config настройки клиента
23
+ * @returns {{url: string|null, policy: object}}
24
+ */
25
+ export function pickPolicy (config) {
26
+ const entries = Object.entries(config?.policy ?? {})
27
+ if (!entries.length) return { url: null, policy: {} }
28
+ const [url, policy] = entries[0]
29
+ return { url, policy: policy ?? {} }
30
+ }
31
+
32
+ /** Ответ с подсказкой: пустой ввод оставляет прежнее значение. */
33
+ async function askDefault (ask, question, current) {
34
+ const shown = current === null || current === undefined || current === ''
35
+ ? question
36
+ : `${question} [${current}]`
37
+ const answer = (await ask(`${shown}: `) ?? '').trim()
38
+ return answer === '' ? current : answer
39
+ }
40
+
41
+ /** Список через запятую ↔ массив. */
42
+ function toList (value) {
43
+ if (Array.isArray(value)) return value
44
+ return String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean)
45
+ }
46
+
47
+ /**
48
+ * Пункт меню «доступ»: имя, каталоги, режимы, кто отвечает на вопросы.
49
+ *
50
+ * @returns {Promise<object>} изменения, которые надо сохранить
51
+ */
52
+ export async function askAccess ({ ask, print = () => {}, config, policy, url = null }) {
53
+ const patch = {}
54
+ // Мастер не просто спрашивает — он объясняет, что означает каждый ответ.
55
+ // Без этого человек отвечает «наугад», а потом не понимает, почему узел
56
+ // отказывает в записи или не задаёт вопросы.
57
+ print('Имя узла — это то, чем узел зовут в вызовах и что видно в админке.')
58
+ patch.name = await askDefault(ask, 'Имя узла', config.name ?? '')
59
+ patch.aliases = toList(await askDefault(ask, 'Алиасы через запятую', toList(config.aliases).join(', ')))
60
+ print('Каталог сессий — где клиент заводит рабочие каталоги для моделей.')
61
+ // каталоги клиент читает из политики сервера, если они там заданы, поэтому
62
+ // по умолчанию подставляем именно их, а не пустой корень конфига
63
+ const workdir = await askDefault(ask, 'Каталог сессий (абсолютный путь)', policy?.workdir ?? config.workdir ?? '')
64
+ print('Разрешённые каталоги — границы записи: всё вне них узлу недоступно на запись.')
65
+ const roots = await askDefault(
66
+ ask,
67
+ 'Разрешённые каталоги через запятую',
68
+ toList(policy?.roots ?? config.roots).join(', '),
69
+ )
70
+ const modes = ['ro', 'rw', 'full']
71
+ print('Потолок прав — самый широкий режим, который узел разрешит вообще:')
72
+ print(' ro — только чтение, rw — запись внутри разрешённых каталогов, full — без песочницы.')
73
+ const max = await askDefault(ask, `Потолок прав (${modes.join('/')})`, policy?.max ?? 'rw')
74
+ print('Стартовый режим — в каком режиме команда запускается по умолчанию.')
75
+ print(' Он может быть уже потолка: тогда расширение требует разрешения человека.')
76
+ const base = await askDefault(ask, `Стартовый режим (${modes.join('/')})`, policy?.base ?? 'rw')
77
+ print('Кто отвечает на вопрос «разрешить шире?»:')
78
+ print(' local — человек за этой машиной (окно на узле), server — человек в DSH, none — не спрашивать.')
79
+ const approver = await askDefault(ask, 'Кто отвечает на вопросы: local/server/none', policy?.approver ?? 'none')
80
+
81
+ // и каталоги, и режимы пишем в раздел этого сервера: раннер собирает общие
82
+ // значения из корня и накрывает их политикой сервера, поэтому корень без
83
+ // политики выглядел бы «настроил и не работает»
84
+ const next = { max, base, approver, roots: toList(roots), workdir }
85
+ if (approver === 'local') {
86
+ const current = policy?.ask?.local?.tool?.command ?? ''
87
+ print('Команда вопроса — чем на этой машине спросить человека. Подойдёт любой')
88
+ print(' инструмент, который возвращает 0 на «да»:')
89
+ print(' zenity --text-info --width=600 --title=dsh-nodes')
90
+ print(' kdialog --textinputbox "dsh-nodes"')
91
+ print(' notify-send ... && свой скрипт, который ждёт ответа')
92
+ print(' Текст вопроса уходит в stdin, если в команде нет %s.')
93
+ const tool = await askDefault(ask, 'Команда вопроса (пусто — не спрашивать)', current)
94
+ next.ask = tool
95
+ ? { local: { enabled: true, tool: { type: 'external', command: tool } } }
96
+ : { local: { enabled: false } }
97
+ }
98
+ // правила пишем в тот сервер, который настраиваем: иначе они окажутся нигде
99
+ patch.policy = url
100
+ ? { ...(config.policy ?? {}), [url]: { ...(config.policy?.[url] ?? {}), ...next } }
101
+ : (config.policy ?? {})
102
+ return patch
103
+ }
104
+
105
+ /** Пункт меню «связь»: опрос и пинг по сокету. */
106
+ export async function askConnection ({ ask, print = () => {}, config }) {
107
+ // Для новой настройки предлагаем рабочие значения: сокет, умерший молча,
108
+ // иначе замечается только по TCP. Ноль — это осознанный выбор «выключить»,
109
+ // и он сохраняется как есть, а не подменяется умолчанием.
110
+ const hasPing = config.pingInterval !== undefined
111
+ print('Периодический опрос — страховка, если сокет не поднялся. Ноль выключает.')
112
+ print('Пинг по сокету — проверка «сервер слышит меня»: 15 с и растяжение до 10 раз,')
113
+ print(' когда сервер давно ничего не присылал. Ноль выключает пинг совсем.')
114
+ return {
115
+ pollIdleMs: Number(await askDefault(ask, 'Периодический опрос, мс (0 — только события)', config.pollIdleMs ?? 0)) || 0,
116
+ pingInterval: Number(await askDefault(ask, 'Пинг по сокету, с (0 — выключен)', config.pingInterval ?? (hasPing ? 0 : 15))) || 0,
117
+ maxPingIntervalFactor: Number(await askDefault(ask, 'Во сколько раз растягивать пинг без работы (0 — не растягивать)', config.maxPingIntervalFactor ?? (hasPing ? 0 : 10))) || 0,
118
+ }
119
+ }
120
+
121
+ /** Показать, что получилось: перед записью человек должен увидеть итог. */
122
+ export function renderConfig (config) {
123
+ const plain = { ...config }
124
+ delete plain.servers
125
+ delete plain.id
126
+ return JSON.stringify(plain, null, 2)
127
+ }
128
+
129
+ /**
130
+ * Цикл меню.
131
+ *
132
+ * @param {object} options
133
+ * @param {(question: string) => Promise<string>} options.ask
134
+ * @param {(text: string) => void} options.print
135
+ * @param {() => object} options.load свежие настройки
136
+ * @param {(patch: object) => object} options.save сохранить и вернуть итог
137
+ * @param {(config: object) => Promise<void>} options.autostart запуск мастера автозапуска
138
+ * @param {() => string[]} [options.status] строки состояния для шапки
139
+ */
140
+ export async function runOnboard ({ ask, print, load, save, autostart, status = () => [] }) {
141
+ for (;;) {
142
+ const config = load()
143
+ print('')
144
+ for (const line of status(config)) print(line)
145
+ print('')
146
+ print('Мастер настройки: спрашивает по одному разделу и объясняет, что к чему.')
147
+ print('Текущее значение стоит в скобках; пустой ответ его не меняет.')
148
+ print('')
149
+ print('1) доступ: имя, каталоги, режимы, кто отвечает на вопросы')
150
+ print('2) связь: опрос, пинг по сокету')
151
+ print('3) автозапуск: systemd-юнит')
152
+ print('4) показать конфиг')
153
+ print('0) выход')
154
+ const choice = (await ask('Что настроить? ') ?? '').trim()
155
+ if (choice === '0' || choice === '') return
156
+
157
+ if (choice === '1') {
158
+ const { url, policy } = pickPolicy(config)
159
+ if (!url) {
160
+ print('Сначала подключись к серверу: dshnc connect <ссылка>. Без сервера')
161
+ print('правила доступа писать некуда — они живут в разделе этого сервера.')
162
+ continue
163
+ }
164
+ print(`Правила для ${url}`)
165
+ const patch = await askAccess({ ask, print, config, policy, url })
166
+ // показываем итог ДО записи: предпросмотр не должен ничего сохранять
167
+ print('')
168
+ print('Итог:')
169
+ print(renderConfig({ ...config, ...patch }))
170
+ const ok = (await ask('Записать? [Y/n] ') ?? '').trim().toLowerCase()
171
+ if (ok === '' || ok === 'y' || ok === 'да') {
172
+ save(patch)
173
+ print('сохранено')
174
+ } else {
175
+ print('не записано')
176
+ }
177
+ continue
178
+ }
179
+
180
+ if (choice === '2') {
181
+ const patch = await askConnection({ ask, print, config })
182
+ save(patch)
183
+ print('сохранено')
184
+ continue
185
+ }
186
+
187
+ if (choice === '3') {
188
+ await autostart(load())
189
+ continue
190
+ }
191
+
192
+ if (choice === '4') {
193
+ print(renderConfig(load()))
194
+ continue
195
+ }
196
+
197
+ print('нет такого пункта')
198
+ }
199
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Когда клиент пингует сервер по сокету и с какой паузой.
3
+ *
4
+ * Логика целиком в одной чистой функции, чтобы её можно было проверить тестом
5
+ * без сети: расписание — это арифметика, а не побочный эффект.
6
+ *
7
+ * Правила:
8
+ * - пинг отправляется через `pingInterval` после последнего успешного
9
+ * общения (`lastSeen`);
10
+ * - если сервер давно не присылал команд (`lastCommand`), пауза растёт:
11
+ * первые пять интервалов она обычная, потом ×2, ×3 … но не больше
12
+ * `maxPingIntervalFactor`;
13
+ * - ответа нет в течение интервала — это разрыв, нужен реконнект.
14
+ *
15
+ * @module dsh-node-client/ping-policy
16
+ */
17
+
18
+ /** После скольких интервалов без команд пауза начинает расти. */
19
+ export const GROW_AFTER_TICKS = 5
20
+
21
+ /**
22
+ * Во сколько раз растянуть паузу.
23
+ *
24
+ * @param {object} options
25
+ * @param {number} options.ticks сколько интервалов прошло без новых команд
26
+ * @param {number} [options.factor] потолок растяжения; 0 или меньше — не растягивать
27
+ * @returns {number} множитель, всегда >= 1
28
+ */
29
+ export function pingMultiplier ({ ticks, factor = 0 }) {
30
+ const cap = Number(factor) > 0 ? Number(factor) : 1
31
+ if (ticks < GROW_AFTER_TICKS) return 1
32
+ // 5 тиков → ×2, 6 → ×3, и так до потолка
33
+ return Math.min(2 + (ticks - GROW_AFTER_TICKS), cap)
34
+ }
35
+
36
+ /**
37
+ * Пауза до следующего пинга в миллисекундах.
38
+ *
39
+ * @param {object} options
40
+ * @param {number} options.pingInterval секунды из конфига; 0 или меньше — выключено
41
+ * @param {number} [options.factor] потолок растяжения
42
+ * @param {number} [options.ticks] интервалов без команд
43
+ * @returns {number} миллисекунды; 0 означает «пинг выключен»
44
+ */
45
+ export function pingDelay ({ pingInterval, factor = 0, ticks = 0 }) {
46
+ const base = Number(pingInterval)
47
+ if (!Number.isFinite(base) || base <= 0) return 0
48
+ return Math.round(base * pingMultiplier({ ticks, factor }) * 1000)
49
+ }