feat(ui): add operator UI server, stores, and insights

This commit is contained in:
2026-02-23 18:49:53 -05:00
parent 8100f4d1c6
commit cf386e1aaa
18 changed files with 3252 additions and 17 deletions

183
src/ui/config-store.ts Normal file
View File

@@ -0,0 +1,183 @@
import { resolve } from "node:path";
import { loadConfig, type AppConfig } from "../config.js";
import { parseEnvFile, writeEnvFileUpdates } from "./env-store.js";
export type RuntimeNotificationSettings = {
webhookUrl: string;
minSeverity: "info" | "warning" | "critical";
alwaysNotifyTypes: string[];
};
export type SecurityPolicySettings = {
violationMode: "hard_abort" | "validation_fail";
allowedBinaries: string[];
commandTimeoutMs: number;
inheritedEnv: string[];
scrubbedEnv: string[];
};
export type LimitSettings = {
maxConcurrent: number;
maxSession: number;
maxRecursiveDepth: number;
topologyMaxDepth: number;
topologyMaxRetries: number;
relationshipMaxChildren: number;
portBase: number;
portBlockSize: number;
portBlockCount: number;
portPrimaryOffset: number;
};
export type UiConfigSnapshot = {
envFilePath: string;
runtimeEvents: RuntimeNotificationSettings;
security: SecurityPolicySettings;
limits: LimitSettings;
paths: {
stateRoot: string;
projectContextPath: string;
runtimeEventLogPath: string;
securityAuditLogPath: string;
};
};
function toCsv(values: readonly string[]): string {
return values.join(",");
}
function sanitizeCsv(values: readonly string[]): string[] {
const output: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const normalized = value.trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
output.push(normalized);
}
return output;
}
function toRuntimeEvents(config: Readonly<AppConfig>): RuntimeNotificationSettings {
return {
webhookUrl: config.runtimeEvents.discordWebhookUrl ?? "",
minSeverity: config.runtimeEvents.discordMinSeverity,
alwaysNotifyTypes: [...config.runtimeEvents.discordAlwaysNotifyTypes],
};
}
function toSecurity(config: Readonly<AppConfig>): SecurityPolicySettings {
return {
violationMode: config.security.violationHandling,
allowedBinaries: [...config.security.shellAllowedBinaries],
commandTimeoutMs: config.security.commandTimeoutMs,
inheritedEnv: [...config.security.inheritedEnvVars],
scrubbedEnv: [...config.security.scrubbedEnvVars],
};
}
function toLimits(config: Readonly<AppConfig>): LimitSettings {
return {
maxConcurrent: config.agentManager.maxConcurrentAgents,
maxSession: config.agentManager.maxSessionAgents,
maxRecursiveDepth: config.agentManager.maxRecursiveDepth,
topologyMaxDepth: config.orchestration.maxDepth,
topologyMaxRetries: config.orchestration.maxRetries,
relationshipMaxChildren: config.orchestration.maxChildren,
portBase: config.provisioning.portRange.basePort,
portBlockSize: config.provisioning.portRange.blockSize,
portBlockCount: config.provisioning.portRange.blockCount,
portPrimaryOffset: config.provisioning.portRange.primaryPortOffset,
};
}
function toSnapshot(config: Readonly<AppConfig>, envFilePath: string): UiConfigSnapshot {
return {
envFilePath,
runtimeEvents: toRuntimeEvents(config),
security: toSecurity(config),
limits: toLimits(config),
paths: {
stateRoot: config.orchestration.stateRoot,
projectContextPath: config.orchestration.projectContextPath,
runtimeEventLogPath: config.runtimeEvents.logPath,
securityAuditLogPath: config.security.auditLogPath,
},
};
}
function mergeEnv(fileValues: Record<string, string>): NodeJS.ProcessEnv {
return {
...process.env,
...fileValues,
};
}
export class UiConfigStore {
private readonly envFilePath: string;
constructor(input: { workspaceRoot: string; envFilePath?: string }) {
this.envFilePath = resolve(input.workspaceRoot, input.envFilePath ?? ".env");
}
getEnvFilePath(): string {
return this.envFilePath;
}
async readSnapshot(): Promise<UiConfigSnapshot> {
const parsed = await parseEnvFile(this.envFilePath);
const config = loadConfig(mergeEnv(parsed.values));
return toSnapshot(config, this.envFilePath);
}
async updateRuntimeEvents(input: RuntimeNotificationSettings): Promise<UiConfigSnapshot> {
const alwaysNotifyTypes = sanitizeCsv(input.alwaysNotifyTypes);
const updates: Record<string, string> = {
AGENT_RUNTIME_DISCORD_WEBHOOK_URL: input.webhookUrl.trim(),
AGENT_RUNTIME_DISCORD_MIN_SEVERITY: input.minSeverity,
AGENT_RUNTIME_DISCORD_ALWAYS_NOTIFY_TYPES: toCsv(alwaysNotifyTypes),
};
const parsed = await writeEnvFileUpdates(this.envFilePath, updates);
const config = loadConfig(mergeEnv(parsed.values));
return toSnapshot(config, this.envFilePath);
}
async updateSecurityPolicy(input: SecurityPolicySettings): Promise<UiConfigSnapshot> {
const updates: Record<string, string> = {
AGENT_SECURITY_VIOLATION_MODE: input.violationMode,
AGENT_SECURITY_ALLOWED_BINARIES: toCsv(sanitizeCsv(input.allowedBinaries)),
AGENT_SECURITY_COMMAND_TIMEOUT_MS: String(input.commandTimeoutMs),
AGENT_SECURITY_ENV_INHERIT: toCsv(sanitizeCsv(input.inheritedEnv)),
AGENT_SECURITY_ENV_SCRUB: toCsv(sanitizeCsv(input.scrubbedEnv)),
};
const parsed = await writeEnvFileUpdates(this.envFilePath, updates);
const config = loadConfig(mergeEnv(parsed.values));
return toSnapshot(config, this.envFilePath);
}
async updateLimits(input: LimitSettings): Promise<UiConfigSnapshot> {
const updates: Record<string, string> = {
AGENT_MAX_CONCURRENT: String(input.maxConcurrent),
AGENT_MAX_SESSION: String(input.maxSession),
AGENT_MAX_RECURSIVE_DEPTH: String(input.maxRecursiveDepth),
AGENT_TOPOLOGY_MAX_DEPTH: String(input.topologyMaxDepth),
AGENT_TOPOLOGY_MAX_RETRIES: String(input.topologyMaxRetries),
AGENT_RELATIONSHIP_MAX_CHILDREN: String(input.relationshipMaxChildren),
AGENT_PORT_BASE: String(input.portBase),
AGENT_PORT_BLOCK_SIZE: String(input.portBlockSize),
AGENT_PORT_BLOCK_COUNT: String(input.portBlockCount),
AGENT_PORT_PRIMARY_OFFSET: String(input.portPrimaryOffset),
};
const parsed = await writeEnvFileUpdates(this.envFilePath, updates);
const config = loadConfig(mergeEnv(parsed.values));
return toSnapshot(config, this.envFilePath);
}
}