diff --git a/src/__tests__/main/cue/cue-executor.test.ts b/src/__tests__/main/cue/cue-executor.test.ts index da82fc3b59..5dd80d7939 100644 --- a/src/__tests__/main/cue/cue-executor.test.ts +++ b/src/__tests__/main/cue/cue-executor.test.ts @@ -107,6 +107,7 @@ vi.mock('../../../main/parsers', () => ({ // Mock child_process.spawn class MockChildProcess extends EventEmitter { + pid = 12345; stdin = { write: vi.fn(), end: vi.fn(), @@ -151,6 +152,7 @@ import { executeCuePrompt, stopCueRun, getActiveProcesses, + getCueProcessList, recordCueHistoryEntry, type CueExecutionConfig, } from '../../../main/cue/cue-executor'; @@ -1304,4 +1306,49 @@ describe('cue-executor', () => { expect(result.stdout).toBe(rawOutput); }); }); + + describe('getCueProcessList', () => { + it('should return empty array when no active processes', () => { + expect(getCueProcessList()).toEqual([]); + }); + + it('should return process info during active run', async () => { + const config = createExecutionConfig({ runId: 'list-test-run', toolType: 'claude-code' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const list = getCueProcessList(); + expect(list).toHaveLength(1); + expect(list[0].runId).toBe('list-test-run'); + expect(list[0].pid).toBe(12345); + expect(list[0].toolType).toBe('claude-code'); + expect(list[0].cwd).toBe('/projects/test'); + expect(list[0].command).toBe('claude'); + expect(Array.isArray(list[0].args)).toBe(true); + expect(typeof list[0].startTime).toBe('number'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should exclude completed processes', async () => { + const config = createExecutionConfig({ runId: 'completed-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Process is active — should appear in list + const activeEntry = getActiveProcesses().get('completed-run'); + expect(activeEntry).toBeDefined(); + expect(getCueProcessList().some((p) => p.runId === 'completed-run')).toBe(true); + + mockChild.emit('close', 0); + await resultPromise; + + // Process completed — should be removed + expect(getActiveProcesses().has('completed-run')).toBe(false); + expect(getCueProcessList().some((p) => p.runId === 'completed-run')).toBe(false); + }); + }); }); diff --git a/src/__tests__/main/cue/cue-sleep-prevention.test.ts b/src/__tests__/main/cue/cue-sleep-prevention.test.ts new file mode 100644 index 0000000000..beb98e0035 --- /dev/null +++ b/src/__tests__/main/cue/cue-sleep-prevention.test.ts @@ -0,0 +1,1129 @@ +/** + * Tests for Cue sleep prevention integration. + * + * Tests cover: + * - Schedule-level sleep prevention (heartbeat/scheduled subscriptions keep PC awake) + * - Run-level sleep prevention (active Cue runs keep PC awake) + * - Cleanup on teardown, removal, stop, and reset + * - Edge cases: disabled subs, agent_id mismatch, config refresh, optional callbacks + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueRunResult } from '../../../main/cue/cue-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 the GitHub poller +const mockCreateCueGitHubPoller = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-github-poller', () => ({ + createCueGitHubPoller: (...args: unknown[]) => mockCreateCueGitHubPoller(args[0]), +})); + +// Mock the task scanner +const mockCreateCueTaskScanner = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-task-scanner', () => ({ + createCueTaskScanner: (...args: unknown[]) => mockCreateCueTaskScanner(args[0]), +})); + +// Mock the database +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: vi.fn(), + closeCueDb: vi.fn(), + pruneCueEvents: vi.fn(), + isCueDbReady: () => true, + recordCueEvent: vi.fn(), + updateCueEventStatus: vi.fn(), +})); + +// 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'; +import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers'; + +describe('Cue Sleep Prevention', () => { + let yamlWatcherCleanup: ReturnType; + let fileWatcherCleanup: ReturnType; + let gitHubPollerCleanup: ReturnType; + let taskScannerCleanup: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + yamlWatcherCleanup = vi.fn(); + mockWatchCueYaml.mockReturnValue(yamlWatcherCleanup); + + fileWatcherCleanup = vi.fn(); + mockCreateCueFileWatcher.mockReturnValue(fileWatcherCleanup); + + gitHubPollerCleanup = vi.fn(); + mockCreateCueGitHubPoller.mockReturnValue(gitHubPollerCleanup); + + taskScannerCleanup = vi.fn(); + mockCreateCueTaskScanner.mockReturnValue(taskScannerCleanup); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('schedule-level sleep prevention', () => { + it('adds schedule reason when session has heartbeat subscription', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-sub', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'do stuff', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('adds schedule reason when session has scheduled subscription', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'scheduled-sub', + event: 'time.scheduled', + schedule_times: ['09:00'], + prompt: 'do stuff', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('does not add schedule reason for file.changed subscriptions only', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'file-watcher', + event: 'file.changed', + watch: '**/*.ts', + prompt: 'review changes', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('does not add schedule reason for agent.completed subscriptions only', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'completion-sub', + event: 'agent.completed', + source_session: 'other-session', + prompt: 'follow up', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('does not add schedule reason for github subscriptions only', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + prompt: 'review pr', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('does not add schedule reason for task.pending subscriptions only', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'task-scanner', + event: 'task.pending', + watch: '**/*.md', + prompt: 'do tasks', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('adds schedule reason once for mixed subs (heartbeat + file.changed)', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-sub', + event: 'time.heartbeat', + interval_minutes: 10, + prompt: 'check health', + enabled: true, + }, + { + name: 'file-watcher', + event: 'file.changed', + watch: '**/*.ts', + prompt: 'review', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + const scheduleCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:schedule:') + ); + expect(scheduleCalls).toHaveLength(1); + expect(scheduleCalls[0][0]).toBe('cue:schedule:session-1'); + }); + + it('does not add schedule reason for disabled heartbeat subscription', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'disabled-heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'do stuff', + enabled: false, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('does not add schedule reason for heartbeat bound to different agent_id', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'other-agent-heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'do stuff', + enabled: true, + agent_id: 'different-session', + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('adds separate schedule reasons for multiple sessions', () => { + const onPreventSleep = vi.fn(); + const session1 = createMockSession({ id: 'session-1', name: 'Session 1' }); + const session2 = createMockSession({ + id: 'session-2', + name: 'Session 2', + projectRoot: '/projects/test2', + }); + const deps = createMockDeps({ + onPreventSleep, + getSessions: vi.fn(() => [session1, session2]), + }); + + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-2'); + }); + + it('removes schedule reason on teardownSession via removeSession', () => { + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onAllowSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + engine.removeSession('session-1'); + + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('removes all schedule reasons on stop()', () => { + const onAllowSleep = vi.fn(); + const session1 = createMockSession({ id: 'session-1', name: 'Session 1' }); + const session2 = createMockSession({ + id: 'session-2', + name: 'Session 2', + projectRoot: '/projects/test2', + }); + const deps = createMockDeps({ + onAllowSleep, + getSessions: vi.fn(() => [session1, session2]), + }); + + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-2'); + }); + + it('refreshSession re-adds schedule reason when config still has heartbeat', () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep, onAllowSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + onPreventSleep.mockClear(); + onAllowSleep.mockClear(); + + engine.refreshSession('session-1', '/projects/test'); + + // teardown releases, re-init re-adds + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('refreshSession cleans up reason when heartbeat removed from config', () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep, onAllowSleep }); + + // Initial config has heartbeat + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + onPreventSleep.mockClear(); + onAllowSleep.mockClear(); + + // Refreshed config has no heartbeat + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'file-watcher', + event: 'file.changed', + watch: '**/*.ts', + prompt: 'review', + enabled: true, + }, + ], + }) + ); + + engine.refreshSession('session-1', '/projects/test'); + + // teardown releases the old reason + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + // re-init does NOT add schedule reason (no time-based subs) + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('refreshSession adds reason when heartbeat added to config', () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep, onAllowSleep }); + + // Initial config has no heartbeat + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'file-watcher', + event: 'file.changed', + watch: '**/*.ts', + prompt: 'review', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + onPreventSleep.mockClear(); + + // Refreshed config adds heartbeat + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + engine.refreshSession('session-1', '/projects/test'); + + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('operates normally when callbacks are not provided', () => { + const deps = createMockDeps(); // no onPreventSleep/onAllowSleep + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + + // Should not throw + expect(() => { + engine.start(); + engine.removeSession('session-1'); + engine.stop(); + }).not.toThrow(); + }); + }); + + describe('run-level sleep prevention', () => { + it('adds block reason when run starts', async () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + // Advance to trigger the heartbeat (immediate fire on setup) + await vi.advanceTimersByTimeAsync(100); + + // Should have been called with a cue:run: reason + const runCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + expect(runCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('removes block reason when run completes', async () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep, onAllowSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + // Let the run complete + await vi.advanceTimersByTimeAsync(100); + + // Find the run reason that was added + const runAddCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + expect(runAddCalls.length).toBeGreaterThanOrEqual(1); + + const runReason = runAddCalls[0][0]; + + // Same reason should have been removed + expect(onAllowSleep).toHaveBeenCalledWith(runReason); + }); + + it('removes block reason when run fails', async () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat' as const, + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'failed' as const, + stdout: '', + stderr: 'something broke', + exitCode: 1, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + const runAddCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + expect(runAddCalls.length).toBeGreaterThanOrEqual(1); + + const runReason = runAddCalls[0][0]; + expect(onAllowSleep).toHaveBeenCalledWith(runReason); + }); + + it('removes block reason when run is manually stopped', async () => { + let resolveRun: (result: CueRunResult) => void; + const runPromise = new Promise((resolve) => { + resolveRun = resolve; + }); + + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn(() => runPromise), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Get the active run + const activeRuns = engine.getActiveRuns(); + expect(activeRuns.length).toBe(1); + const runId = activeRuns[0].runId; + + // Stop the run + engine.stopRun(runId); + + expect(onAllowSleep).toHaveBeenCalledWith(`cue:run:${runId}`); + + // Resolve the run promise to avoid hanging + resolveRun!({ + runId, + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'stopped', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(100); + }); + + it('stopAll removes all run block reasons', async () => { + let resolveRun1: (result: CueRunResult) => void; + let resolveRun2: (result: CueRunResult) => void; + let callCount = 0; + + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn(() => { + callCount++; + if (callCount === 1) { + return new Promise((resolve) => { + resolveRun1 = resolve; + }); + } + return new Promise((resolve) => { + resolveRun2 = resolve; + }); + }), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 2, + queue_size: 10, + }, + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Trigger a second run by advancing to next heartbeat + vi.advanceTimersByTime(5 * 60 * 1000); + await vi.advanceTimersByTimeAsync(100); + + const activeRuns = engine.getActiveRuns(); + expect(activeRuns.length).toBe(2); + + engine.stopAll(); + + // Both run reasons should have been released + for (const run of activeRuns) { + expect(onAllowSleep).toHaveBeenCalledWith(`cue:run:${run.runId}`); + } + + // Resolve promises to avoid hanging + const makeResult = (runId: string): CueRunResult => ({ + runId, + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'stopped', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + resolveRun1!(makeResult(activeRuns[0].runId)); + resolveRun2!(makeResult(activeRuns[1].runId)); + await vi.advanceTimersByTimeAsync(100); + }); + + it('engine stop (reset) removes all run block reasons', async () => { + let resolveRun: (result: CueRunResult) => void; + + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + const activeRuns = engine.getActiveRuns(); + expect(activeRuns.length).toBe(1); + const runId = activeRuns[0].runId; + + engine.stop(); + + // reset() should release run reason + expect(onAllowSleep).toHaveBeenCalledWith(`cue:run:${runId}`); + + // Resolve to avoid hanging + resolveRun!({ + runId, + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'stopped', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(100); + }); + + it('multiple concurrent runs each get their own block reason', async () => { + let callCount = 0; + const resolvers: Array<(result: CueRunResult) => void> = []; + + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onCueRun: vi.fn(() => { + callCount++; + return new Promise((resolve) => { + resolvers.push(resolve); + }); + }), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 3, + queue_size: 10, + }, + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 1, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Trigger more runs + vi.advanceTimersByTime(60 * 1000); + await vi.advanceTimersByTimeAsync(100); + vi.advanceTimersByTime(60 * 1000); + await vi.advanceTimersByTimeAsync(100); + + const runReasons = onPreventSleep.mock.calls + .filter((call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:')) + .map((call) => call[0]); + + // Each run should have a unique reason + const uniqueReasons = new Set(runReasons); + expect(uniqueReasons.size).toBe(runReasons.length); + expect(runReasons.length).toBe(3); + + // Clean up + engine.stop(); + for (const resolve of resolvers) { + resolve({ + runId: 'cleanup', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'stopped', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + } + await vi.advanceTimersByTimeAsync(100); + }); + + it('run with output prompt has single add/remove pair for the main runId', async () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + let callCount = 0; + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn(async (request) => ({ + runId: request.runId, + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed' as const, + stdout: `output-${++callCount}`, + stderr: '', + exitCode: 0, + durationMs: 50, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 60, + prompt: 'main task', + output_prompt: 'summarize results', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Count run-level sleep calls + const runAddCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + const runRemoveCalls = onAllowSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + + // One add (main runId) and one remove (same runId in finally) + expect(runAddCalls).toHaveLength(1); + expect(runRemoveCalls).toHaveLength(1); + expect(runAddCalls[0][0]).toBe(runRemoveCalls[0][0]); + }); + + it('queued event does not add block reason until dispatched', async () => { + let resolveRun: (result: CueRunResult) => void; + let callCount = 0; + + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onCueRun: vi.fn(() => { + callCount++; + if (callCount === 1) { + return new Promise((resolve) => { + resolveRun = resolve; + }); + } + return Promise.resolve({ + runId: 'run-2', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e2', + type: 'time.heartbeat' as const, + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'completed' as const, + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 50, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + }), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, // Only 1 concurrent + queue_size: 10, + }, + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 1, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Count current run-level adds + const runAddsBefore = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ).length; + expect(runAddsBefore).toBe(1); // First run started + + // Trigger another event (will be queued since max_concurrent=1) + vi.advanceTimersByTime(60 * 1000); + await vi.advanceTimersByTimeAsync(100); + + // Queued event should NOT have added a run reason + const runAddsAfterQueue = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ).length; + expect(runAddsAfterQueue).toBe(1); // Still just the first run + + // Now resolve the first run — queued event should be dispatched + resolveRun!({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 50, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(100); + + // Now the queued event should have been dispatched and added its own reason + const runAddsAfterDrain = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ).length; + expect(runAddsAfterDrain).toBe(2); + }); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx index 6cdacca2c6..1261cb057d 100644 --- a/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx +++ b/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx @@ -131,6 +131,113 @@ describe('TriggerNode', () => { expect(unselectedRoot.style.boxShadow).toBeFalsy(); }); + describe('play button', () => { + it('renders when isSaved, pipelineName, and onTriggerPipeline are provided', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: true, + }); + + const playButton = container.querySelector('[title="Run now"]'); + expect(playButton).not.toBeNull(); + }); + + it('is hidden when isSaved is false', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: false, + }); + + expect(container.querySelector('[title="Run now"]')).toBeNull(); + }); + + it('is hidden when onTriggerPipeline is undefined', () => { + const { container } = renderTriggerNode({ + pipelineName: 'my-pipeline', + isSaved: true, + }); + + expect(container.querySelector('[title="Run now"]')).toBeNull(); + }); + + it('is hidden when pipelineName is undefined', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + isSaved: true, + }); + + expect(container.querySelector('[title="Run now"]')).toBeNull(); + }); + + it('calls onTriggerPipeline with pipeline name when clicked', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'test-pipeline', + isSaved: true, + }); + + const playButton = container.querySelector('[title="Run now"]') as HTMLElement; + playButton.click(); + + expect(onTriggerPipeline).toHaveBeenCalledWith('test-pipeline'); + }); + + it('shows "Running…" tooltip when isRunning is true', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: true, + isRunning: true, + }); + + expect(container.querySelector('[title="Running…"]')).not.toBeNull(); + expect(container.querySelector('[title="Run now"]')).toBeNull(); + }); + + it('does not call onTriggerPipeline when isRunning and clicked', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: true, + isRunning: true, + }); + + const runningButton = container.querySelector('[title="Running…"]') as HTMLElement; + runningButton.click(); + + expect(onTriggerPipeline).not.toHaveBeenCalled(); + }); + + it('gear icon still works alongside play button', () => { + const onConfigure = vi.fn(); + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onConfigure, + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: true, + compositeId: 'pipeline-1:trigger-0', + }); + + // Both buttons should exist + expect(container.querySelector('[title="Run now"]')).not.toBeNull(); + expect(container.querySelector('[title="Configure"]')).not.toBeNull(); + + // Gear still works + const gearButton = container.querySelector('[title="Configure"]') as HTMLElement; + gearButton.click(); + expect(onConfigure).toHaveBeenCalledWith('pipeline-1:trigger-0'); + }); + }); + it('should use correct color for each event type', () => { const eventColors: Record = { 'time.heartbeat': '#f59e0b', diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineGraph.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineGraph.test.ts index c1b04b1849..9a2a61e05b 100644 --- a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineGraph.test.ts +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineGraph.test.ts @@ -655,3 +655,54 @@ describe('convertToReactFlowEdges', () => { expect(edges.map((e) => e.id)).toContain('p2:e2'); }); }); + +describe('convertToReactFlowNodes triggerOptions', () => { + it('passes trigger options to trigger node data', () => { + const onTriggerPipeline = vi.fn(); + const runningPipelineIds = new Set(['p1']); + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat')], + }); + + const nodes = convertToReactFlowNodes([pipeline], 'p1', undefined, { + onTriggerPipeline, + isSaved: true, + runningPipelineIds, + }); + + expect(nodes).toHaveLength(1); + const triggerData = nodes[0].data as any; + expect(triggerData.onTriggerPipeline).toBe(onTriggerPipeline); + expect(triggerData.pipelineName).toBe('Pipeline p1'); + expect(triggerData.isSaved).toBe(true); + expect(triggerData.isRunning).toBe(true); + }); + + it('sets isRunning to false when pipeline is not in runningPipelineIds', () => { + const pipeline = makePipeline('p2', { + nodes: [makeTrigger('t1', 'time.heartbeat')], + }); + + const nodes = convertToReactFlowNodes([pipeline], 'p2', undefined, { + onTriggerPipeline: vi.fn(), + isSaved: true, + runningPipelineIds: new Set(['other']), + }); + + const triggerData = nodes[0].data as any; + expect(triggerData.isRunning).toBe(false); + }); + + it('does not include trigger options when not provided', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat')], + }); + + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + const triggerData = nodes[0].data as any; + expect(triggerData.onTriggerPipeline).toBeUndefined(); + expect(triggerData.pipelineName).toBe('Pipeline p1'); + expect(triggerData.isSaved).toBeUndefined(); + expect(triggerData.isRunning).toBeUndefined(); + }); +}); diff --git a/src/__tests__/renderer/components/ProcessMonitor.test.tsx b/src/__tests__/renderer/components/ProcessMonitor.test.tsx index c462bc7c5f..5b75acc348 100644 --- a/src/__tests__/renderer/components/ProcessMonitor.test.tsx +++ b/src/__tests__/renderer/components/ProcessMonitor.test.tsx @@ -135,6 +135,11 @@ interface ActiveProcess { isTerminal: boolean; isBatchMode: boolean; startTime: number; + isCueRun?: boolean; + cueRunId?: string; + cueSessionName?: string; + cueSubscriptionName?: string; + cueEventType?: string; } const createActiveProcess = (overrides: Partial = {}): ActiveProcess => ({ @@ -148,6 +153,22 @@ const createActiveProcess = (overrides: Partial = {}): ActiveProc ...overrides, }); +const createCueProcess = (overrides: Partial = {}): ActiveProcess => ({ + sessionId: 'cue-run-test-uuid', + toolType: 'claude-code', + pid: 99999, + cwd: '/Users/test/project', + isTerminal: false, + isBatchMode: false, + startTime: Date.now() - 30000, + isCueRun: true, + cueRunId: 'test-uuid', + cueSessionName: 'My Agent', + cueSubscriptionName: 'heartbeat-check', + cueEventType: 'time.heartbeat', + ...overrides, +}); + describe('ProcessMonitor', () => { let theme: Theme; let onClose: ReturnType; @@ -167,6 +188,12 @@ describe('ProcessMonitor', () => { // Reset existing kill mock vi.mocked(window.maestro.process.kill).mockReset().mockResolvedValue(undefined); + // Add cue.stopRun mock + if (!(window as any).maestro.cue) { + (window as any).maestro.cue = {}; + } + (window as any).maestro.cue.stopRun = vi.fn().mockResolvedValue(true); + // Mock scrollIntoView Element.prototype.scrollIntoView = vi.fn(); @@ -1716,4 +1743,152 @@ describe('ProcessMonitor', () => { }); }); }); + + describe('CUE RUNS section', () => { + it('renders CUE RUNS section when cue processes are active', async () => { + const cueProc = createCueProcess(); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('CUE RUNS')).toBeInTheDocument(); + }); + }); + + it('does not render CUE RUNS section when no cue processes', async () => { + const regularProc = createActiveProcess(); + const session = createSession(); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([regularProc] as any); + + render(); + + // Wait for async process list to load by confirming the tree rendered + await waitFor(() => { + expect(screen.getByText('UNGROUPED AGENTS')).toBeInTheDocument(); + }); + + // Only then assert CUE RUNS is absent + expect(screen.queryByText('CUE RUNS')).not.toBeInTheDocument(); + }); + + it('shows subscription name and session name in cue process label', async () => { + const cueProc = createCueProcess({ + cueSubscriptionName: 'daily-review', + cueSessionName: 'Code Agent', + }); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('daily-review → Code Agent')).toBeInTheDocument(); + }); + }); + + it('shows event type badge on cue process', async () => { + const cueProc = createCueProcess({ cueEventType: 'time.heartbeat' }); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('TIME HEARTBEAT')).toBeInTheDocument(); + }); + }); + + it('calls cue.stopRun for cue process kill instead of process.kill', async () => { + const cueProc = createCueProcess({ cueRunId: 'run-to-kill' }); + getActiveProcessesMock().mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('CUE RUNS')).toBeInTheDocument(); + }); + + // Click kill button on the cue process + const killButtons = screen.getAllByTitle('Kill process'); + expect(killButtons.length).toBeGreaterThanOrEqual(1); + fireEvent.click(killButtons[0]); + + // Confirm kill via "Kill Process" button + await waitFor(() => { + expect(screen.getByText('Kill Process?')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Kill Process')); + + await waitFor(() => { + expect((window as any).maestro.cue.stopRun).toHaveBeenCalledWith('run-to-kill'); + expect(killMock()).not.toHaveBeenCalled(); + }); + }); + + it('calls process.kill for regular process kill (not cue.stopRun)', async () => { + const regularProc = createActiveProcess({ sessionId: 'session-1-ai-tab-1' }); + const session = createSession(); + getActiveProcessesMock().mockResolvedValue([regularProc] as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('UNGROUPED AGENTS')).toBeInTheDocument(); + }); + + // Click kill button + const killButtons = screen.getAllByTitle('Kill process'); + expect(killButtons.length).toBeGreaterThanOrEqual(1); + fireEvent.click(killButtons[0]); + + // Confirm kill + await waitFor(() => { + expect(screen.getByText('Kill Process?')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Kill Process')); + + await waitFor(() => { + expect(killMock()).toHaveBeenCalledWith('session-1-ai-tab-1'); + expect((window as any).maestro.cue.stopRun).not.toHaveBeenCalled(); + }); + }); + + it('cue section coexists with other sections', async () => { + const session = createSession(); + const regularProc = createActiveProcess(); + const cueProc = createCueProcess(); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([ + regularProc, + cueProc, + ] as any); + + render(); + + await waitFor(() => { + // Both sections should exist + expect(screen.getByText('UNGROUPED AGENTS')).toBeInTheDocument(); + expect(screen.getByText('CUE RUNS')).toBeInTheDocument(); + }); + }); + + it('renders ⚡ emoji for cue section', async () => { + const cueProc = createCueProcess(); + getActiveProcessesMock().mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('⚡')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts index 94e32de022..dbe1911031 100644 --- a/src/main/cue/cue-engine.ts +++ b/src/main/cue/cue-engine.ts @@ -63,6 +63,10 @@ export interface CueEngineDeps { }) => Promise; onStopCueRun?: (runId: string) => boolean; onLog: (level: MainLogLevel, message: string, data?: unknown) => void; + /** Called to prevent system sleep (e.g., when Cue has active scheduled subscriptions or runs) */ + onPreventSleep?: (reason: string) => void; + /** Called to allow system sleep (e.g., when Cue scheduled subscriptions or runs end) */ + onAllowSleep?: (reason: string) => void; } /** Internal state per session with an active Cue config */ @@ -110,6 +114,8 @@ export class CueEngine { onRunStopped: (result) => { this.pushActivityLog(result); }, + onPreventSleep: deps.onPreventSleep, + onAllowSleep: deps.onAllowSleep, }); this.fanInTracker = createCueFanInTracker({ onLog: deps.onLog, @@ -663,6 +669,12 @@ export class CueEngine { } this.sessions.set(session.id, state); + + // Prevent system sleep if this session has time-based subscriptions + if (this.hasTimeBasedSubscriptions(config, session.id)) { + this.deps.onPreventSleep?.(`cue:schedule:${session.id}`); + } + this.deps.onLog( 'cue', `[CUE] Initialized session "${session.name}" with ${config.subscriptions.filter((s) => s.enabled !== false).length} active subscription(s)` @@ -673,10 +685,28 @@ export class CueEngine { this.activityLog.push(result); } + /** Check if a config has any enabled time-based subscriptions that will actually schedule timers */ + private hasTimeBasedSubscriptions(config: CueConfig, sessionId: string): boolean { + return config.subscriptions.some( + (sub) => + sub.enabled !== false && + (!sub.agent_id || sub.agent_id === sessionId) && + ((sub.event === 'time.heartbeat' && + typeof sub.interval_minutes === 'number' && + sub.interval_minutes > 0) || + (sub.event === 'time.scheduled' && + Array.isArray(sub.schedule_times) && + sub.schedule_times.length > 0)) + ); + } + private teardownSession(sessionId: string): void { const state = this.sessions.get(sessionId); if (!state) return; + // Release sleep prevention for this session's scheduled subscriptions + this.deps.onAllowSleep?.(`cue:schedule:${sessionId}`); + for (const timer of state.timers) { clearInterval(timer); } diff --git a/src/main/cue/cue-executor.ts b/src/main/cue/cue-executor.ts index cb2c1bbe9d..a56a5cc2b4 100644 --- a/src/main/cue/cue-executor.ts +++ b/src/main/cue/cue-executor.ts @@ -45,8 +45,42 @@ export interface CueExecutionConfig { agentConfigValues?: Record; } +/** Metadata stored alongside each active Cue process */ +interface CueActiveProcess { + child: ChildProcess; + command: string; + args: string[]; + cwd: string; + toolType: string; + startTime: number; +} + +/** Serializable process info for the Process Monitor */ +export interface CueProcessInfo { + runId: string; + pid: number; + command: string; + args: string[]; + cwd: string; + toolType: string; + startTime: number; +} + +const PROMPT_REDACTED = ''; + +/** + * Build a display-safe copy of spawn args by replacing the prompt payload + * with a fixed placeholder. The prompt is typically the last positional arg + * (after '--') or embedded via promptArgs; we identify it by matching the + * substitutedPrompt value. + */ +function buildDisplayArgs(args: string[], prompt: string): string[] { + if (!prompt) return args; + return args.map((arg) => (arg === prompt ? PROMPT_REDACTED : arg)); +} + /** Map of active Cue processes by runId */ -const activeProcesses = new Map(); +const activeProcesses = new Map(); /** * Extract clean human-readable text from agent stdout. @@ -313,7 +347,14 @@ export async function executeCuePrompt(config: CueExecutionConfig): Promise { - if (!child.killed) { - child.kill('SIGKILL'); + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill('SIGKILL'); } }, SIGKILL_DELAY_MS); @@ -431,10 +474,32 @@ export function stopCueRun(runId: string): boolean { /** * Get the map of currently active processes (for testing/monitoring). */ -export function getActiveProcesses(): Map { +export function getActiveProcesses(): Map { return activeProcesses; } +/** + * Get serializable info about active Cue processes (for Process Monitor). + * Filters out entries where the process PID is unavailable (spawn failure). + */ +export function getCueProcessList(): CueProcessInfo[] { + const result: CueProcessInfo[] = []; + for (const [runId, entry] of activeProcesses) { + if (entry.child.pid) { + result.push({ + runId, + pid: entry.child.pid, + command: entry.command, + args: entry.args, + cwd: entry.cwd, + toolType: entry.toolType, + startTime: entry.startTime, + }); + } + } + return result; +} + /** * Construct a HistoryEntry for a completed Cue run. * diff --git a/src/main/cue/cue-run-manager.ts b/src/main/cue/cue-run-manager.ts index 8f6c9918e7..780f5e1398 100644 --- a/src/main/cue/cue-run-manager.ts +++ b/src/main/cue/cue-run-manager.ts @@ -55,6 +55,10 @@ export interface CueRunManagerDeps { ) => void; /** Called when a run is manually stopped — pushes to activity log only (no chain propagation) */ onRunStopped: (result: CueRunResult) => void; + /** Called to prevent system sleep (e.g., when a Cue run starts) */ + onPreventSleep?: (reason: string) => void; + /** Called to allow system sleep (e.g., when a Cue run ends) */ + onAllowSleep?: (reason: string) => void; } export interface CueRunManager { @@ -159,6 +163,7 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { }; activeRuns.set(runId, { result, abortController }); + deps.onPreventSleep?.(`cue:run:${runId}`); const timeoutMs = (settings?.timeout_minutes ?? 30) * 60 * 1000; try { recordCueEvent({ @@ -271,6 +276,11 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { const wasManuallyStopped = manuallyStoppedRuns.has(runId); + // Only release sleep block here for non-stopped runs — stopRun already released eagerly + if (!wasManuallyStopped) { + deps.onAllowSleep?.(`cue:run:${runId}`); + } + // Only decrement here for non-stopped runs — stopRun already decremented eagerly if (!wasManuallyStopped) { const count = activeRunCount.get(sessionId) ?? 1; @@ -367,6 +377,7 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { run.result.durationMs = Date.now() - new Date(run.result.startedAt).getTime(); activeRuns.delete(runId); + deps.onAllowSleep?.(`cue:run:${runId}`); // Free the concurrency slot immediately so queued events can proceed. // The finally block in doExecuteCueRun skips its decrement for manually stopped runs. @@ -418,6 +429,9 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { }, reset(): void { + for (const runId of activeRuns.keys()) { + deps.onAllowSleep?.(`cue:run:${runId}`); + } activeRuns.clear(); activeRunCount.clear(); eventQueue.clear(); diff --git a/src/main/index.ts b/src/main/index.ts index 968e841f75..995fc55c55 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,7 +9,12 @@ import { ProcessManager } from './process-manager'; import { WebServer } from './web-server'; import { AgentDetector } from './agents'; import { CueEngine } from './cue/cue-engine'; -import { executeCuePrompt, recordCueHistoryEntry, stopCueRun } from './cue/cue-executor'; +import { + executeCuePrompt, + recordCueHistoryEntry, + stopCueRun, + getCueProcessList, +} from './cue/cue-executor'; import { logger } from './utils/logger'; import { tunnelManager } from './tunnel-manager'; import { powerManager } from './power-manager'; @@ -462,6 +467,8 @@ app.whenReady().then(async () => { mainWindow.webContents.send('cue:activityUpdate', data); } }, + onPreventSleep: (reason) => powerManager.addBlockReason(reason), + onAllowSleep: (reason) => powerManager.removeBlockReason(reason), }); logger.info('Core services initialized', 'Startup'); @@ -673,6 +680,24 @@ function setupIpcHandlers() { settingsStore: store, getMainWindow: () => mainWindow, sessionsStore, + getCueProcesses: () => { + // Always query the executor's active process map — processes may still be + // running even if the engine has been disabled (in-flight runs complete + // independently of engine state). + const processList = getCueProcessList(); + if (processList.length === 0) return []; + const activeRuns = cueEngine?.getActiveRuns() ?? []; + // Merge PID/command data from executor with metadata from run manager + return processList.map((proc) => { + const run = activeRuns.find((r) => r.runId === proc.runId); + return { + ...proc, + sessionName: run?.sessionName ?? '', + subscriptionName: run?.subscriptionName ?? '', + eventType: run?.event.type ?? '', + }; + }); + }, }); // Persistence operations - extracted to src/main/ipc/handlers/persistence.ts diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index dc63fc4a4d..a1d56f889f 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -51,6 +51,20 @@ interface AgentConfigsData { /** * Dependencies required for process handler registration */ +/** Cue process info returned by the getCueProcesses callback */ +export interface CueProcessEntry { + runId: string; + pid: number; + command: string; + args: string[]; + cwd: string; + toolType: string; + startTime: number; + sessionName: string; + subscriptionName: string; + eventType: string; +} + export interface ProcessHandlerDependencies { getProcessManager: () => ProcessManager | null; getAgentDetector: () => AgentDetector | null; @@ -58,6 +72,8 @@ export interface ProcessHandlerDependencies { settingsStore: Store; getMainWindow: () => BrowserWindow | null; sessionsStore: Store<{ sessions: any[] }>; + /** Optional callback to get active Cue run processes for Process Monitor */ + getCueProcesses?: () => CueProcessEntry[]; } /** @@ -618,14 +634,14 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void ) ); - // Get all active processes managed by the ProcessManager + // Get all active processes managed by the ProcessManager (and Cue runs if available) ipcMain.handle( 'process:getActiveProcesses', withIpcErrorLogging(handlerOpts('getActiveProcesses'), async () => { const processManager = requireProcessManager(getProcessManager); const processes = processManager.getAll(); // Return serializable process info (exclude non-serializable PTY/child process objects) - return processes.map((p) => ({ + const result: Array> = processes.map((p) => ({ sessionId: p.sessionId, toolType: p.toolType, pid: p.pid, @@ -636,6 +652,29 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void command: p.command, args: p.args, })); + + // Append active Cue run processes if available + const cueProcesses = deps.getCueProcesses?.() ?? []; + for (const cue of cueProcesses) { + result.push({ + sessionId: `cue-run-${cue.runId}`, + toolType: cue.toolType, + pid: cue.pid, + cwd: cue.cwd, + isTerminal: false, + isBatchMode: false, + startTime: cue.startTime, + command: cue.command, + args: cue.args, + isCueRun: true, + cueRunId: cue.runId, + cueSessionName: cue.sessionName, + cueSubscriptionName: cue.subscriptionName, + cueEventType: cue.eventType, + }); + } + + return result; }) ); diff --git a/src/main/power-manager.ts b/src/main/power-manager.ts index d46fd93999..981c36f390 100644 --- a/src/main/power-manager.ts +++ b/src/main/power-manager.ts @@ -39,6 +39,8 @@ export interface PowerStatus { * Reasons follow a naming convention: * - "session:{sessionId}" - AI session is busy * - "autorun:{identifier}" - Auto Run is active + * - "cue:schedule:{sessionId}" - Cue session has active heartbeat/scheduled subscriptions + * - "cue:run:{runId}" - Cue run is executing */ class PowerManager { /** ID of the active powerSaveBlocker, or null if not blocking */ diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts index 246bc68c37..99ba1312ba 100644 --- a/src/main/preload/process.ts +++ b/src/main/preload/process.ts @@ -78,6 +78,16 @@ export interface ActiveProcess { startTime: number; command?: string; args?: string[]; + /** True if this is a Cue automation run process */ + isCueRun?: boolean; + /** The Cue run ID (for stopping via cue:stopRun) */ + cueRunId?: string; + /** Target session name for this Cue run */ + cueSessionName?: string; + /** Subscription name that triggered this Cue run */ + cueSubscriptionName?: string; + /** Event type that triggered this Cue run */ + cueEventType?: string; } /** diff --git a/src/renderer/components/CueModal/CueModal.tsx b/src/renderer/components/CueModal/CueModal.tsx index fb455a49ca..6f5bde6f11 100644 --- a/src/renderer/components/CueModal/CueModal.tsx +++ b/src/renderer/components/CueModal/CueModal.tsx @@ -495,6 +495,7 @@ export function CueModal({ theme, onClose, cueShortcutKeys }: CueModalProps) { onDirtyChange={setPipelineDirty} theme={theme} activeRuns={activeRuns} + onTriggerPipeline={triggerSubscription} /> )} diff --git a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx index 383214bec9..84c09efb4c 100644 --- a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx +++ b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx @@ -46,6 +46,8 @@ export interface CuePipelineEditorProps { onDirtyChange?: (isDirty: boolean) => void; theme: Theme; activeRuns?: ActiveRunInfo[]; + /** Callback to manually trigger a pipeline by name */ + onTriggerPipeline?: (pipelineName: string) => void; } /** Bridges the circular dependency between usePipelineState and usePipelineSelection. */ @@ -66,6 +68,7 @@ function CuePipelineEditorInner({ onDirtyChange, theme, activeRuns: activeRunsProp, + onTriggerPipeline, }: CuePipelineEditorProps) { const reactFlowInstance = useReactFlow(); @@ -162,9 +165,21 @@ function CuePipelineEditorInner({ convertToReactFlowNodes( pipelineState.pipelines, pipelineState.selectedPipelineId, - handleConfigureNode + handleConfigureNode, + { + onTriggerPipeline, + isSaved: !isDirty, + runningPipelineIds, + } ), - [pipelineState.pipelines, pipelineState.selectedPipelineId, handleConfigureNode] + [ + pipelineState.pipelines, + pipelineState.selectedPipelineId, + handleConfigureNode, + onTriggerPipeline, + isDirty, + runningPipelineIds, + ] ); const edges = useMemo( @@ -584,6 +599,9 @@ function CuePipelineEditorInner({ selectedEdgePipelineColor={selectedEdgePipelineColor} onUpdateEdge={onUpdateEdge} onDeleteEdge={onDeleteEdge} + onTriggerPipeline={onTriggerPipeline} + isDirty={isDirty} + runningPipelineIds={runningPipelineIds} /> {contextMenu && ( diff --git a/src/renderer/components/CuePipelineEditor/PipelineCanvas.tsx b/src/renderer/components/CuePipelineEditor/PipelineCanvas.tsx index a2f4dd4332..f6769c8b6b 100644 --- a/src/renderer/components/CuePipelineEditor/PipelineCanvas.tsx +++ b/src/renderer/components/CuePipelineEditor/PipelineCanvas.tsx @@ -97,6 +97,12 @@ export interface PipelineCanvasProps { selectedEdgePipelineColor: string; onUpdateEdge: (edgeId: string, updates: Partial) => void; onDeleteEdge: (edgeId: string) => void; + /** Callback to manually trigger a pipeline by name */ + onTriggerPipeline?: (pipelineName: string) => void; + /** Whether the pipeline config has unsaved changes */ + isDirty?: boolean; + /** Set of pipeline IDs that are currently running */ + runningPipelineIds?: Set; } export const PipelineCanvas = React.memo(function PipelineCanvas({ @@ -146,6 +152,9 @@ export const PipelineCanvas = React.memo(function PipelineCanvas({ selectedEdgePipelineColor, onUpdateEdge, onDeleteEdge, + onTriggerPipeline, + isDirty, + runningPipelineIds, }: PipelineCanvasProps) { return (
@@ -353,21 +362,32 @@ export const PipelineCanvas = React.memo(function PipelineCanvas({ )} {/* Config panels */} - {selectedNode && !selectedEdge && ( - - )} + {selectedNode && + !selectedEdge && + (() => { + const selectedPipeline = pipelines.find((pl) => + pl.nodes.some((n) => n.id === selectedNode.id) + ); + return ( + + ); + })()} {selectedEdge && !selectedNode && ( void; + /** Callback to manually trigger this pipeline */ + onTriggerPipeline?: (pipelineName: string) => void; + /** Pipeline name for triggering */ + pipelineName?: string; + /** Whether the pipeline config is saved (play only works when saved) */ + isSaved?: boolean; + /** Whether this pipeline is currently running */ + isRunning?: boolean; } export const TriggerNode = memo(function TriggerNode({ @@ -123,29 +131,78 @@ export const TriggerNode = memo(function TriggerNode({ )}
- {/* Gear icon - placed before connector to avoid overlap */} + {/* Action icons - placed before connector to avoid overlap */}
{ - e.stopPropagation(); - data.onConfigure?.(data.compositeId); - }} style={{ display: 'flex', alignItems: 'center', - justifyContent: 'center', - cursor: 'pointer', - color: selected ? color : `${color}60`, flexShrink: 0, - padding: '4px 4px', marginRight: 14, - borderRadius: 4, - transition: 'color 0.15s', + gap: 2, }} - onMouseEnter={(e) => (e.currentTarget.style.color = color)} - onMouseLeave={(e) => (e.currentTarget.style.color = selected ? color : `${color}60`)} - title="Configure" > - + {/* Play button — only when pipeline is saved */} + {data.isSaved && data.onTriggerPipeline && data.pipelineName && ( + + )} + + {/* Gear icon */} +
void; triggerDrawerOpen?: boolean; agentDrawerOpen?: boolean; + /** Callback to manually trigger the pipeline this trigger belongs to */ + onTriggerPipeline?: (pipelineName: string) => void; + /** Pipeline name for the selected trigger's pipeline */ + pipelineName?: string; + /** Whether the pipeline config is saved */ + isSaved?: boolean; + /** Whether this pipeline is currently running */ + isRunning?: boolean; } export function NodeConfigPanel({ @@ -48,6 +56,10 @@ export function NodeConfigPanel({ onSwitchToAgent, triggerDrawerOpen, agentDrawerOpen, + onTriggerPipeline, + pipelineName, + isSaved, + isRunning, }: NodeConfigPanelProps) { const [expanded, setExpanded] = useState(false); const isVisible = selectedNode !== null; @@ -142,6 +154,31 @@ export function NodeConfigPanel({ )}
+ {isTrigger && isSaved && onTriggerPipeline && pipelineName && ( + + )} {!isTrigger && ( @@ -965,6 +1031,8 @@ export function ProcessMonitor(props: ProcessMonitorProps) { node.processType === 'moderator' || node.processType === 'participant'; // Determine if this is a wizard process const isWizardProcess = node.processType === 'wizard' || node.processType === 'wizard-gen'; + // Determine if this is a Cue run process + const isCueProcess = node.processType === 'cue'; return (
)} - {/* Jump to agent button */} - {node.sessionId && onNavigateToSession && !isGroupChatProcess && !isWizardProcess && ( - + {node.cueEventType?.replace('.', ' ').toUpperCase() ?? 'CUE'} + )} + {/* Jump to agent button */} + {node.sessionId && + onNavigateToSession && + !isGroupChatProcess && + !isWizardProcess && + !isCueProcess && ( + + )} {/* Jump to group chat button */} {isGroupChatProcess && node.groupChatId && onNavigateToGroupChat && (
)} + + {/* Cue-specific detail fields */} + {detailView.cueSubscriptionName && ( +
+
+ + + Cue Subscription + +
+ + {detailView.cueSubscriptionName} + +
+ )} + + {detailView.cueEventType && ( +
+
+ + + Event Type + +
+ + {detailView.cueEventType} + +
+ )} + + {detailView.cueSessionName && ( +
+
+ + + Target Session + +
+ + {detailView.cueSessionName} + +
+ )}
{/* Working Directory */} @@ -1693,7 +1830,7 @@ export function ProcessMonitor(props: ProcessMonitorProps) { onKeyDown={(e) => { if (e.key === 'Enter' && !isKilling) { e.preventDefault(); - killProcess(killConfirmProcessId); + killProcess(killConfirmProcessId, killConfirmCueRunId); } else if (e.key === 'Escape') { e.preventDefault(); setKillConfirmProcessId(null); @@ -1716,7 +1853,7 @@ export function ProcessMonitor(props: ProcessMonitorProps) { Cancel