@deksden-com/dd-console 0.0.0-stage → 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 +7 -0
- package/README.md +154 -2
- package/dist/build.json +1 -0
- package/dist/cache.js +52 -0
- package/dist/cli.js +98 -0
- package/dist/contracts.js +1 -0
- package/dist/daemon.js +102 -0
- package/dist/file-lock.js +33 -0
- package/dist/journal.js +124 -0
- package/dist/reader-pool.js +73 -0
- package/dist/reader-worker.js +21 -0
- package/dist/readers.js +364 -0
- package/dist/safe-files.js +80 -0
- package/dist/server.js +341 -0
- package/dist/sources.js +137 -0
- package/dist/util.js +25 -0
- package/dist/web/app.js +241 -0
- package/dist/web/index.html +14 -0
- package/dist/web/style.css +2 -0
- package/docs/acceptance.md +20 -0
- package/docs/compatibility.md +30 -0
- package/docs/data-contract.md +32 -0
- package/docs/implementation-plan.md +456 -0
- package/docs/implementation-review.md +33 -0
- package/docs/plan-review.md +27 -0
- package/docs/research.md +82 -0
- package/docs/work-plan.md +88 -0
- package/package.json +57 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @deksden-com/dd-console
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- eeed993: Ship the npm dd-console executable with foreground shutdown, detached start/stop/restart/status commands, build-aware replacement, and process identity checks. Add Changesets release automation.
|
package/README.md
CHANGED
|
@@ -1,3 +1,155 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dd-console
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Локальная веб-консоль для наблюдения за выполнением **dd-flow** и **dd-eval**.
|
|
4
|
+
|
|
5
|
+
Один локальный сервер, два раздела — Flows и Evals, отдельные экраны детализации и навигация через breadcrumbs. Из execution эвала можно перейти во вложенный flow, затем в работу, сессию и её события.
|
|
6
|
+
|
|
7
|
+
Статус: реализован первый локальный read-only выпуск. Сервер слушает только
|
|
8
|
+
`127.0.0.1`, не запускает команды раннеров и не меняет их данные.
|
|
9
|
+
|
|
10
|
+
- [План реализации: архитектура, экраны, API и приёмка](docs/implementation-plan.md)
|
|
11
|
+
- [Очередь работ первого выпуска](docs/work-plan.md)
|
|
12
|
+
- [Исследование существующих раннеров](docs/research.md)
|
|
13
|
+
- [Поддерживаемые источники и версии](docs/compatibility.md)
|
|
14
|
+
- [DTO, freshness и безопасное отображение](docs/data-contract.md)
|
|
15
|
+
- [Фактическая приёмка и известные ограничения](docs/acceptance.md)
|
|
16
|
+
|
|
17
|
+
Первый выпуск — живое наблюдение и просмотр истории. Раннеры сохраняют управление выполнением и своими данными; закрытие консоли не останавливает запуски. Управление pause/resume/stop запланировано следующим этапом.
|
|
18
|
+
|
|
19
|
+
## Запуск
|
|
20
|
+
|
|
21
|
+
Требуется Node.js 26 и macOS либо Linux/glibc. Проверки выполнены на macOS. В корне репозитория:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install
|
|
25
|
+
npm run check
|
|
26
|
+
npm run start:open
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`npm start` запускает сервер без открытия браузера; `npm run start:open` — с открытием. Оба скрипта сначала собирают приложение. Дополнительные параметры передаются после `--`, например `npm start -- --port 8080`.
|
|
30
|
+
|
|
31
|
+
`serve` добавляет доступные по умолчанию `~/.dd-flow` и `~/.dd-eval` в
|
|
32
|
+
`~/.dd-console/sources.json`, затем печатает URL. Для другого расположения
|
|
33
|
+
используйте `--flow-home` и `--eval-home`, либо управляйте реестром явно:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
dd-console sources add --kind flow --path /path/to/dd-flow-home
|
|
37
|
+
dd-console sources add --kind eval --path /path/to/dd-eval-home
|
|
38
|
+
dd-console sources list
|
|
39
|
+
dd-console sources remove SRC-…
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Реестр записывается атомарно и блокируется на время изменения. Если файл
|
|
43
|
+
повреждён, console оставляет его как есть и сообщает ошибку вместо перезаписи.
|
|
44
|
+
Остановите foreground-сервер `Ctrl-C`.
|
|
45
|
+
|
|
46
|
+
Установленный пакет использует ту же команду:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm install -g @deksden-com/dd-console
|
|
50
|
+
dd-console start --open
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
До первой публикации пакет можно установить из созданного
|
|
54
|
+
`npm pack` tarball; инструкции выпуска приведены ниже. API не предназначен для удалённого доступа, а браузер не
|
|
55
|
+
получает пути source и raw evidence. Для Flow используются короткие read-only
|
|
56
|
+
транзакции SQLite с существующим WAL. Данные БД не меняются; служебные SHM
|
|
57
|
+
read-marks обслуживает SQLite. Копирования живого DB/WAL нет.
|
|
58
|
+
|
|
59
|
+
Файлы читаются через дескрипторы каталогов (`openat`/`O_NOFOLLOW`), а локальные
|
|
60
|
+
locks используют `flock`. Для этих POSIX API используется пакет `koffi`.
|
|
61
|
+
История событий доступна страницами; filters/sort/cursors сохраняются в URL.
|
|
62
|
+
Артефакты receipt/stdout/stderr открываются в собственном экране с breadcrumbs.
|
|
63
|
+
Preview ограничен 256 KiB за фрагмент, следующий фрагмент доступен по ссылке.
|
|
64
|
+
|
|
65
|
+
Повторная проверка поставки: `npm run pack:check` после `npm run build`.
|
|
66
|
+
[Отчёт об исправлениях ревью](docs/implementation-review.md).
|
|
67
|
+
|
|
68
|
+
Для новых Eval `dd-eval` публикует рядом с `events.jsonl` файл
|
|
69
|
+
`observation.json` (`dd-eval/observation@1`). Он даёт консоли сохранённое
|
|
70
|
+
состояние Eval и executions без обращения к живому runner. Исторические Eval
|
|
71
|
+
без этой проекции остаются доступными, но имеют явную отметку о частичном
|
|
72
|
+
наблюдении.
|
|
73
|
+
|
|
74
|
+
Список Flows объединяет самостоятельные запуски из Flow-источников и RUN из
|
|
75
|
+
manifest-declared executions Eval с проверенным `managed-runtime.json`.
|
|
76
|
+
Фильтр «Запущен через → Eval» оставляет только вложенные запуски. В строке видны
|
|
77
|
+
полный RUN ID, проект, источник и ссылки на Eval/execution. Одинаковые RUN ID
|
|
78
|
+
в разных runtime остаются отдельными записями. Удалённые или неподтверждённые
|
|
79
|
+
runtime не восстанавливаются по догадке; старые статусы отражают сохранённые данные.
|
|
80
|
+
|
|
81
|
+
### Local Eval home registry
|
|
82
|
+
|
|
83
|
+
`~/.dd-eval/homes.json` is the shared registry, independent of `DD_EVAL_HOME`.
|
|
84
|
+
The version 1 document contains `schema_version: 1` and `homes` entries with
|
|
85
|
+
`id`, canonical `root`, `label`, `created_at` and `disabled` fields.
|
|
86
|
+
Creating an Eval or requesting continuation registers its home automatically.
|
|
87
|
+
An explicit removal stays disabled until explicitly added again.
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
dd-eval homes list
|
|
91
|
+
dd-eval homes add --path /absolute/path/to/eval-home --label qualification
|
|
92
|
+
dd-eval homes remove --id SRC-...
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The console reads the registry on each request, keeps unavailable sources visible,
|
|
96
|
+
and deduplicates it against its own sources. Local source removal overrides
|
|
97
|
+
registry discovery in that console. A new home requires no console restart.
|
|
98
|
+
Registry mutations use a process-owned lock and atomic replacement; malformed
|
|
99
|
+
registry data is preserved and reported instead of reset.
|
|
100
|
+
|
|
101
|
+
## CLI и фоновый сервер
|
|
102
|
+
|
|
103
|
+
После публикации пакет устанавливается глобально:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
npm install -g @deksden-com/dd-console
|
|
107
|
+
dd-console # сервер в терминале; Ctrl-C останавливает его
|
|
108
|
+
dd-console start --open # сервер в фоне; завершение команды оставляет его работающим
|
|
109
|
+
dd-console status # состояние, URL
|
|
110
|
+
dd-console stop # дождаться завершения сервера и освобождения locks
|
|
111
|
+
dd-console restart # заменить процесс актуальной сборкой
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
По умолчанию используется `http://127.0.0.1:50036`; `--port` и
|
|
115
|
+
`DD_CONSOLE_PORT` меняют порт. `--port 0` выбирает свободный порт.
|
|
116
|
+
`serve` — явный вариант запуска в переднем плане.
|
|
117
|
+
`start` повторно использует текущую сборку, при изменении ревизии заменяет её;
|
|
118
|
+
`restart` всегда заменяет процесс и сохраняет текущий порт, если новый не указан.
|
|
119
|
+
`--json` возвращает машиночитаемый результат.
|
|
120
|
+
`status` возвращает код 1, если сервер остановлен или не отвечает.
|
|
121
|
+
Остановка завершается успешно, даже если сервер уже остановлен.
|
|
122
|
+
|
|
123
|
+
Один сервер на `DD_CONSOLE_HOME` (по умолчанию `~/.dd-console`).
|
|
124
|
+
`server.json` хранит PID, URL, уникальную идентичность процесса и версию сборки;
|
|
125
|
+
перед отправкой сигнала CLI сверяет идентичность с `/healthz`.
|
|
126
|
+
Устаревший PID чужого процесса не получает сигнал.
|
|
127
|
+
Вывод фонового процесса сохраняется в `~/.dd-console/server.log`.
|
|
128
|
+
Фоновый режим не устанавливает системную службу: после перезагрузки компьютера
|
|
129
|
+
нужно снова выполнить `dd-console start`. Закрытие браузера не останавливает сервер.
|
|
130
|
+
|
|
131
|
+
В checkout доступны `npm run start:background`, `npm run stop`,
|
|
132
|
+
`npm run restart`, `npm run status`. Команды запуска собирают актуальную ревизию.
|
|
133
|
+
Установленный npm CLI запускается из любого каталога без TypeScript и пересборки.
|
|
134
|
+
|
|
135
|
+
## Выпуск через Changesets
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
npm run changeset # описание изменения и тип semver bump
|
|
139
|
+
npm run version:release # версии, CHANGELOG.md, package-lock.json
|
|
140
|
+
npm run release # сборка и публикация версий через Changesets
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`Release console` в GitHub Actions запускается вручную на `main`:
|
|
144
|
+
при наличии changeset создаёт/обновляет PR с версиями и changelog;
|
|
145
|
+
после слияния этого PR повторный запуск публикует пакет.
|
|
146
|
+
Проверки CLI и установки tarball выполняются перед публикацией.
|
|
147
|
+
CI также проверяет пакет на macOS и Linux.
|
|
148
|
+
|
|
149
|
+
Для публикации настройте npm Trusted Publisher для
|
|
150
|
+
`deksden-com/dd-console`, workflow `release.yml`.
|
|
151
|
+
Первая публикация нового пакета требует npm-аккаунта с правом публикации
|
|
152
|
+
в scope `@deksden-com`; после создания пакета можно настроить Trusted Publisher.
|
|
153
|
+
В GitHub включите разрешение Actions создавать pull requests.
|
|
154
|
+
Непубликуемый исходный changeset хранится в `.changeset/console-lifecycle.md`;
|
|
155
|
+
после `version:release` он превращается в запись changelog и новую версию.
|
package/dist/build.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"0.2.0","build_revision":"0.2.0+1791308847496"}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Bounded in-process cache shared by every browser client. It only retains
|
|
2
|
+
* normalized read models; no source files or raw transcripts are copied. */
|
|
3
|
+
export class ReaderCache {
|
|
4
|
+
maxBytes;
|
|
5
|
+
maxInFlight;
|
|
6
|
+
#entries = new Map();
|
|
7
|
+
#inFlight = new Map();
|
|
8
|
+
#bytes = 0;
|
|
9
|
+
constructor(maxBytes = 64 * 1024 * 1024, maxInFlight = 32) {
|
|
10
|
+
this.maxBytes = maxBytes;
|
|
11
|
+
this.maxInFlight = maxInFlight;
|
|
12
|
+
}
|
|
13
|
+
async get(key, ttlMs, read, refresh = false) {
|
|
14
|
+
const hit = this.#entries.get(key);
|
|
15
|
+
if (!refresh && hit && hit.expiresAt > Date.now()) {
|
|
16
|
+
this.#entries.delete(key);
|
|
17
|
+
this.#entries.set(key, hit);
|
|
18
|
+
return hit.value;
|
|
19
|
+
}
|
|
20
|
+
const pending = this.#inFlight.get(key);
|
|
21
|
+
if (pending)
|
|
22
|
+
return pending;
|
|
23
|
+
if (this.#inFlight.size >= this.maxInFlight)
|
|
24
|
+
throw Object.assign(new Error("Очередь локального чтения занята."), { code: "reader_queue_busy" });
|
|
25
|
+
const stale = () => {
|
|
26
|
+
const value = hit?.value;
|
|
27
|
+
return value && Array.isArray(value.diagnostics) ? { ...value, diagnostics: [...value.diagnostics, { code: "reader_stale", severity: "warning", message: "Показано последнее успешное чтение; источник временно недоступен." }] } : undefined;
|
|
28
|
+
};
|
|
29
|
+
const task = read().then(value => {
|
|
30
|
+
if (value?.diagnostics?.some(item => item.severity === "error"))
|
|
31
|
+
return stale() ?? value;
|
|
32
|
+
this.store(key, value, ttlMs);
|
|
33
|
+
return value;
|
|
34
|
+
}).catch(error => { const value = stale(); if (value)
|
|
35
|
+
return value; throw error; }).finally(() => this.#inFlight.delete(key));
|
|
36
|
+
this.#inFlight.set(key, task);
|
|
37
|
+
return task;
|
|
38
|
+
}
|
|
39
|
+
store(key, value, ttlMs) {
|
|
40
|
+
const previous = this.#entries.get(key);
|
|
41
|
+
if (previous)
|
|
42
|
+
this.#bytes -= previous.bytes;
|
|
43
|
+
const bytes = Buffer.byteLength(JSON.stringify(value));
|
|
44
|
+
this.#entries.set(key, { value, expiresAt: Date.now() + ttlMs, bytes });
|
|
45
|
+
this.#bytes += bytes;
|
|
46
|
+
while (this.#bytes > this.maxBytes && this.#entries.size) {
|
|
47
|
+
const oldest = this.#entries.entries().next().value;
|
|
48
|
+
this.#entries.delete(oldest[0]);
|
|
49
|
+
this.#bytes -= oldest[1].bytes;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { ensureDefaultSources, loadSources, sourceAdd, sourceRemove } from "./sources.js";
|
|
4
|
+
import { manageServer, serverStatus } from "./daemon.js";
|
|
5
|
+
import { serve } from "./server.js";
|
|
6
|
+
const argv = process.argv.slice(2);
|
|
7
|
+
const command = !argv[0] || argv[0].startsWith("--") ? "serve" : argv.shift();
|
|
8
|
+
const args = argv;
|
|
9
|
+
const fail = (message) => { console.error(message); process.exit(2); };
|
|
10
|
+
const usage = "Использование: dd-console [start|stop|restart|status|serve] [--port 50036] [--open] [--flow-home <path>] [--eval-home <path>] [--json] | sources list|add|remove";
|
|
11
|
+
function options(items, values, flags) {
|
|
12
|
+
const result = new Map();
|
|
13
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
14
|
+
const item = items[index];
|
|
15
|
+
if (flags.includes(item)) {
|
|
16
|
+
result.set(item, true);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (!values.includes(item))
|
|
20
|
+
fail(usage);
|
|
21
|
+
const value = items[index + 1];
|
|
22
|
+
if (!value || value.startsWith("--"))
|
|
23
|
+
fail(usage);
|
|
24
|
+
result.set(item, value);
|
|
25
|
+
index += 1;
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
if (args.includes("--help") || command === "help") {
|
|
30
|
+
console.log(usage);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
if (command === "stop" || command === "status") {
|
|
34
|
+
const values = options(args, [], ["--json"]);
|
|
35
|
+
const state = command === "status" ? await serverStatus() : await manageServer("stop", []);
|
|
36
|
+
console.log(values.get("--json") ? JSON.stringify(state) : `dd-console: ${state.status}${state.server ? ` ${state.server.url}` : ""}`);
|
|
37
|
+
if (command === "status" && state.status !== "running")
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
40
|
+
else if (command === "start" || command === "restart") {
|
|
41
|
+
const values = options(args, ["--port", "--flow-home", "--eval-home"], ["--open", "--json"]);
|
|
42
|
+
const previous = command === "restart" ? await serverStatus() : undefined;
|
|
43
|
+
const port = Number(values.get("--port") ?? process.env.DD_CONSOLE_PORT ?? (previous?.server ? new URL(previous.server.url).port : "50036"));
|
|
44
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
45
|
+
fail("--port должен быть числом 0..65535");
|
|
46
|
+
const forwarded = ["--port", String(port)];
|
|
47
|
+
for (const key of ["--flow-home", "--eval-home"])
|
|
48
|
+
if (typeof values.get(key) === "string")
|
|
49
|
+
forwarded.push(key, values.get(key));
|
|
50
|
+
const state = await manageServer(command, forwarded);
|
|
51
|
+
console.log(values.get("--json") ? JSON.stringify(state) : `dd-console: ${state.status} ${state.server.url}`);
|
|
52
|
+
if (values.get("--open"))
|
|
53
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [state.server.url], { stdio: "ignore", detached: true }).on("error", error => console.error(error.message)).unref();
|
|
54
|
+
}
|
|
55
|
+
else if (command === "sources") {
|
|
56
|
+
const [subcommand, ...subargs] = args;
|
|
57
|
+
if (subcommand === "list") {
|
|
58
|
+
if (subargs.length)
|
|
59
|
+
fail(usage);
|
|
60
|
+
console.log(JSON.stringify(await loadSources(), null, 2));
|
|
61
|
+
}
|
|
62
|
+
else if (subcommand === "add") {
|
|
63
|
+
const values = options(subargs, ["--kind", "--path", "--label"], []), kind = values.get("--kind"), target = values.get("--path"), label = values.get("--label");
|
|
64
|
+
if (kind !== "flow" && kind !== "eval")
|
|
65
|
+
fail("Использование: dd-console sources add --kind flow|eval --path <path> [--label <name>]");
|
|
66
|
+
if (typeof target !== "string")
|
|
67
|
+
fail("Использование: dd-console sources add --kind flow|eval --path <path> [--label <name>]");
|
|
68
|
+
console.log(JSON.stringify(await sourceAdd(kind, target, typeof label === "string" ? label : undefined), null, 2));
|
|
69
|
+
}
|
|
70
|
+
else if (subcommand === "remove") {
|
|
71
|
+
if (subargs.length !== 1 || subargs[0].startsWith("--"))
|
|
72
|
+
fail("Использование: dd-console sources remove <source-id>");
|
|
73
|
+
await sourceRemove(subargs[0]);
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
fail("Использование: dd-console sources list|add|remove");
|
|
77
|
+
}
|
|
78
|
+
else if (command === "serve") {
|
|
79
|
+
const values = options(args, ["--port", "--flow-home", "--eval-home"], ["--open", "--json"]), portValue = values.get("--port") ?? process.env.DD_CONSOLE_PORT ?? "50036", port = Number(portValue);
|
|
80
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
81
|
+
fail("--port должен быть числом 0..65535");
|
|
82
|
+
const flowHome = values.get("--flow-home"), evalHome = values.get("--eval-home");
|
|
83
|
+
await ensureDefaultSources(typeof flowHome === "string" ? flowHome : undefined, typeof evalHome === "string" ? evalHome : undefined);
|
|
84
|
+
const { server, url, closed } = await serve(port, process.env), receipt = { ok: true, url };
|
|
85
|
+
if (values.get("--json"))
|
|
86
|
+
console.log(JSON.stringify(receipt));
|
|
87
|
+
else
|
|
88
|
+
console.log(`dd-console: ${url}`);
|
|
89
|
+
if (values.get("--open"))
|
|
90
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore", detached: true }).on("error", error => console.error(error.message)).unref();
|
|
91
|
+
let stopping = false;
|
|
92
|
+
const close = () => { if (stopping)
|
|
93
|
+
return; stopping = true; server.close(); server.closeAllConnections(); void closed.then(() => { process.exitCode = 0; }, () => { process.exitCode = 1; }); };
|
|
94
|
+
process.on("SIGINT", close);
|
|
95
|
+
process.on("SIGTERM", close);
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
fail(usage);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, open, readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { acquireFileLock } from "./file-lock.js";
|
|
6
|
+
import { consoleHome } from "./sources.js";
|
|
7
|
+
const pause = () => new Promise(resolve => setTimeout(resolve, 100));
|
|
8
|
+
const alive = (pid) => { try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code === "ESRCH")
|
|
14
|
+
return false;
|
|
15
|
+
throw error;
|
|
16
|
+
} };
|
|
17
|
+
export async function serverStatus(env = process.env) {
|
|
18
|
+
let server;
|
|
19
|
+
try {
|
|
20
|
+
server = JSON.parse(await readFile(path.join(consoleHome(env), "server.json"), "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === "ENOENT")
|
|
24
|
+
return { status: "stopped" };
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
if (!Number.isSafeInteger(server.pid) || server.pid <= 1 || typeof server.instance !== "string" || !/^http:\/\/127\.0\.0\.1:\d+$/.test(server.url))
|
|
28
|
+
throw new Error("Некорректный server.json; файл сохранён.");
|
|
29
|
+
if (!alive(server.pid))
|
|
30
|
+
return { status: "stopped" };
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(`${server.url}/healthz`, { signal: AbortSignal.timeout(1_000) });
|
|
33
|
+
const health = await response.json();
|
|
34
|
+
if (response.ok && health.pid === server.pid && health.instance === server.instance)
|
|
35
|
+
return { status: "running", server };
|
|
36
|
+
}
|
|
37
|
+
catch { /* Never signal a process identified only by a stale PID. */ }
|
|
38
|
+
return { status: "unresponsive", server };
|
|
39
|
+
}
|
|
40
|
+
async function stop(env) {
|
|
41
|
+
const state = await serverStatus(env);
|
|
42
|
+
if (state.status === "stopped")
|
|
43
|
+
return state;
|
|
44
|
+
if (state.status !== "running")
|
|
45
|
+
throw new Error("Не удалось подтвердить процесс dd-console; сигнал не отправлен. Проверьте server.log.");
|
|
46
|
+
process.kill(state.server.pid, "SIGTERM");
|
|
47
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
48
|
+
if (!alive(state.server.pid))
|
|
49
|
+
return { status: "stopped" };
|
|
50
|
+
await pause();
|
|
51
|
+
}
|
|
52
|
+
throw new Error("dd-console не завершился за 10 секунд.");
|
|
53
|
+
}
|
|
54
|
+
export async function manageServer(command, args, env = process.env) {
|
|
55
|
+
const home = consoleHome(env);
|
|
56
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
57
|
+
const release = await acquireFileLock(path.join(home, "control.lock"), 1_000, "Управление dd-console занято.");
|
|
58
|
+
try {
|
|
59
|
+
if (command === "stop")
|
|
60
|
+
return await stop(env);
|
|
61
|
+
if (command === "restart")
|
|
62
|
+
await stop(env);
|
|
63
|
+
const current = await serverStatus(env);
|
|
64
|
+
if (current.status === "unresponsive")
|
|
65
|
+
throw new Error("dd-console не отвечает; проверьте server.log.");
|
|
66
|
+
const build = JSON.parse(await readFile(new URL("./build.json", import.meta.url), "utf8"));
|
|
67
|
+
if (current.status === "running") {
|
|
68
|
+
if (current.server.build_revision === build.build_revision)
|
|
69
|
+
return current;
|
|
70
|
+
await stop(env);
|
|
71
|
+
}
|
|
72
|
+
const log = await open(path.join(home, "server.log"), "a", 0o600);
|
|
73
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL("./cli.js", import.meta.url)), "serve", ...args], { detached: true, stdio: ["ignore", log.fd, log.fd], env });
|
|
74
|
+
let spawnError;
|
|
75
|
+
child.once("error", error => { spawnError = error; });
|
|
76
|
+
child.unref();
|
|
77
|
+
await log.close();
|
|
78
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
79
|
+
if (spawnError)
|
|
80
|
+
throw spawnError;
|
|
81
|
+
if (child.exitCode !== null)
|
|
82
|
+
throw new Error(`dd-console завершился при запуске; проверьте ${path.join(home, "server.log")}.`);
|
|
83
|
+
const state = await serverStatus(env);
|
|
84
|
+
if (state.status === "running" && state.server && state.server.pid === child.pid) {
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(state.server.url, { signal: AbortSignal.timeout(1_000) });
|
|
87
|
+
await response.text();
|
|
88
|
+
if (response.ok && response.headers.get("content-type")?.startsWith("text/html"))
|
|
89
|
+
return state;
|
|
90
|
+
}
|
|
91
|
+
catch { /* Wait until the UI is ready as well as health. */ }
|
|
92
|
+
}
|
|
93
|
+
await pause();
|
|
94
|
+
}
|
|
95
|
+
if (child.pid)
|
|
96
|
+
child.kill("SIGTERM");
|
|
97
|
+
throw new Error(`dd-console не запустился; проверьте ${path.join(home, "server.log")}.`);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await release();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import koffi from "koffi";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
const libc = koffi.load(process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6");
|
|
5
|
+
const flock = libc.func("int flock(int fd, int operation)");
|
|
6
|
+
/** Kernel-owned locks survive neither process death nor descriptor close.
|
|
7
|
+
* Keep the inode after release: unlinking it can create two independent locks. */
|
|
8
|
+
export async function acquireFileLock(file, attempts, busyMessage) {
|
|
9
|
+
const handle = await open(file, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
10
|
+
try {
|
|
11
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
12
|
+
if (flock(handle.fd, 2 | 4) === 0) {
|
|
13
|
+
await handle.truncate(0);
|
|
14
|
+
await handle.writeFile(`${process.pid}\n`);
|
|
15
|
+
let released = false;
|
|
16
|
+
return async () => { if (!released) {
|
|
17
|
+
released = true;
|
|
18
|
+
await handle.close();
|
|
19
|
+
} };
|
|
20
|
+
}
|
|
21
|
+
const code = koffi.errno();
|
|
22
|
+
if (![koffi.os.errno.EAGAIN, koffi.os.errno.EWOULDBLOCK].includes(code))
|
|
23
|
+
throw new Error("Не удалось заблокировать локальный реестр.");
|
|
24
|
+
if (attempt + 1 < attempts)
|
|
25
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
26
|
+
}
|
|
27
|
+
throw new Error(busyMessage);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
await handle.close();
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/journal.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { workerData } from "node:worker_threads";
|
|
2
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { openWithin } from "./safe-files.js";
|
|
4
|
+
import { string } from "./util.js";
|
|
5
|
+
const secret = workerData?.journalSecret ? Buffer.from(workerData.journalSecret) : randomBytes(32), chunkSize = 256 * 1024, maxLine = 1024 * 1024;
|
|
6
|
+
const stale = () => Object.assign(new Error("Журнал заменён или усечён. Начните чтение заново."), { code: "stale_cursor" });
|
|
7
|
+
const sign = (payload) => createHmac("sha256", secret).update(payload).digest("base64url");
|
|
8
|
+
function encode(cursor) { const payload = Buffer.from(JSON.stringify(cursor)).toString("base64url"); return `${payload}.${sign(payload)}`; }
|
|
9
|
+
function decode(token) {
|
|
10
|
+
try {
|
|
11
|
+
const [payload, signature] = token.split(".");
|
|
12
|
+
const expected = Buffer.from(sign(payload));
|
|
13
|
+
const actual = Buffer.from(signature ?? "");
|
|
14
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
|
|
15
|
+
throw stale();
|
|
16
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw stale();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function event(line, generation, offset) {
|
|
23
|
+
try {
|
|
24
|
+
const value = JSON.parse(line.toString("utf8"));
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
26
|
+
throw new Error();
|
|
27
|
+
const data = value.data, payload = value.payload;
|
|
28
|
+
return { id: string(value.id) ?? `${generation}:${offset}`, execution_id: string(value.executionid) ?? string(value.execution_id), time: string(value.time) ?? string(value.timestamp), type: string(value.type) ?? string(value.event) ?? "event", status: string(data?.state) ?? string(data?.status) ?? string(value.status), stage: string(data?.stage) ?? string(value.stage) ?? null, message: string(data?.message) ?? string(payload?.reason) };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { id: `${generation}:${offset}`, type: "invalid_jsonl_record", status: "partial", message: "Повреждённая строка журнала." };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Pages backwards from the latest complete record, with bounded reads/memory.
|
|
35
|
+
* A signed cursor freezes the upper byte boundary; append leaves it valid.
|
|
36
|
+
* Every cursor revalidates the file generation, prefix and boundary anchor. */
|
|
37
|
+
export async function journalPage(root, file, scope, limit = 50, token) {
|
|
38
|
+
let handle;
|
|
39
|
+
try {
|
|
40
|
+
handle = await openWithin(root, file);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (token)
|
|
44
|
+
throw stale();
|
|
45
|
+
return { items: [], next_cursor: null, truncated: false, pending_tail: false, diagnostics: [{ code: "journal_unavailable", severity: "warning", message: "Журнал отсутствует или недоступен." }] };
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const info = handle.stat(), generation = `${info.dev}:${info.ino}:${info.birthtimeMs}`;
|
|
49
|
+
const hash = async (start, length) => { const buffer = Buffer.alloc(length), { bytesRead } = await handle.read(buffer, start); return createHash("sha256").update(buffer.subarray(0, bytesRead)).digest("hex"); };
|
|
50
|
+
const old = token ? decode(token) : null;
|
|
51
|
+
if (old && (old.scope !== scope || old.generation !== generation || !Number.isSafeInteger(old.end) || old.end < 0 || old.end > old.size || info.size < old.size || old.head !== await hash(0, Math.min(256, old.size)) || old.anchor !== await hash(Math.max(0, old.end - 256), Math.min(256, old.end))))
|
|
52
|
+
throw stale();
|
|
53
|
+
let end = old?.end ?? info.size, pos = end, skipping = old?.skipping ?? false;
|
|
54
|
+
let buffer = Buffer.alloc(0), bytes = 0, pending = false;
|
|
55
|
+
const items = [], diagnostics = [];
|
|
56
|
+
// The rightmost incomplete record waits for a later append. Discard its
|
|
57
|
+
// bounded fragments while walking left; it is never parsed as an event.
|
|
58
|
+
let discardTail = !old || Boolean(old.tail), longLine = skipping;
|
|
59
|
+
while ((pos > 0 || buffer.length) && items.length < limit && bytes < 4 * 1024 * 1024) {
|
|
60
|
+
if (pos > 0) {
|
|
61
|
+
const start = Math.max(0, pos - chunkSize), part = Buffer.alloc(pos - start);
|
|
62
|
+
const { bytesRead } = await handle.read(part, start);
|
|
63
|
+
if (bytesRead !== part.length)
|
|
64
|
+
throw stale();
|
|
65
|
+
buffer = Buffer.concat([part, buffer]);
|
|
66
|
+
bytes += bytesRead;
|
|
67
|
+
pos = start;
|
|
68
|
+
}
|
|
69
|
+
if (discardTail) {
|
|
70
|
+
const last = buffer.lastIndexOf(10);
|
|
71
|
+
if (last < 0) {
|
|
72
|
+
pending = true;
|
|
73
|
+
buffer = Buffer.alloc(0);
|
|
74
|
+
end = pos;
|
|
75
|
+
if (!pos)
|
|
76
|
+
break;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
pending = last < buffer.length - 1 || pending;
|
|
80
|
+
buffer = buffer.subarray(0, last + 1);
|
|
81
|
+
end = pos + buffer.length;
|
|
82
|
+
discardTail = false;
|
|
83
|
+
}
|
|
84
|
+
while (buffer.length && items.length < limit) {
|
|
85
|
+
const previous = buffer.length > 1 ? buffer.lastIndexOf(10, buffer.length - 2) : -1;
|
|
86
|
+
if (previous < 0 && pos > 0)
|
|
87
|
+
break;
|
|
88
|
+
const start = previous + 1, offset = pos + start, line = buffer.subarray(start, buffer.length - 1);
|
|
89
|
+
if (longLine || line.length > maxLine) {
|
|
90
|
+
diagnostics.push({ code: "journal_line_too_large", severity: "warning", message: "Строка журнала больше 1 MiB пропущена." });
|
|
91
|
+
items.push({ id: `${generation}:${offset}`, type: "oversized_jsonl_record", status: "partial" });
|
|
92
|
+
}
|
|
93
|
+
else if (line.length) {
|
|
94
|
+
const item = event(line, generation, offset);
|
|
95
|
+
items.push(item);
|
|
96
|
+
if (item.type === "invalid_jsonl_record")
|
|
97
|
+
diagnostics.push({ code: item.type, severity: "warning", message: item.message });
|
|
98
|
+
}
|
|
99
|
+
longLine = false;
|
|
100
|
+
buffer = buffer.subarray(0, start);
|
|
101
|
+
end = offset;
|
|
102
|
+
}
|
|
103
|
+
if (buffer.length > maxLine) {
|
|
104
|
+
buffer = Buffer.alloc(0);
|
|
105
|
+
longLine = true;
|
|
106
|
+
end = pos;
|
|
107
|
+
}
|
|
108
|
+
if (!pos)
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (longLine || discardTail)
|
|
112
|
+
end = pos;
|
|
113
|
+
const after = handle.stat();
|
|
114
|
+
if (after.size < info.size || after.ino !== info.ino || after.size === info.size && after.mtimeMs !== info.mtimeMs)
|
|
115
|
+
throw stale();
|
|
116
|
+
if (bytes >= 4 * 1024 * 1024)
|
|
117
|
+
diagnostics.push({ code: "journal_scan_budget", severity: "info", message: "Достигнут лимит чтения страницы; продолжите по ссылке следующей страницы." });
|
|
118
|
+
const next_cursor = end > 0 ? encode({ scope, generation, end, size: info.size, head: await hash(0, Math.min(256, info.size)), anchor: await hash(Math.max(0, end - 256), Math.min(256, end)), skipping: longLine, tail: discardTail }) : null;
|
|
119
|
+
return { items, next_cursor, truncated: Boolean(next_cursor), pending_tail: pending, diagnostics };
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
handle.close();
|
|
123
|
+
}
|
|
124
|
+
}
|