Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/agor-daemon/config-lean.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# config-lean.example.yaml — Minimal headless daemon for k8s / cloud deployments
#
# Copy to ~/.agor/config.yaml (or pass via --config flag) to run the daemon
# with only the essential services needed for API/chatbot use cases.
#
# Services disabled in lean mode:
# scheduler — Cron-based session spawning (background timer + DB polling)
# terminals — Web terminal / xterm.js (node-pty, per-session PTY allocation)
# gateway — Slack/Discord/GitHub integrations (Socket Mode listeners, channel cache)
# boards — Spatial canvas boards, board-objects, board-comments (UI-only)
# cards — Kanban cards and card type definitions (UI-only)
# artifacts — Sandpack artifact previews (UI-only, bundler detection)
# file_browser — File/context browsing endpoints (UI file explorer)
# leaderboard — Usage analytics / cost tracking (UI dashboard)
#
# Services kept enabled:
# core — Sessions, tasks, messages (the prompt loop)
# worktrees — Worktree CRUD (required FK for sessions)
# repos — Repository management
# users — User accounts + authentication
# mcp_servers — MCP server configuration (needed for agent tool access)

daemon:
port: 3030
host: 0.0.0.0
allowAnonymous: false

services:
# Core services (cannot be disabled)
core: on
worktrees: on
repos: on
users: on

# Keep MCP servers for agent tool integration
mcp_servers: on

# Disable UI-only and heavyweight services
scheduler: off
terminals: off
gateway: off
boards: off
cards: off
artifacts: off
file_browser: off
leaderboard: off
static_files: off
18 changes: 17 additions & 1 deletion apps/agor-daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,24 +104,31 @@ export interface DaemonStartOptions {
* or from main.ts with no args for direct execution.
*/
export async function startDaemon(options?: DaemonStartOptions): Promise<void> {
const tBoot = performance.now();

// Initialize Handlebars helpers for template rendering
let t0 = performance.now();
registerHandlebarsHelpers();
console.log('✅ Handlebars helpers registered');
console.log(`⏱️ [boot] Handlebars helpers: ${(performance.now() - t0).toFixed(0)}ms`);

// Configure Git to fail fast instead of prompting for credentials
process.env.GIT_TERMINAL_PROMPT = '0';
process.env.GIT_ASKPASS = 'echo';

// Load config: CLI-provided > configPath > default loadConfig()
t0 = performance.now();
const config: AgorConfig = options?.config
? options.config
: options?.configPath
? await loadConfigFromFile(options.configPath)
: await loadConfig();
console.log(`⏱️ [boot] Config load: ${(performance.now() - t0).toFixed(0)}ms`);

// Resolve service tier configuration (validate deps, auto-promote)
t0 = performance.now();
const servicesConfig = resolveServicesConfig(config.services);
logServicesConfig(servicesConfig);
console.log(`⏱️ [boot] Service tier resolution: ${(performance.now() - t0).toFixed(0)}ms`);

const svcTier = (group: string): ServiceTier =>
getServiceTier(servicesConfig, group as ServiceGroupName);
Expand Down Expand Up @@ -342,7 +349,9 @@ export async function startDaemon(options?: DaemonStartOptions): Promise<void> {
configureChannels(app);
configureSwagger(app, { version: DAEMON_VERSION, port: DAEMON_PORT });

t0 = performance.now();
const { db } = await initializeDatabase(DB_PATH);
console.log(`⏱️ [boot] Database init: ${(performance.now() - t0).toFixed(0)}ms`);

// --------------------------------------------------------------------------
// RBAC flags
Expand All @@ -354,6 +363,7 @@ export async function startDaemon(options?: DaemonStartOptions): Promise<void> {
// --------------------------------------------------------------------------
// Phase 1: Register services
// --------------------------------------------------------------------------
t0 = performance.now();
const services = await registerServices({
db,
app,
Expand All @@ -368,10 +378,12 @@ export async function startDaemon(options?: DaemonStartOptions): Promise<void> {
allowSuperadmin,
requireAuth,
});
console.log(`⏱️ [boot] Phase 1 — registerServices: ${(performance.now() - t0).toFixed(0)}ms`);

// --------------------------------------------------------------------------
// Phase 2: Register hooks
// --------------------------------------------------------------------------
t0 = performance.now();
registerHooks({
db,
app,
Expand All @@ -390,10 +402,12 @@ export async function startDaemon(options?: DaemonStartOptions): Promise<void> {
usersRepository: services.usersRepository,
sessionsRepository: services.sessionsRepository,
});
console.log(`⏱️ [boot] Phase 2 — registerHooks: ${(performance.now() - t0).toFixed(0)}ms`);

// --------------------------------------------------------------------------
// Phase 3: Register routes (auth, REST, tier hooks, error handler)
// --------------------------------------------------------------------------
t0 = performance.now();
await registerRoutes({
db,
app,
Expand All @@ -419,6 +433,7 @@ export async function startDaemon(options?: DaemonStartOptions): Promise<void> {
sessionMCPServersService: services.sessionMCPServersService,
terminalsService: services.terminalsService,
});
console.log(`⏱️ [boot] Phase 3 — registerRoutes: ${(performance.now() - t0).toFixed(0)}ms`);

// --------------------------------------------------------------------------
// Phase 4: Startup (orphan cleanup, health, scheduler, listen, shutdown)
Expand All @@ -434,5 +449,6 @@ export async function startDaemon(options?: DaemonStartOptions): Promise<void> {
getSocketServer: socketIOConfig.getSocketServer,
sessionsService: services.sessionsService,
terminalsService: services.terminalsService,
tBoot,
});
}
35 changes: 18 additions & 17 deletions apps/agor-daemon/src/register-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,30 +38,16 @@ import {
persistOAuthToken,
saveOAuth21TokenToDB,
} from './oauth-cache.js';
import type { ArtifactsService } from './services/artifacts.js';
import { createArtifactsService } from './services/artifacts.js';
import { createBoardCommentsService } from './services/board-comments.js';
import { createBoardObjectsService } from './services/board-objects.js';
import { createBoardsService } from './services/boards.js';
import { createCardTypesService } from './services/card-types.js';
import { createCardsService } from './services/cards.js';
import { createConfigService } from './services/config.js';
import { createContextService } from './services/context.js';
import { createFileService } from './services/file.js';
import { createFilesService } from './services/files.js';
import { createGatewayService } from './services/gateway.js';
import { createGatewayChannelsService } from './services/gateway-channels.js';
import { registerGitHubAppSetupRoutes } from './services/github-app-setup.js';
import { createLeaderboardService } from './services/leaderboard.js';
import { createMCPServersService } from './services/mcp-servers.js';
import { createMessagesService } from './services/messages.js';
import { performOAuthDisconnect } from './services/oauth-disconnect.js';
import { createReposService } from './services/repos.js';
import { createSessionMCPServersService } from './services/session-mcp-servers.js';
import { createSessionsService } from './services/sessions.js';
import { createTasksService } from './services/tasks.js';
import { TerminalsService } from './services/terminals.js';
import { createThreadSessionMapService } from './services/thread-session-map.js';
import { createUsersService } from './services/users.js';
import { setupWorktreeOwnersService } from './services/worktree-owners.js';
import { createWorktreesService } from './services/worktrees.js';
Expand Down Expand Up @@ -95,7 +81,7 @@ export interface RegisteredServices {
usersRepository: import('@agor/core/db').UsersRepository;
sessionsRepository: import('@agor/core/db').SessionRepository;
sessionMCPServersService: ReturnType<typeof createSessionMCPServersService>;
terminalsService: TerminalsService | null;
terminalsService: import('./services/terminals.js').TerminalsService | null;
configService: ReturnType<typeof createConfigService>;
boardCommentsService: unknown;
}
Expand Down Expand Up @@ -154,6 +140,7 @@ export async function registerServices(ctx: RegisterServicesContext): Promise<Re
events: ['tool:start', 'tool:complete', 'thinking:chunk'],
});
if (svcEnabled('leaderboard')) {
const { createLeaderboardService } = await import('./services/leaderboard.js');
app.use('/leaderboard', createLeaderboardService(db));
}
const messagesService = createMessagesService(db) as unknown as MessagesServiceImpl;
Expand Down Expand Up @@ -210,6 +197,8 @@ export async function registerServices(ctx: RegisterServicesContext): Promise<Re
// ============================================================================

if (svcEnabled('boards')) {
const { createBoardsService } = await import('./services/boards.js');
const { createBoardObjectsService } = await import('./services/board-objects.js');
app.use('/boards', createBoardsService(db), {
methods: [
'find',
Expand All @@ -231,11 +220,14 @@ export async function registerServices(ctx: RegisterServicesContext): Promise<Re
const boardsService = safeService('boards') as unknown as BoardsServiceImpl | undefined;

if (svcEnabled('cards')) {
const { createCardTypesService } = await import('./services/card-types.js');
const { createCardsService } = await import('./services/cards.js');
app.use('/card-types', createCardTypesService(db));
app.use('/cards', createCardsService(db));
}

if (svcEnabled('artifacts')) {
const { createArtifactsService } = await import('./services/artifacts.js');
app.use('/artifacts', createArtifactsService(db, app));

// Detect self-hosted Sandpack bundler
Expand All @@ -250,6 +242,7 @@ export async function registerServices(ctx: RegisterServicesContext): Promise<Re
const baseUrl = await getBaseUrl();
const origin = new URL(baseUrl).origin;
const bundlerURL = `${origin}/static/sandpack/`;
type ArtifactsService = { selfHostedBundlerURL?: string };
const artifactsService = app.service('artifacts') as unknown as ArtifactsService;
artifactsService.selfHostedBundlerURL = bundlerURL;
console.log(`🧩 Self-hosted Sandpack bundler detected: ${bundlerURL}`);
Expand All @@ -258,6 +251,7 @@ export async function registerServices(ctx: RegisterServicesContext): Promise<Re
}

if (svcEnabled('boards')) {
const { createBoardCommentsService } = await import('./services/board-comments.js');
app.use('/board-comments', createBoardCommentsService(db));
}

Expand Down Expand Up @@ -307,6 +301,10 @@ export async function registerServices(ctx: RegisterServicesContext): Promise<Re
// ============================================================================

if (svcEnabled('gateway')) {
const { createGatewayChannelsService } = await import('./services/gateway-channels.js');
const { createThreadSessionMapService } = await import('./services/thread-session-map.js');
const { createGatewayService } = await import('./services/gateway.js');
const { registerGitHubAppSetupRoutes } = await import('./services/github-app-setup.js');
app.use('/gateway-channels', createGatewayChannelsService(db));
app.use('/thread-session-map', createThreadSessionMapService(db));
app.use('/gateway', createGatewayService(db, app), {
Expand Down Expand Up @@ -344,8 +342,10 @@ export async function registerServices(ctx: RegisterServicesContext): Promise<Re
app.use('/files', createFilesService(db));
}

const terminalsService = svcEnabled('terminals') ? new TerminalsService(app, db) : null;
if (terminalsService) {
let terminalsService: import('./services/terminals.js').TerminalsService | null = null;
if (svcEnabled('terminals')) {
const { TerminalsService } = await import('./services/terminals.js');
terminalsService = new TerminalsService(app, db);
app.use('/terminals', terminalsService, {
events: ['data', 'exit'],
});
Expand Down Expand Up @@ -886,6 +886,7 @@ async function registerMCPServices(
}
};

const { createMCPServersService } = await import('./services/mcp-servers.js');
app.use('/mcp-servers', createMCPServersService(db));

// JWT test endpoint
Expand Down
29 changes: 26 additions & 3 deletions apps/agor-daemon/src/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { SessionStatus, TaskStatus } from '@agor/core/types';
import type { Application, SessionsServiceImpl, TasksServiceImpl } from './declarations.js';
import type { GatewayService } from './services/gateway.js';
import { createHealthMonitor } from './services/health-monitor.js';
import { SchedulerService } from './services/scheduler.js';
import type { TerminalsService } from './services/terminals.js';

// ---------------------------------------------------------------------------
Expand All @@ -35,6 +34,8 @@ export interface StartupContext {
/** Services returned from registerServices() */
sessionsService: SessionsServiceImpl;
terminalsService: TerminalsService | null;
/** Boot start timestamp from startDaemon() for total boot timing */
tBoot: number;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -177,19 +178,30 @@ export async function startup(ctx: StartupContext): Promise<void> {
safeService,
getSocketServer,
terminalsService,
tBoot,
} = ctx;

const tStartup = performance.now();

// 1. Cleanup orphaned tasks/sessions from previous daemon instance
let t0 = performance.now();
await cleanupOrphans(ctx);
console.log(`⏱️ [boot] cleanupOrphans: ${(performance.now() - t0).toFixed(0)}ms`);

// 2. Initialize Health Monitor for periodic environment health checks
t0 = performance.now();
const healthMonitor = await createHealthMonitor(app);
console.log(`⏱️ [boot] createHealthMonitor: ${(performance.now() - t0).toFixed(0)}ms`);

// 3. Validate/generate master secret for API key encryption
t0 = performance.now();
await ensureMasterSecret(config);
console.log(`⏱️ [boot] ensureMasterSecret: ${(performance.now() - t0).toFixed(0)}ms`);

// 4. Start server
t0 = performance.now();
const server = await app.listen(DAEMON_PORT, DAEMON_HOST);
console.log(`⏱️ [boot] app.listen: ${(performance.now() - t0).toFixed(0)}ms`);

const displayHost = DAEMON_HOST === '0.0.0.0' ? 'localhost' : DAEMON_HOST;
console.log(
Expand All @@ -211,27 +223,35 @@ export async function startup(ctx: StartupContext): Promise<void> {
console.log(` - /context`);
console.log(` - /users`);

// 5. Start scheduler service (background worker)
let schedulerService: SchedulerService | null = null;
// 5. Start scheduler service (background worker) — dynamically imported to avoid
// loading the module at all when scheduler is disabled (lean mode optimization)
let schedulerService: import('./services/scheduler.js').SchedulerService | null = null;
if (svcEnabled('scheduler')) {
t0 = performance.now();
const { SchedulerService } = await import('./services/scheduler.js');
schedulerService = new SchedulerService(db, app, {
tickInterval: 30000, // 30 seconds
gracePeriod: 120000, // 2 minutes
debug: process.env.NODE_ENV !== 'production',
unixUserMode: config.execution?.unix_user_mode ?? 'simple',
});
schedulerService.start();
console.log(`⏱️ [boot] scheduler init: ${(performance.now() - t0).toFixed(0)}ms`);
console.log(`🔄 Scheduler started (tick interval: 30s)`);
}

// 6. Initialize gateway: refresh channel state cache, then start Socket Mode listeners
const gatewayService = safeService('gateway') as unknown as GatewayService | undefined;
if (gatewayService) {
const tGw = performance.now();
gatewayService
.refreshChannelState()
.then(() => {
return gatewayService.startListeners();
})
.then(() => {
console.log(`⏱️ [boot] gateway init (async): ${(performance.now() - tGw).toFixed(0)}ms`);
})
.catch((error: unknown) => {
console.error('[gateway] Failed to start listeners:', error);
});
Expand Down Expand Up @@ -308,4 +328,7 @@ export async function startup(ctx: StartupContext): Promise<void> {

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

console.log(`⏱️ [boot] Phase 4 — startup: ${(performance.now() - tStartup).toFixed(0)}ms`);
console.log(`⏱️ [boot] Total boot time: ${(performance.now() - tBoot).toFixed(0)}ms`);
}
20 changes: 20 additions & 0 deletions packages/core/src/types/config-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ export const SERVICE_TIER_RANK: Record<ServiceTier, number> = {
/**
* The 13 service groups configurable in config.yaml under `services:`.
* All default to 'on' for backward compatibility.
*
* ## Lean / Headless Preset
*
* For minimal headless deployments (e.g., k8s executor pods), disable non-essential
* services to reduce boot time and memory footprint:
*
* ```yaml
* services:
* scheduler: off # no cron-based session spawning
* terminals: off # no xterm/pty (heavy native deps)
* gateway: off # no Slack/Discord SDK (heavy)
* boards: off # no spatial canvas
* cards: off # no kanban cards
* artifacts: off # no sandpack artifacts
* leaderboard: off # no usage analytics
* static_files: off # no UI bundle serving
* ```
*
* This leaves core, worktrees, repos, users, file_browser, and mcp_servers active —
* the minimum needed for prompt execution and worktree management.
*/
export interface DaemonServicesConfig {
/** sessions, tasks, messages — the prompt loop */
Expand Down
Loading