/** * worker-bootstrap.ts — entry point for the default "worker" mode of AAO. * * Phase 1 of the AAO Gateway split (2026-05-18) extracted the original * `src/index.ts` startup logic into this function so `src/main.ts` can * dispatch on `AAO_MODE` without two parallel entry points. The previous * `src/index.ts` is kept as a thin compatibility shim that just calls * `start()` here so `node dist/index.js` continues to work for anyone * who has wired that path into their service manager. * * Worker mode behaviour is unchanged: load config → init DB → start * WorkerManager + Scheduler → mount Express API. Anything that used to * live in `index.ts main()` now lives in `start()`. */ import { Repository, BrowserSessionRepo } from './db/repository.js'; import { startCoreServer } from './bridge/server.js'; import { resolveListenPort } from './server/config.js'; import { runMigrations } from './db/migrate.js'; import { logger } from './logger.js'; import { accessSync, existsSync, mkdirSync, constants } from 'fs'; import { dirname, resolve, join } from 'path'; import { OpenAICompatClient, type Message, type ToolDef, type LLMEvent } from './llm/openai-compat.js'; import { runPromptCoach, PROMPT_COACH_TOOL_SCHEMA } from './engine/prompt-coach.js'; import { readUserAgentsMd } from './user-folder/paths.js'; import { readMemoryIndex } from './user-folder/memory.js'; import { setLlmUsageRecorder } from './llm/usage-recorder.js'; import { llmRoutingKey } from './llm/routing-key.js'; import { resolveMaxStreamMs } from './config.js'; import { ConfigManager } from './config-manager.js'; import { WorkerManager } from './worker-manager.js'; import { classifyPiece } from './engine/piece-classifier.js'; import { PieceCatalog } from './engine/piece-catalog.js'; import { Scheduler } from './scheduler.js'; import { buildTitleFallback, buildTitlePrompt } from './title-generation.js'; import { initMcpKeyFromFile } from './mcp/crypto.js'; import { registerShutdownHook, installSignalHandlers } from './bridge/shutdown.js'; import { acquireWorkerLock } from './instance-lock.js'; import { checkBwrapAvailable } from './engine/tools/sandbox.js'; import { SkillCatalog } from './engine/skills.js'; import { VapidKeyStore } from './vapid-store.js'; import { PushService } from './push-service.js'; function runPreflight(configPath: string, dbPath: string): void { const errors: string[] = []; if (!existsSync(configPath)) { logger.info(`[preflight] config file not found: ${configPath}; continuing with defaults`); } logger.info('[preflight] running in local-only mode'); const dbDir = dirname(dbPath); try { mkdirSync(dbDir, { recursive: true }); accessSync(dbDir, constants.W_OK); } catch (e) { errors.push(`DB directory is not writable: ${dbDir} (${String(e)})`); } const schemaCandidates = [ resolve(process.cwd(), 'dist/db/schema.sql'), resolve(process.cwd(), 'src/db/schema.sql'), ]; const hasSchema = schemaCandidates.some(p => existsSync(p)); if (!hasSchema) { errors.push(`schema.sql not found. expected one of: ${schemaCandidates.join(', ')}`); } if (errors.length > 0) { throw new Error(`Preflight failed:\n- ${errors.join('\n- ')}`); } } export interface StartWorkerOptions { /** Override config.yaml path. Defaults to 'config.yaml' (cwd-relative). */ configPath?: string; } export async function start(opts: StartWorkerOptions = {}): Promise { logger.info('maestro starting (mode=worker)...'); const configPath = opts.configPath ?? 'config.yaml'; const configManager = new ConfigManager(configPath); const config = configManager.getConfig(); const dbPath = process.env['DB_PATH'] ?? './data/maestro.db'; runPreflight(configPath, dbPath); // Single-writer guard: refuse to start if another worker process on this // host already owns this DB. Prevents two schedulers from double-claiming // and double-executing jobs (see instance-lock.ts). const workerLock = acquireWorkerLock(dbPath); registerShutdownHook('worker-instance-lock', async () => workerLock.release()); const requiresBwrap = config.safety?.bashUnrestricted || config.safety?.bashSandbox === 'always'; if (requiresBwrap) { const bwrapCheck = await checkBwrapAvailable(); if (!bwrapCheck.ok) { throw new Error( `Bash sandboxing requires bwrap but it is not available: ${bwrapCheck.reason}\n` + `Enable user namespaces in the container/host, or set safety.bash_sandbox: auto.` ); } logger.info('[startup] bash sandbox enabled — bwrap verified'); } else if ((config.safety?.bashSandbox ?? 'auto') === 'auto') { const bwrapCheck = await checkBwrapAvailable(); if (!bwrapCheck.ok) { logger.warn( '[security] bwrap unavailable — Bash falls back to hardened whitelist ' + '(env-scrubbed, but NO filesystem/network isolation). A task can read host ' + 'config secrets and other tenants\' data. Set bash_sandbox: always (requires ' + 'bwrap) for multi-user isolation.' ); } } else if (config.safety?.bashSandbox === 'off') { // `off` is an explicit opt-out: env is still scrubbed, but there is no // filesystem/network isolation, so a task's Bash can reach host config // secrets and the shared DB. Warn so a multi-user operator notices. logger.warn( '[security] bash_sandbox=off — the Bash tool runs without filesystem/network ' + 'isolation (env is still scrubbed). Acceptable for single-user/dev only; set ' + 'bash_sandbox: always (requires bwrap) for multi-user deployments.' ); } const repo = new Repository(dbPath); runMigrations(repo.getDb()); // Install the process-global LLM usage recorder so every OpenAICompatClient // completion (agent loop, title, classify, reflection — gateway + direct) // lands in the per-user hour-grain ledger. Hour grain lets the dashboard // re-bucket usage into the viewer's local calendar day. Best-effort: the // recorder helper swallows write errors so a DB hiccup never kills a stream. // Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md setLlmUsageRecorder((event) => { repo.incrementLlmUsageHourly({ userId: event.userId, source: event.source, model: event.model, route: event.route, tokensIn: event.tokensIn, tokensOut: event.tokensOut, requests: 1, }); }); // 起動時に孤立ジョブを回復 await repo.recoverOrphanedJobs(); // Worker 起動(WorkerManager に委譲) const workerManager = new WorkerManager(repo, configManager); workerManager.start(); // Listen port precedence: PORT env (incl. .env loaded by scripts/server.sh) // > config.yaml server.port > default 9876. UI/config changes apply on the // next restart (no live re-bind), same as the TLS settings. const port = resolveListenPort(process.env['PORT'], config.server?.port, (m) => logger.warn(m)); // タイトル自動生成関数を作成(roles に 'title' を持つ worker を優先、なければ最初の worker) const titleWorker = config.provider.workers.find(w => w.enabled !== false && w.roles?.includes('title')) ?? config.provider.workers[0]; let titleClient: OpenAICompatClient | null = null; let generateTitle: ((body: string, ownerId?: string) => Promise) | undefined; if (titleWorker) { const titleModel = titleWorker.model ?? config.provider.model; // Gateway (proxy) mode routes by role, not model name. Title generation // is cheap, so send a tier the title worker serves (first of auto/fast/ // quality), defaulting to 'auto'. Direct mode sends the configured model. const titleTier = titleWorker.roles?.find(r => r === 'auto' || r === 'fast' || r === 'quality') ?? 'auto'; const titleRoutingKey = llmRoutingKey({ isGateway: titleWorker.proxy === true, role: titleTier, resolveDirectModel: () => titleModel, }); logger.info( `Config: title generation worker=${titleWorker.id} key=${titleRoutingKey ?? ''}` + (titleWorker.proxy === true ? ' (gateway:role)' : ''), ); titleClient = new OpenAICompatClient( titleWorker.endpoint, titleRoutingKey, titleWorker.apiKey, config.provider.retry, (config.provider.timeoutMinutes ?? 10) * 60 * 1000, undefined, undefined, undefined, // proxy mode so title/classification usage is recorded as // source='gateway' with the backendId route (not mislabeled 'direct'). { proxy: titleWorker.proxy === true, maxStreamMs: resolveMaxStreamMs(config.provider) }, ); generateTitle = async (body: string, ownerId?: string): Promise => { const fallback = buildTitleFallback(body); const prompt = buildTitlePrompt(body); if (!prompt) return fallback; let title = ''; try { for await (const event of titleClient!.chat([{ role: 'user', content: prompt }], undefined, undefined, { userId: ownerId })) { if (event.type === 'text') title += event.text; if (event.type === 'error') return fallback; if (event.type === 'done') break; } title = title.replace(/[\s\S]*?<\/think>/g, '').trim(); title = title.split('\n')[0]?.trim() ?? ''; return title.slice(0, 60) || fallback; } catch { return fallback; } }; } const piecesDir = resolve(process.cwd(), 'pieces'); const customPiecesDir = config.customPiecesDir ? resolve(config.customPiecesDir) : undefined; logger.info(`[startup] piecesDir=${piecesDir} customPiecesDir=${customPiecesDir ?? 'none'}`); // カスタムディレクトリが設定されていれば作成 if (customPiecesDir) { mkdirSync(customPiecesDir, { recursive: true }); } // Per-user piece catalog: built-ins loaded once, per-user overrides with 60s TTL cache. // The fallback userId 'local' is used when no authenticated user is present (local mode). const userFolderRoot = config.userFolderRoot ?? './data/users'; const pieceCatalog = new PieceCatalog(piecesDir, userFolderRoot); logger.info(`[startup] PieceCatalog initialised builtinDir=${piecesDir} dataDir=${userFolderRoot}`); const skillsDir = resolve(process.cwd(), 'data', 'skills'); mkdirSync(skillsDir, { recursive: true }); const skillCatalog = new SkillCatalog(skillsDir, userFolderRoot); workerManager.setSkillCatalog(skillCatalog); logger.info(`[startup] SkillCatalog initialised systemDir=${skillsDir} userRoot=${userFolderRoot}`); // V2 Web Push notifications. // Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md. // When `notifications.push.enabled` is false (the default), we still mount // the API routes so the UI can see a clear 503 / "管理者により無効化" // signal, but workers do not fire any pushes. let pushService: PushService | null = null; let vapidStore: VapidKeyStore | null = null; const pushCfg = config.notifications?.push; if (pushCfg?.enabled) { const currentPath = pushCfg.vapidCurrentPath ?? join(process.cwd(), 'data/secrets/vapid.json'); const historyDir = pushCfg.vapidHistoryDir ?? join(process.cwd(), 'data/secrets/vapid-history'); const subject = pushCfg.vapidSubject ?? 'https://maestro.example.com/'; vapidStore = new VapidKeyStore(currentPath, historyDir); vapidStore.loadOrGenerate(subject); pushService = new PushService(repo, vapidStore, { queueConcurrency: pushCfg.queueConcurrency, perSendTimeoutMs: pushCfg.perSendTimeoutMs, payloadMaxBytes: pushCfg.payloadMaxBytes, }); workerManager.setPushService(pushService); logger.info(`[startup] Web Push V2 enabled keyId=${vapidStore.getCurrent().keyId}`); } else { logger.info('[startup] Web Push V2 disabled (notifications.push.enabled=false)'); } const selectPiece = titleClient ? async (body: string, fileNames: string[], userId?: string): Promise => { const pieces = pieceCatalog.getForUser(userId ?? 'local'); const result = await classifyPiece(titleClient!, body, pieces, fileNames, undefined, userId ?? 'local'); return result ?? 'chat'; } : undefined; // On-demand prompt coach (create-dialog draft evaluation). Reuses the cheap // title client and forces the submit_evaluation tool, mirroring reflection. const evaluatePrompt = titleClient ? (r: { instruction: string; piece?: string; userId: string; signal?: AbortSignal }) => runPromptCoach( { userFolderRoot, pieceCatalog, skillCatalog, readMemory: readMemoryIndex, readAgents: readUserAgentsMd, callLlm: async ({ system, user, userId, signal }) => { const messages: Message[] = [ { role: 'system', content: system }, { role: 'user', content: user }, ]; let input: Record | null = null; let errorMsg: string | null = null; for await (const event of titleClient!.chat( messages, [PROMPT_COACH_TOOL_SCHEMA as unknown as ToolDef], signal, { userId }, { temperature: 0.2, toolChoice: { type: 'function', function: { name: 'submit_evaluation' } }, }, ) as AsyncGenerator) { if (event.type === 'tool_use' && event.name === 'submit_evaluation' && input === null) { input = event.input; } else if (event.type === 'error') { errorMsg = event.error; } } if (errorMsg !== null) throw new Error(`prompt-coach LLM ${errorMsg}`); if (input === null) throw new Error('prompt-coach LLM returned no submit_evaluation tool_call'); return input; }, }, r, ) : undefined; // スケジューラ起動 (selectPiece 注入で 'auto' を実 piece に解決) // task_kind='script' をサポートするため、sessRepo / masterKeyPath / userFolderRoot も渡す。 const sessRepoForScheduler = new BrowserSessionRepo(repo.getDb()); const scheduler = new Scheduler(repo, config.worktreeDir, { selectPiece, sessRepo: sessRepoForScheduler, masterKeyPath: config.secrets?.masterKeyPath ?? './data/secrets/master.key', userFolderRoot: config.userFolderRoot ?? './data/users', }); scheduler.start(); // Auto-init MCP encryption key from file (generates on first boot if missing). // Must run before startCoreServer so isKeyConfigured() is truthy when routes register. try { initMcpKeyFromFile(config.secrets?.mcpKeyPath ?? './data/secrets/mcp.key'); logger.info('[startup] MCP encryption key loaded from file'); } catch (e) { logger.warn(`[startup] MCP encryption key init failed: ${e} — MCP client features will be disabled`); } startCoreServer({ repo, worktreeDir: config.worktreeDir, configuredRepos: [], generateTitle, selectPiece, evaluatePrompt, configManager, piecesDir, customPiecesDir, scheduler, authConfig: config.auth, brandingDir: join(dirname(dbPath), 'branding'), workerManager, skillCatalog, pushService, vapidStore, }, port); // Graceful shutdown — see installWorkerShutdownHooks() docstring. installWorkerShutdownHooks({ scheduler, workerManager, repo, }); logger.info('maestro ready (mode=worker)'); } /** * Shutdown-hook wiring for worker mode. Extracted so unit tests can * verify the registry calls without spinning up the entire boot path. * * Registers hooks with the shared `shutdown` registry instead of * stacking per-subsystem signal listeners. The gateway path uses the * same registry, so SIGTERM behaviour is now consistent across modes * and future subsystems can `registerShutdownHook` without hitting * Node's default MaxListeners cap. * * The registry runs hooks concurrently (Promise.allSettled), so any * hook with internal ordering requirements collapses its sequence * into a single closure. Worker drain → repo close is one such case: * closing the DB before WorkerManager.stop() finishes risks * SQLITE_BUSY on the in-flight job write, so we serialise them inside * one hook. */ export interface WorkerShutdownDeps { scheduler: { stop(): void }; /** * WorkerManager.stop() returns { drained, requeued } as of the AAO Gateway * refactor, but this shutdown hook discards the result. Widen to * `Promise` so the structural type accepts any stop() return shape * without recreating a coupling to WorkerManager's concrete type here. */ workerManager: { stop(): Promise }; repo: { close(): void }; } export function installWorkerShutdownHooks(deps: WorkerShutdownDeps): void { registerShutdownHook('worker-scheduler', async () => { deps.scheduler.stop(); }); registerShutdownHook('worker-manager+repo', async () => { await deps.workerManager.stop(); deps.repo.close(); }); installSignalHandlers(); }