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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/__tests__/main/cue/cue-concurrency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ vi.mock('../../../main/cue/cue-file-watcher', () => ({
createCueFileWatcher: (...args: unknown[]) => mockCreateCueFileWatcher(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)}`),
Expand Down
74 changes: 74 additions & 0 deletions src/__tests__/main/cue/cue-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ vi.mock('../../../main/cue/cue-task-scanner', () => ({
createCueTaskScanner: (...args: unknown[]) => mockCreateCueTaskScanner(args[0]),
}));

// Mock the database
const mockInitCueDb = vi.fn();
const mockCloseCueDb = vi.fn();
const mockPruneCueEvents = vi.fn();
vi.mock('../../../main/cue/cue-db', () => ({
initCueDb: (...args: unknown[]) => mockInitCueDb(...args),
closeCueDb: () => mockCloseCueDb(),
pruneCueEvents: (...args: unknown[]) => mockPruneCueEvents(...args),
isCueDbReady: () => true,
recordCueEvent: vi.fn(),
updateCueEventStatus: vi.fn(),
}));

// Mock crypto
vi.mock('crypto', () => ({
randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`),
Expand Down Expand Up @@ -113,6 +126,67 @@ describe('CueEngine', () => {
expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('started'));
expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('stopped'));
});

it('does not enable when initCueDb throws', () => {
mockInitCueDb.mockImplementation(() => {
throw new Error('DB corrupted');
});
const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();
expect(engine.isEnabled()).toBe(false);
});

it('logs error when initCueDb throws', () => {
mockInitCueDb.mockImplementation(() => {
throw new Error('DB corrupted');
});
const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();
expect(deps.onLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('Failed to initialize Cue database')
);
});

it('does not initialize sessions when initCueDb throws', () => {
mockInitCueDb.mockImplementation(() => {
throw new Error('DB corrupted');
});
const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();
expect(mockLoadCueConfig).not.toHaveBeenCalled();
});

it('does not start heartbeat when initCueDb throws', () => {
mockInitCueDb.mockImplementation(() => {
throw new Error('DB corrupted');
});
const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();
// Engine is not enabled, so getStatus should return empty
expect(engine.getStatus()).toEqual([]);
});

it('can retry start after DB failure', () => {
mockInitCueDb
.mockImplementationOnce(() => {
throw new Error('DB corrupted');
})
.mockImplementation(() => {});
mockLoadCueConfig.mockReturnValue(null);
const deps = createMockDeps();
const engine = new CueEngine(deps);

engine.start();
expect(engine.isEnabled()).toBe(false);

engine.start();
expect(engine.isEnabled()).toBe(true);
});
});

describe('session initialization', () => {
Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/main/cue/cue-event-factory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { createCueEvent } from '../../../main/cue/cue-types';

describe('createCueEvent', () => {
it('returns an object with all 5 CueEvent fields', () => {
const event = createCueEvent('time.heartbeat', 'my-trigger', { foo: 'bar' });
expect(event).toHaveProperty('id');
expect(event).toHaveProperty('type');
expect(event).toHaveProperty('timestamp');
expect(event).toHaveProperty('triggerName');
expect(event).toHaveProperty('payload');
});

it('generates a valid UUID for id', () => {
const event = createCueEvent('file.changed', 'watcher');
expect(event.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});

it('generates a valid ISO timestamp', () => {
const event = createCueEvent('time.scheduled', 'daily-check');
const parsed = new Date(event.timestamp).getTime();
expect(Number.isNaN(parsed)).toBe(false);
expect(event.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});

it('sets type to the provided event type', () => {
const event = createCueEvent('github.pull_request', 'pr-watcher');
expect(event.type).toBe('github.pull_request');
});

it('sets triggerName to the provided value', () => {
const event = createCueEvent('task.pending', 'task-scanner');
expect(event.triggerName).toBe('task-scanner');
});

it('defaults payload to empty object when omitted', () => {
const event = createCueEvent('time.heartbeat', 'heartbeat');
expect(event.payload).toEqual({});
});

it('includes provided payload values', () => {
const payload = { interval_minutes: 30, reconciled: true };
const event = createCueEvent('time.heartbeat', 'heartbeat', payload);
expect(event.payload).toEqual(payload);
});

it('generates a unique id on each call', () => {
const event1 = createCueEvent('time.heartbeat', 'a');
const event2 = createCueEvent('time.heartbeat', 'a');
expect(event1.id).not.toBe(event2.id);
});
});
9 changes: 5 additions & 4 deletions src/__tests__/main/cue/cue-sleep-wake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ describe('CueEngine sleep/wake detection', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Reset mockInitCueDb to a no-op (clearAllMocks doesn't reset mockImplementation)
mockInitCueDb.mockReset();
mockWatchCueYaml.mockReturnValue(vi.fn());
mockLoadCueConfig.mockReturnValue(createMockConfig());
mockGetLastHeartbeat.mockReturnValue(null);
Expand Down Expand Up @@ -229,13 +231,12 @@ describe('CueEngine sleep/wake detection', () => {
// Should not throw
expect(() => engine.start()).not.toThrow();

// Should log the warning
// Should log the error and not enable the engine
expect(deps.onLog).toHaveBeenCalledWith(
'warn',
'error',
expect.stringContaining('Failed to initialize Cue database')
);

engine.stop();
expect(engine.isEnabled()).toBe(false);
});

it('should handle heartbeat read failure gracefully during sleep detection', () => {
Expand Down
38 changes: 38 additions & 0 deletions src/__tests__/renderer/hooks/useCue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,49 @@ describe('useCue', () => {
});
});

describe('error state', () => {
it('error is null on successful refresh', async () => {
const { result } = await renderAndSettle();
expect(result.current.error).toBeNull();
});

it('error is set when refresh fails', async () => {
mockGetStatus.mockRejectedValue(new Error('Network error'));
const { result } = await renderAndSettle();
expect(result.current.error).toBe('Network error');
});

it('error clears on successful retry', async () => {
mockGetStatus.mockRejectedValue(new Error('Network error'));
const { result } = await renderAndSettle();
expect(result.current.error).toBe('Network error');

mockGetStatus.mockResolvedValue([]);
await act(async () => {
await result.current.refresh();
});
expect(result.current.error).toBeNull();
});

it('error captures message from Error objects', async () => {
mockGetStatus.mockRejectedValue(new Error('IPC channel closed'));
const { result } = await renderAndSettle();
expect(result.current.error).toBe('IPC channel closed');
});

it('error uses fallback for non-Error rejections', async () => {
mockGetStatus.mockRejectedValue('string rejection');
const { result } = await renderAndSettle();
expect(result.current.error).toBe('Failed to fetch Cue status');
});
});

describe('return value shape', () => {
it('should return all expected properties', async () => {
const { result } = await renderAndSettle();

expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
expect(Array.isArray(result.current.sessions)).toBe(true);
expect(Array.isArray(result.current.activeRuns)).toBe(true);
expect(Array.isArray(result.current.activityLog)).toBe(true);
Expand Down
73 changes: 35 additions & 38 deletions src/main/cue/cue-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@
import * as crypto from 'crypto';
import type { MainLogLevel } from '../../shared/logger-types';
import type { SessionInfo } from '../../shared/types';
import type {
AgentCompletionData,
CueConfig,
CueEvent,
CueGraphSession,
CueRunResult,
CueSessionStatus,
CueSettings,
CueSubscription,
import {
createCueEvent,
DEFAULT_CUE_SETTINGS,
type AgentCompletionData,
type CueConfig,
type CueEvent,
type CueGraphSession,
type CueRunResult,
type CueSessionStatus,
type CueSettings,
type CueSubscription,
} from './cue-types';
import { DEFAULT_CUE_SETTINGS } from './cue-types';
import { captureException } from '../utils/sentry';
import { loadCueConfig, watchCueYaml } from './cue-yaml-loader';
import { matchesFilter, describeFilter } from './cue-filter';
import {
Expand Down Expand Up @@ -140,17 +142,24 @@ export class CueEngine {
start(): void {
if (this.enabled) return;

this.enabled = true;
this.deps.onLog('cue', '[CUE] Engine started');

// Initialize Cue database and prune old events
// Initialize Cue database and prune old events — bail if this fails
try {
initCueDb((level, msg) => this.deps.onLog(level as MainLogLevel, msg));
pruneCueEvents(EVENT_PRUNE_AGE_MS);
} catch (error) {
this.deps.onLog('warn', `[CUE] Failed to initialize Cue database: ${error}`);
this.deps.onLog(
'error',
`[CUE] Failed to initialize Cue database — engine will not start: ${error}`
);
captureException(error instanceof Error ? error : new Error(String(error)), {
extra: { operation: 'cue.dbInit' },
});
return;
}

this.enabled = true;
this.deps.onLog('cue', '[CUE] Engine started');

const sessions = this.deps.getSessions();
for (const session of sessions) {
this.initSession(session);
Expand Down Expand Up @@ -402,13 +411,7 @@ export class CueEngine {
if (sub.name !== subscriptionName) continue;
if (sub.agent_id && sub.agent_id !== sessionId) continue;

const event: CueEvent = {
id: crypto.randomUUID(),
type: sub.event,
timestamp: new Date().toISOString(),
triggerName: sub.name,
payload: { manual: true },
};
const event = createCueEvent(sub.event, sub.name, { manual: true });

this.deps.onLog('cue', `[CUE] "${sub.name}" manually triggered`);
state.lastTriggered = event.timestamp;
Expand Down Expand Up @@ -491,22 +494,16 @@ export class CueEngine {

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: {
sourceSession: completingName,
sourceSessionId: sessionId,
status: completionData?.status ?? 'completed',
exitCode: completionData?.exitCode ?? null,
durationMs: completionData?.durationMs ?? 0,
sourceOutput: (completionData?.stdout ?? '').slice(-SOURCE_OUTPUT_MAX_CHARS),
outputTruncated: (completionData?.stdout ?? '').length > SOURCE_OUTPUT_MAX_CHARS,
triggeredBy: completionData?.triggeredBy,
},
};
const event = createCueEvent('agent.completed', sub.name, {
sourceSession: completingName,
sourceSessionId: sessionId,
status: completionData?.status ?? 'completed',
exitCode: completionData?.exitCode ?? null,
durationMs: completionData?.durationMs ?? 0,
sourceOutput: (completionData?.stdout ?? '').slice(-SOURCE_OUTPUT_MAX_CHARS),
outputTruncated: (completionData?.stdout ?? '').length > SOURCE_OUTPUT_MAX_CHARS,
triggeredBy: completionData?.triggeredBy,
});

// Check payload filter
if (sub.filter && !matchesFilter(event.payload, sub.filter)) {
Expand Down
Loading