@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.
@@ -0,0 +1,241 @@
1
+ const root = document.querySelector("#app");
2
+ let focusAfterRender = true;
3
+ let refreshTimer;
4
+ let activeRequest;
5
+ let renderGeneration = 0;
6
+ let consecutiveFailures = 0;
7
+ let currentSource;
8
+ const escape = (value) => String(value ?? "").replace(/[&<>'"]/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[char]));
9
+ const time = (value) => value ? new Date(value).toLocaleString("ru-RU") : "—";
10
+ const duration = (value) => typeof value !== "number" ? "—" : value < 60_000 ? `${Math.round(value / 1000)} с` : value < 3_600_000 ? `${Math.floor(value / 60_000)} мин ${Math.round(value % 60_000 / 1000)} с` : `${Math.floor(value / 3_600_000)} ч ${Math.round(value % 3_600_000 / 60_000)} мин`;
11
+ const tokens = (usage) => `${usage?.input_tokens?.toLocaleString("ru-RU") ?? "—"} (${usage?.cached_input_tokens?.toLocaleString("ru-RU") ?? "—"}) / ${usage?.output_tokens?.toLocaleString("ru-RU") ?? "—"} = ${usage?.total_tokens?.toLocaleString("ru-RU") ?? "—"}`;
12
+ const route = () => location.pathname.split("/").filter(Boolean).map(decodeURIComponent);
13
+ const routeKey = () => `${location.pathname}${location.search}`;
14
+ const scrollKey = (key = routeKey()) => `dd-console:scroll:${key}`;
15
+ const rememberScroll = () => sessionStorage.setItem(scrollKey(), String(window.scrollY));
16
+ const savedScroll = () => Number(sessionStorage.getItem(scrollKey()) ?? "0") || 0;
17
+ const evalContext = () => {
18
+ const source = location.pathname.split("/")[2] ?? "";
19
+ if (!source.startsWith("drv."))
20
+ return null;
21
+ try {
22
+ const encoded = source.slice(4).replace(/-/g, "+").replace(/_/g, "/");
23
+ const [parent, evalId, execution] = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(encoded), char => char.charCodeAt(0))));
24
+ return typeof parent === "string" && typeof evalId === "string" && typeof execution === "string" ? { source: parent, evalId, execution } : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ };
30
+ const withContext = (url, context = evalContext()) => !context ? url : `${url}${url.includes("?") ? "&" : "?"}fromEval=${encodeURIComponent(context.evalId)}&evalSource=${encodeURIComponent(context.source)}`;
31
+ function contextCrumbs() { const context = evalContext(); if (!context)
32
+ return []; const base = `/sources/${encodeURIComponent(context.source)}/evals/${encodeURIComponent(context.evalId)}`; return [[base, `EVAL ${context.evalId}`], [`${base}/executions/${encodeURIComponent(context.execution)}`, `Execution ${context.execution}`]]; }
33
+ async function api(url) {
34
+ const endpoint = url.startsWith("/api/") ? url : `/api/v1${url}`;
35
+ const response = await fetch(endpoint, { headers: { accept: "application/json" }, signal: activeRequest?.signal });
36
+ const body = await response.json();
37
+ if (!response.ok)
38
+ throw Object.assign(new Error(body.data?.message ?? "Ошибка чтения"), { status: response.status });
39
+ currentSource = body.source;
40
+ return body;
41
+ }
42
+ function link(path, label, current = false) { return `<a href="${escape(path)}" ${current ? 'aria-current="page"' : ""}>${escape(label)}</a>`; }
43
+ function shell(title, body, crumbs = []) {
44
+ const nav = location.pathname.startsWith("/eval") || location.pathname.includes("/evals/") ? "evals" : location.pathname.includes("/projects/") || location.pathname === "/flows" ? "flows" : location.pathname === "/sources" ? "sources" : "overview";
45
+ const position = savedScroll();
46
+ const focusedLink = !focusAfterRender && document.activeElement instanceof HTMLAnchorElement ? document.activeElement.getAttribute("href") : null;
47
+ const observation = currentSource ? `<div class="observation">Источник: ${status(currentSource.availability)} · состояние: ${time(currentSource.source_updated_at)}</div>` : "";
48
+ root.innerHTML = `<div class="shell"><nav class="side"><div class="brand">dd-console</div>${link("/", "Обзор", nav === "overview")}${link("/flows", "Flows", nav === "flows")}${link("/evals", "Evals", nav === "evals")}${link("/sources", "Источники", nav === "sources")}</nav><main id="main" tabindex="-1" class="content"><nav class="crumbs" aria-label="Breadcrumbs">${link("/", "Обзор", !crumbs.length)}${crumbs.map(([path, label], index) => `<span>›</span>${link(path, label, index === crumbs.length - 1)}`).join("")}</nav><header class="headline"><div><h1 tabindex="-1">${escape(title)}</h1>${observation}</div></header>${new URLSearchParams(location.search).has("fromEval") && !evalContext() ? "<div class=notice>Контекст Eval не подтверждён. Показана каноническая навигация Flow.</div>" : ""}${body}</main></div>`;
49
+ const heading = document.querySelector("h1");
50
+ if (focusAfterRender)
51
+ heading?.focus({ preventScroll: true });
52
+ else if (focusedLink)
53
+ Array.from(root.querySelectorAll("a[href]")).find(anchor => anchor.getAttribute("href") === focusedLink)?.focus({ preventScroll: true });
54
+ requestAnimationFrame(() => window.scrollTo({ top: position }));
55
+ }
56
+ function diagnostics(items) { return items.length ? `<div class="${items.some(item => item.severity === "error") ? "error" : "notice"}">${items.map(item => escape(item.message)).join("<br>")}</div>` : ""; }
57
+ function status(value) { return `<span class="status ${escape(value)}">${escape(value)}</span>`; }
58
+ function table(title, items, kind) {
59
+ if (!items.length)
60
+ return `<section class="panel"><h2>${title}</h2><p class="muted">Нет доступных записей.</p></section>`;
61
+ const rows = items.map(item => kind === "flow"
62
+ ? `<tr><td>${link(`/sources/${encodeURIComponent(item.source_id)}/projects/${encodeURIComponent(item.project_id)}/runs/${encodeURIComponent(item.run_id)}`, item.run_id)}</td><td>${escape(item.project_id)}<br>${escape(item.flow_kind ?? "—")}<br><span class="muted">${escape(item.source_label ?? item.source_id)} · ${item.origin === "eval" ? "Eval" : "Самостоятельный"}</span>${item.eval_id ? `<br>${link(`/sources/${encodeURIComponent(item.eval_source_id)}/evals/${encodeURIComponent(item.eval_id)}`, item.eval_id)}<br>${link(`/sources/${encodeURIComponent(item.eval_source_id)}/evals/${encodeURIComponent(item.eval_id)}/executions/${encodeURIComponent(item.execution_id)}`, item.execution_id)}` : ""}</td><td>${status(item.status)}</td><td>${time(item.updated_at)}</td></tr>`
63
+ : `<tr><td>${link(`/sources/${encodeURIComponent(item.source_id)}/evals/${encodeURIComponent(item.eval_id)}`, item.eval_id)}</td><td>${escape(item.case_id ?? "—")}${item.kind === "derived" ? `<br><span class="muted">Производный · от ${escape(item.derived_from?.eval_id ?? "неизвестного Eval")} / ${escape(item.derived_from?.execution_id ?? "—")} · ${escape(item.derived_from?.stage ?? "стадия неизвестна")}</span>` : ""}</td><td>${status(item.status)}</td><td>${time(item.updated_at)}</td></tr>`).join("");
64
+ return `<section class="panel"><h2>${title}</h2><table class="table"><thead><tr><th>ID</th><th>Контекст</th><th>Статус</th><th>Обновлено</th></tr></thead><tbody>${rows}</tbody></table></section>`;
65
+ }
66
+ async function overview() {
67
+ const result = await api("/api/v1/overview");
68
+ shell("Обзор", `${diagnostics(result.diagnostics)}<div class="grid"><section class="card"><h2>Токены</h2><div class="metric">${tokens(result.data.usage)}</div><p class="muted">${escape(result.data.usage.status)} · по измеренным Flow</p></section><section class="card"><h2>Flows</h2><div class="metric">${result.data.flows.length}</div><p class="muted">последние доступные запуски</p></section><section class="card"><h2>Evals</h2><div class="metric">${result.data.evals.length}</div><p class="muted">последние доступные оценки</p></section><section class="card"><h2>Источники</h2><div class="metric">${result.data.sources.length}</div><p class="muted">подключено к локальной консоли</p></section></div><div class="line">Последнее подтверждённое состояние показывается из сохранённых артефактов. Отсутствие нового события не означает зависание.</div>${table("Flows", result.data.flows, "flow")}${table("Evals", result.data.evals, "eval")}`);
69
+ }
70
+ function filters(kind) {
71
+ const params = new URLSearchParams(location.search);
72
+ const field = (key, label) => `<label>${label}<input name="${key}" value="${escape(params.get(key))}"></label>`;
73
+ const select = (key, label, options, fallback) => `<label>${label}<select name="${key}">${options.map(([value, text]) => `<option value="${value}" ${(params.get(key) ?? fallback) === value ? "selected" : ""}>${text}</option>`).join("")}</select></label>`;
74
+ return `<form class="filters" data-filters>${field("q", "Поиск")}${field("status", "Статус")}${field("source", "Источник (ID)")}${kind === "flow" ? select("origin", "Запущен через", [["", "Все"], ["eval", "Eval"], ["standalone", "Самостоятельно"]], "") + field("project", "Проект") + field("kind", "Вид Flow") : field("case", "Case")}${select("sort", "Сортировка", [["updated_at", "Обновлено"], ["created_at", "Создано"], ["status", "Статус"], [kind === "flow" ? "run_id" : "eval_id", "ID"]], "updated_at")}${select("order", "Порядок", [["desc", "По убыванию"], ["asc", "По возрастанию"]], "desc")}<button type="submit">Применить</button>${link(location.pathname, "Сбросить")}</form>`;
75
+ }
76
+ function nextPage(cursor) { if (!cursor)
77
+ return ""; const query = new URLSearchParams(location.search); query.set("cursor", cursor); return `<div class="line">${link(`${location.pathname}?${query}`, "Следующая страница")}</div>`; }
78
+ async function flows() { const result = await api(`/api/v1/flows${location.search}`); shell("Flows", `${diagnostics(result.diagnostics)}${filters("flow")}${table(`Все доступные Flow · ${result.data.total}`, result.data.items, "flow")}${nextPage(result.data.next_cursor)}`, [["/flows", "Flows"]]); }
79
+ async function evals() { const result = await api(`/api/v1/evals${location.search}`); shell("Evals", `${diagnostics(result.diagnostics)}${filters("eval")}${table(`Все доступные Eval · ${result.data.total}`, result.data.items, "eval")}${nextPage(result.data.next_cursor)}`, [["/evals", "Evals"]]); }
80
+ async function sources() {
81
+ const result = await api("/api/v1/sources");
82
+ shell("Источники", `${diagnostics(result.diagnostics)}<div class="items">${result.data.map(item => `<section class="item"><strong>${escape(item.label)}</strong><span class="muted">${escape(item.kind)} · последнее состояние ${time(item.source_updated_at)}</span><div>${status(item.availability)}</div>${item.detail ? `<p class="muted">${escape(item.detail)}</p>` : ""}</section>`).join("") || "<div class=empty>Источники ещё не подключены. Выполните dd-console sources add --kind flow|eval --path &lt;path&gt;.</div>"}</div>`, [["/sources", "Источники"]]);
83
+ }
84
+ async function flow(parts) {
85
+ const [, source, , project, , run] = parts, base = `/sources/${encodeURIComponent(source)}/projects/${encodeURIComponent(project)}/runs/${encodeURIComponent(run)}`, result = await api(base), data = result.data, context = evalContext();
86
+ const parentCrumbs = contextCrumbs();
87
+ const works = data.works.map((item) => `<section class="item"><strong>${link(withContext(`${base}/works/${encodeURIComponent(item.id)}`), item.id)} · ${status(item.status)}</strong><span>${escape(item.task ?? "")}</span><div class="muted">${escape((item.dependencies ?? []).join(", ") || "без зависимостей")}</div></section>`).join("") || "<p class=muted>Работы не зарегистрированы.</p>";
88
+ const checks = data.checks.length ? `<div class=items>${data.checks.map((item) => `<div class=item><strong>${link(withContext(`${base}/checks/${encodeURIComponent(item.id)}`), item.status)} · ${escape(item.command)}</strong><span class=muted>${time(item.finished_at)}</span></div>`).join("")}</div>` : "<p class=muted>Проверки отсутствуют.</p>";
89
+ const stages = data.stages.length ? `<div class=items>${data.stages.map((item) => `<div class=item><strong>${link(withContext(`${base}/stages/${encodeURIComponent(item.stage)}/attempts/${encodeURIComponent(item.attempt)}`), `${item.stage} / ${item.attempt}`)} · ${status(item.status ?? "unknown")}</strong><span class=muted>${time(item.started_at)} → ${time(item.completed_at)} · ${duration(item.duration_ms)}</span><span>Токены: ${tokens(item.usage)} · ${escape(item.usage?.status ?? "unavailable")}</span></div>`).join("")}</div>` : "<p class=muted>Стадии не зарегистрированы.</p>";
90
+ shell(data.run_id, `<div class=line>Проект: ${escape(data.project_id)}</div>${diagnostics(result.diagnostics)}${context ? `<div class=line>${link(`/sources/${encodeURIComponent(context.source)}/evals/${encodeURIComponent(context.evalId)}`, "Вернуться к Eval")}</div>` : ""}<div class=line>${status(data.status)} ${escape(data.stage ?? "нет активной стадии")} · ${escape(data.next_action ?? "нет следующего действия")}</div><div class=grid><section class=card><h2>Токены RUN</h2><div class="metric usage">${tokens(data.usage)}</div><p class="muted">${escape(data.usage?.status ?? "unavailable")} · сессий: ${escape(data.usage?.session_count ?? 0)}</p></section><section class=card><h2>Сессии</h2><div class="items">${data.sessions.map((item) => `<div>${link(withContext(`${base}/sessions/${encodeURIComponent(item.id)}`), item.id)}<br><span class=muted>${tokens(item.usage)} · ${escape(item.usage?.status ?? "unavailable")}</span></div>`).join("") || "—"}</div></section><section class=card><h2>Проверки</h2><div class=metric>${data.checks.length}</div></section><section class=card><h2>Обновлено</h2><p>${time(data.updated_at)}</p></section></div><div class=tabs>${link(withContext(base), "Обзор", true)}${link(withContext(`${base}/events`), "События")}</div><section class=panel><h2>Стадии и попытки</h2>${stages}</section><section class=panel><h2>Работы и зависимости</h2><div class=items>${works}</div></section><section class=panel><h2>Проверки</h2>${checks}</section>`, [["/flows", "Flows"], ...parentCrumbs, [withContext(base), data.run_id]]);
91
+ }
92
+ async function stageAttempt(parts) {
93
+ const base = `/${parts.slice(0, 6).map(encodeURIComponent).join("/")}`, stage = parts[7], attempt = parts[9], result = await api(`${base}/stages/${encodeURIComponent(stage)}/attempts/${encodeURIComponent(attempt)}`), data = result.data;
94
+ const attemptLink = (value, label) => value ? link(withContext(`${base}/stages/${encodeURIComponent(data.stage)}/attempts/${encodeURIComponent(value)}`), label) : "";
95
+ const related = `<section class=panel><h2>Работы попытки</h2>${(data.work_ids ?? []).map((id) => link(withContext(`${base}/works/${encodeURIComponent(id)}`), id)).join(" · ") || "Явные связи не сохранены."}<h2>Проверки попытки</h2>${(data.check_ids ?? []).map((id) => link(withContext(`${base}/checks/${encodeURIComponent(id)}`), id)).join(" · ") || "Явные связи не сохранены."}</section>`;
96
+ shell(`${data.stage} / ${data.attempt}`, `${diagnostics(result.diagnostics)}<div class=line>${status(data.status ?? "unknown")}</div><div class=tabs>${attemptLink(data.previous_attempt, "Предыдущая попытка")}${attemptLink(data.next_attempt, "Следующая попытка")}</div>${related}<section class=panel><h2>Детали попытки</h2><div class=items>${Object.entries(data).filter(([key]) => !["previous_attempt", "next_attempt", "work_ids", "check_ids"].includes(key)).map(([key, value]) => `<div class=item><strong>${escape(key)}</strong><span>${escape(value)}</span></div>`).join("")}</div></section>`, [["/flows", "Flows"], ...contextCrumbs(), [withContext(base), "RUN"], [withContext(location.pathname), `${data.stage} / ${data.attempt}`]]);
97
+ }
98
+ async function entity(parts) {
99
+ const base = `/${parts.slice(0, 6).map(encodeURIComponent).join("/")}`, kind = parts[6], entityId = parts[7], result = await api(`${base}/${kind}/${encodeURIComponent(entityId)}`), data = result.data, context = evalContext();
100
+ const title = kind === "works" ? `Работа ${data.id}` : kind === "sessions" ? `Сессия ${data.id}` : `Проверка ${data.id}`;
101
+ const lines = Object.entries(data).filter(([key]) => !["id", "result", "works", "checks", "session_ids", "dependencies", "parent_id", "work_id", "work_ids", "attempts"].includes(key)).map(([key, value]) => `<div class=item><strong>${escape(key)}</strong><span>${escape(typeof value === "object" ? JSON.stringify(value) : value)}</span></div>`).join("");
102
+ const artifact = kind === "checks" ? `<div class=line>${["receipt", "stdout", "stderr"].map(part => link(withContext(`${base}/artifacts/${encodeURIComponent(data.id)}?part=${part}`), part)).join(" · ")}</div>` : "";
103
+ const related = (kind, ids, label) => ids?.length ? `<section class=panel><h2>${label}</h2>${ids.map(id => link(withContext(`${base}/${kind}/${encodeURIComponent(id)}`), id)).join(" · ")}</section>` : "";
104
+ const relations = (data.attempts?.length ? `<section class=panel><h2>Попытки стадии</h2>${data.attempts.map((item) => link(withContext(`${base}/stages/${encodeURIComponent(item.stage)}/attempts/${encodeURIComponent(item.attempt)}`), `${item.stage} / ${item.attempt}`)).join(" · ")}</section>` : "") + related("works", [data.parent_id, data.work_id].filter(Boolean), "Родительская работа") + related("works", data.dependencies ?? [], "Зависимости") + related("sessions", data.session_ids ?? [], "Сессии работы") + related("checks", (data.checks ?? []).map((check) => check.id), "Проверки работы");
105
+ const parentCrumbs = contextCrumbs();
106
+ shell(title, `${diagnostics(result.diagnostics)}<div class=line>${status(data.status ?? "unknown")}</div>${artifact}${relations}<section class=panel><h2>Детали</h2><div class=items>${lines}</div>${data.result ? `<pre>${escape(data.result)}</pre>` : ""}${data.works?.length ? `<h2>Связанные работы</h2><div class=items>${data.works.map((item) => `<div class=item>${link(withContext(`${base}/works/${encodeURIComponent(item.id)}`), item.id)} · ${escape(item.status)}</div>`).join("")}</div>` : ""}</section>`, [["/flows", "Flows"], ...parentCrumbs, [withContext(base), "RUN"], [withContext(location.pathname), title]]);
107
+ }
108
+ async function artifactDetail(parts) {
109
+ const base = `/${parts.slice(0, 6).map(encodeURIComponent).join("/")}`, id = parts[7], query = new URLSearchParams(location.search), part = query.get("part") ?? "receipt";
110
+ const endpoint = `${base}/artifacts/${encodeURIComponent(id)}`, result = await api(`${endpoint}?part=${encodeURIComponent(part)}`), data = result.data;
111
+ let preview = "<p>Предпросмотр для этого типа недоступен.</p>";
112
+ if (data.preview_available) {
113
+ const value = (await api(`${endpoint}/preview?part=${encodeURIComponent(part)}&offset=${encodeURIComponent(query.get("offset") ?? "0")}`)).data;
114
+ const next = new URLSearchParams(query);
115
+ if (value.next_offset !== null)
116
+ next.set("offset", String(value.next_offset));
117
+ preview = `<pre class=artifact>${escape(value.text)}</pre>${value.truncated ? `<p>Показан фрагмент до 256 KiB.</p>${link(`${location.pathname}?${next}`, "Следующий фрагмент")}` : ""}`;
118
+ }
119
+ shell(data.name, `<div class=line>${escape(data.bytes)} байт · ${escape(part)}</div><p><a href="/api/v1${endpoint}/download?part=${encodeURIComponent(part)}" download>Скачать</a></p><section class=panel>${preview}</section>`, [["/flows", "Flows"], ...contextCrumbs(), [withContext(base), "RUN"], [withContext(`${base}/checks/${encodeURIComponent(id)}`), `Проверка ${id}`], [withContext(location.pathname + `?part=${encodeURIComponent(part)}`), data.name]]);
120
+ }
121
+ function eventNotice(value) { return `${value.truncated ? "<p class=muted>Более ранняя история доступна на следующей странице.</p>" : ""}${value.pending_tail ? "<p class=notice>Последняя строка ещё записывается; она появится после завершения записи.</p>" : ""}`; }
122
+ async function events(parts) {
123
+ const base = `/${parts.slice(0, 6).map(encodeURIComponent).join("/")}`, result = await api(`${base}/events${location.search}`), context = evalContext(), parentCrumbs = contextCrumbs();
124
+ shell("События Flow", `${diagnostics(result.diagnostics)}<section class=panel><h2>Последние события</h2><div class=items>${result.data.items.map(item => `<div class="item event"><strong>${escape(item.type)}</strong><span>${escape(item.time ?? "")} ${escape(item.status ?? "")} ${escape(item.message ?? "")}</span></div>`).join("") || "<p class=muted>События отсутствуют.</p>"}</div>${eventNotice(result.data)}${nextPage(result.data.next_cursor)}</section>`, [["/flows", "Flows"], ...parentCrumbs, [withContext(base), "RUN"], [withContext(location.pathname), "События"]]);
125
+ }
126
+ async function evalDetail(parts) {
127
+ const [, source, , evalId] = parts, result = await api(`/api/v1/sources/${encodeURIComponent(source)}/evals/${encodeURIComponent(evalId)}`), data = result.data;
128
+ const base = `/sources/${encodeURIComponent(source)}/evals/${encodeURIComponent(evalId)}`;
129
+ const executions = data.executions.map((item) => `<div class=item><strong>${link(`${base}/executions/${encodeURIComponent(item.id)}`, item.id)} · ${status(item.state)}</strong><span class=muted>configured: ${escape(item.configured_model ?? "не зафиксирована")} · observed: ${escape(item.observed_model ?? "не подтверждена")}</span>${item.flow ? `<div>${link(`/sources/${encodeURIComponent(item.flow.source_id)}/projects/${encodeURIComponent(item.flow.project_id)}/runs/${encodeURIComponent(item.flow.run_id)}?fromEval=${encodeURIComponent(evalId)}&evalSource=${encodeURIComponent(source)}`, "Открыть Flow")}</div>` : "<div class=muted>Вложенный Flow недоступен.</div>"}</div>`).join("") || "<p class=muted>Execution не найдено.</p>";
130
+ const candidate = data.candidate ? `<div class=item><strong>${escape(data.candidate.outcome ?? "unknown")}</strong><span>${escape(data.candidate.frozen_at ?? "не зафиксирован")}</span>${data.candidate.executions.map((item) => `<span class=muted>${escape(item.id)} · ${escape(item.outcome ?? "unknown")} · ${escape(item.evidence_completeness ?? "evidence неизвестно")}${item.failure_code ? ` · ${escape(item.failure_code)}` : ""}</span>`).join("")}</div>` : "<p class=muted>Candidate ещё не зафиксирован.</p>";
131
+ const judge = data.report ? `<div class=item><strong>${escape(data.report.state ?? "unknown")}</strong><span>${escape(data.report.run_validity ?? "validity неизвестна")} · ${escape(data.report.reliability ?? "reliability неизвестна")}</span><span class=muted>recovery: ${escape(data.report.recovery_count ?? "—")}</span></div>` : "<p class=muted>Judge/report ещё не опубликован.</p>";
132
+ const lineage = data.kind === "derived" ? `<section class=panel><h2>Происхождение</h2><div class=item><strong>Производный Eval</strong><span>Источник: ${escape(data.derived_from?.eval_id ?? "неизвестен")} / ${escape(data.derived_from?.execution_id ?? "—")}</span><span class=muted>Стартовая стадия: ${escape(data.derived_from?.stage ?? "неизвестна")} · snapshot ${escape(data.derived_from?.checkpoint_sha256 ?? "не зафиксирован")}</span></div></section>` : "";
133
+ shell(data.eval_id, `${diagnostics(result.diagnostics)}<div class=line>${status(data.status)} · case ${escape(data.case_id ?? "—")} · checkpoint ${escape(data.checkpoint ?? "—")}</div><div class="grid"><section class=card><h2>Профиль</h2><p>${escape(data.manifest.profile_id ?? "не зафиксирован")}</p></section><section class=card><h2>Definition</h2><p>${escape(data.manifest.definition_commit ?? "не зафиксирован")}</p></section><section class=card><h2>Judge</h2><p>${data.manifest.judge_enabled === true ? "включён" : data.manifest.judge_enabled === false ? "выключен" : "неизвестно"}</p></section></div>${lineage}<section class=panel><h2>Executions</h2><div class=items>${executions}</div></section><section class=panel><h2>Candidate и evidence</h2><div class=items>${candidate}</div></section><section class=panel><h2>Judge / report</h2><div class=items>${judge}</div></section><section class=panel><h2>Последние события</h2><div class=items>${data.events.slice(0, 10).map((item) => `<div class="item event"><strong>${escape(item.type)}</strong><span>${escape(item.time ?? "")} ${escape(item.status ?? "")}</span></div>`).join("") || "<p class=muted>Журнал недоступен.</p>"}</div>${link(`${base}/events`, "Открыть историю событий")}</section>`, [["/evals", "Evals"], [location.pathname, data.eval_id]]);
134
+ }
135
+ async function evalEvents(parts) {
136
+ const [, source, , evalId] = parts, base = `/sources/${encodeURIComponent(source)}/evals/${encodeURIComponent(evalId)}`, result = await api(`${base}/events${location.search}`);
137
+ shell("События Eval", `${diagnostics(result.diagnostics)}<section class=panel><h2>События</h2><div class=items>${result.data.items.map(item => `<div class="item event"><strong>${escape(item.type)}</strong><span>${escape(item.time ?? "")} ${escape(item.status ?? "")}</span></div>`).join("") || "<p class=muted>Журнал недоступен.</p>"}</div>${eventNotice(result.data)}${nextPage(result.data.next_cursor)}</section>`, [["/evals", "Evals"], [base, evalId], [location.pathname, "События"]]);
138
+ }
139
+ async function executionDetail(parts) {
140
+ const [, source, , evalId, , executionId] = parts, base = `/sources/${encodeURIComponent(source)}/evals/${encodeURIComponent(evalId)}`, result = await api(`${base}/executions/${encodeURIComponent(executionId)}`), data = result.data;
141
+ const candidate = data.candidate ? `<section class=panel><h2>Evidence candidate</h2><div class=items><div class=item><strong>${escape(data.candidate.outcome ?? "unknown")}</strong><span>${escape(data.candidate.stage ?? "стадия неизвестна")} · ${escape(data.candidate.evidence_completeness ?? "evidence неизвестно")}</span>${data.candidate.failure_code ? `<span class=muted>${escape(data.candidate.failure_code)}</span>` : ""}</div></div></section>` : "";
142
+ shell(`Execution ${data.execution.id}`, `${diagnostics(result.diagnostics)}<div class=line>${status(data.execution.state)} · configured: ${escape(data.execution.configured_model ?? "не зафиксирована")} · observed: ${escape(data.execution.observed_model ?? "не подтверждена")}</div>${data.execution.flow ? `<div class=line>${link(`/sources/${encodeURIComponent(data.execution.flow.source_id)}/projects/${encodeURIComponent(data.execution.flow.project_id)}/runs/${encodeURIComponent(data.execution.flow.run_id)}?fromEval=${encodeURIComponent(evalId)}&evalSource=${encodeURIComponent(source)}`, "Открыть вложенный Flow")}</div>` : ""}${candidate}<section class=panel><h2>События execution</h2><div class=items>${data.events.map((item) => `<div class="item event"><strong>${escape(item.type)}</strong><span>${escape(item.time ?? "")} ${escape(item.status ?? "")}</span></div>`).join("") || "<p class=muted>События execution не зафиксированы.</p>"}</div></section>`, [["/evals", "Evals"], [base, evalId], [location.pathname, `Execution ${data.execution.id}`]]);
143
+ }
144
+ async function render() {
145
+ const generation = ++renderGeneration;
146
+ activeRequest?.abort();
147
+ activeRequest = new AbortController();
148
+ currentSource = undefined;
149
+ let failed = false;
150
+ try {
151
+ const parts = route();
152
+ if (!parts.length)
153
+ await overview();
154
+ else if (parts[0] === "flows")
155
+ await flows();
156
+ else if (parts[0] === "evals")
157
+ await evals();
158
+ else if (parts[0] === "sources" && parts[2] === "projects" && parts[6] === "artifacts")
159
+ await artifactDetail(parts);
160
+ else if (parts[0] === "sources" && parts[2] === "projects" && parts[6] === "stages")
161
+ await stageAttempt(parts);
162
+ else if (parts[0] === "sources" && parts[2] === "projects" && ["works", "sessions", "checks"].includes(parts[6] ?? ""))
163
+ await entity(parts);
164
+ else if (parts[0] === "sources" && parts[2] === "projects" && parts[6] === "events")
165
+ await events(parts);
166
+ else if (parts[0] === "sources" && parts[2] === "projects")
167
+ await flow(parts);
168
+ else if (parts[0] === "sources" && parts[2] === "evals" && parts[4] === "executions")
169
+ await executionDetail(parts);
170
+ else if (parts[0] === "sources" && parts[2] === "evals" && parts[4] === "events")
171
+ await evalEvents(parts);
172
+ else if (parts[0] === "sources" && parts[2] === "evals")
173
+ await evalDetail(parts);
174
+ else if (parts[0] === "sources")
175
+ await sources();
176
+ else
177
+ throw new Error("Маршрут не найден.");
178
+ }
179
+ catch (error) {
180
+ if (error instanceof DOMException && error.name === "AbortError")
181
+ return;
182
+ if (error.status === 409) {
183
+ const query = new URLSearchParams(location.search);
184
+ query.delete("cursor");
185
+ shell("Страница устарела", `<div class=notice>Источник изменился или срок страницы истёк. Фильтры сохранены.</div>${link(`${location.pathname}?${query}`, "Начать чтение заново")}`);
186
+ return;
187
+ }
188
+ failed = true;
189
+ shell("Не удалось открыть экран", `<div class=error>${escape(error instanceof Error ? error.message : String(error))}</div>`);
190
+ }
191
+ finally {
192
+ if (generation === renderGeneration) {
193
+ consecutiveFailures = failed ? consecutiveFailures + 1 : 0;
194
+ scheduleRefresh();
195
+ }
196
+ }
197
+ }
198
+ function scheduleRefresh() {
199
+ window.clearTimeout(refreshTimer);
200
+ const detail = route()[0] === "sources", baseDelay = detail ? 2_000 : 5_000, delay = Math.min(30_000, baseDelay * 2 ** Math.min(consecutiveFailures, 4));
201
+ refreshTimer = window.setTimeout(() => { if (!document.hidden && !location.search.includes("cursor=") && !document.activeElement?.closest("form")) {
202
+ rememberScroll();
203
+ focusAfterRender = false;
204
+ void render();
205
+ }
206
+ else
207
+ scheduleRefresh(); }, delay);
208
+ }
209
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) {
210
+ rememberScroll();
211
+ focusAfterRender = false;
212
+ void render();
213
+ } });
214
+ document.addEventListener("submit", event => {
215
+ const form = event.target;
216
+ if (!form.matches("[data-filters]"))
217
+ return;
218
+ event.preventDefault();
219
+ const query = new URLSearchParams();
220
+ new FormData(form).forEach((value, key) => { if (String(value))
221
+ query.set(key, String(value)); });
222
+ rememberScroll();
223
+ focusAfterRender = true;
224
+ history.pushState({}, "", `${location.pathname}?${query}`);
225
+ void render();
226
+ });
227
+ document.addEventListener("click", event => {
228
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
229
+ return;
230
+ const target = event.target.closest("a[href]");
231
+ if (target && !target.hash && !target.hasAttribute("download") && target.origin === location.origin && !target.target && !target.pathname.startsWith("/api/")) {
232
+ event.preventDefault();
233
+ rememberScroll();
234
+ focusAfterRender = true;
235
+ history.pushState({}, "", target.href);
236
+ void render();
237
+ }
238
+ });
239
+ window.addEventListener("popstate", () => { focusAfterRender = true; void render(); });
240
+ void render();
241
+ export {};
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>dd-console</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ </head>
9
+ <body>
10
+ <a class="skip" href="#main">К содержимому</a>
11
+ <div id="app"></div>
12
+ <script type="module" src="/app.js"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1,2 @@
1
+ :root{color-scheme:light;font-family:ui-sans-serif,system-ui,sans-serif;color:#18212b;background:#f5f7fa}*{box-sizing:border-box}body{margin:0}.skip{position:absolute;left:-999px}.skip:focus{left:1rem;top:1rem;z-index:10;background:#fff;padding:.6rem;border:2px solid #3157a8}.shell{display:grid;grid-template-columns:224px minmax(0,1fr);min-height:100vh}.side{padding:1.25rem;border-right:1px solid #d8dfe8;background:#fff}.brand{font-weight:800;font-size:1.2rem;margin-bottom:2rem}.side a{display:block;padding:.6rem .7rem;border-radius:.4rem;color:#344052;text-decoration:none;margin:.2rem 0}.side a[aria-current]{background:#e7edf9;color:#1d4695;font-weight:700}.content{max-width:1440px;width:100%;margin:auto;padding:1.5rem}.crumbs{display:flex;gap:.45rem;flex-wrap:wrap;color:#5d6875;font-size:.9rem}.crumbs a{color:#3157a8}.headline{display:flex;justify-content:space-between;align-items:start;gap:1rem;border-bottom:1px solid #d8dfe8;padding:1rem 0}.headline h1{margin:0;font-size:1.7rem}.observation{color:#5d6875;font-size:.85rem;text-align:right}.subtitle{color:#5d6875;margin:.35rem 0 0}.status{font-size:.83rem;padding:.3rem .55rem;border-radius:999px;background:#e7edf9;color:#1d4695}.status.failed,.status.blocked{background:#f9e3e3;color:#a33a3a}.status.waiting,.status.unknown{background:#fff1d7;color:#8a5300}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem;margin:1rem 0}.card,.panel{background:#fff;border:1px solid #d8dfe8;border-radius:.55rem;padding:1rem}.card h2,.panel h2{font-size:1rem;margin:0 0 .65rem}.metric{font-size:1.5rem;font-weight:800}.metric.usage{font-size:1.05rem;line-height:1.35}.line{border-left:3px solid #3157a8;padding:.5rem .75rem;background:#eef3fc;margin:1rem 0}.table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #d8dfe8;border-radius:.55rem;overflow:hidden}.table th,.table td{text-align:left;padding:.7rem;border-bottom:1px solid #e7ebf0;vertical-align:top}.table th{font-size:.78rem;color:#5d6875;text-transform:uppercase;letter-spacing:.04em}.table a{color:#1d4695;font-weight:650}.empty{padding:2rem;background:#fff;border:1px dashed #b9c3d0;border-radius:.55rem;color:#5d6875}.muted{color:#5d6875}.notice{padding:.8rem 1rem;border-left:3px solid #9a5d00;background:#fff7e8;margin:1rem 0}.error{padding:.8rem 1rem;border-left:3px solid #a33a3a;background:#fff1f1;margin:1rem 0}.tabs{display:flex;gap:.4rem;margin:1rem 0;overflow:auto}.tabs a{white-space:nowrap;padding:.45rem .7rem;border:1px solid #d8dfe8;border-radius:.4rem;text-decoration:none;color:#344052}.tabs a[aria-current]{background:#18212b;color:#fff;border-color:#18212b}.items{display:grid;gap:.65rem}.item{padding:.75rem;border:1px solid #e0e5eb;border-radius:.4rem}.item strong{display:block}.event{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.84rem}.right{font-size:.85rem;color:#5d6875;text-align:right}@media(max-width:700px){.shell{display:block}.side{display:flex;gap:.4rem;overflow:auto;border-right:0;border-bottom:1px solid #d8dfe8;padding:.65rem}.brand{margin:0 .5rem 0 0}.side a{white-space:nowrap}.content{padding:1rem}.headline{display:block}.observation{text-align:left;margin-top:.5rem}.status{display:inline-block;margin-top:.6rem}.table th:nth-child(n+4),.table td:nth-child(n+4){display:none}}@media(prefers-reduced-motion:no-preference){.line{animation:pulse 2s ease-in-out infinite}@keyframes pulse{50%{border-color:#7897d2}}}
2
+ .filters{display:flex;flex-wrap:wrap;gap:.75rem;align-items:end;margin:1rem 0}.filters label{display:grid;gap:.25rem;font-size:.85rem}.filters input,.filters select,.filters button{font:inherit;min-height:2.3rem;max-width:100%;padding:.4rem;border:1px solid #b9c3d0;border-radius:.3rem;background:white;color:inherit}.filters button{cursor:pointer}.item,.crumbs,.headline{overflow-wrap:anywhere}.artifact,pre{white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%}.grid{grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr))}.table{table-layout:fixed}.table td{overflow-wrap:anywhere}
@@ -0,0 +1,20 @@
1
+ # Приёмка исправлений ревью — 2026-09-13
2
+
3
+ Среда: macOS, Node 26.7.0. Проверена закреплённая связка producers из `test/fixtures/contracts/provenance.json`.
4
+
5
+ | Проверка | Результат |
6
+ | --- | --- |
7
+ | `npm run check` | Пройдено: TypeScript, 25 Node integration/contract tests и 18 Playwright tests |
8
+ | Браузеры | Chromium и WebKit: прямые ссылки, breadcrumbs, Back, клавиатура, polling, filters, экран артефакта, связанная работа/сессия/check/attempt, viewport 320 px |
9
+ | Согласованность Flow | 60 чтений при отдельном WAL writer и checkpoint: status RUN и Work всегда из одной транзакции |
10
+ | История | 550 записей больше 256 KiB, все страницы без пропусков/дубликатов; append, partial, rotate/truncate, oversized/invalid record |
11
+ | Безопасность | Traversal, symlink файла/каталога, подмена после открытия, единая metadata/preview/download policy, foreign context и наблюдение чужого Eval |
12
+ | Состояния producers | Захваченная настоящая схема Flow; failure, awaiting_provider и recovered execution из observationProjection Eval |
13
+ | Производительность | 100 MiB journal, три клиента, `/healthz`; чтение ограничено бюджетом страницы, вся история доступна продолжением |
14
+ | Кеш и locks | Сохранение времени исходного чтения, явная stale-диагностика, отказ второго сервера, повторный запуск при старом/неполном lock-файле |
15
+ | Доступность | Agent-browser/axe на списке Eval: WCAG 2 A/AA, 0 нарушений и 0 incomplete |
16
+ | Поставка | `npm run pack:check`: tarball → отдельная npm install → запуск CLI из другого cwd, native dependency, assets/API и SIGTERM |
17
+
18
+ SQLite не копируется. `readOnly` и `query_only` запрещают запись в данные; короткая read transaction согласует запросы детали. SHM read-marks могут изменяться штатно. Старое утверждение о побайтовой неизменности любых SQLite sidecars отменено.
19
+
20
+ JSON/JSONL/артефакты читаются через закреплённые дескрипторы с запретом symlink в дочерних компонентах. Самостоятельный архив должен быть согласованным экспортом производителя. Совместимость с другими версиями и платформами требует повторения этих проверок.
@@ -0,0 +1,30 @@
1
+ # Совместимость источников
2
+
3
+ Этот выпуск поддерживает только локальное чтение. Проверенная связка на
4
+ 2026-09-13: `dd-flow-cli` commit `eb2cb9746a9ee386d40f63791f5dbe0847bf5017`
5
+ (`0.9.0-beta.54`) и `dd-eval` commit
6
+ `c140065049bd4040d4c346aac2f8e42f3befab4e` (`0.1.0`). Другие версии не
7
+ считаются совместимыми автоматически: консоль показывает диагностическое
8
+ состояние, а не пытается мигрировать или запускать раннер.
9
+
10
+ | Источник | Обязательный устойчивый контракт | Поведение при отсутствии |
11
+ | --- | --- | --- |
12
+ | Flow | SQLite `db.sqlite`, таблица `runs` и поля, читаемые в `src/readers.ts`; `run_dir` и зарегистрированные receipt-файлы остаются внутри source | `flow_store_missing` или `flow_store_unsupported`; источник не выдаётся за пустой |
13
+ | Eval | `runs/<eval>/manifest.json`; для актуального состояния `observation.json` с `schema_id: dd-eval/observation@1` | исторический Eval открывается с `eval_observation_partial`; текущий статус не выдумывается |
14
+ | Derived Flow | `managed-runtime.json` с `schema_id: dd-eval/managed-runtime@1`, совпадающим `run_id`, а `runtime_root` и `project_root` физически внутри execution | execution остаётся доступным, ссылка на Flow не появляется |
15
+
16
+ Ключи идентичности: самостоятельный Flow — `source_id/project_id/run_id`;
17
+ Eval — `source_id/eval_id`; его Flow — детерминированный
18
+ `derived(source_id, eval_id, execution_id)` вместе с `project_id/run_id`.
19
+ Короткий `run_id` сам по себе никогда не является ключом.
20
+
21
+ `DatabaseSync` открывает Flow SQLite с `readOnly: true`. Reader не импортирует
22
+ runner-модули, не вызывает CLI и не запускает provider-процессы. JSON/JSONL
23
+ читаются только из зарегистрированных root, а артефакты дополнительно
24
+ проверяются на containment. Контрактные тесты покрывают сохранённую Eval
25
+ observation, managed runtime, прямую derived-ссылку после перезапуска,
26
+ частичную/повреждённую строку JSONL и 100 MiB journal. Перед обновлением
27
+ поддерживаемой связки следует повторить эти проверки на обезличенном snapshot,
28
+ включая WAL с committed данными. Чтение выполняется короткой read-only транзакцией на живой SQLite; immutable и файловые копии DB/WAL не используются. Коммиты, конкурентная запись и checkpoint проверены тестом. Основная БД и WAL остаются неизменны при чтении спокойного writer; SHM read-marks являются служебной частью SQLite.
29
+
30
+ Captures действительной схемы и проекций: `test/fixtures/contracts`. Их создаёт `scripts/generate-contract-fixtures.mjs` в пустом временном home. Сами тесты не импортируют соседние репозитории. JSON observation принимается только с подходящими schema_id, run_id, last_sequence и timestamp.
@@ -0,0 +1,32 @@
1
+ # Контракт данных и отображения
2
+
3
+ API возвращает `dd-console/api@1`. Все ответы содержат время чтения
4
+ `observed_at` и время ответа `served_at`; cached observation сохраняет время исходного чтения. Ответы конкретного источника также содержат `source`:
5
+ `availability` и `source_updated_at`, если он известен. Это разные величины:
6
+ `availability` описывает возможность консоли прочитать source, а `status`
7
+ Flow/Eval/Execution — последнее сохранённое состояние выполнения. Поэтому
8
+ `running` может соседствовать с устаревшим последним фактом, а `unavailable`
9
+ не означает, что запуск завершился ошибкой.
10
+
11
+ | Экран / DTO | Откуда берётся | Безопасное отображение | При отсутствии |
12
+ | --- | --- | --- | --- |
13
+ | SourceSummary | реестр и лёгкое чтение source | ID, label, kind, availability, timestamp; без root | unavailable/unsupported с diagnostic |
14
+ | RunSummary / FlowDetail | SQLite Flow | identity, status, stage attempts, works, sessions, checks | `unknown` и diagnostic, без фиктивной стадии |
15
+ | EventPage | `timeline.jsonl` / `events.jsonl` | allowlist id/type/time/status/message; полная история обратными страницами | неполная последняя строка не показывается, повреждённая отмечается |
16
+ | ArtifactInfo | путь из зарегистрированного check receipt | opaque ID, basename, size; text preview до 256 KiB | traversal/symlink за root отклоняется |
17
+ | EvalDetail / ExecutionDetail | manifest, observation, candidate, report, journal | commit/profile, execution states, Candidate/Judge summary | Candidate/Judge не подменяются нулевой оценкой |
18
+
19
+ В API не попадают корни источников, абсолютные пути, токены, raw manifest,
20
+ сырые candidate/evidence payloads или transcript body. Reader извлекает только
21
+ allowlist полей из candidate/report и не передаёт browser-у private path,
22
+ как подтверждает contract test.
23
+
24
+ List endpoints поддерживают q/status/source/project/kind/case и sort/order. Cursor привязан к endpoint и фильтрам. Journal cursor содержит generation/offset, устойчив к append; rotate/truncate возвращает reset. Признаки `truncated` и `pending_tail` объясняют неполноту страницы. List-снимок
25
+ живёт 5 минут; устаревший или чужой cursor получает `409 stale_cursor`.
26
+ Reader cache имеет TTL, LRU и deduplication in-flight reads; SQLite list/detail
27
+ выполняются в ограниченном пуле workers. При переполнении очереди сервер
28
+ возвращает `503 reader_queue_busy` и `Retry-After: 1`.
29
+
30
+ Preview: `?part=receipt|stdout|stderr&offset=<byte offset>`; ответ содержит `next_offset` либо null. Metadata/preview/download разрешают только поля зарегистрированной проверки. Дескриптор фиксирует файл до чтения; symlink в любом дочернем компоненте отвергается. Граница UTF-8 символа сохраняется между фрагментами.
31
+
32
+ Связи stage attempt → work берутся из сохранённых prompt/result bindings внутри `stage.dir`, checks — из work_id. Отсутствующая связь не угадывается по времени. Обратные work/session/check links используют те же сохранённые идентичности.