mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
391 lines
14 KiB
JavaScript
391 lines
14 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { lstatSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { assetstore, events, mailbox, ops, personality, workflow } from '@evomap/evolver-core';
|
|
import { loadPriceTable } from '@evomap/evolver-adapter-public';
|
|
import { loadEnvFileFromEnv } from '@evomap/evolver-mcp';
|
|
import { createGithubPrDiagnosticsProvider, readLogDiagnostics, readPersonalityDiagnostics, WebUIServer, } from '@evomap/evolver-webui';
|
|
import { lifecyclePaths } from './lifecycle.js';
|
|
import { formatMemoryGraphOperatorStatus, loadMemoryGraphOperatorStatus } from './localMemoryGraph.js';
|
|
const USAGE = 'usage: evolver dashboard [--port N] [--no-open]\n';
|
|
class DashboardEnvFileUnavailableError extends Error {
|
|
constructor() {
|
|
super('dashboard_env_file_unavailable');
|
|
this.name = 'DashboardEnvFileUnavailableError';
|
|
}
|
|
}
|
|
function resolveDashboardEnv(env) {
|
|
const resolvedEnv = { ...env };
|
|
const envFile = loadEnvFileFromEnv(resolvedEnv);
|
|
if (envFile.error)
|
|
throw new DashboardEnvFileUnavailableError();
|
|
return resolvedEnv;
|
|
}
|
|
function dashboardStartupError(error) {
|
|
return error instanceof DashboardEnvFileUnavailableError
|
|
? 'dashboard_env_file_unavailable'
|
|
: 'dashboard_start_failed';
|
|
}
|
|
function record(value) {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
}
|
|
function stringField(value) {
|
|
return typeof value === 'string' ? value : null;
|
|
}
|
|
function runSummary(value) {
|
|
const state = record(value);
|
|
if (!state)
|
|
return null;
|
|
const runId = stringField(state['runId']);
|
|
const workflowId = stringField(state['workflowId']);
|
|
const status = stringField(state['status']);
|
|
const createdAt = stringField(state['createdAt']);
|
|
const updatedAt = stringField(state['updatedAt']);
|
|
if (!runId || !workflowId || !status || !createdAt || !updatedAt)
|
|
return null;
|
|
const currentKey = stringField(state['currentStep']);
|
|
const steps = record(state['steps']);
|
|
const currentState = currentKey && steps ? record(steps[currentKey]) : null;
|
|
const currentStep = currentState && currentState['status'] !== 'succeeded'
|
|
? stringField(currentState['executionId']) ?? stringField(currentState['stepId']) ?? currentKey
|
|
: currentState ? null : currentKey;
|
|
return {
|
|
runId,
|
|
workflowId,
|
|
status,
|
|
currentStep,
|
|
createdAt,
|
|
updatedAt,
|
|
completedAt: stringField(state['completedAt']),
|
|
};
|
|
}
|
|
function historyRows(value) {
|
|
if (Array.isArray(value))
|
|
return value;
|
|
const envelope = record(value);
|
|
if (!envelope)
|
|
return [];
|
|
if (Array.isArray(envelope['history']))
|
|
return envelope['history'];
|
|
if (Array.isArray(envelope['entries']))
|
|
return envelope['entries'];
|
|
return [];
|
|
}
|
|
function historyEntry(value, index) {
|
|
const entry = record(value);
|
|
if (!entry)
|
|
return null;
|
|
const actor = record(entry['actor']);
|
|
const timestamp = stringField(entry['timestamp'])
|
|
?? stringField(entry['createdAt'])
|
|
?? stringField(entry['at'])
|
|
?? stringField(entry['ts']);
|
|
const type = stringField(entry['type'])
|
|
?? stringField(entry['event'])
|
|
?? stringField(entry['operation'])
|
|
?? stringField(entry['kind']);
|
|
if (!timestamp || !type)
|
|
return null;
|
|
const rawSequence = entry['sequence'] ?? entry['seq'];
|
|
const sequence = typeof rawSequence === 'number' && Number.isSafeInteger(rawSequence) && rawSequence >= 0
|
|
? rawSequence
|
|
: index;
|
|
const status = stringField(entry['status']);
|
|
const stepId = stringField(entry['stepId']);
|
|
const executionId = stringField(entry['executionId']);
|
|
const gateId = stringField(entry['gateId']);
|
|
const attempt = typeof entry['attempt'] === 'number' && Number.isSafeInteger(entry['attempt']) && entry['attempt'] >= 0
|
|
? entry['attempt']
|
|
: null;
|
|
const actorId = stringField(entry['actorId']) ?? stringField(entry['actor']) ?? stringField(actor?.['id']);
|
|
const errorClass = stringField(entry['errorClass']);
|
|
return {
|
|
sequence,
|
|
timestamp,
|
|
type,
|
|
...(status ? { status } : {}),
|
|
...(stepId ? { stepId } : {}),
|
|
...(executionId ? { executionId } : {}),
|
|
...(gateId ? { gateId } : {}),
|
|
...(attempt !== null ? { attempt } : {}),
|
|
...(actorId ? { actorId } : {}),
|
|
...(errorClass ? { errorClass } : {}),
|
|
};
|
|
}
|
|
function readDurableHistory(store, runId, state) {
|
|
const candidate = store;
|
|
const reader = candidate.readHistory ?? candidate.listHistory ?? candidate.history;
|
|
if (reader)
|
|
return historyRows(reader.call(store, runId));
|
|
return historyRows(record(state)?.['history']);
|
|
}
|
|
export function createWorkflowDashboardProvider(store) {
|
|
const read = (runId) => {
|
|
try {
|
|
return store.read(runId);
|
|
}
|
|
catch (error) {
|
|
if (error instanceof workflow.WorkflowStateError && error.code === 'STATE_NOT_FOUND')
|
|
return null;
|
|
throw error;
|
|
}
|
|
};
|
|
return {
|
|
listRuns: () => store.listRunIds()
|
|
.map((runId) => runSummary(read(runId)))
|
|
.filter((run) => run !== null)
|
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.runId.localeCompare(b.runId)),
|
|
getRun: (runId) => runSummary(read(runId)),
|
|
getHistory: (runId) => {
|
|
const state = read(runId);
|
|
if (state === null)
|
|
return null;
|
|
return readDurableHistory(store, runId, state)
|
|
.map(historyEntry)
|
|
.filter((entry) => entry !== null);
|
|
},
|
|
};
|
|
}
|
|
export function createDashboardWorkflowProvider(env = process.env, defaultStateDir = workflow.defaultWorkflowStateDir) {
|
|
const resolvedEnv = resolveDashboardEnv(env);
|
|
const configuredStateDir = resolvedEnv['EVOLVER_WORKFLOW_STATE_DIR']?.trim();
|
|
const stateDir = configuredStateDir || defaultStateDir();
|
|
return createWorkflowDashboardProvider(new workflow.WorkflowStateStore(stateDir));
|
|
}
|
|
function parsePort(raw) {
|
|
if (!/^\d+$/.test(raw))
|
|
return null;
|
|
const port = Number(raw);
|
|
return Number.isSafeInteger(port) && port >= 0 && port <= 65_535 ? port : null;
|
|
}
|
|
function parseDashboardOptions(argv) {
|
|
const options = { help: false, noOpen: false, port: 0 };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i] ?? '';
|
|
if (arg === '--help' || arg === '-h') {
|
|
options.help = true;
|
|
continue;
|
|
}
|
|
if (arg === '--no-open') {
|
|
options.noOpen = true;
|
|
continue;
|
|
}
|
|
if (arg === '--port') {
|
|
const raw = argv[i + 1];
|
|
if (raw === undefined || raw.startsWith('--'))
|
|
return { ok: false, error: 'missing_port' };
|
|
const port = parsePort(raw);
|
|
if (port === null)
|
|
return { ok: false, error: 'invalid_port' };
|
|
options.port = port;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--port=')) {
|
|
const port = parsePort(arg.slice('--port='.length));
|
|
if (port === null)
|
|
return { ok: false, error: 'invalid_port' };
|
|
options.port = port;
|
|
continue;
|
|
}
|
|
return { ok: false, error: 'unknown_argument' };
|
|
}
|
|
return { ok: true, options };
|
|
}
|
|
export function createDashboardServer(memoryGraphStatus, env = process.env) {
|
|
const resolvedEnv = resolveDashboardEnv(env);
|
|
const eventsPath = events.rootEventsPath(resolvedEnv);
|
|
const tracesDir = events.tracesDir(resolvedEnv);
|
|
const store = new assetstore.LocalJsonlProvider(events.assetsDir(resolvedEnv));
|
|
const prices = loadPriceTable();
|
|
const personalityStore = new personality.PersonalityStore({
|
|
path: events.personalityStatePath(resolvedEnv),
|
|
});
|
|
const logFile = lifecyclePaths(resolvedEnv).logFile;
|
|
const githubPrDiagnostics = createGithubPrDiagnosticsProvider({ cwd: process.cwd() });
|
|
const mailboxStore = openDashboardMailbox(resolvedEnv);
|
|
const server = new WebUIServer({
|
|
eventsPath,
|
|
store,
|
|
...(mailboxStore ? { mailbox: mailboxStore } : {}),
|
|
valueSummary: (window, eventSnapshot) => ops.loadValueSummary({
|
|
traces: ops.readTraceRecords(tracesDir),
|
|
events: eventSnapshot,
|
|
referenceScope: store.referenceScope,
|
|
prices,
|
|
}, window),
|
|
retentionReport: () => events.buildRetentionReport({
|
|
rootEventsPath: eventsPath,
|
|
materialStorePath: events.materialStorePath(resolvedEnv),
|
|
materialCursorPath: events.materialWatermarkPath(resolvedEnv),
|
|
}),
|
|
personalityDiagnostics: () => readPersonalityDiagnostics(() => personalityStore.load()),
|
|
logDiagnostics: () => readLogDiagnostics(logFile),
|
|
githubPrDiagnostics: () => githubPrDiagnostics.read(),
|
|
memoryGraphStatus,
|
|
workflow: createDashboardWorkflowProvider(resolvedEnv),
|
|
});
|
|
if (!mailboxStore)
|
|
return server;
|
|
let closed = false;
|
|
return {
|
|
token: server.token,
|
|
launchTicket: server.launchTicket,
|
|
listen: (port) => server.listen(port),
|
|
close: async () => {
|
|
if (closed)
|
|
return;
|
|
closed = true;
|
|
try {
|
|
await server.close();
|
|
}
|
|
catch (error) {
|
|
if (error.code !== 'ERR_SERVER_NOT_RUNNING')
|
|
throw error;
|
|
}
|
|
finally {
|
|
mailboxStore.close();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
function openDashboardMailbox(env) {
|
|
const configured = env['EVOLVER_PROXY_STORE']?.trim();
|
|
const path = configured || join(events.evomapHome(env), 'proxy', 'mailbox.db');
|
|
try {
|
|
const stat = lstatSync(path);
|
|
if (!stat.isFile() || stat.isSymbolicLink())
|
|
return undefined;
|
|
return new mailbox.MailboxStore({ path });
|
|
}
|
|
catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
export function dashboardOpenCommand(url, platform = process.platform) {
|
|
if (platform === 'win32')
|
|
return { command: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] };
|
|
if (platform === 'darwin')
|
|
return { command: 'open', args: [url] };
|
|
return { command: 'xdg-open', args: [url] };
|
|
}
|
|
async function openDashboardUrl(url) {
|
|
const command = dashboardOpenCommand(url);
|
|
return new Promise((resolve) => {
|
|
const child = spawn(command.command, command.args, {
|
|
detached: true,
|
|
shell: false,
|
|
stdio: 'ignore',
|
|
windowsHide: true,
|
|
});
|
|
child.once('error', () => resolve(false));
|
|
child.once('spawn', () => {
|
|
child.unref();
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
function waitForShutdownSignal() {
|
|
return new Promise((resolve) => {
|
|
const finish = () => {
|
|
process.removeListener('SIGINT', finish);
|
|
process.removeListener('SIGTERM', finish);
|
|
resolve();
|
|
};
|
|
process.once('SIGINT', finish);
|
|
process.once('SIGTERM', finish);
|
|
});
|
|
}
|
|
function isAddressInUse(error) {
|
|
return typeof error === 'object'
|
|
&& error !== null
|
|
&& 'code' in error
|
|
&& error.code === 'EADDRINUSE';
|
|
}
|
|
async function listenWithPortFallback(server, requestedPort, maxAttempts) {
|
|
const attempts = requestedPort === 0
|
|
? 1
|
|
: Math.min(Math.max(1, maxAttempts), 65_536 - requestedPort);
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
try {
|
|
return await server.listen(requestedPort + attempt);
|
|
}
|
|
catch (error) {
|
|
if (!isAddressInUse(error) || attempt === attempts - 1)
|
|
throw error;
|
|
}
|
|
}
|
|
throw new Error('dashboard_start_failed');
|
|
}
|
|
export async function runDashboardCommand(argv, deps = {}, options = {}) {
|
|
const stdout = deps.stdout ?? ((text) => { process.stdout.write(text); });
|
|
const stderr = deps.stderr ?? ((text) => { process.stderr.write(text); });
|
|
const parsed = parseDashboardOptions(argv);
|
|
if (!parsed.ok) {
|
|
stderr(`dashboard: ${parsed.error}\n${USAGE}`);
|
|
return 2;
|
|
}
|
|
if (parsed.options.help) {
|
|
stdout(USAGE);
|
|
return 0;
|
|
}
|
|
const createServer = deps.createServer ?? createDashboardServer;
|
|
let env;
|
|
try {
|
|
env = resolveDashboardEnv(deps.env ?? process.env);
|
|
}
|
|
catch (error) {
|
|
stderr(`dashboard: ${dashboardStartupError(error)}\n`);
|
|
return 1;
|
|
}
|
|
const memoryGraphStatus = deps.memoryGraphStatus ?? (() => loadMemoryGraphOperatorStatus(env));
|
|
const openUrl = deps.openUrl ?? openDashboardUrl;
|
|
const waitForShutdown = deps.waitForShutdown ?? waitForShutdownSignal;
|
|
let server;
|
|
try {
|
|
server = createServer(memoryGraphStatus, env);
|
|
}
|
|
catch (error) {
|
|
stderr(`dashboard: ${dashboardStartupError(error)}\n`);
|
|
return 1;
|
|
}
|
|
let listening = false;
|
|
try {
|
|
const port = await listenWithPortFallback(server, parsed.options.port, options.eaddrinusePortAttempts ?? 1);
|
|
listening = true;
|
|
let graphStatus;
|
|
try {
|
|
graphStatus = memoryGraphStatus();
|
|
}
|
|
catch {
|
|
graphStatus = { recovery: 'degraded', compactedRecords: 0, activeRecords: 0, corruptLines: 1, oversizedLines: 0, oversizedFiles: 0, archives: 0 };
|
|
}
|
|
stdout(`dashboard: memory-graph ${formatMemoryGraphOperatorStatus(graphStatus)}\n`);
|
|
const baseUrl = `http://127.0.0.1:${port}/`;
|
|
const launchUrl = `${baseUrl}launch?ticket=${encodeURIComponent(server.launchTicket)}`;
|
|
if (parsed.options.noOpen) {
|
|
stdout(`dashboard: ${launchUrl}\n`);
|
|
}
|
|
else if (await openUrl(launchUrl)) {
|
|
stdout(`dashboard: ${baseUrl}\n`);
|
|
}
|
|
else {
|
|
stderr('dashboard: browser_open_failed\n');
|
|
stdout(`dashboard: ${launchUrl}\n`);
|
|
}
|
|
await waitForShutdown();
|
|
return 0;
|
|
}
|
|
catch {
|
|
stderr(`dashboard: ${listening ? 'dashboard_runtime_failed' : 'dashboard_start_failed'}\n`);
|
|
return 1;
|
|
}
|
|
finally {
|
|
try {
|
|
await server.close();
|
|
}
|
|
catch {
|
|
// The command result takes precedence over a close race or an unbound HTTP server.
|
|
}
|
|
}
|
|
} |