@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/dist/server.js ADDED
@@ -0,0 +1,341 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile, stat, writeFile, rename, rm } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
5
+ import { acquireServerLock, loadSources, consoleHome } from "./sources.js";
6
+ import { ReaderCache } from "./cache.js";
7
+ import { ReaderPool } from "./reader-pool.js";
8
+ import { derivedSource } from "./readers.js";
9
+ import { id, now } from "./util.js";
10
+ import { openWithin } from "./safe-files.js";
11
+ const webRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "web");
12
+ const loadBuildInfo = async () => {
13
+ const value = JSON.parse(await readFile(path.join(path.dirname(fileURLToPath(import.meta.url)), "build.json"), "utf8"));
14
+ if (typeof value.version !== "string" || typeof value.build_revision !== "string")
15
+ throw new Error("Некорректная информация о сборке dd-console.");
16
+ return value;
17
+ };
18
+ const readers = new ReaderCache();
19
+ let workerPool;
20
+ const pool = () => workerPool ??= new ReaderPool();
21
+ const snapshots = new Map();
22
+ const json = (response, status, body, headers = {}) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", ...headers }); response.end(JSON.stringify(body)); };
23
+ const availability = (diagnostics) => diagnostics.some(item => item.code.endsWith("_unsupported")) ? "unsupported" : diagnostics.some(item => item.severity === "error") ? "unavailable" : diagnostics.length ? "partial" : "available";
24
+ const sourceObservation = (source, diagnostics = [], source_updated_at, observedAt) => ({ id: source.id, kind: source.kind, availability: availability(diagnostics), observed_at: observedAt ?? now(), ...(source_updated_at ? { source_updated_at } : {}) });
25
+ const envelope = (data, diagnostics = [], source) => ({ schema_version: "dd-console/api@1", request_id: id("REQ"), served_at: now(), observed_at: source?.observed_at ?? data?.observed_at ?? now(), data, diagnostics, ...(source ? { source } : {}) });
26
+ const notFound = (response, message = "Не найдено.") => json(response, 404, envelope({ code: "not_found", message }));
27
+ const staleCursor = (response) => json(response, 409, envelope({ code: "stale_cursor", message: "Страница устарела; обновите список." }));
28
+ async function readSources(items, read) {
29
+ const results = [];
30
+ for (let offset = 0; offset < items.length; offset += 2)
31
+ results.push(...await Promise.all(items.slice(offset, offset + 2).map(read)));
32
+ return results;
33
+ }
34
+ async function sources(env = process.env) { return (await loadSources(env)).sources.filter(source => !source.disabled); }
35
+ async function resolveSource(sourceId, env = process.env) {
36
+ const registered = await sources(env);
37
+ const direct = registered.find(source => source.id === sourceId);
38
+ if (direct)
39
+ return direct;
40
+ if (!sourceId?.startsWith("drv."))
41
+ return undefined;
42
+ try {
43
+ const [parentId, evalId, executionId] = JSON.parse(Buffer.from(sourceId.slice(4), "base64url").toString("utf8"));
44
+ const parent = registered.find(source => source.id === parentId && source.kind === "eval");
45
+ return parent ? await derivedSource(parent, evalId, executionId) : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ const readSnapshot = async (operation, ...args) => {
52
+ const value = await pool().run(operation, ...args);
53
+ return value ? Object.assign(value, { observed_at: now() }) : null;
54
+ };
55
+ const cachedFlowRuns = (source, refresh = false) => readers.get(JSON.stringify(["flow-runs", source.id, source.root]), 2_000, async () => readSnapshot("flowRuns", source), refresh);
56
+ const cachedSourceFlows = (source, refresh = false) => source.kind === "flow" ? cachedFlowRuns(source, refresh) : readers.get(JSON.stringify(["eval-flows", source.id, source.root]), 2_000, () => readSnapshot("evalFlowRuns", source), refresh);
57
+ const cachedEvalRuns = (source, refresh = false) => readers.get(JSON.stringify(["eval-runs", source.id, source.root]), 2_000, () => readSnapshot("evalRuns", source), refresh);
58
+ const cachedFlowDetail = (source, projectId, runId, refresh = false) => readers.get(JSON.stringify(["flow-detail", source.id, source.root, projectId, runId]), 2_000, async () => readSnapshot("flowDetail", source, projectId, runId), refresh);
59
+ const cachedEvalDetail = (source, evalId, refresh = false) => readers.get(JSON.stringify(["eval-detail", source.id, source.root, evalId]), 2_000, () => readSnapshot("evalDetail", source, evalId), refresh);
60
+ const cachedEvalExecutionDetail = (source, evalId, executionId, refresh = false) => readers.get(JSON.stringify(["eval-execution", source.id, source.root, evalId, executionId]), 2_000, () => readSnapshot("evalExecutionDetail", source, evalId, executionId), refresh);
61
+ const mergeUsage = (items) => {
62
+ const present = items.filter(item => item.status !== "unavailable");
63
+ if (!present.length)
64
+ return { status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0 };
65
+ const sum = (key) => present.some(item => item[key] === null) ? null : present.reduce((total, item) => total + item[key], 0);
66
+ const input_tokens = sum("input_tokens"), cached_input_tokens = sum("cached_input_tokens"), output_tokens = sum("output_tokens"), total_tokens = sum("total_tokens");
67
+ return { status: present.some(item => item.status === "partial") || [input_tokens, cached_input_tokens, output_tokens, total_tokens].some(value => value === null) ? "partial" : "measured", input_tokens, cached_input_tokens, output_tokens, total_tokens, session_count: present.reduce((total, item) => total + item.session_count, 0) };
68
+ };
69
+ async function sourceSummary(source, refresh = false) { try {
70
+ const result = source.kind === "flow" ? await cachedFlowRuns(source, refresh) : await cachedEvalRuns(source, refresh);
71
+ const updated = source.kind === "flow" ? result.runs.map(item => item.updated_at) : result.evals.map(item => item.updated_at);
72
+ const source_updated_at = updated.filter((item) => Boolean(item)).sort().at(-1);
73
+ return { id: source.id, kind: source.kind, label: source.label, created_at: source.created_at, availability: availability(result.diagnostics), observed_at: result.observed_at ?? now(), ...(source_updated_at ? { source_updated_at } : {}), detail: result.diagnostics[0]?.message };
74
+ }
75
+ catch (error) {
76
+ return { id: source.id, kind: source.kind, label: source.label, created_at: source.created_at, availability: "unavailable", observed_at: now(), detail: "Не удалось прочитать источник." };
77
+ } }
78
+ function pathParts(url) { return url.pathname.split("/").filter(Boolean).map(decodeURIComponent); }
79
+ const pageLimit = (url) => Math.min(200, Math.max(1, Math.floor(Number(url.searchParams.get("limit") ?? 50) || 50)));
80
+ function page(url, scope, values, observedAt = now()) {
81
+ const limit = pageLimit(url);
82
+ const token = url.searchParams.get("cursor");
83
+ const filters = ["q", "status", "source", "project", "kind", "case", "origin", "sort", "order"];
84
+ scope += JSON.stringify(filters.map(key => [key, url.searchParams.get(key) ?? ""]));
85
+ const value = (item, key) => String(item[key] ?? "");
86
+ values = values.filter(item => {
87
+ for (const [query, field] of [["origin", "origin"], ["status", "status"], ["source", "source_id"], ["project", "project_id"], ["kind", "flow_kind"], ["case", "case_id"]])
88
+ if (url.searchParams.get(query) && value(item, field) !== url.searchParams.get(query))
89
+ return false;
90
+ const q = (url.searchParams.get("q") ?? "").toLocaleLowerCase();
91
+ return !q || ["run_id", "eval_id", "short_id", "case_id", "project_id", "flow_kind", "execution_id", "source_label"].some(key => value(item, key).toLocaleLowerCase().includes(q));
92
+ });
93
+ const sort = url.searchParams.get("sort") ?? "updated_at", direction = url.searchParams.get("order") === "asc" ? 1 : -1;
94
+ const identity = (item) => ["source_id", "project_id", "run_id", "eval_id"].map(key => value(item, key)).join("/");
95
+ values.sort((left, right) => direction * value(left, sort).localeCompare(value(right, sort)) || identity(left).localeCompare(identity(right)));
96
+ let snapshotId = "", offset = 0, items = values;
97
+ if (token) {
98
+ try {
99
+ [snapshotId, offset] = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ const snapshot = snapshots.get(snapshotId);
105
+ if (!snapshot || snapshot.scope !== scope || snapshot.expiresAt < Date.now() || !Number.isSafeInteger(offset) || offset < 0)
106
+ return null;
107
+ items = snapshot.items;
108
+ observedAt = snapshot.observed_at;
109
+ }
110
+ else {
111
+ snapshotId = id("SNP");
112
+ const bytes = Buffer.byteLength(JSON.stringify(values));
113
+ if (bytes > 64 * 1024 * 1024)
114
+ throw Object.assign(new Error("Слишком большой список; сузьте фильтры."), { code: "reader_queue_busy" });
115
+ snapshots.set(snapshotId, { scope, items: values, bytes, observed_at: observedAt, expiresAt: Date.now() + 5 * 60_000 });
116
+ }
117
+ for (const [key, snapshot] of snapshots)
118
+ if (snapshot.expiresAt < Date.now())
119
+ snapshots.delete(key);
120
+ while (snapshots.size > 256 || [...snapshots.values()].reduce((sum, snapshot) => sum + snapshot.bytes, 0) > 64 * 1024 * 1024)
121
+ snapshots.delete(snapshots.keys().next().value);
122
+ const slice = items.slice(offset, offset + limit), next = offset + slice.length < items.length ? Buffer.from(JSON.stringify([snapshotId, offset + slice.length])).toString("base64url") : null;
123
+ return { items: slice, total: items.length, next_cursor: next, observed_at: observedAt };
124
+ }
125
+ const textArtifact = (name) => /\.(?:json|jsonl|log|txt|md|out|err)$/i.test(name);
126
+ async function artifactResponse(response, source, item, action, url) {
127
+ const handle = await openWithin(source.boundary_root ?? source.root, item.file);
128
+ try {
129
+ const info = handle.stat(), metadata = { id: item.id, part: item.part, name: item.name, bytes: info.size, preview_available: textArtifact(item.name) };
130
+ if (action === "download") {
131
+ response.writeHead(200, { "content-type": "application/octet-stream", "content-length": info.size, "content-disposition": `attachment; filename="artifact"; filename*=UTF-8''${encodeURIComponent(item.name).replace(/'/g, "%27")}`, "x-content-type-options": "nosniff", "cache-control": "no-store" });
132
+ const stream = handle.stream();
133
+ stream.on("error", () => response.destroy());
134
+ response.on("close", () => stream.destroy());
135
+ stream.pipe(response);
136
+ return;
137
+ }
138
+ if (action === "preview") {
139
+ if (!metadata.preview_available)
140
+ return json(response, 415, envelope({ code: "preview_unsupported", message: "Для этого типа доступно только скачивание." }));
141
+ const offset = Number(url.searchParams.get("offset") ?? 0);
142
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > info.size)
143
+ return json(response, 400, envelope({ code: "bad_offset", message: "Некорректное смещение preview." }));
144
+ const buffer = Buffer.alloc(Math.min(262144, info.size - offset)), result = await handle.read(buffer, offset);
145
+ let bytesRead = result.bytesRead;
146
+ if (offset + bytesRead < info.size && bytesRead) {
147
+ let start = bytesRead - 1;
148
+ while (start > 0 && (buffer[start] & 0xc0) === 0x80)
149
+ start--;
150
+ const byte = buffer[start], width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
151
+ if (start + width > bytesRead)
152
+ bytesRead = start;
153
+ }
154
+ return json(response, 200, envelope({ ...metadata, text: buffer.subarray(0, bytesRead).toString("utf8"), offset, next_offset: offset + bytesRead < info.size ? offset + bytesRead : null, truncated: offset + bytesRead < info.size }));
155
+ }
156
+ return json(response, 200, envelope(metadata));
157
+ }
158
+ finally {
159
+ handle.close();
160
+ }
161
+ }
162
+ async function staticFile(response, name) { const file = path.join(webRoot, name); try {
163
+ const info = await stat(file);
164
+ if (!info.isFile())
165
+ return false;
166
+ const content = await readFile(file);
167
+ response.writeHead(200, { "content-type": name.endsWith(".css") ? "text/css; charset=utf-8" : name.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", "content-security-policy": "default-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; script-src 'self'" });
168
+ response.end(content);
169
+ return true;
170
+ }
171
+ catch {
172
+ return false;
173
+ } }
174
+ export async function requestHandler(request, response, env = process.env, build) {
175
+ const host = request.headers.host ?? "";
176
+ if (!/^127\.0\.0\.1:\d+$/.test(host) && !/^localhost:\d+$/.test(host))
177
+ return json(response, 400, envelope({ code: "bad_host", message: "Недопустимый Host." }));
178
+ if (request.socket.localPort && Number(host.split(":").at(-1)) !== request.socket.localPort)
179
+ return json(response, 400, envelope({ code: "bad_host", message: "Неверный порт Host." }));
180
+ const origin = request.headers.origin;
181
+ if (origin && origin !== `http://${host}`)
182
+ return json(response, 403, envelope({ code: "bad_origin", message: "Недопустимый Origin." }));
183
+ if (request.method !== "GET" && request.method !== "HEAD")
184
+ return json(response, 405, envelope({ code: "method_not_allowed", message: "Только чтение." }));
185
+ const url = new URL(request.url ?? "/", `http://${host}`);
186
+ let parts;
187
+ try {
188
+ parts = pathParts(url);
189
+ }
190
+ catch {
191
+ return json(response, 400, envelope({ code: "bad_path", message: "Некорректное кодирование URL." }));
192
+ }
193
+ if (url.searchParams.has("limit") && (!Number.isSafeInteger(Number(url.searchParams.get("limit"))) || Number(url.searchParams.get("limit")) < 1 || Number(url.searchParams.get("limit")) > 200))
194
+ return json(response, 400, envelope({ code: "bad_limit", message: "limit должен быть целым числом от 1 до 200." }));
195
+ if (url.searchParams.has("sort") && !["updated_at", "created_at", "status", "run_id", "eval_id"].includes(url.searchParams.get("sort")))
196
+ return json(response, 400, envelope({ code: "bad_sort", message: "Неизвестное поле сортировки." }));
197
+ if (url.searchParams.has("order") && !["asc", "desc"].includes(url.searchParams.get("order")))
198
+ return json(response, 400, envelope({ code: "bad_order", message: "order должен быть asc или desc." }));
199
+ const refresh = url.searchParams.get("refresh") === "1";
200
+ if (url.pathname === "/healthz")
201
+ return json(response, 200, { ok: true, ...(build ?? { version: "0.1.0" }) });
202
+ if (url.pathname === "/api/v1/sources")
203
+ return json(response, 200, envelope(await readSources(await sources(env), source => sourceSummary(source, refresh))));
204
+ if (url.pathname === "/api/v1/overview") {
205
+ const active = await sources(env);
206
+ const summaries = await readSources(active, source => sourceSummary(source, refresh));
207
+ const flow = await readSources(active, item => cachedSourceFlows(item, refresh));
208
+ const evals = await readSources(active.filter(item => item.kind === "eval"), item => cachedEvalRuns(item, refresh));
209
+ return json(response, 200, envelope({ sources: summaries, usage: mergeUsage(flow.map(item => item.usage)), flows: flow.flatMap(item => item.runs).sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")).slice(0, 20), evals: evals.flatMap(item => item.evals).sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")).slice(0, 20) }, flow.flatMap(item => item.diagnostics)));
210
+ }
211
+ if (url.pathname === "/api/v1/flows") {
212
+ const data = await readSources(await sources(env), item => cachedSourceFlows(item, refresh));
213
+ const result = page(url, "flows", data.flatMap(item => item.runs), data.map(item => item.observed_at ?? now()).sort()[0]);
214
+ return result ? json(response, 200, envelope(result, data.flatMap(item => item.diagnostics))) : staleCursor(response);
215
+ }
216
+ if (url.pathname === "/api/v1/evals") {
217
+ const data = await readSources((await sources(env)).filter(item => item.kind === "eval"), item => cachedEvalRuns(item, refresh));
218
+ const result = page(url, "evals", data.flatMap(item => item.evals), data.map(item => item.observed_at ?? now()).sort()[0]);
219
+ return result ? json(response, 200, envelope(result, data.flatMap(item => item.diagnostics))) : staleCursor(response);
220
+ }
221
+ const current = await resolveSource(parts[3], env);
222
+ if (parts[0] === "api" && parts[1] === "v1" && parts[2] === "sources" && !current)
223
+ return notFound(response, "Источник не найден.");
224
+ if (parts.slice(0, 3).join("/") === "api/v1/sources" && current?.kind === "flow" && parts[4] === "projects" && parts[6] === "runs" && parts[5] && parts[7]) {
225
+ if (parts.length !== 8 && !((parts[8] === "events" && parts.length === 9) || (["works", "sessions", "checks"].includes(parts[8] ?? "") && parts.length === 10) || (parts[8] === "stages" && parts[10] === "attempts" && parts.length === 12) || (parts[8] === "artifacts" && [10, 11].includes(parts.length))))
226
+ return notFound(response);
227
+ const detail = await cachedFlowDetail(current, parts[5], parts[7], refresh);
228
+ if (!detail)
229
+ return notFound(response, "Flow не найден.");
230
+ if (parts[8] === "events") {
231
+ const result = await pool().run("flowEvents", current, parts[5], parts[7], pageLimit(url), url.searchParams.get("cursor"));
232
+ return result ? json(response, 200, envelope(result, [...detail.diagnostics, ...result.diagnostics], sourceObservation(current, [...detail.diagnostics, ...result.diagnostics], detail.updated_at, detail.observed_at))) : staleCursor(response);
233
+ }
234
+ if (parts[8] === "stages" && parts[9] && parts[10] === "attempts" && parts[11]) {
235
+ const attempt = detail.stages.find(item => item.stage === parts[9] && item.attempt === parts[11]);
236
+ if (!attempt)
237
+ return notFound(response, "Попытка стадии не найдена.");
238
+ const attempts = detail.stages.filter(item => item.stage === attempt.stage).sort((left, right) => (left.order ?? 0) - (right.order ?? 0)), index = attempts.findIndex(item => item.attempt === attempt.attempt);
239
+ return json(response, 200, envelope({ ...attempt, previous_attempt: attempts[index - 1]?.attempt ?? null, next_attempt: attempts[index + 1]?.attempt ?? null }, detail.diagnostics, sourceObservation(current, detail.diagnostics, detail.updated_at, detail.observed_at)));
240
+ }
241
+ if (parts[8] === "works" && parts[9]) {
242
+ const item = detail.works.find(work => work.id === parts[9]);
243
+ return item ? json(response, 200, envelope({ ...item, checks: detail.checks.filter(check => check.work_id === item.id) }, detail.diagnostics, sourceObservation(current, detail.diagnostics, detail.updated_at, detail.observed_at))) : notFound(response, "Работа не найдена.");
244
+ }
245
+ if (parts[8] === "sessions" && parts[9]) {
246
+ const item = detail.sessions.find(session => session.id === parts[9]);
247
+ return item ? json(response, 200, envelope({ ...item, works: detail.works.filter(work => item.work_ids?.includes(work.id)) }, detail.diagnostics, sourceObservation(current, detail.diagnostics, detail.updated_at, detail.observed_at))) : notFound(response, "Сессия не найдена.");
248
+ }
249
+ if (parts[8] === "checks" && parts[9]) {
250
+ const item = detail.checks.find(check => check.id === parts[9]);
251
+ return item ? json(response, 200, envelope({ ...item, session_ids: detail.works.find(work => work.id === item.work_id)?.session_ids ?? [], attempts: detail.works.find(work => work.id === item.work_id)?.attempts ?? [] }, detail.diagnostics, sourceObservation(current, detail.diagnostics, detail.updated_at, detail.observed_at))) : notFound(response, "Проверка не найдена.");
252
+ }
253
+ if (parts[8] === "artifacts" && parts[9]) {
254
+ if (parts.length > 11 || parts[10] && !["download", "preview"].includes(parts[10]))
255
+ return notFound(response);
256
+ const item = await pool().run("flowArtifact", current, parts[5], parts[7], parts[9], url.searchParams.get("part") ?? "receipt");
257
+ if (!item)
258
+ return notFound(response, "Артефакт отсутствует или недоступен.");
259
+ return artifactResponse(response, current, item, parts[10], url);
260
+ }
261
+ if (parts.length !== 8)
262
+ return notFound(response);
263
+ return json(response, 200, envelope(detail, detail.diagnostics, sourceObservation(current, detail.diagnostics, detail.updated_at, detail.observed_at)));
264
+ }
265
+ if (parts.slice(0, 3).join("/") === "api/v1/sources" && current?.kind === "eval" && parts[4] === "evals" && parts[5]) {
266
+ if (parts.length !== 6 && !(parts[6] === "events" && parts.length === 7) && !(parts[6] === "executions" && parts.length === 8))
267
+ return notFound(response);
268
+ if (parts[6] === "executions" && parts[7]) {
269
+ const execution = await cachedEvalExecutionDetail(current, parts[5], parts[7], refresh);
270
+ return execution ? json(response, 200, envelope(execution, execution.diagnostics, sourceObservation(current, execution.diagnostics, undefined, execution.observed_at))) : notFound(response, "Execution не найден.");
271
+ }
272
+ const detail = await cachedEvalDetail(current, parts[5], refresh);
273
+ if (!detail)
274
+ return notFound(response, "Eval не найден.");
275
+ if (parts[6] === "events") {
276
+ const result = await pool().run("evalEvents", current, parts[5], pageLimit(url), url.searchParams.get("cursor"));
277
+ return result ? json(response, 200, envelope(result, [...detail.diagnostics, ...result.diagnostics], sourceObservation(current, [...detail.diagnostics, ...result.diagnostics], detail.updated_at, detail.observed_at))) : staleCursor(response);
278
+ }
279
+ return json(response, 200, envelope(detail, detail.diagnostics, sourceObservation(current, detail.diagnostics, detail.updated_at, detail.observed_at)));
280
+ }
281
+ if (url.pathname === "/app.js" && await staticFile(response, "app.js"))
282
+ return;
283
+ if (url.pathname === "/style.css" && await staticFile(response, "style.css"))
284
+ return;
285
+ if (!url.pathname.startsWith("/api/")) {
286
+ if (await staticFile(response, "index.html"))
287
+ return;
288
+ }
289
+ return notFound(response);
290
+ }
291
+ export async function serve(port, env = process.env) {
292
+ const releaseLock = await acquireServerLock(env);
293
+ let build;
294
+ try {
295
+ build = { ...await loadBuildInfo(), pid: process.pid, instance: id("SERVER") };
296
+ }
297
+ catch (error) {
298
+ await releaseLock();
299
+ throw error;
300
+ }
301
+ const server = createServer((request, response) => void requestHandler(request, response, env, build).catch(error => { const code = error.code; const busy = code === "reader_queue_busy" || code === "reader_deadline"; if (code === "stale_cursor")
302
+ return staleCursor(response); if (response.headersSent) {
303
+ response.destroy();
304
+ return;
305
+ } json(response, busy ? 503 : 500, envelope({ code: busy ? "reader_queue_busy" : "read_failed", message: busy ? "Локальное чтение временно недоступно. Повторите запрос." : "Не удалось прочитать локальные данные." }), busy ? { "retry-after": "1" } : {}); }));
306
+ try {
307
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", () => { server.off("error", reject); resolve(); }); });
308
+ }
309
+ catch (error) {
310
+ await releaseLock();
311
+ throw error;
312
+ }
313
+ const address = server.address();
314
+ if (!address || typeof address === "string")
315
+ throw new Error("Сервер не вернул TCP адрес.");
316
+ const ownedPool = pool();
317
+ const closed = new Promise((resolve, reject) => server.once("close", () => {
318
+ if (workerPool === ownedPool)
319
+ workerPool = undefined;
320
+ void (async () => { try {
321
+ await rm(path.join(consoleHome(env), "server.json"), { force: true });
322
+ await ownedPool.close();
323
+ }
324
+ finally {
325
+ await releaseLock();
326
+ } })().then(() => resolve(), reject);
327
+ }));
328
+ // Callers may await closed to ensure the lock and workers have been released.
329
+ closed.catch(() => { });
330
+ const url = `http://127.0.0.1:${address.port}`, stateFile = path.join(consoleHome(env), "server.json");
331
+ try {
332
+ await writeFile(`${stateFile}.tmp`, JSON.stringify({ ...build, url }), { mode: 0o600 });
333
+ await rename(`${stateFile}.tmp`, stateFile);
334
+ }
335
+ catch (error) {
336
+ server.close();
337
+ await closed;
338
+ throw error;
339
+ }
340
+ return { server, url, env, closed };
341
+ }
@@ -0,0 +1,137 @@
1
+ import { access, mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import path from "node:path";
4
+ import { acquireFileLock } from "./file-lock.js";
5
+ import { id, now } from "./util.js";
6
+ export const consoleHome = (env = process.env) => path.resolve(env.DD_CONSOLE_HOME ?? path.join(env.HOME ?? ".", ".dd-console"));
7
+ const registryPath = (env = process.env) => path.join(consoleHome(env), "sources.json");
8
+ const serverLockPath = (env = process.env) => path.join(consoleHome(env), "server.lock");
9
+ async function loadLocalSources(env = process.env) {
10
+ const file = registryPath(env);
11
+ try {
12
+ const text = await readFile(file, "utf8");
13
+ let value;
14
+ try {
15
+ value = JSON.parse(text);
16
+ }
17
+ catch {
18
+ throw new Error("sources registry is invalid; it was left unchanged");
19
+ }
20
+ if (!value || value.schema_version !== 1 || !Array.isArray(value.sources))
21
+ throw new Error("sources registry format is unsupported");
22
+ if (value.sources.some(source => !source || typeof source.id !== "string" || !/^SRC-[a-zA-Z0-9-]+$/.test(source.id) || !["flow", "eval"].includes(source.kind) || typeof source.root !== "string" || !path.isAbsolute(source.root) || typeof source.label !== "string" || typeof source.created_at !== "string" || source.disabled !== undefined && typeof source.disabled !== "boolean" || ["database_path", "boundary_root", "allowed_run_id"].some(key => key in source)))
23
+ throw new Error("sources registry entries are invalid; it was left unchanged");
24
+ if (new Set(value.sources.map(source => source.id)).size !== value.sources.length)
25
+ throw new Error("duplicate source identity");
26
+ return value;
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT")
30
+ return { schema_version: 1, sources: [] };
31
+ throw error;
32
+ }
33
+ }
34
+ // The producer registry is read on every request, so new homes need no restart.
35
+ export async function loadSources(env = process.env) {
36
+ const local = await loadLocalSources(env);
37
+ let registry;
38
+ try {
39
+ registry = JSON.parse(await readFile(path.join(env.HOME ?? ".", ".dd-eval", "homes.json"), "utf8"));
40
+ }
41
+ catch (error) {
42
+ if (error.code === "ENOENT")
43
+ return local;
44
+ throw new Error("Eval homes registry is invalid or unreadable");
45
+ }
46
+ if (registry?.schema_version !== 1 || !Array.isArray(registry.homes) || registry.homes.some(h => !h || typeof h.id !== "string" || !/^SRC-[a-zA-Z0-9-]+$/.test(h.id) || typeof h.root !== "string" || !path.isAbsolute(h.root) || typeof h.label !== "string" || typeof h.created_at !== "string" || typeof h.disabled !== "boolean"))
47
+ throw new Error("Eval homes registry format is invalid");
48
+ if (new Set(registry.homes.map(h => h.id)).size !== registry.homes.length || new Set(registry.homes.map(h => h.root)).size !== registry.homes.length)
49
+ throw new Error("Duplicate Eval home identity");
50
+ for (const home of registry.homes) {
51
+ const existing = local.sources.find(s => s.kind === "eval" && s.root === home.root);
52
+ if (existing) {
53
+ existing.disabled = existing.disabled || home.disabled;
54
+ continue;
55
+ }
56
+ if (local.sources.some(s => s.id === home.id))
57
+ throw new Error("Conflicting Eval home identity");
58
+ local.sources.push({ id: home.id, root: home.root, label: home.label, created_at: home.created_at, disabled: home.disabled, kind: "eval" });
59
+ }
60
+ return local;
61
+ }
62
+ async function withRegistryLock(env, action) {
63
+ const file = registryPath(env);
64
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
65
+ const release = await acquireFileLock(`${file}.lock`, 50, "sources registry is busy");
66
+ try {
67
+ return await action();
68
+ }
69
+ finally {
70
+ await release();
71
+ }
72
+ }
73
+ export async function acquireServerLock(env = process.env) {
74
+ const file = serverLockPath(env);
75
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
76
+ return acquireFileLock(file, 1, "dd-console уже запущен для этого DD_CONSOLE_HOME");
77
+ }
78
+ async function saveSources(registry, env = process.env) {
79
+ const file = registryPath(env);
80
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
81
+ const temp = `${file}.${process.pid}.tmp`;
82
+ await writeFile(temp, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 });
83
+ await rename(temp, file);
84
+ }
85
+ export async function sourceAdd(kind, root, label, env = process.env) {
86
+ const canonical = await realpath(path.resolve(root));
87
+ await access(canonical, constants.R_OK);
88
+ if (!(await stat(canonical)).isDirectory())
89
+ throw new Error("source root must be a directory");
90
+ return withRegistryLock(env, async () => {
91
+ const registry = await loadLocalSources(env);
92
+ const existing = registry.sources.find(item => item.kind === kind && item.root === canonical);
93
+ if (existing) {
94
+ if (existing.disabled) {
95
+ existing.disabled = false;
96
+ await saveSources(registry, env);
97
+ }
98
+ return existing;
99
+ }
100
+ const inherited = (await loadSources(env)).sources.find(item => item.kind === kind && item.root === canonical);
101
+ const source = { id: inherited?.id ?? id("SRC"), kind, root: canonical, label: label ?? inherited?.label ?? path.basename(canonical), created_at: inherited?.created_at ?? now() };
102
+ registry.sources.push(source);
103
+ await saveSources(registry, env);
104
+ return source;
105
+ });
106
+ }
107
+ export async function sourceRemove(sourceId, env = process.env) {
108
+ await withRegistryLock(env, async () => {
109
+ const registry = await loadLocalSources(env);
110
+ let source = registry.sources.find(item => item.id === sourceId);
111
+ if (!source) {
112
+ const inherited = (await loadSources(env)).sources.find(item => item.id === sourceId);
113
+ if (inherited) {
114
+ source = { ...inherited };
115
+ registry.sources.push(source);
116
+ }
117
+ }
118
+ if (!source)
119
+ throw new Error(`unknown source ${sourceId}`);
120
+ source.disabled = true;
121
+ await saveSources(registry, env);
122
+ });
123
+ }
124
+ export async function ensureDefaultSources(flowHome, evalHome, env = process.env) {
125
+ const values = [["flow", flowHome ?? env.DD_FLOW_HOME ?? path.join(env.HOME ?? ".", ".dd-flow")], ["eval", evalHome ?? env.DD_EVAL_HOME ?? path.join(env.HOME ?? ".", ".dd-eval")]];
126
+ for (const [kind, root] of values) {
127
+ if (!root)
128
+ continue;
129
+ try {
130
+ await sourceAdd(kind, root, undefined, env);
131
+ }
132
+ catch (error) {
133
+ if (error.code !== "ENOENT")
134
+ throw error;
135
+ }
136
+ }
137
+ }
package/dist/util.js ADDED
@@ -0,0 +1,25 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { realpath } from "node:fs/promises";
4
+ export const now = () => new Date().toISOString();
5
+ export const id = (prefix) => `${prefix}-${crypto.randomUUID()}`;
6
+ export const safeJson = (text, fallback) => { try {
7
+ return JSON.parse(text);
8
+ }
9
+ catch {
10
+ return fallback;
11
+ } };
12
+ export const inside = (root, candidate) => {
13
+ const relative = path.relative(root, candidate);
14
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
15
+ };
16
+ export const realpathInside = async (root, candidate) => {
17
+ try {
18
+ return inside(await realpath(root), await realpath(candidate));
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ };
24
+ export const string = (value) => typeof value === "string" ? value : undefined;
25
+ export const asArray = (value) => Array.isArray(value) ? value : [];