feat(ops): add setup operator pack, heartbeat alert cooldown, and doctor strict mode

This commit is contained in:
William Valentin
2026-02-16 14:57:56 -08:00
parent 030fb13a26
commit 3210e75c94
12 changed files with 274 additions and 17 deletions
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from 'vitest';
import { runChecks, type CheckResult, type DoctorContext } from './doctor.js';
import { computeDoctorExitCode, runChecks, type CheckResult, type DoctorContext } from './doctor.js';
import { writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
@@ -11,6 +11,22 @@ describe('doctor checks', () => {
try { rmSync(testDir, { recursive: true }); } catch {}
});
it('computeDoctorExitCode returns 0 with warnings in non-strict mode', () => {
const results: CheckResult[] = [
{ status: 'pass', label: 'a' },
{ status: 'warn', label: 'b' },
];
expect(computeDoctorExitCode(results, false)).toBe(0);
});
it('computeDoctorExitCode returns 1 with warnings in strict mode', () => {
const results: CheckResult[] = [
{ status: 'pass', label: 'a' },
{ status: 'warn', label: 'b' },
];
expect(computeDoctorExitCode(results, true)).toBe(1);
});
it('reports PASS when config file exists and is valid', async () => {
mkdirSync(testDir, { recursive: true });
const configPath = join(testDir, 'config.yaml');
+18 -2
View File
@@ -632,12 +632,25 @@ export async function runChecks(ctx: DoctorContext): Promise<CheckResult[]> {
return results;
}
export function computeDoctorExitCode(results: CheckResult[], strict: boolean): number {
const failCount = results.filter((r) => r.status === 'fail').length;
const warnCount = results.filter((r) => r.status === 'warn').length;
if (failCount > 0) {
return 1;
}
if (strict && warnCount > 0) {
return 1;
}
return 0;
}
export function registerDoctorCommand(program: Command): void {
program
.command('doctor')
.description('Validate configuration and check system health')
.option('-c, --config <path>', 'Config file path')
.action(async (opts: { config?: string }) => {
.option('--strict', 'Treat warnings as failures')
.action(async (opts: { config?: string; strict?: boolean }) => {
const configPath = opts.config ?? getConfigPath();
const dataDir = getDataDir();
@@ -662,7 +675,10 @@ export function registerDoctorCommand(program: Command): void {
};
console.log(`Results: ${counts.pass} passed, ${counts.fail} failed, ${counts.warn} warnings, ${counts.skip} skipped`);
if (opts.strict && counts.warn > 0) {
console.log('Strict mode enabled: warnings are treated as failures.');
}
process.exit(counts.fail > 0 ? 1 : 0);
process.exit(computeDoctorExitCode(results, Boolean(opts.strict)));
});
}
+24
View File
@@ -57,6 +57,30 @@ const GOOGLE_SERVICES: GoogleService[] = [
];
export async function setupAutomation(p: Prompter, builder: ConfigBuilder): Promise<void> {
const enableOperatorPack = await p.confirm(
'Enable operator automation pack (scheduled backups + heartbeat alerts + daily briefing + MinIO sync)?',
false,
);
if (enableOperatorPack) {
const config = builder.build();
const telegramPeer = config.telegram?.allowed_chat_ids?.[0];
const defaultOutputChannel = telegramPeer ? 'telegram' : 'webchat';
const defaultOutputPeer = telegramPeer ? String(telegramPeer) : 'operator';
const backupSchedule = await p.ask('Backup cron schedule', '0 2 * * *');
const dailyBriefingSchedule = await p.ask('Daily briefing cron schedule', '0 8 * * *');
const enableMinioSync = await p.confirm('Include default MinIO sync task?', true);
builder.applyOperatorPack({
outputChannel: defaultOutputChannel,
outputPeer: defaultOutputPeer,
backupSchedule,
dailyBriefingSchedule,
enableMinioSync,
});
p.println(`✓ Operator pack enabled (alerts routed to ${defaultOutputChannel}/${defaultOutputPeer})`);
}
const cron = await p.confirm('Enable cron scheduler?', false);
if (cron) {
builder.setCronEnabled();
+19
View File
@@ -84,4 +84,23 @@ describe('ConfigBuilder', () => {
const obj = builder.build();
expect(obj.server.token).toBe('my-secret-token');
});
it('applies operator automation pack defaults', () => {
const builder = new ConfigBuilder();
builder.applyOperatorPack({
outputChannel: 'telegram',
outputPeer: '123',
backupSchedule: '0 2 * * *',
dailyBriefingSchedule: '0 8 * * *',
enableMinioSync: true,
});
const obj = builder.build();
expect(obj.backup?.enabled).toBe(true);
expect(obj.backup?.schedule).toBe('0 2 * * *');
expect(obj.backup?.run_on_start).toBe(true);
expect((obj.automation as Record<string, unknown>)?.heartbeat).toBeDefined();
expect((obj.automation as Record<string, unknown>)?.daily_briefing).toBeDefined();
expect((obj.automation as Record<string, unknown>)?.minio_sync).toBeDefined();
});
});
+62
View File
@@ -43,9 +43,23 @@ export interface SetupConfig {
gtasks?: { enabled?: boolean };
heartbeat?: { enabled?: boolean };
} & Record<string, unknown>;
backup?: {
enabled?: boolean;
schedule?: string;
run_on_start?: boolean;
notify?: { channel: string; peer: string };
} & Record<string, unknown>;
[key: string]: unknown;
}
interface OperatorPackOptions {
outputChannel: string;
outputPeer: string;
backupSchedule: string;
dailyBriefingSchedule: string;
enableMinioSync?: boolean;
}
export class ConfigBuilder {
private config: SetupConfig;
@@ -187,6 +201,54 @@ export class ConfigBuilder {
this.config.automation = automation;
}
applyOperatorPack(options: OperatorPackOptions): void {
const automation = (this.config.automation ?? {}) as Record<string, unknown>;
const backup = (this.config.backup ?? {}) as Record<string, unknown>;
backup.enabled = true;
backup.schedule = options.backupSchedule;
backup.run_on_start = true;
backup.notify = { channel: options.outputChannel, peer: options.outputPeer };
automation.heartbeat = {
enabled: true,
notify: { channel: options.outputChannel, peer: options.outputPeer },
interval: '5m',
failure_threshold: 2,
notify_cooldown: '30m',
};
automation.daily_briefing = {
enabled: true,
schedule: options.dailyBriefingSchedule,
output: { channel: options.outputChannel, peer: options.outputPeer },
dedupe_per_local_day: true,
model_tier: 'fast',
};
if (options.enableMinioSync ?? true) {
automation.minio_sync = {
enabled: true,
interval: '6h',
run_on_start: true,
notify: { channel: options.outputChannel, peer: options.outputPeer },
tasks: [
{
prefix: 'knowledge/',
namespace_base: 'global/knowledge/minio',
mode: 'append',
max_objects: 20,
max_chars_per_object: 8000,
force: false,
},
],
};
}
this.config.automation = automation;
this.config.backup = backup;
}
build(): SetupConfig {
return structuredClone(this.config) as SetupConfig;
}