From 9108f0a36dfbc41db4cf46ec3d25a52c689093ae Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Thu, 26 Mar 2026 10:06:54 -0600 Subject: [PATCH 1/6] fix(cue): surface DB init errors to UI + throttle drag to prevent node vanishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem 1 — Silent Cue enable failure (Linux): CueEngine.start() caught better-sqlite3 DB init errors and returned silently. The IPC call appeared to succeed, refresh() saw enabled=false, and the toggle flipped back with zero feedback. Users on Linux where the native binding was missing saw the toggle just snap back to "Disabled" with no explanation. Fix: - src/main/cue/cue-engine.ts: change `return` → `throw error` in the DB init catch block. Keeps existing Sentry capture and logging, but now propagates to the IPC layer so the renderer gets a rejected promise. - src/renderer/components/CueModal/CueModal.tsx: add catch block in handleToggle that calls notifyToast with the error message. Users now see exactly why enabling failed (e.g. "Could not locate the bindings file"). Problem 2 — Pipeline nodes vanish during drag (Linux): onNodesChange called setPipelineState on every mousemove during drag. Each update triggered a full convertToReactFlowNodes recomputation via useMemo, creating a tight render loop. On Linux with slower compositing, this caused nodes to flicker or disappear entirely. Fix: - src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx: buffer drag position updates in a ref and flush once per animation frame via requestAnimationFrame. Non-drag position changes (drop, programmatic moves) still commit immediately. Cleanup cancels any pending frame on unmount. Tests updated: - src/__tests__/main/cue/cue-engine.test.ts: 5 tests that called engine.start() expecting silent failure now use expect().toThrow() - src/__tests__/main/cue/cue-sleep-wake.test.ts: 1 test updated from .not.toThrow() to .toThrow('DB init failed') Affected surfaces: - Cue enable/disable toggle in CueModal (all platforms) - Pipeline canvas drag-and-drop (primarily Linux, no regression on macOS) - IPC handler cue:enable now returns rejected promise on DB failure - No changes to stats-db, build pipeline, or other features --- src/__tests__/main/cue/cue-engine.test.ts | 10 +-- src/__tests__/main/cue/cue-sleep-wake.test.ts | 4 +- src/main/cue/cue-engine.ts | 2 +- src/renderer/components/CueModal/CueModal.tsx | 7 ++ .../CuePipelineEditor/CuePipelineEditor.tsx | 64 ++++++++++++++++++- 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/__tests__/main/cue/cue-engine.test.ts b/src/__tests__/main/cue/cue-engine.test.ts index 873a6ff1c..f466247c1 100644 --- a/src/__tests__/main/cue/cue-engine.test.ts +++ b/src/__tests__/main/cue/cue-engine.test.ts @@ -133,7 +133,7 @@ describe('CueEngine', () => { }); const deps = createMockDeps(); const engine = new CueEngine(deps); - engine.start(); + expect(() => engine.start()).toThrow('DB corrupted'); expect(engine.isEnabled()).toBe(false); }); @@ -143,7 +143,7 @@ describe('CueEngine', () => { }); const deps = createMockDeps(); const engine = new CueEngine(deps); - engine.start(); + expect(() => engine.start()).toThrow('DB corrupted'); expect(deps.onLog).toHaveBeenCalledWith( 'error', expect.stringContaining('Failed to initialize Cue database') @@ -156,7 +156,7 @@ describe('CueEngine', () => { }); const deps = createMockDeps(); const engine = new CueEngine(deps); - engine.start(); + expect(() => engine.start()).toThrow('DB corrupted'); expect(mockLoadCueConfig).not.toHaveBeenCalled(); }); @@ -166,7 +166,7 @@ describe('CueEngine', () => { }); const deps = createMockDeps(); const engine = new CueEngine(deps); - engine.start(); + expect(() => engine.start()).toThrow('DB corrupted'); // Engine is not enabled, so getStatus should return empty expect(engine.getStatus()).toEqual([]); }); @@ -181,7 +181,7 @@ describe('CueEngine', () => { const deps = createMockDeps(); const engine = new CueEngine(deps); - engine.start(); + expect(() => engine.start()).toThrow('DB corrupted'); expect(engine.isEnabled()).toBe(false); engine.start(); diff --git a/src/__tests__/main/cue/cue-sleep-wake.test.ts b/src/__tests__/main/cue/cue-sleep-wake.test.ts index 268c14f7f..a14ffebfc 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 throw so IPC callers can surface the error + expect(() => engine.start()).toThrow('DB init failed'); // Should log the error and not enable the engine expect(deps.onLog).toHaveBeenCalledWith( diff --git a/src/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts index dbe191103..9b31980ca 100644 --- a/src/main/cue/cue-engine.ts +++ b/src/main/cue/cue-engine.ts @@ -160,7 +160,7 @@ export class CueEngine { captureException(error instanceof Error ? error : new Error(String(error)), { extra: { operation: 'cue.dbInit' }, }); - return; + throw error; } this.enabled = true; diff --git a/src/renderer/components/CueModal/CueModal.tsx b/src/renderer/components/CueModal/CueModal.tsx index 18414c830..9a78374ee 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,12 @@ export function CueModal({ theme, onClose, cueShortcutKeys }: CueModalProps) { } else { await enable(); } + } catch (err) { + notifyToast({ + type: 'error', + title: 'Cue', + message: err instanceof Error ? err.message : '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 0cd934746..d5d3379e3 100644 --- a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx +++ b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx @@ -242,19 +242,79 @@ 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) { setPipelineState((prev) => { const isAllPipelines = prev.selectedPipelineId === null; @@ -304,7 +364,7 @@ function CuePipelineEditorInner({ persistLayout(); } }, - [persistLayout, setPipelineState] + [persistLayout, setPipelineState, flushDragBuffer] ); const onEdgesChange: OnEdgesChange = useCallback(() => {}, []); From aa409da044346c6eaa347ce94c0ce2b9dfbf3c16 Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Thu, 26 Mar 2026 13:22:12 -0600 Subject: [PATCH 2/6] feat(cue): add app.startup trigger + fix pipeline numbering Add `app.startup` as the 8th Cue event type, firing once per application launch to run initialization workflows (workspace setup, dep installs, health checks). System startup semantics: - Fires only on app launch (isSystemBoot=true), not when toggling Cue on/off - Deduplicates across YAML hot-reloads via startupFiredKeys Set - Keys persist across engine stop/start cycles (feature toggling) - Resets on session removal + next app restart - Full interop with fan-out, filters, output_prompt, chaining Pipeline numbering fix: - createPipeline now derives next number from highest existing pipeline name instead of array length, preventing duplicate names after deletion - Remove unused agentCmd variable in agentStore (lint warning) 22 files changed, 19 new startup tests, 5 yaml validation tests, 1 pipeline numbering test. 23,883 tests passing. --- docs/maestro-cue-configuration.md | 2 +- docs/maestro-cue-events.md | 40 +- docs/maestro-cue-examples.md | 22 + src/__tests__/main/cue/cue-startup.test.ts | 530 ++++++++++++++++++ .../main/cue/cue-yaml-loader.test.ts | 75 +++ .../drawers/TriggerDrawer.test.tsx | 4 +- .../hooks/cue/usePipelineState.test.ts | 34 +- src/main/cue/cue-engine.ts | 56 +- src/main/cue/cue-types.ts | 3 + src/main/cue/cue-yaml-loader.ts | 2 + src/main/index.ts | 2 +- src/renderer/components/CueHelpModal.tsx | 24 +- .../CuePipelineEditor/cueEventConstants.ts | 5 +- .../drawers/TriggerDrawer.tsx | 7 + .../panels/triggers/TriggerConfig.tsx | 9 + src/renderer/constants/cuePatterns.ts | 13 + src/renderer/constants/cueYamlDefaults.ts | 5 + src/renderer/hooks/cue/useCueAiChat.ts | 4 +- src/renderer/hooks/cue/usePipelineState.ts | 11 +- src/renderer/stores/agentStore.ts | 3 - src/shared/cue-pipeline-types.ts | 1 + src/shared/templateVariables.ts | 2 +- 22 files changed, 835 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/main/cue/cue-startup.test.ts diff --git a/docs/maestro-cue-configuration.md b/docs/maestro-cue-configuration.md index 68825674f..79acd3df3 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 diff --git a/docs/maestro-cue-events.md b/docs/maestro-cue-events.md index fc0288dcd..4f2571188 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 `prompt`. + +**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 ba29e58fe..0ec2601b2 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-startup.test.ts b/src/__tests__/main/cue/cue-startup.test.ts new file mode 100644 index 000000000..f1959fc18 --- /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 00d7a4c39..112a5292e 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 d0c1dac1c..12fe4ee3f 100644 --- a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx @@ -112,14 +112,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/hooks/cue/usePipelineState.test.ts b/src/__tests__/renderer/hooks/cue/usePipelineState.test.ts index b573a3278..e4f23424d 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 9b31980ca..5a743dfd5 100644 --- a/src/main/cue/cue-engine.ts +++ b/src/main/cue/cue-engine.ts @@ -88,6 +88,11 @@ 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(); + /** Whether the engine was started as part of system boot (app launch), not a user feature toggle */ + private isSystemBoot = false; private heartbeat: CueHeartbeat; private deps: CueEngineDeps; @@ -144,10 +149,16 @@ 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; + if (isSystemBoot) { + this.isSystemBoot = true; + } + // Initialize Cue database and prune old events — bail if this fails try { initCueDb((level, msg) => this.deps.onLog(level as MainLogLevel, msg)); @@ -198,6 +209,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 +270,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 +689,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 on system startup (app launch), not when user toggles Cue feature on/off + if (!this.isSystemBoot) 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 23389085d..03c7e3320 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 42b327781..0b48af8b9 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 8fbaa4808..c93c6fda2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -529,7 +529,7 @@ 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(); + cueEngine.start(true); } // 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 2b27dbee0..63c9ff4e6 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,13 @@ export function CueHelpContent({ theme, cueShortcutKeys }: CueHelpContentProps) borderColor: theme.colors.border, }} > +

+ # Startup +
+ - name: "Init Workspace" +
+ {' '}event: app.startup +
# Interval
@@ -478,7 +500,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/CuePipelineEditor/cueEventConstants.ts b/src/renderer/components/CuePipelineEditor/cueEventConstants.ts index 74f5a92f1..7cb0e050c 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 e7e51778d..6b9813d10 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 9943ce25b..70272ad60 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/constants/cuePatterns.ts b/src/renderer/constants/cuePatterns.ts index c72a9643e..ee65a9392 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 cbd14a61d..509759524 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 27e4cf643..6fd7fc166 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 33d83c26a..b65172426 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 f7f550d9b..d4fcdfba8 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 404ce3a21..aa49b2035 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 From 17ba2b6092b370f12ab4474f726a0d44f3aa136d Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Thu, 26 Mar 2026 13:27:38 -0600 Subject: [PATCH 3/6] fix(test): add jest-dom vitest type reference to TriggerDrawer test Adds `/// ` to resolve TS errors for toBeInTheDocument, toHaveStyle, toHaveAttribute matchers that were missing type augmentation. --- .../components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx index 12fe4ee3f..a21a292ae 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'; From 78ad7e7830ba4a243c09e91d124eaea20ef5a0b2 Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Thu, 26 Mar 2026 15:44:06 -0600 Subject: [PATCH 4/6] fix(cue): scope boot scan flag, graceful DB init, review fixes Engine: - Replace persistent isSystemBoot with scoped isBootScan that is set true only during the start() session scan and cleared immediately after, preventing later refreshSession/auto-discovery from firing app.startup subs - DB init failure now returns gracefully instead of throwing, so the Electron main process is never blocked at startup - Wrap cueEngine.start(true) in try-catch at index.ts boot site UI: - CueModal toggle error toast is now context-aware (enable vs disable) - CueHelpModal startup quick-reference example includes prompt field - CuePipelineEditor cancels pending RAF and merges drag buffer before applying non-drag position updates to prevent stale flush Docs: - Fix inconsistent event count in configuration validation section - Clarify prompt_file as alternative to prompt in events doc --- docs/maestro-cue-configuration.md | 2 +- docs/maestro-cue-events.md | 2 +- src/__tests__/main/cue/cue-engine.test.ts | 10 +++++----- src/__tests__/main/cue/cue-sleep-wake.test.ts | 4 ++-- src/main/cue/cue-engine.ts | 20 ++++++++++++------- src/main/index.ts | 9 ++++++++- src/renderer/components/CueHelpModal.tsx | 2 ++ src/renderer/components/CueModal/CueModal.tsx | 7 ++++++- .../CuePipelineEditor/CuePipelineEditor.tsx | 15 ++++++++++++++ 9 files changed, 53 insertions(+), 18 deletions(-) diff --git a/docs/maestro-cue-configuration.md b/docs/maestro-cue-configuration.md index 79acd3df3..e3d504d6b 100644 --- a/docs/maestro-cue-configuration.md +++ b/docs/maestro-cue-configuration.md @@ -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 4f2571188..93feacd10 100644 --- a/docs/maestro-cue-events.md +++ b/docs/maestro-cue-events.md @@ -10,7 +10,7 @@ Cue supports eight event types. Each type watches for a different kind of activi 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 `prompt`. +**Required fields:** None beyond the universal `name`, `event`, and either `prompt` or `prompt_file`. **Behavior:** diff --git a/src/__tests__/main/cue/cue-engine.test.ts b/src/__tests__/main/cue/cue-engine.test.ts index f466247c1..873a6ff1c 100644 --- a/src/__tests__/main/cue/cue-engine.test.ts +++ b/src/__tests__/main/cue/cue-engine.test.ts @@ -133,7 +133,7 @@ describe('CueEngine', () => { }); const deps = createMockDeps(); const engine = new CueEngine(deps); - expect(() => engine.start()).toThrow('DB corrupted'); + engine.start(); expect(engine.isEnabled()).toBe(false); }); @@ -143,7 +143,7 @@ describe('CueEngine', () => { }); const deps = createMockDeps(); const engine = new CueEngine(deps); - expect(() => engine.start()).toThrow('DB corrupted'); + engine.start(); expect(deps.onLog).toHaveBeenCalledWith( 'error', expect.stringContaining('Failed to initialize Cue database') @@ -156,7 +156,7 @@ describe('CueEngine', () => { }); const deps = createMockDeps(); const engine = new CueEngine(deps); - expect(() => engine.start()).toThrow('DB corrupted'); + engine.start(); expect(mockLoadCueConfig).not.toHaveBeenCalled(); }); @@ -166,7 +166,7 @@ describe('CueEngine', () => { }); const deps = createMockDeps(); const engine = new CueEngine(deps); - expect(() => engine.start()).toThrow('DB corrupted'); + engine.start(); // Engine is not enabled, so getStatus should return empty expect(engine.getStatus()).toEqual([]); }); @@ -181,7 +181,7 @@ describe('CueEngine', () => { const deps = createMockDeps(); const engine = new CueEngine(deps); - expect(() => engine.start()).toThrow('DB corrupted'); + engine.start(); expect(engine.isEnabled()).toBe(false); engine.start(); diff --git a/src/__tests__/main/cue/cue-sleep-wake.test.ts b/src/__tests__/main/cue/cue-sleep-wake.test.ts index a14ffebfc..dc2f96471 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 throw so IPC callers can surface the error - expect(() => engine.start()).toThrow('DB init failed'); + // 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/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts index 5a743dfd5..b31711098 100644 --- a/src/main/cue/cue-engine.ts +++ b/src/main/cue/cue-engine.ts @@ -91,8 +91,9 @@ export class CueEngine { /** 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(); - /** Whether the engine was started as part of system boot (app launch), not a user feature toggle */ - private isSystemBoot = false; + /** 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; @@ -156,10 +157,10 @@ export class CueEngine { if (this.enabled) return; if (isSystemBoot) { - this.isSystemBoot = true; + this.isBootScan = true; } - // Initialize Cue database and prune old events — bail if this fails + // 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); @@ -171,7 +172,8 @@ export class CueEngine { captureException(error instanceof Error ? error : new Error(String(error)), { extra: { operation: 'cue.dbInit' }, }); - throw error; + this.isBootScan = false; + return; } this.enabled = true; @@ -182,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(); @@ -695,8 +701,8 @@ export class CueEngine { if (sub.agent_id && sub.agent_id !== session.id) continue; if (sub.event !== 'app.startup') continue; - // Only fire on system startup (app launch), not when user toggles Cue feature on/off - if (!this.isSystemBoot) 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; diff --git a/src/main/index.ts b/src/main/index.ts index c93c6fda2..b464f13ff 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(true); + 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 63c9ff4e6..3dc62f9d5 100644 --- a/src/renderer/components/CueHelpModal.tsx +++ b/src/renderer/components/CueHelpModal.tsx @@ -325,6 +325,8 @@ export function CueHelpContent({ theme, cueShortcutKeys }: CueHelpContentProps) - name: "Init Workspace"
{' '}event: app.startup +
+ {' '}prompt: prompts/init.md
# Interval diff --git a/src/renderer/components/CueModal/CueModal.tsx b/src/renderer/components/CueModal/CueModal.tsx index 9a78374ee..923ce73e6 100644 --- a/src/renderer/components/CueModal/CueModal.tsx +++ b/src/renderer/components/CueModal/CueModal.tsx @@ -104,7 +104,12 @@ export function CueModal({ theme, onClose, cueShortcutKeys }: CueModalProps) { notifyToast({ type: 'error', title: 'Cue', - message: err instanceof Error ? err.message : 'Failed to enable Cue engine', + 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 d5d3379e3..1a1362e92 100644 --- a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx +++ b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx @@ -316,6 +316,21 @@ function CuePipelineEditorInner({ } 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; From 309d589f85d6073e99a565689c2fd4951463d4e4 Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Thu, 26 Mar 2026 16:16:53 -0600 Subject: [PATCH 5/6] fix(pipeline): preserve color/name on reload + fitView on view switch - mergePipelinesWithSavedLayout now restores saved color and name from the layout file when pipeline IDs match, preventing colors/names from being re-derived by parse order on every reload - Add fitView effect on selectedPipelineId change so switching from All Pipelines to a single pipeline (or vice versa) auto-centers the viewport on the visible nodes instead of leaving it at the stacked Y-offset position --- .../utils/pipelineLayout.test.ts | 37 +++++++++++++++++++ .../CuePipelineEditor/CuePipelineEditor.tsx | 16 ++++++++ .../CuePipelineEditor/utils/pipelineLayout.ts | 21 +++++++---- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts index c7cfa3642..b4fb632e4 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/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx index 1a1362e92..6829f0266 100644 --- a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx +++ b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx @@ -234,6 +234,22 @@ 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. + const prevSelectedIdRef = useRef(pipelineState.selectedPipelineId); + useEffect(() => { + if (prevSelectedIdRef.current === pipelineState.selectedPipelineId) return; + prevSelectedIdRef.current = pipelineState.selectedPipelineId; + + // 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 diff --git a/src/renderer/components/CuePipelineEditor/utils/pipelineLayout.ts b/src/renderer/components/CuePipelineEditor/utils/pipelineLayout.ts index 7c7e4600c..139a19441 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, From 75edbdf5b4ef91d1b7830f3d739bd5e664f9b29c Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Thu, 26 Mar 2026 16:29:34 -0600 Subject: [PATCH 6/6] fix(pipeline): skip fitView on mount hydration to preserve saved viewport Add hasHydratedSelectionRef guard so the fitView effect skips the initial selectedPipelineId change from layout restoration, preventing it from overwriting the saved viewport restored by usePipelineLayout. Subsequent user-initiated pipeline switches still trigger fitView. --- .../components/CuePipelineEditor/CuePipelineEditor.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx index 6829f0266..ed7dd4ad7 100644 --- a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx +++ b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx @@ -238,11 +238,20 @@ function CuePipelineEditorInner({ // 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 });