feat: Extended DaemonConfig with control_channel_id and orchestrator se…

- "packages/daemon/src/types.ts"
- "packages/daemon/src/config.ts"
- "packages/daemon/src/daemon.ts"
- "packages/daemon/src/discord-bot.ts"
- "packages/daemon/src/discord-bot.test.ts"
- "packages/daemon/src/index.ts"

GSD-Task: S05/T02
This commit is contained in:
Lex Christopherson 2026-03-27 15:46:58 -06:00
parent bbba5f83b9
commit 898e797772
6 changed files with 435 additions and 3 deletions

View file

@ -57,7 +57,17 @@ export function validateConfig(raw: unknown): DaemonConfig {
guild_id: typeof d['guild_id'] === 'string' ? d['guild_id'] : '',
owner_id: typeof d['owner_id'] === 'string' ? d['owner_id'] : '',
...(typeof d['dm_on_blocker'] === 'boolean' ? { dm_on_blocker: d['dm_on_blocker'] } : {}),
...(typeof d['control_channel_id'] === 'string' ? { control_channel_id: d['control_channel_id'] } : {}),
};
// Parse orchestrator sub-block
if (d['orchestrator'] != null && typeof d['orchestrator'] === 'object') {
const orc = d['orchestrator'] as Record<string, unknown>;
discord.orchestrator = {
...(typeof orc['model'] === 'string' ? { model: orc['model'] } : {}),
...(typeof orc['max_tokens'] === 'number' && orc['max_tokens'] > 0 ? { max_tokens: orc['max_tokens'] } : {}),
};
}
}
// --- projects ---

View file

@ -4,6 +4,7 @@ import { SessionManager } from './session-manager.js';
import { scanForProjects } from './project-scanner.js';
import { DiscordBot, validateDiscordConfig } from './discord-bot.js';
import { EventBridge } from './event-bridge.js';
import { Orchestrator } from './orchestrator.js';
/**
* Core daemon class ties config + logger together with lifecycle management.
@ -17,6 +18,7 @@ export class Daemon {
private sessionManager: SessionManager | undefined;
private discordBot: DiscordBot | undefined;
private eventBridge: EventBridge | undefined;
private orchestrator: Orchestrator | undefined;
constructor(
private readonly config: DaemonConfig,
@ -51,6 +53,7 @@ export class Daemon {
config: this.config.discord,
logger: this.logger,
sessionManager: this.sessionManager,
scanProjects: () => this.scanProjects(),
});
await this.discordBot.login();
@ -69,6 +72,28 @@ export class Daemon {
this.discordBot.setEventBridge(this.eventBridge);
this.eventBridge.start();
this.logger.info('event bridge wired');
// Wire up Orchestrator if control_channel_id is configured
if (this.config.discord.control_channel_id) {
this.orchestrator = new Orchestrator({
sessionManager: this.sessionManager,
channelManager,
scanProjects: () => this.scanProjects(),
config: {
model: this.config.discord.orchestrator?.model ?? 'claude-sonnet-4-5-20250929',
max_tokens: this.config.discord.orchestrator?.max_tokens ?? 1024,
control_channel_id: this.config.discord.control_channel_id,
},
logger: this.logger,
ownerId: this.config.discord.owner_id,
});
client.on('messageCreate', (message) => {
void this.orchestrator!.handleMessage(message);
});
this.logger.info('orchestrator wired', {
control_channel_id: this.config.discord.control_channel_id,
});
}
} else {
this.logger.warn('event bridge skipped — channel manager or client not available');
}
@ -100,6 +125,11 @@ export class Daemon {
return this.eventBridge;
}
/** Accessor for the orchestrator (available after start() with control_channel_id configured). */
getOrchestrator(): Orchestrator | undefined {
return this.orchestrator;
}
/** Idempotent shutdown: log, cleanup sessions, close logger, exit. */
async shutdown(): Promise<void> {
if (this.shuttingDown) return;
@ -117,6 +147,12 @@ export class Daemon {
this.keepaliveTimer = undefined;
}
// Stop Orchestrator first
if (this.orchestrator) {
this.orchestrator.stop();
this.orchestrator = undefined;
}
// Stop EventBridge before Discord bot destroy
if (this.eventBridge) {
await this.eventBridge.stop();

View file

@ -10,6 +10,7 @@ import { sanitizeChannelName, ChannelManager } from './channel-manager.js';
import { buildCommands, formatSessionStatus } from './commands.js';
import { Daemon } from './daemon.js';
import { Logger } from './logger.js';
import { validateConfig } from './config.js';
import type { DaemonConfig, LogEntry, ManagedSession } from './types.js';
// ---------- helpers ----------
@ -573,3 +574,219 @@ describe('command dispatch', () => {
assert.equal(authorized, true);
});
});
// ---------- Config validation: new fields ----------
describe('validateConfig — control_channel_id and orchestrator', () => {
it('parses control_channel_id from discord block', () => {
const config = validateConfig({
discord: {
token: 'tok',
guild_id: 'g1',
owner_id: 'o1',
control_channel_id: 'ch-123',
},
});
assert.equal(config.discord?.control_channel_id, 'ch-123');
});
it('omits control_channel_id when not present', () => {
const config = validateConfig({
discord: {
token: 'tok',
guild_id: 'g1',
owner_id: 'o1',
},
});
assert.equal(config.discord?.control_channel_id, undefined);
});
it('parses orchestrator model and max_tokens', () => {
const config = validateConfig({
discord: {
token: 'tok',
guild_id: 'g1',
owner_id: 'o1',
orchestrator: { model: 'claude-opus-2025', max_tokens: 2048 },
},
});
assert.equal(config.discord?.orchestrator?.model, 'claude-opus-2025');
assert.equal(config.discord?.orchestrator?.max_tokens, 2048);
});
it('missing orchestrator block results in undefined', () => {
const config = validateConfig({
discord: {
token: 'tok',
guild_id: 'g1',
owner_id: 'o1',
},
});
assert.equal(config.discord?.orchestrator, undefined);
});
it('empty orchestrator block has no model or max_tokens', () => {
const config = validateConfig({
discord: {
token: 'tok',
guild_id: 'g1',
owner_id: 'o1',
orchestrator: {},
},
});
// orchestrator object should exist but with no values set
assert.ok(config.discord?.orchestrator !== undefined);
assert.equal(config.discord?.orchestrator?.model, undefined);
assert.equal(config.discord?.orchestrator?.max_tokens, undefined);
});
it('ignores non-numeric max_tokens', () => {
const config = validateConfig({
discord: {
token: 'tok',
guild_id: 'g1',
owner_id: 'o1',
orchestrator: { max_tokens: 'not a number' },
},
});
assert.equal(config.discord?.orchestrator?.max_tokens, undefined);
});
it('ignores non-string model', () => {
const config = validateConfig({
discord: {
token: 'tok',
guild_id: 'g1',
owner_id: 'o1',
orchestrator: { model: 42 },
},
});
assert.equal(config.discord?.orchestrator?.model, undefined);
});
});
// ---------- Daemon wiring: orchestrator ----------
describe('Daemon orchestrator wiring', () => {
it('orchestrator is undefined when control_channel_id is not set', async () => {
const dir = tmpDir();
cleanupDirs.push(dir);
const logPath = join(dir, 'no-orchestrator.log');
const config: DaemonConfig = {
discord: undefined,
projects: { scan_roots: [] },
log: { file: logPath, level: 'debug', max_size_mb: 50 },
};
const logger = new Logger({ filePath: logPath, level: 'debug' });
const daemon = new Daemon(config, logger);
await daemon.start();
assert.equal(daemon.getOrchestrator(), undefined);
const origExit = process.exit;
// @ts-expect-error — overriding process.exit for test
process.exit = () => {};
try {
await daemon.shutdown();
} finally {
process.exit = origExit;
}
});
it('orchestrator is undefined when discord has no control_channel_id', async () => {
// Even with a discord block that fails login, orchestrator should not be created
// because there's no control_channel_id
const dir = tmpDir();
cleanupDirs.push(dir);
const logPath = join(dir, 'no-ctl-chan.log');
const config: DaemonConfig = {
discord: {
token: 'bad-token',
guild_id: 'g1',
owner_id: 'o1',
// control_channel_id intentionally omitted
},
projects: { scan_roots: [] },
log: { file: logPath, level: 'debug', max_size_mb: 50 },
};
const logger = new Logger({ filePath: logPath, level: 'debug' });
const daemon = new Daemon(config, logger);
await daemon.start();
// Login fails, so orchestrator can't be wired regardless. But the code path
// that checks control_channel_id comes after successful login/eventBridge wiring.
// Since login fails, orchestrator is undefined.
assert.equal(daemon.getOrchestrator(), undefined);
const origExit = process.exit;
// @ts-expect-error — overriding process.exit for test
process.exit = () => {};
try {
await daemon.shutdown();
} finally {
process.exit = origExit;
}
});
});
// ---------- /gsd-start and /gsd-stop logic paths ----------
describe('/gsd-start and /gsd-stop logic', () => {
// These test the observable logic paths exercised by the handlers.
// Since handleGsdStart/handleGsdStop are private, we test the data layer
// they depend on — project scanning, session listing, and edge cases.
it('/gsd-start: scanForProjects returning 0 projects', async () => {
// Simulates the "no projects" path
const { scanForProjects } = await import('./project-scanner.js');
// With no scan roots, should return empty
const projects = await scanForProjects([]);
assert.equal(projects.length, 0);
});
it('/gsd-stop: getAllSessions returns empty when no sessions active', async () => {
const { SessionManager } = await import('./session-manager.js');
const dir = tmpDir();
cleanupDirs.push(dir);
const logPath = join(dir, 'sm-test.log');
const logger = new Logger({ filePath: logPath, level: 'debug' });
const sm = new SessionManager(logger);
const sessions = sm.getAllSessions();
assert.equal(sessions.length, 0);
await logger.close();
});
it('/gsd-stop: filters to active sessions only', () => {
// Simulate the filter logic used in handleGsdStop
const allSessions: Partial<ManagedSession>[] = [
{ sessionId: 's1', status: 'running', projectName: 'alpha' },
{ sessionId: 's2', status: 'completed', projectName: 'beta' },
{ sessionId: 's3', status: 'blocked', projectName: 'gamma' },
{ sessionId: 's4', status: 'error', projectName: 'delta' },
{ sessionId: 's5', status: 'starting', projectName: 'epsilon' },
{ sessionId: 's6', status: 'cancelled', projectName: 'zeta' },
];
const active = allSessions.filter(
(s) => s.status === 'running' || s.status === 'blocked' || s.status === 'starting',
);
assert.equal(active.length, 3);
assert.deepEqual(active.map((s) => s.projectName), ['alpha', 'gamma', 'epsilon']);
});
it('/gsd-start: >25 projects are truncated for select menu', () => {
// Simulate the truncation logic
const projects = Array.from({ length: 30 }, (_, i) => ({
name: `project-${i}`,
path: `/home/user/project-${i}`,
markers: [] as string[],
lastModified: Date.now(),
}));
const truncated = projects.slice(0, 25);
assert.equal(truncated.length, 25);
assert.equal(truncated[24].name, 'project-24');
});
});

View file

@ -10,10 +10,14 @@ import {
Client,
GatewayIntentBits,
REST,
StringSelectMenuBuilder,
ActionRowBuilder,
ComponentType,
type Interaction,
type Guild,
type StringSelectMenuInteraction,
} from 'discord.js';
import type { DaemonConfig, VerbosityLevel } from './types.js';
import type { DaemonConfig, VerbosityLevel, ProjectInfo } from './types.js';
import type { Logger } from './logger.js';
import type { SessionManager } from './session-manager.js';
import { ChannelManager } from './channel-manager.js';
@ -62,6 +66,8 @@ export interface DiscordBotOptions {
config: NonNullable<DaemonConfig['discord']>;
logger: Logger;
sessionManager: SessionManager;
/** Optional function to scan for projects (passed from Daemon). */
scanProjects?: () => Promise<ProjectInfo[]>;
}
export class DiscordBot {
@ -73,11 +79,13 @@ export class DiscordBot {
private readonly config: NonNullable<DaemonConfig['discord']>;
private readonly logger: Logger;
private readonly sessionManager: SessionManager;
private readonly scanProjects?: () => Promise<ProjectInfo[]>;
constructor(opts: DiscordBotOptions) {
this.config = opts.config;
this.logger = opts.logger;
this.sessionManager = opts.sessionManager;
this.scanProjects = opts.scanProjects;
}
/**
@ -223,9 +231,15 @@ export class DiscordBot {
break;
}
case 'gsd-start':
this.handleGsdStart(interaction).catch((err) => {
this.logger.warn('gsd-start handler error', {
error: err instanceof Error ? err.message : String(err),
});
});
break;
case 'gsd-stop':
interaction.reply({ content: 'Coming soon — use #gsd-control', ephemeral: true }).catch((err) => {
this.logger.warn(`${commandName} reply failed`, {
this.handleGsdStop(interaction).catch((err) => {
this.logger.warn('gsd-stop handler error', {
error: err instanceof Error ? err.message : String(err),
});
});
@ -258,4 +272,150 @@ export class DiscordBot {
break;
}
}
// ---------------------------------------------------------------------------
// Private: /gsd-start handler
// ---------------------------------------------------------------------------
private async handleGsdStart(interaction: import('discord.js').ChatInputCommandInteraction): Promise<void> {
await interaction.deferReply({ ephemeral: true });
this.logger.info('gsd-start: scanning projects');
if (!this.scanProjects) {
await interaction.editReply({ content: 'Project scanning not available.' });
return;
}
let projects: ProjectInfo[];
try {
projects = await this.scanProjects();
} catch (err) {
this.logger.error('gsd-start: scan failed', {
error: err instanceof Error ? err.message : String(err),
});
await interaction.editReply({ content: 'Failed to scan for projects.' });
return;
}
if (projects.length === 0) {
await interaction.editReply({ content: 'No projects found.' });
return;
}
// Discord select menus support max 25 options
const truncated = projects.slice(0, 25);
const select = new StringSelectMenuBuilder()
.setCustomId('gsd-start-select')
.setPlaceholder('Select a project to start')
.addOptions(
truncated.map((p) => ({
label: p.name.slice(0, 100), // Discord label max 100 chars
value: p.path,
description: p.markers.join(', ').slice(0, 100) || undefined,
})),
);
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
const reply = await interaction.editReply({
content: `Select a project to start (${truncated.length}${projects.length > 25 ? ` of ${projects.length}` : ''} projects):`,
components: [row],
});
try {
const collected = await reply.awaitMessageComponent({
componentType: ComponentType.StringSelect,
time: 60_000,
filter: (i) => i.user.id === interaction.user.id,
}) as StringSelectMenuInteraction;
const projectPath = collected.values[0];
this.logger.info('gsd-start: project selected', { projectPath });
try {
const sessionId = await this.sessionManager.startSession({ projectDir: projectPath });
await collected.update({
content: `✅ Session started for **${projectPath}** (ID: \`${sessionId}\`)`,
components: [],
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.error('gsd-start: startSession failed', { error: errMsg, projectPath });
await collected.update({
content: `❌ Failed to start session: ${errMsg}`,
components: [],
});
}
} catch {
// Timeout or other collector error
this.logger.info('gsd-start: selection timed out');
await interaction.editReply({ content: 'Selection timed out.', components: [] });
}
}
// ---------------------------------------------------------------------------
// Private: /gsd-stop handler
// ---------------------------------------------------------------------------
private async handleGsdStop(interaction: import('discord.js').ChatInputCommandInteraction): Promise<void> {
await interaction.deferReply({ ephemeral: true });
this.logger.info('gsd-stop: listing sessions');
const allSessions = this.sessionManager.getAllSessions();
const activeSessions = allSessions.filter(
(s) => s.status === 'running' || s.status === 'blocked' || s.status === 'starting',
);
if (activeSessions.length === 0) {
await interaction.editReply({ content: 'No active sessions.' });
return;
}
// Discord select menus support max 25 options
const truncated = activeSessions.slice(0, 25);
const select = new StringSelectMenuBuilder()
.setCustomId('gsd-stop-select')
.setPlaceholder('Select a session to stop')
.addOptions(
truncated.map((s) => ({
label: `${s.projectName} (${s.status})`.slice(0, 100),
value: s.sessionId,
})),
);
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
const reply = await interaction.editReply({
content: `Select a session to stop (${truncated.length} active):`,
components: [row],
});
try {
const collected = await reply.awaitMessageComponent({
componentType: ComponentType.StringSelect,
time: 60_000,
filter: (i) => i.user.id === interaction.user.id,
}) as StringSelectMenuInteraction;
const sessionId = collected.values[0];
this.logger.info('gsd-stop: session selected', { sessionId });
try {
await this.sessionManager.cancelSession(sessionId);
await collected.update({
content: `✅ Session \`${sessionId}\` stopped.`,
components: [],
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.error('gsd-stop: cancelSession failed', { error: errMsg, sessionId });
await collected.update({
content: `❌ Failed to stop session: ${errMsg}`,
components: [],
});
}
} catch {
// Timeout or other collector error
this.logger.info('gsd-stop: selection timed out');
await interaction.editReply({ content: 'Selection timed out.', components: [] });
}
}
}

View file

@ -26,6 +26,8 @@ export type { ChannelManagerOptions } from './channel-manager.js';
export { buildCommands, formatSessionStatus, registerGuildCommands } from './commands.js';
export { EventBridge } from './event-bridge.js';
export type { BridgeClient, EventBridgeOptions } from './event-bridge.js';
export { Orchestrator } from './orchestrator.js';
export type { OrchestratorConfig, OrchestratorDeps, DiscordMessageLike } from './orchestrator.js';
export { MessageBatcher } from './message-batcher.js';
export type { SendPayload, SendFn, BatcherLogger, BatcherOptions } from './message-batcher.js';
export { VerbosityManager, shouldShowAtLevel } from './verbosity.js';

View file

@ -34,6 +34,13 @@ export interface DaemonConfig {
owner_id: string;
/** When true, DM the owner on blocker events in addition to channel messages */
dm_on_blocker?: boolean;
/** Discord channel ID where the orchestrator listens for natural language commands */
control_channel_id?: string;
/** LLM orchestrator settings */
orchestrator?: {
model?: string;
max_tokens?: number;
};
};
projects: {
scan_roots: string[];