@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { Worker } from "node:worker_threads";
|
|
3
|
+
/** Keeps SQLite and large JSON parsing away from the loopback HTTP event loop. */
|
|
4
|
+
export class ReaderPool {
|
|
5
|
+
workers;
|
|
6
|
+
maxQueue;
|
|
7
|
+
deadlineMs;
|
|
8
|
+
#slots = [];
|
|
9
|
+
#queue = [];
|
|
10
|
+
#nextId = 1;
|
|
11
|
+
#closed = false;
|
|
12
|
+
#journalSecret = randomBytes(32);
|
|
13
|
+
constructor(workers = 2, maxQueue = 32, deadlineMs = 2_000) {
|
|
14
|
+
this.workers = workers;
|
|
15
|
+
this.maxQueue = maxQueue;
|
|
16
|
+
this.deadlineMs = deadlineMs;
|
|
17
|
+
for (let index = 0; index < workers; index += 1)
|
|
18
|
+
this.#slots.push(this.spawn());
|
|
19
|
+
}
|
|
20
|
+
run(operation, ...args) {
|
|
21
|
+
if (this.#closed)
|
|
22
|
+
return Promise.reject(new Error("Reader pool закрыт."));
|
|
23
|
+
if (this.#queue.length >= this.maxQueue && !this.#slots.some(slot => !slot.job))
|
|
24
|
+
return Promise.reject(Object.assign(new Error("Очередь локального чтения занята."), { code: "reader_queue_busy" }));
|
|
25
|
+
return new Promise((resolve, reject) => { this.#queue.push({ id: this.#nextId++, operation, args, resolve: value => resolve(value), reject }); this.dispatch(); });
|
|
26
|
+
}
|
|
27
|
+
async close() { this.#closed = true; for (const slot of this.#slots)
|
|
28
|
+
if (slot.job) {
|
|
29
|
+
clearTimeout(slot.job.timer);
|
|
30
|
+
slot.job.reject(new Error("Сервер остановлен."));
|
|
31
|
+
slot.job = undefined;
|
|
32
|
+
} await Promise.all(this.#slots.map(slot => slot.worker.terminate())); this.#slots = []; for (const job of this.#queue.splice(0))
|
|
33
|
+
job.reject(new Error("Сервер остановлен.")); }
|
|
34
|
+
spawn() {
|
|
35
|
+
const workerFile = new URL("./reader-worker.js", import.meta.url), inheritedInputType = process.execArgv.some(arg => arg.startsWith("--input-type"));
|
|
36
|
+
const slot = { worker: inheritedInputType ? new Worker(workerFile, { workerData: { journalSecret: this.#journalSecret }, execArgv: process.execArgv.filter(arg => !arg.startsWith("--input-type")) }) : new Worker(workerFile, { workerData: { journalSecret: this.#journalSecret } }) };
|
|
37
|
+
slot.worker.on("message", message => { const job = slot.job; if (!job || job.id !== message.id)
|
|
38
|
+
return; clearTimeout(job.timer); slot.job = undefined; message.error ? job.reject(Object.assign(new Error(message.error), { code: message.code })) : job.resolve(message.value); this.dispatch(); });
|
|
39
|
+
slot.worker.on("error", error => { if (slot.job) {
|
|
40
|
+
clearTimeout(slot.job.timer);
|
|
41
|
+
slot.job.reject(error);
|
|
42
|
+
slot.job = undefined;
|
|
43
|
+
} if (!this.#closed)
|
|
44
|
+
this.replace(slot); });
|
|
45
|
+
slot.worker.on("exit", code => { if (code !== 0 && !this.#closed)
|
|
46
|
+
this.replace(slot); });
|
|
47
|
+
return slot;
|
|
48
|
+
}
|
|
49
|
+
dispatch() {
|
|
50
|
+
for (const slot of this.#slots) {
|
|
51
|
+
if (slot.job)
|
|
52
|
+
continue;
|
|
53
|
+
const job = this.#queue.shift();
|
|
54
|
+
if (!job)
|
|
55
|
+
return;
|
|
56
|
+
slot.job = job;
|
|
57
|
+
job.timer = setTimeout(() => { if (slot.job !== job)
|
|
58
|
+
return; slot.job = undefined; job.reject(Object.assign(new Error("Локальное чтение превысило дедлайн."), { code: "reader_deadline" })); void slot.worker.terminate(); this.replace(slot); this.dispatch(); }, this.deadlineMs);
|
|
59
|
+
slot.worker.postMessage({ id: job.id, operation: job.operation, args: job.args });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
replace(slot) {
|
|
63
|
+
const index = this.#slots.indexOf(slot);
|
|
64
|
+
if (index < 0)
|
|
65
|
+
return;
|
|
66
|
+
if (slot.job) {
|
|
67
|
+
clearTimeout(slot.job.timer);
|
|
68
|
+
slot.job.reject(new Error("Reader worker завершился."));
|
|
69
|
+
}
|
|
70
|
+
this.#slots[index] = this.spawn();
|
|
71
|
+
this.dispatch();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import * as readers from "./readers.js";
|
|
3
|
+
const port = parentPort;
|
|
4
|
+
if (!port)
|
|
5
|
+
throw new Error("reader worker requires a parent port");
|
|
6
|
+
const operations = {
|
|
7
|
+
flowRuns: readers.flowRuns, flowDetail: readers.flowDetail, flowStageAttempt: readers.flowStageAttempt,
|
|
8
|
+
flowEvents: readers.flowEvents, flowArtifact: readers.flowArtifact, flowSessionWorks: readers.flowSessionWorks,
|
|
9
|
+
evalFlowRuns: readers.evalFlowRuns, evalRuns: readers.evalRuns, evalDetail: readers.evalDetail, evalExecutionDetail: readers.evalExecutionDetail, evalEvents: readers.evalEvents,
|
|
10
|
+
};
|
|
11
|
+
port.on("message", async ({ id, operation, args }) => {
|
|
12
|
+
try {
|
|
13
|
+
if (!operations[operation])
|
|
14
|
+
throw new Error("unsupported reader operation");
|
|
15
|
+
const value = await operations[operation](...args);
|
|
16
|
+
port.postMessage({ id, value });
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
port.postMessage({ id, error: error instanceof Error ? error.message : String(error), code: error.code });
|
|
20
|
+
}
|
|
21
|
+
});
|
package/dist/readers.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { asArray, inside, realpathInside, safeJson, string } from "./util.js";
|
|
5
|
+
import { journalPage } from "./journal.js";
|
|
6
|
+
import { openWithin, readTextWithin } from "./safe-files.js";
|
|
7
|
+
const diagnostic = (code, message, severity = "warning") => ({ code, message, severity });
|
|
8
|
+
// Short read transactions include committed WAL and keep all detail queries at
|
|
9
|
+
// one SQLite revision. Never immutable on a live store; never invoke migrations.
|
|
10
|
+
export const openDb = (file) => {
|
|
11
|
+
const db = new DatabaseSync(file, { readOnly: true, timeout: 250 });
|
|
12
|
+
try {
|
|
13
|
+
db.exec("PRAGMA query_only=ON; BEGIN");
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
db.close();
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
return db;
|
|
20
|
+
};
|
|
21
|
+
const rows = (db, sql, values = []) => db.prepare(sql).all(...values);
|
|
22
|
+
const row = (db, sql, values = []) => db.prepare(sql).get(...values);
|
|
23
|
+
const exists = async (file) => stat(file).then(() => true).catch(() => false);
|
|
24
|
+
const projectedUsage = (value) => { const usage = value, number = (name) => typeof usage?.[name] === "number" ? usage[name] : null; return ["measured", "partial", "unavailable"].includes(String(usage?.status)) ? { status: usage.status, input_tokens: number("input_tokens"), cached_input_tokens: number("cached_input_tokens"), output_tokens: number("output_tokens"), total_tokens: number("total_tokens"), session_count: typeof usage?.session_count === "number" ? usage.session_count : 0 } : undefined; };
|
|
25
|
+
const stageAttempts = (index) => asArray(index.stage_runs).flatMap(item => { const stage = item, name = string(stage.stage), attempt = string(stage.attempt), started_at = string(stage.started_at), completed_at = string(stage.completed_at), elapsed = Date.parse(completed_at ?? "") - Date.parse(started_at ?? ""); return name && attempt ? [{ stage: name, attempt, order: typeof stage.order === "number" ? stage.order : undefined, status: string(stage.status), started_at, updated_at: string(stage.updated_at), completed_at, duration_ms: typeof stage.duration_ms === "number" ? stage.duration_ms : Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : undefined, data_schema_id: string(stage.data_schema_id), usage: projectedUsage(stage.usage) }] : []; });
|
|
26
|
+
const unavailableUsage = () => ({ status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0 });
|
|
27
|
+
const usageColumns = (db) => rows(db, "PRAGMA table_info(usage)").map(item => item.name);
|
|
28
|
+
const usageRows = (db, projectId, runId) => {
|
|
29
|
+
if (!row(db, "SELECT 1 FROM sqlite_schema WHERE type='table' AND name='usage'") || !["project_id", "run_id", "session_id", "stage", "stage_attempt", "total_tokens", "input_tokens", "output_tokens", "extraction_status", "turn_attribution"].every(name => usageColumns(db).includes(name)))
|
|
30
|
+
return [];
|
|
31
|
+
const cached = usageColumns(db).includes("cache_read_input_tokens") ? "cache_read_input_tokens" : usageColumns(db).includes("cached_input_tokens") ? "cached_input_tokens" : "NULL";
|
|
32
|
+
return rows(db, `SELECT session_id, stage, stage_attempt, total_tokens, input_tokens, ${cached} AS cached_input_tokens, output_tokens, extraction_status, turn_attribution FROM usage WHERE project_id = ?${runId ? " AND run_id = ?" : ""}`, runId ? [projectId, runId] : [projectId]);
|
|
33
|
+
};
|
|
34
|
+
const usageSummary = (items) => {
|
|
35
|
+
const measured = items.filter(item => item.turn_attribution === "windowed_work_session" && item.extraction_status === "measured");
|
|
36
|
+
if (!measured.length)
|
|
37
|
+
return unavailableUsage();
|
|
38
|
+
const sum = (key) => measured.some(item => item[key] === null) ? null : measured.reduce((total, item) => total + item[key], 0);
|
|
39
|
+
const input_tokens = sum("input_tokens"), cached_input_tokens = sum("cached_input_tokens"), output_tokens = sum("output_tokens"), total_tokens = sum("total_tokens");
|
|
40
|
+
return { status: [input_tokens, cached_input_tokens, output_tokens, total_tokens].every(value => value !== null) ? "measured" : "partial", input_tokens, cached_input_tokens, output_tokens, total_tokens, session_count: new Set(measured.map(item => item.session_id)).size };
|
|
41
|
+
};
|
|
42
|
+
const mergeUsage = (items) => {
|
|
43
|
+
const present = items.filter(item => item.status !== "unavailable");
|
|
44
|
+
if (!present.length)
|
|
45
|
+
return unavailableUsage();
|
|
46
|
+
const sum = (key) => present.some(item => item[key] === null) ? null : present.reduce((total, item) => total + item[key], 0);
|
|
47
|
+
const input_tokens = sum("input_tokens"), cached_input_tokens = sum("cached_input_tokens"), output_tokens = sum("output_tokens"), total_tokens = sum("total_tokens");
|
|
48
|
+
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) };
|
|
49
|
+
};
|
|
50
|
+
async function dbPath(source) {
|
|
51
|
+
const file = path.join(source.root, "db.sqlite");
|
|
52
|
+
try {
|
|
53
|
+
const handle = await openWithin(source.boundary_root ?? source.root, file);
|
|
54
|
+
handle.close();
|
|
55
|
+
return file;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code === "ENOENT")
|
|
59
|
+
return file;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function flowRuns(source) {
|
|
64
|
+
let db;
|
|
65
|
+
try {
|
|
66
|
+
const file = await dbPath(source);
|
|
67
|
+
if (!(await exists(file)))
|
|
68
|
+
return { runs: [], usage: unavailableUsage(), diagnostics: [diagnostic("flow_store_missing", "Хранилище dd-flow не найдено.", "error")] };
|
|
69
|
+
db = openDb(file);
|
|
70
|
+
const tables = rows(db, "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'runs'");
|
|
71
|
+
if (!tables.length)
|
|
72
|
+
return { runs: [], usage: unavailableUsage(), diagnostics: [diagnostic("flow_store_unsupported", "Хранилище dd-flow не содержит таблицу runs.", "error")] };
|
|
73
|
+
const list = rows(db, "SELECT id, short_id, project_id, flow_kind, status, verdict, next_action, created_at, updated_at, completed_at, index_json FROM runs ORDER BY updated_at DESC, id DESC");
|
|
74
|
+
const selected = list.filter(item => !source.allowed_run_id || item.id === source.allowed_run_id);
|
|
75
|
+
const usage = mergeUsage(selected.map(item => usageSummary(usageRows(db, item.project_id, item.id).filter(entry => entry.stage === null))));
|
|
76
|
+
return { runs: selected.map(item => {
|
|
77
|
+
const index = safeJson(item.index_json, {});
|
|
78
|
+
const stages = asArray(index.stage_runs);
|
|
79
|
+
const active = stages.find(value => typeof value === "object" && value !== null && value.status === "running");
|
|
80
|
+
return { origin: "standalone", source_label: source.label, source_id: source.id, project_id: item.project_id, run_id: item.id, short_id: item.short_id, flow_kind: item.flow_kind, status: item.status, verdict: item.verdict, stage: string(active?.stage) ?? string(index.current_stage) ?? null, next_action: item.next_action, created_at: item.created_at, updated_at: item.updated_at, completed_at: item.completed_at };
|
|
81
|
+
}), usage, diagnostics: [] };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return { runs: [], usage: unavailableUsage(), diagnostics: [diagnostic(error instanceof Error && /no such (column|table)/.test(error.message) ? "flow_store_unsupported" : "flow_read_failed", "Хранилище Flow недоступно или имеет несовместимую схему.", "error")] };
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
db?.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function flowDetail(source, projectId, runId) {
|
|
91
|
+
if (source.allowed_run_id && source.allowed_run_id !== runId)
|
|
92
|
+
return null;
|
|
93
|
+
let db;
|
|
94
|
+
try {
|
|
95
|
+
const file = await dbPath(source);
|
|
96
|
+
if (!(await exists(file)))
|
|
97
|
+
return null;
|
|
98
|
+
db = openDb(file);
|
|
99
|
+
const run = row(db, "SELECT id, short_id, project_id, flow_kind, status, verdict, next_action, created_at, updated_at, completed_at, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
100
|
+
if (!run)
|
|
101
|
+
return null;
|
|
102
|
+
const index = safeJson(run.index_json, {});
|
|
103
|
+
const works = rows(db, "SELECT work_id, parent_work_id, task, status, depends_on_json, created_at, started_at, completed_at, result FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).map(item => ({ id: item.work_id, parent_id: item.parent_work_id, task: item.task, status: item.status, dependencies: safeJson(item.depends_on_json, []), created_at: item.created_at, started_at: item.started_at, completed_at: item.completed_at, result: item.result }));
|
|
104
|
+
const sessions = rows(db, "SELECT session_id, status, harness, role, model, current_stage, updated_at FROM sessions WHERE project_id = ? AND run_id = ? ORDER BY updated_at DESC", [projectId, runId]).map(item => ({ id: item.session_id, status: item.status, harness: item.harness, role: item.role, model: item.model, stage: item.current_stage, updated_at: item.updated_at }));
|
|
105
|
+
const checks = rows(db, "SELECT id, status, command, work_id, started_at, finished_at, exit_code FROM check_receipts WHERE project_id = ? AND run_id = ? ORDER BY finished_at DESC", [projectId, runId]);
|
|
106
|
+
if (row(db, "SELECT 1 FROM sqlite_schema WHERE type='table' AND name='work_sessions'")) {
|
|
107
|
+
const links = rows(db, "SELECT DISTINCT ws.work_id, ws.session_id FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id JOIN sessions s ON s.session_id=ws.session_id AND s.project_id=w.project_id AND s.run_id=w.run_id WHERE w.project_id=? AND w.run_id=?", [projectId, runId]);
|
|
108
|
+
for (const work of works)
|
|
109
|
+
work.session_ids = links.filter(link => link.work_id === work.id).map(link => link.session_id);
|
|
110
|
+
for (const session of sessions)
|
|
111
|
+
session.work_ids = links.filter(link => link.session_id === session.id).map(link => link.work_id);
|
|
112
|
+
}
|
|
113
|
+
const stages = stageAttempts(index), usage = usageRows(db, projectId, runId), runUsage = usageSummary(usage.filter(item => item.stage === null));
|
|
114
|
+
for (const session of sessions)
|
|
115
|
+
session.usage = usageSummary(usage.filter(item => item.stage === null && item.session_id === session.id));
|
|
116
|
+
for (const stage of stages)
|
|
117
|
+
stage.usage ??= usageSummary(usage.filter(item => item.stage === stage.stage && item.stage_attempt === stage.attempt));
|
|
118
|
+
const linkColumns = rows(db, "PRAGMA table_info(work_sessions)").map(item => item.name);
|
|
119
|
+
if (linkColumns.includes("prompt_path") && linkColumns.includes("result_path")) {
|
|
120
|
+
const runPath = row(db, "SELECT run_dir FROM runs WHERE project_id=? AND id=?", [projectId, runId]);
|
|
121
|
+
const bindings = rows(db, "SELECT ws.work_id, ws.prompt_path, ws.result_path FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id WHERE w.project_id=? AND w.run_id=?", [projectId, runId]);
|
|
122
|
+
for (const stage of stages) {
|
|
123
|
+
const entry = asArray(index.stage_runs).find(item => item?.stage === stage.stage && item?.attempt === stage.attempt);
|
|
124
|
+
const directory = runPath && string(entry?.dir) ? path.resolve(runPath.run_dir, string(entry?.dir)) : null;
|
|
125
|
+
stage.work_ids = directory && inside(runPath.run_dir, directory) ? [...new Set(bindings.filter(binding => [binding.prompt_path, binding.result_path].some(file => file && inside(directory, file))).map(binding => binding.work_id))] : [];
|
|
126
|
+
stage.check_ids = checks.filter(check => check.work_id && stage.work_ids.includes(check.work_id)).map(check => check.id);
|
|
127
|
+
for (const work of works.filter(work => stage.work_ids.includes(work.id)))
|
|
128
|
+
(work.attempts ??= []).push({ stage: stage.stage, attempt: stage.attempt });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { source_id: source.id, project_id: run.project_id, run_id: run.id, short_id: run.short_id, flow_kind: run.flow_kind, status: run.status, verdict: run.verdict, stage: string(index.current_stage) ?? null, next_action: run.next_action, created_at: run.created_at, updated_at: run.updated_at, completed_at: run.completed_at, usage: runUsage, stages, works, sessions, checks, diagnostics: [] };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return { source_id: source.id, project_id: projectId, run_id: runId, status: "unknown", usage: unavailableUsage(), stages: [], works: [], sessions: [], checks: [], diagnostics: [diagnostic("flow_detail_failed", "Не удалось прочитать детали Flow.", "error")] };
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
db?.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export async function flowStageAttempt(source, projectId, runId, stage, attempt) { const detail = await flowDetail(source, projectId, runId); return detail?.stages.find(item => item.stage === stage && item.attempt === attempt) ?? null; }
|
|
141
|
+
export async function flowEvents(source, projectId, runId, limit = 50, cursor) {
|
|
142
|
+
const file = await dbPath(source);
|
|
143
|
+
const db = openDb(file);
|
|
144
|
+
let run;
|
|
145
|
+
try {
|
|
146
|
+
run = row(db, "SELECT run_dir FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
db.close();
|
|
150
|
+
}
|
|
151
|
+
if (!run)
|
|
152
|
+
return null;
|
|
153
|
+
return journalPage(source.boundary_root ?? source.root, path.join(run.run_dir, "timeline.jsonl"), JSON.stringify([source.id, projectId, runId]), limit, cursor);
|
|
154
|
+
}
|
|
155
|
+
export async function evalEvents(source, evalId, limit = 50, cursor) {
|
|
156
|
+
const directory = await evalDirectory(source, evalId);
|
|
157
|
+
return journalPage(source.boundary_root ?? source.root, path.join(directory ?? source.root, "events.jsonl"), JSON.stringify([source.id, evalId]), limit, cursor);
|
|
158
|
+
}
|
|
159
|
+
export async function flowArtifact(source, projectId, runId, checkId, part = "receipt") {
|
|
160
|
+
const file = await dbPath(source);
|
|
161
|
+
if (!(await exists(file)))
|
|
162
|
+
return null;
|
|
163
|
+
const db = openDb(file);
|
|
164
|
+
try {
|
|
165
|
+
const run = row(db, "SELECT run_dir FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
166
|
+
const check = row(db, "SELECT id, stdout_path, stderr_path, receipt_path FROM check_receipts WHERE project_id = ? AND run_id = ? AND id = ?", [projectId, runId, checkId]);
|
|
167
|
+
if (!run || !check)
|
|
168
|
+
return null;
|
|
169
|
+
const choices = { receipt: check.receipt_path, stdout: check.stdout_path, stderr: check.stderr_path };
|
|
170
|
+
const candidate = choices[part];
|
|
171
|
+
if (!candidate || !inside(run.run_dir, candidate))
|
|
172
|
+
return null;
|
|
173
|
+
const handle = await openWithin(source.boundary_root ?? source.root, candidate);
|
|
174
|
+
try {
|
|
175
|
+
return { id: check.id, part, name: path.basename(candidate), file: candidate, bytes: handle.stat().size };
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
handle.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
db.close();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export async function flowSessionWorks(source, projectId, runId, sessionId) {
|
|
189
|
+
const file = await dbPath(source);
|
|
190
|
+
if (!(await exists(file)))
|
|
191
|
+
return [];
|
|
192
|
+
let db;
|
|
193
|
+
try {
|
|
194
|
+
db = openDb(file);
|
|
195
|
+
return rows(db, "SELECT w.work_id, w.parent_work_id, w.task, w.status, w.depends_on_json, w.created_at, w.started_at, w.completed_at, w.result FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? ORDER BY w.created_at", [projectId, runId, sessionId]).map(item => ({ id: item.work_id, parent_id: item.parent_work_id, task: item.task, status: item.status, dependencies: safeJson(item.depends_on_json, []), created_at: item.created_at, started_at: item.started_at, completed_at: item.completed_at, result: item.result }));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
db?.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Eval has two producer-owned, direct-child namespaces: ordinary runs and
|
|
205
|
+
// derived forks. Do not recurse into either namespace.
|
|
206
|
+
async function runDirectories(root) {
|
|
207
|
+
const directories = [];
|
|
208
|
+
for (const namespace of ["runs", "forks"]) {
|
|
209
|
+
const parent = path.join(root, namespace);
|
|
210
|
+
try {
|
|
211
|
+
directories.push(...(await readdir(parent, { withFileTypes: true })).filter(item => item.isDirectory()).map(item => path.join(parent, item.name)));
|
|
212
|
+
}
|
|
213
|
+
catch { /* absent namespace */ }
|
|
214
|
+
}
|
|
215
|
+
return directories;
|
|
216
|
+
}
|
|
217
|
+
async function evalDirectory(source, evalId) {
|
|
218
|
+
if (!evalId || path.basename(evalId) !== evalId || [".", ".."].includes(evalId))
|
|
219
|
+
return null;
|
|
220
|
+
for (const directory of await runDirectories(source.root)) {
|
|
221
|
+
if (!(await realpathInside(source.root, directory)))
|
|
222
|
+
continue;
|
|
223
|
+
const manifest = await readJsonIf(path.join(directory, "manifest.json"), source.root);
|
|
224
|
+
if ((string(manifest?.run_id) ?? path.basename(directory)) === evalId)
|
|
225
|
+
return directory;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
function derivedFrom(manifest) {
|
|
230
|
+
const source = manifest.derived_from;
|
|
231
|
+
if (!source)
|
|
232
|
+
return undefined;
|
|
233
|
+
const parent = string(source.source_eval);
|
|
234
|
+
return { eval_id: parent ? path.basename(parent) : undefined, execution_id: string(source.source_execution), stage: string(asArray(manifest.executions)[0]?.stage), checkpoint_sha256: string(source.checkpoint_sha256) };
|
|
235
|
+
}
|
|
236
|
+
export async function evalRuns(source) {
|
|
237
|
+
if (!(await exists(path.join(source.root, "runs"))) && !(await exists(path.join(source.root, "forks"))))
|
|
238
|
+
return { evals: [], diagnostics: [diagnostic("eval_store_missing", "Каталог запусков Eval отсутствует.", "error")] };
|
|
239
|
+
const dirs = await runDirectories(source.root);
|
|
240
|
+
const evals = [];
|
|
241
|
+
const diagnostics = [];
|
|
242
|
+
for (const dir of dirs) {
|
|
243
|
+
try {
|
|
244
|
+
const manifest = safeJson(await readTextWithin(source.root, path.join(dir, "manifest.json")), {});
|
|
245
|
+
const evalId = string(manifest.run_id) ?? path.basename(dir);
|
|
246
|
+
const executions = asArray(manifest.executions);
|
|
247
|
+
const report = await readJsonIf(path.join(dir, "reports", "report.json"), source.root);
|
|
248
|
+
const observation = await evalObservation(dir, source.root, evalId);
|
|
249
|
+
evals.push({ source_id: source.id, eval_id: evalId, case_id: string(manifest.case_id), kind: string(manifest.kind), derived_from: derivedFrom(manifest), status: string(observation?.state) ?? string(report?.state) ?? (executions.length ? "unknown" : "planned"), created_at: string(manifest.created_at), updated_at: string(observation?.observed_at) ?? (await stat(path.join(dir, "manifest.json"))).mtime.toISOString(), checkpoint: string(manifest.input_checkpoint?.id), execution_count: executions.length });
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
diagnostics.push(diagnostic("eval_manifest_invalid", `Не удалось прочитать ${path.basename(dir)}.`, "warning"));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { evals: evals.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")), diagnostics };
|
|
256
|
+
}
|
|
257
|
+
async function readJsonIf(file, root) {
|
|
258
|
+
try {
|
|
259
|
+
const value = JSON.parse(await readTextWithin(root, file));
|
|
260
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function candidateSummary(value) {
|
|
267
|
+
if (!value)
|
|
268
|
+
return null;
|
|
269
|
+
return { outcome: string(value.outcome), frozen_at: string(value.frozen_at), immutable_hash: string(value.immutable_hash), executions: asArray(value.executions).map(item => { const execution = item, failure = execution.failure, recovery = execution.recovery; return { id: string(execution.execution) ?? "unknown", stage: string(execution.stage), outcome: string(execution.outcome), evidence_completeness: string(execution.evidence_completeness), failure_code: string(failure?.code), recovery_id: string(recovery?.recovery_id) }; }) };
|
|
270
|
+
}
|
|
271
|
+
function reportSummary(value) { return value ? { state: string(value.state), run_validity: string(value.run_validity), reliability: string(value.reliability), recovery_count: typeof value.recovery_count === "number" ? value.recovery_count : undefined, execution_count: asArray(value.execution_history).length } : null; }
|
|
272
|
+
async function evalObservation(dir, root, evalId) { const value = await readJsonIf(path.join(dir, "observation.json"), root); return value?.schema_id === "dd-eval/observation@1" && value.run_id === evalId && Number.isSafeInteger(value.last_sequence) && typeof value.state === "string" && typeof value.observed_at === "string" && Number.isFinite(Date.parse(value.observed_at)) ? value : null; }
|
|
273
|
+
export async function evalDetail(source, evalId) {
|
|
274
|
+
const dir = await evalDirectory(source, evalId);
|
|
275
|
+
if (!dir || !(await realpathInside(dir, path.join(dir, "manifest.json"))) || !(await exists(path.join(dir, "manifest.json"))))
|
|
276
|
+
return null;
|
|
277
|
+
const manifest = safeJson(await readTextWithin(source.root, path.join(dir, "manifest.json")), {});
|
|
278
|
+
const observation = await evalObservation(dir, source.root, evalId);
|
|
279
|
+
const observedExecutions = new Map(asArray(observation?.executions).flatMap(item => { const record = item, executionId = string(record.id), state = string(record.state); return executionId && state ? [[executionId, state]] : []; }));
|
|
280
|
+
const executions = [];
|
|
281
|
+
const journalResult = await evalEvents(source, evalId, 10);
|
|
282
|
+
const journal = journalResult.items;
|
|
283
|
+
for (const raw of asArray(manifest.executions)) {
|
|
284
|
+
const item = raw, execId = string(item.id) ?? "unknown", executionsRoot = path.join(dir, "executions"), executionRoot = path.join(executionsRoot, execId);
|
|
285
|
+
const managed = inside(executionsRoot, executionRoot) && await realpathInside(dir, executionRoot) ? await readJsonIf(path.join(executionRoot, "managed-runtime.json"), source.root) : null;
|
|
286
|
+
let flow = null;
|
|
287
|
+
if (managed?.schema_id === "dd-eval/managed-runtime@1" && string(managed.run_id) && string(managed.runtime_root) && string(managed.project_root)) {
|
|
288
|
+
const runtimeRoot = string(managed.runtime_root);
|
|
289
|
+
const projectRoot = string(managed.project_root);
|
|
290
|
+
if (await realpathInside(executionRoot, runtimeRoot) && await realpathInside(executionRoot, projectRoot)) {
|
|
291
|
+
const derived = { id: derivedSourceId(source.id, evalId, execId), kind: "flow", root: runtimeRoot, boundary_root: source.root, allowed_run_id: string(managed.run_id), label: `${evalId}/${execId}`, created_at: "" };
|
|
292
|
+
const run = (await flowRuns(derived)).runs.find(value => value.run_id === string(managed.run_id));
|
|
293
|
+
if (run)
|
|
294
|
+
flow = { source_id: derived.id, project_id: run.project_id, run_id: run.run_id };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
executions.push({ id: execId, state: observedExecutions.get(execId) ?? string(item.state) ?? "unknown", configured_model: string(item.profile?.model), flow });
|
|
298
|
+
}
|
|
299
|
+
const report = await readJsonIf(path.join(dir, "reports", "report.json"), source.root);
|
|
300
|
+
const candidate = await readJsonIf(path.join(dir, "candidate.json"), source.root);
|
|
301
|
+
const definition = manifest.definition, profile = manifest.profile, judge = profile?.judge;
|
|
302
|
+
return { source_id: source.id, eval_id: string(manifest.run_id) ?? evalId, case_id: string(manifest.case_id), kind: string(manifest.kind), derived_from: derivedFrom(manifest), status: string(observation?.state) ?? string(report?.state) ?? "unknown", created_at: string(manifest.created_at), updated_at: string(observation?.observed_at) ?? (await stat(path.join(dir, "manifest.json"))).mtime.toISOString(), checkpoint: string(manifest.input_checkpoint?.id), execution_count: executions.length, manifest: { kind: string(manifest.kind), definition_commit: string(definition?.commit), profile_id: string(profile?.id), judge_enabled: typeof judge?.enabled === "boolean" ? judge.enabled : undefined }, executions, events: journal, candidate: candidateSummary(candidate), report: reportSummary(report), diagnostics: [...journalResult.diagnostics, ...(observation ? [] : [diagnostic("eval_observation_partial", "У исторического Eval нет сохранённой observation-проекции; показаны доступные факты журнала.", "info")])] };
|
|
303
|
+
}
|
|
304
|
+
export async function evalExecutionDetail(source, evalId, executionId) {
|
|
305
|
+
const detail = await evalDetail(source, evalId), execution = detail?.executions.find(item => item.id === executionId);
|
|
306
|
+
if (!detail || !execution)
|
|
307
|
+
return null;
|
|
308
|
+
const candidate = detail.candidate?.executions.find(item => item.id === executionId) ?? null;
|
|
309
|
+
return { observed_at: detail.observed_at, execution, candidate, events: detail.events.filter(event => event.execution_id === executionId), diagnostics: detail.diagnostics };
|
|
310
|
+
}
|
|
311
|
+
export const derivedSourceId = (sourceId, evalId, executionId) => `drv.${Buffer.from(JSON.stringify([sourceId, evalId, executionId])).toString("base64url")}`;
|
|
312
|
+
export async function derivedSource(source, evalId, executionId, knownEvalRoot) {
|
|
313
|
+
if (typeof evalId !== "string" || typeof executionId !== "string" || !evalId || !executionId || path.basename(evalId) !== evalId || path.basename(executionId) !== executionId || [".", ".."].includes(evalId) || [".", ".."].includes(executionId))
|
|
314
|
+
return null;
|
|
315
|
+
const evalRoot = knownEvalRoot ?? await evalDirectory(source, evalId);
|
|
316
|
+
if (!evalRoot || !(await realpathInside(source.root, evalRoot)))
|
|
317
|
+
return null;
|
|
318
|
+
const manifest = await readJsonIf(path.join(evalRoot, "manifest.json"), source.root);
|
|
319
|
+
if ((string(manifest?.run_id) ?? path.basename(evalRoot)) !== evalId)
|
|
320
|
+
return null;
|
|
321
|
+
if (!asArray(manifest?.executions).some(item => item && typeof item === "object" && item.id === executionId))
|
|
322
|
+
return null;
|
|
323
|
+
const executionsRoot = path.join(evalRoot, "executions"), root = path.join(executionsRoot, executionId);
|
|
324
|
+
if (!inside(executionsRoot, root) || !(await realpathInside(evalRoot, root)) || !(await realpathInside(executionsRoot, root)))
|
|
325
|
+
return null;
|
|
326
|
+
const managed = await readJsonIf(path.join(root, "managed-runtime.json"), source.root);
|
|
327
|
+
const runtime = string(managed?.runtime_root);
|
|
328
|
+
const project = string(managed?.project_root);
|
|
329
|
+
const run = string(managed?.run_id);
|
|
330
|
+
if (managed?.schema_id !== "dd-eval/managed-runtime@1" || !runtime || !project || !run || !(await realpathInside(root, runtime)) || !(await realpathInside(root, project)))
|
|
331
|
+
return null;
|
|
332
|
+
return { id: derivedSourceId(source.id, evalId, executionId), kind: "flow", root: runtime, boundary_root: source.root, allowed_run_id: run, label: `${evalId}/${executionId}`, created_at: "" };
|
|
333
|
+
}
|
|
334
|
+
// Only manifest-declared executions with validated managed-runtime boundaries
|
|
335
|
+
// contribute Flow runs. Never discover arbitrary databases below the Eval home.
|
|
336
|
+
export async function evalFlowRuns(source) {
|
|
337
|
+
const runs = [], diagnostics = [];
|
|
338
|
+
const usages = [];
|
|
339
|
+
let unavailable = 0;
|
|
340
|
+
for (const dir of await runDirectories(source.root)) {
|
|
341
|
+
const manifest = await readJsonIf(path.join(dir, "manifest.json"), source.root);
|
|
342
|
+
if (!manifest)
|
|
343
|
+
continue;
|
|
344
|
+
const evalId = string(manifest.run_id) ?? path.basename(dir);
|
|
345
|
+
for (const raw of asArray(manifest.executions)) {
|
|
346
|
+
const executionId = raw && typeof raw === "object" ? string(raw.id) : undefined;
|
|
347
|
+
if (!executionId)
|
|
348
|
+
continue;
|
|
349
|
+
const derived = await derivedSource(source, evalId, executionId, dir);
|
|
350
|
+
if (!derived) {
|
|
351
|
+
if (await readJsonIf(path.join(dir, "executions", executionId, "managed-runtime.json"), source.root))
|
|
352
|
+
unavailable++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const result = await flowRuns(derived);
|
|
356
|
+
runs.push(...result.runs.map(run => ({ ...run, origin: "eval", source_label: source.label, eval_source_id: source.id, eval_id: evalId, execution_id: executionId })));
|
|
357
|
+
usages.push(result.usage);
|
|
358
|
+
diagnostics.push(...result.diagnostics.map(item => ({ ...item, message: `${evalId}/${executionId}: ${item.message}` })));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (unavailable)
|
|
362
|
+
diagnostics.push(diagnostic("eval_flow_unavailable", `Связанные Flow недоступны у ${unavailable} execution: runtime отсутствует или его связь не прошла проверку.`));
|
|
363
|
+
return { runs, usage: mergeUsage(usages), diagnostics };
|
|
364
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import koffi from "koffi";
|
|
2
|
+
import { openSync, closeSync, constants, createReadStream, fstatSync, read as readCallback } from "node:fs";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { inside } from "./util.js";
|
|
7
|
+
// Node has no openat API. Walking held directory descriptors prevents a rename
|
|
8
|
+
// or symlink replacement from redirecting a later component outside the root.
|
|
9
|
+
const libc = koffi.load(process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6");
|
|
10
|
+
const openat = libc.func("int openat(int dirfd, const char *pathname, int flags, unsigned int mode)");
|
|
11
|
+
const nativeClose = libc.func("int close(int fd)");
|
|
12
|
+
const read = promisify(readCallback);
|
|
13
|
+
function openComponent(parent, name, directory) {
|
|
14
|
+
const fd = openat(parent, name, constants.O_RDONLY | (process.platform === "darwin" ? 0x1000000 : 0x80000) | constants.O_NOFOLLOW | constants.O_NONBLOCK | (directory ? constants.O_DIRECTORY : 0), 0);
|
|
15
|
+
if (fd < 0)
|
|
16
|
+
throw Object.assign(new Error("Файл отсутствует или находится за границами источника."), { code: koffi.errno() === 2 ? "ENOENT" : "unsafe_path" });
|
|
17
|
+
return fd;
|
|
18
|
+
}
|
|
19
|
+
export async function openWithin(root, file) {
|
|
20
|
+
const canonicalRoot = await realpath(root);
|
|
21
|
+
const resolvedRoot = path.resolve(root);
|
|
22
|
+
const systemAlias = process.platform === "darwin" && (resolvedRoot.startsWith("/var/") || resolvedRoot.startsWith("/tmp/"));
|
|
23
|
+
if (canonicalRoot !== resolvedRoot && !systemAlias)
|
|
24
|
+
throw Object.assign(new Error("Корень источника заменён символической ссылкой."), { code: "unsafe_path" });
|
|
25
|
+
const candidate = inside(path.resolve(root), path.resolve(file)) ? path.join(canonicalRoot, path.relative(path.resolve(root), path.resolve(file))) : await realpath(file);
|
|
26
|
+
const relative = path.relative(canonicalRoot, candidate);
|
|
27
|
+
if (!relative || !inside(canonicalRoot, candidate))
|
|
28
|
+
throw Object.assign(new Error("Недопустимый путь источника."), { code: "unsafe_path" });
|
|
29
|
+
const components = [...canonicalRoot.split(path.sep).filter(Boolean), ...relative.split(path.sep)];
|
|
30
|
+
let fd = openat(process.platform === "darwin" ? -2 : -100, "/", constants.O_RDONLY | constants.O_DIRECTORY | (process.platform === "darwin" ? 0x1000000 : 0x80000), 0);
|
|
31
|
+
if (fd < 0)
|
|
32
|
+
throw new Error("Не удалось открыть корень файловой системы.");
|
|
33
|
+
try {
|
|
34
|
+
for (let index = 0; index < components.length; index++) {
|
|
35
|
+
const child = openComponent(fd, components[index], index < components.length - 1);
|
|
36
|
+
nativeClose(fd);
|
|
37
|
+
fd = child;
|
|
38
|
+
}
|
|
39
|
+
if (!fstatSync(fd).isFile())
|
|
40
|
+
throw Object.assign(new Error("Источник не является обычным файлом."), { code: "unsafe_path" });
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
nativeClose(fd);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
// Adopt the pinned file into Node so worker termination also closes it.
|
|
47
|
+
try {
|
|
48
|
+
const adopted = openSync(`/dev/fd/${fd}`, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
49
|
+
nativeClose(fd);
|
|
50
|
+
fd = adopted;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
nativeClose(fd);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
let closed = false;
|
|
57
|
+
return {
|
|
58
|
+
fd,
|
|
59
|
+
stat: () => fstatSync(fd),
|
|
60
|
+
read: (buffer, position) => read(fd, buffer, 0, buffer.length, position),
|
|
61
|
+
close: () => { if (!closed) {
|
|
62
|
+
closed = true;
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
} },
|
|
65
|
+
stream: () => { closed = true; return createReadStream("", { fd, autoClose: true }); },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export async function readTextWithin(root, file, maxBytes = 8 * 1024 * 1024) {
|
|
69
|
+
const handle = await openWithin(root, file);
|
|
70
|
+
try {
|
|
71
|
+
const size = handle.stat().size;
|
|
72
|
+
if (size > maxBytes)
|
|
73
|
+
throw Object.assign(new Error("JSON превышает допустимый размер."), { code: "file_too_large" });
|
|
74
|
+
const buffer = Buffer.alloc(size), { bytesRead } = await handle.read(buffer, 0);
|
|
75
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
handle.close();
|
|
79
|
+
}
|
|
80
|
+
}
|