Skip to content

Commit bd9ba47

Browse files
Merge pull request #582 from RunMaestro/cue-polish
refactor(cue): decompose cue-engine.ts into 5 focused modules + minor fixes hardening the overall Cue functionality
2 parents 7e44202 + d85290c commit bd9ba47

20 files changed

Lines changed: 1545 additions & 2452 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
pull_request:
55
branches:
66
- main
7+
- rc
78
- '*-RC'
89
push:
910
branches: [main]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Tests for the Cue activity log ring buffer.
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { createCueActivityLog } from '../../../main/cue/cue-activity-log';
7+
import type { CueRunResult } from '../../../main/cue/cue-types';
8+
9+
function makeResult(id: string): CueRunResult {
10+
return {
11+
runId: id,
12+
sessionId: 'session-1',
13+
sessionName: 'Test',
14+
subscriptionName: 'sub',
15+
event: { id: 'e1', type: 'time.heartbeat', timestamp: '', triggerName: 'sub', payload: {} },
16+
status: 'completed',
17+
stdout: '',
18+
stderr: '',
19+
exitCode: 0,
20+
durationMs: 100,
21+
startedAt: '',
22+
endedAt: '',
23+
};
24+
}
25+
26+
describe('createCueActivityLog', () => {
27+
it('stores and retrieves results', () => {
28+
const log = createCueActivityLog();
29+
log.push(makeResult('r1'));
30+
log.push(makeResult('r2'));
31+
expect(log.getAll()).toHaveLength(2);
32+
expect(log.getAll()[0].runId).toBe('r1');
33+
});
34+
35+
it('respects limit parameter on getAll', () => {
36+
const log = createCueActivityLog();
37+
log.push(makeResult('r1'));
38+
log.push(makeResult('r2'));
39+
log.push(makeResult('r3'));
40+
const last2 = log.getAll(2);
41+
expect(last2).toHaveLength(2);
42+
expect(last2[0].runId).toBe('r2');
43+
expect(last2[1].runId).toBe('r3');
44+
});
45+
46+
it('evicts oldest entries when exceeding maxSize', () => {
47+
const log = createCueActivityLog(3);
48+
log.push(makeResult('r1'));
49+
log.push(makeResult('r2'));
50+
log.push(makeResult('r3'));
51+
log.push(makeResult('r4'));
52+
const all = log.getAll();
53+
expect(all).toHaveLength(3);
54+
expect(all[0].runId).toBe('r2');
55+
expect(all[2].runId).toBe('r4');
56+
});
57+
58+
it('clear empties the log', () => {
59+
const log = createCueActivityLog();
60+
log.push(makeResult('r1'));
61+
log.clear();
62+
expect(log.getAll()).toHaveLength(0);
63+
});
64+
65+
it('returns a copy from getAll, not a reference', () => {
66+
const log = createCueActivityLog();
67+
log.push(makeResult('r1'));
68+
const snapshot = log.getAll();
69+
log.push(makeResult('r2'));
70+
expect(snapshot).toHaveLength(1);
71+
});
72+
});

src/__tests__/main/cue/cue-completion-chains.test.ts

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1616
import type { CueConfig, CueEvent } from '../../../main/cue/cue-types';
17-
import type { SessionInfo } from '../../../shared/types';
1817

1918
// Mock the yaml loader
2019
const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>();
@@ -52,47 +51,7 @@ vi.mock('crypto', () => ({
5251
}));
5352

5453
import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine';
55-
56-
function createMockSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
57-
return {
58-
id: 'session-1',
59-
name: 'Test Session',
60-
toolType: 'claude-code',
61-
cwd: '/projects/test',
62-
projectRoot: '/projects/test',
63-
...overrides,
64-
};
65-
}
66-
67-
function createMockConfig(overrides: Partial<CueConfig> = {}): CueConfig {
68-
return {
69-
subscriptions: [],
70-
settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 },
71-
...overrides,
72-
};
73-
}
74-
75-
function createMockDeps(overrides: Partial<CueEngineDeps> = {}): CueEngineDeps {
76-
return {
77-
getSessions: vi.fn(() => [createMockSession()]),
78-
onCueRun: vi.fn(async () => ({
79-
runId: 'run-1',
80-
sessionId: 'session-1',
81-
sessionName: 'Test Session',
82-
subscriptionName: 'test',
83-
event: {} as CueEvent,
84-
status: 'completed' as const,
85-
stdout: 'output',
86-
stderr: '',
87-
exitCode: 0,
88-
durationMs: 100,
89-
startedAt: new Date().toISOString(),
90-
endedAt: new Date().toISOString(),
91-
})) as CueEngineDeps['onCueRun'],
92-
onLog: vi.fn(),
93-
...overrides,
94-
};
95-
}
54+
import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers';
9655

9756
describe('CueEngine completion chains', () => {
9857
beforeEach(() => {

src/__tests__/main/cue/cue-concurrency.test.ts

Lines changed: 48 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1515
import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types';
16-
import type { SessionInfo } from '../../../shared/types';
1716

1817
// Mock the yaml loader
1918
const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>();
@@ -35,52 +34,7 @@ vi.mock('crypto', () => ({
3534
}));
3635

3736
import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine';
38-
39-
function createMockSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
40-
return {
41-
id: 'session-1',
42-
name: 'Test Session',
43-
toolType: 'claude-code',
44-
cwd: '/projects/test',
45-
projectRoot: '/projects/test',
46-
...overrides,
47-
};
48-
}
49-
50-
function createMockConfig(overrides: Partial<CueConfig> = {}): CueConfig {
51-
return {
52-
subscriptions: [],
53-
settings: {
54-
timeout_minutes: 30,
55-
timeout_on_fail: 'break',
56-
max_concurrent: 1,
57-
queue_size: 10,
58-
},
59-
...overrides,
60-
};
61-
}
62-
63-
function createMockDeps(overrides: Partial<CueEngineDeps> = {}): CueEngineDeps {
64-
return {
65-
getSessions: vi.fn(() => [createMockSession()]),
66-
onCueRun: vi.fn(async () => ({
67-
runId: 'run-1',
68-
sessionId: 'session-1',
69-
sessionName: 'Test Session',
70-
subscriptionName: 'test',
71-
event: {} as CueEvent,
72-
status: 'completed' as const,
73-
stdout: 'output',
74-
stderr: '',
75-
exitCode: 0,
76-
durationMs: 100,
77-
startedAt: new Date().toISOString(),
78-
endedAt: new Date().toISOString(),
79-
})),
80-
onLog: vi.fn(),
81-
...overrides,
82-
};
83-
}
37+
import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers';
8438

8539
describe('CueEngine Concurrency Control', () => {
8640
let yamlWatcherCleanup: ReturnType<typeof vi.fn>;
@@ -503,6 +457,53 @@ describe('CueEngine Concurrency Control', () => {
503457
});
504458
});
505459

460+
describe('stopRun concurrency slot release', () => {
461+
it('stopRun frees the concurrency slot so queued events dispatch immediately', async () => {
462+
const deps = createMockDeps({
463+
onCueRun: vi.fn(() => new Promise<CueRunResult>(() => {})), // Never resolves
464+
});
465+
const config = createMockConfig({
466+
settings: {
467+
timeout_minutes: 30,
468+
timeout_on_fail: 'break',
469+
max_concurrent: 1,
470+
queue_size: 10,
471+
},
472+
subscriptions: [
473+
{
474+
name: 'timer',
475+
event: 'time.heartbeat',
476+
enabled: true,
477+
prompt: 'test',
478+
interval_minutes: 1,
479+
},
480+
],
481+
});
482+
mockLoadCueConfig.mockReturnValue(config);
483+
const engine = new CueEngine(deps);
484+
engine.start();
485+
486+
// First run starts immediately
487+
await vi.advanceTimersByTimeAsync(10);
488+
expect(engine.getActiveRuns()).toHaveLength(1);
489+
490+
// Second event gets queued (max_concurrent = 1)
491+
vi.advanceTimersByTime(1 * 60 * 1000);
492+
expect(engine.getQueueStatus().get('session-1')).toBe(1);
493+
494+
// Stop the active run — should free the slot and drain the queue
495+
const activeRun = engine.getActiveRuns()[0];
496+
engine.stopRun(activeRun.runId);
497+
498+
// The queued event should have been dispatched (onCueRun called again)
499+
expect(deps.onCueRun).toHaveBeenCalledTimes(2);
500+
expect(engine.getQueueStatus().size).toBe(0);
501+
502+
engine.stopAll();
503+
engine.stop();
504+
});
505+
});
506+
506507
describe('clearQueue', () => {
507508
it('clears queued events for a specific session', async () => {
508509
const deps = createMockDeps({

src/__tests__/main/cue/cue-engine.test.ts

Lines changed: 75 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1717
import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types';
18-
import type { SessionInfo } from '../../../shared/types';
1918

2019
// Mock the yaml loader
2120
const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>();
@@ -53,48 +52,7 @@ import {
5352
calculateNextScheduledTime,
5453
type CueEngineDeps,
5554
} from '../../../main/cue/cue-engine';
56-
57-
function createMockSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
58-
return {
59-
id: 'session-1',
60-
name: 'Test Session',
61-
toolType: 'claude-code',
62-
cwd: '/projects/test',
63-
projectRoot: '/projects/test',
64-
...overrides,
65-
};
66-
}
67-
68-
function createMockConfig(overrides: Partial<CueConfig> = {}): CueConfig {
69-
return {
70-
subscriptions: [],
71-
settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 },
72-
...overrides,
73-
};
74-
}
75-
76-
function createMockDeps(overrides: Partial<CueEngineDeps> = {}): CueEngineDeps {
77-
return {
78-
getSessions: vi.fn(() => [createMockSession()]),
79-
onCueRun: vi.fn(async (request: Parameters<CueEngineDeps['onCueRun']>[0]) => ({
80-
runId: 'run-1',
81-
sessionId: 'session-1',
82-
sessionName: 'Test Session',
83-
subscriptionName: request.subscriptionName,
84-
event: request.event,
85-
status: 'completed' as const,
86-
stdout: 'output',
87-
stderr: '',
88-
exitCode: 0,
89-
durationMs: 100,
90-
startedAt: new Date().toISOString(),
91-
endedAt: new Date().toISOString(),
92-
})),
93-
onStopCueRun: vi.fn(() => true),
94-
onLog: vi.fn(),
95-
...overrides,
96-
};
97-
}
55+
import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers';
9856

9957
describe('CueEngine', () => {
10058
let yamlWatcherCleanup: ReturnType<typeof vi.fn>;
@@ -930,6 +888,39 @@ describe('CueEngine', () => {
930888
engine.stop();
931889
});
932890

891+
it('stopRun adds the stopped run to the activity log', async () => {
892+
const deps = createMockDeps({
893+
onCueRun: vi.fn(() => new Promise<CueRunResult>(() => {})),
894+
});
895+
const config = createMockConfig({
896+
subscriptions: [
897+
{
898+
name: 'timer',
899+
event: 'time.heartbeat',
900+
enabled: true,
901+
prompt: 'test',
902+
interval_minutes: 60,
903+
},
904+
],
905+
});
906+
mockLoadCueConfig.mockReturnValue(config);
907+
const engine = new CueEngine(deps);
908+
engine.start();
909+
910+
await vi.advanceTimersByTimeAsync(10);
911+
912+
const activeRun = engine.getActiveRuns()[0];
913+
expect(activeRun).toBeDefined();
914+
engine.stopRun(activeRun.runId);
915+
916+
const log = engine.getActivityLog();
917+
expect(log).toHaveLength(1);
918+
expect(log[0].runId).toBe(activeRun.runId);
919+
expect(log[0].status).toBe('stopped');
920+
921+
engine.stop();
922+
});
923+
933924
it('stopAll clears all active runs', async () => {
934925
// Use a slow-resolving onCueRun to keep runs active
935926
const deps = createMockDeps({
@@ -1897,6 +1888,47 @@ describe('CueEngine', () => {
18971888
engine.stop();
18981889
});
18991890

1891+
it('refreshes nextTriggers after time.scheduled fires', async () => {
1892+
// Monday 2026-03-09 at 08:59 — next trigger should be 09:00 today
1893+
vi.setSystemTime(new Date('2026-03-09T08:59:00'));
1894+
1895+
const config = createMockConfig({
1896+
subscriptions: [
1897+
{
1898+
name: 'refresh-schedule',
1899+
event: 'time.scheduled',
1900+
enabled: true,
1901+
prompt: 'check',
1902+
schedule_times: ['09:00'],
1903+
},
1904+
],
1905+
});
1906+
mockLoadCueConfig.mockReturnValue(config);
1907+
const deps = createMockDeps();
1908+
const engine = new CueEngine(deps);
1909+
engine.start();
1910+
1911+
const statusBefore = engine.getStatus();
1912+
const subBefore = statusBefore.find((s) => s.sessionId === 'session-1');
1913+
const nextBefore = subBefore!.nextTrigger!;
1914+
// nextTrigger should be pointing at 09:00 today (ISO string)
1915+
const nextBeforeDate = new Date(nextBefore);
1916+
expect(nextBeforeDate.getHours()).toBe(9);
1917+
expect(nextBeforeDate.getMinutes()).toBe(0);
1918+
1919+
// Advance to 09:00 — the subscription fires
1920+
vi.advanceTimersByTime(60_000);
1921+
await vi.advanceTimersByTimeAsync(10);
1922+
1923+
// After firing, nextTrigger should have advanced to a future time (tomorrow 09:00)
1924+
const statusAfter = engine.getStatus();
1925+
const subAfter = statusAfter.find((s) => s.sessionId === 'session-1');
1926+
expect(subAfter!.nextTrigger).toBeDefined();
1927+
expect(new Date(subAfter!.nextTrigger!).getTime()).toBeGreaterThan(nextBeforeDate.getTime());
1928+
1929+
engine.stop();
1930+
});
1931+
19001932
it('uses prompt_file when configured', async () => {
19011933
// Monday at 08:59 — fires at 09:00
19021934
vi.setSystemTime(new Date('2026-03-09T08:59:00'));

0 commit comments

Comments
 (0)