diff --git a/docs/maestro-cue-configuration.md b/docs/maestro-cue-configuration.md index 68825674ff..e3d504d6b7 100644 --- a/docs/maestro-cue-configuration.md +++ b/docs/maestro-cue-configuration.md @@ -57,7 +57,7 @@ Each subscription is a trigger-prompt pairing. When the trigger fires, Cue sends | Field | Type | Description | | -------- | ------ | ---------------------------------------------------------------------- | | `name` | string | Unique identifier. Used in logs, history, and as a reference in chains | -| `event` | string | One of the seven [event types](./maestro-cue-events) | +| `event` | string | One of the eight [event types](./maestro-cue-events) | | `prompt` | string | The prompt to send, either inline text or a path to a `.md` file | ### Optional Fields @@ -167,7 +167,7 @@ The engine validates your YAML on every load. Common validation errors: | Error | Fix | | --------------------------------------- | ------------------------------------------------------------ | | `"name" is required` | Every subscription needs a unique `name` field | -| `"event" is required` | Specify one of the seven event types | +| `"event" is required` | Specify one of the eight event types | | `"prompt" is required` | Provide inline text or a file path | | `"interval_minutes" is required` | `time.heartbeat` events must specify a positive interval | | `"schedule_times" is required` | `time.scheduled` events must have at least one `HH:MM` time | diff --git a/docs/maestro-cue-events.md b/docs/maestro-cue-events.md index fc0288dcde..93feacd106 100644 --- a/docs/maestro-cue-events.md +++ b/docs/maestro-cue-events.md @@ -1,10 +1,46 @@ --- title: Cue Event Types -description: Detailed reference for all seven Maestro Cue event types with configuration, payloads, and examples. +description: Detailed reference for all eight Maestro Cue event types with configuration, payloads, and examples. icon: calendar-check --- -Cue supports seven event types. Each type watches for a different kind of activity and produces a payload that can be injected into prompts via [template variables](./maestro-cue-advanced#template-variables). +Cue supports eight event types. Each type watches for a different kind of activity and produces a payload that can be injected into prompts via [template variables](./maestro-cue-advanced#template-variables). + +## app.startup + +Fires exactly once when the Maestro application launches. Ideal for workspace setup, dependency installation, health checks, or any initialization that should happen once per session. + +**Required fields:** None beyond the universal `name`, `event`, and either `prompt` or `prompt_file`. + +**Behavior:** + +- Fires once per application launch +- Does NOT re-fire when toggling Cue on/off in Settings +- Does not re-fire on YAML hot-reload (deduplication by subscription name) +- Resets on session removal (so re-adding the session fires again on next app launch) +- Not affected by sleep/wake reconciliation +- Works with `fan_out`, `filter`, `output_prompt`, and `prompt_file` + +**Example:** + +```yaml +subscriptions: + - name: init-workspace + event: app.startup + prompt: | + Set up the development environment: + 1. Run `npm install` if node_modules is missing + 2. Check that required env vars are set + 3. Report any issues found +``` + +**Payload fields:** + +| Field | Type | Description | +| -------- | ------ | ------------------------- | +| `reason` | string | Always `"system_startup"` | + +--- ## time.heartbeat diff --git a/docs/maestro-cue-examples.md b/docs/maestro-cue-examples.md index ba29e58fee..0ec2601b2a 100644 --- a/docs/maestro-cue-examples.md +++ b/docs/maestro-cue-examples.md @@ -6,6 +6,28 @@ icon: lightbulb Complete, copy-paste-ready `.maestro/cue.yaml` configurations for common workflows. Each example is self-contained — drop it into your project's `.maestro/` directory and adjust agent names to match your Left Bar. +## Workspace Initialization + +Run setup tasks once when the Maestro application launches — install dependencies, verify environment, run health checks. + +**Agents needed:** `setup-agent` + +```yaml +subscriptions: + - name: init-workspace + event: app.startup + prompt: | + Initialize the workspace: + 1. Run `npm install` if node_modules is missing or outdated + 2. Verify required environment variables are set + 3. Run `npm run build` to ensure the project compiles + Report any issues found. +``` + +This fires exactly once per application launch. Toggling Cue off and back on does NOT re-fire it. Only an application restart triggers it again. Editing the YAML does not re-trigger it. + +--- + ## CI-Style Pipeline Lint, test, and deploy in sequence. Each step only runs if the previous one succeeded. diff --git a/src/__tests__/main/cue/cue-sleep-wake.test.ts b/src/__tests__/main/cue/cue-sleep-wake.test.ts index 268c14f7fc..dc2f96471d 100644 --- a/src/__tests__/main/cue/cue-sleep-wake.test.ts +++ b/src/__tests__/main/cue/cue-sleep-wake.test.ts @@ -228,8 +228,8 @@ describe('CueEngine sleep/wake detection', () => { const deps = createMockDeps(); const engine = new CueEngine(deps); - // Should not throw - expect(() => engine.start()).not.toThrow(); + // Should return gracefully without enabling (no throw) + engine.start(); // Should log the error and not enable the engine expect(deps.onLog).toHaveBeenCalledWith( diff --git a/src/__tests__/main/cue/cue-startup.test.ts b/src/__tests__/main/cue/cue-startup.test.ts new file mode 100644 index 0000000000..f1959fc183 --- /dev/null +++ b/src/__tests__/main/cue/cue-startup.test.ts @@ -0,0 +1,530 @@ +/** + * Tests for the app.startup Cue event type. + * + * Tests cover: + * - Fires on system startup (isSystemBoot=true) + * - Does NOT fire on user feature toggle (isSystemBoot=false) + * - Deduplication on YAML hot-reload (refreshSession) + * - Does NOT re-fire on engine stop/start toggle + * - Fires again on next system boot after removeSession + * - enabled: false is respected + * - agent_id binding is respected + * - Filter matching + * - Fan-out dispatch + * - Chaining with agent.completed + * - Multiple startup subs per session + * - Multiple sessions each fire independently + * - Event payload contains reason: 'system_startup' + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, 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(), + updateHeartbeat: vi.fn(), + getLastHeartbeat: vi.fn(() => null), +})); + +// Mock reconciler +vi.mock('../../../main/cue/cue-reconciler', () => ({ + reconcileMissedTimeEvents: 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('CueEngine app.startup', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockCreateCueFileWatcher.mockReturnValue(vi.fn()); + mockCreateCueGitHubPoller.mockReturnValue(vi.fn()); + mockCreateCueTaskScanner.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function createStartupConfig(overrides: Partial = {}): CueConfig { + return createMockConfig({ + subscriptions: [ + { + name: 'init-workspace', + event: 'app.startup', + enabled: true, + prompt: 'Set up workspace', + ...overrides, + }, + ], + }); + } + + it('fires on system startup (isSystemBoot=true)', async () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + subscriptionName: 'init-workspace', + prompt: 'Set up workspace', + event: expect.objectContaining({ + type: 'app.startup', + triggerName: 'init-workspace', + }), + }) + ); + + engine.stop(); + }); + + it('does NOT fire on user feature toggle (isSystemBoot=false)', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); // No true — simulates user toggling Cue on + + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('does not re-fire on refreshSession (YAML hot-reload)', async () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Simulate YAML hot-reload + engine.refreshSession('session-1', '/projects/test'); + + // Should still be only 1 call — deduplication prevents re-fire + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('does NOT re-fire on engine stop/start toggle', async () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + engine.start(true); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // User toggles Cue off then on (feature toggle, not system boot) + engine.stop(); + engine.start(); // isSystemBoot defaults to false + + // Should NOT re-fire — startupFiredKeys persist across stop/start + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('fires again on next system boot after removeSession', async () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Wait for the first run to complete so concurrency slot is free + await vi.advanceTimersByTimeAsync(100); + + // Remove session — clears startup fired keys for that session + engine.removeSession('session-1'); + + // Simulate a new system boot cycle + engine.stop(); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + engine.stop(); + }); + + it('respects enabled: false', () => { + const config = createStartupConfig({ enabled: false }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('respects agent_id binding — skips if agent_id does not match session', () => { + const config = createStartupConfig({ agent_id: 'other-session' }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('fires when agent_id matches session', () => { + const config = createStartupConfig({ agent_id: 'session-1' }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('respects filter — does not fire when filter does not match', () => { + const config = createStartupConfig({ + filter: { reason: 'nonexistent_reason' }, + }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('filter not matched')); + + engine.stop(); + }); + + it('fires when filter matches', () => { + const config = createStartupConfig({ + filter: { reason: 'system_startup' }, + }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('works with fan_out — dispatches to all targets', async () => { + const session1 = createMockSession({ id: 'session-1', name: 'Main' }); + const session2 = createMockSession({ id: 'session-2', name: 'Worker-A' }); + const session3 = createMockSession({ id: 'session-3', name: 'Worker-B' }); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'fan-out-init', + event: 'app.startup', + enabled: true, + prompt: 'Initialize', + fan_out: ['Worker-A', 'Worker-B'], + }, + ], + }); + mockLoadCueConfig.mockImplementation((root) => { + return root === '/projects/test' ? config : null; + }); + + const deps = createMockDeps({ + getSessions: vi.fn(() => [session1, session2, session3]), + }); + const engine = new CueEngine(deps); + engine.start(true); + + // Fan-out dispatches to both targets + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-2' })); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-3' })); + + engine.stop(); + }); + + it('chains with agent.completed', async () => { + const session1 = createMockSession({ id: 'session-1', name: 'Initializer' }); + const session2 = createMockSession({ + id: 'session-2', + name: 'Post-Init', + projectRoot: '/projects/test2', + }); + + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'startup-trigger', + event: 'app.startup', + enabled: true, + prompt: 'Initialize workspace', + }, + ], + }); + + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'post-init', + event: 'agent.completed', + enabled: true, + prompt: 'Run post-init tasks', + source_session: 'Initializer', + }, + ], + }); + + mockLoadCueConfig.mockImplementation((root) => { + if (root === '/projects/test') return config1; + if (root === '/projects/test2') return config2; + return null; + }); + + const onCueRun = vi.fn(async (request: Parameters[0]) => ({ + runId: request.runId, + sessionId: request.sessionId, + sessionName: request.sessionId === 'session-1' ? 'Initializer' : 'Post-Init', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed' as const, + stdout: 'done', + stderr: '', + exitCode: 0, + durationMs: 50, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })); + + const deps = createMockDeps({ + getSessions: vi.fn(() => [session1, session2]), + onCueRun, + }); + const engine = new CueEngine(deps); + engine.start(true); + + expect(onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + subscriptionName: 'startup-trigger', + }) + ); + + await vi.advanceTimersByTimeAsync(100); + + expect(onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-2', + subscriptionName: 'post-init', + }) + ); + + engine.stop(); + }); + + it('multiple startup subs per session each fire independently', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'init-deps', + event: 'app.startup', + enabled: true, + prompt: 'Install dependencies', + }, + { + name: 'init-env', + event: 'app.startup', + enabled: true, + prompt: 'Check environment', + }, + ], + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 2, + queue_size: 10, + }, + }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + await vi.advanceTimersByTimeAsync(100); + + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ subscriptionName: 'init-deps' }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ subscriptionName: 'init-env' }) + ); + + engine.stop(); + }); + + it('startup across multiple sessions fires independently', () => { + const session1 = createMockSession({ id: 'session-1', name: 'Agent A', projectRoot: '/proj1' }); + const session2 = createMockSession({ id: 'session-2', name: 'Agent B', projectRoot: '/proj2' }); + + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps({ + getSessions: vi.fn(() => [session1, session2]), + }); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-1' })); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-2' })); + + engine.stop(); + }); + + it('event payload contains reason: system_startup', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + type: 'app.startup', + payload: expect.objectContaining({ reason: 'system_startup' }), + }), + }) + ); + + engine.stop(); + }); + + it('does not prevent system sleep via schedule reason', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + const engine = new CueEngine(deps); + engine.start(true); + + const scheduleCalls = onPreventSleep.mock.calls.filter( + (args: unknown[]) => + typeof args[0] === 'string' && (args[0] as string).startsWith('cue:schedule:') + ); + expect(scheduleCalls).toHaveLength(0); + + engine.stop(); + }); + + it('logs trigger message', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('"init-workspace" triggered (app.startup)') + ); + + engine.stop(); + }); + + it('does not fire when engine is not enabled', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + // Don't call start() — engine is disabled + + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + + it('isSystemBoot flag persists across stop/start — second system boot still deduplicates', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // First system boot + engine.start(true); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Stop and start again as system boot — should NOT fire (keys persisted) + engine.stop(); + engine.start(true); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); +}); diff --git a/src/__tests__/main/cue/cue-yaml-loader.test.ts b/src/__tests__/main/cue/cue-yaml-loader.test.ts index 00d7a4c395..112a5292e1 100644 --- a/src/__tests__/main/cue/cue-yaml-loader.test.ts +++ b/src/__tests__/main/cue/cue-yaml-loader.test.ts @@ -1441,4 +1441,79 @@ subscriptions: expect(result!.subscriptions[0].filter).toBeUndefined(); }); }); + + describe('validateCueConfig — app.startup', () => { + it('accepts a minimal app.startup subscription', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'init', + event: 'app.startup', + prompt: 'Set up workspace', + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts app.startup with optional filter', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'init-filtered', + event: 'app.startup', + prompt: 'Set up workspace', + filter: { reason: 'engine_start' }, + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts app.startup with prompt_file instead of prompt', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'init-file', + event: 'app.startup', + prompt_file: 'prompts/init.md', + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects app.startup without prompt or prompt_file', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'init-no-prompt', + event: 'app.startup', + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"prompt" or "prompt_file" is required')]) + ); + }); + + it('rejects app.startup without name', () => { + const result = validateCueConfig({ + subscriptions: [ + { + event: 'app.startup', + prompt: 'Init', + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"name" is required')]) + ); + }); + }); }); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx index d0c1dac1c7..a21a292ae8 100644 --- a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx @@ -1,3 +1,4 @@ +/// import { describe, it, expect } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { TriggerDrawer } from '../../../../../renderer/components/CuePipelineEditor/drawers/TriggerDrawer'; @@ -112,14 +113,14 @@ describe('TriggerDrawer', () => { expect(drawer.style.transform).toBe('translateX(0)'); }); - it('should render exactly 6 trigger types (no agent.completed)', () => { + it('should render exactly 7 trigger types (no agent.completed)', () => { const { container } = render( {}} theme={mockTheme} /> ); // Each trigger item is a draggable div; count them const draggableItems = container.querySelectorAll('[draggable="true"]'); - expect(draggableItems.length).toBe(6); + expect(draggableItems.length).toBe(7); }); it('should not show agent.completed when filtering by "agent"', () => { diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts index c7cfa36421..b4fb632e46 100644 --- a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts @@ -136,6 +136,43 @@ describe('mergePipelinesWithSavedLayout', () => { expect(triggerNode?.position).toEqual({ x: 0, y: 0 }); }); + it('restores saved color and name over live-derived values', () => { + // Live pipelines get colors from parse order (the bug scenario) + const livePipelines = [ + makePipeline({ id: 'p1', name: 'Pipeline 1', color: '#06b6d4' }), + makePipeline({ id: 'p2', name: 'Pipeline 2', color: '#8b5cf6' }), + ]; + // Saved layout has different (original) colors and names + const savedLayout: PipelineLayoutState = { + pipelines: [ + makePipeline({ id: 'p1', name: 'My Custom Name', color: '#ef4444' }), + makePipeline({ id: 'p2', name: 'Another Name', color: '#22c55e' }), + ], + selectedPipelineId: null, + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + + expect(result.pipelines[0].name).toBe('My Custom Name'); + expect(result.pipelines[0].color).toBe('#ef4444'); + expect(result.pipelines[1].name).toBe('Another Name'); + expect(result.pipelines[1].color).toBe('#22c55e'); + }); + + it('keeps live color and name when no saved layout match exists', () => { + const livePipelines = [makePipeline({ id: 'p-new', name: 'Brand New', color: '#3b82f6' })]; + const savedLayout: PipelineLayoutState = { + pipelines: [makePipeline({ id: 'p-old', name: 'Old One', color: '#ef4444' })], + selectedPipelineId: null, + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + + // No matching ID in saved layout — live values preserved + expect(result.pipelines[0].name).toBe('Brand New'); + expect(result.pipelines[0].color).toBe('#3b82f6'); + }); + it('returns all live pipelines even when saved layout has fewer', () => { const livePipelines = [ makePipeline({ id: 'p1', name: 'first' }), diff --git a/src/__tests__/renderer/hooks/cue/usePipelineState.test.ts b/src/__tests__/renderer/hooks/cue/usePipelineState.test.ts index b573a32782..e4f23424db 100644 --- a/src/__tests__/renderer/hooks/cue/usePipelineState.test.ts +++ b/src/__tests__/renderer/hooks/cue/usePipelineState.test.ts @@ -133,9 +133,10 @@ function makePipeline(overrides?: Partial): CuePipeline { // ─── DEFAULT_TRIGGER_LABELS ────────────────────────────────────────────────── describe('DEFAULT_TRIGGER_LABELS', () => { - it('has entries for all seven event types', () => { + it('has entries for all eight event types', () => { const keys = Object.keys(DEFAULT_TRIGGER_LABELS); - expect(keys).toHaveLength(7); + expect(keys).toHaveLength(8); + expect(keys).toContain('app.startup'); expect(keys).toContain('time.heartbeat'); expect(keys).toContain('time.scheduled'); expect(keys).toContain('file.changed'); @@ -318,6 +319,35 @@ describe('usePipelineState', () => { expect(result.current.pipelineState.pipelines[1].name).toBe('Pipeline 2'); }); + it('createPipeline avoids duplicate names after deletion', () => { + vi.useFakeTimers(); + const { result } = renderHook(() => usePipelineState(createDefaultParams())); + + // Create Pipeline 1, Pipeline 2, and Pipeline 3 with distinct timestamps + act(() => result.current.createPipeline()); + vi.advanceTimersByTime(1); + act(() => result.current.createPipeline()); + vi.advanceTimersByTime(1); + act(() => result.current.createPipeline()); + expect(result.current.pipelineState.pipelines).toHaveLength(3); + + // Delete Pipeline 2 (middle one, no nodes → no confirm dialog) + const secondId = result.current.pipelineState.pipelines[1].id; + act(() => result.current.deletePipeline(secondId)); + expect(result.current.pipelineState.pipelines).toHaveLength(2); + + // Remaining: Pipeline 1 and Pipeline 3. maxNum=3, so next should be Pipeline 4 (not Pipeline 3 again) + vi.advanceTimersByTime(1); + act(() => result.current.createPipeline()); + + expect(result.current.pipelineState.pipelines).toHaveLength(3); + expect(result.current.pipelineState.pipelines[0].name).toBe('Pipeline 1'); + expect(result.current.pipelineState.pipelines[1].name).toBe('Pipeline 3'); + expect(result.current.pipelineState.pipelines[2].name).toBe('Pipeline 4'); + + vi.useRealTimers(); + }); + it('deletePipeline removes the pipeline when confirm returns true', () => { const { result } = renderHook(() => usePipelineState(createDefaultParams())); diff --git a/src/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts index dbe1911031..b31711098e 100644 --- a/src/main/cue/cue-engine.ts +++ b/src/main/cue/cue-engine.ts @@ -88,6 +88,12 @@ export class CueEngine { private pendingYamlWatchers = new Map void>(); /** Tracks "subName:HH:MM" keys that time.scheduled already fired, preventing double-fire on config refresh */ private scheduledFiredKeys = new Set(); + /** Tracks "sessionId:subName" keys for app.startup subscriptions that already fired this process lifecycle. + * NOT cleared on stop() — persists across engine stop/start cycles (feature toggling). Only resets on app restart. */ + private startupFiredKeys = new Set(); + /** True only during the initial session scan inside start(true). Cleared immediately after the scan + * completes so that later refreshSession/initSession calls don't fire app.startup subs. */ + private isBootScan = false; private heartbeat: CueHeartbeat; private deps: CueEngineDeps; @@ -144,11 +150,17 @@ export class CueEngine { }); } - /** Enable the engine and scan all sessions for Cue configs */ - start(): void { + /** Enable the engine and scan all sessions for Cue configs. + * @param isSystemBoot Pass `true` only at application launch (index.ts). When false (default), + * app.startup subscriptions will NOT fire — this prevents re-firing when the user toggles Cue on/off. */ + start(isSystemBoot = false): void { if (this.enabled) return; - // Initialize Cue database and prune old events — bail if this fails + if (isSystemBoot) { + this.isBootScan = true; + } + + // Initialize Cue database and prune old events — fail gracefully so app startup is not blocked try { initCueDb((level, msg) => this.deps.onLog(level as MainLogLevel, msg)); pruneCueEvents(EVENT_PRUNE_AGE_MS); @@ -160,6 +172,7 @@ export class CueEngine { captureException(error instanceof Error ? error : new Error(String(error)), { extra: { operation: 'cue.dbInit' }, }); + this.isBootScan = false; return; } @@ -171,6 +184,10 @@ export class CueEngine { this.initSession(session); } + // Boot scan complete — clear the flag so later refreshSession/initSession + // calls (YAML hot-reload, auto-discovery) don't fire app.startup subs + this.isBootScan = false; + // Detect sleep gap from previous heartbeat this.heartbeat.detectSleepAndReconcile(); @@ -198,6 +215,9 @@ export class CueEngine { this.runManager.reset(); this.fanInTracker.reset(); this.scheduledFiredKeys.clear(); + // NOTE: startupFiredKeys is NOT cleared here — it persists across engine stop/start + // cycles so that toggling Cue off/on does not re-fire app.startup subscriptions. + // It only resets when the Electron process restarts (new CueEngine instance). // Stop heartbeat and close database this.heartbeat.stop(); @@ -256,6 +276,13 @@ export class CueEngine { this.sessions.delete(sessionId); this.runManager.clearQueue(sessionId); + // Clear startup fired keys so re-adding this session will fire startup again + for (const key of this.startupFiredKeys) { + if (key.startsWith(`${sessionId}:`)) { + this.startupFiredKeys.delete(key); + } + } + const pendingWatcher = this.pendingYamlWatchers.get(sessionId); if (pendingWatcher) { pendingWatcher(); @@ -668,6 +695,37 @@ export class CueEngine { // agent.completed subscriptions are handled reactively via notifyAgentCompleted } + // Fire app.startup subscriptions (once per system boot, deduplicated across hot-reloads and feature toggles) + for (const sub of config.subscriptions) { + if (sub.enabled === false) continue; + if (sub.agent_id && sub.agent_id !== session.id) continue; + if (sub.event !== 'app.startup') continue; + + // Only fire during the initial boot scan (app launch), not on later refreshSession/feature toggle + if (!this.isBootScan) continue; + + const firedKey = `${session.id}:${sub.name}`; + if (this.startupFiredKeys.has(firedKey)) continue; + this.startupFiredKeys.add(firedKey); + + const event = createCueEvent('app.startup', sub.name, { + reason: 'system_startup', + }); + + // Check payload filter + if (sub.filter && !matchesFilter(event.payload, sub.filter)) { + this.deps.onLog( + 'cue', + `[CUE] "${sub.name}" filter not matched (${describeFilter(sub.filter)})` + ); + continue; + } + + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (app.startup)`); + state.lastTriggered = event.timestamp; + this.dispatchSubscription(session.id, sub, event, session.name); + } + this.sessions.set(session.id, state); // Prevent system sleep if this session has time-based subscriptions diff --git a/src/main/cue/cue-types.ts b/src/main/cue/cue-types.ts index 23389085dc..03c7e33205 100644 --- a/src/main/cue/cue-types.ts +++ b/src/main/cue/cue-types.ts @@ -4,6 +4,7 @@ import * as crypto from 'crypto'; * Core type definitions for the Maestro Cue event-driven automation system. * * Cue triggers agent prompts in response to events: + * - app.startup: fires once when the Maestro application starts (per session, per app launch) * - time.heartbeat: periodic timer-based triggers ("run every X minutes") * - time.scheduled: cron-like triggers (specific times and days of week) * - file.changed: file system change triggers @@ -29,6 +30,7 @@ export const CUE_SCHEDULE_DAYS: CueScheduleDay[] = [ /** Event types that can trigger a Cue subscription */ export type CueEventType = + | 'app.startup' | 'time.heartbeat' | 'time.scheduled' | 'file.changed' @@ -39,6 +41,7 @@ export type CueEventType = /** All valid event type values (used for validation) */ export const CUE_EVENT_TYPES: CueEventType[] = [ + 'app.startup', 'time.heartbeat', 'time.scheduled', 'file.changed', diff --git a/src/main/cue/cue-yaml-loader.ts b/src/main/cue/cue-yaml-loader.ts index 42b327781a..0b48af8b94 100644 --- a/src/main/cue/cue-yaml-loader.ts +++ b/src/main/cue/cue-yaml-loader.ts @@ -363,6 +363,8 @@ export function validateCueConfig(config: unknown): { valid: boolean; errors: st ); } } + } else if (event === 'app.startup') { + // No additional required fields — simplest trigger type } else if ( sub.event && typeof sub.event === 'string' && diff --git a/src/main/index.ts b/src/main/index.ts index 8fbaa48086..b464f13ff1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -529,7 +529,14 @@ app.whenReady().then(async () => { const encoreFeatures = store.get('encoreFeatures', {}) as Record; if (encoreFeatures.maestroCue && cueEngine) { logger.info('Maestro Cue Encore Feature enabled — starting Cue engine', 'Startup'); - cueEngine.start(); + try { + cueEngine.start(true); + } catch (err) { + logger.error( + `Cue engine failed to start at boot — will remain available for retry via Settings: ${err}`, + 'Startup' + ); + } } // Set custom application menu to prevent macOS from injecting native diff --git a/src/renderer/components/CueHelpModal.tsx b/src/renderer/components/CueHelpModal.tsx index 2b27dbee00..3dc62f9d5e 100644 --- a/src/renderer/components/CueHelpModal.tsx +++ b/src/renderer/components/CueHelpModal.tsx @@ -87,6 +87,21 @@ export function CueHelpContent({ theme, cueShortcutKeys }: CueHelpContentProps)

Event Types

+
+

+ Startup{' '} + + app.startup + +

+

+ Fires once when the Maestro application starts. No additional fields required. Does + not re-fire on YAML hot-reload or when toggling Cue on/off. +

+

Heartbeat{' '} @@ -304,6 +319,15 @@ export function CueHelpContent({ theme, cueShortcutKeys }: CueHelpContentProps) borderColor: theme.colors.border, }} > +

+ # Startup +
+ - name: "Init Workspace" +
+ {' '}event: app.startup +
+ {' '}prompt: prompts/init.md +
# Interval
@@ -478,7 +502,7 @@ export function CueHelpContent({ theme, cueShortcutKeys }: CueHelpContentProps) >
{'{{CUE_EVENT_TYPE}}'} — Event - type (time.heartbeat, time.scheduled, file.changed, agent.completed, + type (app.startup, time.heartbeat, time.scheduled, file.changed, agent.completed, github.pull_request, github.issue, task.pending)
diff --git a/src/renderer/components/CueModal/CueModal.tsx b/src/renderer/components/CueModal/CueModal.tsx index 18414c8303..923ce73e6a 100644 --- a/src/renderer/components/CueModal/CueModal.tsx +++ b/src/renderer/components/CueModal/CueModal.tsx @@ -31,6 +31,7 @@ import { SessionsTable } from './SessionsTable'; import { ActiveRunsList } from './ActiveRunsList'; import { ActivityLog } from './ActivityLog'; import { buildSubscriptionPipelineMap } from './cueModalUtils'; +import { notifyToast } from '../../stores/notificationStore'; type CueModalTab = 'dashboard' | 'pipeline'; @@ -99,6 +100,17 @@ export function CueModal({ theme, onClose, cueShortcutKeys }: CueModalProps) { } else { await enable(); } + } catch (err) { + notifyToast({ + type: 'error', + title: 'Cue', + message: + err instanceof Error + ? err.message + : isEnabled + ? 'Failed to disable Cue engine' + : 'Failed to enable Cue engine', + }); } finally { setToggling(false); } diff --git a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx index 0cd9347465..ed7dd4ad79 100644 --- a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx +++ b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx @@ -234,6 +234,31 @@ function CuePipelineEditorInner({ ] ); + // ─── Fit view on pipeline selection change ────────────────────────────── + // When switching between "All Pipelines" and a single pipeline (or between + // two different pipelines), the visible nodes change. Without a fitView call + // the viewport stays where it was, so the selected pipeline may appear off-screen. + // Skip the first change (mount hydration) so we don't overwrite the saved + // viewport restored by usePipelineLayout. + const prevSelectedIdRef = useRef(pipelineState.selectedPipelineId); + const hasHydratedSelectionRef = useRef(false); + useEffect(() => { + if (prevSelectedIdRef.current === pipelineState.selectedPipelineId) return; + prevSelectedIdRef.current = pipelineState.selectedPipelineId; + + // Skip the initial hydration — let usePipelineLayout restore the saved viewport + if (!hasHydratedSelectionRef.current) { + hasHydratedSelectionRef.current = true; + return; + } + + // Short delay so ReactFlow has rendered the new node set before fitting + const timer = setTimeout(() => { + reactFlowInstance.fitView({ padding: 0.15, duration: 200 }); + }, 50); + return () => clearTimeout(timer); + }, [pipelineState.selectedPipelineId, reactFlowInstance]); + // ─── Canvas callbacks ──────────────────────────────────────────────────── // Ref mirror so onNodesChange reads the latest stable offsets without @@ -242,20 +267,95 @@ function CuePipelineEditorInner({ const stableYOffsetsRef = useRef(stableYOffsets); stableYOffsetsRef.current = stableYOffsets; + // Throttle drag updates: buffer the latest positions during drag and + // flush at most once per animation frame. This keeps the controlled + // nodes in sync with ReactFlow (so the node visually follows the + // cursor) while avoiding a full convertToReactFlowNodes recompute + // on every mouse-move event, which caused nodes to vanish on Linux. + const dragBufferRef = useRef | null>(null); + const rafIdRef = useRef(null); + + const flushDragBuffer = useCallback(() => { + rafIdRef.current = null; + const buffer = dragBufferRef.current; + if (!buffer || buffer.size === 0) return; + dragBufferRef.current = null; + + setPipelineState((prev) => { + const isAllPipelines = prev.selectedPipelineId === null; + const yOffsets = stableYOffsetsRef.current; + + const newPipelines = prev.pipelines.map((pipeline) => { + const yOffset = isAllPipelines ? (yOffsets.get(pipeline.id) ?? 0) : 0; + return { + ...pipeline, + nodes: pipeline.nodes.map((pNode) => { + const newPos = buffer.get(`${pipeline.id}:${pNode.id}`); + if (newPos) { + return { + ...pNode, + position: isAllPipelines ? { x: newPos.x, y: newPos.y - yOffset } : newPos, + }; + } + return pNode; + }), + }; + }); + return { ...prev, pipelines: newPipelines }; + }); + }, [setPipelineState]); + + // Clean up any pending animation frame on unmount + useEffect(() => { + return () => { + if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current); + }; + }, []); + const onNodesChange: OnNodesChange = useCallback( (changes) => { const positionUpdates = new Map(); let hasPositionChange = false; + let isDragging = false; for (const change of changes) { if (change.type === 'position' && change.position) { positionUpdates.set(change.id, change.position); - if (!change.dragging) { + if (change.dragging) { + isDragging = true; + } else { hasPositionChange = true; } } } + // During active drag: buffer positions and flush once per frame + if (isDragging && positionUpdates.size > 0) { + if (!dragBufferRef.current) dragBufferRef.current = new Map(); + for (const [id, pos] of positionUpdates) { + dragBufferRef.current.set(id, pos); + } + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(flushDragBuffer); + } + return; + } + if (positionUpdates.size > 0) { + // Cancel any pending drag RAF and merge buffered positions so stale + // coordinates cannot flush after we apply the non-drag update + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + if (dragBufferRef.current) { + for (const [id, pos] of dragBufferRef.current) { + if (!positionUpdates.has(id)) { + positionUpdates.set(id, pos); + } + } + dragBufferRef.current = null; + } + setPipelineState((prev) => { const isAllPipelines = prev.selectedPipelineId === null; @@ -304,7 +404,7 @@ function CuePipelineEditorInner({ persistLayout(); } }, - [persistLayout, setPipelineState] + [persistLayout, setPipelineState, flushDragBuffer] ); const onEdgesChange: OnEdgesChange = useCallback(() => {}, []); diff --git a/src/renderer/components/CuePipelineEditor/cueEventConstants.ts b/src/renderer/components/CuePipelineEditor/cueEventConstants.ts index 74f5a92f1a..7cb0e050c1 100644 --- a/src/renderer/components/CuePipelineEditor/cueEventConstants.ts +++ b/src/renderer/components/CuePipelineEditor/cueEventConstants.ts @@ -5,11 +5,12 @@ * TriggerNode, TriggerDrawer, NodeConfigPanel, and PipelineCanvas. */ -import { Clock, FileText, Zap, GitPullRequest, CircleDot, CheckSquare } from 'lucide-react'; +import { Clock, FileText, Zap, GitPullRequest, CircleDot, CheckSquare, Power } from 'lucide-react'; import type { CueEventType } from '../../../shared/cue-pipeline-types'; /** Icon component for each event type */ export const EVENT_ICONS: Record = { + 'app.startup': Power, 'time.heartbeat': Clock, 'time.scheduled': Clock, 'file.changed': FileText, @@ -21,6 +22,7 @@ export const EVENT_ICONS: Record = { /** Display label for each event type */ export const EVENT_LABELS: Record = { + 'app.startup': 'App Startup', 'time.heartbeat': 'Heartbeat Timer', 'time.scheduled': 'Scheduled', 'file.changed': 'File Change', @@ -32,6 +34,7 @@ export const EVENT_LABELS: Record = { /** Brand color for each event type (used in nodes, drawers, minimap) */ export const EVENT_COLORS: Record = { + 'app.startup': '#10b981', 'time.heartbeat': '#f59e0b', 'time.scheduled': '#8b5cf6', 'file.changed': '#3b82f6', diff --git a/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx index e7e51778db..6b9813d107 100644 --- a/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx +++ b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx @@ -19,6 +19,13 @@ interface TriggerItem { } const TRIGGER_ITEMS: TriggerItem[] = [ + { + eventType: 'app.startup', + label: 'Startup', + description: 'Run once when application starts', + icon: EVENT_ICONS['app.startup'], + color: EVENT_COLORS['app.startup'], + }, { eventType: 'time.heartbeat', label: 'Heartbeat', diff --git a/src/renderer/components/CuePipelineEditor/panels/triggers/TriggerConfig.tsx b/src/renderer/components/CuePipelineEditor/panels/triggers/TriggerConfig.tsx index 9943ce25bf..70272ad608 100644 --- a/src/renderer/components/CuePipelineEditor/panels/triggers/TriggerConfig.tsx +++ b/src/renderer/components/CuePipelineEditor/panels/triggers/TriggerConfig.tsx @@ -247,6 +247,15 @@ export function TriggerConfig({ node, theme, onUpdateNode }: TriggerConfigProps)
); + case 'app.startup': + return ( +
+ {nameField} +
+ Fires once when the Maestro application starts. No additional configuration needed. +
+
+ ); default: return null; } diff --git a/src/renderer/components/CuePipelineEditor/utils/pipelineLayout.ts b/src/renderer/components/CuePipelineEditor/utils/pipelineLayout.ts index 7c7e4600c7..139a194417 100644 --- a/src/renderer/components/CuePipelineEditor/utils/pipelineLayout.ts +++ b/src/renderer/components/CuePipelineEditor/utils/pipelineLayout.ts @@ -23,19 +23,26 @@ export function mergePipelinesWithSavedLayout( savedLayout: PipelineLayoutState ): CuePipelineState { const savedPositions = new Map(); + const savedPipelineProps = new Map(); for (const sp of savedLayout.pipelines) { + savedPipelineProps.set(sp.id, { name: sp.name, color: sp.color }); for (const node of sp.nodes) { savedPositions.set(`${sp.id}:${node.id}`, node.position); } } - const mergedPipelines = livePipelines.map((pipeline) => ({ - ...pipeline, - nodes: pipeline.nodes.map((node) => { - const savedPos = savedPositions.get(`${pipeline.id}:${node.id}`); - return savedPos ? { ...node, position: savedPos } : node; - }), - })); + const mergedPipelines = livePipelines.map((pipeline) => { + const savedProps = savedPipelineProps.get(pipeline.id); + return { + ...pipeline, + // Restore saved name and color so they don't change on reload + ...(savedProps && { name: savedProps.name, color: savedProps.color }), + nodes: pipeline.nodes.map((node) => { + const savedPos = savedPositions.get(`${pipeline.id}:${node.id}`); + return savedPos ? { ...node, position: savedPos } : node; + }), + }; + }); return { pipelines: mergedPipelines, diff --git a/src/renderer/constants/cuePatterns.ts b/src/renderer/constants/cuePatterns.ts index c72a9643e6..ee65a9392f 100644 --- a/src/renderer/constants/cuePatterns.ts +++ b/src/renderer/constants/cuePatterns.ts @@ -7,6 +7,19 @@ export interface CuePattern { } export const CUE_PATTERNS: CuePattern[] = [ + { + id: 'startup', + name: 'Startup', + description: 'Run once when the application starts', + explanation: + 'Fires a single time when the Maestro application launches. Perfect for workspace setup, dependency installation, or health checks. Does not re-fire on YAML hot-reload or when toggling Cue on/off.', + yaml: `subscriptions: + - name: "Initialize Workspace" + event: app.startup + prompt: prompts/workspace-init.md + enabled: true +`, + }, { id: 'heartbeat-task', name: 'Heartbeat', diff --git a/src/renderer/constants/cueYamlDefaults.ts b/src/renderer/constants/cueYamlDefaults.ts index cbd14a61d8..509759524d 100644 --- a/src/renderer/constants/cueYamlDefaults.ts +++ b/src/renderer/constants/cueYamlDefaults.ts @@ -3,6 +3,11 @@ export const CUE_YAML_TEMPLATE = `# .maestro/cue.yaml # Define event-driven subscriptions for your agents. # # subscriptions: +# - name: "initialize workspace" +# event: app.startup +# prompt: prompts/workspace-init.md +# enabled: true +# # - name: "code review on change" # event: file.changed # watch: "src/**/*.ts" diff --git a/src/renderer/hooks/cue/useCueAiChat.ts b/src/renderer/hooks/cue/useCueAiChat.ts index 27e4cf6439..6fd7fc1665 100644 --- a/src/renderer/hooks/cue/useCueAiChat.ts +++ b/src/renderer/hooks/cue/useCueAiChat.ts @@ -10,7 +10,7 @@ import { buildSpawnConfigForAgent } from '../../utils/sessionHelpers'; const AI_SYSTEM_PROMPT = `You are configuring maestro-cue.yaml for the user. Be terse. Plain text only — no markdown, no code fences, no bullet lists, no formatting. -Event types: time.heartbeat (interval_minutes), time.scheduled (schedule_times array, optional schedule_days), file.changed (watch glob), agent.completed (source_session, optional fan_out), github.pull_request (poll_minutes, optional repo), github.issue (poll_minutes, optional repo), task.pending (watch glob, poll_minutes). +Event types: app.startup (fires once on application start, no extra fields), time.heartbeat (interval_minutes), time.scheduled (schedule_times array, optional schedule_days), file.changed (watch glob), agent.completed (source_session, optional fan_out), github.pull_request (poll_minutes, optional repo), github.issue (poll_minutes, optional repo), task.pending (watch glob, poll_minutes). Optional filter block on any subscription: AND'd conditions on payload fields. Operators: exact string, "!value" negation, ">N"/" = { + 'app.startup': 'Startup', 'time.heartbeat': 'Heartbeat', 'time.scheduled': 'Scheduled', 'file.changed': 'File Change', @@ -346,9 +347,17 @@ export function usePipelineState({ const createPipeline = useCallback(() => { setPipelineState((prev) => { + // Find the highest existing pipeline number to avoid duplicates after deletions + let maxNum = 0; + for (const p of prev.pipelines) { + const match = p.name.match(/^Pipeline (\d+)$/); + if (match) { + maxNum = Math.max(maxNum, parseInt(match[1], 10)); + } + } const newPipeline: CuePipeline = { id: `pipeline-${Date.now()}`, - name: `Pipeline ${prev.pipelines.length + 1}`, + name: `Pipeline ${maxNum + 1}`, color: getNextPipelineColor(prev.pipelines), nodes: [], edges: [], diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts index 33d83c26a6..b651724264 100644 --- a/src/renderer/stores/agentStore.ts +++ b/src/renderer/stores/agentStore.ts @@ -366,9 +366,6 @@ export const useAgentStore = create()((set, get) => ({ } else if (item.type === 'command' && item.command) { // Process a slash command - find matching command // Check user-defined commands first, then agent-discovered commands with prompts - const agentCmd = session.agentCommands?.find( - (cmd) => cmd.command === item.command && cmd.prompt - ); const matchingCommand = deps.customAICommands.find((cmd) => cmd.command === item.command) || deps.speckitCommands.find((cmd) => cmd.command === item.command) || diff --git a/src/shared/cue-pipeline-types.ts b/src/shared/cue-pipeline-types.ts index f7f550d9b1..d4fcdfba8f 100644 --- a/src/shared/cue-pipeline-types.ts +++ b/src/shared/cue-pipeline-types.ts @@ -8,6 +8,7 @@ /** Event types that can trigger a Cue subscription (mirrored from cue-types.ts for renderer access) */ export type CueEventType = + | 'app.startup' | 'time.heartbeat' | 'time.scheduled' | 'file.changed' diff --git a/src/shared/templateVariables.ts b/src/shared/templateVariables.ts index 404ce3a215..aa49b20356 100644 --- a/src/shared/templateVariables.ts +++ b/src/shared/templateVariables.ts @@ -52,7 +52,7 @@ import { buildSessionDeepLink, buildGroupDeepLink } from './deep-link-urls'; * {{CONTEXT_USAGE}} - Current context window usage percentage * * Cue Variables (Cue automation only): - * {{CUE_EVENT_TYPE}} - Cue event type (time.heartbeat, time.scheduled, file.changed, agent.completed) + * {{CUE_EVENT_TYPE}} - Cue event type (app.startup, time.heartbeat, time.scheduled, file.changed, agent.completed, github.*, task.pending) * {{CUE_EVENT_TIMESTAMP}} - Cue event timestamp * {{CUE_TRIGGER_NAME}} - Cue trigger/subscription name * {{CUE_RUN_ID}} - Cue run UUID