agent-orchestrator/packages/cli/src/lib/dashboard-setup.ts

292 lines
9.1 KiB
TypeScript

import { readFileSync, writeFileSync } from "node:fs";
import chalk from "chalk";
import { parseDocument } from "yaml";
import {
CONFIG_SCHEMA_URL,
DEFAULT_DASHBOARD_NOTIFICATION_LIMIT,
getDashboardNotificationStorePath,
isCanonicalGlobalConfigPath,
findConfigFile,
normalizeDashboardNotificationLimit,
readDashboardNotificationsFromFile,
} from "@aoagents/ao-core";
import {
applyNotifierRoutingPreset,
getNotifierRoutingState,
promptNotifierRoutingPreset,
resolveRoutingPresetOption,
type ClackPrompts,
type NotifierRoutingPreset,
} from "./notifier-routing.js";
export interface DashboardSetupOptions {
limit?: string;
refresh?: boolean;
status?: boolean;
force?: boolean;
nonInteractive?: boolean;
routingPreset?: string;
}
interface ConfigContext {
configPath: string;
rawConfig: Record<string, unknown>;
}
interface DashboardConfig {
plugin: "dashboard";
limit: number;
}
interface ResolvedDashboardSetup {
limit: number;
routingPreset?: NotifierRoutingPreset;
}
export class DashboardSetupError extends Error {
constructor(
message: string,
public readonly exitCode = 1,
) {
super(message);
this.name = "DashboardSetupError";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function readConfigContext(): ConfigContext {
const configPath = findConfigFile() ?? undefined;
if (!configPath) {
throw new DashboardSetupError(
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
);
}
const rawYaml = readFileSync(configPath, "utf-8");
const doc = parseDocument(rawYaml);
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
return { configPath, rawConfig };
}
function getExistingDashboard(rawConfig: Record<string, unknown>): Record<string, unknown> {
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
const existing = notifiers["dashboard"];
return isRecord(existing) ? existing : {};
}
function parseLimit(value: unknown): number {
if (value === undefined || value === null || value === "") {
return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT;
}
const parsed =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value.trim(), 10)
: Number.NaN;
if (!Number.isFinite(parsed)) {
throw new DashboardSetupError("Dashboard notification limit must be a number.");
}
return normalizeDashboardNotificationLimit(parsed);
}
function resolveDashboardRoutingPreset(
value: string | undefined,
): NotifierRoutingPreset | undefined {
try {
return resolveRoutingPresetOption(value, "dashboard") as NotifierRoutingPreset | undefined;
} catch (error) {
throw new DashboardSetupError(error instanceof Error ? error.message : String(error));
}
}
function toDashboardConfig(resolved: ResolvedDashboardSetup): DashboardConfig {
return {
plugin: "dashboard",
limit: resolved.limit,
};
}
function writeDashboardConfig(configPath: string, resolved: ResolvedDashboardSetup): void {
const rawYaml = readFileSync(configPath, "utf-8");
const doc = parseDocument(rawYaml);
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
notifiers["dashboard"] = toDashboardConfig(resolved);
rawConfig["notifiers"] = notifiers;
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
rawConfig["defaults"] = defaults;
applyNotifierRoutingPreset(rawConfig, "dashboard", resolved.routingPreset);
if (!isCanonicalGlobalConfigPath(configPath)) {
const currentSchema = doc.get("$schema");
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
doc.set("$schema", CONFIG_SCHEMA_URL);
}
}
doc.setIn(["notifiers"], rawConfig["notifiers"]);
doc.setIn(["defaults"], rawConfig["defaults"]);
if (rawConfig["notificationRouting"] !== undefined) {
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
}
writeFileSync(configPath, doc.toString({ indent: 2 }));
}
function cancelSetup(clack: ClackPrompts): never {
clack.cancel("Dashboard setup cancelled.");
throw new DashboardSetupError("Dashboard setup cancelled.", 0);
}
async function shouldReplaceConflictingDashboard(
plugin: unknown,
force: boolean,
nonInteractive: boolean,
): Promise<boolean> {
if (plugin === undefined || plugin === "dashboard" || force) return true;
if (nonInteractive) {
throw new DashboardSetupError(
`notifiers.dashboard already uses plugin "${String(plugin)}". Pass --force to replace it.`,
);
}
const clack = await import("@clack/prompts");
const answer = await clack.confirm({
message: `notifiers.dashboard already uses plugin "${String(plugin)}". Replace it?`,
initialValue: false,
});
if (clack.isCancel(answer)) {
cancelSetup(clack);
}
return answer === true;
}
async function resolveInteractiveSetup(
opts: DashboardSetupOptions,
existingDashboard: Record<string, unknown>,
rawConfig: Record<string, unknown>,
): Promise<ResolvedDashboardSetup> {
const clack = await import("@clack/prompts");
const optionRoutingPreset = resolveDashboardRoutingPreset(opts.routingPreset);
const existingLimit = parseLimit(existingDashboard["limit"]);
clack.intro(chalk.bgCyan(chalk.black(" ao setup dashboard ")));
while (true) {
const limitInput = await clack.text({
message: "How many dashboard notifications should AO keep?",
placeholder: String(DEFAULT_DASHBOARD_NOTIFICATION_LIMIT),
initialValue: stringValue(opts.limit) ?? String(existingLimit),
validate: (value) => {
try {
parseLimit(value);
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
},
});
if (clack.isCancel(limitInput)) {
cancelSetup(clack);
}
const routingSelection =
optionRoutingPreset ??
(await promptNotifierRoutingPreset(clack, rawConfig, "dashboard", "dashboard", () =>
cancelSetup(clack),
));
if (routingSelection === "back") continue;
return {
limit: parseLimit(limitInput),
routingPreset: routingSelection === "preserve" ? undefined : routingSelection,
};
}
}
function resolveNonInteractiveSetup(
opts: DashboardSetupOptions,
existingDashboard: Record<string, unknown>,
): ResolvedDashboardSetup {
const limit =
opts.limit !== undefined
? parseLimit(opts.limit)
: opts.refresh
? parseLimit(existingDashboard["limit"])
: DEFAULT_DASHBOARD_NOTIFICATION_LIMIT;
const routingPreset =
resolveDashboardRoutingPreset(opts.routingPreset) ??
(opts.refresh ? undefined : "urgent-action");
return { limit, routingPreset };
}
function printStatus(): void {
const context = readConfigContext();
const existingDashboard = getExistingDashboard(context.rawConfig);
const plugin = stringValue(existingDashboard["plugin"]);
const limit = parseLimit(existingDashboard["limit"]);
const storePath = getDashboardNotificationStorePath(context.configPath);
const records = readDashboardNotificationsFromFile(storePath, limit);
const latest = records.at(-1);
console.log(chalk.bold("Dashboard notifier status"));
console.log(` Config: ${context.configPath}`);
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
console.log(` Limit: ${limit}`);
console.log(` Store: ${storePath}`);
console.log(` Stored: ${records.length}`);
console.log(` Latest: ${latest?.receivedAt ?? chalk.dim("none")}`);
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "dashboard").label}`);
}
export async function runDashboardSetupAction(opts: DashboardSetupOptions): Promise<void> {
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
const force = Boolean(opts.force);
if (opts.status) {
printStatus();
return;
}
const context = readConfigContext();
const existingDashboard = getExistingDashboard(context.rawConfig);
const shouldWire = await shouldReplaceConflictingDashboard(
existingDashboard["plugin"],
force,
nonInteractive,
);
if (!shouldWire) return;
const resolved = nonInteractive
? resolveNonInteractiveSetup(opts, existingDashboard)
: await resolveInteractiveSetup(opts, existingDashboard, context.rawConfig);
writeDashboardConfig(context.configPath, resolved);
console.log(chalk.green(`Config written to ${context.configPath}`));
if (!nonInteractive) {
const clack = await import("@clack/prompts");
clack.outro(
`${chalk.green("Dashboard setup complete!")} AO will retain the latest ${resolved.limit} dashboard notifications.\n` +
chalk.dim(" Test it with: ao notify test --to dashboard --template basic"),
);
} else {
console.log(chalk.green("\nDashboard setup complete."));
console.log(chalk.dim("Test it with: ao notify test --to dashboard --template basic"));
}
}