feat(ui): add operator UI server, stores, and insights
This commit is contained in:
183
src/ui/config-store.ts
Normal file
183
src/ui/config-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user