From b9bc6b4ee2d8ac344cdc81c25e8a35bb97e4c5c8 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Tue, 3 Mar 2026 16:56:54 -0800 Subject: [PATCH 001/194] feat: add maestro:// deep link protocol and clickable OS notifications Introduce maestro:// URL scheme support for navigating to agents, tabs, and groups from external apps and OS notification clicks. - Add deep-links module with URL parsing, protocol registration, single-instance locking, and cross-platform event handling - Wire notification click handlers to navigate to the originating agent/tab via deep link dispatch - Thread sessionId/tabId context through notification preload bridge - Add onDeepLink listener in renderer with routing to existing navigation handlers - Register maestro:// protocol in electron-builder config - Add 18 tests covering URL parsing and notification click wiring --- package.json | 6 + src/__tests__/main/deep-links.test.ts | 141 ++++++++++++ .../main/ipc/handlers/notifications.test.ts | 48 +++- src/main/deep-links.ts | 216 ++++++++++++++++++ src/main/index.ts | 12 +- src/main/ipc/handlers/index.ts | 2 +- src/main/ipc/handlers/notifications.ts | 31 ++- src/main/preload/index.ts | 1 + src/main/preload/notifications.ts | 6 +- src/main/preload/system.ts | 11 + src/renderer/App.tsx | 29 +++ src/renderer/global.d.ts | 3 +- src/renderer/stores/notificationStore.ts | 2 +- src/shared/types.ts | 19 ++ 14 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/main/deep-links.test.ts create mode 100644 src/main/deep-links.ts diff --git a/package.json b/package.json index f12544e11..4e06faefa 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,12 @@ "npmRebuild": false, "appId": "com.maestro.app", "productName": "Maestro", + "protocols": [ + { + "name": "Maestro", + "schemes": ["maestro"] + } + ], "publish": { "provider": "github", "owner": "RunMaestro", diff --git a/src/__tests__/main/deep-links.test.ts b/src/__tests__/main/deep-links.test.ts new file mode 100644 index 000000000..25b373897 --- /dev/null +++ b/src/__tests__/main/deep-links.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for deep link URL parsing + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron before importing the module under test +vi.mock('electron', () => ({ + app: { + isPackaged: false, + setAsDefaultProtocolClient: vi.fn(), + requestSingleInstanceLock: vi.fn().mockReturnValue(true), + on: vi.fn(), + quit: vi.fn(), + }, + BrowserWindow: { + getAllWindows: vi.fn().mockReturnValue([]), + }, +})); + +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../main/utils/safe-send', () => ({ + isWebContentsAvailable: vi.fn().mockReturnValue(true), +})); + +import { parseDeepLink } from '../../main/deep-links'; + +describe('parseDeepLink', () => { + describe('focus action', () => { + it('should parse maestro://focus', () => { + expect(parseDeepLink('maestro://focus')).toEqual({ action: 'focus' }); + }); + + it('should parse empty path as focus', () => { + expect(parseDeepLink('maestro://')).toEqual({ action: 'focus' }); + }); + + it('should parse protocol-only as focus', () => { + expect(parseDeepLink('maestro:')).toEqual({ action: 'focus' }); + }); + }); + + describe('session action', () => { + it('should parse session URL', () => { + expect(parseDeepLink('maestro://session/abc123')).toEqual({ + action: 'session', + sessionId: 'abc123', + }); + }); + + it('should parse session URL with tab', () => { + expect(parseDeepLink('maestro://session/abc123/tab/tab456')).toEqual({ + action: 'session', + sessionId: 'abc123', + tabId: 'tab456', + }); + }); + + it('should decode URI-encoded session IDs', () => { + expect(parseDeepLink('maestro://session/session%20with%20space')).toEqual({ + action: 'session', + sessionId: 'session with space', + }); + }); + + it('should decode URI-encoded tab IDs', () => { + expect(parseDeepLink('maestro://session/abc/tab/tab%2Fslash')).toEqual({ + action: 'session', + sessionId: 'abc', + tabId: 'tab/slash', + }); + }); + + it('should return null for session without ID', () => { + expect(parseDeepLink('maestro://session')).toBeNull(); + expect(parseDeepLink('maestro://session/')).toBeNull(); + }); + + it('should ignore extra path segments after tab ID', () => { + const result = parseDeepLink('maestro://session/abc/tab/tab1/extra/stuff'); + expect(result).toEqual({ + action: 'session', + sessionId: 'abc', + tabId: 'tab1', + }); + }); + }); + + describe('group action', () => { + it('should parse group URL', () => { + expect(parseDeepLink('maestro://group/grp789')).toEqual({ + action: 'group', + groupId: 'grp789', + }); + }); + + it('should decode URI-encoded group IDs', () => { + expect(parseDeepLink('maestro://group/group%20name')).toEqual({ + action: 'group', + groupId: 'group name', + }); + }); + + it('should return null for group without ID', () => { + expect(parseDeepLink('maestro://group')).toBeNull(); + expect(parseDeepLink('maestro://group/')).toBeNull(); + }); + }); + + describe('Windows compatibility', () => { + it('should handle Windows maestro: prefix (no double slash)', () => { + expect(parseDeepLink('maestro:session/abc123')).toEqual({ + action: 'session', + sessionId: 'abc123', + }); + }); + + it('should handle Windows focus without double slash', () => { + expect(parseDeepLink('maestro:focus')).toEqual({ action: 'focus' }); + }); + }); + + describe('error handling', () => { + it('should return null for unrecognized resource', () => { + expect(parseDeepLink('maestro://unknown/abc')).toBeNull(); + }); + + it('should return null for completely malformed URLs', () => { + // parseDeepLink is tolerant of most inputs, but unrecognized resources return null + expect(parseDeepLink('maestro://settings')).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/ipc/handlers/notifications.test.ts b/src/__tests__/main/ipc/handlers/notifications.test.ts index add55b37c..30b6b1e7c 100644 --- a/src/__tests__/main/ipc/handlers/notifications.test.ts +++ b/src/__tests__/main/ipc/handlers/notifications.test.ts @@ -17,6 +17,7 @@ import { ipcMain } from 'electron'; const mocks = vi.hoisted(() => ({ mockNotificationShow: vi.fn(), mockNotificationIsSupported: vi.fn().mockReturnValue(true), + mockNotificationOn: vi.fn(), })); // Mock electron with a proper class for Notification @@ -29,6 +30,9 @@ vi.mock('electron', () => { show() { mocks.mockNotificationShow(); } + on(event: string, handler: () => void) { + mocks.mockNotificationOn(event, handler); + } static isSupported() { return mocks.mockNotificationIsSupported(); } @@ -55,6 +59,15 @@ vi.mock('../../../../main/utils/logger', () => ({ }, })); +// Mock deep-links module (used by notification click handler) +vi.mock('../../../../main/deep-links', () => ({ + parseDeepLink: vi.fn((url: string) => { + if (url.includes('session/')) return { action: 'session', sessionId: 'test-session' }; + return { action: 'focus' }; + }), + dispatchDeepLink: vi.fn(), +})); + // Mock child_process - must include default export vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); @@ -99,6 +112,8 @@ import { describe('Notification IPC Handlers', () => { let handlers: Map; + const mockGetMainWindow = vi.fn().mockReturnValue(null); + beforeEach(() => { vi.clearAllMocks(); resetNotificationState(); @@ -107,13 +122,14 @@ describe('Notification IPC Handlers', () => { // Reset mocks mocks.mockNotificationIsSupported.mockReturnValue(true); mocks.mockNotificationShow.mockClear(); + mocks.mockNotificationOn.mockClear(); // Capture registered handlers vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => { handlers.set(channel, handler); }); - registerNotificationsHandlers(); + registerNotificationsHandlers({ getMainWindow: mockGetMainWindow }); }); afterEach(() => { @@ -186,6 +202,36 @@ describe('Notification IPC Handlers', () => { }); }); + describe('notification:show click-to-navigate', () => { + it('should register click handler when sessionId is provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'session-123'); + + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('should register click handler when sessionId and tabId are provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'session-123', 'tab-456'); + + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('should not register click handler when sessionId is not provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body'); + + expect(mocks.mockNotificationOn).not.toHaveBeenCalled(); + }); + + it('should not register click handler when sessionId is undefined', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', undefined, undefined); + + expect(mocks.mockNotificationOn).not.toHaveBeenCalled(); + }); + }); + describe('notification:stopSpeak', () => { it('should return error when no active notification process', async () => { const handler = handlers.get('notification:stopSpeak')!; diff --git a/src/main/deep-links.ts b/src/main/deep-links.ts new file mode 100644 index 000000000..433606d43 --- /dev/null +++ b/src/main/deep-links.ts @@ -0,0 +1,216 @@ +/** + * Deep Link Handler for maestro:// URL scheme + * + * Provides OS-level protocol registration and URL parsing for deep links. + * Enables clickable OS notifications and external app integrations. + * + * URL scheme: + * maestro://focus — bring window to foreground + * maestro://session/{sessionId} — navigate to agent + * maestro://session/{sessionId}/tab/{tabId} — navigate to agent + tab + * maestro://group/{groupId} — expand group, focus first session + * + * Platform behavior: + * macOS: app.on('open-url') delivers the URL + * Windows/Linux: app.on('second-instance') delivers argv with URL; + * cold start delivers via process.argv + */ + +import { app, BrowserWindow } from 'electron'; +import { logger } from './utils/logger'; +import { isWebContentsAvailable } from './utils/safe-send'; +import type { ParsedDeepLink } from '../shared/types'; + +// ============================================================================ +// Constants +// ============================================================================ + +const PROTOCOL = 'maestro'; +const IPC_CHANNEL = 'app:deepLink'; + +// ============================================================================ +// State +// ============================================================================ + +/** URL received before the window was ready — flushed after createWindow() */ +let pendingDeepLinkUrl: string | null = null; + +// ============================================================================ +// URL Parsing +// ============================================================================ + +/** + * Parse a maestro:// URL into a structured deep link object. + * Returns null for malformed or unrecognized URLs. + */ +export function parseDeepLink(url: string): ParsedDeepLink | null { + try { + // Normalize: strip protocol prefix (handles both maestro:// and maestro: on Windows) + const normalized = url.replace(/^maestro:\/\//, '').replace(/^maestro:/, ''); + const parts = normalized.split('/').filter(Boolean); + + if (parts.length === 0) return { action: 'focus' }; + + const [resource, id, sub, subId] = parts; + + if (resource === 'focus') return { action: 'focus' }; + + if (resource === 'session' && id) { + if (sub === 'tab' && subId) { + return { action: 'session', sessionId: decodeURIComponent(id), tabId: decodeURIComponent(subId) }; + } + return { action: 'session', sessionId: decodeURIComponent(id) }; + } + + if (resource === 'group' && id) { + return { action: 'group', groupId: decodeURIComponent(id) }; + } + + logger.warn(`Unrecognized deep link resource: ${resource}`, 'DeepLink'); + return null; + } catch (error) { + logger.error('Failed to parse deep link URL', 'DeepLink', { url, error: String(error) }); + return null; + } +} + +// ============================================================================ +// Deep Link Dispatch +// ============================================================================ + +/** + * Process a deep link URL: parse it, bring window to foreground, and send to renderer. + */ +function processDeepLink(url: string, getMainWindow: () => BrowserWindow | null): void { + logger.info('Processing deep link', 'DeepLink', { url }); + + const parsed = parseDeepLink(url); + if (!parsed) return; + + const win = getMainWindow(); + if (!win) { + // Window not ready yet — buffer for later + pendingDeepLinkUrl = url; + logger.debug('Window not ready, buffering deep link', 'DeepLink'); + return; + } + + // Bring window to foreground + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + + // For 'focus' action, bringing window to front is all we need + if (parsed.action === 'focus') return; + + // Send parsed payload to renderer for navigation + if (isWebContentsAvailable(win)) { + win.webContents.send(IPC_CHANNEL, parsed); + } +} + +// ============================================================================ +// Lifecycle Setup +// ============================================================================ + +/** + * Set up deep link protocol handling. + * + * MUST be called synchronously before app.whenReady() because + * requestSingleInstanceLock() only works before the app is ready. + * + * @returns false if another instance is already running (caller should app.quit()) + */ +export function setupDeepLinkHandling(getMainWindow: () => BrowserWindow | null): boolean { + // Register as handler for maestro:// URLs + // In dev mode, skip registration to avoid clobbering the production app's registration + const isDev = !app.isPackaged; + if (!isDev) { + app.setAsDefaultProtocolClient(PROTOCOL); + logger.info('Registered as default protocol client for maestro://', 'DeepLink'); + } else { + // In dev, register only if explicitly opted in + if (process.env.REGISTER_DEEP_LINKS_IN_DEV === '1') { + app.setAsDefaultProtocolClient(PROTOCOL); + logger.info('Registered protocol client in dev mode (REGISTER_DEEP_LINKS_IN_DEV=1)', 'DeepLink'); + } else { + logger.debug('Skipping protocol registration in dev mode', 'DeepLink'); + } + } + + // Single-instance lock (Windows/Linux deep link support) + // On macOS, open-url handles this; on Windows/Linux, the OS launches a new instance + // with the URL in argv, and second-instance event fires in the primary instance + const gotTheLock = app.requestSingleInstanceLock(); + if (!gotTheLock) { + // Another instance is running — it will receive our argv via second-instance + logger.info('Another instance is running, quitting', 'DeepLink'); + return false; + } + + // Handle second-instance event (Windows/Linux: new instance launched with deep link URL) + app.on('second-instance', (_event, argv) => { + const deepLinkUrl = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`)); + if (deepLinkUrl) { + processDeepLink(deepLinkUrl, getMainWindow); + } else { + // No deep link, but user tried to open a second instance — bring existing window to front + const win = getMainWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } + } + }); + + // Handle open-url event (macOS: OS delivers URL to running app) + app.on('open-url', (event, url) => { + event.preventDefault(); + processDeepLink(url, getMainWindow); + }); + + // Check process.argv for cold-start deep link (Windows/Linux: app launched with URL as arg) + const deepLinkArg = process.argv.find((arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`)); + if (deepLinkArg) { + pendingDeepLinkUrl = deepLinkArg; + logger.info('Found deep link in process argv (cold start)', 'DeepLink', { url: deepLinkArg }); + } + + return true; +} + +/** + * Flush any pending deep link URL that arrived before the window was ready. + * Call this after createWindow() inside app.whenReady(). + */ +export function flushPendingDeepLink(getMainWindow: () => BrowserWindow | null): void { + if (!pendingDeepLinkUrl) return; + + const url = pendingDeepLinkUrl; + pendingDeepLinkUrl = null; + logger.info('Flushing pending deep link', 'DeepLink', { url }); + processDeepLink(url, getMainWindow); +} + +/** + * Directly dispatch a parsed deep link to the renderer. + * Used by notification click handlers to avoid an OS protocol round-trip. + */ +export function dispatchDeepLink( + parsed: ParsedDeepLink, + getMainWindow: () => BrowserWindow | null +): void { + const win = getMainWindow(); + if (!win) return; + + // Bring window to foreground + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + + if (parsed.action === 'focus') return; + + if (isWebContentsAvailable(win)) { + win.webContents.send(IPC_CHANNEL, parsed); + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 577f81530..9f1688e0f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -92,6 +92,7 @@ import { } from './constants'; // initAutoUpdater is now used by window-manager.ts (Phase 4 refactoring) import { checkWslEnvironment } from './utils/wslDetector'; +import { setupDeepLinkHandling, flushPendingDeepLink } from './deep-links'; // Extracted modules (Phase 1 refactoring) import { parseParticipantSessionId } from './group-chat/session-parser'; import { extractTextFromStreamJson } from './group-chat/output-parser'; @@ -292,6 +293,12 @@ function createWindow() { // Set up global error handlers for uncaught exceptions (Phase 4 refactoring) setupGlobalErrorHandlers(); +// Set up deep link protocol handling (must be before app.whenReady for requestSingleInstanceLock) +const gotSingleInstanceLock = setupDeepLinkHandling(() => mainWindow); +if (!gotSingleInstanceLock) { + app.quit(); +} + app.whenReady().then(async () => { // Load logger settings first const logLevel = store.get('logLevel', 'info'); @@ -395,6 +402,9 @@ app.whenReady().then(async () => { logger.info('Creating main window', 'Startup'); createWindow(); + // Flush any deep link URL that arrived before the window was ready (cold start) + flushPendingDeepLink(() => mainWindow); + // Note: History file watching is handled by HistoryManager.startWatching() above // which uses the new per-session file format in the history/ directory @@ -657,7 +667,7 @@ function setupIpcHandlers() { registerAgentErrorHandlers(); // Register notification handlers (extracted to handlers/notifications.ts) - registerNotificationsHandlers(); + registerNotificationsHandlers({ getMainWindow: () => mainWindow }); // Register attachments handlers (extracted to handlers/attachments.ts) registerAttachmentsHandlers({ app }); diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 1dfd2e805..6e980222c 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -260,7 +260,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void { settingsStore: deps.settingsStore, }); // Register notification handlers (OS notifications and TTS) - registerNotificationsHandlers(); + registerNotificationsHandlers({ getMainWindow: deps.getMainWindow }); // Register Symphony handlers for token donation / open source contributions registerSymphonyHandlers({ app: deps.app, diff --git a/src/main/ipc/handlers/notifications.ts b/src/main/ipc/handlers/notifications.ts index 95367b775..03f775c55 100644 --- a/src/main/ipc/handlers/notifications.ts +++ b/src/main/ipc/handlers/notifications.ts @@ -14,6 +14,7 @@ import { ipcMain, Notification, BrowserWindow } from 'electron'; import { spawn, type ChildProcess } from 'child_process'; import { logger } from '../../utils/logger'; import { isWebContentsAvailable } from '../../utils/safe-send'; +import { parseDeepLink, dispatchDeepLink } from '../../deep-links'; // ========================================================================== // Constants @@ -329,14 +330,21 @@ async function processNextNotification(): Promise { // Handler Registration // ========================================================================== +/** + * Dependencies for notification handlers + */ +export interface NotificationsHandlerDependencies { + getMainWindow: () => BrowserWindow | null; +} + /** * Register all notification-related IPC handlers */ -export function registerNotificationsHandlers(): void { - // Show OS notification +export function registerNotificationsHandlers(deps?: NotificationsHandlerDependencies): void { + // Show OS notification (with optional click-to-navigate support) ipcMain.handle( 'notification:show', - async (_event, title: string, body: string): Promise => { + async (_event, title: string, body: string, sessionId?: string, tabId?: string): Promise => { try { if (Notification.isSupported()) { const notification = new Notification({ @@ -344,8 +352,23 @@ export function registerNotificationsHandlers(): void { body, silent: true, // Don't play system sound - we have our own audio feedback option }); + + // Wire click handler for navigation if session context is provided + if (sessionId && deps?.getMainWindow) { + const deepLinkUrl = tabId + ? `maestro://session/${sessionId}/tab/${tabId}` + : `maestro://session/${sessionId}`; + + notification.on('click', () => { + const parsed = parseDeepLink(deepLinkUrl); + if (parsed) { + dispatchDeepLink(parsed, deps.getMainWindow); + } + }); + } + notification.show(); - logger.debug('Showed OS notification', 'Notification', { title, body }); + logger.debug('Showed OS notification', 'Notification', { title, body, sessionId, tabId }); return { success: true }; } else { logger.warn('OS notifications not supported on this platform', 'Notification'); diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f8..6749e8a66 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -303,6 +303,7 @@ export type { ShellInfo, UpdateStatus, } from './system'; +export type { ParsedDeepLink } from '../../shared/types'; export type { // From sshRemote SshRemoteApi, diff --git a/src/main/preload/notifications.ts b/src/main/preload/notifications.ts index 1f1f3d415..162de0187 100644 --- a/src/main/preload/notifications.ts +++ b/src/main/preload/notifications.ts @@ -35,9 +35,11 @@ export function createNotificationApi() { * Show an OS notification * @param title - Notification title * @param body - Notification body text + * @param sessionId - Optional session ID for click-to-navigate + * @param tabId - Optional tab ID for click-to-navigate */ - show: (title: string, body: string): Promise => - ipcRenderer.invoke('notification:show', title, body), + show: (title: string, body: string, sessionId?: string, tabId?: string): Promise => + ipcRenderer.invoke('notification:show', title, body, sessionId, tabId), /** * Execute a custom notification command (e.g., TTS, logging) diff --git a/src/main/preload/system.ts b/src/main/preload/system.ts index 14303a269..c88cb5c01 100644 --- a/src/main/preload/system.ts +++ b/src/main/preload/system.ts @@ -5,6 +5,7 @@ */ import { ipcRenderer } from 'electron'; +import type { ParsedDeepLink } from '../../shared/types'; /** * Shell information @@ -202,6 +203,16 @@ export function createAppApi() { ipcRenderer.on('app:systemResume', handler); return () => ipcRenderer.removeListener('app:systemResume', handler); }, + /** + * Listen for deep link navigation events (maestro:// URLs) + * Fired when the app is activated via a deep link from OS notification clicks, + * external apps, or CLI commands. + */ + onDeepLink: (callback: (deepLink: ParsedDeepLink) => void): (() => void) => { + const handler = (_: unknown, deepLink: ParsedDeepLink) => callback(deepLink); + ipcRenderer.on('app:deepLink', handler); + return () => ipcRenderer.removeListener('app:deepLink', handler); + }, }; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 667edde5c..829fc0c7c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1504,6 +1504,35 @@ function MaestroConsoleInner() { [setActiveSessionId] ); + // Deep link navigation handler — processes maestro:// URLs from OS notifications, + // external apps, and CLI commands + useEffect(() => { + const unsubscribe = window.maestro.app.onDeepLink((deepLink) => { + if (deepLink.action === 'focus') { + // Window already brought to foreground by main process + return; + } + if (deepLink.action === 'session' && deepLink.sessionId) { + handleToastSessionClick(deepLink.sessionId, deepLink.tabId); + return; + } + if (deepLink.action === 'group' && deepLink.groupId) { + // Find first session in group and navigate to it + const groupSession = sessions.find((s) => s.groupId === deepLink.groupId); + if (groupSession) { + handleToastSessionClick(groupSession.id); + } + // Expand the group if it's collapsed + setGroups((prev) => + prev.map((g) => + g.id === deepLink.groupId ? { ...g, collapsed: false } : g + ) + ); + } + }); + return unsubscribe; + }, [handleToastSessionClick, sessions, setGroups]); + // --- SESSION SORTING --- // Extracted hook for sorted and visible session lists (ignores leading emojis for alphabetization) const { sortedSessions, visibleSessions } = useSortedSessions({ diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 38d94ab07..bc36a97b9 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -1037,6 +1037,7 @@ interface MaestroAPI { confirmQuit: () => void; cancelQuit: () => void; onSystemResume: (callback: () => void) => () => void; + onDeepLink: (callback: (deepLink: { action: 'focus' | 'session' | 'group'; sessionId?: string; tabId?: string; groupId?: string }) => void) => () => void; }; platform: string; logger: { @@ -1317,7 +1318,7 @@ interface MaestroAPI { reload: () => Promise; }; notification: { - show: (title: string, body: string) => Promise<{ success: boolean; error?: string }>; + show: (title: string, body: string, sessionId?: string, tabId?: string) => Promise<{ success: boolean; error?: string }>; speak: ( text: string, command?: string diff --git a/src/renderer/stores/notificationStore.ts b/src/renderer/stores/notificationStore.ts index 5b7bd7d64..d380fe3c6 100644 --- a/src/renderer/stores/notificationStore.ts +++ b/src/renderer/stores/notificationStore.ts @@ -265,7 +265,7 @@ export function notifyToast(toast: Omit): string { const prefix = bodyParts.length > 0 ? `${bodyParts.join(' > ')}: ` : ''; const notifBody = prefix + firstSentence; - window.maestro.notification.show(notifTitle, notifBody).catch((err) => { + window.maestro.notification.show(notifTitle, notifBody, toast.sessionId, toast.tabId).catch((err) => { console.error('[notificationStore] Failed to show OS notification:', err); }); } diff --git a/src/shared/types.ts b/src/shared/types.ts index 663774a3c..5d5b1c058 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -373,6 +373,25 @@ export interface AgentSshRemoteConfig { workingDirOverride?: string; } +// ============================================================================ +// Deep Link Types +// ============================================================================ + +/** + * Parsed deep link from a maestro:// URL. + * Used by both main process (URL parsing) and renderer (navigation dispatch). + */ +export interface ParsedDeepLink { + /** The type of navigation action */ + action: 'focus' | 'session' | 'group'; + /** Maestro session ID (for action: 'session') */ + sessionId?: string; + /** Tab ID within the session (for action: 'session') */ + tabId?: string; + /** Group ID (for action: 'group') */ + groupId?: string; +} + // ============================================================================ // Global Agent Statistics Types // ============================================================================ From 96be37da63e805b057a6d3e91e6fba8e9822c4d4 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 09:45:15 -0800 Subject: [PATCH 002/194] fix: address PR review feedback for deep links - URI-encode sessionId/tabId when constructing deep link URLs in notification click handler to prevent malformed URLs with special chars - Add process.exit(0) after app.quit() so secondary instances exit immediately without running further module-level setup - Use useRef for sessions in deep link effect to avoid tearing down and re-registering the IPC listener on every sessions change - Guard against navigating to non-existent session IDs in deep link handler to prevent invalid UI state - Add cross-reference comment in global.d.ts linking to canonical ParsedDeepLink type (can't import in ambient declaration file) - Add test for URI-encoding round-trip in notification click handler --- .../main/ipc/handlers/notifications.test.ts | 14 ++++++++++++++ src/main/index.ts | 1 + src/main/ipc/handlers/notifications.ts | 12 +++++++++--- src/renderer/App.tsx | 16 +++++++++++----- src/renderer/global.d.ts | 17 +++++++++++++++-- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/notifications.test.ts b/src/__tests__/main/ipc/handlers/notifications.test.ts index 30b6b1e7c..a1ed411db 100644 --- a/src/__tests__/main/ipc/handlers/notifications.test.ts +++ b/src/__tests__/main/ipc/handlers/notifications.test.ts @@ -217,6 +217,20 @@ describe('Notification IPC Handlers', () => { expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); }); + it('should URI-encode sessionId and tabId in deep link URL', async () => { + const { parseDeepLink } = await import('../../../../main/deep-links'); + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'id/with/slashes', 'tab?special'); + + // Trigger the click handler + const clickHandler = mocks.mockNotificationOn.mock.calls[0][1]; + clickHandler(); + + expect(parseDeepLink).toHaveBeenCalledWith( + `maestro://session/${encodeURIComponent('id/with/slashes')}/tab/${encodeURIComponent('tab?special')}` + ); + }); + it('should not register click handler when sessionId is not provided', async () => { const handler = handlers.get('notification:show')!; await handler({}, 'Title', 'Body'); diff --git a/src/main/index.ts b/src/main/index.ts index 9f1688e0f..444c6b66a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -297,6 +297,7 @@ setupGlobalErrorHandlers(); const gotSingleInstanceLock = setupDeepLinkHandling(() => mainWindow); if (!gotSingleInstanceLock) { app.quit(); + process.exit(0); } app.whenReady().then(async () => { diff --git a/src/main/ipc/handlers/notifications.ts b/src/main/ipc/handlers/notifications.ts index 03f775c55..4b1ba37b7 100644 --- a/src/main/ipc/handlers/notifications.ts +++ b/src/main/ipc/handlers/notifications.ts @@ -344,7 +344,13 @@ export function registerNotificationsHandlers(deps?: NotificationsHandlerDepende // Show OS notification (with optional click-to-navigate support) ipcMain.handle( 'notification:show', - async (_event, title: string, body: string, sessionId?: string, tabId?: string): Promise => { + async ( + _event, + title: string, + body: string, + sessionId?: string, + tabId?: string + ): Promise => { try { if (Notification.isSupported()) { const notification = new Notification({ @@ -356,8 +362,8 @@ export function registerNotificationsHandlers(deps?: NotificationsHandlerDepende // Wire click handler for navigation if session context is provided if (sessionId && deps?.getMainWindow) { const deepLinkUrl = tabId - ? `maestro://session/${sessionId}/tab/${tabId}` - : `maestro://session/${sessionId}`; + ? `maestro://session/${encodeURIComponent(sessionId)}/tab/${encodeURIComponent(tabId)}` + : `maestro://session/${encodeURIComponent(sessionId)}`; notification.on('click', () => { const parsed = parseDeepLink(deepLinkUrl); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 829fc0c7c..9ddfbce86 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1504,6 +1504,12 @@ function MaestroConsoleInner() { [setActiveSessionId] ); + // Keep a ref to sessions so the deep link listener doesn't churn on every sessions change + const sessionsRef = useRef(sessions); + useEffect(() => { + sessionsRef.current = sessions; + }, [sessions]); + // Deep link navigation handler — processes maestro:// URLs from OS notifications, // external apps, and CLI commands useEffect(() => { @@ -1513,25 +1519,25 @@ function MaestroConsoleInner() { return; } if (deepLink.action === 'session' && deepLink.sessionId) { + const targetExists = sessionsRef.current.some((s) => s.id === deepLink.sessionId); + if (!targetExists) return; handleToastSessionClick(deepLink.sessionId, deepLink.tabId); return; } if (deepLink.action === 'group' && deepLink.groupId) { // Find first session in group and navigate to it - const groupSession = sessions.find((s) => s.groupId === deepLink.groupId); + const groupSession = sessionsRef.current.find((s) => s.groupId === deepLink.groupId); if (groupSession) { handleToastSessionClick(groupSession.id); } // Expand the group if it's collapsed setGroups((prev) => - prev.map((g) => - g.id === deepLink.groupId ? { ...g, collapsed: false } : g - ) + prev.map((g) => (g.id === deepLink.groupId ? { ...g, collapsed: false } : g)) ); } }); return unsubscribe; - }, [handleToastSessionClick, sessions, setGroups]); + }, [handleToastSessionClick, setGroups]); // --- SESSION SORTING --- // Extracted hook for sorted and visible session lists (ignores leading emojis for alphabetization) diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index bc36a97b9..83e96a3df 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -1037,7 +1037,15 @@ interface MaestroAPI { confirmQuit: () => void; cancelQuit: () => void; onSystemResume: (callback: () => void) => () => void; - onDeepLink: (callback: (deepLink: { action: 'focus' | 'session' | 'group'; sessionId?: string; tabId?: string; groupId?: string }) => void) => () => void; + /** @see ParsedDeepLink in src/shared/types.ts — keep in sync */ + onDeepLink: ( + callback: (deepLink: { + action: 'focus' | 'session' | 'group'; + sessionId?: string; + tabId?: string; + groupId?: string; + }) => void + ) => () => void; }; platform: string; logger: { @@ -1318,7 +1326,12 @@ interface MaestroAPI { reload: () => Promise; }; notification: { - show: (title: string, body: string, sessionId?: string, tabId?: string) => Promise<{ success: boolean; error?: string }>; + show: ( + title: string, + body: string, + sessionId?: string, + tabId?: string + ) => Promise<{ success: boolean; error?: string }>; speak: ( text: string, command?: string From df9c61d2e608c6bb48a40f2f3d9e6a521de47e00 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 10:04:56 -0800 Subject: [PATCH 003/194] feat: add deep link template variables, shared URL builders, and docs - Add shared deep-link-urls.ts with buildSessionDeepLink(), buildGroupDeepLink(), and buildFocusDeepLink() utilities - Add {{AGENT_DEEP_LINK}}, {{TAB_DEEP_LINK}}, {{GROUP_DEEP_LINK}} template variables available in system prompts, custom AI commands, and Auto Run documents - Wire activeTabId and groupId into TemplateContext at all call sites (agentStore, useInputProcessing, useRemoteHandlers, useDocumentProcessor, useMergeTransferHandlers, batch-processor) - Refactor notifications.ts to use shared buildSessionDeepLink() - Add sessionId/tabId to notifyToast callers where context is available (merge, transfer, summarize, PR creation) - Add docs/deep-links.md documentation page with URL format, usage examples, template variables, and platform behavior - Add 8 tests for URL builders, 6 tests for template variable substitution including URI encoding --- docs/deep-links.md | 96 +++++++++++++++++++ docs/docs.json | 2 +- src/__tests__/shared/deep-link-urls.test.ts | 54 +++++++++++ .../shared/templateVariables.test.ts | 63 ++++++++++++ src/cli/services/batch-processor.ts | 1 + src/main/ipc/handlers/notifications.ts | 5 +- src/renderer/App.tsx | 1 + .../hooks/agent/useMergeTransferHandlers.ts | 5 + .../hooks/agent/useSummarizeAndContinue.ts | 2 + .../hooks/batch/useDocumentProcessor.ts | 2 + .../hooks/input/useInputProcessing.ts | 4 + .../hooks/remote/useRemoteHandlers.ts | 2 + src/renderer/stores/agentStore.ts | 6 ++ src/shared/deep-link-urls.ts | 33 +++++++ src/shared/templateVariables.ts | 19 ++++ 15 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 docs/deep-links.md create mode 100644 src/__tests__/shared/deep-link-urls.test.ts create mode 100644 src/shared/deep-link-urls.ts diff --git a/docs/deep-links.md b/docs/deep-links.md new file mode 100644 index 000000000..a0e618dd7 --- /dev/null +++ b/docs/deep-links.md @@ -0,0 +1,96 @@ +--- +title: Deep Links +description: Navigate to specific agents, tabs, and groups using maestro:// URLs from external apps, scripts, and OS notifications. +icon: link +--- + +# Deep Links + +Maestro registers the `maestro://` URL protocol, enabling navigation to specific agents, tabs, and groups from external tools, scripts, shell commands, and OS notification clicks. + +## URL Format + +``` +maestro://[action]/[parameters] +``` + +### Available Actions + +| URL | Action | +| ------------------------------------------- | ------------------------------------------ | +| `maestro://focus` | Bring Maestro window to foreground | +| `maestro://session/{sessionId}` | Navigate to an agent | +| `maestro://session/{sessionId}/tab/{tabId}` | Navigate to a specific tab within an agent | +| `maestro://group/{groupId}` | Expand a group and focus its first agent | + +IDs containing special characters (`/`, `?`, `#`, `%`, etc.) are automatically URI-encoded and decoded. + +## Usage + +### From Terminal + +```bash +# macOS +open "maestro://session/abc123" +open "maestro://session/abc123/tab/def456" +open "maestro://group/my-group-id" +open "maestro://focus" + +# Linux +xdg-open "maestro://session/abc123" + +# Windows +start maestro://session/abc123 +``` + +### OS Notification Clicks + +When Maestro is running in the background and an agent completes a task, the OS notification is automatically linked to the originating agent and tab. Clicking the notification brings Maestro to the foreground and navigates directly to that agent's tab. + +This works out of the box — no configuration needed. Ensure **OS Notifications** are enabled in Settings. + +### Template Variables + +Deep link URLs are available as template variables in system prompts, custom AI commands, and Auto Run documents: + +| Variable | Description | Example Value | +| --------------------- | ---------------------------------------------- | ------------------------------------- | +| `{{AGENT_DEEP_LINK}}` | Link to the current agent | `maestro://session/abc123` | +| `{{TAB_DEEP_LINK}}` | Link to the current agent + active tab | `maestro://session/abc123/tab/def456` | +| `{{GROUP_DEEP_LINK}}` | Link to the agent's group (empty if ungrouped) | `maestro://group/grp789` | + +These variables can be used in: + +- **System prompts** — give AI agents awareness of their own deep link for cross-referencing +- **Custom AI commands** — include deep links in generated output +- **Auto Run documents** — reference agents in batch automation workflows +- **Custom notification commands** — include deep links in TTS or logging scripts + +### From Scripts and External Tools + +Any application can launch Maestro deep links by opening the URL. This enables integrations like: + +- CI/CD pipelines that open a specific agent after deployment +- Shell scripts that navigate to a group after batch operations +- Alfred/Raycast workflows for quick agent access +- Bookmarks for frequently-used agents + +## Platform Behavior + +| Platform | Mechanism | +| ----------------- | ----------------------------------------------------------------------------- | +| **macOS** | `app.on('open-url')` delivers the URL to the running instance | +| **Windows/Linux** | `app.on('second-instance')` delivers the URL via argv to the primary instance | +| **Cold start** | URL is buffered and processed after the window is ready | + +Maestro uses a single-instance lock — opening a deep link when Maestro is already running delivers the URL to the existing instance rather than launching a new one. + + +In development mode, protocol registration is skipped by default to avoid overriding the production app's handler. Set `REGISTER_DEEP_LINKS_IN_DEV=1` to enable it during development. + + +## Related + +- [Configuration](./configuration) — OS notification settings +- [General Usage](./general-usage) — Core UI and workflow patterns +- [MCP Server](./mcp-server) — Connect AI applications to Maestro diff --git a/docs/docs.json b/docs/docs.json index e786b6ea5..dcce5e6d7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -82,7 +82,7 @@ }, { "group": "Integrations", - "pages": ["mcp-server"], + "pages": ["mcp-server", "deep-links"], "icon": "plug" }, { diff --git a/src/__tests__/shared/deep-link-urls.test.ts b/src/__tests__/shared/deep-link-urls.test.ts new file mode 100644 index 000000000..f5163aca2 --- /dev/null +++ b/src/__tests__/shared/deep-link-urls.test.ts @@ -0,0 +1,54 @@ +/** + * Tests for src/shared/deep-link-urls.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + buildSessionDeepLink, + buildGroupDeepLink, + buildFocusDeepLink, +} from '../../shared/deep-link-urls'; + +describe('buildSessionDeepLink', () => { + it('should build a session-only deep link', () => { + expect(buildSessionDeepLink('abc123')).toBe('maestro://session/abc123'); + }); + + it('should build a session + tab deep link', () => { + expect(buildSessionDeepLink('abc123', 'tab456')).toBe('maestro://session/abc123/tab/tab456'); + }); + + it('should URI-encode session IDs with special characters', () => { + expect(buildSessionDeepLink('id/with/slashes')).toBe( + `maestro://session/${encodeURIComponent('id/with/slashes')}` + ); + }); + + it('should URI-encode tab IDs with special characters', () => { + expect(buildSessionDeepLink('sess', 'tab?special')).toBe( + `maestro://session/sess/tab/${encodeURIComponent('tab?special')}` + ); + }); + + it('should not include tab segment when tabId is undefined', () => { + expect(buildSessionDeepLink('abc123', undefined)).toBe('maestro://session/abc123'); + }); +}); + +describe('buildGroupDeepLink', () => { + it('should build a group deep link', () => { + expect(buildGroupDeepLink('grp789')).toBe('maestro://group/grp789'); + }); + + it('should URI-encode group IDs with special characters', () => { + expect(buildGroupDeepLink('group/name')).toBe( + `maestro://group/${encodeURIComponent('group/name')}` + ); + }); +}); + +describe('buildFocusDeepLink', () => { + it('should build a focus deep link', () => { + expect(buildFocusDeepLink()).toBe('maestro://focus'); + }); +}); diff --git a/src/__tests__/shared/templateVariables.test.ts b/src/__tests__/shared/templateVariables.test.ts index 6d1d8c3eb..84dc4d01a 100644 --- a/src/__tests__/shared/templateVariables.test.ts +++ b/src/__tests__/shared/templateVariables.test.ts @@ -93,6 +93,13 @@ describe('TEMPLATE_VARIABLES constant', () => { expect(variables).toContain('{{IS_GIT_REPO}}'); }); + it('should include deep link variables', () => { + const variables = TEMPLATE_VARIABLES.map((v) => v.variable); + expect(variables).toContain('{{AGENT_DEEP_LINK}}'); + expect(variables).toContain('{{TAB_DEEP_LINK}}'); + expect(variables).toContain('{{GROUP_DEEP_LINK}}'); + }); + it('should mark Auto Run-only variables with autoRunOnly flag', () => { const autoRunOnlyVars = TEMPLATE_VARIABLES.filter((v) => v.autoRunOnly); const autoRunOnlyNames = autoRunOnlyVars.map((v) => v.variable); @@ -565,6 +572,62 @@ describe('substituteTemplateVariables', () => { }); }); + describe('Deep Link Variables', () => { + it('should replace {{AGENT_DEEP_LINK}} with session deep link URL', () => { + const context = createTestContext({ + session: createTestSession({ id: 'sess-abc' }), + }); + const result = substituteTemplateVariables('Link: {{AGENT_DEEP_LINK}}', context); + expect(result).toBe('Link: maestro://session/sess-abc'); + }); + + it('should replace {{TAB_DEEP_LINK}} with session+tab deep link when activeTabId provided', () => { + const context = createTestContext({ + session: createTestSession({ id: 'sess-abc' }), + activeTabId: 'tab-def', + }); + const result = substituteTemplateVariables('Link: {{TAB_DEEP_LINK}}', context); + expect(result).toBe('Link: maestro://session/sess-abc/tab/tab-def'); + }); + + it('should replace {{TAB_DEEP_LINK}} with session-only link when no activeTabId', () => { + const context = createTestContext({ + session: createTestSession({ id: 'sess-abc' }), + }); + const result = substituteTemplateVariables('Link: {{TAB_DEEP_LINK}}', context); + expect(result).toBe('Link: maestro://session/sess-abc'); + }); + + it('should replace {{GROUP_DEEP_LINK}} with group deep link when groupId provided', () => { + const context = createTestContext({ + groupId: 'grp-789', + }); + const result = substituteTemplateVariables('Link: {{GROUP_DEEP_LINK}}', context); + expect(result).toBe('Link: maestro://group/grp-789'); + }); + + it('should replace {{GROUP_DEEP_LINK}} with empty string when no groupId', () => { + const context = createTestContext(); + const result = substituteTemplateVariables('Link: {{GROUP_DEEP_LINK}}', context); + expect(result).toBe('Link: '); + }); + + it('should URI-encode special characters in deep link IDs', () => { + const context = createTestContext({ + session: createTestSession({ id: 'id/with/slashes' }), + activeTabId: 'tab?special', + groupId: 'group#hash', + }); + const agentResult = substituteTemplateVariables('{{AGENT_DEEP_LINK}}', context); + const tabResult = substituteTemplateVariables('{{TAB_DEEP_LINK}}', context); + const groupResult = substituteTemplateVariables('{{GROUP_DEEP_LINK}}', context); + + expect(agentResult).toContain(encodeURIComponent('id/with/slashes')); + expect(tabResult).toContain(encodeURIComponent('tab?special')); + expect(groupResult).toContain(encodeURIComponent('group#hash')); + }); + }); + describe('Case Insensitivity', () => { it('should handle lowercase variables', () => { const context = createTestContext({ diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 3127cac6a..20e2e19a1 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -397,6 +397,7 @@ export async function* runPlaybook( }, gitBranch, groupName, + groupId: session.groupId, autoRunFolder: folderPath, loopNumber: loopIteration + 1, // 1-indexed documentName: docEntry.filename, diff --git a/src/main/ipc/handlers/notifications.ts b/src/main/ipc/handlers/notifications.ts index 4b1ba37b7..aed1e00a3 100644 --- a/src/main/ipc/handlers/notifications.ts +++ b/src/main/ipc/handlers/notifications.ts @@ -15,6 +15,7 @@ import { spawn, type ChildProcess } from 'child_process'; import { logger } from '../../utils/logger'; import { isWebContentsAvailable } from '../../utils/safe-send'; import { parseDeepLink, dispatchDeepLink } from '../../deep-links'; +import { buildSessionDeepLink } from '../../../shared/deep-link-urls'; // ========================================================================== // Constants @@ -361,9 +362,7 @@ export function registerNotificationsHandlers(deps?: NotificationsHandlerDepende // Wire click handler for navigation if session context is provided if (sessionId && deps?.getMainWindow) { - const deepLinkUrl = tabId - ? `maestro://session/${encodeURIComponent(sessionId)}/tab/${encodeURIComponent(tabId)}` - : `maestro://session/${encodeURIComponent(sessionId)}`; + const deepLinkUrl = buildSessionDeepLink(sessionId, tabId); notification.on('click', () => { const parsed = parseDeepLink(deepLinkUrl); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9ddfbce86..0e5ccd515 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1756,6 +1756,7 @@ function MaestroConsoleInner() { message: prDetails.title, actionUrl: prDetails.url, actionLabel: prDetails.url, + sessionId: session?.id, }); // Add history entry with PR details if (session) { diff --git a/src/renderer/hooks/agent/useMergeTransferHandlers.ts b/src/renderer/hooks/agent/useMergeTransferHandlers.ts index d3eb80d9a..714fe679f 100644 --- a/src/renderer/hooks/agent/useMergeTransferHandlers.ts +++ b/src/renderer/hooks/agent/useMergeTransferHandlers.ts @@ -186,6 +186,8 @@ export function useMergeTransferHandlers( message: `"${result.sourceSessionName || 'Current Session'}" → "${ result.targetSessionName || 'Selected Session' }"${tokenInfo}.${savedInfo}`, + sessionId: result.targetSessionId, + tabId: result.targetTabId, }); // Clear the merge state for the source tab @@ -220,6 +222,7 @@ export function useMergeTransferHandlers( type: 'success', title: 'Context Transferred', message: `Created "${sessionName}" with transferred context`, + sessionId, }); // Show desktop notification for visibility when app is not focused @@ -474,6 +477,8 @@ You are taking over this conversation. Based on the context above, provide a bri const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session: targetSession, gitBranch, + groupId: targetSession.groupId, + activeTabId: newTabId, conductorProfile, }); effectivePrompt = `${substitutedSystemPrompt}\n\n---\n\n# User Request\n\n${effectivePrompt}`; diff --git a/src/renderer/hooks/agent/useSummarizeAndContinue.ts b/src/renderer/hooks/agent/useSummarizeAndContinue.ts index bc211bf0b..8980ec947 100644 --- a/src/renderer/hooks/agent/useSummarizeAndContinue.ts +++ b/src/renderer/hooks/agent/useSummarizeAndContinue.ts @@ -347,6 +347,8 @@ export function useSummarizeAndContinue(session: Session | null): UseSummarizeAn type: 'warning', title: 'Cannot Compact', message: `Context too small. Need at least ${contextSummarizationService.getMinContextUsagePercent()}% usage, ~2k tokens, or 8+ messages to compact.`, + sessionId, + tabId: targetTabId, }); return; } diff --git a/src/renderer/hooks/batch/useDocumentProcessor.ts b/src/renderer/hooks/batch/useDocumentProcessor.ts index 3fa69365a..f0e173694 100644 --- a/src/renderer/hooks/batch/useDocumentProcessor.ts +++ b/src/renderer/hooks/batch/useDocumentProcessor.ts @@ -297,6 +297,8 @@ export function useDocumentProcessor(): UseDocumentProcessorReturn { session, gitBranch, groupName, + groupId: session.groupId, + activeTabId: session.activeTabId, autoRunFolder: folderPath, loopNumber: loopIteration, // Already 1-indexed from caller documentName: filename, diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts index 69cece5fb..46cd1f410 100644 --- a/src/renderer/hooks/input/useInputProcessing.ts +++ b/src/renderer/hooks/input/useInputProcessing.ts @@ -244,6 +244,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces substituteTemplateVariables(matchingCustomCommand.prompt, { session: activeSession, gitBranch, + groupId: activeSession.groupId, + activeTabId: activeSession.activeTabId, conductorProfile, }); @@ -969,6 +971,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session: freshSession, gitBranch, + groupId: freshSession.groupId, + activeTabId: freshSession.activeTabId, historyFilePath, conductorProfile, }); diff --git a/src/renderer/hooks/remote/useRemoteHandlers.ts b/src/renderer/hooks/remote/useRemoteHandlers.ts index eae21ec64..273c1b3eb 100644 --- a/src/renderer/hooks/remote/useRemoteHandlers.ts +++ b/src/renderer/hooks/remote/useRemoteHandlers.ts @@ -285,6 +285,8 @@ export function useRemoteHandlers(deps: UseRemoteHandlersDeps): UseRemoteHandler promptToSend = substituteTemplateVariables(matchingCommand.prompt, { session, gitBranch, + groupId: session.groupId, + activeTabId: session.activeTabId, conductorProfile, }); commandMetadata = { diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts index c377651a3..3aa83cee1 100644 --- a/src/renderer/stores/agentStore.ts +++ b/src/renderer/stores/agentStore.ts @@ -314,6 +314,8 @@ export const useAgentStore = create()((set, get) => ({ const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session, gitBranch, + groupId: session.groupId, + activeTabId: targetTab.id, conductorProfile: deps.conductorProfile, }); @@ -384,6 +386,8 @@ export const useAgentStore = create()((set, get) => ({ const substitutedPrompt = substituteTemplateVariables(promptWithArgs, { session, gitBranch, + groupId: session.groupId, + activeTabId: targetTab.id, conductorProfile: deps.conductorProfile, }); @@ -394,6 +398,8 @@ export const useAgentStore = create()((set, get) => ({ const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session, gitBranch, + groupId: session.groupId, + activeTabId: targetTab.id, conductorProfile: deps.conductorProfile, }); promptForAgent = `${substitutedSystemPrompt}\n\n---\n\n# User Request\n\n${substitutedPrompt}`; diff --git a/src/shared/deep-link-urls.ts b/src/shared/deep-link-urls.ts new file mode 100644 index 000000000..d6383e440 --- /dev/null +++ b/src/shared/deep-link-urls.ts @@ -0,0 +1,33 @@ +/** + * Deep Link URL Builders + * + * Shared utilities for constructing maestro:// URLs with proper URI encoding. + * Used by both main process (notification click handlers) and shared modules + * (template variable substitution). + */ + +const PROTOCOL = 'maestro://'; + +/** + * Build a deep link URL for a session, optionally targeting a specific tab. + */ +export function buildSessionDeepLink(sessionId: string, tabId?: string): string { + if (tabId) { + return `${PROTOCOL}session/${encodeURIComponent(sessionId)}/tab/${encodeURIComponent(tabId)}`; + } + return `${PROTOCOL}session/${encodeURIComponent(sessionId)}`; +} + +/** + * Build a deep link URL for a group. + */ +export function buildGroupDeepLink(groupId: string): string { + return `${PROTOCOL}group/${encodeURIComponent(groupId)}`; +} + +/** + * Build a deep link URL that simply brings the window to foreground. + */ +export function buildFocusDeepLink(): string { + return `${PROTOCOL}focus`; +} diff --git a/src/shared/templateVariables.ts b/src/shared/templateVariables.ts index 39ecae010..afd395c22 100644 --- a/src/shared/templateVariables.ts +++ b/src/shared/templateVariables.ts @@ -1,3 +1,5 @@ +import { buildSessionDeepLink, buildGroupDeepLink } from './deep-link-urls'; + /** * Template Variable System for Auto Run and Custom AI Commands * @@ -40,6 +42,11 @@ * {{GIT_BRANCH}} - Current git branch name (requires git repo) * {{IS_GIT_REPO}} - "true" or "false" * + * Deep Link Variables: + * {{AGENT_DEEP_LINK}} - maestro:// deep link to this agent + * {{TAB_DEEP_LINK}} - maestro:// deep link to this agent + active tab + * {{GROUP_DEEP_LINK}} - maestro:// deep link to this agent's group (if grouped) + * * Context Variables: * {{CONTEXT_USAGE}} - Current context window usage percentage */ @@ -64,6 +71,8 @@ export interface TemplateContext { session: TemplateSessionInfo; gitBranch?: string; groupName?: string; + groupId?: string; + activeTabId?: string; autoRunFolder?: string; loopNumber?: number; // Auto Run document context @@ -78,6 +87,7 @@ export interface TemplateContext { // List of all available template variables for documentation (alphabetically sorted) // Variables marked as autoRunOnly are only shown in Auto Run contexts, not in AI Commands settings export const TEMPLATE_VARIABLES = [ + { variable: '{{AGENT_DEEP_LINK}}', description: 'Deep link to this agent (maestro://)' }, { variable: '{{AGENT_GROUP}}', description: 'Agent group name' }, { variable: '{{CONDUCTOR_PROFILE}}', description: "Conductor's About Me profile" }, { variable: '{{AGENT_HISTORY_PATH}}', description: 'History file path (task recall)' }, @@ -95,6 +105,7 @@ export const TEMPLATE_VARIABLES = [ { variable: '{{DOCUMENT_NAME}}', description: 'Current document name', autoRunOnly: true }, { variable: '{{DOCUMENT_PATH}}', description: 'Current document path', autoRunOnly: true }, { variable: '{{GIT_BRANCH}}', description: 'Git branch name' }, + { variable: '{{GROUP_DEEP_LINK}}', description: 'Deep link to agent group (maestro://)' }, { variable: '{{IS_GIT_REPO}}', description: 'Is git repo (true/false)' }, { variable: '{{LOOP_NUMBER}}', @@ -102,6 +113,7 @@ export const TEMPLATE_VARIABLES = [ autoRunOnly: true, }, { variable: '{{MONTH}}', description: 'Month (01-12)' }, + { variable: '{{TAB_DEEP_LINK}}', description: 'Deep link to agent + active tab (maestro://)' }, { variable: '{{TIME}}', description: 'Time (HH:MM:SS)' }, { variable: '{{TIMESTAMP}}', description: 'Unix timestamp (ms)' }, { variable: '{{TIME_SHORT}}', description: 'Time (HH:MM)' }, @@ -121,6 +133,8 @@ export function substituteTemplateVariables(template: string, context: TemplateC session, gitBranch, groupName, + groupId, + activeTabId, autoRunFolder, loopNumber, documentName, @@ -181,6 +195,11 @@ export function substituteTemplateVariables(template: string, context: TemplateC GIT_BRANCH: gitBranch || '', IS_GIT_REPO: String(session.isGitRepo ?? false), + // Deep link variables + AGENT_DEEP_LINK: buildSessionDeepLink(session.id), + TAB_DEEP_LINK: buildSessionDeepLink(session.id, activeTabId), + GROUP_DEEP_LINK: groupId ? buildGroupDeepLink(groupId) : '', + // Context variables CONTEXT_USAGE: String(session.contextUsage || 0), }; From 089b835f9ac033430f6cb81bfa2b2ee6b5cdd500 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Tue, 3 Mar 2026 16:51:42 -0800 Subject: [PATCH 004/194] feat: stream real-time history entries into Director's Notes with live activity indicators Broadcast new history entries via IPC when they are added, subscribe in the UnifiedHistoryTab with RAF batching and deduplication, and extend the HistoryStatsBar with spinning Active agent count and Queued message count indicators derived from the Zustand session store. --- .../main/ipc/handlers/history.test.ts | 16 ++- src/main/ipc/handlers/history.ts | 11 +- src/main/ipc/handlers/index.ts | 8 +- src/main/preload/directorNotes.ts | 16 +++ .../DirectorNotes/UnifiedHistoryTab.tsx | 113 +++++++++++++++++- .../components/History/HistoryStatsBar.tsx | 54 ++++++++- src/renderer/global.d.ts | 28 +++++ 7 files changed, 238 insertions(+), 8 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/history.test.ts b/src/__tests__/main/ipc/handlers/history.test.ts index e612489d7..ff1216e06 100644 --- a/src/__tests__/main/ipc/handlers/history.test.ts +++ b/src/__tests__/main/ipc/handlers/history.test.ts @@ -38,6 +38,7 @@ vi.mock('../../../../main/utils/logger', () => ({ describe('history IPC handlers', () => { let handlers: Map; let mockHistoryManager: Partial; + let mockSafeSend: ReturnType; // Sample history entries for testing const createMockEntry = (overrides: Partial = {}): HistoryEntry => ({ @@ -54,6 +55,8 @@ describe('history IPC handlers', () => { // Clear mocks vi.clearAllMocks(); + mockSafeSend = vi.fn(); + // Create mock history manager mockHistoryManager = { getEntries: vi.fn().mockReturnValue([]), @@ -101,8 +104,8 @@ describe('history IPC handlers', () => { handlers.set(channel, handler); }); - // Register handlers - registerHistoryHandlers(); + // Register handlers with mock safeSend + registerHistoryHandlers({ safeSend: mockSafeSend }); }); afterEach(() => { @@ -282,6 +285,15 @@ describe('history IPC handlers', () => { expect(result).toBe(true); }); + it('should broadcast entry via safeSend after adding', async () => { + const entry = createMockEntry({ sessionId: 'session-1', projectPath: '/test' }); + + const handler = handlers.get('history:add'); + await handler!({} as any, entry); + + expect(mockSafeSend).toHaveBeenCalledWith('history:entryAdded', entry, 'session-1'); + }); + it('should use orphaned session ID when sessionId is missing', async () => { const entry = createMockEntry({ sessionId: undefined, projectPath: '/test' }); diff --git a/src/main/ipc/handlers/history.ts b/src/main/ipc/handlers/history.ts index 1bc5875ef..ad582fe63 100644 --- a/src/main/ipc/handlers/history.ts +++ b/src/main/ipc/handlers/history.ts @@ -18,9 +18,14 @@ import { HistoryEntry } from '../../../shared/types'; import { PaginationOptions, ORPHANED_SESSION_ID } from '../../../shared/history'; import { getHistoryManager } from '../../history-manager'; import { withIpcErrorLogging, CreateHandlerOptions } from '../../utils/ipcHandler'; +import type { SafeSendFn } from '../../utils/safe-send'; const LOG_CONTEXT = '[History]'; +export interface HistoryHandlerDependencies { + safeSend: SafeSendFn; +} + // Helper to create handler options with consistent context const handlerOpts = (operation: string): Pick => ({ context: LOG_CONTEXT, @@ -39,7 +44,7 @@ const handlerOpts = (operation: string): Pick => ipcRenderer.invoke('director-notes:generateSynopsis', options), + + /** + * Subscribe to new history entries as they are added in real-time. + * Returns a cleanup function to unsubscribe. + */ + onHistoryEntryAdded: ( + callback: (entry: UnifiedHistoryEntry, sourceSessionId: string) => void + ): (() => void) => { + const handler = (_event: unknown, entry: UnifiedHistoryEntry, sessionId: string) => { + callback(entry, sessionId); + }; + ipcRenderer.on('history:entryAdded', handler); + return () => { + ipcRenderer.removeListener('history:entryAdded', handler); + }; + }, }; } diff --git a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx index eaeda0ba3..ec483afe3 100644 --- a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx +++ b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx @@ -23,6 +23,7 @@ import { import type { HistoryStats } from '../History'; import { HistoryDetailModal } from '../HistoryDetailModal'; import { useListNavigation, useSettings } from '../../hooks'; +import { useSessionStore } from '../../stores/sessionStore'; import type { TabFocusHandle } from './OverviewTab'; /** Page size for progressive loading */ @@ -86,6 +87,114 @@ export const UnifiedHistoryTab = forwardRef(null); + // --- Live agent activity from Zustand (primitive selectors for efficient re-renders) --- + const activeAgentCount = useSessionStore( + (s) => s.sessions.filter((sess) => sess.state === 'busy').length + ); + const totalQueuedItems = useSessionStore((s) => + s.sessions.reduce((sum, sess) => sum + (sess.executionQueue?.length || 0), 0) + ); + + // Merge live counts into history stats for the stats bar + const enrichedStats = useMemo(() => { + if (!historyStats) return null; + return { + ...historyStats, + activeAgentCount, + totalQueuedItems, + }; + }, [historyStats, activeAgentCount, totalQueuedItems]); + + // --- Real-time streaming of new history entries --- + const pendingEntriesRef = useRef([]); + const rafIdRef = useRef(null); + + // Build session name map for enriching streamed entries with agentName + const sessionNameMap = useSessionStore((s) => { + const map = new Map(); + for (const sess of s.sessions) { + map.set(sess.id, sess.name); + } + return map; + }); + + useEffect(() => { + const flushPending = () => { + rafIdRef.current = null; + const batch = pendingEntriesRef.current; + if (batch.length === 0) return; + pendingEntriesRef.current = []; + + setEntries((prev) => { + const existingIds = new Set(prev.map((e) => e.id)); + const newEntries = batch.filter((e) => !existingIds.has(e.id)); + if (newEntries.length === 0) return prev; + const merged = [...newEntries, ...prev]; + merged.sort((a, b) => b.timestamp - a.timestamp); + return merged; + }); + + setTotalEntries((prev) => prev + batch.length); + + // Update graph entries for ActivityGraph + setGraphEntries((prev) => { + const existingIds = new Set(prev.map((e) => e.id)); + const newEntries = batch.filter((e) => !existingIds.has(e.id)); + if (newEntries.length === 0) return prev; + const merged = [...newEntries, ...prev]; + merged.sort((a, b) => b.timestamp - a.timestamp); + return merged; + }); + + // Incrementally update stats counters + setHistoryStats((prev) => { + if (!prev) return prev; + let newAuto = 0; + let newUser = 0; + for (const entry of batch) { + if (entry.type === 'AUTO') newAuto++; + else if (entry.type === 'USER') newUser++; + } + return { + ...prev, + autoCount: prev.autoCount + newAuto, + userCount: prev.userCount + newUser, + totalCount: prev.totalCount + newAuto + newUser, + }; + }); + }; + + const cleanup = window.maestro.directorNotes.onHistoryEntryAdded( + (rawEntry, sourceSessionId) => { + // Check if entry is within lookback window + if (lookbackHours !== null) { + const cutoff = Date.now() - lookbackHours * 60 * 60 * 1000; + if (rawEntry.timestamp < cutoff) return; + } + + const enriched = { + ...rawEntry, + sourceSessionId, + agentName: sessionNameMap.get(sourceSessionId), + } as UnifiedHistoryEntry; + + pendingEntriesRef.current.push(enriched); + + // Coalesce into a single frame update + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(flushPending); + } + } + ); + + return () => { + cleanup(); + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + }; + }, [lookbackHours, sessionNameMap]); + useImperativeHandle( ref, () => ({ @@ -439,8 +548,8 @@ export const UnifiedHistoryTab = forwardRef {/* Stats bar — scrolls with entries */} - {!isLoading && historyStats && historyStats.totalCount > 0 && ( - + {!isLoading && enrichedStats && enrichedStats.totalCount > 0 && ( + )} {isLoading ? ( diff --git a/src/renderer/components/History/HistoryStatsBar.tsx b/src/renderer/components/History/HistoryStatsBar.tsx index 5e7da6588..eeaccbf92 100644 --- a/src/renderer/components/History/HistoryStatsBar.tsx +++ b/src/renderer/components/History/HistoryStatsBar.tsx @@ -1,5 +1,5 @@ import React, { memo } from 'react'; -import { Layers, Hash, Bot, User, BarChart3 } from 'lucide-react'; +import { Layers, Hash, Bot, User, BarChart3, Loader2, ListOrdered } from 'lucide-react'; import type { Theme } from '../../types'; export interface HistoryStats { @@ -8,6 +8,10 @@ export interface HistoryStats { autoCount: number; userCount: number; totalCount: number; + /** Number of agents currently in 'busy' state (live indicator) */ + activeAgentCount?: number; + /** Total queued messages across all agents (live indicator) */ + totalQueuedItems?: number; } interface HistoryStatsBarProps { @@ -45,6 +49,10 @@ function StatItem({ icon, label, value, color, theme }: StatItemProps) { ); } +const showLiveIndicators = (stats: HistoryStats) => + (stats.activeAgentCount !== undefined && stats.activeAgentCount > 0) || + (stats.totalQueuedItems !== undefined && stats.totalQueuedItems > 0); + export const HistoryStatsBar = memo(function HistoryStatsBar({ stats, theme, @@ -88,6 +96,50 @@ export const HistoryStatsBar = memo(function HistoryStatsBar({ color={theme.colors.textMain} theme={theme} /> + + {/* Live activity indicators — only shown when provided and > 0 */} + {showLiveIndicators(stats) && ( + <> +
+ {stats.activeAgentCount !== undefined && stats.activeAgentCount > 0 && ( +
+ + + + + Active + + + {stats.activeAgentCount} + +
+ )} + {stats.totalQueuedItems !== undefined && stats.totalQueuedItems > 0 && ( + } + label="Queued" + value={stats.totalQueuedItems} + color={theme.colors.accent} + theme={theme} + /> + )} + + )}
); }); diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 83e96a3df..94c0da10a 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -2711,6 +2711,34 @@ interface MaestroAPI { }; error?: string; }>; + /** Subscribe to new history entries as they are added in real-time. Returns cleanup function. */ + onHistoryEntryAdded: ( + callback: ( + entry: { + id: string; + type: HistoryEntryType; + timestamp: number; + summary: string; + fullResponse?: string; + agentSessionId?: string; + sessionName?: string; + projectPath: string; + sessionId?: string; + contextUsage?: number; + success?: boolean; + elapsedTimeMs?: number; + validated?: boolean; + usageStats?: { + totalCostUsd: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + }; + }, + sourceSessionId: string + ) => void + ) => () => void; }; // WakaTime API (CLI check, API key validation) From dce2e6d17a853e386e263620ab8bcfce0b313076 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 09:40:08 -0800 Subject: [PATCH 005/194] =?UTF-8?q?fix:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20stable=20ref=20pattern,=20deduplicated=20counters,?= =?UTF-8?q?=20correct=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace unstable sessionNameMap Zustand selector (new Map per render) with a stable ref + subscribe pattern to avoid streaming effect re-subscription - Dedupe within batch before merging; compute setTotalEntries and setHistoryStats from deduplicated entries only (not raw batch) - Clear pendingEntriesRef on cleanup to prevent stale replay after resubscribe - Use HistoryEntry (not UnifiedHistoryEntry) in preload callback type since the wire payload lacks sourceSessionId - Use canonical UsageStats interface in global.d.ts (fixes pre-existing cacheReadTokens/cacheWriteTokens field name mismatch) --- src/main/preload/directorNotes.ts | 6 +- .../DirectorNotes/UnifiedHistoryTab.tsx | 74 +++++++++++-------- src/renderer/global.d.ts | 16 +--- 3 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/main/preload/directorNotes.ts b/src/main/preload/directorNotes.ts index 325e184da..26394266c 100644 --- a/src/main/preload/directorNotes.ts +++ b/src/main/preload/directorNotes.ts @@ -7,7 +7,7 @@ */ import { ipcRenderer } from 'electron'; -import type { ToolType } from '../../shared/types'; +import type { ToolType, HistoryEntry } from '../../shared/types'; /** Aggregate stats returned alongside unified history */ export interface UnifiedHistoryStats { @@ -112,9 +112,9 @@ export function createDirectorNotesApi() { * Returns a cleanup function to unsubscribe. */ onHistoryEntryAdded: ( - callback: (entry: UnifiedHistoryEntry, sourceSessionId: string) => void + callback: (entry: HistoryEntry, sourceSessionId: string) => void ): (() => void) => { - const handler = (_event: unknown, entry: UnifiedHistoryEntry, sessionId: string) => { + const handler = (_event: unknown, entry: HistoryEntry, sessionId: string) => { callback(entry, sessionId); }; ipcRenderer.on('history:entryAdded', handler); diff --git a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx index ec483afe3..501c64bac 100644 --- a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx +++ b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx @@ -109,14 +109,13 @@ export const UnifiedHistoryTab = forwardRef([]); const rafIdRef = useRef(null); - // Build session name map for enriching streamed entries with agentName - const sessionNameMap = useSessionStore((s) => { - const map = new Map(); - for (const sess of s.sessions) { - map.set(sess.id, sess.name); - } - return map; - }); + // Stable ref for session names — avoids making the streaming effect depend on session state + const sessionsRef = useRef(useSessionStore.getState().sessions); + useEffect(() => { + return useSessionStore.subscribe((s) => { + sessionsRef.current = s.sessions; + }); + }, []); useEffect(() => { const flushPending = () => { @@ -125,43 +124,55 @@ export const UnifiedHistoryTab = forwardRef(); + const uniqueBatch: UnifiedHistoryEntry[] = []; + for (const entry of batch) { + if (!seen.has(entry.id)) { + seen.add(entry.id); + uniqueBatch.push(entry); + } + } + setEntries((prev) => { const existingIds = new Set(prev.map((e) => e.id)); - const newEntries = batch.filter((e) => !existingIds.has(e.id)); + const newEntries = uniqueBatch.filter((e) => !existingIds.has(e.id)); if (newEntries.length === 0) return prev; + + // Update total count to match actual additions + setTotalEntries((t) => t + newEntries.length); + + // Incrementally update stats counters from deduplicated entries + setHistoryStats((prevStats) => { + if (!prevStats) return prevStats; + let newAuto = 0; + let newUser = 0; + for (const entry of newEntries) { + if (entry.type === 'AUTO') newAuto++; + else if (entry.type === 'USER') newUser++; + } + return { + ...prevStats, + autoCount: prevStats.autoCount + newAuto, + userCount: prevStats.userCount + newUser, + totalCount: prevStats.totalCount + newAuto + newUser, + }; + }); + const merged = [...newEntries, ...prev]; merged.sort((a, b) => b.timestamp - a.timestamp); return merged; }); - setTotalEntries((prev) => prev + batch.length); - // Update graph entries for ActivityGraph setGraphEntries((prev) => { const existingIds = new Set(prev.map((e) => e.id)); - const newEntries = batch.filter((e) => !existingIds.has(e.id)); + const newEntries = uniqueBatch.filter((e) => !existingIds.has(e.id)); if (newEntries.length === 0) return prev; const merged = [...newEntries, ...prev]; merged.sort((a, b) => b.timestamp - a.timestamp); return merged; }); - - // Incrementally update stats counters - setHistoryStats((prev) => { - if (!prev) return prev; - let newAuto = 0; - let newUser = 0; - for (const entry of batch) { - if (entry.type === 'AUTO') newAuto++; - else if (entry.type === 'USER') newUser++; - } - return { - ...prev, - autoCount: prev.autoCount + newAuto, - userCount: prev.userCount + newUser, - totalCount: prev.totalCount + newAuto + newUser, - }; - }); }; const cleanup = window.maestro.directorNotes.onHistoryEntryAdded( @@ -175,7 +186,7 @@ export const UnifiedHistoryTab = forwardRef s.id === sourceSessionId)?.name, } as UnifiedHistoryEntry; pendingEntriesRef.current.push(enriched); @@ -192,8 +203,9 @@ export const UnifiedHistoryTab = forwardRef; total: number; limit: number; @@ -2728,13 +2722,7 @@ interface MaestroAPI { success?: boolean; elapsedTimeMs?: number; validated?: boolean; - usageStats?: { - totalCostUsd: number; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - }; + usageStats?: UsageStats; }, sourceSessionId: string ) => void From c03d9e36d1cb9c0294623cf0c8adac164b3572fd Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 11:39:26 -0800 Subject: [PATCH 006/194] feat: gate Maestro Symphony behind Encore Features toggle, add multi-URL registry support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `symphony: boolean` (default true) to EncoreFeatureFlags - Gate Symphony modal, menu item, keyboard shortcut (⇧⌘Y), and command palette entry - Add `symphonyRegistryUrls` setting for user-configured additional registry URLs - Replace single `fetchRegistry()` with `fetchRegistries()` that fetches default + custom URLs in parallel - Merge repositories by slug (default registry wins on conflicts), isolated per-URL error handling - Add Symphony toggle + Registry Sources UI in Settings > Encore tab - Update tests for new symphony flag across all encore feature assertions --- .../components/SettingsModal.test.tsx | 147 ++++++++- src/main/index.ts | 1 + src/main/ipc/handlers/index.ts | 1 + src/main/ipc/handlers/symphony.ts | 77 +++-- src/renderer/App.tsx | 6 +- src/renderer/components/AppModals.tsx | 4 +- src/renderer/components/QuickActionsModal.tsx | 24 +- .../SessionList/HamburgerMenuContent.tsx | 94 +++--- .../components/Settings/tabs/EncoreTab.tsx | 305 +++++++++++++++++- .../hooks/keyboard/useMainKeyboardHandler.ts | 4 +- src/renderer/hooks/settings/useSettings.ts | 4 + src/renderer/stores/settingsStore.ts | 14 + src/renderer/types/index.ts | 2 + 13 files changed, 592 insertions(+), 91 deletions(-) diff --git a/src/__tests__/renderer/components/SettingsModal.test.tsx b/src/__tests__/renderer/components/SettingsModal.test.tsx index ab4aa7e61..a5d2c1bbc 100644 --- a/src/__tests__/renderer/components/SettingsModal.test.tsx +++ b/src/__tests__/renderer/components/SettingsModal.test.tsx @@ -207,7 +207,7 @@ vi.mock('../../../renderer/hooks/settings/useSettings', () => ({ customAICommands: [], setCustomAICommands: mockSetCustomAICommands, // Encore features - encoreFeatures: { directorNotes: false }, + encoreFeatures: { directorNotes: false, usageStats: true, symphony: true }, setEncoreFeatures: mockSetEncoreFeatures, // Conductor profile settings conductorProfile: '', @@ -273,6 +273,9 @@ vi.mock('../../../renderer/hooks/settings/useSettings', () => ({ setUseNativeTitleBar: vi.fn(), autoHideMenuBar: false, setAutoHideMenuBar: vi.fn(), + // Symphony registry URLs + symphonyRegistryUrls: [], + setSymphonyRegistryUrls: vi.fn(), ...mockUseSettingsOverrides, }), })); @@ -2333,12 +2336,14 @@ describe('SettingsModal', () => { expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ directorNotes: true, + usageStats: true, + symphony: true, }); }); it('should call setEncoreFeatures with false when toggling DN off', async () => { mockSetEncoreFeatures.mockClear(); - mockUseSettingsOverrides = { encoreFeatures: { directorNotes: true } }; + mockUseSettingsOverrides = { encoreFeatures: { directorNotes: true, usageStats: true, symphony: true } }; render(); await act(async () => { @@ -2357,12 +2362,148 @@ describe('SettingsModal', () => { expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ directorNotes: false, + usageStats: true, + symphony: true, }); }); + it('should show Usage & Stats feature toggle defaulting to on', async () => { + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + expect(screen.getByText('Usage & Stats')).toBeInTheDocument(); + // Settings should be visible when enabled (default on) + expect(screen.getByText('Enable stats collection')).toBeInTheDocument(); + }); + + it('should call setEncoreFeatures when Usage & Stats toggle is clicked off', async () => { + mockSetEncoreFeatures.mockClear(); + + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + const usSection = screen.getByText('Usage & Stats').closest('button'); + expect(usSection).toBeInTheDocument(); + fireEvent.click(usSection!); + + expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ + directorNotes: false, + usageStats: false, + symphony: true, + }); + }); + + it('should show Maestro Symphony feature toggle defaulting to on', async () => { + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + expect(screen.getByText('Maestro Symphony')).toBeInTheDocument(); + // Settings should be visible when enabled (default on) + expect(screen.getByText('Registry Sources')).toBeInTheDocument(); + }); + + it('should call setEncoreFeatures when Symphony toggle is clicked off', async () => { + mockSetEncoreFeatures.mockClear(); + + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + const symphonySection = screen.getByText('Maestro Symphony').closest('button'); + expect(symphonySection).toBeInTheDocument(); + fireEvent.click(symphonySection!); + + expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ + directorNotes: false, + usageStats: true, + symphony: false, + }); + }); + + it('should call setEncoreFeatures when Symphony toggle is clicked on', async () => { + mockSetEncoreFeatures.mockClear(); + mockUseSettingsOverrides = { encoreFeatures: { directorNotes: false, usageStats: true, symphony: false } }; + + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + const symphonySection = screen.getByText('Maestro Symphony').closest('button'); + expect(symphonySection).toBeInTheDocument(); + fireEvent.click(symphonySection!); + + expect(mockSetEncoreFeatures).toHaveBeenCalledWith({ + directorNotes: false, + usageStats: true, + symphony: true, + }); + }); + + it('should hide Symphony registry settings when symphony is disabled', async () => { + mockUseSettingsOverrides = { encoreFeatures: { directorNotes: false, usageStats: true, symphony: false } }; + + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + fireEvent.click(screen.getByTitle('Encore Features')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + expect(screen.getByText('Maestro Symphony')).toBeInTheDocument(); + expect(screen.queryByText('Registry Sources')).not.toBeInTheDocument(); + }); + describe("with Director's Notes enabled", () => { beforeEach(() => { - mockUseSettingsOverrides = { encoreFeatures: { directorNotes: true } }; + mockUseSettingsOverrides = { encoreFeatures: { directorNotes: true, usageStats: true, symphony: true } }; }); it('should render provider dropdown with detected available agents', async () => { diff --git a/src/main/index.ts b/src/main/index.ts index 444c6b66a..48f38756c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -684,6 +684,7 @@ function setupIpcHandlers() { app, getMainWindow: () => mainWindow, sessionsStore, + settingsStore: store, }); // Register tab naming handlers for automatic tab naming diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 6eceea2b6..a54a65a3e 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -270,6 +270,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void { app: deps.app, getMainWindow: deps.getMainWindow, sessionsStore: deps.sessionsStore, + settingsStore: deps.settingsStore, }); // Register agent error handlers (error state management) registerAgentErrorHandlers(); diff --git a/src/main/ipc/handlers/symphony.ts b/src/main/ipc/handlers/symphony.ts index 686b0bacd..f5fe5cd85 100644 --- a/src/main/ipc/handlers/symphony.ts +++ b/src/main/ipc/handlers/symphony.ts @@ -201,6 +201,7 @@ export interface SymphonyHandlerDependencies { app: App; getMainWindow: () => BrowserWindow | null; sessionsStore: Store; + settingsStore: Store; } // ============================================================================ @@ -382,39 +383,67 @@ function parseDocumentPaths(body: string): DocumentReference[] { // ============================================================================ /** - * Fetch the symphony registry from GitHub. + * Fetch a single symphony registry from a URL. + * Returns null on failure instead of throwing (isolated error handling per URL). */ -async function fetchRegistry(): Promise { - logger.info('Fetching Symphony registry', LOG_CONTEXT); - +async function fetchSingleRegistry(url: string): Promise { try { - const response = await fetch(SYMPHONY_REGISTRY_URL); - + const response = await fetch(url); if (!response.ok) { - throw new SymphonyError( - `Failed to fetch registry: ${response.status} ${response.statusText}`, - 'network' - ); + logger.warn(`Failed to fetch registry from ${url}: ${response.status}`, LOG_CONTEXT); + return null; } - const data = (await response.json()) as SymphonyRegistry; - if (!data.repositories || !Array.isArray(data.repositories)) { - throw new SymphonyError('Invalid registry structure', 'parse'); + logger.warn(`Invalid registry structure from ${url}`, LOG_CONTEXT); + return null; } - - logger.info(`Fetched registry with ${data.repositories.length} repos`, LOG_CONTEXT); + logger.info(`Fetched ${data.repositories.length} repos from ${url}`, LOG_CONTEXT); return data; } catch (error) { - if (error instanceof SymphonyError) throw error; - throw new SymphonyError( - `Network error: ${error instanceof Error ? error.message : String(error)}`, - 'network', - error - ); + logger.warn(`Network error fetching registry from ${url}: ${error instanceof Error ? error.message : String(error)}`, LOG_CONTEXT); + return null; } } +/** + * Fetch and merge symphony registries from all configured URLs. + * Default URL always fetched first (wins on slug conflicts). + * Custom URL failures are isolated — other registries still load. + */ +async function fetchRegistries(customUrls: string[]): Promise { + logger.info(`Fetching Symphony registries (1 default + ${customUrls.length} custom)`, LOG_CONTEXT); + + const allUrls = [SYMPHONY_REGISTRY_URL, ...customUrls]; + const results = await Promise.allSettled(allUrls.map(fetchSingleRegistry)); + + const seenSlugs = new Set(); + const mergedRepos: SymphonyRegistry['repositories'] = []; + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + for (const repo of result.value.repositories) { + if (!seenSlugs.has(repo.slug)) { + seenSlugs.add(repo.slug); + mergedRepos.push(repo); + } + } + } + } + + if (mergedRepos.length === 0) { + throw new SymphonyError('Failed to fetch registry from all configured URLs', 'network'); + } + + logger.info(`Merged registry: ${mergedRepos.length} repos from ${allUrls.length} sources`, LOG_CONTEXT); + + return { + schemaVersion: '1.0', + lastUpdated: new Date().toISOString(), + repositories: mergedRepos, + }; +} + /** * Fetch GitHub star counts for multiple repositories. * Uses concurrent requests with a concurrency limit to stay within rate limits. @@ -1024,6 +1053,7 @@ export function registerSymphonyHandlers({ app, getMainWindow, sessionsStore, + settingsStore, }: SymphonyHandlerDependencies): void { // ───────────────────────────────────────────────────────────────────────── // Registry Operations @@ -1112,9 +1142,10 @@ export function registerSymphonyHandlers({ }; } - // Fetch fresh data + // Fetch fresh data from all configured registries try { - const registry = await fetchRegistry(); + const customUrls = (settingsStore.get('symphonyRegistryUrls') as string[] | undefined) ?? []; + const registry = await fetchRegistries(customUrls); const enriched = await enrichWithStars(registry, cache, !!forceRefresh); // Update cache (enriched registry includes stars on repo objects, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0e5ccd515..02b1bde14 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2587,7 +2587,7 @@ function MaestroConsoleInner() { setAboutModalOpen={setAboutModalOpen} setLogViewerOpen={setLogViewerOpen} setProcessMonitorOpen={setProcessMonitorOpen} - setUsageDashboardOpen={setUsageDashboardOpen} + setUsageDashboardOpen={encoreFeatures.usageStats ? setUsageDashboardOpen : undefined} setActiveRightTab={setActiveRightTab} setAgentSessionsOpen={setAgentSessionsOpen} setActiveAgentSessionId={setActiveAgentSessionId} @@ -2662,7 +2662,7 @@ function MaestroConsoleInner() { getDocumentTaskCount={getDocumentTaskCount} onAutoRunRefresh={handleAutoRunRefresh} onOpenMarketplace={handleOpenMarketplace} - onOpenSymphony={() => setSymphonyModalOpen(true)} + onOpenSymphony={encoreFeatures.symphony ? () => setSymphonyModalOpen(true) : undefined} onOpenDirectorNotes={ encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined } @@ -2826,7 +2826,7 @@ function MaestroConsoleInner() { )} {/* --- SYMPHONY MODAL (lazy-loaded) --- */} - {symphonyModalOpen && ( + {encoreFeatures.symphony && symphonyModalOpen && ( void; setLogViewerOpen: (open: boolean) => void; setProcessMonitorOpen: (open: boolean) => void; - setUsageDashboardOpen: (open: boolean) => void; + setUsageDashboardOpen?: (open: boolean) => void; setActiveRightTab: (tab: RightPanelTab) => void; setAgentSessionsOpen: (open: boolean) => void; setActiveAgentSessionId: (id: string | null) => void; @@ -1911,7 +1911,7 @@ export interface AppModalsProps { setAboutModalOpen: (open: boolean) => void; setLogViewerOpen: (open: boolean) => void; setProcessMonitorOpen: (open: boolean) => void; - setUsageDashboardOpen: (open: boolean) => void; + setUsageDashboardOpen?: (open: boolean) => void; setActiveRightTab: (tab: RightPanelTab) => void; setAgentSessionsOpen: (open: boolean) => void; setActiveAgentSessionId: (id: string | null) => void; diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index d80133411..c0c9983f7 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -51,7 +51,7 @@ interface QuickActionsModalProps { setAboutModalOpen: (open: boolean) => void; setLogViewerOpen: (open: boolean) => void; setProcessMonitorOpen: (open: boolean) => void; - setUsageDashboardOpen: (open: boolean) => void; + setUsageDashboardOpen?: (open: boolean) => void; setAgentSessionsOpen: (open: boolean) => void; setActiveAgentSessionId: (id: string | null) => void; setGitDiffPreview: (diff: string | null) => void; @@ -695,15 +695,19 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct setQuickActionOpen(false); }, }, - { - id: 'usageDashboard', - label: 'Usage Dashboard', - shortcut: shortcuts.usageDashboard, - action: () => { - setUsageDashboardOpen(true); - setQuickActionOpen(false); - }, - }, + ...(setUsageDashboardOpen + ? [ + { + id: 'usageDashboard', + label: 'Usage Dashboard', + shortcut: shortcuts.usageDashboard, + action: () => { + setUsageDashboardOpen(true); + setQuickActionOpen(false); + }, + }, + ] + : []), ...(activeSession && hasActiveSessionCapability?.('supportsSessionStorage') ? [ { diff --git a/src/renderer/components/SessionList/HamburgerMenuContent.tsx b/src/renderer/components/SessionList/HamburgerMenuContent.tsx index 104509ea6..c47a71f5e 100644 --- a/src/renderer/components/SessionList/HamburgerMenuContent.tsx +++ b/src/renderer/components/SessionList/HamburgerMenuContent.tsx @@ -36,7 +36,7 @@ export function HamburgerMenuContent({ setMenuOpen, }: HamburgerMenuContentProps) { const shortcuts = useSettingsStore((s) => s.shortcuts); - const directorNotesEnabled = useSettingsStore((s) => s.encoreFeatures.directorNotes); + const encoreFeatures = useSettingsStore((s) => s.encoreFeatures); const { setShortcutsHelpOpen, setSettingsModalOpen, @@ -239,53 +239,57 @@ export function HamburgerMenuContent({ {formatShortcutKeys(shortcuts.processMonitor.keys)} - - + )} + {encoreFeatures.symphony && ( + - {directorNotesEnabled && ( + +
+
+ Maestro Symphony +
+
+ Contribute to open source +
+
+ + {shortcuts.openSymphony ? formatShortcutKeys(shortcuts.openSymphony.keys) : '⇧⌘Y'} + + + )} + {encoreFeatures.directorNotes && ( + + {encoreFeatures.usageStats && ( +
+
+

+ Enable stats collection +

+
+

+ Track queries and Auto Run sessions for the dashboard. Configure collection + and time range settings in the General tab. +

+
+ )} + + + {/* Maestro Symphony Feature Section */} +
+ + + {encoreFeatures.symphony && ( +
+
+ +

+ Repositories are loaded from all configured registry URLs. The default + registry cannot be removed. +

+ + {/* Default URL (immutable) */} +
+ + + {SYMPHONY_REGISTRY_URL} + + + default + +
+ + {/* Custom URLs list */} + {symphonyRegistryUrls.map((url) => ( +
+ + {url} + + +
+ ))} + + {/* Add new URL input */} +
+
+ { + setNewRegistryUrl(e.target.value); + setRegistryUrlError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddRegistryUrl(); + } + }} + placeholder="https://example.com/registry.json" + className="w-full px-3 py-2 rounded text-sm font-mono outline-none" + style={{ + backgroundColor: theme.colors.bgActivity, + borderColor: registryUrlError + ? theme.colors.error + : theme.colors.border, + border: '1px solid', + color: theme.colors.textMain, + }} + /> + {registryUrlError && ( +

+ {registryUrlError} +

+ )} +
+ +
+
+
+ )} +
+ {/* Director's Notes Feature Section */}
void; + // Symphony registry URLs (additional user-configured registries) + symphonyRegistryUrls: string[]; + setSymphonyRegistryUrls: (value: string[]) => void; + // Director's Notes settings directorNotesSettings: DirectorNotesSettings; setDirectorNotesSettings: (value: DirectorNotesSettings) => void; diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 0400cfb00..fc2744d0c 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -116,6 +116,8 @@ export const DEFAULT_ONBOARDING_STATS: OnboardingStats = { export const DEFAULT_ENCORE_FEATURES: EncoreFeatureFlags = { directorNotes: false, + usageStats: true, + symphony: true, }; export const DEFAULT_DIRECTOR_NOTES_SETTINGS: DirectorNotesSettings = { @@ -245,6 +247,7 @@ export interface SettingsStoreState { autoScrollAiMode: boolean; userMessageAlignment: 'left' | 'right'; encoreFeatures: EncoreFeatureFlags; + symphonyRegistryUrls: string[]; directorNotesSettings: DirectorNotesSettings; wakatimeApiKey: string; wakatimeEnabled: boolean; @@ -316,6 +319,7 @@ export interface SettingsStoreActions { setAutoScrollAiMode: (value: boolean) => void; setUserMessageAlignment: (value: 'left' | 'right') => void; setEncoreFeatures: (value: EncoreFeatureFlags) => void; + setSymphonyRegistryUrls: (value: string[]) => void; setDirectorNotesSettings: (value: DirectorNotesSettings) => void; setWakatimeApiKey: (value: string) => void; setWakatimeEnabled: (value: boolean) => void; @@ -463,6 +467,7 @@ export const useSettingsStore = create()((set, get) => ({ autoScrollAiMode: false, userMessageAlignment: 'right', encoreFeatures: DEFAULT_ENCORE_FEATURES, + symphonyRegistryUrls: [], directorNotesSettings: DEFAULT_DIRECTOR_NOTES_SETTINGS, wakatimeApiKey: '', wakatimeEnabled: false, @@ -788,6 +793,11 @@ export const useSettingsStore = create()((set, get) => ({ window.maestro.settings.set('encoreFeatures', value); }, + setSymphonyRegistryUrls: (value) => { + set({ symphonyRegistryUrls: value }); + window.maestro.settings.set('symphonyRegistryUrls', value); + }, + setDirectorNotesSettings: (value) => { set({ directorNotesSettings: value }); window.maestro.settings.set('directorNotesSettings', value); @@ -1696,6 +1706,10 @@ export async function loadAllSettings(): Promise { }; } + // Symphony registry URLs (additional user-configured registries) + if (allSettings['symphonyRegistryUrls'] !== undefined) + patch.symphonyRegistryUrls = allSettings['symphonyRegistryUrls'] as string[]; + // Director's Notes settings (merge with defaults to preserve new fields) if (allSettings['directorNotesSettings'] !== undefined) { patch.directorNotesSettings = { diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 1dbcddb15..f989571f9 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -908,6 +908,8 @@ export interface LeaderboardSubmitResponse { // Each key is a feature ID, value indicates whether it's enabled export interface EncoreFeatureFlags { directorNotes: boolean; + usageStats: boolean; + symphony: boolean; } // Director's Notes settings for synopsis generation From bbb51f78b792856e858957b9ad087f990ff9801f Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 15:23:10 -0800 Subject: [PATCH 007/194] fix: address CodeRabbit PR review findings for encore-features - Redact registry URLs before logging to prevent credential leakage - Skip registry cache when custom source URLs are configured (stale cache fix) - Runtime-validate symphonyRegistryUrls from settings store - Reset modal-open flags when Encore Feature toggles are disabled - Normalize registry URLs before duplicate/default checks - Add aria-label to icon-only registry URL remove button - Expose setSymphonyRegistryUrls in getSettingsActions() - Validate persisted symphonyRegistryUrls with Array.isArray guard --- src/main/ipc/handlers/symphony.ts | 37 ++++++++++++++++--- src/renderer/App.tsx | 9 +++++ .../components/Settings/tabs/EncoreTab.tsx | 27 +++++++++++--- src/renderer/stores/settingsStore.ts | 8 +++- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/main/ipc/handlers/symphony.ts b/src/main/ipc/handlers/symphony.ts index f5fe5cd85..5c1f0ee2f 100644 --- a/src/main/ipc/handlers/symphony.ts +++ b/src/main/ipc/handlers/symphony.ts @@ -386,22 +386,39 @@ function parseDocumentPaths(body: string): DocumentReference[] { * Fetch a single symphony registry from a URL. * Returns null on failure instead of throwing (isolated error handling per URL). */ +/** + * Redact a URL for safe logging — strips credentials, query params, and fragments. + */ +function redactUrlForLog(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return '[invalid-url]'; + } +} + async function fetchSingleRegistry(url: string): Promise { + const safeUrl = redactUrlForLog(url); try { const response = await fetch(url); if (!response.ok) { - logger.warn(`Failed to fetch registry from ${url}: ${response.status}`, LOG_CONTEXT); + logger.warn(`Failed to fetch registry from ${safeUrl}: ${response.status}`, LOG_CONTEXT); return null; } const data = (await response.json()) as SymphonyRegistry; if (!data.repositories || !Array.isArray(data.repositories)) { - logger.warn(`Invalid registry structure from ${url}`, LOG_CONTEXT); + logger.warn(`Invalid registry structure from ${safeUrl}`, LOG_CONTEXT); return null; } - logger.info(`Fetched ${data.repositories.length} repos from ${url}`, LOG_CONTEXT); + logger.info(`Fetched ${data.repositories.length} repos from ${safeUrl}`, LOG_CONTEXT); return data; } catch (error) { - logger.warn(`Network error fetching registry from ${url}: ${error instanceof Error ? error.message : String(error)}`, LOG_CONTEXT); + logger.warn(`Network error fetching registry from ${safeUrl}: ${error instanceof Error ? error.message : String(error)}`, LOG_CONTEXT); return null; } } @@ -1128,9 +1145,20 @@ export function registerSymphonyHandlers({ async (forceRefresh?: boolean): Promise> => { const cache = await readCache(app); + // Runtime-validate custom URLs from settings + const rawCustomUrls = settingsStore.get('symphonyRegistryUrls'); + const customUrls = Array.isArray(rawCustomUrls) + ? rawCustomUrls.filter((u): u is string => typeof u === 'string' && u.trim().length > 0) + : []; + + // Skip cache when custom sources are configured — cache doesn't track + // which source URLs produced it, so URL changes would serve stale data. + const hasCustomSources = customUrls.length > 0; + // Check cache validity if ( !forceRefresh && + !hasCustomSources && cache?.registry && isCacheValid(cache.registry.fetchedAt, REGISTRY_CACHE_TTL_MS) ) { @@ -1144,7 +1172,6 @@ export function registerSymphonyHandlers({ // Fetch fresh data from all configured registries try { - const customUrls = (settingsStore.get('symphonyRegistryUrls') as string[] | undefined) ?? []; const registry = await fetchRegistries(customUrls); const enriched = await enrichWithStars(registry, cache, !!forceRefresh); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 02b1bde14..e31780731 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -415,6 +415,15 @@ function MaestroConsoleInner() { encoreFeatures, } = settings; + // Reset modal-open flags when their Encore Feature toggle is disabled + useEffect(() => { + if (!encoreFeatures.symphony) setSymphonyModalOpen(false); + }, [encoreFeatures.symphony, setSymphonyModalOpen]); + + useEffect(() => { + if (!encoreFeatures.usageStats) setUsageDashboardOpen(false); + }, [encoreFeatures.usageStats, setUsageDashboardOpen]); + // --- KEYBOARD SHORTCUT HELPERS --- const { isShortcut, isTabShortcut } = useKeyboardShortcutHelpers({ shortcuts, diff --git a/src/renderer/components/Settings/tabs/EncoreTab.tsx b/src/renderer/components/Settings/tabs/EncoreTab.tsx index 9e27cf83b..1035f002f 100644 --- a/src/renderer/components/Settings/tabs/EncoreTab.tsx +++ b/src/renderer/components/Settings/tabs/EncoreTab.tsx @@ -33,31 +33,46 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { const [newRegistryUrl, setNewRegistryUrl] = useState(''); const [registryUrlError, setRegistryUrlError] = useState(null); + const canonicalizeUrl = (raw: string): string => { + const u = new URL(raw.trim()); + u.hash = ''; + return u.href; + }; + const handleAddRegistryUrl = () => { const trimmed = newRegistryUrl.trim(); if (!trimmed) { setRegistryUrlError('URL cannot be empty'); return; } + let canonical: string; try { const parsed = new URL(trimmed); if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { setRegistryUrlError('URL must use HTTP or HTTPS'); return; } + canonical = canonicalizeUrl(trimmed); } catch { setRegistryUrlError('Invalid URL format'); return; } - if (trimmed === SYMPHONY_REGISTRY_URL) { - setRegistryUrlError('This is the default registry URL'); - return; - } - if (symphonyRegistryUrls.includes(trimmed)) { + try { + if (canonical === canonicalizeUrl(SYMPHONY_REGISTRY_URL)) { + setRegistryUrlError('This is the default registry URL'); + return; + } + } catch { /* default URL should always parse */ } + const existing = new Set( + symphonyRegistryUrls.map((u) => { + try { return canonicalizeUrl(u); } catch { return u.trim(); } + }) + ); + if (existing.has(canonical)) { setRegistryUrlError('URL already added'); return; } - setSymphonyRegistryUrls([...symphonyRegistryUrls, trimmed]); + setSymphonyRegistryUrls([...symphonyRegistryUrls, canonical]); setNewRegistryUrl(''); setRegistryUrlError(null); }; diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index fc2744d0c..13b7e52ed 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -1707,8 +1707,11 @@ export async function loadAllSettings(): Promise { } // Symphony registry URLs (additional user-configured registries) - if (allSettings['symphonyRegistryUrls'] !== undefined) - patch.symphonyRegistryUrls = allSettings['symphonyRegistryUrls'] as string[]; + if (Array.isArray(allSettings['symphonyRegistryUrls'])) { + patch.symphonyRegistryUrls = (allSettings['symphonyRegistryUrls'] as unknown[]) + .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) + .map((v) => v.trim()); + } // Director's Notes settings (merge with defaults to preserve new fields) if (allSettings['directorNotesSettings'] !== undefined) { @@ -1841,6 +1844,7 @@ export function getSettingsActions() { setSuppressWindowsWarning: state.setSuppressWindowsWarning, setAutoScrollAiMode: state.setAutoScrollAiMode, setEncoreFeatures: state.setEncoreFeatures, + setSymphonyRegistryUrls: state.setSymphonyRegistryUrls, setDirectorNotesSettings: state.setDirectorNotesSettings, setWakatimeApiKey: state.setWakatimeApiKey, setWakatimeEnabled: state.setWakatimeEnabled, From f11f9d5734a0c9c29cff6e570d3bbcfb1173a4c1 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 1 Mar 2026 01:58:19 -0600 Subject: [PATCH 008/194] MAESTRO: Phase 01 - Cue foundational types, template variables, logger, and Encore feature flag - Register maestroCue as an Encore Feature flag (EncoreFeatureFlags, DEFAULT_ENCORE_FEATURES) - Create src/main/cue/cue-types.ts with CueEventType, CueSubscription, CueSettings, CueConfig, CueEvent, CueRunStatus, CueRunResult, CueSessionStatus, and related constants - Add 'CUE' to HistoryEntryType across shared types, global.d.ts, preload, IPC handlers, and hooks - Add cueTriggerName, cueEventType, cueSourceSession optional fields to HistoryEntry - Add 'cue' log level to MainLogLevel, LOG_LEVEL_PRIORITY, logger switch/case, and LogViewer with teal color (#06b6d4), always-enabled filter, and agent name pill - Add 10 Cue-specific template variables (CUE_EVENT_TYPE, CUE_TRIGGER_NAME, etc.) with cueOnly flag - Extend TemplateContext with cue? field and substituteTemplateVariables with Cue replacements - Update TEMPLATE_VARIABLES_GENERAL filter to exclude cueOnly variables --- .../web/mobile/MobileHistoryPanel.test.tsx | 2 + src/main/cue/cue-types.ts | 84 +++++++++++++++++++ src/main/ipc/handlers/director-notes.ts | 2 +- src/main/preload/directorNotes.ts | 4 +- src/main/preload/files.ts | 2 +- src/main/utils/logger.ts | 15 ++++ src/renderer/components/LogViewer.tsx | 53 ++++++++---- src/renderer/global.d.ts | 4 +- .../hooks/agent/useAgentSessionManagement.ts | 2 +- src/renderer/stores/settingsStore.ts | 1 + src/renderer/types/index.ts | 1 + src/shared/logger-types.ts | 4 +- src/shared/templateVariables.ts | 52 +++++++++++- src/shared/types.ts | 5 +- 14 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 src/main/cue/cue-types.ts diff --git a/src/__tests__/web/mobile/MobileHistoryPanel.test.tsx b/src/__tests__/web/mobile/MobileHistoryPanel.test.tsx index 0cfe0caf2..fa400cc08 100644 --- a/src/__tests__/web/mobile/MobileHistoryPanel.test.tsx +++ b/src/__tests__/web/mobile/MobileHistoryPanel.test.tsx @@ -127,8 +127,10 @@ describe('MobileHistoryPanel', () => { it('exports HistoryEntryType type', () => { const autoType: HistoryEntryType = 'AUTO'; const userType: HistoryEntryType = 'USER'; + const cueType: HistoryEntryType = 'CUE'; expect(autoType).toBe('AUTO'); expect(userType).toBe('USER'); + expect(cueType).toBe('CUE'); }); it('exports HistoryEntry interface', () => { diff --git a/src/main/cue/cue-types.ts b/src/main/cue/cue-types.ts new file mode 100644 index 000000000..939752002 --- /dev/null +++ b/src/main/cue/cue-types.ts @@ -0,0 +1,84 @@ +/** + * Core type definitions for the Maestro Cue event-driven automation system. + * + * Cue triggers agent prompts in response to events: + * - time.interval: periodic timer-based triggers + * - file.changed: file system change triggers + * - agent.completed: triggers when another agent finishes + */ + +/** Event types that can trigger a Cue subscription */ +export type CueEventType = 'time.interval' | 'file.changed' | 'agent.completed'; + +/** A Cue subscription defines a trigger-prompt pairing */ +export interface CueSubscription { + name: string; + event: CueEventType; + enabled: boolean; + prompt: string; + interval_minutes?: number; + watch?: string; + source_session?: string | string[]; + fan_out?: string[]; +} + +/** Global Cue settings */ +export interface CueSettings { + timeout_minutes: number; + timeout_on_fail: 'break' | 'continue'; +} + +/** Default Cue settings */ +export const DEFAULT_CUE_SETTINGS: CueSettings = { + timeout_minutes: 30, + timeout_on_fail: 'break', +}; + +/** Top-level Cue configuration (parsed from YAML) */ +export interface CueConfig { + subscriptions: CueSubscription[]; + settings: CueSettings; +} + +/** An event instance produced by a trigger */ +export interface CueEvent { + id: string; + type: CueEventType; + timestamp: string; + triggerName: string; + payload: Record; +} + +/** Status of a Cue run */ +export type CueRunStatus = 'running' | 'completed' | 'failed' | 'timeout' | 'stopped'; + +/** Result of a completed (or failed/timed-out) Cue run */ +export interface CueRunResult { + runId: string; + sessionId: string; + sessionName: string; + subscriptionName: string; + event: CueEvent; + status: CueRunStatus; + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; + startedAt: string; + endedAt: string; +} + +/** Status summary for a Cue-enabled session */ +export interface CueSessionStatus { + sessionId: string; + sessionName: string; + toolType: string; + enabled: boolean; + subscriptionCount: number; + activeRuns: number; + lastTriggered?: string; + nextTrigger?: string; +} + +/** Default filename for Cue configuration */ +export const CUE_YAML_FILENAME = 'maestro-cue.yaml'; diff --git a/src/main/ipc/handlers/director-notes.ts b/src/main/ipc/handlers/director-notes.ts index 1a90409e3..b7c3f5349 100644 --- a/src/main/ipc/handlers/director-notes.ts +++ b/src/main/ipc/handlers/director-notes.ts @@ -80,7 +80,7 @@ export interface DirectorNotesHandlerDependencies { export interface UnifiedHistoryOptions { lookbackDays: number; - filter?: 'AUTO' | 'USER' | null; // null = both + filter?: 'AUTO' | 'USER' | 'CUE' | null; // null = both /** Number of entries to return per page (default: 100) */ limit?: number; /** Number of entries to skip for pagination (default: 0) */ diff --git a/src/main/preload/directorNotes.ts b/src/main/preload/directorNotes.ts index 26394266c..ee70ddb64 100644 --- a/src/main/preload/directorNotes.ts +++ b/src/main/preload/directorNotes.ts @@ -35,7 +35,7 @@ export interface PaginatedUnifiedHistoryResult { */ export interface UnifiedHistoryOptions { lookbackDays: number; - filter?: 'AUTO' | 'USER' | null; // null = both + filter?: 'AUTO' | 'USER' | 'CUE' | null; // null = both /** Number of entries to return per page (default: 100) */ limit?: number; /** Number of entries to skip for pagination (default: 0) */ @@ -47,7 +47,7 @@ export interface UnifiedHistoryOptions { */ export interface UnifiedHistoryEntry { id: string; - type: 'AUTO' | 'USER'; + type: 'AUTO' | 'USER' | 'CUE'; timestamp: number; summary: string; fullResponse?: string; diff --git a/src/main/preload/files.ts b/src/main/preload/files.ts index 1206cc184..e92fbd158 100644 --- a/src/main/preload/files.ts +++ b/src/main/preload/files.ts @@ -14,7 +14,7 @@ import { ipcRenderer } from 'electron'; */ export interface HistoryEntry { id: string; - type: 'AUTO' | 'USER'; + type: 'AUTO' | 'USER' | 'CUE'; timestamp: number; summary: string; fullResponse?: string; diff --git a/src/main/utils/logger.ts b/src/main/utils/logger.ts index 84de57df7..746a637c5 100644 --- a/src/main/utils/logger.ts +++ b/src/main/utils/logger.ts @@ -192,6 +192,10 @@ class Logger extends EventEmitter { // Auto Run logs for workflow tracking (orange in LogViewer) console.info(message, entry.data || ''); break; + case 'cue': + // Cue event-driven automation logs (teal in LogViewer) + console.info(message, entry.data || ''); + break; } } catch { // Silently ignore EPIPE errors - console is disconnected @@ -265,6 +269,17 @@ class Logger extends EventEmitter { }); } + cue(message: string, context?: string, data?: unknown): void { + // Cue logs are always logged (event-driven automation tracking) + this.addLog({ + timestamp: Date.now(), + level: 'cue', + message, + context, + data, + }); + } + getLogs(filter?: { level?: MainLogLevel; context?: string; limit?: number }): SystemLogEntry[] { let filtered = [...this.logs]; diff --git a/src/renderer/components/LogViewer.tsx b/src/renderer/components/LogViewer.tsx index 342a3ef38..8f781f7c6 100644 --- a/src/renderer/components/LogViewer.tsx +++ b/src/renderer/components/LogViewer.tsx @@ -18,7 +18,7 @@ import { ConfirmModal } from './ConfirmModal'; interface SystemLogEntry { timestamp: number; - level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'; + level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'; message: string; context?: string; data?: unknown; @@ -49,6 +49,7 @@ const LOG_LEVEL_COLORS: Record = { error: { fg: '#ef4444', bg: 'rgba(239, 68, 68, 0.15)' }, // Red toast: { fg: '#a855f7', bg: 'rgba(168, 85, 247, 0.15)' }, // Purple autorun: { fg: '#f97316', bg: 'rgba(249, 115, 22, 0.15)' }, // Orange + cue: { fg: '#06b6d4', bg: 'rgba(6, 182, 212, 0.15)' }, // Teal }; export function LogViewer({ @@ -66,7 +67,7 @@ export function LogViewer({ // Determine which log levels are enabled based on current log level setting // Levels with priority >= current level are enabled - const enabledLevels = new Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'>( + const enabledLevels = new Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'>( (['debug', 'info', 'warn', 'error'] as const).filter( (level) => LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[logLevel] ) @@ -75,27 +76,29 @@ export function LogViewer({ enabledLevels.add('toast'); // Auto Run is always enabled (workflow tracking cannot be turned off) enabledLevels.add('autorun'); + // Cue is always enabled (event-driven automation tracking) + enabledLevels.add('cue'); // Initialize selectedLevels from saved settings if available const [selectedLevels, setSelectedLevelsState] = useState< - Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'> + Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'> >(() => { if (savedSelectedLevels && savedSelectedLevels.length > 0) { return new Set( - savedSelectedLevels as ('debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun')[] + savedSelectedLevels as ('debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue')[] ); } - return new Set(['debug', 'info', 'warn', 'error', 'toast', 'autorun']); + return new Set(['debug', 'info', 'warn', 'error', 'toast', 'autorun', 'cue']); }); // Wrapper to persist changes when selectedLevels changes const setSelectedLevels = useCallback( ( updater: - | Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'> + | Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'> | (( - prev: Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'> - ) => Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'>) + prev: Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'> + ) => Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'>) ) => { setSelectedLevelsState((prev) => { const newSet = typeof updater === 'function' ? updater(prev) : updater; @@ -484,7 +487,7 @@ export function LogViewer({ ALL {/* Individual level toggle buttons */} - {(['debug', 'info', 'warn', 'error', 'toast', 'autorun'] as const).map((level) => { + {(['debug', 'info', 'warn', 'error', 'toast', 'autorun', 'cue'] as const).map((level) => { const isSelected = selectedLevels.has(level); const isEnabled = enabledLevels.has(level); return ( @@ -626,14 +629,20 @@ export function LogViewer({ {new Date(log.timestamp).toLocaleTimeString()} {/* Context pill - show for non-toast/autorun entries */} - {log.level !== 'toast' && log.level !== 'autorun' && log.context && ( - - {log.context} - - )} + {log.level !== 'toast' && + log.level !== 'autorun' && + log.level !== 'cue' && + log.context && ( + + {log.context} + + )} {/* Agent name pill for toast entries (from data.project) */} {(() => { if (log.level !== 'toast') return null; @@ -660,6 +669,16 @@ export function LogViewer({ {log.context} )} + {/* Agent name pill for cue entries (from context) */} + {log.level === 'cue' && log.context && ( + + + {log.context} + + )}
{log.message} diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index ce10f48b3..a2b38e144 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -138,7 +138,7 @@ interface UsageStats { reasoningTokens?: number; // Separate reasoning tokens (Codex o3/o4-mini) } -type HistoryEntryType = 'AUTO' | 'USER'; +type HistoryEntryType = 'AUTO' | 'USER' | 'CUE'; /** * Result type for reading session messages from agent storage. @@ -2654,7 +2654,7 @@ interface MaestroAPI { directorNotes: { getUnifiedHistory: (options: { lookbackDays: number; - filter?: 'AUTO' | 'USER' | null; + filter?: 'AUTO' | 'USER' | 'CUE' | null; limit?: number; offset?: number; }) => Promise<{ diff --git a/src/renderer/hooks/agent/useAgentSessionManagement.ts b/src/renderer/hooks/agent/useAgentSessionManagement.ts index 0c30d898d..f429dc0f5 100644 --- a/src/renderer/hooks/agent/useAgentSessionManagement.ts +++ b/src/renderer/hooks/agent/useAgentSessionManagement.ts @@ -8,7 +8,7 @@ import type { RightPanelHandle } from '../../components/RightPanel'; * History entry for the addHistoryEntry function. */ export interface HistoryEntryInput { - type: 'AUTO' | 'USER'; + type: 'AUTO' | 'USER' | 'CUE'; summary: string; fullResponse?: string; agentSessionId?: string; diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 13b7e52ed..779760ace 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -118,6 +118,7 @@ export const DEFAULT_ENCORE_FEATURES: EncoreFeatureFlags = { directorNotes: false, usageStats: true, symphony: true, + maestroCue: false, }; export const DEFAULT_DIRECTOR_NOTES_SETTINGS: DirectorNotesSettings = { diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index f989571f9..93e68e0d9 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -910,6 +910,7 @@ export interface EncoreFeatureFlags { directorNotes: boolean; usageStats: boolean; symphony: boolean; + maestroCue: boolean; } // Director's Notes settings for synopsis generation diff --git a/src/shared/logger-types.ts b/src/shared/logger-types.ts index 606301f03..6d4acfbe1 100644 --- a/src/shared/logger-types.ts +++ b/src/shared/logger-types.ts @@ -11,8 +11,9 @@ export type BaseLogLevel = 'debug' | 'info' | 'warn' | 'error'; * Extended log levels available in the main process logger. * - 'toast': User-facing toast notifications (always logged) * - 'autorun': Auto Run workflow tracking logs (always logged) + * - 'cue': Cue event-driven automation logs (always visible) */ -export type MainLogLevel = BaseLogLevel | 'toast' | 'autorun'; +export type MainLogLevel = BaseLogLevel | 'toast' | 'autorun' | 'cue'; /** * Log level type alias for backwards compatibility. @@ -31,6 +32,7 @@ export const LOG_LEVEL_PRIORITY: Record = { error: 3, toast: 1, // Toast notifications always logged at info priority (always visible) autorun: 1, // Auto Run logs always logged at info priority (always visible) + cue: 1, // Cue event-driven automation logs (always visible) }; /** diff --git a/src/shared/templateVariables.ts b/src/shared/templateVariables.ts index afd395c22..045f300b4 100644 --- a/src/shared/templateVariables.ts +++ b/src/shared/templateVariables.ts @@ -49,6 +49,18 @@ import { buildSessionDeepLink, buildGroupDeepLink } from './deep-link-urls'; * * Context Variables: * {{CONTEXT_USAGE}} - Current context window usage percentage + * + * Cue Variables (Cue automation only): + * {{CUE_EVENT_TYPE}} - Cue event type (time.interval, file.changed, agent.completed) + * {{CUE_EVENT_TIMESTAMP}} - Cue event timestamp + * {{CUE_TRIGGER_NAME}} - Cue trigger/subscription name + * {{CUE_RUN_ID}} - Cue run UUID + * {{CUE_FILE_PATH}} - Changed file path (file.changed events) + * {{CUE_FILE_NAME}} - Changed file name + * {{CUE_FILE_DIR}} - Changed file directory + * {{CUE_FILE_EXT}} - Changed file extension + * {{CUE_SOURCE_SESSION}} - Source session name (agent.completed events) + * {{CUE_SOURCE_OUTPUT}} - Source session output (agent.completed events) */ /** @@ -82,10 +94,24 @@ export interface TemplateContext { historyFilePath?: string; // Conductor profile (user's About Me from settings) conductorProfile?: string; + // Cue event context (for Cue automation prompts) + cue?: { + eventType?: string; + eventTimestamp?: string; + triggerName?: string; + runId?: string; + filePath?: string; + fileName?: string; + fileDir?: string; + fileExt?: string; + sourceSession?: string; + sourceOutput?: string; + }; } // List of all available template variables for documentation (alphabetically sorted) // Variables marked as autoRunOnly are only shown in Auto Run contexts, not in AI Commands settings +// Variables marked as cueOnly are only shown in Cue automation contexts export const TEMPLATE_VARIABLES = [ { variable: '{{AGENT_DEEP_LINK}}', description: 'Deep link to this agent (maestro://)' }, { variable: '{{AGENT_GROUP}}', description: 'Agent group name' }, @@ -97,6 +123,16 @@ export const TEMPLATE_VARIABLES = [ { variable: '{{AUTORUN_FOLDER}}', description: 'Auto Run folder path', autoRunOnly: true }, { variable: '{{TAB_NAME}}', description: 'Custom tab name' }, { variable: '{{CONTEXT_USAGE}}', description: 'Context usage %' }, + { variable: '{{CUE_EVENT_TIMESTAMP}}', description: 'Cue event timestamp', cueOnly: true }, + { variable: '{{CUE_EVENT_TYPE}}', description: 'Cue event type', cueOnly: true }, + { variable: '{{CUE_FILE_DIR}}', description: 'Changed file directory', cueOnly: true }, + { variable: '{{CUE_FILE_EXT}}', description: 'Changed file extension', cueOnly: true }, + { variable: '{{CUE_FILE_NAME}}', description: 'Changed file name', cueOnly: true }, + { variable: '{{CUE_FILE_PATH}}', description: 'Changed file path', cueOnly: true }, + { variable: '{{CUE_RUN_ID}}', description: 'Cue run UUID', cueOnly: true }, + { variable: '{{CUE_SOURCE_OUTPUT}}', description: 'Source session output', cueOnly: true }, + { variable: '{{CUE_SOURCE_SESSION}}', description: 'Source session name', cueOnly: true }, + { variable: '{{CUE_TRIGGER_NAME}}', description: 'Cue trigger name', cueOnly: true }, { variable: '{{CWD}}', description: 'Working directory' }, { variable: '{{DATE}}', description: 'Date (YYYY-MM-DD)' }, { variable: '{{DATETIME}}', description: 'Full datetime' }, @@ -123,7 +159,9 @@ export const TEMPLATE_VARIABLES = [ ]; // Filtered list excluding Auto Run-only variables (for AI Commands panel) -export const TEMPLATE_VARIABLES_GENERAL = TEMPLATE_VARIABLES.filter((v) => !v.autoRunOnly); +export const TEMPLATE_VARIABLES_GENERAL = TEMPLATE_VARIABLES.filter( + (v) => !v.autoRunOnly && !v.cueOnly +); /** * Substitute template variables in a string with actual values @@ -202,6 +240,18 @@ export function substituteTemplateVariables(template: string, context: TemplateC // Context variables CONTEXT_USAGE: String(session.contextUsage || 0), + + // Cue variables + CUE_EVENT_TYPE: context.cue?.eventType || '', + CUE_EVENT_TIMESTAMP: context.cue?.eventTimestamp || '', + CUE_TRIGGER_NAME: context.cue?.triggerName || '', + CUE_RUN_ID: context.cue?.runId || '', + CUE_FILE_PATH: context.cue?.filePath || '', + CUE_FILE_NAME: context.cue?.fileName || '', + CUE_FILE_DIR: context.cue?.fileDir || '', + CUE_FILE_EXT: context.cue?.fileExt || '', + CUE_SOURCE_SESSION: context.cue?.sourceSession || '', + CUE_SOURCE_OUTPUT: context.cue?.sourceOutput || '', }; // Perform case-insensitive replacement diff --git a/src/shared/types.ts b/src/shared/types.ts index 5d5b1c058..6641b60f1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -55,7 +55,7 @@ export interface UsageStats { } // History entry types for the History panel -export type HistoryEntryType = 'AUTO' | 'USER'; +export type HistoryEntryType = 'AUTO' | 'USER' | 'CUE'; export interface HistoryEntry { id: string; @@ -72,6 +72,9 @@ export interface HistoryEntry { success?: boolean; elapsedTimeMs?: number; validated?: boolean; + cueTriggerName?: string; + cueEventType?: string; + cueSourceSession?: string; } // Document entry within a playbook From d8feb257100b105707900193f161f97386a9e4db Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 1 Mar 2026 02:07:01 -0600 Subject: [PATCH 009/194] MAESTRO: Phase 02 - Cue Engine core, YAML loader, and file watcher provider Implements the three core modules for the Cue event-driven automation engine: - cue-yaml-loader.ts: Discovers and parses maestro-cue.yaml files with js-yaml, validates config structure, watches for file changes via chokidar with 1-second debounce - cue-file-watcher.ts: Wraps chokidar for file.changed subscriptions with per-file debouncing (5s default), constructs CueEvent instances with full file metadata payloads - cue-engine.ts: Main coordinator class with dependency injection, manages time.interval timers (fires immediately then on interval), file watchers, agent.completed listeners with fan-in tracking, activity log ring buffer (max 500), and run lifecycle management Added js-yaml and @types/js-yaml dependencies. 57 tests across 3 test files. --- package-lock.json | 85 ++- package.json | 2 + src/__tests__/main/cue/cue-engine.test.ts | 647 ++++++++++++++++++ .../main/cue/cue-file-watcher.test.ts | 218 ++++++ .../main/cue/cue-yaml-loader.test.ts | 311 +++++++++ src/main/cue/cue-engine.ts | 401 +++++++++++ src/main/cue/cue-file-watcher.ts | 82 +++ src/main/cue/cue-yaml-loader.ts | 183 +++++ 8 files changed, 1897 insertions(+), 32 deletions(-) create mode 100644 src/__tests__/main/cue/cue-engine.test.ts create mode 100644 src/__tests__/main/cue/cue-file-watcher.test.ts create mode 100644 src/__tests__/main/cue/cue-yaml-loader.test.ts create mode 100644 src/main/cue/cue-engine.ts create mode 100644 src/main/cue/cue-file-watcher.ts create mode 100644 src/main/cue/cue-yaml-loader.ts diff --git a/package-lock.json b/package-lock.json index 7482623e1..1fd63a55f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", "marked": "^17.0.1", "mermaid": "^11.12.1", "node-pty": "^1.1.0", @@ -67,6 +68,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/canvas-confetti": "^1.9.0", "@types/electron-devtools-installer": "^2.2.5", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.6", "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", @@ -264,6 +266,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -667,6 +670,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -710,6 +714,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2283,6 +2288,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2304,6 +2310,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2316,6 +2323,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2331,6 +2339,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -2718,6 +2727,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2734,6 +2744,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -2751,6 +2762,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3809,8 +3821,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4239,6 +4250,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4348,6 +4366,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4359,6 +4378,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4484,6 +4504,7 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4914,6 +4935,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4995,6 +5017,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5998,6 +6021,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6480,6 +6504,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7205,6 +7230,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -7614,6 +7640,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8111,6 +8138,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8206,8 +8234,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.3.0", @@ -8351,7 +8378,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8365,7 +8391,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8385,7 +8410,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8408,7 +8432,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8425,7 +8448,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -8442,7 +8464,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8457,7 +8478,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8473,7 +8493,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8486,8 +8505,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": { "version": "1.1.1", @@ -8495,7 +8513,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8506,7 +8523,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8517,7 +8533,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8533,7 +8548,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9215,6 +9229,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11134,6 +11149,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11954,6 +11970,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12423,16 +12440,14 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -12445,8 +12460,7 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -12460,8 +12474,7 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -12475,8 +12488,7 @@ "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -12567,7 +12579,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -15065,6 +15076,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15305,7 +15317,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15321,7 +15332,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -15666,6 +15676,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15695,6 +15706,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -15742,6 +15754,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15928,7 +15941,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17685,6 +17699,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17995,6 +18010,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18368,6 +18384,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18873,6 +18890,7 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19463,6 +19481,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19476,6 +19495,7 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -20073,6 +20093,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 4e06faefa..8d6092fbb 100644 --- a/package.json +++ b/package.json @@ -246,6 +246,7 @@ "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", "marked": "^17.0.1", "mermaid": "^11.12.1", "node-pty": "^1.1.0", @@ -274,6 +275,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/canvas-confetti": "^1.9.0", "@types/electron-devtools-installer": "^2.2.5", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.6", "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", diff --git a/src/__tests__/main/cue/cue-engine.test.ts b/src/__tests__/main/cue/cue-engine.test.ts new file mode 100644 index 000000000..825a20719 --- /dev/null +++ b/src/__tests__/main/cue/cue-engine.test.ts @@ -0,0 +1,647 @@ +/** + * Tests for the Cue Engine core. + * + * Tests cover: + * - Engine lifecycle (start, stop, isEnabled) + * - Session initialization from YAML configs + * - Timer-based subscriptions (time.interval) + * - File watcher subscriptions (file.changed) + * - Agent completion subscriptions (agent.completed) + * - Fan-in tracking for multi-source agent.completed + * - Active run tracking and stopping + * - Activity log ring buffer + * - Session refresh and removal + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Mock the yaml loader +const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>(); +const mockWatchCueYaml = vi.fn<(projectRoot: string, onChange: () => void) => () => void>(); +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), + watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), +})); + +// Mock the file watcher +const mockCreateCueFileWatcher = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: (...args: unknown[]) => mockCreateCueFileWatcher(args[0]), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { timeout_minutes: 30, timeout_on_fail: 'break' }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine', () => { + let yamlWatcherCleanup: ReturnType; + let fileWatcherCleanup: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + yamlWatcherCleanup = vi.fn(); + mockWatchCueYaml.mockReturnValue(yamlWatcherCleanup); + + fileWatcherCleanup = vi.fn(); + mockCreateCueFileWatcher.mockReturnValue(fileWatcherCleanup); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('lifecycle', () => { + it('starts as disabled', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.isEnabled()).toBe(false); + }); + + it('becomes enabled after start()', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + expect(engine.isEnabled()).toBe(true); + }); + + it('becomes disabled after stop()', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + engine.stop(); + expect(engine.isEnabled()).toBe(false); + }); + + it('logs start and stop events', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('started')); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('stopped')); + }); + }); + + describe('session initialization', () => { + it('scans all sessions on start', () => { + const sessions = [ + createMockSession({ id: 's1', projectRoot: '/proj1' }), + createMockSession({ id: 's2', projectRoot: '/proj2' }), + ]; + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockLoadCueConfig).toHaveBeenCalledWith('/proj1'); + expect(mockLoadCueConfig).toHaveBeenCalledWith('/proj2'); + }); + + it('skips sessions without a cue config', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(engine.getStatus()).toHaveLength(0); + }); + + it('initializes sessions with valid config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(1); + }); + + it('sets up YAML file watcher for config changes', () => { + mockLoadCueConfig.mockReturnValue(createMockConfig()); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockWatchCueYaml).toHaveBeenCalled(); + }); + }); + + describe('time.interval subscriptions', () => { + it('fires immediately on setup', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'Run check', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Should fire immediately + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'Run check', + expect.objectContaining({ type: 'time.interval', triggerName: 'periodic' }) + ); + }); + + it('fires on the interval', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'Run check', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + // Advance 5 minutes + vi.advanceTimersByTime(5 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Advance another 5 minutes + vi.advanceTimersByTime(5 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + engine.stop(); + }); + + it('skips disabled subscriptions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'disabled', + event: 'time.interval', + enabled: false, + prompt: 'noop', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + engine.stop(); + }); + + it('clears timers on stop', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.stop(); + + vi.advanceTimersByTime(60 * 1000); + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + }); + + describe('file.changed subscriptions', () => { + it('creates a file watcher with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'watch-src', + event: 'file.changed', + enabled: true, + prompt: 'lint', + watch: 'src/**/*.ts', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueFileWatcher).toHaveBeenCalledWith( + expect.objectContaining({ + watchGlob: 'src/**/*.ts', + projectRoot: '/projects/test', + debounceMs: 5000, + triggerName: 'watch-src', + }) + ); + + engine.stop(); + }); + + it('cleans up file watcher on stop', () => { + const config = createMockConfig({ + subscriptions: [ + { name: 'watch', event: 'file.changed', enabled: true, prompt: 'test', watch: '**/*.ts' }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + engine.stop(); + + expect(fileWatcherCleanup).toHaveBeenCalled(); + }); + }); + + describe('agent.completed subscriptions', () => { + it('fires for single source_session match', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'on-done', + }) + ); + }); + + it('does not fire for non-matching session', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-b'); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + + it('tracks fan-in completions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + // First completion — should not fire + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + // Second completion — should fire + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'all-done', + }) + ); + }); + + it('resets fan-in tracker after firing', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + engine.notifyAgentCompleted('agent-a'); + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + + // Start again — should need both to fire again + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + }); + + describe('session management', () => { + it('removeSession tears down subscriptions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.removeSession('session-1'); + expect(engine.getStatus()).toHaveLength(0); + expect(yamlWatcherCleanup).toHaveBeenCalled(); + }); + + it('refreshSession re-reads config', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'old', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'new-1', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + { + name: 'new-2', + event: 'time.interval', + enabled: true, + prompt: 'test2', + interval_minutes: 15, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config1).mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.refreshSession('session-1', '/projects/test'); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(2); + }); + }); + + describe('activity log', () => { + it('records completed runs', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Wait for the async run to complete + await vi.advanceTimersByTimeAsync(100); + + const log = engine.getActivityLog(); + expect(log.length).toBeGreaterThan(0); + expect(log[0].subscriptionName).toBe('periodic'); + }); + + it('respects limit parameter', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Run multiple intervals + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + + const limited = engine.getActivityLog(1); + expect(limited).toHaveLength(1); + + engine.stop(); + }); + }); + + describe('run management', () => { + it('stopRun returns false for non-existent run', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.stopRun('nonexistent')).toBe(false); + }); + + it('stopAll clears all active runs', async () => { + // Use a slow-resolving onCueRun to keep runs active + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), // Never resolves + }); + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + // Allow async execution to start + await vi.advanceTimersByTimeAsync(10); + + expect(engine.getActiveRuns().length).toBeGreaterThan(0); + engine.stopAll(); + expect(engine.getActiveRuns()).toHaveLength(0); + + engine.stop(); + }); + }); + + describe('getStatus', () => { + it('returns correct status for active sessions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + { + name: 'disabled', + event: 'time.interval', + enabled: false, + prompt: 'noop', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].sessionId).toBe('session-1'); + expect(status[0].sessionName).toBe('Test Session'); + expect(status[0].subscriptionCount).toBe(1); // Only enabled ones + expect(status[0].enabled).toBe(true); + + engine.stop(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-file-watcher.test.ts b/src/__tests__/main/cue/cue-file-watcher.test.ts new file mode 100644 index 000000000..7d4d8e5d9 --- /dev/null +++ b/src/__tests__/main/cue/cue-file-watcher.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for the Cue file watcher provider. + * + * Tests cover: + * - Chokidar watcher creation with correct options + * - Per-file debouncing of change events + * - CueEvent construction with correct payload + * - Cleanup of timers and watcher + * - Error handling + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock crypto.randomUUID +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => 'test-uuid-1234'), +})); + +// Mock chokidar +const mockOn = vi.fn().mockReturnThis(); +const mockClose = vi.fn(); +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: mockOn, + close: mockClose, + })), +})); + +import { createCueFileWatcher } from '../../../main/cue/cue-file-watcher'; +import type { CueEvent } from '../../../main/cue/cue-types'; +import * as chokidar from 'chokidar'; + +describe('cue-file-watcher', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates a chokidar watcher with correct options', () => { + createCueFileWatcher({ + watchGlob: 'src/**/*.ts', + projectRoot: '/projects/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test-trigger', + }); + + expect(chokidar.watch).toHaveBeenCalledWith('src/**/*.ts', { + cwd: '/projects/test', + ignoreInitial: true, + persistent: true, + }); + }); + + it('registers change, add, and unlink handlers', () => { + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test', + }); + + const registeredEvents = mockOn.mock.calls.map((call) => call[0]); + expect(registeredEvents).toContain('change'); + expect(registeredEvents).toContain('add'); + expect(registeredEvents).toContain('unlink'); + expect(registeredEvents).toContain('error'); + }); + + it('debounces events per file', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + expect(changeHandler).toBeDefined(); + + // Rapid changes to the same file + changeHandler('src/index.ts'); + changeHandler('src/index.ts'); + changeHandler('src/index.ts'); + + vi.advanceTimersByTime(5000); + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it('does not coalesce events from different files', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + + changeHandler('src/a.ts'); + changeHandler('src/b.ts'); + + vi.advanceTimersByTime(5000); + expect(onEvent).toHaveBeenCalledTimes(2); + }); + + it('constructs a CueEvent with correct payload for change events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'my-trigger', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + changeHandler('src/index.ts'); + vi.advanceTimersByTime(100); + + expect(onEvent).toHaveBeenCalledTimes(1); + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.id).toBe('test-uuid-1234'); + expect(event.type).toBe('file.changed'); + expect(event.triggerName).toBe('my-trigger'); + expect(event.payload.filename).toBe('index.ts'); + expect(event.payload.extension).toBe('.ts'); + expect(event.payload.changeType).toBe('change'); + }); + + it('reports correct changeType for add events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'test', + }); + + const addHandler = mockOn.mock.calls.find((call) => call[0] === 'add')?.[1]; + addHandler('src/new.ts'); + vi.advanceTimersByTime(100); + + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.payload.changeType).toBe('add'); + }); + + it('reports correct changeType for unlink events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'test', + }); + + const unlinkHandler = mockOn.mock.calls.find((call) => call[0] === 'unlink')?.[1]; + unlinkHandler('src/deleted.ts'); + vi.advanceTimersByTime(100); + + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.payload.changeType).toBe('unlink'); + }); + + it('cleanup function clears timers and closes watcher', () => { + const onEvent = vi.fn(); + const cleanup = createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + // Trigger a change to create a pending timer + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + changeHandler('src/index.ts'); + + cleanup(); + + // Advance past debounce — event should NOT fire since cleanup was called + vi.advanceTimersByTime(5000); + expect(onEvent).not.toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + }); + + it('handles watcher errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test', + }); + + const errorHandler = mockOn.mock.calls.find((call) => call[0] === 'error')?.[1]; + expect(errorHandler).toBeDefined(); + + // Should not throw + errorHandler(new Error('Watch error')); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/main/cue/cue-yaml-loader.test.ts b/src/__tests__/main/cue/cue-yaml-loader.test.ts new file mode 100644 index 000000000..6a3eb197e --- /dev/null +++ b/src/__tests__/main/cue/cue-yaml-loader.test.ts @@ -0,0 +1,311 @@ +/** + * Tests for the Cue YAML loader module. + * + * Tests cover: + * - Loading and parsing maestro-cue.yaml files + * - Handling missing files + * - Merging with default settings + * - Validation of subscription fields per event type + * - YAML file watching with debounce + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock chokidar +const mockChokidarOn = vi.fn().mockReturnThis(); +const mockChokidarClose = vi.fn(); +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: mockChokidarOn, + close: mockChokidarClose, + })), +})); + +// Mock fs +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +vi.mock('fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// Must import after mocks +import { loadCueConfig, watchCueYaml, validateCueConfig } from '../../../main/cue/cue-yaml-loader'; +import * as chokidar from 'chokidar'; + +describe('cue-yaml-loader', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('loadCueConfig', () => { + it('returns null when file does not exist', () => { + mockExistsSync.mockReturnValue(false); + const result = loadCueConfig('/projects/test'); + expect(result).toBeNull(); + }); + + it('parses a valid YAML config with subscriptions and settings', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: daily-check + event: time.interval + enabled: true + prompt: Check all tests + interval_minutes: 60 + - name: watch-src + event: file.changed + enabled: true + prompt: Run lint + watch: "src/**/*.ts" +settings: + timeout_minutes: 15 + timeout_on_fail: continue +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions).toHaveLength(2); + expect(result!.subscriptions[0].name).toBe('daily-check'); + expect(result!.subscriptions[0].event).toBe('time.interval'); + expect(result!.subscriptions[0].interval_minutes).toBe(60); + expect(result!.subscriptions[1].name).toBe('watch-src'); + expect(result!.subscriptions[1].watch).toBe('src/**/*.ts'); + expect(result!.settings.timeout_minutes).toBe(15); + expect(result!.settings.timeout_on_fail).toBe('continue'); + }); + + it('uses default settings when settings section is missing', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.interval + prompt: Do stuff + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.settings.timeout_minutes).toBe(30); + expect(result!.settings.timeout_on_fail).toBe('break'); + }); + + it('defaults enabled to true when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.interval + prompt: Do stuff + interval_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].enabled).toBe(true); + }); + + it('respects enabled: false', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: disabled-sub + event: time.interval + enabled: false + prompt: Do stuff + interval_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].enabled).toBe(false); + }); + + it('returns null for empty YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(''); + const result = loadCueConfig('/projects/test'); + expect(result).toBeNull(); + }); + + it('throws on malformed YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{ invalid yaml ['); + expect(() => loadCueConfig('/projects/test')).toThrow(); + }); + + it('handles agent.completed with source_session array', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: fan-in-trigger + event: agent.completed + prompt: All agents done + source_session: + - agent-1 + - agent-2 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].source_session).toEqual(['agent-1', 'agent-2']); + }); + }); + + describe('watchCueYaml', () => { + it('watches the correct file path', () => { + watchCueYaml('/projects/test', vi.fn()); + expect(chokidar.watch).toHaveBeenCalledWith( + expect.stringContaining('maestro-cue.yaml'), + expect.objectContaining({ persistent: true, ignoreInitial: true }) + ); + }); + + it('calls onChange with debounce on file change', () => { + const onChange = vi.fn(); + watchCueYaml('/projects/test', onChange); + + // Simulate a 'change' event via the mock's on handler + const changeHandler = mockChokidarOn.mock.calls.find( + (call: unknown[]) => call[0] === 'change' + )?.[1]; + expect(changeHandler).toBeDefined(); + + changeHandler!(); + expect(onChange).not.toHaveBeenCalled(); // Not yet — debounced + + vi.advanceTimersByTime(1000); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('debounces multiple rapid changes', () => { + const onChange = vi.fn(); + watchCueYaml('/projects/test', onChange); + + const changeHandler = mockChokidarOn.mock.calls.find( + (call: unknown[]) => call[0] === 'change' + )?.[1]; + + changeHandler!(); + vi.advanceTimersByTime(500); + changeHandler!(); + vi.advanceTimersByTime(500); + changeHandler!(); + vi.advanceTimersByTime(1000); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('cleanup function closes watcher', () => { + const cleanup = watchCueYaml('/projects/test', vi.fn()); + cleanup(); + expect(mockChokidarClose).toHaveBeenCalled(); + }); + + it('registers handlers for add, change, and unlink events', () => { + watchCueYaml('/projects/test', vi.fn()); + const registeredEvents = mockChokidarOn.mock.calls.map((call: unknown[]) => call[0]); + expect(registeredEvents).toContain('add'); + expect(registeredEvents).toContain('change'); + expect(registeredEvents).toContain('unlink'); + }); + }); + + describe('validateCueConfig', () => { + it('returns valid for a correct config', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: 'test', event: 'time.interval', prompt: 'Do it', interval_minutes: 5 }, + ], + settings: { timeout_minutes: 30, timeout_on_fail: 'break' }, + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects non-object config', () => { + const result = validateCueConfig(null); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('non-null object'); + }); + + it('requires subscriptions array', () => { + const result = validateCueConfig({ settings: {} }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('subscriptions'); + }); + + it('requires name on subscriptions', () => { + const result = validateCueConfig({ + subscriptions: [{ event: 'time.interval', prompt: 'Test', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('"name"')])); + }); + + it('requires interval_minutes for time.interval', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.interval', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('interval_minutes')]) + ); + }); + + it('requires watch for file.changed', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'file.changed', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('watch')])); + }); + + it('requires source_session for agent.completed', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'agent.completed', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('source_session')]) + ); + }); + + it('rejects invalid timeout_on_fail value', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'invalid' }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('timeout_on_fail')]) + ); + }); + + it('accepts valid timeout_on_fail values', () => { + const breakResult = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'break' }, + }); + expect(breakResult.valid).toBe(true); + + const continueResult = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'continue' }, + }); + expect(continueResult.valid).toBe(true); + }); + + it('requires prompt to be a non-empty string', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.interval', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('"prompt"')])); + }); + }); +}); diff --git a/src/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts new file mode 100644 index 000000000..ddd9b18c0 --- /dev/null +++ b/src/main/cue/cue-engine.ts @@ -0,0 +1,401 @@ +/** + * Cue Engine Core — the main coordinator for Maestro Cue event-driven automation. + * + * Discovers maestro-cue.yaml files per session, manages interval timers, + * file watchers, and agent completion listeners. Runs in the Electron main process. + */ + +import * as crypto from 'crypto'; +import type { MainLogLevel } from '../../shared/logger-types'; +import type { SessionInfo } from '../../shared/types'; +import type { CueConfig, CueEvent, CueRunResult, CueSessionStatus } from './cue-types'; +import { loadCueConfig, watchCueYaml } from './cue-yaml-loader'; +import { createCueFileWatcher } from './cue-file-watcher'; + +const ACTIVITY_LOG_MAX = 500; +const DEFAULT_FILE_DEBOUNCE_MS = 5000; + +/** Dependencies injected into the CueEngine */ +export interface CueEngineDeps { + getSessions: () => SessionInfo[]; + onCueRun: (sessionId: string, prompt: string, event: CueEvent) => Promise; + onLog: (level: MainLogLevel, message: string, data?: unknown) => void; +} + +/** Internal state per session with an active Cue config */ +interface SessionState { + config: CueConfig; + timers: ReturnType[]; + watchers: (() => void)[]; + yamlWatcher: (() => void) | null; + lastTriggered?: string; + nextTriggers: Map; // subscriptionName -> next trigger timestamp +} + +/** Active run tracking */ +interface ActiveRun { + result: CueRunResult; + abortController?: AbortController; +} + +export class CueEngine { + private enabled = false; + private sessions = new Map(); + private activeRuns = new Map(); + private activityLog: CueRunResult[] = []; + private fanInTrackers = new Map>(); + private deps: CueEngineDeps; + + constructor(deps: CueEngineDeps) { + this.deps = deps; + } + + /** Enable the engine and scan all sessions for Cue configs */ + start(): void { + this.enabled = true; + this.deps.onLog('cue', '[CUE] Engine started'); + + const sessions = this.deps.getSessions(); + for (const session of sessions) { + this.initSession(session); + } + } + + /** Disable the engine, clearing all timers and watchers */ + stop(): void { + this.enabled = false; + for (const [sessionId] of this.sessions) { + this.teardownSession(sessionId); + } + this.sessions.clear(); + this.deps.onLog('cue', '[CUE] Engine stopped'); + } + + /** Re-read the YAML for a specific session, tearing down old subscriptions */ + refreshSession(sessionId: string, projectRoot: string): void { + this.teardownSession(sessionId); + this.sessions.delete(sessionId); + + const session = this.deps.getSessions().find((s) => s.id === sessionId); + if (session) { + this.initSession({ ...session, projectRoot }); + } + } + + /** Teardown all subscriptions for a session */ + removeSession(sessionId: string): void { + this.teardownSession(sessionId); + this.sessions.delete(sessionId); + this.deps.onLog('cue', `[CUE] Session removed: ${sessionId}`); + } + + /** Returns status of all sessions with Cue configs */ + getStatus(): CueSessionStatus[] { + const result: CueSessionStatus[] = []; + const allSessions = this.deps.getSessions(); + + for (const [sessionId, state] of this.sessions) { + const session = allSessions.find((s) => s.id === sessionId); + if (!session) continue; + + const activeRunCount = [...this.activeRuns.values()].filter( + (r) => r.result.sessionId === sessionId + ).length; + + let nextTrigger: string | undefined; + if (state.nextTriggers.size > 0) { + const earliest = Math.min(...state.nextTriggers.values()); + nextTrigger = new Date(earliest).toISOString(); + } + + result.push({ + sessionId, + sessionName: session.name, + toolType: session.toolType, + enabled: true, + subscriptionCount: state.config.subscriptions.filter((s) => s.enabled !== false).length, + activeRuns: activeRunCount, + lastTriggered: state.lastTriggered, + nextTrigger, + }); + } + + return result; + } + + /** Returns currently running Cue executions */ + getActiveRuns(): CueRunResult[] { + return [...this.activeRuns.values()].map((r) => r.result); + } + + /** Returns recent completed/failed runs */ + getActivityLog(limit?: number): CueRunResult[] { + if (limit !== undefined) { + return this.activityLog.slice(-limit); + } + return [...this.activityLog]; + } + + /** Stops a specific running execution */ + stopRun(runId: string): boolean { + const run = this.activeRuns.get(runId); + if (!run) return false; + + run.abortController?.abort(); + run.result.status = 'stopped'; + run.result.endedAt = new Date().toISOString(); + run.result.durationMs = Date.now() - new Date(run.result.startedAt).getTime(); + + this.activeRuns.delete(runId); + this.pushActivityLog(run.result); + this.deps.onLog('cue', `[CUE] Run stopped: ${runId}`); + return true; + } + + /** Stops all running executions */ + stopAll(): void { + for (const [runId] of this.activeRuns) { + this.stopRun(runId); + } + } + + /** Returns master enabled state */ + isEnabled(): boolean { + return this.enabled; + } + + /** Notify the engine that an agent session has completed (for agent.completed triggers) */ + notifyAgentCompleted(sessionId: string): void { + if (!this.enabled) return; + + for (const [ownerSessionId, state] of this.sessions) { + for (const sub of state.config.subscriptions) { + if (sub.event !== 'agent.completed' || sub.enabled === false) continue; + + const sources = Array.isArray(sub.source_session) + ? sub.source_session + : sub.source_session + ? [sub.source_session] + : []; + + if (!sources.includes(sessionId)) continue; + + if (sources.length === 1) { + // Single source — fire immediately + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'agent.completed', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { completedSessionId: sessionId }, + }; + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (agent.completed)`); + this.executeCueRun(ownerSessionId, sub.prompt, event, sub.name); + } else { + // Fan-in: track completions + const key = `${ownerSessionId}:${sub.name}`; + if (!this.fanInTrackers.has(key)) { + this.fanInTrackers.set(key, new Set()); + } + const tracker = this.fanInTrackers.get(key)!; + tracker.add(sessionId); + + if (tracker.size >= sources.length) { + this.fanInTrackers.delete(key); + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'agent.completed', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { + completedSessions: [...tracker], + }, + }; + this.deps.onLog( + 'cue', + `[CUE] "${sub.name}" triggered (agent.completed, fan-in complete)` + ); + this.executeCueRun(ownerSessionId, sub.prompt, event, sub.name); + } + } + } + } + } + + // --- Private methods --- + + private initSession(session: SessionInfo): void { + if (!this.enabled) return; + + const config = loadCueConfig(session.projectRoot); + if (!config) return; + + const state: SessionState = { + config, + timers: [], + watchers: [], + yamlWatcher: null, + nextTriggers: new Map(), + }; + + // Watch the YAML file for changes + state.yamlWatcher = watchCueYaml(session.projectRoot, () => { + this.deps.onLog('cue', `[CUE] Config changed for session "${session.name}", refreshing`); + this.refreshSession(session.id, session.projectRoot); + }); + + // Set up subscriptions + for (const sub of config.subscriptions) { + if (sub.enabled === false) continue; + + if (sub.event === 'time.interval' && sub.interval_minutes) { + this.setupTimerSubscription(session, state, sub); + } else if (sub.event === 'file.changed' && sub.watch) { + this.setupFileWatcherSubscription(session, state, sub); + } + // agent.completed subscriptions are handled reactively via notifyAgentCompleted + } + + this.sessions.set(session.id, state); + this.deps.onLog( + 'cue', + `[CUE] Initialized session "${session.name}" with ${config.subscriptions.filter((s) => s.enabled !== false).length} active subscription(s)` + ); + } + + private setupTimerSubscription( + session: SessionInfo, + state: SessionState, + sub: { name: string; prompt: string; interval_minutes?: number } + ): void { + const intervalMs = (sub.interval_minutes ?? 0) * 60 * 1000; + if (intervalMs <= 0) return; + + // Fire immediately on first setup + const immediateEvent: CueEvent = { + id: crypto.randomUUID(), + type: 'time.interval', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { interval_minutes: sub.interval_minutes }, + }; + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (time.interval, initial)`); + this.executeCueRun(session.id, sub.prompt, immediateEvent, sub.name); + + // Then on the interval + const timer = setInterval(() => { + if (!this.enabled) return; + + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'time.interval', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { interval_minutes: sub.interval_minutes }, + }; + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (time.interval)`); + state.lastTriggered = event.timestamp; + state.nextTriggers.set(sub.name, Date.now() + intervalMs); + this.executeCueRun(session.id, sub.prompt, event, sub.name); + }, intervalMs); + + state.nextTriggers.set(sub.name, Date.now() + intervalMs); + state.timers.push(timer); + } + + private setupFileWatcherSubscription( + session: SessionInfo, + state: SessionState, + sub: { name: string; prompt: string; watch?: string } + ): void { + if (!sub.watch) return; + + const cleanup = createCueFileWatcher({ + watchGlob: sub.watch, + projectRoot: session.projectRoot, + debounceMs: DEFAULT_FILE_DEBOUNCE_MS, + triggerName: sub.name, + onEvent: (event) => { + if (!this.enabled) return; + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (file.changed)`); + state.lastTriggered = event.timestamp; + this.executeCueRun(session.id, sub.prompt, event, sub.name); + }, + }); + + state.watchers.push(cleanup); + } + + private async executeCueRun( + sessionId: string, + prompt: string, + event: CueEvent, + subscriptionName: string + ): Promise { + const session = this.deps.getSessions().find((s) => s.id === sessionId); + const runId = crypto.randomUUID(); + const abortController = new AbortController(); + + const result: CueRunResult = { + runId, + sessionId, + sessionName: session?.name ?? 'Unknown', + subscriptionName, + event, + status: 'running', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: '', + }; + + this.activeRuns.set(runId, { result, abortController }); + + try { + const runResult = await this.deps.onCueRun(sessionId, prompt, event); + result.status = runResult.status; + result.stdout = runResult.stdout; + result.stderr = runResult.stderr; + result.exitCode = runResult.exitCode; + } catch (error) { + result.status = 'failed'; + result.stderr = error instanceof Error ? error.message : String(error); + } finally { + result.endedAt = new Date().toISOString(); + result.durationMs = Date.now() - new Date(result.startedAt).getTime(); + this.activeRuns.delete(runId); + this.pushActivityLog(result); + } + } + + private pushActivityLog(result: CueRunResult): void { + this.activityLog.push(result); + if (this.activityLog.length > ACTIVITY_LOG_MAX) { + this.activityLog = this.activityLog.slice(-ACTIVITY_LOG_MAX); + } + } + + private teardownSession(sessionId: string): void { + const state = this.sessions.get(sessionId); + if (!state) return; + + for (const timer of state.timers) { + clearInterval(timer); + } + for (const cleanup of state.watchers) { + cleanup(); + } + if (state.yamlWatcher) { + state.yamlWatcher(); + } + + // Clean up fan-in trackers for this session + for (const key of this.fanInTrackers.keys()) { + if (key.startsWith(`${sessionId}:`)) { + this.fanInTrackers.delete(key); + } + } + } +} diff --git a/src/main/cue/cue-file-watcher.ts b/src/main/cue/cue-file-watcher.ts new file mode 100644 index 000000000..e37825442 --- /dev/null +++ b/src/main/cue/cue-file-watcher.ts @@ -0,0 +1,82 @@ +/** + * File watcher provider for Maestro Cue file.changed subscriptions. + * + * Wraps chokidar to watch glob patterns with per-file debouncing + * and produces CueEvent instances for the engine. + */ + +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as chokidar from 'chokidar'; +import type { CueEvent } from './cue-types'; + +export interface CueFileWatcherConfig { + watchGlob: string; + projectRoot: string; + debounceMs: number; + onEvent: (event: CueEvent) => void; + triggerName: string; +} + +/** + * Creates a chokidar file watcher for a Cue file.changed subscription. + * Returns a cleanup function to stop watching. + */ +export function createCueFileWatcher(config: CueFileWatcherConfig): () => void { + const { watchGlob, projectRoot, debounceMs, onEvent, triggerName } = config; + const debounceTimers = new Map>(); + + const watcher = chokidar.watch(watchGlob, { + cwd: projectRoot, + ignoreInitial: true, + persistent: true, + }); + + const handleEvent = (changeType: 'change' | 'add' | 'unlink') => (filePath: string) => { + const existingTimer = debounceTimers.get(filePath); + if (existingTimer) { + clearTimeout(existingTimer); + } + + debounceTimers.set( + filePath, + setTimeout(() => { + debounceTimers.delete(filePath); + + const absolutePath = path.resolve(projectRoot, filePath); + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'file.changed', + timestamp: new Date().toISOString(), + triggerName, + payload: { + path: absolutePath, + filename: path.basename(filePath), + directory: path.dirname(absolutePath), + extension: path.extname(filePath), + changeType, + }, + }; + + onEvent(event); + }, debounceMs) + ); + }; + + watcher.on('change', handleEvent('change')); + watcher.on('add', handleEvent('add')); + watcher.on('unlink', handleEvent('unlink')); + + watcher.on('error', (error) => { + // Log but don't crash — the parent engine will handle logging + console.error(`[CUE] File watcher error for "${triggerName}":`, error); + }); + + return () => { + for (const timer of debounceTimers.values()) { + clearTimeout(timer); + } + debounceTimers.clear(); + watcher.close(); + }; +} diff --git a/src/main/cue/cue-yaml-loader.ts b/src/main/cue/cue-yaml-loader.ts new file mode 100644 index 000000000..cec9a05a7 --- /dev/null +++ b/src/main/cue/cue-yaml-loader.ts @@ -0,0 +1,183 @@ +/** + * YAML loader for Maestro Cue configuration files. + * + * Handles discovery, parsing, validation, and watching of maestro-cue.yaml files. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import * as chokidar from 'chokidar'; +import { + type CueConfig, + type CueSubscription, + type CueSettings, + DEFAULT_CUE_SETTINGS, + CUE_YAML_FILENAME, +} from './cue-types'; + +/** + * Loads and parses a maestro-cue.yaml file from the given project root. + * Returns null if the file doesn't exist. Throws on malformed YAML. + */ +export function loadCueConfig(projectRoot: string): CueConfig | null { + const filePath = path.join(projectRoot, CUE_YAML_FILENAME); + + if (!fs.existsSync(filePath)) { + return null; + } + + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = yaml.load(raw) as Record | null; + + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const subscriptions: CueSubscription[] = []; + const rawSubs = parsed.subscriptions; + if (Array.isArray(rawSubs)) { + for (const sub of rawSubs) { + if (sub && typeof sub === 'object') { + subscriptions.push({ + name: String(sub.name ?? ''), + event: String(sub.event ?? '') as CueSubscription['event'], + enabled: sub.enabled !== false, + prompt: String(sub.prompt ?? ''), + interval_minutes: + typeof sub.interval_minutes === 'number' ? sub.interval_minutes : undefined, + watch: typeof sub.watch === 'string' ? sub.watch : undefined, + source_session: sub.source_session, + fan_out: Array.isArray(sub.fan_out) ? sub.fan_out : undefined, + }); + } + } + } + + const rawSettings = parsed.settings as Record | undefined; + const settings: CueSettings = { + timeout_minutes: + typeof rawSettings?.timeout_minutes === 'number' + ? rawSettings.timeout_minutes + : DEFAULT_CUE_SETTINGS.timeout_minutes, + timeout_on_fail: + rawSettings?.timeout_on_fail === 'break' || rawSettings?.timeout_on_fail === 'continue' + ? rawSettings.timeout_on_fail + : DEFAULT_CUE_SETTINGS.timeout_on_fail, + }; + + return { subscriptions, settings }; +} + +/** + * Watches a maestro-cue.yaml file for changes. Returns a cleanup function. + * Calls onChange when the file is created, modified, or deleted. + * Debounces by 1 second. + */ +export function watchCueYaml(projectRoot: string, onChange: () => void): () => void { + const filePath = path.join(projectRoot, CUE_YAML_FILENAME); + let debounceTimer: ReturnType | null = null; + + const watcher = chokidar.watch(filePath, { + persistent: true, + ignoreInitial: true, + }); + + const debouncedOnChange = () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + onChange(); + }, 1000); + }; + + watcher.on('add', debouncedOnChange); + watcher.on('change', debouncedOnChange); + watcher.on('unlink', debouncedOnChange); + + return () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + watcher.close(); + }; +} + +/** + * Validates a CueConfig-shaped object. Returns validation result with error messages. + */ +export function validateCueConfig(config: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!config || typeof config !== 'object') { + return { valid: false, errors: ['Config must be a non-null object'] }; + } + + const cfg = config as Record; + + if (!Array.isArray(cfg.subscriptions)) { + errors.push('Config must have a "subscriptions" array'); + } else { + for (let i = 0; i < cfg.subscriptions.length; i++) { + const sub = cfg.subscriptions[i] as Record; + const prefix = `subscriptions[${i}]`; + + if (!sub || typeof sub !== 'object') { + errors.push(`${prefix}: must be an object`); + continue; + } + + if (!sub.name || typeof sub.name !== 'string') { + errors.push(`${prefix}: "name" is required and must be a string`); + } + + if (!sub.event || typeof sub.event !== 'string') { + errors.push(`${prefix}: "event" is required and must be a string`); + } + + if (!sub.prompt || typeof sub.prompt !== 'string') { + errors.push(`${prefix}: "prompt" is required and must be a non-empty string`); + } + + const event = sub.event as string; + if (event === 'time.interval') { + if (typeof sub.interval_minutes !== 'number' || sub.interval_minutes <= 0) { + errors.push( + `${prefix}: "interval_minutes" is required and must be a positive number for time.interval events` + ); + } + } else if (event === 'file.changed') { + if (!sub.watch || typeof sub.watch !== 'string') { + errors.push( + `${prefix}: "watch" is required and must be a non-empty string for file.changed events` + ); + } + } else if (event === 'agent.completed') { + if (!sub.source_session) { + errors.push(`${prefix}: "source_session" is required for agent.completed events`); + } else if (typeof sub.source_session !== 'string' && !Array.isArray(sub.source_session)) { + errors.push( + `${prefix}: "source_session" must be a string or array of strings for agent.completed events` + ); + } + } + } + } + + if (cfg.settings !== undefined) { + if (typeof cfg.settings !== 'object' || cfg.settings === null) { + errors.push('"settings" must be an object'); + } else { + const settings = cfg.settings as Record; + if (settings.timeout_on_fail !== undefined) { + if (settings.timeout_on_fail !== 'break' && settings.timeout_on_fail !== 'continue') { + errors.push('"settings.timeout_on_fail" must be "break" or "continue"'); + } + } + } + } + + return { valid: errors.length === 0, errors }; +} From 21bbcbc822baede42526b128820cdb7872ddfb55 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 1 Mar 2026 02:18:21 -0600 Subject: [PATCH 010/194] MAESTRO: Phase 03 - Cue executor for background agent spawning and history recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Cue executor module that spawns background agent processes when Cue triggers fire, following the same spawn pattern as Auto Run's process:spawn IPC handler. Key exports: - executeCuePrompt(): Full 10-step pipeline (prompt resolution, template substitution, agent arg building, SSH wrapping, process spawn with stdout/stderr capture, timeout enforcement with SIGTERM→SIGKILL) - stopCueRun(): Graceful process termination by runId - recordCueHistoryEntry(): Constructs HistoryEntry with type 'CUE' and all Cue-specific fields (trigger name, event type, source session) - getActiveProcesses(): Monitor running Cue processes Test coverage: 31 tests in cue-executor.test.ts covering execution paths, SSH remote, timeout escalation, history entry construction, and edge cases. Full suite: 21,635 tests passing across 512 files, zero regressions. --- src/__tests__/main/cue/cue-executor.test.ts | 891 ++++++++++++++++++++ src/main/cue/cue-executor.ts | 381 +++++++++ 2 files changed, 1272 insertions(+) create mode 100644 src/__tests__/main/cue/cue-executor.test.ts create mode 100644 src/main/cue/cue-executor.ts diff --git a/src/__tests__/main/cue/cue-executor.test.ts b/src/__tests__/main/cue/cue-executor.test.ts new file mode 100644 index 000000000..1e388fb71 --- /dev/null +++ b/src/__tests__/main/cue/cue-executor.test.ts @@ -0,0 +1,891 @@ +/** + * Tests for the Cue executor module. + * + * Tests cover: + * - Prompt file resolution (absolute and relative paths) + * - Prompt file read failures + * - Template variable substitution with Cue event context + * - Agent argument building (follows process:spawn pattern) + * - Process spawning and stdout/stderr capture + * - Timeout enforcement with SIGTERM → SIGKILL escalation + * - Successful completion and failure detection + * - SSH remote execution wrapping + * - stopCueRun process termination + * - recordCueHistoryEntry construction + * - History entry field population and response truncation + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import type { ChildProcess } from 'child_process'; +import type { CueEvent, CueSubscription, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; +import type { TemplateContext } from '../../../shared/templateVariables'; + +// --- Mocks --- + +// Mock fs +const mockReadFileSync = vi.fn(); +vi.mock('fs', () => ({ + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => 'test-uuid-1234'), +})); + +// Mock substituteTemplateVariables +const mockSubstitute = vi.fn((template: string) => `substituted: ${template}`); +vi.mock('../../../shared/templateVariables', () => ({ + substituteTemplateVariables: (...args: unknown[]) => mockSubstitute(args[0] as string, args[1]), +})); + +// Mock agents module +const mockGetAgentDefinition = vi.fn(); +const mockGetAgentCapabilities = vi.fn(() => ({ + supportsResume: true, + supportsReadOnlyMode: true, + supportsJsonOutput: true, + supportsSessionId: true, + supportsImageInput: false, + supportsImageInputOnResume: false, + supportsSlashCommands: true, + supportsSessionStorage: true, + supportsCostTracking: true, + supportsContextUsage: true, + supportsThinking: false, + supportsStdin: false, + supportsRawStdin: false, + supportsModelSelection: false, + supportsModelDiscovery: false, + supportsBatchMode: true, + supportsYoloMode: true, + supportsExitCodes: true, + supportsWorkingDir: false, +})); +vi.mock('../../../main/agents', () => ({ + getAgentDefinition: (...args: unknown[]) => mockGetAgentDefinition(...args), + getAgentCapabilities: (...args: unknown[]) => mockGetAgentCapabilities(...args), +})); + +// Mock buildAgentArgs and applyAgentConfigOverrides +const mockBuildAgentArgs = vi.fn((_agent: unknown, _opts: unknown) => [ + '--print', + '--verbose', + '--output-format', + 'stream-json', + '--dangerously-skip-permissions', + '--', + 'prompt-content', +]); +const mockApplyOverrides = vi.fn((_agent: unknown, args: string[], _overrides: unknown) => ({ + args, + effectiveCustomEnvVars: undefined, + customArgsSource: 'none' as const, + customEnvSource: 'none' as const, + modelSource: 'default' as const, +})); +vi.mock('../../../main/utils/agent-args', () => ({ + buildAgentArgs: (...args: unknown[]) => mockBuildAgentArgs(...args), + applyAgentConfigOverrides: (...args: unknown[]) => mockApplyOverrides(...args), +})); + +// Mock wrapSpawnWithSsh +const mockWrapSpawnWithSsh = vi.fn(); +vi.mock('../../../main/utils/ssh-spawn-wrapper', () => ({ + wrapSpawnWithSsh: (...args: unknown[]) => mockWrapSpawnWithSsh(...args), +})); + +// Mock child_process.spawn +class MockChildProcess extends EventEmitter { + stdin = { + write: vi.fn(), + end: vi.fn(), + }; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + + kill(signal?: string) { + this.killed = true; + return true; + } + + constructor() { + super(); + // Set encoding methods on stdout/stderr + (this.stdout as any).setEncoding = vi.fn(); + (this.stderr as any).setEncoding = vi.fn(); + } +} + +let mockChild: MockChildProcess; +const mockSpawn = vi.fn(() => { + mockChild = new MockChildProcess(); + return mockChild as unknown as ChildProcess; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + default: { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + }, + }; +}); + +// Must import after mocks +import { + executeCuePrompt, + stopCueRun, + getActiveProcesses, + recordCueHistoryEntry, + type CueExecutionConfig, +} from '../../../main/cue/cue-executor'; + +// --- Helpers --- + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockSubscription(overrides: Partial = {}): CueSubscription { + return { + name: 'Watch config', + event: 'file.changed', + enabled: true, + prompt: 'prompts/on-config-change.md', + watch: '**/*.yaml', + ...overrides, + }; +} + +function createMockEvent(overrides: Partial = {}): CueEvent { + return { + id: 'event-1', + type: 'file.changed', + timestamp: '2026-03-01T00:00:00.000Z', + triggerName: 'Watch config', + payload: { + path: '/projects/test/config.yaml', + filename: 'config.yaml', + directory: '/projects/test', + extension: '.yaml', + }, + ...overrides, + }; +} + +function createMockTemplateContext(): TemplateContext { + return { + session: { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + }, + }; +} + +function createExecutionConfig(overrides: Partial = {}): CueExecutionConfig { + return { + runId: 'run-1', + session: createMockSession(), + subscription: createMockSubscription(), + event: createMockEvent(), + promptPath: 'prompts/on-config-change.md', + toolType: 'claude-code', + projectRoot: '/projects/test', + templateContext: createMockTemplateContext(), + timeoutMs: 30000, + onLog: vi.fn(), + ...overrides, + }; +} + +const defaultAgentDef = { + id: 'claude-code', + name: 'Claude Code', + binaryName: 'claude', + command: 'claude', + args: [ + '--print', + '--verbose', + '--output-format', + 'stream-json', + '--dangerously-skip-permissions', + ], +}; + +// --- Tests --- + +describe('cue-executor', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + getActiveProcesses().clear(); + + // Default mock implementations + mockReadFileSync.mockReturnValue('Prompt content: check {{CUE_FILE_PATH}}'); + mockGetAgentDefinition.mockReturnValue(defaultAgentDef); + mockSubstitute.mockImplementation((template: string) => `substituted: ${template}`); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('executeCuePrompt', () => { + it('should resolve relative prompt paths against projectRoot', async () => { + const config = createExecutionConfig({ + promptPath: 'prompts/check.md', + projectRoot: '/projects/test', + }); + + const resultPromise = executeCuePrompt(config); + // Let spawn happen + await vi.advanceTimersByTimeAsync(0); + + expect(mockReadFileSync).toHaveBeenCalledWith('/projects/test/prompts/check.md', 'utf-8'); + + // Close the process to resolve + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should use absolute prompt paths directly', async () => { + const config = createExecutionConfig({ + promptPath: '/absolute/path/prompt.md', + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockReadFileSync).toHaveBeenCalledWith('/absolute/path/prompt.md', 'utf-8'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should return failed result when prompt file cannot be read', async () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file'); + }); + + const config = createExecutionConfig(); + const result = await executeCuePrompt(config); + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Failed to read prompt file'); + expect(result.stderr).toContain('ENOENT'); + expect(result.exitCode).toBeNull(); + }); + + it('should populate Cue event data in template context', async () => { + const event = createMockEvent({ + type: 'file.changed', + payload: { + path: '/projects/test/src/app.ts', + filename: 'app.ts', + directory: '/projects/test/src', + extension: '.ts', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Verify template context was populated with cue data + expect(templateContext.cue).toEqual({ + eventType: 'file.changed', + eventTimestamp: event.timestamp, + triggerName: 'Watch config', + runId: 'run-1', + filePath: '/projects/test/src/app.ts', + fileName: 'app.ts', + fileDir: '/projects/test/src', + fileExt: '.ts', + sourceSession: '', + sourceOutput: '', + }); + + // Verify substituteTemplateVariables was called + expect(mockSubstitute).toHaveBeenCalledWith( + 'Prompt content: check {{CUE_FILE_PATH}}', + templateContext + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should return failed result for unknown agent type', async () => { + mockGetAgentDefinition.mockReturnValue(undefined); + + const config = createExecutionConfig({ toolType: 'nonexistent' }); + const result = await executeCuePrompt(config); + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Unknown agent type: nonexistent'); + }); + + it('should build agent args using the same pipeline as process:spawn', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Verify buildAgentArgs was called with proper params + expect(mockBuildAgentArgs).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'claude-code', + binaryName: 'claude', + command: 'claude', + }), + expect.objectContaining({ + baseArgs: defaultAgentDef.args, + cwd: '/projects/test', + yoloMode: true, + }) + ); + + // Verify applyAgentConfigOverrides was called + expect(mockApplyOverrides).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should spawn the process with correct command and args', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockSpawn).toHaveBeenCalledWith( + 'claude', + expect.any(Array), + expect.objectContaining({ + cwd: '/projects/test', + stdio: ['pipe', 'pipe', 'pipe'], + }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should capture stdout and stderr from the process', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Emit some output + mockChild.stdout.emit('data', 'Hello '); + mockChild.stdout.emit('data', 'world'); + mockChild.stderr.emit('data', 'Warning: something'); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.stdout).toBe('Hello world'); + expect(result.stderr).toBe('Warning: something'); + }); + + it('should return completed status on exit code 0', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.status).toBe('completed'); + expect(result.exitCode).toBe(0); + expect(result.runId).toBe('run-1'); + expect(result.sessionId).toBe('session-1'); + expect(result.sessionName).toBe('Test Session'); + expect(result.subscriptionName).toBe('Watch config'); + }); + + it('should return failed status on non-zero exit code', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('close', 1); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.exitCode).toBe(1); + }); + + it('should handle spawn errors gracefully', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('error', new Error('spawn ENOENT')); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Spawn error: spawn ENOENT'); + expect(result.exitCode).toBeNull(); + }); + + it('should track the process in activeProcesses while running', async () => { + const config = createExecutionConfig({ runId: 'tracked-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(getActiveProcesses().has('tracked-run')).toBe(true); + + mockChild.emit('close', 0); + await resultPromise; + + expect(getActiveProcesses().has('tracked-run')).toBe(false); + }); + + it('should use custom path when provided', async () => { + const config = createExecutionConfig({ + customPath: '/custom/claude', + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockSpawn).toHaveBeenCalledWith( + '/custom/claude', + expect.any(Array), + expect.any(Object) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should close stdin for local execution', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // For local (non-SSH) execution, stdin should just be closed + expect(mockChild.stdin.end).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + + describe('timeout enforcement', () => { + it('should send SIGTERM when timeout expires', async () => { + const config = createExecutionConfig({ timeoutMs: 5000 }); + const killSpy = vi.spyOn(mockChild, 'kill'); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Wait: re-spy after child is created + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(5000); + + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + // Process exits after SIGTERM + mockChild.emit('close', null); + const result = await resultPromise; + + expect(result.status).toBe('timeout'); + }); + + it('should escalate to SIGKILL after SIGTERM + delay', async () => { + const config = createExecutionConfig({ timeoutMs: 5000 }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(5000); + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + // Reset to track SIGKILL — but killed is already true so SIGKILL won't fire + // since child.killed is true. That's correct behavior. + mockChild.killed = false; + + // Advance past SIGKILL delay + await vi.advanceTimersByTimeAsync(5000); + expect(childKill).toHaveBeenCalledWith('SIGKILL'); + + mockChild.emit('close', null); + await resultPromise; + }); + + it('should not timeout when timeoutMs is 0', async () => { + const config = createExecutionConfig({ timeoutMs: 0 }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance a lot of time + await vi.advanceTimersByTimeAsync(60000); + expect(childKill).not.toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + describe('SSH remote execution', () => { + it('should call wrapSpawnWithSsh when SSH is enabled', async () => { + const mockSshStore = { getSshRemotes: vi.fn(() => []) }; + + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['-o', 'BatchMode=yes', 'user@host', 'claude --print'], + cwd: '/Users/test', + customEnvVars: undefined, + prompt: undefined, + sshRemoteUsed: { id: 'remote-1', name: 'My Server', host: 'host.example.com' }, + }); + + const config = createExecutionConfig({ + sshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + sshStore: mockSshStore, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockWrapSpawnWithSsh).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'claude', + agentBinaryName: 'claude', + }), + { enabled: true, remoteId: 'remote-1' }, + mockSshStore + ); + + expect(mockSpawn).toHaveBeenCalledWith( + 'ssh', + expect.arrayContaining(['-o', 'BatchMode=yes']), + expect.objectContaining({ cwd: '/Users/test' }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should write prompt to stdin for SSH large prompt mode', async () => { + const mockSshStore = { getSshRemotes: vi.fn(() => []) }; + + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['user@host'], + cwd: '/Users/test', + customEnvVars: undefined, + prompt: 'large prompt content', // SSH returns prompt for stdin delivery + sshRemoteUsed: { id: 'remote-1', name: 'Server', host: 'host' }, + }); + + const config = createExecutionConfig({ + sshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + sshStore: mockSshStore, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockChild.stdin.write).toHaveBeenCalledWith('large prompt content'); + expect(mockChild.stdin.end).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + it('should pass custom model and args through config overrides', async () => { + const config = createExecutionConfig({ + customModel: 'claude-4-opus', + customArgs: '--max-tokens 1000', + customEnvVars: { API_KEY: 'test-key' }, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockApplyOverrides).toHaveBeenCalledWith( + expect.anything(), + expect.any(Array), + expect.objectContaining({ + sessionCustomModel: 'claude-4-opus', + sessionCustomArgs: '--max-tokens 1000', + sessionCustomEnvVars: { API_KEY: 'test-key' }, + }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should include event duration in the result', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Advance some time + await vi.advanceTimersByTimeAsync(1500); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.durationMs).toBeGreaterThanOrEqual(1500); + expect(result.startedAt).toBeTruthy(); + expect(result.endedAt).toBeTruthy(); + }); + + it('should populate agent.completed event context correctly', async () => { + const event = createMockEvent({ + type: 'agent.completed', + triggerName: 'On agent done', + payload: { + sourceSession: 'builder-session', + sourceOutput: 'Build completed successfully', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.sourceSession).toBe('builder-session'); + expect(templateContext.cue?.sourceOutput).toBe('Build completed successfully'); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + describe('stopCueRun', () => { + it('should return false for unknown runId', () => { + expect(stopCueRun('nonexistent')).toBe(false); + }); + + it('should send SIGTERM to a running process', async () => { + const config = createExecutionConfig({ runId: 'stop-test-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + const stopped = stopCueRun('stop-test-run'); + expect(stopped).toBe(true); + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + mockChild.emit('close', null); + await resultPromise; + }); + }); + + describe('recordCueHistoryEntry', () => { + it('should construct a proper CUE history entry', () => { + const result: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Watch config', + event: createMockEvent(), + status: 'completed', + stdout: 'Task completed successfully', + stderr: '', + exitCode: 0, + durationMs: 5000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:05.000Z', + }; + + const session = createMockSession(); + const entry = recordCueHistoryEntry(result, session); + + expect(entry.type).toBe('CUE'); + expect(entry.id).toBe('test-uuid-1234'); + expect(entry.summary).toBe('[CUE] "Watch config" (file.changed)'); + expect(entry.fullResponse).toBe('Task completed successfully'); + expect(entry.projectPath).toBe('/projects/test'); + expect(entry.sessionId).toBe('session-1'); + expect(entry.sessionName).toBe('Test Session'); + expect(entry.success).toBe(true); + expect(entry.elapsedTimeMs).toBe(5000); + expect(entry.cueTriggerName).toBe('Watch config'); + expect(entry.cueEventType).toBe('file.changed'); + }); + + it('should set success to false for failed runs', () => { + const result: CueRunResult = { + runId: 'run-2', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Periodic check', + event: createMockEvent({ type: 'time.interval' }), + status: 'failed', + stdout: '', + stderr: 'Error occurred', + exitCode: 1, + durationMs: 2000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:02.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.success).toBe(false); + expect(entry.summary).toBe('[CUE] "Periodic check" (time.interval)'); + }); + + it('should truncate long stdout in fullResponse', () => { + const longOutput = 'x'.repeat(15000); + const result: CueRunResult = { + runId: 'run-3', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Large output', + event: createMockEvent(), + status: 'completed', + stdout: longOutput, + stderr: '', + exitCode: 0, + durationMs: 1000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:01.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.fullResponse?.length).toBe(10000); + }); + + it('should set fullResponse to undefined when stdout is empty', () => { + const result: CueRunResult = { + runId: 'run-4', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Silent run', + event: createMockEvent(), + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 500, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:00.500Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.fullResponse).toBeUndefined(); + }); + + it('should populate cueSourceSession from agent.completed event payload', () => { + const result: CueRunResult = { + runId: 'run-5', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'On build done', + event: createMockEvent({ + type: 'agent.completed', + payload: { + sourceSession: 'builder-agent', + }, + }), + status: 'completed', + stdout: 'Done', + stderr: '', + exitCode: 0, + durationMs: 3000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:03.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.cueSourceSession).toBe('builder-agent'); + expect(entry.cueEventType).toBe('agent.completed'); + }); + + it('should set cueSourceSession to undefined when not present in payload', () => { + const result: CueRunResult = { + runId: 'run-6', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Timer check', + event: createMockEvent({ + type: 'time.interval', + payload: { interval_minutes: 5 }, + }), + status: 'completed', + stdout: 'OK', + stderr: '', + exitCode: 0, + durationMs: 1000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:01.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.cueSourceSession).toBeUndefined(); + }); + + it('should use projectRoot for projectPath, falling back to cwd', () => { + const session = createMockSession({ projectRoot: '', cwd: '/fallback/cwd' }); + const result: CueRunResult = { + runId: 'run-7', + sessionId: 'session-1', + sessionName: 'Test', + subscriptionName: 'Test', + event: createMockEvent(), + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:00.100Z', + }; + + const entry = recordCueHistoryEntry(result, session); + + // Empty string is falsy, so should fall back to cwd + expect(entry.projectPath).toBe('/fallback/cwd'); + }); + }); +}); diff --git a/src/main/cue/cue-executor.ts b/src/main/cue/cue-executor.ts new file mode 100644 index 000000000..1e4c12bb2 --- /dev/null +++ b/src/main/cue/cue-executor.ts @@ -0,0 +1,381 @@ +/** + * Cue Executor — spawns background agent processes when Cue triggers fire. + * + * Reads prompt files, substitutes Cue-specific template variables, spawns the + * agent process, captures output, enforces timeouts, and records history entries. + * Follows the same spawn pattern as Auto Run (via process:spawn IPC handler). + */ + +import { spawn, type ChildProcess } from 'child_process'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { CueEvent, CueRunResult, CueRunStatus, CueSubscription } from './cue-types'; +import type { HistoryEntry, SessionInfo } from '../../shared/types'; +import { substituteTemplateVariables, type TemplateContext } from '../../shared/templateVariables'; +import { getAgentDefinition, getAgentCapabilities } from '../agents'; +import { buildAgentArgs, applyAgentConfigOverrides } from '../utils/agent-args'; +import { wrapSpawnWithSsh, type SshSpawnWrapConfig } from '../utils/ssh-spawn-wrapper'; +import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; + +const SIGKILL_DELAY_MS = 5000; +const MAX_HISTORY_RESPONSE_LENGTH = 10000; + +/** Configuration for executing a Cue-triggered prompt */ +export interface CueExecutionConfig { + runId: string; + session: SessionInfo; + subscription: CueSubscription; + event: CueEvent; + promptPath: string; + toolType: string; + projectRoot: string; + templateContext: TemplateContext; + timeoutMs: number; + sshRemoteConfig?: { enabled: boolean; remoteId: string | null }; + customPath?: string; + customArgs?: string; + customEnvVars?: Record; + customModel?: string; + onLog: (level: string, message: string) => void; + /** Optional SSH settings store for SSH remote execution */ + sshStore?: SshRemoteSettingsStore; + /** Optional agent-level config values (from agent config store) */ + agentConfigValues?: Record; +} + +/** Map of active Cue processes by runId */ +const activeProcesses = new Map(); + +/** + * Execute a Cue-triggered prompt by spawning an agent process. + * + * Steps: + * 1. Resolve and read the prompt file + * 2. Populate template context with Cue event data + * 3. Substitute template variables + * 4. Build agent spawn args (same pattern as process:spawn) + * 5. Apply SSH wrapping if configured + * 6. Spawn the process, capture stdout/stderr + * 7. Enforce timeout with SIGTERM → SIGKILL escalation + * 8. Return CueRunResult + */ +export async function executeCuePrompt(config: CueExecutionConfig): Promise { + const { + runId, + session, + subscription, + event, + promptPath, + toolType, + projectRoot, + templateContext, + timeoutMs, + sshRemoteConfig, + customPath, + customArgs, + customEnvVars, + customModel, + onLog, + sshStore, + agentConfigValues, + } = config; + + const startedAt = new Date().toISOString(); + const startTime = Date.now(); + + // 1. Resolve the prompt path + const resolvedPath = path.isAbsolute(promptPath) + ? promptPath + : path.join(projectRoot, promptPath); + + // 2. Read the prompt file + let promptContent: string; + try { + promptContent = fs.readFileSync(resolvedPath, 'utf-8'); + } catch (error) { + const message = `Failed to read prompt file: ${resolvedPath} - ${error instanceof Error ? error.message : String(error)}`; + onLog('error', message); + return { + runId, + sessionId: session.id, + sessionName: session.name, + subscriptionName: subscription.name, + event, + status: 'failed', + stdout: '', + stderr: message, + exitCode: null, + durationMs: Date.now() - startTime, + startedAt, + endedAt: new Date().toISOString(), + }; + } + + // 3. Populate the template context with Cue event data + templateContext.cue = { + eventType: event.type, + eventTimestamp: event.timestamp, + triggerName: subscription.name, + runId, + filePath: String(event.payload.path ?? ''), + fileName: String(event.payload.filename ?? ''), + fileDir: String(event.payload.directory ?? ''), + fileExt: String(event.payload.extension ?? ''), + sourceSession: String(event.payload.sourceSession ?? ''), + sourceOutput: String(event.payload.sourceOutput ?? ''), + }; + + // 4. Substitute template variables + const substitutedPrompt = substituteTemplateVariables(promptContent, templateContext); + + // 5. Look up agent definition and build args + const agentDef = getAgentDefinition(toolType); + if (!agentDef) { + const message = `Unknown agent type: ${toolType}`; + onLog('error', message); + return { + runId, + sessionId: session.id, + sessionName: session.name, + subscriptionName: subscription.name, + event, + status: 'failed', + stdout: '', + stderr: message, + exitCode: null, + durationMs: Date.now() - startTime, + startedAt, + endedAt: new Date().toISOString(), + }; + } + + // Build args following the same pipeline as process:spawn + // Cast to AgentConfig-like shape with available/path/capabilities for buildAgentArgs + const agentConfig = { + ...agentDef, + available: true, + path: customPath || agentDef.command, + capabilities: getAgentCapabilities(toolType), + }; + + let finalArgs = buildAgentArgs(agentConfig, { + baseArgs: agentDef.args, + prompt: substitutedPrompt, + cwd: projectRoot, + yoloMode: true, // Cue runs always use YOLO mode like Auto Run + }); + + // Apply config overrides (custom model, custom args, custom env vars) + const configResolution = applyAgentConfigOverrides(agentConfig, finalArgs, { + agentConfigValues: (agentConfigValues ?? {}) as Record, + sessionCustomModel: customModel, + sessionCustomArgs: customArgs, + sessionCustomEnvVars: customEnvVars, + }); + finalArgs = configResolution.args; + const effectiveEnvVars = configResolution.effectiveCustomEnvVars; + + // Determine the command to use + let command = customPath || agentDef.command; + + // 6. Apply SSH wrapping if configured + let spawnArgs = finalArgs; + let spawnCwd = projectRoot; + let spawnEnvVars = effectiveEnvVars; + let prompt: string | undefined = substitutedPrompt; + + if (sshRemoteConfig?.enabled && sshStore) { + const sshWrapConfig: SshSpawnWrapConfig = { + command, + args: finalArgs, + cwd: projectRoot, + prompt: substitutedPrompt, + customEnvVars: effectiveEnvVars, + agentBinaryName: agentDef.binaryName, + promptArgs: agentDef.promptArgs, + noPromptSeparator: agentDef.noPromptSeparator, + }; + + const sshResult = await wrapSpawnWithSsh(sshWrapConfig, sshRemoteConfig, sshStore); + command = sshResult.command; + spawnArgs = sshResult.args; + spawnCwd = sshResult.cwd; + spawnEnvVars = sshResult.customEnvVars; + prompt = sshResult.prompt; + + if (sshResult.sshRemoteUsed) { + onLog( + 'cue', + `[CUE] Using SSH remote: ${sshResult.sshRemoteUsed.name || sshResult.sshRemoteUsed.host}` + ); + } + } + + // 7. Spawn the process + onLog('cue', `[CUE] Executing run ${runId}: "${subscription.name}" → ${command} (${event.type})`); + + return new Promise((resolve) => { + const env = { + ...process.env, + ...(spawnEnvVars || {}), + }; + + const child = spawn(command, spawnArgs, { + cwd: spawnCwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + activeProcesses.set(runId, child); + + let stdout = ''; + let stderr = ''; + let settled = false; + let timeoutTimer: ReturnType | undefined; + let killTimer: ReturnType | undefined; + + const finish = (status: CueRunStatus, exitCode: number | null) => { + if (settled) return; + settled = true; + + activeProcesses.delete(runId); + if (timeoutTimer) clearTimeout(timeoutTimer); + if (killTimer) clearTimeout(killTimer); + + resolve({ + runId, + sessionId: session.id, + sessionName: session.name, + subscriptionName: subscription.name, + event, + status, + stdout, + stderr, + exitCode, + durationMs: Date.now() - startTime, + startedAt, + endedAt: new Date().toISOString(), + }); + }; + + // Capture stdout + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (data: string) => { + stdout += data; + }); + + // Capture stderr + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (data: string) => { + stderr += data; + }); + + // Handle process exit + child.on('close', (code) => { + const status: CueRunStatus = code === 0 ? 'completed' : 'failed'; + finish(status, code); + }); + + // Handle spawn errors + child.on('error', (error) => { + stderr += `\nSpawn error: ${error.message}`; + finish('failed', null); + }); + + // Write prompt to stdin if not embedded in args + // For agents with promptArgs (like OpenCode -p), the prompt is in the args + // For others (like Claude --print), if prompt was passed via args separator, skip stdin + // When SSH wrapping returns a prompt, it means "send via stdin" + if (prompt && sshRemoteConfig?.enabled) { + // SSH large prompt mode — send via stdin + child.stdin?.write(prompt); + child.stdin?.end(); + } else { + // Local mode — prompt is already in the args (via buildAgentArgs) + child.stdin?.end(); + } + + // 8. Enforce timeout + if (timeoutMs > 0) { + timeoutTimer = setTimeout(() => { + if (settled) return; + onLog('cue', `[CUE] Run ${runId} timed out after ${timeoutMs}ms, sending SIGTERM`); + child.kill('SIGTERM'); + + // Escalate to SIGKILL after delay + killTimer = setTimeout(() => { + if (settled) return; + onLog('cue', `[CUE] Run ${runId} still alive, sending SIGKILL`); + child.kill('SIGKILL'); + }, SIGKILL_DELAY_MS); + + // If the process exits after SIGTERM, mark as timeout + child.removeAllListeners('close'); + child.on('close', (code) => { + finish('timeout', code); + }); + }, timeoutMs); + } + }); +} + +/** + * Stop a running Cue process by runId. + * Sends SIGTERM, then SIGKILL after 5 seconds. + * + * @returns true if the process was found and signaled, false if not found + */ +export function stopCueRun(runId: string): boolean { + const child = activeProcesses.get(runId); + if (!child) return false; + + child.kill('SIGTERM'); + + // Escalate to SIGKILL after delay + setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, SIGKILL_DELAY_MS); + + return true; +} + +/** + * Get the map of currently active processes (for testing/monitoring). + */ +export function getActiveProcesses(): Map { + return activeProcesses; +} + +/** + * Construct a HistoryEntry for a completed Cue run. + * + * Follows the same pattern as Auto Run's history recording with type: 'AUTO', + * but uses type: 'CUE' and populates Cue-specific fields. + */ +export function recordCueHistoryEntry(result: CueRunResult, session: SessionInfo): HistoryEntry { + const fullResponse = + result.stdout.length > MAX_HISTORY_RESPONSE_LENGTH + ? result.stdout.substring(0, MAX_HISTORY_RESPONSE_LENGTH) + : result.stdout; + + return { + id: crypto.randomUUID(), + type: 'CUE', + timestamp: Date.now(), + summary: `[CUE] "${result.subscriptionName}" (${result.event.type})`, + fullResponse: fullResponse || undefined, + projectPath: session.projectRoot || session.cwd, + sessionId: session.id, + sessionName: session.name, + success: result.status === 'completed', + elapsedTimeMs: result.durationMs, + cueTriggerName: result.subscriptionName, + cueEventType: result.event.type, + cueSourceSession: result.event.payload.sourceSession + ? String(result.event.payload.sourceSession) + : undefined, + }; +} From 03ef75983305d46a72041480bb3b6682a55e655e Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 1 Mar 2026 02:36:07 -0600 Subject: [PATCH 011/194] MAESTRO: Phase 04 - IPC handlers, preload API, and CueEngine initialization for Maestro Cue --- .../main/cue/cue-ipc-handlers.test.ts | 323 ++++++++++++++++++ src/main/index.ts | 63 +++- src/main/ipc/handlers/cue.ts | 172 ++++++++++ src/main/ipc/handlers/index.ts | 3 + src/main/preload/cue.ts | 111 ++++++ src/main/preload/index.ts | 15 + src/renderer/global.d.ts | 90 +++++ 7 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/main/cue/cue-ipc-handlers.test.ts create mode 100644 src/main/ipc/handlers/cue.ts create mode 100644 src/main/preload/cue.ts diff --git a/src/__tests__/main/cue/cue-ipc-handlers.test.ts b/src/__tests__/main/cue/cue-ipc-handlers.test.ts new file mode 100644 index 000000000..1bacc7c1f --- /dev/null +++ b/src/__tests__/main/cue/cue-ipc-handlers.test.ts @@ -0,0 +1,323 @@ +/** + * Tests for Cue IPC handlers. + * + * Tests cover: + * - Handler registration with ipcMain.handle + * - Delegation to CueEngine methods (getStatus, getActiveRuns, etc.) + * - YAML read/write/validate operations + * - Engine enable/disable controls + * - Error handling when engine is not initialized + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Track registered IPC handlers +const registeredHandlers = new Map unknown>(); + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + registeredHandlers.set(channel, handler); + }), + }, +})); + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + join: vi.fn((...args: string[]) => args.join('/')), + }; +}); + +vi.mock('js-yaml', () => ({ + load: vi.fn(), +})); + +vi.mock('../../../main/utils/ipcHandler', () => ({ + withIpcErrorLogging: vi.fn( + ( + _opts: unknown, + handler: (...args: unknown[]) => unknown + ): ((_event: unknown, ...args: unknown[]) => unknown) => { + return (_event: unknown, ...args: unknown[]) => handler(...args); + } + ), +})); + +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + validateCueConfig: vi.fn(), +})); + +vi.mock('../../../main/cue/cue-types', () => ({ + CUE_YAML_FILENAME: 'maestro-cue.yaml', +})); + +import { registerCueHandlers } from '../../../main/ipc/handlers/cue'; +import { validateCueConfig } from '../../../main/cue/cue-yaml-loader'; +import * as yaml from 'js-yaml'; + +// Create a mock CueEngine +function createMockEngine() { + return { + getStatus: vi.fn().mockReturnValue([]), + getActiveRuns: vi.fn().mockReturnValue([]), + getActivityLog: vi.fn().mockReturnValue([]), + start: vi.fn(), + stop: vi.fn(), + stopRun: vi.fn().mockReturnValue(true), + stopAll: vi.fn(), + refreshSession: vi.fn(), + isEnabled: vi.fn().mockReturnValue(false), + }; +} + +describe('Cue IPC Handlers', () => { + let mockEngine: ReturnType; + + beforeEach(() => { + registeredHandlers.clear(); + vi.clearAllMocks(); + mockEngine = createMockEngine(); + }); + + afterEach(() => { + registeredHandlers.clear(); + }); + + function registerAndGetHandler(channel: string) { + registerCueHandlers({ + getCueEngine: () => mockEngine as any, + }); + const handler = registeredHandlers.get(channel); + if (!handler) { + throw new Error(`Handler for channel "${channel}" not registered`); + } + return handler; + } + + describe('handler registration', () => { + it('should register all expected IPC channels', () => { + registerCueHandlers({ + getCueEngine: () => mockEngine as any, + }); + + const expectedChannels = [ + 'cue:getStatus', + 'cue:getActiveRuns', + 'cue:getActivityLog', + 'cue:enable', + 'cue:disable', + 'cue:stopRun', + 'cue:stopAll', + 'cue:refreshSession', + 'cue:readYaml', + 'cue:writeYaml', + 'cue:validateYaml', + ]; + + for (const channel of expectedChannels) { + expect(registeredHandlers.has(channel)).toBe(true); + } + }); + }); + + describe('engine not initialized', () => { + it('should throw when engine is null', async () => { + registerCueHandlers({ + getCueEngine: () => null, + }); + + const handler = registeredHandlers.get('cue:getStatus')!; + await expect(handler(null)).rejects.toThrow('Cue engine not initialized'); + }); + }); + + describe('cue:getStatus', () => { + it('should delegate to engine.getStatus()', async () => { + const mockStatus = [ + { + sessionId: 's1', + sessionName: 'Test', + toolType: 'claude-code', + enabled: true, + subscriptionCount: 2, + activeRuns: 0, + }, + ]; + mockEngine.getStatus.mockReturnValue(mockStatus); + + const handler = registerAndGetHandler('cue:getStatus'); + const result = await handler(null); + expect(result).toEqual(mockStatus); + expect(mockEngine.getStatus).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:getActiveRuns', () => { + it('should delegate to engine.getActiveRuns()', async () => { + const mockRuns = [{ runId: 'r1', status: 'running' }]; + mockEngine.getActiveRuns.mockReturnValue(mockRuns); + + const handler = registerAndGetHandler('cue:getActiveRuns'); + const result = await handler(null); + expect(result).toEqual(mockRuns); + expect(mockEngine.getActiveRuns).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:getActivityLog', () => { + it('should delegate to engine.getActivityLog() with limit', async () => { + const mockLog = [{ runId: 'r1', status: 'completed' }]; + mockEngine.getActivityLog.mockReturnValue(mockLog); + + const handler = registerAndGetHandler('cue:getActivityLog'); + const result = await handler(null, { limit: 10 }); + expect(result).toEqual(mockLog); + expect(mockEngine.getActivityLog).toHaveBeenCalledWith(10); + }); + + it('should pass undefined limit when not provided', async () => { + const handler = registerAndGetHandler('cue:getActivityLog'); + await handler(null, {}); + expect(mockEngine.getActivityLog).toHaveBeenCalledWith(undefined); + }); + }); + + describe('cue:enable', () => { + it('should call engine.start()', async () => { + const handler = registerAndGetHandler('cue:enable'); + await handler(null); + expect(mockEngine.start).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:disable', () => { + it('should call engine.stop()', async () => { + const handler = registerAndGetHandler('cue:disable'); + await handler(null); + expect(mockEngine.stop).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:stopRun', () => { + it('should delegate to engine.stopRun() with runId', async () => { + mockEngine.stopRun.mockReturnValue(true); + const handler = registerAndGetHandler('cue:stopRun'); + const result = await handler(null, { runId: 'run-123' }); + expect(result).toBe(true); + expect(mockEngine.stopRun).toHaveBeenCalledWith('run-123'); + }); + + it('should return false when run not found', async () => { + mockEngine.stopRun.mockReturnValue(false); + const handler = registerAndGetHandler('cue:stopRun'); + const result = await handler(null, { runId: 'nonexistent' }); + expect(result).toBe(false); + }); + }); + + describe('cue:stopAll', () => { + it('should call engine.stopAll()', async () => { + const handler = registerAndGetHandler('cue:stopAll'); + await handler(null); + expect(mockEngine.stopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:refreshSession', () => { + it('should delegate to engine.refreshSession()', async () => { + const handler = registerAndGetHandler('cue:refreshSession'); + await handler(null, { sessionId: 's1', projectRoot: '/projects/test' }); + expect(mockEngine.refreshSession).toHaveBeenCalledWith('s1', '/projects/test'); + }); + }); + + describe('cue:readYaml', () => { + it('should return file content when file exists', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('subscriptions: []'); + + const handler = registerAndGetHandler('cue:readYaml'); + const result = await handler(null, { projectRoot: '/projects/test' }); + expect(result).toBe('subscriptions: []'); + expect(fs.existsSync).toHaveBeenCalledWith('/projects/test/maestro-cue.yaml'); + expect(fs.readFileSync).toHaveBeenCalledWith('/projects/test/maestro-cue.yaml', 'utf-8'); + }); + + it('should return null when file does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const handler = registerAndGetHandler('cue:readYaml'); + const result = await handler(null, { projectRoot: '/projects/test' }); + expect(result).toBeNull(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('cue:writeYaml', () => { + it('should write content to the correct file path', async () => { + const content = 'subscriptions:\n - name: test\n event: time.interval'; + + const handler = registerAndGetHandler('cue:writeYaml'); + await handler(null, { projectRoot: '/projects/test', content }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/projects/test/maestro-cue.yaml', + content, + 'utf-8' + ); + }); + }); + + describe('cue:validateYaml', () => { + it('should return valid result for valid YAML', async () => { + const content = 'subscriptions: []'; + vi.mocked(yaml.load).mockReturnValue({ subscriptions: [] }); + vi.mocked(validateCueConfig).mockReturnValue({ valid: true, errors: [] }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ valid: true, errors: [] }); + expect(yaml.load).toHaveBeenCalledWith(content); + expect(validateCueConfig).toHaveBeenCalledWith({ subscriptions: [] }); + }); + + it('should return errors for invalid config', async () => { + const content = 'subscriptions: invalid'; + vi.mocked(yaml.load).mockReturnValue({ subscriptions: 'invalid' }); + vi.mocked(validateCueConfig).mockReturnValue({ + valid: false, + errors: ['Config must have a "subscriptions" array'], + }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ + valid: false, + errors: ['Config must have a "subscriptions" array'], + }); + }); + + it('should return parse error for malformed YAML', async () => { + const content = '{{invalid yaml'; + vi.mocked(yaml.load).mockImplementation(() => { + throw new Error('bad indentation'); + }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ + valid: false, + errors: ['YAML parse error: bad indentation'], + }); + }); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index 48f38756c..0cdad70ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,7 @@ import crypto from 'crypto'; import { ProcessManager } from './process-manager'; import { WebServer } from './web-server'; import { AgentDetector } from './agents'; +import { CueEngine } from './cue/cue-engine'; import { logger } from './utils/logger'; import { tunnelManager } from './tunnel-manager'; import { powerManager } from './power-manager'; @@ -53,6 +54,7 @@ import { registerTabNamingHandlers, registerAgentErrorHandlers, registerDirectorNotesHandlers, + registerCueHandlers, registerWakatimeHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, @@ -244,6 +246,7 @@ let mainWindow: BrowserWindow | null = null; let processManager: ProcessManager | null = null; let webServer: WebServer | null = null; let agentDetector: AgentDetector | null = null; +let cueEngine: CueEngine | null = null; // Create safeSend with dependency injection (Phase 2 refactoring) const safeSend = createSafeSend(() => mainWindow); @@ -335,6 +338,46 @@ app.whenReady().then(async () => { logger.info(`Loaded custom agent paths: ${JSON.stringify(customPaths)}`, 'Startup'); } + // Initialize Cue Engine for event-driven automation + cueEngine = new CueEngine({ + getSessions: () => { + const stored = sessionsStore.get('sessions', []); + return stored.map((s: any) => ({ + id: s.id, + name: s.name, + toolType: s.toolType, + cwd: s.cwd || s.fullPath || os.homedir(), + projectRoot: s.cwd || s.fullPath || os.homedir(), + })); + }, + onCueRun: async (sessionId, _prompt, event) => { + // Stub for Phase 03 executor integration — returns a placeholder result. + // The actual executor (cue-executor.ts) is wired in a future phase. + logger.info(`[CUE] Run triggered for session ${sessionId}: ${event.triggerName}`, 'Cue'); + return { + runId: event.id, + sessionId, + sessionName: '', + subscriptionName: event.triggerName, + event, + status: 'completed' as const, + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + }, + onLog: (_level, message, data) => { + logger.cue(message, 'Cue', data); + // Push activity updates to renderer + if (mainWindow && isWebContentsAvailable(mainWindow) && data) { + mainWindow.webContents.send('cue:activityUpdate', data); + } + }, + }); + logger.info('Core services initialized', 'Startup'); // Initialize history manager (handles migration from legacy format if needed) @@ -380,6 +423,13 @@ app.whenReady().then(async () => { logger.debug('Setting up process event listeners', 'Startup'); setupProcessListeners(); + // Start Cue engine if the Encore Feature flag is enabled + const encoreFeatures = store.get('encoreFeatures', {}) as Record; + if (encoreFeatures.maestroCue && cueEngine) { + logger.info('Maestro Cue Encore Feature enabled — starting Cue engine', 'Startup'); + cueEngine.start(); + } + // Set custom application menu to prevent macOS from injecting native // "Show Previous Tab" (Cmd+Shift+{) and "Show Next Tab" (Cmd+Shift+}) // menu items into the default Window menu. Without this, those keyboard @@ -447,7 +497,13 @@ const quitHandler = createQuitHandler({ getActiveGroomingSessionCount, cleanupAllGroomingSessions, closeStatsDB, - stopCliWatcher: () => cliWatcher.stop(), + stopCliWatcher: () => { + cliWatcher.stop(); + // Stop Cue engine on app quit + if (cueEngine?.isEnabled()) { + cueEngine.stop(); + } + }, }); quitHandler.setup(); @@ -496,6 +552,11 @@ function setupIpcHandlers() { agentConfigsStore, }); + // Cue - event-driven automation engine + registerCueHandlers({ + getCueEngine: () => cueEngine, + }); + // Agent management operations - extracted to src/main/ipc/handlers/agents.ts registerAgentsHandlers({ getAgentDetector: () => agentDetector, diff --git a/src/main/ipc/handlers/cue.ts b/src/main/ipc/handlers/cue.ts new file mode 100644 index 000000000..d986708c1 --- /dev/null +++ b/src/main/ipc/handlers/cue.ts @@ -0,0 +1,172 @@ +/** + * Cue IPC Handlers + * + * Provides IPC handlers for the Maestro Cue event-driven automation system: + * - Engine runtime controls (enable/disable, stop runs) + * - Status and activity log queries + * - YAML configuration management (read, write, validate) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { ipcMain } from 'electron'; +import * as yaml from 'js-yaml'; +import { withIpcErrorLogging, type CreateHandlerOptions } from '../../utils/ipcHandler'; +import { validateCueConfig } from '../../cue/cue-yaml-loader'; +import { CUE_YAML_FILENAME } from '../../cue/cue-types'; +import type { CueEngine } from '../../cue/cue-engine'; +import type { CueRunResult, CueSessionStatus } from '../../cue/cue-types'; + +const LOG_CONTEXT = '[Cue]'; + +// Helper to create handler options with consistent context +const handlerOpts = (operation: string): Pick => ({ + context: LOG_CONTEXT, + operation, +}); + +/** + * Dependencies required for Cue handler registration + */ +export interface CueHandlerDependencies { + getCueEngine: () => CueEngine | null; +} + +/** + * Register all Cue IPC handlers. + * + * These handlers provide: + * - Engine status and activity log queries + * - Runtime engine controls (enable/disable) + * - Run management (stop individual or all) + * - YAML configuration management + */ +export function registerCueHandlers(deps: CueHandlerDependencies): void { + const { getCueEngine } = deps; + + const requireEngine = (): CueEngine => { + const engine = getCueEngine(); + if (!engine) { + throw new Error('Cue engine not initialized'); + } + return engine; + }; + + // Get status of all Cue-enabled sessions + ipcMain.handle( + 'cue:getStatus', + withIpcErrorLogging(handlerOpts('getStatus'), async (): Promise => { + return requireEngine().getStatus(); + }) + ); + + // Get currently active Cue runs + ipcMain.handle( + 'cue:getActiveRuns', + withIpcErrorLogging(handlerOpts('getActiveRuns'), async (): Promise => { + return requireEngine().getActiveRuns(); + }) + ); + + // Get activity log (recent completed/failed runs) + ipcMain.handle( + 'cue:getActivityLog', + withIpcErrorLogging( + handlerOpts('getActivityLog'), + async (options: { limit?: number }): Promise => { + return requireEngine().getActivityLog(options?.limit); + } + ) + ); + + // Enable the Cue engine (runtime control) + ipcMain.handle( + 'cue:enable', + withIpcErrorLogging(handlerOpts('enable'), async (): Promise => { + requireEngine().start(); + }) + ); + + // Disable the Cue engine (runtime control) + ipcMain.handle( + 'cue:disable', + withIpcErrorLogging(handlerOpts('disable'), async (): Promise => { + requireEngine().stop(); + }) + ); + + // Stop a specific running Cue execution + ipcMain.handle( + 'cue:stopRun', + withIpcErrorLogging( + handlerOpts('stopRun'), + async (options: { runId: string }): Promise => { + return requireEngine().stopRun(options.runId); + } + ) + ); + + // Stop all running Cue executions + ipcMain.handle( + 'cue:stopAll', + withIpcErrorLogging(handlerOpts('stopAll'), async (): Promise => { + requireEngine().stopAll(); + }) + ); + + // Refresh a session's Cue configuration + ipcMain.handle( + 'cue:refreshSession', + withIpcErrorLogging( + handlerOpts('refreshSession'), + async (options: { sessionId: string; projectRoot: string }): Promise => { + requireEngine().refreshSession(options.sessionId, options.projectRoot); + } + ) + ); + + // Read raw YAML content from a session's maestro-cue.yaml + ipcMain.handle( + 'cue:readYaml', + withIpcErrorLogging( + handlerOpts('readYaml'), + async (options: { projectRoot: string }): Promise => { + const filePath = path.join(options.projectRoot, CUE_YAML_FILENAME); + if (!fs.existsSync(filePath)) { + return null; + } + return fs.readFileSync(filePath, 'utf-8'); + } + ) + ); + + // Write YAML content to a session's maestro-cue.yaml + ipcMain.handle( + 'cue:writeYaml', + withIpcErrorLogging( + handlerOpts('writeYaml'), + async (options: { projectRoot: string; content: string }): Promise => { + const filePath = path.join(options.projectRoot, CUE_YAML_FILENAME); + fs.writeFileSync(filePath, options.content, 'utf-8'); + // The file watcher in CueEngine will automatically detect the change and refresh + } + ) + ); + + // Validate YAML content as a Cue configuration + ipcMain.handle( + 'cue:validateYaml', + withIpcErrorLogging( + handlerOpts('validateYaml'), + async (options: { content: string }): Promise<{ valid: boolean; errors: string[] }> => { + try { + const parsed = yaml.load(options.content); + return validateCueConfig(parsed); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { valid: false, errors: [`YAML parse error: ${message}`] }; + } + } + ) + ); +} diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index a54a65a3e..90256512f 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -52,6 +52,7 @@ import { registerSymphonyHandlers, SymphonyHandlerDependencies } from './symphon import { registerAgentErrorHandlers } from './agent-error'; import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes'; +import { registerCueHandlers, CueHandlerDependencies } from './cue'; import { registerWakatimeHandlers } from './wakatime'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; @@ -98,6 +99,8 @@ export { registerTabNamingHandlers }; export type { TabNamingHandlerDependencies }; export { registerDirectorNotesHandlers }; export type { DirectorNotesHandlerDependencies }; +export { registerCueHandlers }; +export type { CueHandlerDependencies }; export { registerWakatimeHandlers }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; diff --git a/src/main/preload/cue.ts b/src/main/preload/cue.ts new file mode 100644 index 000000000..c7834132a --- /dev/null +++ b/src/main/preload/cue.ts @@ -0,0 +1,111 @@ +/** + * Preload API for Cue operations + * + * Provides the window.maestro.cue namespace for: + * - Engine status and activity log queries + * - Runtime engine controls (enable/disable) + * - Run management (stop individual or all) + * - YAML configuration management (read, write, validate) + * - Real-time activity updates via event listener + */ + +import { ipcRenderer } from 'electron'; + +/** Event types that can trigger a Cue subscription */ +export type CueEventType = 'time.interval' | 'file.changed' | 'agent.completed'; + +/** Status of a Cue run */ +export type CueRunStatus = 'running' | 'completed' | 'failed' | 'timeout' | 'stopped'; + +/** An event instance produced by a trigger */ +export interface CueEvent { + id: string; + type: CueEventType; + timestamp: string; + triggerName: string; + payload: Record; +} + +/** Result of a completed (or failed/timed-out) Cue run */ +export interface CueRunResult { + runId: string; + sessionId: string; + sessionName: string; + subscriptionName: string; + event: CueEvent; + status: CueRunStatus; + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; + startedAt: string; + endedAt: string; +} + +/** Status summary for a Cue-enabled session */ +export interface CueSessionStatus { + sessionId: string; + sessionName: string; + toolType: string; + enabled: boolean; + subscriptionCount: number; + activeRuns: number; + lastTriggered?: string; + nextTrigger?: string; +} + +/** + * Creates the Cue API object for preload exposure + */ +export function createCueApi() { + return { + // Get status of all Cue-enabled sessions + getStatus: (): Promise => ipcRenderer.invoke('cue:getStatus'), + + // Get currently active Cue runs + getActiveRuns: (): Promise => ipcRenderer.invoke('cue:getActiveRuns'), + + // Get activity log (recent completed/failed runs) + getActivityLog: (limit?: number): Promise => + ipcRenderer.invoke('cue:getActivityLog', { limit }), + + // Enable the Cue engine (runtime control) + enable: (): Promise => ipcRenderer.invoke('cue:enable'), + + // Disable the Cue engine (runtime control) + disable: (): Promise => ipcRenderer.invoke('cue:disable'), + + // Stop a specific running Cue execution + stopRun: (runId: string): Promise => ipcRenderer.invoke('cue:stopRun', { runId }), + + // Stop all running Cue executions + stopAll: (): Promise => ipcRenderer.invoke('cue:stopAll'), + + // Refresh a session's Cue configuration + refreshSession: (sessionId: string, projectRoot: string): Promise => + ipcRenderer.invoke('cue:refreshSession', { sessionId, projectRoot }), + + // Read raw YAML content from a session's maestro-cue.yaml + readYaml: (projectRoot: string): Promise => + ipcRenderer.invoke('cue:readYaml', { projectRoot }), + + // Write YAML content to a session's maestro-cue.yaml + writeYaml: (projectRoot: string, content: string): Promise => + ipcRenderer.invoke('cue:writeYaml', { projectRoot, content }), + + // Validate YAML content as a Cue configuration + validateYaml: (content: string): Promise<{ valid: boolean; errors: string[] }> => + ipcRenderer.invoke('cue:validateYaml', { content }), + + // Listen for real-time activity updates from the main process + onActivityUpdate: (callback: (data: CueRunResult) => void): (() => void) => { + const handler = (_e: unknown, data: CueRunResult) => callback(data); + ipcRenderer.on('cue:activityUpdate', handler); + return () => { + ipcRenderer.removeListener('cue:activityUpdate', handler); + }; + }, + }; +} + +export type CueApi = ReturnType; diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index 6749e8a66..afacc5c18 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -49,6 +49,7 @@ import { createAgentsApi } from './agents'; import { createSymphonyApi } from './symphony'; import { createTabNamingApi } from './tabNaming'; import { createDirectorNotesApi } from './directorNotes'; +import { createCueApi } from './cue'; import { createWakatimeApi } from './wakatime'; // Expose protected methods that allow the renderer process to use @@ -189,6 +190,9 @@ contextBridge.exposeInMainWorld('maestro', { // Director's Notes API (unified history + synopsis) directorNotes: createDirectorNotesApi(), + // Cue API (event-driven automation) + cue: createCueApi(), + // WakaTime API (CLI check, API key validation) wakatime: createWakatimeApi(), }); @@ -262,6 +266,8 @@ export { createTabNamingApi, // Director's Notes createDirectorNotesApi, + // Cue + createCueApi, // WakaTime createWakatimeApi, }; @@ -469,6 +475,15 @@ export type { SynopsisResult, SynopsisStats, } from './directorNotes'; +export type { + // From cue + CueApi, + CueRunResult, + CueSessionStatus, + CueEvent, + CueEventType, + CueRunStatus, +} from './cue'; export type { // From wakatime WakatimeApi, diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index a2b38e144..faea489c4 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -2729,6 +2729,96 @@ interface MaestroAPI { ) => () => void; }; + // Cue API (event-driven automation) + cue: { + getStatus: () => Promise< + Array<{ + sessionId: string; + sessionName: string; + toolType: string; + enabled: boolean; + subscriptionCount: number; + activeRuns: number; + lastTriggered?: string; + nextTrigger?: string; + }> + >; + getActiveRuns: () => Promise< + Array<{ + runId: string; + sessionId: string; + sessionName: string; + subscriptionName: string; + event: { + id: string; + type: 'time.interval' | 'file.changed' | 'agent.completed'; + timestamp: string; + triggerName: string; + payload: Record; + }; + status: 'running' | 'completed' | 'failed' | 'timeout' | 'stopped'; + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; + startedAt: string; + endedAt: string; + }> + >; + getActivityLog: (limit?: number) => Promise< + Array<{ + runId: string; + sessionId: string; + sessionName: string; + subscriptionName: string; + event: { + id: string; + type: 'time.interval' | 'file.changed' | 'agent.completed'; + timestamp: string; + triggerName: string; + payload: Record; + }; + status: 'running' | 'completed' | 'failed' | 'timeout' | 'stopped'; + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; + startedAt: string; + endedAt: string; + }> + >; + enable: () => Promise; + disable: () => Promise; + stopRun: (runId: string) => Promise; + stopAll: () => Promise; + refreshSession: (sessionId: string, projectRoot: string) => Promise; + readYaml: (projectRoot: string) => Promise; + writeYaml: (projectRoot: string, content: string) => Promise; + validateYaml: (content: string) => Promise<{ valid: boolean; errors: string[] }>; + onActivityUpdate: ( + callback: (data: { + runId: string; + sessionId: string; + sessionName: string; + subscriptionName: string; + event: { + id: string; + type: 'time.interval' | 'file.changed' | 'agent.completed'; + timestamp: string; + triggerName: string; + payload: Record; + }; + status: 'running' | 'completed' | 'failed' | 'timeout' | 'stopped'; + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; + startedAt: string; + endedAt: string; + }) => void + ) => () => void; + }; + // WakaTime API (CLI check, API key validation) wakatime: { checkCli: () => Promise<{ available: boolean; version?: string }>; From b8126318891b3ef4ea6161ad7be336ebff16efd7 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 1 Mar 2026 02:42:38 -0600 Subject: [PATCH 012/194] MAESTRO: Phase 05 - CUE type rendering in History panel and detail modal Add CUE entry support across all History components: - HistoryFilterToggle: CUE filter button with teal (#06b6d4) color and Zap icon - HistoryEntryItem: CUE pill, success/failure badges, and trigger metadata subtitle - HistoryPanel & UnifiedHistoryTab: CUE included in default activeFilters - HistoryDetailModal: CUE pill color, icon, success/failure indicator, trigger metadata display - Comprehensive test coverage for all CUE rendering paths (205 new/updated tests pass) --- .../History/HistoryEntryItem.test.tsx | 79 ++++++++++++++++++ .../History/HistoryFilterToggle.test.tsx | 51 +++++++++++- .../components/HistoryDetailModal.test.tsx | 83 +++++++++++++++++++ .../renderer/components/HistoryPanel.test.tsx | 39 ++++++++- .../DirectorNotes/UnifiedHistoryTab.tsx | 2 +- .../components/History/HistoryEntryItem.tsx | 21 ++++- .../History/HistoryFilterToggle.tsx | 12 ++- .../components/HistoryDetailModal.tsx | 38 +++++++-- src/renderer/components/HistoryPanel.tsx | 2 +- 9 files changed, 311 insertions(+), 16 deletions(-) diff --git a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx index e10c9715f..9a4a7476f 100644 --- a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx +++ b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx @@ -97,6 +97,85 @@ describe('HistoryEntryItem', () => { expect(screen.getByText('USER')).toBeInTheDocument(); }); + it('shows CUE type pill for CUE entries', () => { + render( + + ); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('shows CUE pill with teal color', () => { + render( + + ); + const cuePill = screen.getByText('CUE').closest('span')!; + expect(cuePill).toHaveStyle({ color: '#06b6d4' }); + }); + + it('shows success indicator for successful CUE entries', () => { + render( + + ); + expect(screen.getByTitle('Task completed successfully')).toBeInTheDocument(); + }); + + it('shows failure indicator for failed CUE entries', () => { + render( + + ); + expect(screen.getByTitle('Task failed')).toBeInTheDocument(); + }); + + it('shows CUE event type metadata when present', () => { + render( + + ); + expect(screen.getByText('Triggered by: file_change')).toBeInTheDocument(); + }); + + it('does not show CUE metadata for non-CUE entries', () => { + render( + + ); + expect(screen.queryByText(/Triggered by:/)).not.toBeInTheDocument(); + }); + it('shows success indicator for successful AUTO entries', () => { render( { expect(userButton).toHaveStyle({ color: mockTheme.colors.textDim }); }); - it('renders both buttons even when no filters are active', () => { + it('renders all three buttons even when no filters are active', () => { render( ([])} @@ -145,5 +145,54 @@ describe('HistoryFilterToggle', () => { ); expect(screen.getByText('AUTO')).toBeInTheDocument(); expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('renders CUE filter button', () => { + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('calls onToggleFilter with CUE when CUE button is clicked', () => { + const onToggleFilter = vi.fn(); + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={onToggleFilter} + theme={mockTheme} + /> + ); + fireEvent.click(screen.getByText('CUE')); + expect(onToggleFilter).toHaveBeenCalledWith('CUE'); + }); + + it('styles active CUE button with teal colors', () => { + render( + (['CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + const cueButton = screen.getByText('CUE').closest('button')!; + expect(cueButton).toHaveStyle({ color: '#06b6d4' }); + }); + + it('shows CUE button as inactive when not in active filters', () => { + render( + (['AUTO', 'USER'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + const cueButton = screen.getByText('CUE').closest('button')!; + expect(cueButton.className).toContain('opacity-40'); }); }); diff --git a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx index 090436495..ebc06b205 100644 --- a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx +++ b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx @@ -207,6 +207,74 @@ describe('HistoryDetailModal', () => { ); expect(validatedIndicator).toBeInTheDocument(); }); + + it('should render CUE type with correct pill and teal color', () => { + render( + + ); + + const cuePill = screen.getByText('CUE'); + expect(cuePill).toBeInTheDocument(); + expect(cuePill.closest('span')).toHaveStyle({ color: '#06b6d4' }); + }); + + it('should show success indicator for CUE entries with success=true', () => { + render( + + ); + + const successIndicator = screen.getByTitle('Task completed successfully'); + expect(successIndicator).toBeInTheDocument(); + }); + + it('should show failure indicator for CUE entries with success=false', () => { + render( + + ); + + const failureIndicator = screen.getByTitle('Task failed'); + expect(failureIndicator).toBeInTheDocument(); + }); + + it('should display CUE trigger metadata when available', () => { + render( + + ); + + expect(screen.getByTitle('Trigger: lint-on-save')).toBeInTheDocument(); + }); + + it('should not display CUE trigger metadata for non-CUE entries', () => { + render( + + ); + + expect(screen.queryByTitle(/Trigger:/)).not.toBeInTheDocument(); + }); }); describe('Content Display', () => { @@ -810,6 +878,21 @@ describe('HistoryDetailModal', () => { expect(screen.getByText(/auto history entry/)).toBeInTheDocument(); }); + it('should show correct type in delete confirmation for CUE entry', () => { + render( + + ); + + fireEvent.click(screen.getByTitle('Delete this history entry')); + + expect(screen.getByText(/cue history entry/)).toBeInTheDocument(); + }); + it('should cancel delete when Cancel button is clicked', () => { render( { }); }); + it('should toggle CUE filter', async () => { + const autoEntry = createMockEntry({ type: 'AUTO', summary: 'Auto task' }); + const cueEntry = createMockEntry({ + id: 'cue-1', + type: 'CUE', + summary: 'Cue triggered task', + cueTriggerName: 'lint-on-save', + cueEventType: 'file_change', + }); + mockHistoryGetAll.mockResolvedValue([autoEntry, cueEntry]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Auto task')).toBeInTheDocument(); + expect(screen.getByText('Cue triggered task')).toBeInTheDocument(); + }); + + // Toggle off CUE + const cueFilter = screen.getByRole('button', { name: /CUE/i }); + fireEvent.click(cueFilter); + + await waitFor(() => { + expect(screen.getByText('Auto task')).toBeInTheDocument(); + expect(screen.queryByText('Cue triggered task')).not.toBeInTheDocument(); + }); + + // Toggle CUE back on + fireEvent.click(cueFilter); + + await waitFor(() => { + expect(screen.getByText('Cue triggered task')).toBeInTheDocument(); + }); + }); + it('should filter by search text in summary', async () => { const entry1 = createMockEntry({ summary: 'Alpha task' }); const entry2 = createMockEntry({ summary: 'Beta task' }); @@ -1666,10 +1701,12 @@ describe('HistoryPanel', () => { await waitFor(() => { const autoFilter = screen.getByRole('button', { name: /AUTO/i }); const userFilter = screen.getByRole('button', { name: /USER/i }); + const cueFilter = screen.getByRole('button', { name: /CUE/i }); - // Both should be active by default + // All should be active by default expect(autoFilter).toHaveClass('opacity-100'); expect(userFilter).toHaveClass('opacity-100'); + expect(cueFilter).toHaveClass('opacity-100'); }); }); diff --git a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx index 501c64bac..f14f8db74 100644 --- a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx +++ b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx @@ -70,7 +70,7 @@ export const UnifiedHistoryTab = forwardRef>( - new Set(['AUTO', 'USER']) + new Set(['AUTO', 'USER', 'CUE']) ); const [detailModalEntry, setDetailModalEntry] = useState(null); const [lookbackHours, setLookbackHours] = useState(() => diff --git a/src/renderer/components/History/HistoryEntryItem.tsx b/src/renderer/components/History/HistoryEntryItem.tsx index 2de4932d2..4f4cb43e0 100644 --- a/src/renderer/components/History/HistoryEntryItem.tsx +++ b/src/renderer/components/History/HistoryEntryItem.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { Bot, User, ExternalLink, Check, X, Clock, Award } from 'lucide-react'; +import { Bot, User, Zap, ExternalLink, Check, X, Clock, Award } from 'lucide-react'; import type { Theme, HistoryEntry, HistoryEntryType } from '../../types'; import { formatElapsedTime } from '../../utils/formatters'; import { stripMarkdown } from '../../utils/textProcessing'; @@ -20,6 +20,12 @@ const getPillColor = (type: HistoryEntryType, theme: Theme) => { text: theme.colors.accent, border: theme.colors.accent + '40', }; + case 'CUE': + return { + bg: '#06b6d420', + text: '#06b6d4', + border: '#06b6d440', + }; default: return { bg: theme.colors.bgActivity, @@ -36,6 +42,8 @@ const getEntryIcon = (type: HistoryEntryType) => { return Bot; case 'USER': return User; + case 'CUE': + return Zap; default: return Bot; } @@ -134,8 +142,8 @@ export const HistoryEntryItem = memo(function HistoryEntryItem({ )} - {/* Success/Failure Indicator for AUTO entries */} - {entry.type === 'AUTO' && entry.success !== undefined && ( + {/* Success/Failure Indicator for AUTO and CUE entries */} + {(entry.type === 'AUTO' || entry.type === 'CUE') && entry.success !== undefined && ( + {/* CUE metadata subtitle */} + {entry.type === 'CUE' && entry.cueEventType && ( +

+ Triggered by: {entry.cueEventType} +

+ )} + {/* Footer Row - Time, Cost, and Achievement Action */} {(entry.elapsedTimeMs !== undefined || (entry.usageStats && entry.usageStats.totalCostUsd > 0) || diff --git a/src/renderer/components/History/HistoryFilterToggle.tsx b/src/renderer/components/History/HistoryFilterToggle.tsx index 31ea1ab35..21d48d2fa 100644 --- a/src/renderer/components/History/HistoryFilterToggle.tsx +++ b/src/renderer/components/History/HistoryFilterToggle.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { Bot, User } from 'lucide-react'; +import { Bot, User, Zap } from 'lucide-react'; import type { Theme, HistoryEntryType } from '../../types'; export interface HistoryFilterToggleProps { @@ -23,6 +23,12 @@ const getPillColor = (type: HistoryEntryType, theme: Theme) => { text: theme.colors.accent, border: theme.colors.accent + '40', }; + case 'CUE': + return { + bg: '#06b6d420', + text: '#06b6d4', + border: '#06b6d440', + }; default: return { bg: theme.colors.bgActivity, @@ -39,6 +45,8 @@ const getEntryIcon = (type: HistoryEntryType) => { return Bot; case 'USER': return User; + case 'CUE': + return Zap; default: return Bot; } @@ -51,7 +59,7 @@ export const HistoryFilterToggle = memo(function HistoryFilterToggle({ }: HistoryFilterToggleProps) { return (
- {(['AUTO', 'USER'] as HistoryEntryType[]).map((type) => { + {(['AUTO', 'USER', 'CUE'] as HistoryEntryType[]).map((type) => { const isActive = activeFilters.has(type); const colors = getPillColor(type, theme); const Icon = getEntryIcon(type); diff --git a/src/renderer/components/HistoryDetailModal.tsx b/src/renderer/components/HistoryDetailModal.tsx index b31ea5aeb..3585e7d92 100644 --- a/src/renderer/components/HistoryDetailModal.tsx +++ b/src/renderer/components/HistoryDetailModal.tsx @@ -174,6 +174,13 @@ export function HistoryDetailModal({ border: theme.colors.warning + '40', }; } + if (entry.type === 'CUE') { + return { + bg: '#06b6d420', + text: '#06b6d4', + border: '#06b6d440', + }; + } return { bg: theme.colors.accent + '20', text: theme.colors.accent, @@ -182,7 +189,7 @@ export function HistoryDetailModal({ }; const colors = getPillColor(); - const Icon = entry.type === 'AUTO' ? Bot : User; + const Icon = entry.type === 'AUTO' ? Bot : entry.type === 'CUE' ? Zap : User; // Access agentName from unified history entries (Director's Notes) const agentName = (entry as HistoryEntry & { agentName?: string }).agentName; @@ -246,8 +253,8 @@ export function HistoryDetailModal({ )}
- {/* Success/Failure Indicator for AUTO entries */} - {entry.type === 'AUTO' && entry.success !== undefined && ( + {/* Success/Failure Indicator for AUTO and CUE entries */} + {(entry.type === 'AUTO' || entry.type === 'CUE') && entry.success !== undefined && ( - {/* Validated toggle for AUTO entries */} - {entry.type === 'AUTO' && entry.success && onUpdate && ( + {/* CUE metadata */} + {entry.type === 'CUE' && entry.cueTriggerName && ( + + {entry.cueTriggerName} + {entry.cueEventType && ` \u2022 ${entry.cueEventType}`} + + )} + + {/* Validated toggle for AUTO and CUE entries */} + {(entry.type === 'AUTO' || entry.type === 'CUE') && entry.success && onUpdate && (

- Are you sure you want to delete this {entry.type === 'AUTO' ? 'auto' : 'user'}{' '} - history entry? This action cannot be undone. + Are you sure you want to delete this{' '} + {entry.type === 'AUTO' ? 'auto' : entry.type === 'CUE' ? 'cue' : 'user'} history + entry? This action cannot be undone.

diff --git a/src/renderer/components/HistoryPanel.tsx b/src/renderer/components/HistoryPanel.tsx index b30be7f76..f4dd6897f 100644 --- a/src/renderer/components/HistoryPanel.tsx +++ b/src/renderer/components/HistoryPanel.tsx @@ -59,7 +59,7 @@ export const HistoryPanel = React.memo( ) { const [historyEntries, setHistoryEntries] = useState([]); const [activeFilters, setActiveFilters] = useState>( - new Set(['AUTO', 'USER']) + new Set(['AUTO', 'USER', 'CUE']) ); const [isLoading, setIsLoading] = useState(true); const [detailModalEntry, setDetailModalEntry] = useState(null); From d79064dbd27dbbb21185743dfc0b5d47899af054 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 1 Mar 2026 02:46:45 -0600 Subject: [PATCH 013/194] MAESTRO: Phase 05 - CUE log level test coverage for LogViewer component --- .../renderer/components/LogViewer.test.tsx | 122 +++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/src/__tests__/renderer/components/LogViewer.test.tsx b/src/__tests__/renderer/components/LogViewer.test.tsx index 85f2ef46a..d150179ca 100644 --- a/src/__tests__/renderer/components/LogViewer.test.tsx +++ b/src/__tests__/renderer/components/LogViewer.test.tsx @@ -2,7 +2,7 @@ * LogViewer.tsx Test Suite * * Tests for the LogViewer component which displays Maestro system logs with: - * - Log level filtering (debug, info, warn, error, toast) + * - Log level filtering (debug, info, warn, error, toast, autorun, cue) * - Search functionality * - Expand/collapse log details * - Export and clear logs @@ -43,7 +43,7 @@ const mockTheme: Theme = { const createMockLog = ( overrides: Partial<{ timestamp: number; - level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'; + level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'; message: string; context?: string; data?: unknown; @@ -228,6 +228,8 @@ describe('LogViewer', () => { expect(screen.getByRole('button', { name: 'WARN' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'ERROR' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'TOAST' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'AUTORUN' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'CUE' })).toBeInTheDocument(); }); }); @@ -316,6 +318,45 @@ describe('LogViewer', () => { }); }); + it('should always enable cue level regardless of logLevel', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'CUE' })).not.toBeDisabled(); + }); + }); + + it('should filter cue logs by level when CUE toggle clicked', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ level: 'cue', message: 'Cue event fired' }), + createMockLog({ level: 'info', message: 'Info message' }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Cue event fired')).toBeInTheDocument(); + expect(screen.getByText('Info message')).toBeInTheDocument(); + }); + + // Click CUE to disable it + const cueButton = screen.getByRole('button', { name: 'CUE' }); + fireEvent.click(cueButton); + + await waitFor(() => { + expect(screen.queryByText('Cue event fired')).not.toBeInTheDocument(); + // Info should still be visible + expect(screen.getByText('Info message')).toBeInTheDocument(); + }); + + // Click CUE to re-enable it + fireEvent.click(cueButton); + + await waitFor(() => { + expect(screen.getByText('Cue event fired')).toBeInTheDocument(); + }); + }); + it('should persist level selections via callback', async () => { const onSelectedLevelsChange = vi.fn(); @@ -1064,6 +1105,83 @@ describe('LogViewer', () => { }); }); + it('should display agent pill for cue entries with context', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: '[CUE] "On PR Opened" triggered (pull_request.opened)', + context: 'My Cue Agent', + }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('My Cue Agent')).toBeInTheDocument(); + }); + }); + + it('should render cue agent pill with teal color', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: '[CUE] "Deploy Check" triggered (push)', + context: 'Cue Session', + }), + ]); + + render(); + + await waitFor(() => { + const agentPill = screen.getByText('Cue Session'); + expect(agentPill).toBeInTheDocument(); + expect(agentPill.closest('span')).toHaveStyle({ + backgroundColor: 'rgba(6, 182, 212, 0.2)', + color: '#06b6d4', + }); + }); + }); + + it('should not show context badge for cue entries (uses agent pill instead)', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: 'Cue triggered', + context: 'CueContext', + }), + ]); + + render(); + + await waitFor(() => { + // The context should appear as an agent pill, not as a context badge + const contextElement = screen.getByText('CueContext'); + expect(contextElement).toBeInTheDocument(); + // Verify it's styled as an agent pill (teal), not a context badge (accent color) + expect(contextElement.closest('span')).toHaveStyle({ color: '#06b6d4' }); + }); + }); + + it('should render cue level pill with teal color', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: 'Cue level test', + }), + ]); + + render(); + + await waitFor(() => { + const levelPill = screen.getByText('cue'); + expect(levelPill).toBeInTheDocument(); + expect(levelPill).toHaveStyle({ + color: '#06b6d4', + backgroundColor: 'rgba(6, 182, 212, 0.15)', + }); + }); + }); + it('should not show context badge for toast entries', async () => { getMockGetLogs().mockResolvedValue([ createMockLog({ From 967aceccb00aefc2d0de5732d44d6d3e86730be0 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 1 Mar 2026 03:12:11 -0600 Subject: [PATCH 014/194] MAESTRO: Phase 06 - Cue Modal dashboard with sessions, active runs, and activity log Add the Maestro Cue dashboard modal with full Encore Feature gating: - CueModal component with sessions table, active runs list, and activity log - useCue hook for state management, event subscriptions, and 10s polling - Settings toggle in Encore tab, command palette entry, keyboard shortcut (Cmd+Shift+U) - SessionList hamburger menu entry, modal store integration, lazy loading - 30 tests covering hook behavior and modal rendering --- .../renderer/components/CueModal.test.tsx | 332 ++++++++++++++ src/__tests__/renderer/hooks/useCue.test.ts | 246 +++++++++++ src/renderer/App.tsx | 13 + src/renderer/components/AppModals.tsx | 11 + src/renderer/components/CueModal.tsx | 417 ++++++++++++++++++ src/renderer/components/QuickActionsModal.tsx | 18 + .../SessionList/HamburgerMenuContent.tsx | 29 ++ .../components/Settings/tabs/EncoreTab.tsx | 66 ++- src/renderer/constants/modalPriorities.ts | 8 +- src/renderer/constants/shortcuts.ts | 5 + .../hooks/keyboard/useMainKeyboardHandler.ts | 4 + src/renderer/hooks/useCue.ts | 146 ++++++ src/renderer/stores/modalStore.ts | 11 +- 13 files changed, 1303 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/renderer/components/CueModal.test.tsx create mode 100644 src/__tests__/renderer/hooks/useCue.test.ts create mode 100644 src/renderer/components/CueModal.tsx create mode 100644 src/renderer/hooks/useCue.ts diff --git a/src/__tests__/renderer/components/CueModal.test.tsx b/src/__tests__/renderer/components/CueModal.test.tsx new file mode 100644 index 000000000..60a8090fc --- /dev/null +++ b/src/__tests__/renderer/components/CueModal.test.tsx @@ -0,0 +1,332 @@ +/** + * Tests for CueModal component + * + * Tests the Cue Modal dashboard including: + * - Sessions table rendering (empty state and populated) + * - Active runs section with stop controls + * - Activity log rendering with success/failure indicators + * - Master enable/disable toggle + * - Close button and backdrop click + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CueModal } from '../../../renderer/components/CueModal'; +import type { Theme } from '../../../renderer/types'; + +// Mock LayerStackContext +const mockRegisterLayer = vi.fn(() => 'layer-cue-modal'); +const mockUnregisterLayer = vi.fn(); + +vi.mock('../../../renderer/contexts/LayerStackContext', () => ({ + useLayerStack: () => ({ + registerLayer: mockRegisterLayer, + unregisterLayer: mockUnregisterLayer, + }), +})); + +// Mock modal priorities +vi.mock('../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { + CUE_MODAL: 460, + }, +})); + +// Mock useCue hook +const mockEnable = vi.fn().mockResolvedValue(undefined); +const mockDisable = vi.fn().mockResolvedValue(undefined); +const mockStopRun = vi.fn().mockResolvedValue(undefined); +const mockStopAll = vi.fn().mockResolvedValue(undefined); +const mockRefresh = vi.fn().mockResolvedValue(undefined); + +const defaultUseCueReturn = { + sessions: [], + activeRuns: [], + activityLog: [], + loading: false, + enable: mockEnable, + disable: mockDisable, + stopRun: mockStopRun, + stopAll: mockStopAll, + refresh: mockRefresh, +}; + +let mockUseCueReturn = { ...defaultUseCueReturn }; + +vi.mock('../../../renderer/hooks/useCue', () => ({ + useCue: () => mockUseCueReturn, +})); + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + scrollbar: '#44475a', + scrollbarHover: '#6272a4', + }, +}; + +const mockSession = { + sessionId: 'sess-1', + sessionName: 'Test Session', + toolType: 'claude-code', + enabled: true, + subscriptionCount: 3, + activeRuns: 1, + lastTriggered: new Date().toISOString(), +}; + +const mockActiveRun = { + runId: 'run-1', + sessionId: 'sess-1', + sessionName: 'Test Session', + subscriptionName: 'on-save', + event: { + id: 'evt-1', + type: 'file.changed' as const, + timestamp: new Date().toISOString(), + triggerName: 'on-save', + payload: { file: '/src/index.ts' }, + }, + status: 'running' as const, + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: '', +}; + +const mockCompletedRun = { + ...mockActiveRun, + runId: 'run-2', + status: 'completed' as const, + stdout: 'Done', + exitCode: 0, + durationMs: 5000, + endedAt: new Date().toISOString(), +}; + +const mockFailedRun = { + ...mockActiveRun, + runId: 'run-3', + status: 'failed' as const, + stderr: 'Error occurred', + exitCode: 1, + durationMs: 2000, + endedAt: new Date().toISOString(), +}; + +describe('CueModal', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseCueReturn = { ...defaultUseCueReturn }; + }); + + describe('rendering', () => { + it('should render the modal with header', () => { + render(); + + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + }); + + it('should register layer on mount and unregister on unmount', () => { + const { unmount } = render(); + + expect(mockRegisterLayer).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'modal', + priority: 460, + }) + ); + + unmount(); + expect(mockUnregisterLayer).toHaveBeenCalledWith('layer-cue-modal'); + }); + + it('should show loading state', () => { + mockUseCueReturn = { ...defaultUseCueReturn, loading: true }; + + render(); + + expect(screen.getByText('Loading Cue status...')).toBeInTheDocument(); + }); + }); + + describe('sessions table', () => { + it('should show empty state when no sessions have Cue configs', () => { + render(); + + expect(screen.getByText(/No sessions have a maestro-cue.yaml file/)).toBeInTheDocument(); + }); + + it('should render sessions with status indicators', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + expect(screen.getByText('Test Session')).toBeInTheDocument(); + expect(screen.getByText('claude-code')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should show Paused status for disabled sessions', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [{ ...mockSession, enabled: false }], + }; + + render(); + + expect(screen.getByText('Paused')).toBeInTheDocument(); + }); + }); + + describe('active runs', () => { + it('should show "No active runs" when empty', () => { + render(); + + expect(screen.getByText('No active runs')).toBeInTheDocument(); + }); + + it('should render active runs with stop buttons', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun], + }; + + render(); + + expect(screen.getByText('"on-save"')).toBeInTheDocument(); + expect(screen.getByTitle('Stop run')).toBeInTheDocument(); + }); + + it('should call stopRun when stop button is clicked', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun], + }; + + render(); + + fireEvent.click(screen.getByTitle('Stop run')); + expect(mockStopRun).toHaveBeenCalledWith('run-1'); + }); + + it('should show Stop All button when multiple runs active', () => { + const secondRun = { ...mockActiveRun, runId: 'run-2', subscriptionName: 'on-timer' }; + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun, secondRun], + }; + + render(); + + const stopAllButton = screen.getByText('Stop All'); + expect(stopAllButton).toBeInTheDocument(); + + fireEvent.click(stopAllButton); + expect(mockStopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('activity log', () => { + it('should show "No activity yet" when empty', () => { + render(); + + expect(screen.getByText('No activity yet')).toBeInTheDocument(); + }); + + it('should render completed runs with checkmark', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activityLog: [mockCompletedRun], + }; + + render(); + + expect(screen.getByText(/completed in 5s/)).toBeInTheDocument(); + }); + + it('should render failed runs with cross mark', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activityLog: [mockFailedRun], + }; + + render(); + + expect(screen.getByText(/failed/)).toBeInTheDocument(); + }); + }); + + describe('master toggle', () => { + it('should show Disabled when no sessions are enabled', () => { + render(); + + expect(screen.getByText('Disabled')).toBeInTheDocument(); + }); + + it('should show Enabled when sessions are enabled', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + expect(screen.getByText('Enabled')).toBeInTheDocument(); + }); + + it('should call disable when toggling off', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + fireEvent.click(screen.getByText('Enabled')); + expect(mockDisable).toHaveBeenCalledOnce(); + }); + + it('should call enable when toggling on', () => { + render(); + + fireEvent.click(screen.getByText('Disabled')); + expect(mockEnable).toHaveBeenCalledOnce(); + }); + }); + + describe('close behavior', () => { + it('should call onClose when close button is clicked', () => { + render(); + + // The close button has an X icon + const buttons = screen.getAllByRole('button'); + const closeButton = buttons.find((b) => b.querySelector('.lucide-x')); + if (closeButton) { + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalledOnce(); + } + }); + }); +}); diff --git a/src/__tests__/renderer/hooks/useCue.test.ts b/src/__tests__/renderer/hooks/useCue.test.ts new file mode 100644 index 000000000..185b81165 --- /dev/null +++ b/src/__tests__/renderer/hooks/useCue.test.ts @@ -0,0 +1,246 @@ +/** + * Tests for useCue hook + * + * This hook manages Cue state for the renderer, including session status, + * active runs, and activity log. Tests verify data fetching, actions, + * event subscriptions, and cleanup. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useCue } from '../../../renderer/hooks/useCue'; + +// Mock Cue API +const mockGetStatus = vi.fn(); +const mockGetActiveRuns = vi.fn(); +const mockGetActivityLog = vi.fn(); +const mockEnable = vi.fn(); +const mockDisable = vi.fn(); +const mockStopRun = vi.fn(); +const mockStopAll = vi.fn(); +const mockOnActivityUpdate = vi.fn(); + +const mockUnsubscribe = vi.fn(); + +// Mock setInterval/clearInterval to prevent polling during tests +const originalSetInterval = globalThis.setInterval; +const originalClearInterval = globalThis.clearInterval; + +beforeEach(() => { + vi.clearAllMocks(); + + globalThis.setInterval = vi.fn( + () => 999 as unknown as ReturnType + ) as unknown as typeof setInterval; + globalThis.clearInterval = vi.fn() as unknown as typeof clearInterval; + + mockGetStatus.mockResolvedValue([]); + mockGetActiveRuns.mockResolvedValue([]); + mockGetActivityLog.mockResolvedValue([]); + mockEnable.mockResolvedValue(undefined); + mockDisable.mockResolvedValue(undefined); + mockStopRun.mockResolvedValue(true); + mockStopAll.mockResolvedValue(undefined); + mockOnActivityUpdate.mockReturnValue(mockUnsubscribe); + + (window as any).maestro = { + ...(window as any).maestro, + cue: { + getStatus: mockGetStatus, + getActiveRuns: mockGetActiveRuns, + getActivityLog: mockGetActivityLog, + enable: mockEnable, + disable: mockDisable, + stopRun: mockStopRun, + stopAll: mockStopAll, + onActivityUpdate: mockOnActivityUpdate, + }, + }; +}); + +afterEach(() => { + globalThis.setInterval = originalSetInterval; + globalThis.clearInterval = originalClearInterval; + vi.restoreAllMocks(); +}); + +const mockSession = { + sessionId: 'sess-1', + sessionName: 'Test Session', + toolType: 'claude-code', + enabled: true, + subscriptionCount: 3, + activeRuns: 1, + lastTriggered: '2026-03-01T00:00:00Z', +}; + +const mockRun = { + runId: 'run-1', + sessionId: 'sess-1', + sessionName: 'Test Session', + subscriptionName: 'on-save', + event: { + id: 'evt-1', + type: 'file.changed' as const, + timestamp: '2026-03-01T00:00:00Z', + triggerName: 'on-save', + payload: { file: '/src/index.ts' }, + }, + status: 'completed' as const, + stdout: 'Done', + stderr: '', + exitCode: 0, + durationMs: 5000, + startedAt: '2026-03-01T00:00:00Z', + endedAt: '2026-03-01T00:00:05Z', +}; + +// Helper: render hook and flush all pending microtasks so state settles +async function renderAndSettle() { + let hookResult: ReturnType, unknown>>; + await act(async () => { + hookResult = renderHook(() => useCue()); + // Allow microtasks (Promise.all resolution) to complete + await Promise.resolve(); + }); + return hookResult!; +} + +describe('useCue', () => { + describe('initial fetch', () => { + it('should fetch status, active runs, and activity log on mount', async () => { + mockGetStatus.mockResolvedValue([mockSession]); + mockGetActiveRuns.mockResolvedValue([]); + mockGetActivityLog.mockResolvedValue([mockRun]); + + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + expect(result.current.sessions).toEqual([mockSession]); + expect(result.current.activeRuns).toEqual([]); + expect(result.current.activityLog).toEqual([mockRun]); + expect(mockGetActivityLog).toHaveBeenCalledWith(100); + }); + + it('should set loading to false even if fetch fails', async () => { + mockGetStatus.mockRejectedValue(new Error('Network error')); + + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + }); + }); + + describe('actions', () => { + it('should call enable and refresh', async () => { + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + + await act(async () => { + await result.current.enable(); + }); + + expect(mockEnable).toHaveBeenCalledOnce(); + expect(mockGetStatus.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('should call disable and refresh', async () => { + const { result } = await renderAndSettle(); + + await act(async () => { + await result.current.disable(); + }); + + expect(mockDisable).toHaveBeenCalledOnce(); + }); + + it('should call stopRun with runId and refresh', async () => { + const { result } = await renderAndSettle(); + + await act(async () => { + await result.current.stopRun('run-1'); + }); + + expect(mockStopRun).toHaveBeenCalledWith('run-1'); + }); + + it('should call stopAll and refresh', async () => { + const { result } = await renderAndSettle(); + + await act(async () => { + await result.current.stopAll(); + }); + + expect(mockStopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('event subscription', () => { + it('should subscribe to activity updates on mount', async () => { + await renderAndSettle(); + + expect(mockOnActivityUpdate).toHaveBeenCalledOnce(); + }); + + it('should unsubscribe on unmount', async () => { + const { unmount } = await renderAndSettle(); + + expect(mockOnActivityUpdate).toHaveBeenCalledOnce(); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledOnce(); + }); + + it('should refresh when activity update is received', async () => { + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + + const activityCallback = mockOnActivityUpdate.mock.calls[0][0]; + mockGetStatus.mockClear(); + + await act(async () => { + activityCallback(mockRun); + await Promise.resolve(); + }); + + expect(mockGetStatus).toHaveBeenCalled(); + }); + }); + + describe('polling setup', () => { + it('should set up interval on mount', async () => { + await renderAndSettle(); + + expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), 10_000); + }); + + it('should clear interval on unmount', async () => { + const { unmount } = await renderAndSettle(); + + expect(globalThis.setInterval).toHaveBeenCalled(); + + unmount(); + + expect(globalThis.clearInterval).toHaveBeenCalled(); + }); + }); + + describe('return value shape', () => { + it('should return all expected properties', async () => { + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + expect(Array.isArray(result.current.sessions)).toBe(true); + expect(Array.isArray(result.current.activeRuns)).toBe(true); + expect(Array.isArray(result.current.activityLog)).toBe(true); + expect(typeof result.current.enable).toBe('function'); + expect(typeof result.current.disable).toBe('function'); + expect(typeof result.current.stopRun).toBe('function'); + expect(typeof result.current.stopAll).toBe('function'); + expect(typeof result.current.refresh).toBe('function'); + }); + }); +}); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index e31780731..df44f6da7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -41,6 +41,7 @@ const DocumentGraphView = lazy(() => const DirectorNotesModal = lazy(() => import('./components/DirectorNotes').then((m) => ({ default: m.DirectorNotesModal })) ); +const CueModal = lazy(() => import('./components/CueModal').then((m) => ({ default: m.CueModal }))); // SymphonyContributionData type moved to useSymphonyContribution hook @@ -326,6 +327,9 @@ function MaestroConsoleInner() { // Director's Notes Modal directorNotesOpen, setDirectorNotesOpen, + // Maestro Cue Modal + cueModalOpen, + setCueModalOpen, } = useModalActions(); // --- MOBILE LANDSCAPE MODE (reading-only view) --- @@ -2004,6 +2008,7 @@ function MaestroConsoleInner() { setMarketplaceModalOpen, setSymphonyModalOpen, setDirectorNotesOpen, + setCueModalOpen, encoreFeatures, setShowNewGroupChatModal, deleteGroupChatWithConfirmation, @@ -2675,6 +2680,7 @@ function MaestroConsoleInner() { onOpenDirectorNotes={ encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined } + onOpenMaestroCue={encoreFeatures.maestroCue ? () => setCueModalOpen(true) : undefined} autoScrollAiMode={autoScrollAiMode} setAutoScrollAiMode={setAutoScrollAiMode} onCloseTabSwitcher={handleCloseTabSwitcher} @@ -2866,6 +2872,13 @@ function MaestroConsoleInner() { )} + {/* --- MAESTRO CUE MODAL (lazy-loaded, Encore Feature) --- */} + {encoreFeatures.maestroCue && cueModalOpen && ( + + setCueModalOpen(false)} /> + + )} + {/* --- GIST PUBLISH MODAL --- */} {/* Supports both file preview tabs and tab context gist publishing */} {gistPublishModalOpen && (activeFileTab || tabGistContent) && ( diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 8d140ddd6..d68336115 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -862,6 +862,9 @@ export interface AppUtilityModalsProps { // Director's Notes onOpenDirectorNotes?: () => void; + // Maestro Cue + onOpenMaestroCue?: () => void; + // Auto-scroll autoScrollAiMode?: boolean; setAutoScrollAiMode?: (value: boolean) => void; @@ -1063,6 +1066,8 @@ export const AppUtilityModals = memo(function AppUtilityModals({ onOpenSymphony, // Director's Notes onOpenDirectorNotes, + // Maestro Cue + onOpenMaestroCue, // Auto-scroll autoScrollAiMode, setAutoScrollAiMode, @@ -1221,6 +1226,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({ onOpenLastDocumentGraph={onOpenLastDocumentGraph} onOpenSymphony={onOpenSymphony} onOpenDirectorNotes={onOpenDirectorNotes} + onOpenMaestroCue={onOpenMaestroCue} autoScrollAiMode={autoScrollAiMode} setAutoScrollAiMode={setAutoScrollAiMode} /> @@ -1997,6 +2003,8 @@ export interface AppModalsProps { onOpenSymphony?: () => void; // Director's Notes onOpenDirectorNotes?: () => void; + // Maestro Cue + onOpenMaestroCue?: () => void; // Auto-scroll autoScrollAiMode?: boolean; setAutoScrollAiMode?: (value: boolean) => void; @@ -2364,6 +2372,8 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { onOpenSymphony, // Director's Notes onOpenDirectorNotes, + // Maestro Cue + onOpenMaestroCue, // Auto-scroll autoScrollAiMode, setAutoScrollAiMode, @@ -2670,6 +2680,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { onOpenMarketplace={onOpenMarketplace} onOpenSymphony={onOpenSymphony} onOpenDirectorNotes={onOpenDirectorNotes} + onOpenMaestroCue={onOpenMaestroCue} autoScrollAiMode={autoScrollAiMode} setAutoScrollAiMode={setAutoScrollAiMode} tabSwitcherOpen={tabSwitcherOpen} diff --git a/src/renderer/components/CueModal.tsx b/src/renderer/components/CueModal.tsx new file mode 100644 index 000000000..628c08093 --- /dev/null +++ b/src/renderer/components/CueModal.tsx @@ -0,0 +1,417 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { X, Zap, Square, HelpCircle, StopCircle } from 'lucide-react'; +import type { Theme } from '../types'; +import { useLayerStack } from '../contexts/LayerStackContext'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { useCue } from '../hooks/useCue'; +import type { CueSessionStatus, CueRunResult } from '../hooks/useCue'; + +interface CueModalProps { + theme: Theme; + onClose: () => void; +} + +const CUE_TEAL = '#06b6d4'; + +function formatRelativeTime(dateStr?: string): string { + if (!dateStr) return '—'; + const diff = Date.now() - new Date(dateStr).getTime(); + if (diff < 0) return 'just now'; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainSeconds = seconds % 60; + return `${minutes}m ${remainSeconds}s`; +} + +function formatElapsed(startedAt: string): string { + const diff = Date.now() - new Date(startedAt).getTime(); + return formatDuration(Math.max(0, diff)); +} + +function StatusDot({ status }: { status: 'active' | 'paused' | 'none' }) { + const color = status === 'active' ? '#22c55e' : status === 'paused' ? '#eab308' : '#6b7280'; + return ; +} + +function SessionsTable({ sessions, theme }: { sessions: CueSessionStatus[]; theme: Theme }) { + if (sessions.length === 0) { + return ( +
+ No sessions have a maestro-cue.yaml file. Create one in your project root to get started. +
+ ); + } + + return ( + + + + + + + + + + + + {sessions.map((s) => { + const status = !s.enabled ? 'paused' : s.subscriptionCount > 0 ? 'active' : 'none'; + return ( + + + + + + + + ); + })} + +
SessionAgentStatusLast TriggeredSubs
+ {s.sessionName} + + {s.toolType} + + + + + {status === 'active' ? 'Active' : status === 'paused' ? 'Paused' : 'No Config'} + + + + {formatRelativeTime(s.lastTriggered)} + + {s.subscriptionCount} +
+ ); +} + +function ActiveRunsList({ + runs, + theme, + onStopRun, + onStopAll, +}: { + runs: CueRunResult[]; + theme: Theme; + onStopRun: (runId: string) => void; + onStopAll: () => void; +}) { + if (runs.length === 0) { + return ( +
+ No active runs +
+ ); + } + + return ( +
+ {runs.length > 1 && ( +
+ +
+ )} + {runs.map((run) => ( +
+ +
+ {run.sessionName} + + — + + "{run.subscriptionName}" +
+ + {formatElapsed(run.startedAt)} + +
+ ))} +
+ ); +} + +function ActivityLog({ log, theme }: { log: CueRunResult[]; theme: Theme }) { + const [visibleCount, setVisibleCount] = useState(100); + + if (log.length === 0) { + return ( +
+ No activity yet +
+ ); + } + + const visible = log.slice(0, visibleCount); + + return ( +
+ {visible.map((entry) => { + const isFailed = entry.status === 'failed' || entry.status === 'timeout'; + const eventType = entry.event.type; + const filePayload = + eventType === 'file.changed' && entry.event.payload?.file + ? ` (${String(entry.event.payload.file).split('/').pop()})` + : ''; + + return ( +
+ + {new Date(entry.startedAt).toLocaleTimeString()} + + + + "{entry.subscriptionName}" + + {' '} + triggered ({eventType}){filePayload} →{' '} + + {isFailed ? ( + {entry.status} ✗ + ) : ( + + completed in {formatDuration(entry.durationMs)} ✓ + + )} + +
+ ); + })} + {log.length > visibleCount && ( + + )} +
+ ); +} + +export function CueModal({ theme, onClose }: CueModalProps) { + const { registerLayer, unregisterLayer } = useLayerStack(); + const layerIdRef = useRef(); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const { sessions, activeRuns, activityLog, loading, enable, disable, stopRun, stopAll } = + useCue(); + + const isEnabled = sessions.some((s) => s.enabled); + + const handleToggle = useCallback(() => { + if (isEnabled) { + disable(); + } else { + enable(); + } + }, [isEnabled, enable, disable]); + + // Register layer on mount + useEffect(() => { + const id = registerLayer({ + type: 'modal', + priority: MODAL_PRIORITIES.CUE_MODAL, + blocksLowerLayers: true, + capturesFocus: true, + focusTrap: 'strict', + onEscape: () => { + onCloseRef.current(); + }, + }); + layerIdRef.current = id; + + return () => { + if (layerIdRef.current) { + unregisterLayer(layerIdRef.current); + } + }; + }, [registerLayer, unregisterLayer]); + + // Active runs section is collapsible when empty + const [activeRunsExpanded, setActiveRunsExpanded] = useState(true); + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > + {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ +

+ Maestro Cue +

+
+
+ {/* Master toggle */} + + + {/* Help button */} + + + {/* Close button */} + +
+
+ + {/* Body */} +
+ {loading ? ( +
+ Loading Cue status... +
+ ) : ( + <> + {/* Section 1: Sessions with Cue */} +
+

+ Sessions with Cue +

+ +
+ + {/* Section 2: Active Runs */} +
+ + {activeRunsExpanded && ( + + )} +
+ + {/* Section 3: Activity Log */} +
+

+ Activity Log +

+
+ +
+
+ + )} +
+
+
, + document.body + ); +} diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index c0c9983f7..823aba39d 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -118,6 +118,8 @@ interface QuickActionsModalProps { onOpenSymphony?: () => void; // Director's Notes onOpenDirectorNotes?: () => void; + // Maestro Cue + onOpenMaestroCue?: () => void; // Auto-scroll autoScrollAiMode?: boolean; setAutoScrollAiMode?: (value: boolean) => void; @@ -205,6 +207,7 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct onOpenLastDocumentGraph, onOpenSymphony, onOpenDirectorNotes, + onOpenMaestroCue, autoScrollAiMode, setAutoScrollAiMode, } = props; @@ -1039,6 +1042,21 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct }, ] : []), + // Maestro Cue - event-driven automation dashboard + ...(onOpenMaestroCue + ? [ + { + id: 'maestro-cue', + label: 'Maestro Cue', + shortcut: shortcuts.maestroCue, + subtext: 'Event-driven automation dashboard', + action: () => { + onOpenMaestroCue(); + setQuickActionOpen(false); + }, + }, + ] + : []), // Auto-scroll toggle ...(setAutoScrollAiMode ? [ diff --git a/src/renderer/components/SessionList/HamburgerMenuContent.tsx b/src/renderer/components/SessionList/HamburgerMenuContent.tsx index c47a71f5e..9d6b41d3e 100644 --- a/src/renderer/components/SessionList/HamburgerMenuContent.tsx +++ b/src/renderer/components/SessionList/HamburgerMenuContent.tsx @@ -14,6 +14,7 @@ import { BarChart3, Music, Command, + Zap, } from 'lucide-react'; import type { Theme } from '../../types'; import { formatShortcutKeys } from '../../utils/shortcutFormatter'; @@ -46,6 +47,7 @@ export function HamburgerMenuContent({ setUsageDashboardOpen, setSymphonyModalOpen, setDirectorNotesOpen, + setCueModalOpen, setUpdateCheckModalOpen, setAboutModalOpen, setQuickActionOpen, @@ -316,6 +318,33 @@ export function HamburgerMenuContent({ )} )} + {encoreFeatures.maestroCue && ( + + )}
+ {/* Maestro Cue Feature Section */} +
+ +
+ {/* Director's Notes Feature Section */}
= { label: "Director's Notes", keys: ['Meta', 'Shift', 'o'], }, + maestroCue: { + id: 'maestroCue', + label: 'Maestro Cue', + keys: ['Meta', 'Shift', 'u'], + }, }; // Non-editable shortcuts (displayed in help but not configurable) diff --git a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index fcd051f7c..1c8e6f9a3 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -441,6 +441,10 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { e.preventDefault(); ctx.setDirectorNotesOpen?.(true); trackShortcut('directorNotes'); + } else if (ctx.isShortcut(e, 'maestroCue') && ctx.encoreFeatures?.maestroCue) { + e.preventDefault(); + ctx.setCueModalOpen?.(true); + trackShortcut('maestroCue'); } else if (ctx.isShortcut(e, 'jumpToBottom')) { e.preventDefault(); // Jump to the bottom of the current main panel output (AI logs or terminal output) diff --git a/src/renderer/hooks/useCue.ts b/src/renderer/hooks/useCue.ts new file mode 100644 index 000000000..1861ed38f --- /dev/null +++ b/src/renderer/hooks/useCue.ts @@ -0,0 +1,146 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +/** Event types that can trigger a Cue subscription */ +type CueEventType = 'time.interval' | 'file.changed' | 'agent.completed'; + +/** Status of a Cue run */ +type CueRunStatus = 'running' | 'completed' | 'failed' | 'timeout' | 'stopped'; + +/** An event instance produced by a trigger */ +interface CueEvent { + id: string; + type: CueEventType; + timestamp: string; + triggerName: string; + payload: Record; +} + +/** Result of a completed (or failed/timed-out) Cue run */ +export interface CueRunResult { + runId: string; + sessionId: string; + sessionName: string; + subscriptionName: string; + event: CueEvent; + status: CueRunStatus; + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; + startedAt: string; + endedAt: string; +} + +/** Status summary for a Cue-enabled session */ +export interface CueSessionStatus { + sessionId: string; + sessionName: string; + toolType: string; + enabled: boolean; + subscriptionCount: number; + activeRuns: number; + lastTriggered?: string; + nextTrigger?: string; +} + +export interface UseCueReturn { + sessions: CueSessionStatus[]; + activeRuns: CueRunResult[]; + activityLog: CueRunResult[]; + loading: boolean; + enable: () => Promise; + disable: () => Promise; + stopRun: (runId: string) => Promise; + stopAll: () => Promise; + refresh: () => Promise; +} + +const POLL_INTERVAL_MS = 10_000; + +/** + * Hook that manages Cue state for the renderer. + * Fetches status, active runs, and activity log from the Cue IPC API. + * Auto-refreshes on mount, listens for activity updates, and polls periodically. + */ +export function useCue(): UseCueReturn { + const [sessions, setSessions] = useState([]); + const [activeRuns, setActiveRuns] = useState([]); + const [activityLog, setActivityLog] = useState([]); + const [loading, setLoading] = useState(true); + const mountedRef = useRef(true); + + const refresh = useCallback(async () => { + try { + const [statusData, runsData, logData] = await Promise.all([ + window.maestro.cue.getStatus(), + window.maestro.cue.getActiveRuns(), + window.maestro.cue.getActivityLog(100), + ]); + if (!mountedRef.current) return; + setSessions(statusData); + setActiveRuns(runsData); + setActivityLog(logData); + } catch { + // Let Sentry capture if truly unexpected + } finally { + if (mountedRef.current) { + setLoading(false); + } + } + }, []); + + const enable = useCallback(async () => { + await window.maestro.cue.enable(); + await refresh(); + }, [refresh]); + + const disable = useCallback(async () => { + await window.maestro.cue.disable(); + await refresh(); + }, [refresh]); + + const stopRun = useCallback( + async (runId: string) => { + await window.maestro.cue.stopRun(runId); + await refresh(); + }, + [refresh] + ); + + const stopAll = useCallback(async () => { + await window.maestro.cue.stopAll(); + await refresh(); + }, [refresh]); + + // Initial fetch + event subscription + polling + useEffect(() => { + mountedRef.current = true; + refresh(); + + // Subscribe to real-time activity updates + const unsubscribe = window.maestro.cue.onActivityUpdate(() => { + refresh(); + }); + + // Periodic polling for status updates (timer counts, next trigger estimates) + const intervalId = setInterval(refresh, POLL_INTERVAL_MS); + + return () => { + mountedRef.current = false; + unsubscribe(); + clearInterval(intervalId); + }; + }, [refresh]); + + return { + sessions, + activeRuns, + activityLog, + loading, + enable, + disable, + stopRun, + stopAll, + refresh, + }; +} diff --git a/src/renderer/stores/modalStore.ts b/src/renderer/stores/modalStore.ts index c83b97951..5a252ece1 100644 --- a/src/renderer/stores/modalStore.ts +++ b/src/renderer/stores/modalStore.ts @@ -220,7 +220,9 @@ export type ModalId = // Platform Warnings | 'windowsWarning' // Director's Notes - | 'directorNotes'; + | 'directorNotes' + // Maestro Cue + | 'cueModal'; /** * Type mapping from ModalId to its data type. @@ -761,6 +763,9 @@ export function getModalActions() { setDirectorNotesOpen: (open: boolean) => open ? openModal('directorNotes') : closeModal('directorNotes'), + // Maestro Cue Modal + setCueModalOpen: (open: boolean) => (open ? openModal('cueModal') : closeModal('cueModal')), + // Lightbox refs replacement - use updateModalData instead setLightboxIsGroupChat: (isGroupChat: boolean) => updateModalData('lightbox', { isGroupChat }), setLightboxAllowDelete: (allowDelete: boolean) => updateModalData('lightbox', { allowDelete }), @@ -850,6 +855,7 @@ export function useModalActions() { const symphonyModalOpen = useModalStore(selectModalOpen('symphony')); const windowsWarningModalOpen = useModalStore(selectModalOpen('windowsWarning')); const directorNotesOpen = useModalStore(selectModalOpen('directorNotes')); + const cueModalOpen = useModalStore(selectModalOpen('cueModal')); // Get stable actions const actions = getModalActions(); @@ -1018,6 +1024,9 @@ export function useModalActions() { // Director's Notes Modal directorNotesOpen, + // Maestro Cue Modal + cueModalOpen, + // Lightbox ref replacements (now stored as data) lightboxIsGroupChat: lightboxData?.isGroupChat ?? false, lightboxAllowDelete: lightboxData?.allowDelete ?? false, From b3c568dd704b2e86ee3ad825561f07e070fbab1b Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 1 Mar 2026 03:28:56 -0600 Subject: [PATCH 015/194] MAESTRO: Phase 07 - Cue YAML Editor with AI-assisted prompt generation Add CueYamlEditor component for creating and editing maestro-cue.yaml files. Features split-view layout with AI assist (left panel for description + clipboard copy) and YAML editor (right panel with line numbers, debounced validation, Tab indentation). Integrates into CueModal via Edit YAML button on each session row. --- .../renderer/components/CueModal.test.tsx | 8 + .../components/CueYamlEditor.test.tsx | 536 ++++++++++++++++++ src/main/cue/cue-engine.ts | 1 + src/main/cue/cue-types.ts | 1 + src/renderer/components/CueModal.tsx | 304 +++++----- src/renderer/components/CueYamlEditor.tsx | 350 ++++++++++++ src/renderer/constants/modalPriorities.ts | 3 + src/renderer/global.d.ts | 1 + src/renderer/hooks/useCue.ts | 1 + 9 files changed, 1076 insertions(+), 129 deletions(-) create mode 100644 src/__tests__/renderer/components/CueYamlEditor.test.tsx create mode 100644 src/renderer/components/CueYamlEditor.tsx diff --git a/src/__tests__/renderer/components/CueModal.test.tsx b/src/__tests__/renderer/components/CueModal.test.tsx index 60a8090fc..442424b81 100644 --- a/src/__tests__/renderer/components/CueModal.test.tsx +++ b/src/__tests__/renderer/components/CueModal.test.tsx @@ -29,9 +29,16 @@ vi.mock('../../../renderer/contexts/LayerStackContext', () => ({ vi.mock('../../../renderer/constants/modalPriorities', () => ({ MODAL_PRIORITIES: { CUE_MODAL: 460, + CUE_YAML_EDITOR: 463, }, })); +// Mock CueYamlEditor +vi.mock('../../../renderer/components/CueYamlEditor', () => ({ + CueYamlEditor: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ?
YAML Editor Mock
: null, +})); + // Mock useCue hook const mockEnable = vi.fn().mockResolvedValue(undefined); const mockDisable = vi.fn().mockResolvedValue(undefined); @@ -82,6 +89,7 @@ const mockSession = { sessionId: 'sess-1', sessionName: 'Test Session', toolType: 'claude-code', + projectRoot: '/test/project', enabled: true, subscriptionCount: 3, activeRuns: 1, diff --git a/src/__tests__/renderer/components/CueYamlEditor.test.tsx b/src/__tests__/renderer/components/CueYamlEditor.test.tsx new file mode 100644 index 000000000..19ba88248 --- /dev/null +++ b/src/__tests__/renderer/components/CueYamlEditor.test.tsx @@ -0,0 +1,536 @@ +/** + * Tests for CueYamlEditor component + * + * Tests the Cue YAML editor including: + * - Loading existing YAML content on mount + * - YAML template shown when no file exists + * - Real-time validation with error display + * - AI assist section with clipboard copy + * - Save/Cancel functionality with dirty state + * - Line numbers gutter + * - Tab key indentation + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { CueYamlEditor } from '../../../renderer/components/CueYamlEditor'; +import type { Theme } from '../../../renderer/types'; + +// Mock the Modal component +vi.mock('../../../renderer/components/ui/Modal', () => ({ + Modal: ({ + children, + footer, + title, + testId, + onClose, + }: { + children: React.ReactNode; + footer?: React.ReactNode; + title: string; + testId?: string; + onClose: () => void; + }) => ( +
+
{children}
+ {footer &&
{footer}
} +
+ ), + ModalFooter: ({ + onCancel, + onConfirm, + confirmLabel, + confirmDisabled, + }: { + onCancel: () => void; + onConfirm: () => void; + confirmLabel: string; + confirmDisabled: boolean; + theme: Theme; + }) => ( + <> + + + + ), +})); + +// Mock modal priorities +vi.mock('../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { + CUE_YAML_EDITOR: 463, + }, +})); + +// Mock IPC methods +const mockReadYaml = vi.fn(); +const mockWriteYaml = vi.fn(); +const mockValidateYaml = vi.fn(); +const mockRefreshSession = vi.fn(); + +const mockClipboardWriteText = vi.fn().mockResolvedValue(undefined); + +const existingWindowMaestro = (window as any).maestro; + +beforeEach(() => { + vi.clearAllMocks(); + + (window as any).maestro = { + ...existingWindowMaestro, + cue: { + ...existingWindowMaestro?.cue, + readYaml: mockReadYaml, + writeYaml: mockWriteYaml, + validateYaml: mockValidateYaml, + refreshSession: mockRefreshSession, + }, + }; + + Object.assign(navigator, { + clipboard: { + writeText: mockClipboardWriteText, + }, + }); + + // Default: file doesn't exist, YAML is valid + mockReadYaml.mockResolvedValue(null); + mockWriteYaml.mockResolvedValue(undefined); + mockValidateYaml.mockResolvedValue({ valid: true, errors: [] }); + mockRefreshSession.mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + (window as any).maestro = existingWindowMaestro; +}); + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + scrollbar: '#44475a', + scrollbarHover: '#6272a4', + }, +}; + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + projectRoot: '/test/project', + sessionId: 'sess-1', + theme: mockTheme, +}; + +describe('CueYamlEditor', () => { + describe('rendering', () => { + it('should not render when isOpen is false', () => { + render(); + expect(screen.queryByTestId('cue-yaml-editor')).not.toBeInTheDocument(); + }); + + it('should render when isOpen is true', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('cue-yaml-editor')).toBeInTheDocument(); + }); + }); + + it('should show loading state initially', () => { + // Make readYaml never resolve to keep loading state + mockReadYaml.mockReturnValue(new Promise(() => {})); + render(); + + expect(screen.getByText('Loading YAML...')).toBeInTheDocument(); + }); + + it('should render AI assist section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('AI Assist')).toBeInTheDocument(); + }); + expect(screen.getByTestId('ai-description-input')).toBeInTheDocument(); + expect(screen.getByTestId('copy-prompt-button')).toBeInTheDocument(); + }); + + it('should render YAML editor section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('YAML Configuration')).toBeInTheDocument(); + }); + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + it('should render line numbers gutter', async () => { + mockReadYaml.mockResolvedValue('line1\nline2\nline3'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('line-numbers')).toBeInTheDocument(); + }); + expect(screen.getByTestId('line-numbers').textContent).toContain('1'); + expect(screen.getByTestId('line-numbers').textContent).toContain('2'); + expect(screen.getByTestId('line-numbers').textContent).toContain('3'); + }); + }); + + describe('YAML loading', () => { + it('should load existing YAML from projectRoot on mount', async () => { + const existingYaml = 'subscriptions:\n - name: "test"\n event: time.interval'; + mockReadYaml.mockResolvedValue(existingYaml); + + render(); + + await waitFor(() => { + expect(mockReadYaml).toHaveBeenCalledWith('/test/project'); + }); + expect(screen.getByTestId('yaml-editor')).toHaveValue(existingYaml); + }); + + it('should show template when no YAML file exists', async () => { + mockReadYaml.mockResolvedValue(null); + + render(); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('# maestro-cue.yaml'); + }); + }); + + it('should show template when readYaml throws', async () => { + mockReadYaml.mockRejectedValue(new Error('File read error')); + + render(); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('# maestro-cue.yaml'); + }); + }); + }); + + describe('validation', () => { + it('should show valid indicator when YAML is valid', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + mockValidateYaml.mockResolvedValue({ valid: true, errors: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Valid YAML')).toBeInTheDocument(); + }); + }); + + it('should show validation errors when YAML is invalid', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + // Change the content to trigger validation + mockValidateYaml.mockResolvedValue({ + valid: false, + errors: ['Missing required field: name'], + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'invalid: yaml: content' }, + }); + + await waitFor( + () => { + expect(screen.getByTestId('validation-errors')).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + + expect(screen.getByText('Missing required field: name')).toBeInTheDocument(); + expect(screen.getByText('1 error')).toBeInTheDocument(); + }); + + it('should show plural error count for multiple errors', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + mockValidateYaml.mockResolvedValue({ + valid: false, + errors: ['Error one', 'Error two'], + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'bad' }, + }); + + await waitFor( + () => { + expect(screen.getByText('2 errors')).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + }); + + it('should debounce validation calls', async () => { + vi.useFakeTimers(); + mockReadYaml.mockResolvedValue('initial'); + + render(); + + // Wait for initial load + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Rapidly change the content + const editor = screen.getByTestId('yaml-editor'); + fireEvent.change(editor, { target: { value: 'change1' } }); + fireEvent.change(editor, { target: { value: 'change2' } }); + fireEvent.change(editor, { target: { value: 'change3' } }); + + // Before debounce window, validateYaml should not be called for the changes + // (may have been called during initial load) + const callsBeforeDebounce = mockValidateYaml.mock.calls.length; + + // Advance past debounce timer + await act(async () => { + vi.advanceTimersByTime(600); + }); + + // Should only have added one validation call (for the last change) + expect(mockValidateYaml.mock.calls.length).toBe(callsBeforeDebounce + 1); + expect(mockValidateYaml).toHaveBeenLastCalledWith('change3'); + + vi.useRealTimers(); + }); + }); + + describe('AI assist', () => { + it('should have disabled copy button when description is empty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('copy-prompt-button')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('copy-prompt-button')).toBeDisabled(); + }); + + it('should enable copy button when description has text', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-description-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-description-input'), { + target: { value: 'Watch for file changes' }, + }); + + expect(screen.getByTestId('copy-prompt-button')).not.toBeDisabled(); + }); + + it('should copy system prompt + description to clipboard', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-description-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-description-input'), { + target: { value: 'Run code review on save' }, + }); + + fireEvent.click(screen.getByTestId('copy-prompt-button')); + + await waitFor(() => { + expect(mockClipboardWriteText).toHaveBeenCalledOnce(); + }); + + const copiedText = mockClipboardWriteText.mock.calls[0][0]; + expect(copiedText).toContain('Maestro Cue configuration generator'); + expect(copiedText).toContain('Run code review on save'); + }); + + it('should show "Copied!" feedback after copying', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-description-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-description-input'), { + target: { value: 'test' }, + }); + + fireEvent.click(screen.getByTestId('copy-prompt-button')); + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + }); + }); + + describe('save and cancel', () => { + it('should disable Save when content has not changed', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('should enable Save when content is modified and valid', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified content' }, + }); + + // Save should be enabled since content changed and validation is still valid + expect(screen.getByText('Save')).not.toBeDisabled(); + }); + + it('should disable Save when validation fails', async () => { + vi.useFakeTimers(); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + mockValidateYaml.mockResolvedValue({ valid: false, errors: ['Bad YAML'] }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'invalid' }, + }); + + // Advance past debounce + await act(async () => { + vi.advanceTimersByTime(600); + }); + + // Wait for async validation to complete + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getByText('Save')).toBeDisabled(); + + vi.useRealTimers(); + }); + + it('should call writeYaml and refreshSession on Save', async () => { + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'new content' }, + }); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockWriteYaml).toHaveBeenCalledWith('/test/project', 'new content'); + }); + expect(mockRefreshSession).toHaveBeenCalledWith('sess-1', '/test/project'); + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + }); + + it('should call onClose when Cancel is clicked and content is not dirty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Cancel')); + + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + }); + + it('should prompt for confirmation when Cancel is clicked with dirty content', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified' }, + }); + + fireEvent.click(screen.getByText('Cancel')); + + expect(mockConfirm).toHaveBeenCalledWith('You have unsaved changes. Discard them?'); + expect(defaultProps.onClose).not.toHaveBeenCalled(); + + mockConfirm.mockRestore(); + }); + + it('should close when user confirms discard on Cancel', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified' }, + }); + + fireEvent.click(screen.getByText('Cancel')); + + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + + mockConfirm.mockRestore(); + }); + }); +}); diff --git a/src/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts index ddd9b18c0..5453baf47 100644 --- a/src/main/cue/cue-engine.ts +++ b/src/main/cue/cue-engine.ts @@ -112,6 +112,7 @@ export class CueEngine { sessionId, sessionName: session.name, toolType: session.toolType, + projectRoot: session.projectRoot, enabled: true, subscriptionCount: state.config.subscriptions.filter((s) => s.enabled !== false).length, activeRuns: activeRunCount, diff --git a/src/main/cue/cue-types.ts b/src/main/cue/cue-types.ts index 939752002..520483f06 100644 --- a/src/main/cue/cue-types.ts +++ b/src/main/cue/cue-types.ts @@ -73,6 +73,7 @@ export interface CueSessionStatus { sessionId: string; sessionName: string; toolType: string; + projectRoot: string; enabled: boolean; subscriptionCount: number; activeRuns: number; diff --git a/src/renderer/components/CueModal.tsx b/src/renderer/components/CueModal.tsx index 628c08093..9fbd3b869 100644 --- a/src/renderer/components/CueModal.tsx +++ b/src/renderer/components/CueModal.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { X, Zap, Square, HelpCircle, StopCircle } from 'lucide-react'; +import { X, Zap, Square, HelpCircle, StopCircle, FileEdit } from 'lucide-react'; import type { Theme } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { useCue } from '../hooks/useCue'; import type { CueSessionStatus, CueRunResult } from '../hooks/useCue'; +import { CueYamlEditor } from './CueYamlEditor'; interface CueModalProps { theme: Theme; @@ -46,7 +47,15 @@ function StatusDot({ status }: { status: 'active' | 'paused' | 'none' }) { return ; } -function SessionsTable({ sessions, theme }: { sessions: CueSessionStatus[]; theme: Theme }) { +function SessionsTable({ + sessions, + theme, + onEditYaml, +}: { + sessions: CueSessionStatus[]; + theme: Theme; + onEditYaml: (session: CueSessionStatus) => void; +}) { if (sessions.length === 0) { return (
@@ -67,6 +76,7 @@ function SessionsTable({ sessions, theme }: { sessions: CueSessionStatus[]; them Status Last Triggered Subs + @@ -98,6 +108,17 @@ function SessionsTable({ sessions, theme }: { sessions: CueSessionStatus[]; them {s.subscriptionCount} + + + ); })} @@ -267,151 +288,176 @@ export function CueModal({ theme, onClose }: CueModalProps) { }; }, [registerLayer, unregisterLayer]); + // YAML editor state + const [yamlEditorSession, setYamlEditorSession] = useState(null); + + const handleEditYaml = useCallback((session: CueSessionStatus) => { + setYamlEditorSession(session); + }, []); + + const handleCloseYamlEditor = useCallback(() => { + setYamlEditorSession(null); + }, []); + // Active runs section is collapsible when empty const [activeRunsExpanded, setActiveRunsExpanded] = useState(true); - return createPortal( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > - {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} + return ( + <> + {createPortal(
{ + if (e.target === e.currentTarget) onClose(); + }} > -
- -

- Maestro Cue -

-
-
- {/* Master toggle */} -
- {isEnabled ? 'Enabled' : 'Disabled'} - - - {/* Help button */} - - - {/* Close button */} - -
-
+ > +
+
+
+ {isEnabled ? 'Enabled' : 'Disabled'} + - {/* Body */} -
- {loading ? ( -
- Loading Cue status... -
- ) : ( - <> - {/* Section 1: Sessions with Cue */} -
-

- Sessions with Cue -

- -
+ + - {/* Section 2: Active Runs */} -
+ {/* Close button */} - {activeRunsExpanded && ( - - )}
+
- {/* Section 3: Activity Log */} -
-

- Activity Log -

-
- + {/* Body */} +
+ {loading ? ( +
+ Loading Cue status...
-
- - )} -
-
-
, - document.body + ) : ( + <> + {/* Section 1: Sessions with Cue */} +
+

+ Sessions with Cue +

+ +
+ + {/* Section 2: Active Runs */} +
+ + {activeRunsExpanded && ( + + )} +
+ + {/* Section 3: Activity Log */} +
+

+ Activity Log +

+
+ +
+
+ + )} +
+
+
, + document.body + )} + {yamlEditorSession && ( + + )} + ); } diff --git a/src/renderer/components/CueYamlEditor.tsx b/src/renderer/components/CueYamlEditor.tsx new file mode 100644 index 000000000..486b0991b --- /dev/null +++ b/src/renderer/components/CueYamlEditor.tsx @@ -0,0 +1,350 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { CheckCircle, XCircle, Copy, Zap } from 'lucide-react'; +import { Modal, ModalFooter } from './ui/Modal'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import type { Theme } from '../types'; + +const CUE_TEAL = '#06b6d4'; + +const YAML_TEMPLATE = `# maestro-cue.yaml +# Define event-driven subscriptions for your agents. +# +# subscriptions: +# - name: "code review on change" +# event: file.changed +# watch: "src/**/*.ts" +# prompt: prompts/review.md +# enabled: true +# +# - name: "hourly security audit" +# event: time.interval +# interval_minutes: 60 +# prompt: prompts/security-audit.md +# enabled: true +# +# - name: "deploy after tests pass" +# event: agent.completed +# source_session: "test-runner" +# prompt: prompts/deploy.md +# enabled: true +# +# settings: +# timeout_minutes: 30 +# timeout_on_fail: break +`; + +const AI_SYSTEM_PROMPT = `You are a Maestro Cue configuration generator. Generate valid maestro-cue.yaml content based on the user's description. + +Available event types: +- time.interval: Runs on a timer. Requires \`interval_minutes\`. +- file.changed: Runs when files matching a glob pattern change. Requires \`watch\` (glob pattern). +- agent.completed: Runs when another agent session completes. Requires \`source_session\` (name or array for fan-in). Optional \`fan_out\` array to trigger multiple sessions. + +YAML format: +subscriptions: + - name: "descriptive name" + event: time.interval | file.changed | agent.completed + interval_minutes: N # for time.interval + watch: "glob/pattern/**" # for file.changed + source_session: "name" # for agent.completed (string or string[]) + fan_out: ["name1", "name2"] # optional, for agent.completed + prompt: path/to/prompt.md # relative to project root + enabled: true + +settings: + timeout_minutes: 30 + timeout_on_fail: break # or "continue" + +Output ONLY the YAML content, no markdown code fences, no explanation.`; + +const AI_PLACEHOLDER = + 'Watch for changes in src/ and run a code review every time a TypeScript file is modified. Also run a security audit every 2 hours.'; + +interface CueYamlEditorProps { + isOpen: boolean; + onClose: () => void; + projectRoot: string; + sessionId: string; + theme: Theme; +} + +export function CueYamlEditor({ + isOpen, + onClose, + projectRoot, + sessionId, + theme, +}: CueYamlEditorProps) { + const [yamlContent, setYamlContent] = useState(''); + const [originalContent, setOriginalContent] = useState(''); + const [aiDescription, setAiDescription] = useState(''); + const [validationErrors, setValidationErrors] = useState([]); + const [isValid, setIsValid] = useState(true); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + const validateTimerRef = useRef>(); + const yamlTextareaRef = useRef(null); + + // Load existing YAML on mount + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + + async function loadYaml() { + setLoading(true); + try { + const content = await window.maestro.cue.readYaml(projectRoot); + if (cancelled) return; + const initial = content ?? YAML_TEMPLATE; + setYamlContent(initial); + setOriginalContent(initial); + } catch { + if (cancelled) return; + setYamlContent(YAML_TEMPLATE); + setOriginalContent(YAML_TEMPLATE); + } finally { + if (!cancelled) setLoading(false); + } + } + + loadYaml(); + return () => { + cancelled = true; + }; + }, [isOpen, projectRoot]); + + // Debounced validation + const validateYaml = useCallback((content: string) => { + if (validateTimerRef.current) { + clearTimeout(validateTimerRef.current); + } + validateTimerRef.current = setTimeout(async () => { + try { + const result = await window.maestro.cue.validateYaml(content); + setIsValid(result.valid); + setValidationErrors(result.errors); + } catch { + setIsValid(false); + setValidationErrors(['Failed to validate YAML']); + } + }, 500); + }, []); + + // Cleanup validation timer + useEffect(() => { + return () => { + if (validateTimerRef.current) { + clearTimeout(validateTimerRef.current); + } + }; + }, []); + + const handleYamlChange = useCallback( + (value: string) => { + setYamlContent(value); + validateYaml(value); + }, + [validateYaml] + ); + + const handleSave = useCallback(async () => { + if (!isValid) return; + try { + await window.maestro.cue.writeYaml(projectRoot, yamlContent); + await window.maestro.cue.refreshSession(sessionId, projectRoot); + onClose(); + } catch { + // Let Sentry capture unexpected errors + } + }, [isValid, projectRoot, yamlContent, sessionId, onClose]); + + const handleClose = useCallback(() => { + const isDirty = yamlContent !== originalContent; + if (isDirty) { + const confirmed = window.confirm('You have unsaved changes. Discard them?'); + if (!confirmed) return; + } + onClose(); + }, [yamlContent, originalContent, onClose]); + + const handleCopyPrompt = useCallback(async () => { + const fullPrompt = `${AI_SYSTEM_PROMPT}\n\n---\n\nUser request:\n${aiDescription}`; + try { + await navigator.clipboard.writeText(fullPrompt); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API may fail in some contexts + } + }, [aiDescription]); + + // Handle Tab key in textarea for indentation + const handleYamlKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + e.preventDefault(); + const textarea = e.currentTarget; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const indent = ' '; + const newValue = yamlContent.substring(0, start) + indent + yamlContent.substring(end); + setYamlContent(newValue); + validateYaml(newValue); + // Restore cursor position after React re-renders + requestAnimationFrame(() => { + textarea.selectionStart = textarea.selectionEnd = start + indent.length; + }); + } + }, + [yamlContent, validateYaml] + ); + + if (!isOpen) return null; + + const isDirty = yamlContent !== originalContent; + + return ( + } + testId="cue-yaml-editor" + footer={ +
+
+ {isValid ? ( + <> + + Valid YAML + + ) : ( + <> + + + {validationErrors.length} error{validationErrors.length !== 1 ? 's' : ''} + + + )} +
+ +
+ } + > + {loading ? ( +
+ Loading YAML... +
+ ) : ( +
+ {/* Left side: AI input (40%) */} +
+

+ AI Assist +

+

+ Describe what you want your agent to do, then copy the prompt to paste into any agent. +

+