diff --git a/.prettierignore b/.prettierignore index adadb7111..64727c86c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ node_modules/ coverage/ *.min.js .gitignore +.prettierignore diff --git a/CLAUDE-IPC.md b/CLAUDE-IPC.md index 1e9c9334b..aa12a746e 100644 --- a/CLAUDE-IPC.md +++ b/CLAUDE-IPC.md @@ -43,6 +43,7 @@ The `window.maestro` API exposes the following namespaces: - `history` - Per-agent execution history (see History API below) - `cli` - CLI activity detection for playbook runs - `tempfile` - Temporary file management for batch processing +- `cue` - Maestro Cue event-driven automation (see Cue API below) ## Analytics & Visualization @@ -74,6 +75,40 @@ window.maestro.history = { **AI Context Integration**: Use `getFilePath(sessionId)` to get the path to an agent's history file. This file can be passed directly to AI agents as context, giving them visibility into past completed tasks, decisions, and work patterns. +## Cue API + +Maestro Cue event-driven automation engine. Gated behind the `maestroCue` Encore Feature flag. + +```typescript +window.maestro.cue = { + // Query engine state + getStatus: () => Promise, + getActiveRuns: () => Promise, + getActivityLog: (limit?) => Promise, + + // Engine controls + enable: () => Promise, + disable: () => Promise, + + // Run management + stopRun: (runId) => Promise, + stopAll: () => Promise, + + // Session config management + refreshSession: (sessionId, projectRoot) => Promise, + + // YAML config file operations + readYaml: (projectRoot) => Promise, + writeYaml: (projectRoot, content) => Promise, + validateYaml: (content) => Promise<{ valid: boolean; errors: string[] }>, + + // Real-time updates + onActivityUpdate: (callback) => () => void, // Returns unsubscribe function +}; +``` + +**Events:** `cue:activityUpdate` is pushed from main process on subscription triggers, run completions, config reloads, and config removals. + ## Power Management - `power` - Sleep prevention: setEnabled, isEnabled, getStatus, addReason, removeReason diff --git a/CLAUDE-PATTERNS.md b/CLAUDE-PATTERNS.md index 2b4d39977..ec3e2494c 100644 --- a/CLAUDE-PATTERNS.md +++ b/CLAUDE-PATTERNS.md @@ -348,9 +348,9 @@ When adding a new Encore Feature, gate **all** access points: 6. **Hamburger menu** — Make the setter optional, conditionally render the menu item in `SessionList.tsx` 7. **Command palette** — Pass `undefined` for the handler in `QuickActionsModal.tsx` (already conditionally renders based on handler existence) -### Reference Implementation: Director's Notes +### Reference Implementations -Director's Notes is the first Encore Feature and serves as the canonical example: +**Director's Notes** — First Encore Feature, canonical example: - **Flag:** `encoreFeatures.directorNotes` in `EncoreFeatureFlags` - **App.tsx gating:** Modal render wrapped in `{encoreFeatures.directorNotes && directorNotesOpen && (…)}`, callback passed as `encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined` @@ -358,6 +358,15 @@ Director's Notes is the first Encore Feature and serves as the canonical example - **Hamburger menu:** `setDirectorNotesOpen` made optional in `SessionList.tsx`, button conditionally rendered with `{setDirectorNotesOpen && (…)}` - **Command palette:** `onOpenDirectorNotes` already conditionally renders in `QuickActionsModal.tsx` — passing `undefined` from App.tsx is sufficient +**Maestro Cue** — Event-driven automation, second Encore Feature: + +- **Flag:** `encoreFeatures.maestroCue` in `EncoreFeatureFlags` +- **App.tsx gating:** Cue modal, hooks (`useCue`, `useCueAutoDiscovery`), and engine lifecycle gated on `encoreFeatures.maestroCue` +- **Keyboard shortcut:** `ctx.encoreFeatures?.maestroCue` guard in `useMainKeyboardHandler.ts` +- **Hamburger menu:** `setMaestroCueOpen` made optional in `SessionList.tsx` +- **Command palette:** `onOpenMaestroCue` conditionally renders in `QuickActionsModal.tsx` +- **Session list:** Cue status indicator (Zap icon) gated on `maestroCueEnabled` + When adding a new Encore Feature, mirror this pattern across all access points. See [CONTRIBUTING.md → Encore Features](CONTRIBUTING.md#encore-features-feature-gating) for the full contributor guide. diff --git a/CLAUDE.md b/CLAUDE.md index 0c9c61127..50803d0de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,11 @@ Use "agent" in user-facing language. Reserve "session" for provider-level conver - **Command Terminal** - Main window in terminal/shell mode - **System Log Viewer** - Special view for system logs (`LogViewer.tsx`) +### Automation + +- **Cue** — Event-driven automation system (Maestro Cue), gated as an Encore Feature. Watches for file changes, time intervals, agent completions, GitHub PRs/issues, and pending markdown tasks to trigger automated prompts. Configured via `maestro-cue.yaml` per project. +- **Cue Modal** — Dashboard for managing Cue subscriptions and viewing activity (`CueModal.tsx`) + ### Agent States (color-coded) - **Green** - Ready/idle @@ -131,9 +136,10 @@ src/ │ ├── preload.ts # Secure IPC bridge │ ├── process-manager.ts # Process spawning (PTY + child_process) │ ├── agent-*.ts # Agent detection, capabilities, session storage +│ ├── cue/ # Maestro Cue event-driven automation engine │ ├── parsers/ # Per-agent output parsers + error patterns │ ├── storage/ # Per-agent session storage implementations -│ ├── ipc/handlers/ # IPC handler modules (stats, git, playbooks, etc.) +│ ├── ipc/handlers/ # IPC handler modules (stats, git, playbooks, cue, etc.) │ └── utils/ # Utilities (execFile, ssh-spawn-wrapper, etc.) │ ├── renderer/ # React frontend (desktop) @@ -202,6 +208,10 @@ src/ | Add Director's Notes feature | `src/renderer/components/DirectorNotes/`, `src/main/ipc/handlers/director-notes.ts` | | Add Encore Feature | `src/renderer/types/index.ts` (flag), `useSettings.ts` (state), `SettingsModal.tsx` (toggle UI), gate in `App.tsx` + keyboard handler | | Modify history components | `src/renderer/components/History/` | +| Add Cue event type | `src/main/cue/cue-types.ts`, `src/main/cue/cue-engine.ts` | +| Add Cue template variable | `src/shared/templateVariables.ts`, `src/main/cue/cue-executor.ts` | +| Modify Cue modal | `src/renderer/components/CueModal.tsx` | +| Configure Cue engine | `src/main/cue/cue-engine.ts`, `src/main/ipc/handlers/cue.ts` | --- diff --git a/docs/docs.json b/docs/docs.json index 069f0c48b..ed0d41f87 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -74,7 +74,15 @@ { "group": "Encore Features", "icon": "flask", - "pages": ["encore-features", "director-notes"] + "pages": [ + "encore-features", + "director-notes", + "maestro-cue", + "maestro-cue-configuration", + "maestro-cue-events", + "maestro-cue-advanced", + "maestro-cue-examples" + ] }, { "group": "Providers & CLI", diff --git a/docs/encore-features.md b/docs/encore-features.md index 9b4928de7..f697ebba8 100644 --- a/docs/encore-features.md +++ b/docs/encore-features.md @@ -16,11 +16,10 @@ Open **Settings** (`Cmd+,` / `Ctrl+,`) and navigate to the **Encore Features** t ## Available Features -| Feature | Shortcut | Description | -| ------------------------------------ | ------------------------------ | --------------------------------------------------------------- | -| [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses | - -More features will be added here as they ship. +| Feature | Shortcut | Description | +| ------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------ | +| [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses | +| [Maestro Cue](./maestro-cue) | `Cmd+Shift+Q` / `Ctrl+Shift+Q` | Event-driven automation: file changes, timers, agent chaining, GitHub polling, and task tracking | ## For Developers diff --git a/docs/maestro-cue-advanced.md b/docs/maestro-cue-advanced.md new file mode 100644 index 000000000..5be71bff1 --- /dev/null +++ b/docs/maestro-cue-advanced.md @@ -0,0 +1,372 @@ +--- +title: Cue Advanced Patterns +description: Fan-in/fan-out, payload filtering, agent chaining, template variables, and concurrency control. +icon: diagram-project +--- + +Cue supports sophisticated automation patterns beyond simple trigger-prompt pairings. This guide covers the advanced features that enable complex multi-agent workflows. + +## Fan-Out + +Fan-out sends a single trigger's prompt to multiple target agents simultaneously. Use this when one event should kick off parallel work across several agents. + +**How it works:** Add a `fan_out` field with a list of agent names. When the trigger fires, Cue spawns a run against each target agent. + +```yaml +subscriptions: + - name: parallel-deploy + event: agent.completed + source_session: 'build-agent' + fan_out: + - 'deploy-staging' + - 'deploy-production' + - 'deploy-docs' + prompt: | + Build completed. Deploy the latest artifacts. + Source output: {{CUE_SOURCE_OUTPUT}} +``` + +In this example, when `build-agent` finishes, Cue sends the same prompt to three different agents in parallel. + +**Notes:** + +- Each fan-out target runs independently — failures in one don't affect others +- All targets receive the same prompt with the same template variable values +- Fan-out targets must be agent names visible in the Left Bar +- Fan-out respects `max_concurrent` — if slots are full, excess runs are queued + +## Fan-In + +Fan-in waits for **multiple** source agents to complete before firing a single trigger. Use this to coordinate work that depends on several agents finishing first. + +**How it works:** Set `source_session` to a list of agent names. Cue waits for all of them to complete before firing the subscription. + +```yaml +subscriptions: + - name: integration-tests + event: agent.completed + source_session: + - 'backend-build' + - 'frontend-build' + - 'api-tests' + prompt: | + All prerequisite agents have completed. + Run the full integration test suite with `npm run test:integration`. + +settings: + timeout_minutes: 60 # Wait up to 60 minutes for all sources + timeout_on_fail: continue # Fire anyway if timeout is reached +``` + +**Behavior:** + +- Cue tracks completions from each source agent independently +- The subscription fires only when **all** listed sources have completed +- If `timeout_on_fail` is `'continue'`, the subscription fires with partial data after the timeout +- If `timeout_on_fail` is `'break'` (default), the subscription is marked as timed out and does not fire +- Completion tracking resets after the subscription fires + +## Filtering + +Filters let you conditionally trigger subscriptions based on event payload data. All filter conditions are AND'd — every condition must pass for the subscription to fire. + +### Filter Syntax + +Filters are key-value pairs where the key is a payload field name and the value is an expression: + +```yaml +filter: + field_name: expression +``` + +### Expression Types + +| Expression | Meaning | Example | +| -------------- | --------------------- | ---------------------- | +| `"value"` | Exact string match | `extension: ".ts"` | +| `123` | Exact numeric match | `exitCode: 0` | +| `true`/`false` | Exact boolean match | `draft: false` | +| `"!value"` | Negation (not equal) | `status: "!failed"` | +| `">=N"` | Greater than or equal | `taskCount: ">=3"` | +| `">N"` | Greater than | `durationMs: ">60000"` | +| `"<=N"` | Less than or equal | `exitCode: "<=1"` | +| `"=3' + prompt: | + {{CUE_TASK_COUNT}} tasks are pending. Work through them in priority order. +``` + +**Skip files in test directories:** + +```yaml +- name: lint-src-only + event: file.changed + watch: '**/*.ts' + filter: + path: '!**/test/**' + prompt: Lint {{CUE_FILE_PATH}}. +``` + +## Agent Chaining + +Agent chaining connects multiple agents in a pipeline where each agent's completion triggers the next. This is built on `agent.completed` events with optional filtering. + +### Simple Chain + +```yaml +subscriptions: + # Step 1: Lint + - name: lint + event: file.changed + watch: 'src/**/*.ts' + prompt: Run the linter on {{CUE_FILE_PATH}}. + + # Step 2: Test (after lint passes) + - name: test-after-lint + event: agent.completed + source_session: 'lint-agent' + filter: + exitCode: 0 + prompt: Lint passed. Run the related test suite. + + # Step 3: Build (after tests pass) + - name: build-after-test + event: agent.completed + source_session: 'test-agent' + filter: + exitCode: 0 + prompt: Tests passed. Build the project with `npm run build`. +``` + +### Diamond Pattern + +Combine fan-out and fan-in for complex workflows: + +``` + ┌─── backend-build ───┐ +trigger ──┤ ├── integration-tests + └─── frontend-build ──┘ +``` + +```yaml +subscriptions: + # Fan-out: trigger both builds + - name: parallel-builds + event: file.changed + watch: 'src/**/*' + fan_out: + - 'backend-agent' + - 'frontend-agent' + prompt: Source changed. Rebuild your component. + + # Fan-in: wait for both, then test + - name: integration-tests + event: agent.completed + source_session: + - 'backend-agent' + - 'frontend-agent' + prompt: Both builds complete. Run integration tests. +``` + +## Template Variables + +All prompts support `{{VARIABLE}}` syntax. Variables are replaced with event payload data before the prompt is sent to the agent. + +### Common Variables (All Events) + +| Variable | Description | +| ------------------------- | ------------------------------ | +| `{{CUE_EVENT_TYPE}}` | Event type that triggered this | +| `{{CUE_EVENT_TIMESTAMP}}` | ISO 8601 timestamp | +| `{{CUE_TRIGGER_NAME}}` | Subscription name | +| `{{CUE_RUN_ID}}` | Unique run UUID | + +### File Variables (`file.changed`, `task.pending`) + +| Variable | Description | +| -------------------------- | -------------------------------------- | +| `{{CUE_FILE_PATH}}` | Absolute file path | +| `{{CUE_FILE_NAME}}` | Filename only | +| `{{CUE_FILE_DIR}}` | Directory path | +| `{{CUE_FILE_EXT}}` | Extension (with dot) | +| `{{CUE_FILE_CHANGE_TYPE}}` | Change type: `add`, `change`, `unlink` | + +### Task Variables (`task.pending`) + +| Variable | Description | +| ------------------------ | --------------------------------------- | +| `{{CUE_TASK_FILE}}` | File path with pending tasks | +| `{{CUE_TASK_FILE_NAME}}` | Filename only | +| `{{CUE_TASK_FILE_DIR}}` | Directory path | +| `{{CUE_TASK_COUNT}}` | Number of pending tasks | +| `{{CUE_TASK_LIST}}` | Formatted list (line number: task text) | +| `{{CUE_TASK_CONTENT}}` | Full file content (truncated to 10K) | + +### Agent Variables (`agent.completed`) + +| Variable | Description | +| ----------------------------- | --------------------------------------------- | +| `{{CUE_SOURCE_SESSION}}` | Source agent name(s) | +| `{{CUE_SOURCE_OUTPUT}}` | Source agent output (truncated to 5K) | +| `{{CUE_SOURCE_STATUS}}` | Run status (`completed`, `failed`, `timeout`) | +| `{{CUE_SOURCE_EXIT_CODE}}` | Process exit code | +| `{{CUE_SOURCE_DURATION}}` | Run duration in milliseconds | +| `{{CUE_SOURCE_TRIGGERED_BY}}` | Subscription that triggered the source run | + +### GitHub Variables (`github.pull_request`, `github.issue`) + +| Variable | Description | PR | Issue | +| ------------------------ | --------------------------- | --- | ----- | +| `{{CUE_GH_TYPE}}` | `pull_request` or `issue` | Y | Y | +| `{{CUE_GH_NUMBER}}` | PR/issue number | Y | Y | +| `{{CUE_GH_TITLE}}` | Title | Y | Y | +| `{{CUE_GH_AUTHOR}}` | Author login | Y | Y | +| `{{CUE_GH_URL}}` | HTML URL | Y | Y | +| `{{CUE_GH_BODY}}` | Body text (truncated) | Y | Y | +| `{{CUE_GH_LABELS}}` | Labels (comma-separated) | Y | Y | +| `{{CUE_GH_STATE}}` | State (`open` / `closed`) | Y | Y | +| `{{CUE_GH_REPO}}` | Repository (`owner/repo`) | Y | Y | +| `{{CUE_GH_BRANCH}}` | Head branch | Y | | +| `{{CUE_GH_BASE_BRANCH}}` | Base branch | Y | | +| `{{CUE_GH_ASSIGNEES}}` | Assignees (comma-separated) | | Y | + +### Standard Variables + +Cue prompts also have access to all standard Maestro template variables (like `{{PROJECT_ROOT}}`, `{{TIMESTAMP}}`, etc.) — the same variables available in Auto Run playbooks and system prompts. + +## Concurrency Control + +Control how many Cue-triggered runs can execute simultaneously and how overflow events are handled. + +### max_concurrent + +Limits parallel runs per agent. When all slots are occupied, new events are queued. + +```yaml +settings: + max_concurrent: 3 # Up to 3 runs at once +``` + +**Range:** 1–10. **Default:** 1 (serial execution). + +With `max_concurrent: 1` (default), events are processed one at a time in order. This is the safest setting — it prevents agents from receiving overlapping prompts. + +Increase `max_concurrent` when your subscriptions are independent and don't conflict with each other (e.g., reviewing different PRs, scanning different files). + +### queue_size + +Controls how many events can wait when all concurrent slots are full. + +```yaml +settings: + queue_size: 20 # Buffer up to 20 events +``` + +**Range:** 0–50. **Default:** 10. + +- Events beyond the queue limit are **dropped** (silently discarded) +- Set to `0` to disable queuing — events that can't run immediately are discarded +- The current queue depth is visible in the Cue Modal's sessions table + +### Timeout + +Prevents runaway agents from blocking the pipeline. + +```yaml +settings: + timeout_minutes: 45 # Kill runs after 45 minutes + timeout_on_fail: continue # Let downstream subscriptions proceed anyway +``` + +**`timeout_on_fail` options:** + +- `break` (default) — Timed-out runs are marked as failed. Downstream `agent.completed` subscriptions see the failure. +- `continue` — Timed-out runs are stopped, but downstream subscriptions still fire with whatever data is available. Useful for fan-in patterns where you'd rather proceed with partial results than block the entire pipeline. + +## Sleep/Wake Reconciliation + +Cue handles system sleep gracefully: + +- **`time.interval`** subscriptions reconcile missed intervals on wake. If your machine sleeps through three intervals, Cue fires one catch-up event (not three). +- **File watchers** (`file.changed`, `task.pending`) resume monitoring on wake. Changes that occurred during sleep may trigger events depending on the OS file system notification behavior. +- **GitHub pollers** resume polling on wake. Any PRs/issues created during sleep are detected on the next poll. + +The engine uses a heartbeat mechanism to detect sleep periods. This is transparent — no configuration needed. + +## Persistence + +Cue persists its state in a local SQLite database: + +- **Event journal** — Records all events (completed, failed, timed out) for the Activity Log +- **GitHub seen tracking** — Remembers which PRs/issues have already triggered events (30-day retention) +- **Heartbeat** — Tracks engine uptime for sleep/wake detection + +Events older than 7 days are automatically pruned to keep the database lean. diff --git a/docs/maestro-cue-configuration.md b/docs/maestro-cue-configuration.md new file mode 100644 index 000000000..ce8bf658e --- /dev/null +++ b/docs/maestro-cue-configuration.md @@ -0,0 +1,243 @@ +--- +title: Cue Configuration Reference +description: Complete YAML schema reference for maestro-cue.yaml configuration files. +icon: file-code +--- + +Cue is configured via a `maestro-cue.yaml` file placed in your project root — the same directory where your Maestro agent runs. The engine watches this file for changes and hot-reloads automatically. + +## File Location + +``` +your-project/ +├── maestro-cue.yaml # Cue configuration +├── src/ +├── package.json +└── ... +``` + +Maestro discovers this file automatically when the Cue Encore Feature is enabled. Each agent that has a `maestro-cue.yaml` in its project root gets its own independent Cue engine instance. + +## Full Schema + +```yaml +# Subscriptions define trigger-prompt pairings +subscriptions: + - name: string # Required. Unique identifier for this subscription + event: string # Required. Event type (see Event Types) + enabled: boolean # Optional. Default: true + prompt: string # Required. Prompt text or path to a .md file + + # Event-specific fields + interval_minutes: number # Required for time.interval + watch: string # Required for file.changed, task.pending (glob pattern) + source_session: string | list # Required for agent.completed + fan_out: list # Optional. Target session names for fan-out + filter: object # Optional. Payload field conditions + repo: string # Optional for github.* (auto-detected if omitted) + poll_minutes: number # Optional for github.*, task.pending + +# Global settings (all optional — sensible defaults applied) +settings: + timeout_minutes: number # Default: 30. Max run duration before timeout + timeout_on_fail: string # Default: 'break'. What to do on timeout: 'break' or 'continue' + max_concurrent: number # Default: 1. Simultaneous runs (1-10) + queue_size: number # Default: 10. Max queued events (0-50) +``` + +## Subscriptions + +Each subscription is a trigger-prompt pairing. When the trigger fires, Cue sends the prompt to the agent. + +### Required Fields + +| Field | Type | Description | +| -------- | ------ | ---------------------------------------------------------------------- | +| `name` | string | Unique identifier. Used in logs, history, and as a reference in chains | +| `event` | string | One of the six [event types](./maestro-cue-events) | +| `prompt` | string | The prompt to send, either inline text or a path to a `.md` file | + +### Optional Fields + +| Field | Type | Default | Description | +| ------------------ | --------------- | ------- | ----------------------------------------------------------------------- | +| `enabled` | boolean | `true` | Set to `false` to pause a subscription without removing it | +| `interval_minutes` | number | — | Timer interval. Required for `time.interval` | +| `watch` | string (glob) | — | File glob pattern. Required for `file.changed`, `task.pending` | +| `source_session` | string or list | — | Source agent name(s). Required for `agent.completed` | +| `fan_out` | list of strings | — | Target agent names to fan out to | +| `filter` | object | — | Payload conditions (see [Filtering](./maestro-cue-advanced#filtering)) | +| `repo` | string | — | GitHub repo (`owner/repo`). Auto-detected from git remote | +| `poll_minutes` | number | varies | Poll interval for `github.*` (default 5) and `task.pending` (default 1) | + +### Prompt Field + +The `prompt` field accepts either inline text or a file path: + +**Inline prompt:** + +```yaml +prompt: | + Please lint the file {{CUE_FILE_PATH}} and fix any errors. +``` + +**File reference:** + +```yaml +prompt: prompts/lint-check.md +``` + +File paths are resolved relative to the project root. Prompt files support the same `{{VARIABLE}}` template syntax as inline prompts. + +### Disabling Subscriptions + +Set `enabled: false` to pause a subscription without deleting it: + +```yaml +subscriptions: + - name: nightly-report + event: time.interval + interval_minutes: 1440 + enabled: false # Paused — won't fire until re-enabled + prompt: Generate a daily summary report. +``` + +## Settings + +The optional `settings` block configures global engine behavior. All fields have sensible defaults — you only need to include settings you want to override. + +### timeout_minutes + +**Default:** `30` | **Type:** positive number + +Maximum duration (in minutes) for a single Cue-triggered run. If an agent takes longer than this, the run is terminated. + +```yaml +settings: + timeout_minutes: 60 # Allow up to 1 hour per run +``` + +### timeout_on_fail + +**Default:** `'break'` | **Type:** `'break'` or `'continue'` + +What happens when a run times out: + +- **`break`** — Stop the run and mark it as failed. No further processing for this event. +- **`continue`** — Stop the run but allow downstream subscriptions (in fan-in chains) to proceed with partial data. + +```yaml +settings: + timeout_on_fail: continue # Don't block the pipeline on slow agents +``` + +### max_concurrent + +**Default:** `1` | **Type:** integer, 1–10 + +Maximum number of Cue-triggered runs that can execute simultaneously for this agent. Additional events are queued. + +```yaml +settings: + max_concurrent: 3 # Allow up to 3 parallel runs +``` + +### queue_size + +**Default:** `10` | **Type:** integer, 0–50 + +Maximum number of events that can be queued when all concurrent slots are occupied. Events beyond this limit are dropped. + +Set to `0` to disable queueing — events that can't run immediately are discarded. + +```yaml +settings: + queue_size: 20 # Buffer up to 20 events +``` + +## Validation + +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 six event types | +| `"prompt" is required` | Provide inline text or a file path | +| `"interval_minutes" is required` | `time.interval` events must specify a positive interval | +| `"watch" is required` | `file.changed` and `task.pending` events need a glob pattern | +| `"source_session" is required` | `agent.completed` events need the name of the source agent | +| `"max_concurrent" must be between 1-10` | Keep concurrent runs within the allowed range | +| `"queue_size" must be between 0-50` | Keep queue size within the allowed range | +| `filter key must be string/number/bool` | Filter values only accept primitive types | + +The inline YAML editor in the Cue Modal shows validation errors in real-time as you type. + +## Complete Example + +A realistic configuration demonstrating multiple event types working together: + +```yaml +subscriptions: + # Lint TypeScript files on save + - name: lint-on-save + event: file.changed + watch: 'src/**/*.ts' + filter: + extension: '.ts' + prompt: | + The file {{CUE_FILE_PATH}} was modified. + Run `npx eslint {{CUE_FILE_PATH}} --fix` and report any remaining issues. + + # Run tests every 30 minutes + - name: periodic-tests + event: time.interval + interval_minutes: 30 + prompt: | + Run the test suite with `npm test`. + If any tests fail, investigate and fix them. + + # Review new PRs automatically + - name: pr-review + event: github.pull_request + poll_minutes: 3 + filter: + draft: false + prompt: | + A new PR needs review: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + URL: {{CUE_GH_URL}} + + {{CUE_GH_BODY}} + + Please review this PR for code quality, potential bugs, and style issues. + + # Work on pending tasks from TODO.md + - name: task-worker + event: task.pending + watch: 'TODO.md' + poll_minutes: 5 + prompt: | + There are {{CUE_TASK_COUNT}} pending tasks in {{CUE_TASK_FILE}}: + + {{CUE_TASK_LIST}} + + Pick the highest priority task and complete it. + When done, check off the task in the file. + + # Chain: deploy after tests pass + - name: deploy-after-tests + event: agent.completed + source_session: 'test-runner' + filter: + status: completed + exitCode: 0 + prompt: | + Tests passed successfully. Deploy to staging with `npm run deploy:staging`. + +settings: + timeout_minutes: 45 + max_concurrent: 2 + queue_size: 15 +``` diff --git a/docs/maestro-cue-events.md b/docs/maestro-cue-events.md new file mode 100644 index 000000000..fe0df4a15 --- /dev/null +++ b/docs/maestro-cue-events.md @@ -0,0 +1,311 @@ +--- +title: Cue Event Types +description: Detailed reference for all six Maestro Cue event types with configuration, payloads, and examples. +icon: calendar-check +--- + +Cue supports six 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). + +## time.interval + +Fires on a periodic timer. The subscription triggers immediately when the engine starts, then repeats at the configured interval. + +**Required fields:** + +| Field | Type | Description | +| ------------------ | ------ | -------------------------------------- | +| `interval_minutes` | number | Minutes between triggers (must be > 0) | + +**Behavior:** + +- Fires immediately on engine start (or when the subscription is first loaded) +- Reconciles missed intervals after system sleep — if your machine sleeps through one or more intervals, Cue fires a catch-up event on wake +- The interval resets after each trigger, not after each run completes + +**Example:** + +```yaml +subscriptions: + - name: hourly-summary + event: time.interval + interval_minutes: 60 + prompt: | + Generate a summary of git activity in the last hour. + Run `git log --oneline --since="1 hour ago"` and organize by author. +``` + +**Payload fields:** None specific to this event type. Use common variables like `{{CUE_TRIGGER_NAME}}` and `{{CUE_EVENT_TIMESTAMP}}`. + +--- + +## file.changed + +Fires when files matching a glob pattern are created, modified, or deleted. + +**Required fields:** + +| Field | Type | Description | +| ------- | ------------- | --------------------------------- | +| `watch` | string (glob) | Glob pattern for files to monitor | + +**Behavior:** + +- Monitors for `add`, `change`, and `unlink` (delete) events +- Debounces by 5 seconds per file — rapid saves to the same file produce a single event +- The glob is evaluated relative to the project root +- Standard glob syntax: `*` matches within a directory, `**` matches across directories + +**Example:** + +```yaml +subscriptions: + - name: test-on-change + event: file.changed + watch: 'src/**/*.{ts,tsx}' + filter: + changeType: '!unlink' # Don't trigger on file deletions + prompt: | + The file {{CUE_FILE_PATH}} was {{CUE_EVENT_TYPE}}. + Run the tests related to this file and report results. +``` + +**Payload fields:** + +| Variable | Description | Example | +| -------------------------- | --------------------------------- | ------------------------- | +| `{{CUE_FILE_PATH}}` | Absolute path to the changed file | `/project/src/app.ts` | +| `{{CUE_FILE_NAME}}` | Filename only | `app.ts` | +| `{{CUE_FILE_DIR}}` | Directory containing the file | `/project/src` | +| `{{CUE_FILE_EXT}}` | File extension (with dot) | `.ts` | +| `{{CUE_FILE_CHANGE_TYPE}}` | Change type | `add`, `change`, `unlink` | + +The `changeType` field is also available in [filters](./maestro-cue-advanced#filtering). + +--- + +## agent.completed + +Fires when another Maestro agent finishes a task. This is the foundation for agent chaining — building multi-step pipelines where one agent's completion triggers the next. + +**Required fields:** + +| Field | Type | Description | +| ---------------- | -------------- | ----------------------------------------------- | +| `source_session` | string or list | Name(s) of the agent(s) to watch for completion | + +**Behavior:** + +- **Single source** (string): Fires immediately when the named agent completes +- **Multiple sources** (list): Waits for **all** named agents to complete before firing (fan-in). See [Fan-In](./maestro-cue-advanced#fan-in) +- The source agent's output is captured and available via `{{CUE_SOURCE_OUTPUT}}` (truncated to 5,000 characters) +- Matches agent names as shown in the Left Bar + +**Example — single source:** + +```yaml +subscriptions: + - name: deploy-after-build + event: agent.completed + source_session: 'builder' + filter: + exitCode: 0 # Only deploy if build succeeded + prompt: | + The build agent completed successfully. + Output: {{CUE_SOURCE_OUTPUT}} + + Deploy to staging with `npm run deploy:staging`. +``` + +**Example — fan-in (multiple sources):** + +```yaml +subscriptions: + - name: integration-tests + event: agent.completed + source_session: + - 'backend-build' + - 'frontend-build' + prompt: | + Both builds completed. Run the full integration test suite. +``` + +**Payload fields:** + +| Variable | Description | Example | +| ----------------------------- | ------------------------------------------------------ | ----------------- | +| `{{CUE_SOURCE_SESSION}}` | Name of the completing agent(s) | `builder` | +| `{{CUE_SOURCE_OUTPUT}}` | Truncated stdout from the source (max 5K chars) | `Build succeeded` | +| `{{CUE_SOURCE_STATUS}}` | Run status (`completed`, `failed`, `timeout`) | `completed` | +| `{{CUE_SOURCE_EXIT_CODE}}` | Process exit code | `0` | +| `{{CUE_SOURCE_DURATION}}` | Run duration in milliseconds | `15000` | +| `{{CUE_SOURCE_TRIGGERED_BY}}` | Name of the subscription that triggered the source run | `lint-on-save` | + +These fields are also available in [filters](./maestro-cue-advanced#filtering). + +The `triggeredBy` field is particularly useful when a source agent has multiple Cue subscriptions but you only want to chain from a specific one. See [Selective Chaining](./maestro-cue-examples#selective-chaining-with-triggeredby) for a complete example. + +--- + +## task.pending + +Watches markdown files for unchecked task items (`- [ ]`) and fires when pending tasks are found. + +**Required fields:** + +| Field | Type | Description | +| ------- | ------------- | --------------------------------------- | +| `watch` | string (glob) | Glob pattern for markdown files to scan | + +**Optional fields:** + +| Field | Type | Default | Description | +| -------------- | ------ | ------- | --------------------------------- | +| `poll_minutes` | number | 1 | Minutes between scans (minimum 1) | + +**Behavior:** + +- Scans files matching the glob pattern at the configured interval +- Fires when unchecked tasks (`- [ ]`) are found +- Only fires when the task list changes (new tasks appear or existing ones are modified) +- The full task list is formatted and available via `{{CUE_TASK_LIST}}` +- File content (truncated to 10K characters) is available via `{{CUE_TASK_CONTENT}}` + +**Example:** + +```yaml +subscriptions: + - name: todo-worker + event: task.pending + watch: '**/*.md' + poll_minutes: 5 + prompt: | + Found {{CUE_TASK_COUNT}} pending tasks in {{CUE_TASK_FILE}}: + + {{CUE_TASK_LIST}} + + Pick the most important task and complete it. + When finished, mark it as done by changing `- [ ]` to `- [x]`. +``` + +**Payload fields:** + +| Variable | Description | Example | +| ------------------------ | ------------------------------------------ | ---------------------- | +| `{{CUE_TASK_FILE}}` | Path to the file containing tasks | `/project/TODO.md` | +| `{{CUE_TASK_FILE_NAME}}` | Filename only | `TODO.md` | +| `{{CUE_TASK_FILE_DIR}}` | Directory containing the file | `/project` | +| `{{CUE_TASK_COUNT}}` | Number of pending tasks found | `3` | +| `{{CUE_TASK_LIST}}` | Formatted list with line numbers | `L5: Write unit tests` | +| `{{CUE_TASK_CONTENT}}` | Full file content (truncated to 10K chars) | _(file contents)_ | + +--- + +## github.pull_request + +Polls GitHub for new pull requests using the GitHub CLI (`gh`). + +**Optional fields:** + +| Field | Type | Default | Description | +| -------------- | ------ | ------- | ---------------------------------------------------------------------------- | +| `repo` | string | auto | GitHub repo in `owner/repo` format. Auto-detected from git remote if omitted | +| `poll_minutes` | number | 5 | Minutes between polls (minimum 1) | + +**Behavior:** + +- Requires the [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated +- On first run, seeds the "seen" list with existing PRs — only **new** PRs trigger events +- Tracks seen PRs in a local database with 30-day retention +- Auto-detects the repository from the git remote if `repo` is not specified + +**Example:** + +```yaml +subscriptions: + - name: pr-reviewer + event: github.pull_request + poll_minutes: 3 + filter: + draft: false # Skip draft PRs + base_branch: main # Only PRs targeting main + prompt: | + New PR: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + Labels: {{CUE_GH_LABELS}} + URL: {{CUE_GH_URL}} + + {{CUE_GH_BODY}} + + Review this PR for: + 1. Code quality and style consistency + 2. Potential bugs or edge cases + 3. Test coverage +``` + +**Payload fields:** + +| Variable | Description | Example | +| ------------------------ | --------------------------------- | ------------------------------------- | +| `{{CUE_GH_TYPE}}` | Always `pull_request` | `pull_request` | +| `{{CUE_GH_NUMBER}}` | PR number | `42` | +| `{{CUE_GH_TITLE}}` | PR title | `Add user authentication` | +| `{{CUE_GH_AUTHOR}}` | Author's GitHub login | `octocat` | +| `{{CUE_GH_URL}}` | HTML URL to the PR | `https://github.com/org/repo/pull/42` | +| `{{CUE_GH_BODY}}` | PR description (truncated) | _(PR body text)_ | +| `{{CUE_GH_LABELS}}` | Comma-separated label names | `bug, priority-high` | +| `{{CUE_GH_STATE}}` | PR state | `open` | +| `{{CUE_GH_BRANCH}}` | Head (source) branch | `feature/auth` | +| `{{CUE_GH_BASE_BRANCH}}` | Base (target) branch | `main` | +| `{{CUE_GH_REPO}}` | Repository in `owner/repo` format | `RunMaestro/Maestro` | + +--- + +## github.issue + +Polls GitHub for new issues using the GitHub CLI (`gh`). Behaves identically to `github.pull_request` but for issues. + +**Optional fields:** + +| Field | Type | Default | Description | +| -------------- | ------ | ------- | ---------------------------------- | +| `repo` | string | auto | GitHub repo in `owner/repo` format | +| `poll_minutes` | number | 5 | Minutes between polls (minimum 1) | + +**Behavior:** + +Same as `github.pull_request` — requires GitHub CLI, seeds on first run, tracks seen issues. + +**Example:** + +```yaml +subscriptions: + - name: issue-triage + event: github.issue + poll_minutes: 5 + filter: + labels: '!wontfix' # Skip issues labeled wontfix + prompt: | + New issue: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Assignees: {{CUE_GH_ASSIGNEES}} + Labels: {{CUE_GH_LABELS}} + + {{CUE_GH_BODY}} + + Triage this issue: + 1. Identify the area of the codebase affected + 2. Estimate complexity (small/medium/large) + 3. Suggest which team member should handle it +``` + +**Payload fields:** + +Same as `github.pull_request`, except: + +| Variable | Description | Example | +| ---------------------- | ------------------------------- | ------------ | +| `{{CUE_GH_TYPE}}` | Always `issue` | `issue` | +| `{{CUE_GH_ASSIGNEES}}` | Comma-separated assignee logins | `alice, bob` | + +The branch-specific variables (`{{CUE_GH_BRANCH}}`, `{{CUE_GH_BASE_BRANCH}}`) are not available for issues. diff --git a/docs/maestro-cue-examples.md b/docs/maestro-cue-examples.md new file mode 100644 index 000000000..c53dd7e4c --- /dev/null +++ b/docs/maestro-cue-examples.md @@ -0,0 +1,407 @@ +--- +title: Cue Examples +description: Real-world Maestro Cue configurations for common automation workflows. +icon: lightbulb +--- + +Complete, copy-paste-ready `maestro-cue.yaml` configurations for common workflows. Each example is self-contained — drop it into your project root and adjust agent names to match your Left Bar. + +## CI-Style Pipeline + +Lint, test, and deploy in sequence. Each step only runs if the previous one succeeded. + +**Agents needed:** `linter`, `tester`, `deployer` + +The `linter` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: lint-on-save + event: file.changed + watch: 'src/**/*.{ts,tsx}' + prompt: | + Run `npx eslint {{CUE_FILE_PATH}} --fix`. + Report any errors that couldn't be auto-fixed. +``` + +The `tester` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: test-after-lint + event: agent.completed + source_session: 'linter' + filter: + status: completed + exitCode: 0 + prompt: | + Lint passed. Run `npm test` and report results. +``` + +The `deployer` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: deploy-after-tests + event: agent.completed + source_session: 'tester' + filter: + status: completed + exitCode: 0 + prompt: | + Tests passed. Deploy to staging with `npm run deploy:staging`. +``` + +--- + +## Selective Chaining with triggeredBy + +When an agent has multiple subscriptions but only one should chain to another agent, use the `triggeredBy` filter. This field contains the subscription name that triggered the completing run. + +**Agents needed:** `worker` (has multiple cue subscriptions), `reviewer` + +The `worker` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + # This one should NOT trigger the reviewer + - name: routine-cleanup + event: time.interval + interval_minutes: 60 + prompt: Run `npm run clean` and remove stale build artifacts. + + # This one should NOT trigger the reviewer either + - name: lint-check + event: file.changed + watch: 'src/**/*.ts' + prompt: Lint {{CUE_FILE_PATH}}. + + # Only THIS one should trigger the reviewer + - name: implement-feature + event: github.issue + filter: + labels: 'enhancement' + prompt: | + New feature request: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + {{CUE_GH_BODY}} + + Implement this feature following existing patterns. +``` + +The `reviewer` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: review-new-feature + event: agent.completed + source_session: 'worker' + filter: + triggeredBy: 'implement-feature' # Only chains from this specific subscription + status: completed + prompt: | + The worker just implemented a feature. Review the changes: + + {{CUE_SOURCE_OUTPUT}} + + Check for: + 1. Code quality and consistency + 2. Missing test coverage + 3. Documentation gaps +``` + +The `triggeredBy` filter also supports glob patterns: `triggeredBy: "implement-*"` matches any subscription name starting with `implement-`. + +--- + +## Research Swarm + +Fan out a question to multiple agents, then fan in to synthesize results. + +**Agents needed:** `coordinator`, `researcher-a`, `researcher-b`, `researcher-c` + +The `coordinator` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + # Fan-out: send the research question to all researchers + - name: dispatch-research + event: file.changed + watch: 'research-question.md' + fan_out: + - 'researcher-a' + - 'researcher-b' + - 'researcher-c' + prompt: | + Research the following question from different angles. + File: {{CUE_FILE_PATH}} + + Read the file and provide a thorough analysis. + + # Fan-in: synthesize when all researchers finish + - name: synthesize-results + event: agent.completed + source_session: + - 'researcher-a' + - 'researcher-b' + - 'researcher-c' + prompt: | + All researchers have completed their analysis. + + Combined outputs: + {{CUE_SOURCE_OUTPUT}} + + Synthesize these perspectives into a single coherent report. + Highlight agreements, contradictions, and key insights. + +settings: + timeout_minutes: 60 + timeout_on_fail: continue # Synthesize with partial results if someone times out +``` + +--- + +## PR Review with Targeted Follow-Up + +Auto-review new PRs, then selectively notify a security reviewer only for PRs that touch auth code. + +**Agents needed:** `pr-reviewer`, `security-reviewer` + +The `pr-reviewer` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: review-all-prs + event: github.pull_request + poll_minutes: 3 + filter: + draft: false + base_branch: main + prompt: | + New PR: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + URL: {{CUE_GH_URL}} + + {{CUE_GH_BODY}} + + Review for code quality, bugs, and style. + In your output, list all files changed. +``` + +The `security-reviewer` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: security-review + event: agent.completed + source_session: 'pr-reviewer' + filter: + triggeredBy: 'review-all-prs' + status: completed + prompt: | + A PR was just reviewed. Check if any auth/security-sensitive files were changed: + + {{CUE_SOURCE_OUTPUT}} + + If auth, session, or permission-related code was modified: + 1. Audit the changes for security vulnerabilities + 2. Check for injection, XSS, or auth bypass risks + 3. Verify proper input validation + + If no security-sensitive files were changed, respond with "No security review needed." +``` + +--- + +## TODO Task Queue + +Watch a markdown file for unchecked tasks and work through them sequentially. + +**Agents needed:** `task-worker` + +```yaml +subscriptions: + - name: work-todos + event: task.pending + watch: 'TODO.md' + poll_minutes: 2 + filter: + taskCount: '>=1' + prompt: | + There are {{CUE_TASK_COUNT}} pending tasks in {{CUE_TASK_FILE}}: + + {{CUE_TASK_LIST}} + + Pick the FIRST unchecked task and complete it. + When done, change `- [ ]` to `- [x]` in the file. + Do NOT work on more than one task at a time. + +settings: + max_concurrent: 1 # Serial execution — one task at a time +``` + +--- + +## Multi-Environment Deploy + +Fan out deployments to staging, production, and docs after a build passes. + +**Agents needed:** `builder`, `deploy-staging`, `deploy-prod`, `deploy-docs` + +The `builder` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: build-on-push + event: file.changed + watch: 'src/**/*' + prompt: | + Source files changed. Run a full build with `npm run build`. + Report success or failure. +``` + +Any agent with visibility to `builder` (e.g., `deploy-staging`): + +```yaml +subscriptions: + - name: fan-out-deploy + event: agent.completed + source_session: 'builder' + filter: + triggeredBy: 'build-on-push' + exitCode: 0 + fan_out: + - 'deploy-staging' + - 'deploy-prod' + - 'deploy-docs' + prompt: | + Build succeeded. Deploy your target environment. + Build output: {{CUE_SOURCE_OUTPUT}} +``` + +--- + +## Issue Triage Bot + +Auto-triage new GitHub issues by labeling and assigning them. + +**Agents needed:** `triage-bot` + +```yaml +subscriptions: + - name: triage-issues + event: github.issue + poll_minutes: 5 + filter: + state: open + labels: '!triaged' # Skip already-triaged issues + prompt: | + New issue needs triage: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Labels: {{CUE_GH_LABELS}} + + {{CUE_GH_BODY}} + + Triage this issue: + 1. Identify the component/area affected + 2. Estimate complexity (small / medium / large) + 3. Suggest priority (P0-P3) + 4. Recommend an assignee based on the area + 5. Run `gh issue edit {{CUE_GH_NUMBER}} --add-label "triaged"` to mark as triaged +``` + +--- + +## Debate Pattern + +Two agents analyze a problem independently, then a third synthesizes their perspectives. + +**Agents needed:** `advocate`, `critic`, `judge` + +The config that triggers the debate (on any agent with visibility): + +```yaml +subscriptions: + - name: start-debate + event: file.changed + watch: 'debate-topic.md' + fan_out: + - 'advocate' + - 'critic' + prompt: | + Read {{CUE_FILE_PATH}} and analyze the proposal. + + You are assigned a role — argue from that perspective: + - advocate: argue IN FAVOR, highlight benefits and opportunities + - critic: argue AGAINST, highlight risks and weaknesses + + Be thorough and specific. +``` + +The `judge` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: synthesize-debate + event: agent.completed + source_session: + - 'advocate' + - 'critic' + prompt: | + Both sides of the debate have been presented. + + Arguments: + {{CUE_SOURCE_OUTPUT}} + + As the judge: + 1. Summarize each side's strongest points + 2. Identify where they agree and disagree + 3. Render a verdict with your reasoning + 4. Propose a path forward that addresses both perspectives + +settings: + timeout_minutes: 45 + timeout_on_fail: continue +``` + +--- + +## Scheduled Report with Conditional Chain + +Generate an hourly report, but only notify a summary agent when there's meaningful activity. + +**Agents needed:** `reporter`, `summarizer` + +The `reporter` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: hourly-git-report + event: time.interval + interval_minutes: 60 + prompt: | + Generate a report of git activity in the last hour. + Run `git log --oneline --since="1 hour ago"`. + + If there are commits, format them as a structured report. + If there are no commits, respond with exactly: "NO_ACTIVITY" +``` + +The `summarizer` agent's `maestro-cue.yaml`: + +```yaml +subscriptions: + - name: summarize-activity + event: agent.completed + source_session: 'reporter' + filter: + triggeredBy: 'hourly-git-report' + status: completed + prompt: | + The hourly reporter just finished. Here's its output: + + {{CUE_SOURCE_OUTPUT}} + + If the output says "NO_ACTIVITY", respond with "Nothing to summarize." + Otherwise, create a concise executive summary of the development activity. +``` diff --git a/docs/maestro-cue.md b/docs/maestro-cue.md new file mode 100644 index 000000000..8ad6f3689 --- /dev/null +++ b/docs/maestro-cue.md @@ -0,0 +1,176 @@ +--- +title: Maestro Cue +description: Event-driven automation that triggers agent prompts in response to file changes, timers, agent completions, GitHub activity, and pending tasks. +icon: bolt +--- + +Maestro Cue is an event-driven automation engine that watches for things happening in your projects and automatically sends prompts to your agents in response. Instead of manually kicking off tasks, you define **subscriptions** — trigger-prompt pairings — in a YAML file, and Cue handles the rest. + + +Maestro Cue is an **Encore Feature** — it's disabled by default. Enable it in **Settings > Encore Features** to access the shortcut, modal, and automation engine. + + +![Encore Features settings panel](./screenshots/encore-features.png) + +## What Can Cue Do? + +A few examples of what you can automate with Cue: + +- **Run linting whenever TypeScript files change** — watch `src/**/*.ts` and prompt an agent to lint on every save +- **Generate a daily standup summary** — fire every 60 minutes to scan recent git activity and draft a report +- **Chain agents together** — when your build agent finishes, automatically trigger a test agent, then a deploy agent +- **Triage new GitHub PRs** — poll for new pull requests and prompt an agent to review the diff +- **Track TODO progress** — scan markdown files for unchecked tasks and prompt an agent to work on the next one +- **Fan out deployments** — when a build completes, trigger multiple deploy agents simultaneously + +## Enabling Cue + +1. Open **Settings** (`Cmd+,` / `Ctrl+,`) +2. Navigate to the **Encore Features** tab +3. Toggle **Maestro Cue** on + +Once enabled, Maestro automatically scans all your active agents for `maestro-cue.yaml` files in their project roots. The Cue engine starts immediately — no restart required. + +## Quick Start + +Create a file called `maestro-cue.yaml` in the root of any project that has an active Maestro agent: + +```yaml +subscriptions: + - name: lint-on-save + event: file.changed + watch: 'src/**/*.ts' + prompt: | + The file {{CUE_FILE_PATH}} was just modified. + Please run the linter and fix any issues. +``` + +That's it. Whenever a `.ts` file in `src/` changes, Cue sends that prompt to the agent with the file path filled in automatically. + +## The Cue Modal + +Open the Cue dashboard to monitor and manage all automation activity. + +**Keyboard shortcut:** + +- macOS: `Cmd+Shift+Q` +- Windows/Linux: `Ctrl+Shift+Q` + +**From Quick Actions:** + +- Press `Cmd+K` / `Ctrl+K` and search for "Maestro Cue" + +### Sessions Table + +The primary view shows all agents that have a `maestro-cue.yaml` file: + + + +| Column | Description | +| ------------------ | ------------------------------------------------ | +| **Session** | Agent name | +| **Agent** | Provider type (Claude Code, Codex, etc.) | +| **Status** | Green dot = active, yellow = paused, gray = none | +| **Last Triggered** | How long ago the most recent event fired | +| **Subs** | Number of subscriptions in the YAML | +| **Queue** | Events waiting to be processed | +| **Edit** | Opens the inline YAML editor for that agent | + +### Active Runs + +Shows currently executing Cue-triggered prompts with elapsed time and which subscription triggered them. + +### Activity Log + +A chronological record of completed and failed runs. Each entry shows: + +- Subscription name and event type +- Status (completed, failed, timeout, stopped) +- Duration +- Timestamp + +### YAML Editor + +Click the edit button on any session row to open the inline YAML editor. Changes are validated in real-time — errors appear immediately so you can fix them before saving. The engine hot-reloads your config automatically when the file changes. + +### Help + +Built-in reference guide accessible from the modal header. Covers configuration syntax, event types, and template variables. + +## Configuration File + +Cue is configured via a `maestro-cue.yaml` file placed in your project root (the same directory where your agent runs). See the [Configuration Reference](./maestro-cue-configuration) for the complete YAML schema. + +## Event Types + +Cue supports six event types that trigger subscriptions: + +| Event Type | Trigger | Key Fields | +| --------------------- | ---------------------------------- | ---------------------- | +| `time.interval` | Periodic timer | `interval_minutes` | +| `file.changed` | File created, modified, or deleted | `watch` (glob pattern) | +| `agent.completed` | Another agent finishes a task | `source_session` | +| `task.pending` | Unchecked markdown tasks found | `watch` (glob pattern) | +| `github.pull_request` | New PR opened on GitHub | `repo` (optional) | +| `github.issue` | New issue opened on GitHub | `repo` (optional) | + +See [Event Types](./maestro-cue-events) for detailed documentation and examples for each type. + +## Template Variables + +Prompts support `{{VARIABLE}}` syntax for injecting event data. When Cue fires a subscription, it replaces template variables with the actual event payload before sending the prompt to the agent. + +```yaml +prompt: | + A new PR was opened: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + URL: {{CUE_GH_URL}} + + Please review this PR and provide feedback. +``` + +See [Advanced Patterns](./maestro-cue-advanced) for the complete template variable reference. + +## Advanced Features + +Cue supports sophisticated automation patterns beyond simple trigger-prompt pairings: + +- **[Fan-out](./maestro-cue-advanced#fan-out)** — One trigger fires against multiple target agents simultaneously +- **[Fan-in](./maestro-cue-advanced#fan-in)** — Wait for multiple agents to complete before triggering +- **[Payload filtering](./maestro-cue-advanced#filtering)** — Conditionally trigger based on event data (glob matching, comparisons, negation) +- **[Agent chaining](./maestro-cue-advanced#agent-chaining)** — Build multi-step pipelines where each agent's output feeds the next +- **[Concurrency control](./maestro-cue-advanced#concurrency-control)** — Limit simultaneous runs and queue overflow events + +See [Advanced Patterns](./maestro-cue-advanced) for full documentation. + +## Keyboard Shortcuts + +| Shortcut | Action | +| ------------------------------ | -------------- | +| `Cmd+Shift+Q` / `Ctrl+Shift+Q` | Open Cue Modal | +| `Esc` | Close modal | + +## History Integration + +Cue-triggered runs appear in the History panel with a teal **CUE** badge. Each entry records: + +- The subscription name that triggered it +- The event type +- The source session (for agent completion chains) + +Filter by CUE entries in the History panel or in Director's Notes (when both Encore Features are enabled) to isolate automated activity from manual work. + +## Requirements + +- **GitHub CLI (`gh`)** — Required only for `github.pull_request` and `github.issue` events. Must be installed and authenticated (`gh auth login`). +- **File watching** — `file.changed` and `task.pending` events use filesystem watchers. No additional dependencies required. + +## Tips + +- **Start simple** — Begin with a single `file.changed` or `time.interval` subscription before building complex chains +- **Use the YAML editor** — The inline editor validates your config in real-time, catching errors before they reach the engine +- **Check the Activity Log** — If a subscription isn't firing, the activity log shows failures with error details +- **Prompt files vs inline** — For complex prompts, point the `prompt` field at a `.md` file instead of inlining YAML +- **Hot reload** — The engine watches `maestro-cue.yaml` for changes and reloads automatically — no need to restart Maestro +- **Template variables** — Use `{{CUE_TRIGGER_NAME}}` in prompts so the agent knows which automation triggered it diff --git a/package-lock.json b/package-lock.json index 7482623e1..dec5dc09e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,9 +36,11 @@ "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", "marked": "^17.0.1", "mermaid": "^11.12.1", "node-pty": "^1.1.0", + "picomatch": "^4.0.3", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", @@ -67,7 +69,9 @@ "@types/better-sqlite3": "^7.6.13", "@types/canvas-confetti": "^1.9.0", "@types/electron-devtools-installer": "^2.2.5", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.6", + "@types/picomatch": "^4.0.2", "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", @@ -264,6 +268,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -667,6 +672,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -710,6 +716,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2283,6 +2290,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2304,6 +2312,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2316,6 +2325,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2331,6 +2341,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -2718,6 +2729,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2734,6 +2746,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -2751,6 +2764,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3809,8 +3823,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4239,6 +4252,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4309,6 +4329,13 @@ "@types/pg": "*" } }, + "node_modules/@types/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -4348,6 +4375,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4359,6 +4387,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4484,6 +4513,7 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4914,6 +4944,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4995,6 +5026,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5130,6 +5162,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/app-builder-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", @@ -5998,6 +6042,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6480,6 +6525,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7205,6 +7251,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -7614,6 +7661,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8111,6 +8159,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8206,8 +8255,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.3.0", @@ -8351,7 +8399,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8365,7 +8412,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8385,7 +8431,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8408,7 +8453,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8425,7 +8469,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -8442,7 +8485,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8457,7 +8499,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8473,7 +8514,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8486,8 +8526,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": { "version": "1.1.1", @@ -8495,7 +8534,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8506,7 +8544,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8517,7 +8554,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8533,7 +8569,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9215,6 +9250,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11134,6 +11170,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11954,6 +11991,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12423,16 +12461,14 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -12445,8 +12481,7 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -12460,8 +12495,7 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -12475,8 +12509,7 @@ "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -12567,7 +12600,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13728,6 +13760,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -14841,12 +14886,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -15065,6 +15111,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15305,7 +15352,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15321,7 +15367,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -15666,6 +15711,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15695,6 +15741,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -15742,6 +15789,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15871,6 +15919,18 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -15928,7 +15988,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17679,19 +17740,6 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -17995,6 +18043,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18368,6 +18417,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18873,6 +18923,7 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19457,25 +19508,13 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest/node_modules/vite": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -20073,6 +20112,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index cdc26a0b7..4b8922d78 100644 --- a/package.json +++ b/package.json @@ -240,9 +240,11 @@ "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", "marked": "^17.0.1", "mermaid": "^11.12.1", "node-pty": "^1.1.0", + "picomatch": "^4.0.3", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", @@ -268,7 +270,9 @@ "@types/better-sqlite3": "^7.6.13", "@types/canvas-confetti": "^1.9.0", "@types/electron-devtools-installer": "^2.2.5", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.6", + "@types/picomatch": "^4.0.2", "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", diff --git a/src/__tests__/main/cue/cue-completion-chains.test.ts b/src/__tests__/main/cue/cue-completion-chains.test.ts new file mode 100644 index 000000000..42bb10b62 --- /dev/null +++ b/src/__tests__/main/cue/cue-completion-chains.test.ts @@ -0,0 +1,592 @@ +/** + * Tests for Cue Engine completion chains (Phase 09). + * + * Tests cover: + * - Completion event emission after Cue runs + * - Completion data in event payloads + * - Session name matching (matching by name, not just ID) + * - Fan-out dispatch to multiple target sessions + * - Fan-in data tracking (output concatenation, session names) + * - Fan-in timeout handling (break and continue modes) + * - hasCompletionSubscribers check + * - clearFanInState cleanup + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/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 crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine completion chains', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockCreateCueFileWatcher.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('completion data in event payload', () => { + it('includes completion data when provided', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a', { + sessionName: 'Agent A', + status: 'completed', + exitCode: 0, + durationMs: 5000, + stdout: 'test output', + triggeredBy: 'some-sub', + }); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + payload: expect.objectContaining({ + sourceSession: 'Agent A', + sourceSessionId: 'agent-a', + status: 'completed', + exitCode: 0, + durationMs: 5000, + sourceOutput: 'test output', + triggeredBy: 'some-sub', + }), + }) + ); + + engine.stop(); + }); + + it('truncates sourceOutput to 5000 chars', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + const longOutput = 'x'.repeat(10000); + engine.notifyAgentCompleted('agent-a', { stdout: longOutput }); + + const call = (deps.onCueRun as ReturnType).mock.calls[0]; + const event = call[2] as CueEvent; + expect((event.payload.sourceOutput as string).length).toBe(5000); + + engine.stop(); + }); + }); + + describe('session name matching', () => { + it('matches by session name when source_session uses name', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Test Session' }), + createMockSession({ id: 'session-2', name: 'Agent Alpha' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-alpha-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'Agent Alpha', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('session-2'); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'on-alpha-done', + }) + ); + + engine.stop(); + }); + }); + + describe('completion event emission (chaining)', () => { + it('emits completion event after Cue run finishes', async () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Source', projectRoot: '/proj1' }), + createMockSession({ id: 'session-2', name: 'Downstream', projectRoot: '/proj2' }), + ]; + + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'do work', + interval_minutes: 60, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'chain', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'Source', + }, + ], + }); + + mockLoadCueConfig.mockImplementation((projectRoot) => { + if (projectRoot === '/proj1') return config1; + if (projectRoot === '/proj2') return config2; + return null; + }); + + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'do work', + expect.objectContaining({ type: 'time.interval' }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-2', + 'follow up', + expect.objectContaining({ type: 'agent.completed', triggerName: 'chain' }) + ); + + engine.stop(); + }); + }); + + describe('fan-out', () => { + it('dispatches to each fan_out target session', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Orchestrator', projectRoot: '/projects/orch' }), + createMockSession({ id: 'session-2', name: 'Frontend', projectRoot: '/projects/fe' }), + createMockSession({ id: 'session-3', name: 'Backend', projectRoot: '/projects/be' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'deploy-all', + event: 'agent.completed', + enabled: true, + prompt: 'deploy', + source_session: 'trigger-session', + fan_out: ['Frontend', 'Backend'], + }, + ], + }); + // Only the orchestrator session owns the subscription + mockLoadCueConfig.mockImplementation((root: string) => + root === '/projects/orch' ? config : null + ); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('trigger-session'); + + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-2', + 'deploy', + expect.objectContaining({ + payload: expect.objectContaining({ fanOutSource: 'trigger-session', fanOutIndex: 0 }), + }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-3', + 'deploy', + expect.objectContaining({ + payload: expect.objectContaining({ fanOutSource: 'trigger-session', fanOutIndex: 1 }), + }) + ); + + engine.stop(); + }); + + it('logs fan-out dispatch', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Orchestrator', projectRoot: '/projects/orch' }), + createMockSession({ id: 'session-2', name: 'Frontend', projectRoot: '/projects/fe' }), + createMockSession({ id: 'session-3', name: 'Backend', projectRoot: '/projects/be' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'deploy-all', + event: 'agent.completed', + enabled: true, + prompt: 'deploy', + source_session: 'trigger-session', + fan_out: ['Frontend', 'Backend'], + }, + ], + }); + mockLoadCueConfig.mockImplementation((root: string) => + root === '/projects/orch' ? config : null + ); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('trigger-session'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Fan-out: "deploy-all" → Frontend, Backend') + ); + + engine.stop(); + }); + + it('skips missing fan-out targets with log', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Orchestrator', projectRoot: '/projects/orch' }), + createMockSession({ id: 'session-2', name: 'Frontend', projectRoot: '/projects/fe' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'deploy-all', + event: 'agent.completed', + enabled: true, + prompt: 'deploy', + source_session: 'trigger-session', + fan_out: ['Frontend', 'NonExistent'], + }, + ], + }); + mockLoadCueConfig.mockImplementation((root: string) => + root === '/projects/orch' ? config : null + ); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('trigger-session'); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Fan-out target not found: "NonExistent"') + ); + + engine.stop(); + }); + }); + + describe('fan-in data tracking', () => { + it('concatenates fan-in source outputs in event payload', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + engine.notifyAgentCompleted('agent-a', { sessionName: 'Agent A', stdout: 'output-a' }); + engine.notifyAgentCompleted('agent-b', { sessionName: 'Agent B', stdout: 'output-b' }); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + payload: expect.objectContaining({ + sourceOutput: 'output-a\n---\noutput-b', + sourceSession: 'Agent A, Agent B', + }), + }) + ); + + engine.stop(); + }); + + it('logs waiting message during fan-in', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b', 'agent-c'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('waiting for 2 more session(s)') + ); + + engine.stop(); + }); + }); + + describe('fan-in timeout', () => { + it('clears tracker on timeout in break mode', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + settings: { timeout_minutes: 1, timeout_on_fail: 'break' }, + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1 * 60 * 1000 + 100); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('timed out (break mode)') + ); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('fires with partial data on timeout in continue mode', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + settings: { timeout_minutes: 1, timeout_on_fail: 'continue' }, + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a', { stdout: 'partial-output' }); + + vi.advanceTimersByTime(1 * 60 * 1000 + 100); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + payload: expect.objectContaining({ + partial: true, + timedOutSessions: expect.arrayContaining(['agent-b']), + }), + }) + ); + + engine.stop(); + }); + }); + + describe('hasCompletionSubscribers', () => { + it('returns true when subscribers exist for a session', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Source' }), + createMockSession({ id: 'session-2', name: 'Listener' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-source-done', + event: 'agent.completed', + enabled: true, + prompt: 'react', + source_session: 'Source', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + expect(engine.hasCompletionSubscribers('session-1')).toBe(true); + expect(engine.hasCompletionSubscribers('session-2')).toBe(false); + expect(engine.hasCompletionSubscribers('unknown')).toBe(false); + + engine.stop(); + }); + + it('returns false when engine is disabled', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.hasCompletionSubscribers('any')).toBe(false); + }); + }); + + describe('clearFanInState', () => { + it('clears fan-in trackers for a specific session', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.notifyAgentCompleted('agent-a'); + vi.clearAllMocks(); + + engine.clearFanInState('session-1'); + + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-concurrency.test.ts b/src/__tests__/main/cue/cue-concurrency.test.ts new file mode 100644 index 000000000..cba31c875 --- /dev/null +++ b/src/__tests__/main/cue/cue-concurrency.test.ts @@ -0,0 +1,636 @@ +/** + * Tests for per-session concurrency control and event queuing. + * + * Tests cover: + * - Concurrency limits (max_concurrent) gate event dispatch + * - Event queuing when at concurrency limit + * - Queue draining when slots free + * - Queue overflow (oldest entry dropped) + * - Stale event eviction during drain + * - Queue cleanup on stopAll, removeSession, and stop + * - getQueueStatus() and clearQueue() public API + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/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 crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine Concurrency Control', () => { + let yamlWatcherCleanup: ReturnType; + let fileWatcherCleanup: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + yamlWatcherCleanup = vi.fn(); + mockWatchCueYaml.mockReturnValue(yamlWatcherCleanup); + + fileWatcherCleanup = vi.fn(); + mockCreateCueFileWatcher.mockReturnValue(fileWatcherCleanup); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('max_concurrent enforcement', () => { + it('allows dispatching when below max_concurrent', async () => { + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 3, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Initial fire should dispatch (1/3 concurrent) + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + engine.stop(); + }); + + it('queues events when at max_concurrent limit', async () => { + // Create a never-resolving onCueRun to keep runs active + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + // Allow the initial fire to start (never completes) + await vi.advanceTimersByTimeAsync(10); + + // First call dispatched + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Trigger another interval — should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + // Still only 1 call — the second was queued + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Verify queue has an entry + const queueStatus = engine.getQueueStatus(); + expect(queueStatus.get('session-1')).toBe(1); + + engine.stopAll(); + engine.stop(); + }); + + it('logs queue activity with correct format', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 5, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + + // Trigger another interval — should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Event queued for "Test Session"') + ); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('1/5 in queue')); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('queue draining', () => { + it('dequeues and dispatches when a slot frees up', async () => { + let resolveRun: ((val: CueRunResult) => void) | undefined; + const deps = createMockDeps({ + onCueRun: vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Trigger another — should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + // Complete the first run — should drain the queue + resolveRun!({ + runId: 'r1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(10); + + // The queued event should now be dispatched + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + // Queue should be empty + expect(engine.getQueueStatus().size).toBe(0); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('queue overflow', () => { + it('drops oldest entry when queue is full', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 2, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + + // Fill the queue (size 2) + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 1 + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 2 + + expect(engine.getQueueStatus().get('session-1')).toBe(2); + + // Overflow — should drop oldest + vi.advanceTimersByTime(1 * 60 * 1000); // queued: still 2, but oldest dropped + + expect(engine.getQueueStatus().get('session-1')).toBe(2); + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Queue full for "Test Session", dropping oldest event') + ); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('stale event eviction', () => { + it('drops stale events during drain', async () => { + let resolveRun: ((val: CueRunResult) => void) | undefined; + const deps = createMockDeps({ + onCueRun: vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 1, // 1 minute timeout + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Queue an event + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + // Wait long enough for the queued event to become stale (> 1 minute) + vi.advanceTimersByTime(2 * 60 * 1000); + + // Complete the first run — drain should evict the stale event + resolveRun!({ + runId: 'r1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(10); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Dropping stale queued event') + ); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('queue cleanup', () => { + it('stopAll clears all queues', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.stopAll(); + expect(engine.getQueueStatus().size).toBe(0); + engine.stop(); + }); + + it('removeSession clears queue for that session', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.removeSession('session-1'); + expect(engine.getQueueStatus().size).toBe(0); + engine.stop(); + }); + + it('engine stop clears all queues', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.stop(); + expect(engine.getQueueStatus().size).toBe(0); + }); + }); + + describe('clearQueue', () => { + it('clears queued events for a specific session', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(2); + + engine.clearQueue('session-1'); + expect(engine.getQueueStatus().size).toBe(0); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('getQueueStatus', () => { + it('returns empty map when no events are queued', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(engine.getQueueStatus().size).toBe(0); + engine.stop(); + }); + + it('returns correct count per session', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + + expect(engine.getQueueStatus().get('session-1')).toBe(3); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('multi-concurrent slots', () => { + it('allows multiple concurrent runs up to max_concurrent', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 3, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); // Initial fire + + // Trigger 2 more intervals — all should dispatch (3 slots) + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(3); + expect(engine.getQueueStatus().size).toBe(0); // Nothing queued + + // 4th trigger should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(3); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.stopAll(); + engine.stop(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-db.test.ts b/src/__tests__/main/cue/cue-db.test.ts new file mode 100644 index 000000000..2f9884233 --- /dev/null +++ b/src/__tests__/main/cue/cue-db.test.ts @@ -0,0 +1,420 @@ +/** + * Tests for the Cue Database module (cue-db.ts). + * + * Note: better-sqlite3 is a native module compiled for Electron's Node version. + * These tests use a mocked database to verify the logic without requiring the + * native module. The mock validates that the correct SQL statements and parameters + * are passed to better-sqlite3. + * + * Tests cover: + * - Database initialization and lifecycle + * - Event recording, status updates, and retrieval + * - Heartbeat write and read + * - Event pruning (housekeeping) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as path from 'path'; +import * as os from 'os'; + +// Store parameters passed to mock statement methods +const runCalls: unknown[][] = []; +const getCalls: unknown[][] = []; +const allCalls: unknown[][] = []; +let mockGetReturn: unknown = undefined; +let mockAllReturn: unknown[] = []; + +const mockStatement = { + run: vi.fn((...args: unknown[]) => { + runCalls.push(args); + return { changes: 1 }; + }), + get: vi.fn((...args: unknown[]) => { + getCalls.push(args); + return mockGetReturn; + }), + all: vi.fn((...args: unknown[]) => { + allCalls.push(args); + return mockAllReturn; + }), +}; + +const prepareCalls: string[] = []; + +const mockDb = { + pragma: vi.fn(), + prepare: vi.fn((sql: string) => { + prepareCalls.push(sql); + return mockStatement; + }), + close: vi.fn(), +}; + +vi.mock('better-sqlite3', () => ({ + default: class MockDatabase { + constructor() { + /* noop */ + } + pragma = mockDb.pragma; + prepare = mockDb.prepare; + close = mockDb.close; + }, +})); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => os.tmpdir()), + }, +})); + +import { + initCueDb, + closeCueDb, + isCueDbReady, + recordCueEvent, + updateCueEventStatus, + getRecentCueEvents, + updateHeartbeat, + getLastHeartbeat, + pruneCueEvents, + isGitHubItemSeen, + markGitHubItemSeen, + hasAnyGitHubSeen, + pruneGitHubSeen, + clearGitHubSeenForSubscription, +} from '../../../main/cue/cue-db'; + +beforeEach(() => { + vi.clearAllMocks(); + runCalls.length = 0; + getCalls.length = 0; + allCalls.length = 0; + prepareCalls.length = 0; + mockGetReturn = undefined; + mockAllReturn = []; + + // Ensure the module's internal db is reset + closeCueDb(); +}); + +afterEach(() => { + closeCueDb(); +}); + +describe('cue-db lifecycle', () => { + it('should report ready after initialization', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + expect(isCueDbReady()).toBe(true); + }); + + it('should report not ready after close', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + closeCueDb(); + expect(isCueDbReady()).toBe(false); + }); + + it('should not double-initialize', () => { + const dbPath = path.join(os.tmpdir(), 'test-cue.db'); + initCueDb(undefined, dbPath); + const callCountAfterFirst = mockDb.pragma.mock.calls.length; + + initCueDb(undefined, dbPath); + // No new pragma calls because it short-circuited + expect(mockDb.pragma.mock.calls.length).toBe(callCountAfterFirst); + }); + + it('should set WAL mode on initialization', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + expect(mockDb.pragma).toHaveBeenCalledWith('journal_mode = WAL'); + }); + + it('should create tables and indexes on initialization', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + + // Should have prepared CREATE TABLE and CREATE INDEX statements + expect(prepareCalls.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS cue_events'))).toBe( + true + ); + expect( + prepareCalls.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS cue_heartbeat')) + ).toBe(true); + expect(prepareCalls.some((sql) => sql.includes('idx_cue_events_created'))).toBe(true); + expect(prepareCalls.some((sql) => sql.includes('idx_cue_events_session'))).toBe(true); + expect( + prepareCalls.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS cue_github_seen')) + ).toBe(true); + expect(prepareCalls.some((sql) => sql.includes('idx_cue_github_seen_at'))).toBe(true); + }); + + it('should throw when accessing before initialization', () => { + expect(() => + recordCueEvent({ + id: 'test-1', + type: 'time.interval', + triggerName: 'test', + sessionId: 'session-1', + subscriptionName: 'test-sub', + status: 'running', + }) + ).toThrow('Cue database not initialized'); + }); + + it('should close the database', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + closeCueDb(); + expect(mockDb.close).toHaveBeenCalled(); + }); +}); + +describe('cue-db event journal', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + prepareCalls.length = 0; + }); + + it('should record an event with correct parameters', () => { + recordCueEvent({ + id: 'evt-1', + type: 'time.interval', + triggerName: 'my-trigger', + sessionId: 'session-1', + subscriptionName: 'periodic-check', + status: 'running', + }); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR REPLACE INTO cue_events') + ); + expect(runCalls.length).toBeGreaterThan(0); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('evt-1'); // id + expect(lastRun[1]).toBe('time.interval'); // type + expect(lastRun[2]).toBe('my-trigger'); // trigger_name + expect(lastRun[3]).toBe('session-1'); // session_id + expect(lastRun[4]).toBe('periodic-check'); // subscription_name + expect(lastRun[5]).toBe('running'); // status + expect(typeof lastRun[6]).toBe('number'); // created_at (timestamp) + expect(lastRun[7]).toBeNull(); // payload (null when not provided) + }); + + it('should record an event with payload', () => { + const payload = JSON.stringify({ reconciled: true, missedCount: 3 }); + recordCueEvent({ + id: 'evt-2', + type: 'time.interval', + triggerName: 'cron-trigger', + sessionId: 'session-2', + subscriptionName: 'cron-sub', + status: 'completed', + payload, + }); + + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[7]).toBe(payload); + }); + + it('should update event status with completed_at timestamp', () => { + updateCueEventStatus('evt-3', 'completed'); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('UPDATE cue_events SET status') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('completed'); // status + expect(typeof lastRun[1]).toBe('number'); // completed_at + expect(lastRun[2]).toBe('evt-3'); // id + }); + + it('should query recent events with correct since parameter', () => { + const since = Date.now() - 1000; + getRecentCueEvents(since); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('FROM cue_events WHERE created_at >=') + ); + const lastAll = allCalls[allCalls.length - 1]; + expect(lastAll[0]).toBe(since); + }); + + it('should query recent events with limit', () => { + const since = Date.now() - 1000; + getRecentCueEvents(since, 10); + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('LIMIT')); + const lastAll = allCalls[allCalls.length - 1]; + expect(lastAll[0]).toBe(since); + expect(lastAll[1]).toBe(10); + }); + + it('should map row data to CueEventRecord correctly', () => { + mockAllReturn = [ + { + id: 'evt-mapped', + type: 'file.changed', + trigger_name: 'file-trigger', + session_id: 'session-1', + subscription_name: 'file-sub', + status: 'completed', + created_at: 1000000, + completed_at: 1000500, + payload: '{"file":"test.ts"}', + }, + ]; + + const events = getRecentCueEvents(0); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + id: 'evt-mapped', + type: 'file.changed', + triggerName: 'file-trigger', + sessionId: 'session-1', + subscriptionName: 'file-sub', + status: 'completed', + createdAt: 1000000, + completedAt: 1000500, + payload: '{"file":"test.ts"}', + }); + }); +}); + +describe('cue-db heartbeat', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + getCalls.length = 0; + prepareCalls.length = 0; + }); + + it('should write heartbeat with INSERT OR REPLACE', () => { + updateHeartbeat(); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR REPLACE INTO cue_heartbeat') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(typeof lastRun[0]).toBe('number'); // current timestamp + }); + + it('should return null when no heartbeat exists', () => { + mockGetReturn = undefined; + const result = getLastHeartbeat(); + expect(result).toBeNull(); + }); + + it('should return the last_seen value when heartbeat exists', () => { + mockGetReturn = { last_seen: 1234567890 }; + const result = getLastHeartbeat(); + expect(result).toBe(1234567890); + }); +}); + +describe('cue-db pruning', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + prepareCalls.length = 0; + }); + + it('should delete events older than specified age', () => { + const olderThanMs = 7 * 24 * 60 * 60 * 1000; + const before = Date.now(); + pruneCueEvents(olderThanMs); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM cue_events WHERE created_at < ?') + ); + const lastRun = runCalls[runCalls.length - 1]; + const cutoff = lastRun[0] as number; + // The cutoff should be approximately Date.now() - olderThanMs + expect(cutoff).toBeLessThanOrEqual(before); + expect(cutoff).toBeGreaterThan(before - olderThanMs - 1000); + }); +}); + +describe('cue-db github seen tracking', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + getCalls.length = 0; + prepareCalls.length = 0; + mockGetReturn = undefined; + }); + + it('isGitHubItemSeen should return false when item not found', () => { + mockGetReturn = undefined; + const result = isGitHubItemSeen('sub-1', 'pr:owner/repo:123'); + expect(result).toBe(false); + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining( + 'SELECT 1 FROM cue_github_seen WHERE subscription_id = ? AND item_key = ?' + ) + ); + const lastGet = getCalls[getCalls.length - 1]; + expect(lastGet[0]).toBe('sub-1'); + expect(lastGet[1]).toBe('pr:owner/repo:123'); + }); + + it('isGitHubItemSeen should return true when item exists', () => { + mockGetReturn = { '1': 1 }; + const result = isGitHubItemSeen('sub-1', 'pr:owner/repo:123'); + expect(result).toBe(true); + }); + + it('markGitHubItemSeen should INSERT OR IGNORE with correct parameters', () => { + markGitHubItemSeen('sub-1', 'pr:owner/repo:456'); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO cue_github_seen') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('sub-1'); + expect(lastRun[1]).toBe('pr:owner/repo:456'); + expect(typeof lastRun[2]).toBe('number'); // seen_at + }); + + it('hasAnyGitHubSeen should return false when no records exist', () => { + mockGetReturn = undefined; + const result = hasAnyGitHubSeen('sub-1'); + expect(result).toBe(false); + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('SELECT 1 FROM cue_github_seen WHERE subscription_id = ? LIMIT 1') + ); + const lastGet = getCalls[getCalls.length - 1]; + expect(lastGet[0]).toBe('sub-1'); + }); + + it('hasAnyGitHubSeen should return true when records exist', () => { + mockGetReturn = { '1': 1 }; + const result = hasAnyGitHubSeen('sub-1'); + expect(result).toBe(true); + }); + + it('pruneGitHubSeen should delete old records with correct cutoff', () => { + const olderThanMs = 30 * 24 * 60 * 60 * 1000; + const before = Date.now(); + pruneGitHubSeen(olderThanMs); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM cue_github_seen WHERE seen_at < ?') + ); + const lastRun = runCalls[runCalls.length - 1]; + const cutoff = lastRun[0] as number; + expect(cutoff).toBeLessThanOrEqual(before); + expect(cutoff).toBeGreaterThan(before - olderThanMs - 1000); + }); + + it('clearGitHubSeenForSubscription should delete all records for a subscription', () => { + clearGitHubSeenForSubscription('sub-1'); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM cue_github_seen WHERE subscription_id = ?') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('sub-1'); + }); +}); diff --git a/src/__tests__/main/cue/cue-engine.test.ts b/src/__tests__/main/cue/cue-engine.test.ts new file mode 100644 index 000000000..e8f96a133 --- /dev/null +++ b/src/__tests__/main/cue/cue-engine.test.ts @@ -0,0 +1,1156 @@ +/** + * Tests for the Cue Engine core. + * + * Tests cover: + * - Engine lifecycle (start, stop, isEnabled) + * - Session initialization from YAML configs + * - Timer-based subscriptions (time.interval) + * - File watcher subscriptions (file.changed) + * - Agent completion subscriptions (agent.completed) + * - Fan-in tracking for multi-source agent.completed + * - Active run tracking and stopping + * - Activity log ring buffer + * - Session refresh and removal + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/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 crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine', () => { + let yamlWatcherCleanup: ReturnType; + let fileWatcherCleanup: ReturnType; + + let gitHubPollerCleanup: ReturnType; + let taskScannerCleanup: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + yamlWatcherCleanup = vi.fn(); + mockWatchCueYaml.mockReturnValue(yamlWatcherCleanup); + + fileWatcherCleanup = vi.fn(); + mockCreateCueFileWatcher.mockReturnValue(fileWatcherCleanup); + + gitHubPollerCleanup = vi.fn(); + mockCreateCueGitHubPoller.mockReturnValue(gitHubPollerCleanup); + + taskScannerCleanup = vi.fn(); + mockCreateCueTaskScanner.mockReturnValue(taskScannerCleanup); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('lifecycle', () => { + it('starts as disabled', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.isEnabled()).toBe(false); + }); + + it('becomes enabled after start()', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + expect(engine.isEnabled()).toBe(true); + }); + + it('becomes disabled after stop()', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + engine.stop(); + expect(engine.isEnabled()).toBe(false); + }); + + it('logs start and stop events', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('started')); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('stopped')); + }); + }); + + describe('session initialization', () => { + it('scans all sessions on start', () => { + const sessions = [ + createMockSession({ id: 's1', projectRoot: '/proj1' }), + createMockSession({ id: 's2', projectRoot: '/proj2' }), + ]; + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockLoadCueConfig).toHaveBeenCalledWith('/proj1'); + expect(mockLoadCueConfig).toHaveBeenCalledWith('/proj2'); + }); + + it('skips sessions without a cue config', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(engine.getStatus()).toHaveLength(0); + }); + + it('initializes sessions with valid config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(1); + }); + + it('sets up YAML file watcher for config changes', () => { + mockLoadCueConfig.mockReturnValue(createMockConfig()); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockWatchCueYaml).toHaveBeenCalled(); + }); + }); + + describe('time.interval subscriptions', () => { + it('fires immediately on setup', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'Run check', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Should fire immediately + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'Run check', + expect.objectContaining({ type: 'time.interval', triggerName: 'periodic' }) + ); + }); + + it('fires on the interval', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'Run check', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Flush microtasks to let the initial run complete and free the concurrency slot + await vi.advanceTimersByTimeAsync(0); + vi.clearAllMocks(); + + // Advance 5 minutes + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Advance another 5 minutes + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + engine.stop(); + }); + + it('skips disabled subscriptions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'disabled', + event: 'time.interval', + enabled: false, + prompt: 'noop', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + engine.stop(); + }); + + it('clears timers on stop', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.stop(); + + vi.advanceTimersByTime(60 * 1000); + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + }); + + describe('file.changed subscriptions', () => { + it('creates a file watcher with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'watch-src', + event: 'file.changed', + enabled: true, + prompt: 'lint', + watch: 'src/**/*.ts', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueFileWatcher).toHaveBeenCalledWith( + expect.objectContaining({ + watchGlob: 'src/**/*.ts', + projectRoot: '/projects/test', + debounceMs: 5000, + triggerName: 'watch-src', + }) + ); + + engine.stop(); + }); + + it('cleans up file watcher on stop', () => { + const config = createMockConfig({ + subscriptions: [ + { name: 'watch', event: 'file.changed', enabled: true, prompt: 'test', watch: '**/*.ts' }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + engine.stop(); + + expect(fileWatcherCleanup).toHaveBeenCalled(); + }); + }); + + describe('agent.completed subscriptions', () => { + it('fires for single source_session match', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'on-done', + }) + ); + }); + + it('does not fire for non-matching session', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-b'); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + + it('tracks fan-in completions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + // First completion — should not fire + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + // Second completion — should fire + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'all-done', + }) + ); + }); + + it('resets fan-in tracker after firing', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + engine.notifyAgentCompleted('agent-a'); + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + + // Start again — should need both to fire again + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + }); + + describe('session management', () => { + it('removeSession tears down subscriptions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.removeSession('session-1'); + expect(engine.getStatus()).toHaveLength(0); + expect(yamlWatcherCleanup).toHaveBeenCalled(); + }); + + it('refreshSession re-reads config', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'old', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'new-1', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + { + name: 'new-2', + event: 'time.interval', + enabled: true, + prompt: 'test2', + interval_minutes: 15, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config1).mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.refreshSession('session-1', '/projects/test'); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(2); + }); + }); + + describe('YAML hot reload', () => { + it('logs "Config reloaded" with subscription count when config changes', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'old-sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'new-sub-1', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + { + name: 'new-sub-2', + event: 'time.interval', + enabled: true, + prompt: 'test2', + interval_minutes: 15, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config1).mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.refreshSession('session-1', '/projects/test'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config reloaded for "Test Session" (2 subscriptions)'), + expect.objectContaining({ type: 'configReloaded', sessionId: 'session-1' }) + ); + }); + + it('passes data to onLog for IPC push on config reload', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.refreshSession('session-1', '/projects/test'); + + // Verify data parameter is passed (triggers cue:activityUpdate in main process) + const reloadCall = (deps.onLog as ReturnType).mock.calls.find( + (call: unknown[]) => typeof call[1] === 'string' && call[1].includes('Config reloaded') + ); + expect(reloadCall).toBeDefined(); + expect(reloadCall![2]).toEqual( + expect.objectContaining({ type: 'configReloaded', sessionId: 'session-1' }) + ); + + engine.stop(); + }); + + it('logs "Config removed" when YAML file is deleted', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + // First call returns config (initial load), second returns null (file deleted) + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.refreshSession('session-1', '/projects/test'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config removed for "Test Session"'), + expect.objectContaining({ type: 'configRemoved', sessionId: 'session-1' }) + ); + expect(engine.getStatus()).toHaveLength(0); + }); + + it('sets up a pending yaml watcher after config deletion for re-creation', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const initialWatchCalls = mockWatchCueYaml.mock.calls.length; + engine.refreshSession('session-1', '/projects/test'); + + // A new yaml watcher should be created for watching re-creation + expect(mockWatchCueYaml.mock.calls.length).toBe(initialWatchCalls + 1); + }); + + it('recovers when config file is re-created after deletion', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'original', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'recreated', + event: 'time.interval', + enabled: true, + prompt: 'test2', + interval_minutes: 10, + }, + ], + }); + // First: initial config, second: null (deleted), third: new config (re-created) + mockLoadCueConfig + .mockReturnValueOnce(config1) + .mockReturnValueOnce(null) + .mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Delete config + engine.refreshSession('session-1', '/projects/test'); + expect(engine.getStatus()).toHaveLength(0); + + // Capture the pending yaml watcher callback + const lastWatchCall = mockWatchCueYaml.mock.calls[mockWatchCueYaml.mock.calls.length - 1]; + const pendingOnChange = lastWatchCall[1] as () => void; + + // Simulate file re-creation by invoking the watcher callback + pendingOnChange(); + + // Session should be re-initialized with the new config + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(1); + }); + + it('cleans up pending yaml watchers on engine stop', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const pendingCleanup = vi.fn(); + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + mockWatchCueYaml.mockReturnValueOnce(yamlWatcherCleanup).mockReturnValue(pendingCleanup); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Delete config — creates pending yaml watcher + engine.refreshSession('session-1', '/projects/test'); + + // Stop engine — should clean up pending watcher + engine.stop(); + expect(pendingCleanup).toHaveBeenCalled(); + }); + + it('cleans up pending yaml watchers on removeSession', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const pendingCleanup = vi.fn(); + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + mockWatchCueYaml.mockReturnValueOnce(yamlWatcherCleanup).mockReturnValue(pendingCleanup); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Delete config — creates pending yaml watcher + engine.refreshSession('session-1', '/projects/test'); + + // Remove session — should clean up pending watcher + engine.removeSession('session-1'); + expect(pendingCleanup).toHaveBeenCalled(); + }); + + it('triggers refresh via yaml watcher callback on file change', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Capture the yaml watcher callback + const watchCall = mockWatchCueYaml.mock.calls[0]; + const onChange = watchCall[1] as () => void; + + vi.clearAllMocks(); + mockLoadCueConfig.mockReturnValue(config); + mockWatchCueYaml.mockReturnValue(vi.fn()); + + // Simulate file change by invoking the watcher callback + onChange(); + + // refreshSession should have been called (loadCueConfig invoked for re-init) + expect(mockLoadCueConfig).toHaveBeenCalledWith('/projects/test'); + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config reloaded'), + expect.any(Object) + ); + }); + + it('does not log "Config removed" when session never had config', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + // Session never had a config, so refreshSession with null should not log "Config removed" + engine.refreshSession('session-1', '/projects/test'); + + const removedCall = (deps.onLog as ReturnType).mock.calls.find( + (call: unknown[]) => typeof call[1] === 'string' && call[1].includes('Config removed') + ); + expect(removedCall).toBeUndefined(); + }); + }); + + describe('activity log', () => { + it('records completed runs', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Wait for the async run to complete + await vi.advanceTimersByTimeAsync(100); + + const log = engine.getActivityLog(); + expect(log.length).toBeGreaterThan(0); + expect(log[0].subscriptionName).toBe('periodic'); + }); + + it('respects limit parameter', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Run multiple intervals + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + + const limited = engine.getActivityLog(1); + expect(limited).toHaveLength(1); + + engine.stop(); + }); + }); + + describe('run management', () => { + it('stopRun returns false for non-existent run', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.stopRun('nonexistent')).toBe(false); + }); + + it('stopAll clears all active runs', async () => { + // Use a slow-resolving onCueRun to keep runs active + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), // Never resolves + }); + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + // Allow async execution to start + await vi.advanceTimersByTimeAsync(10); + + expect(engine.getActiveRuns().length).toBeGreaterThan(0); + engine.stopAll(); + expect(engine.getActiveRuns()).toHaveLength(0); + + engine.stop(); + }); + }); + + describe('github.pull_request / github.issue subscriptions', () => { + it('github.pull_request subscription creates a GitHub poller with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + enabled: true, + prompt: 'review PR', + repo: 'owner/repo', + poll_minutes: 10, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'github.pull_request', + repo: 'owner/repo', + pollMinutes: 10, + projectRoot: '/projects/test', + triggerName: 'pr-watcher', + subscriptionId: 'session-1:pr-watcher', + }) + ); + + engine.stop(); + }); + + it('github.issue subscription creates a GitHub poller', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'issue-watcher', + event: 'github.issue', + enabled: true, + prompt: 'triage issue', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'github.issue', + pollMinutes: 5, // default + triggerName: 'issue-watcher', + subscriptionId: 'session-1:issue-watcher', + }) + ); + + engine.stop(); + }); + + it('cleanup function is called on session teardown', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + enabled: true, + prompt: 'review', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + engine.removeSession('session-1'); + + expect(gitHubPollerCleanup).toHaveBeenCalled(); + }); + + it('disabled github subscription is skipped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + enabled: false, + prompt: 'review', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).not.toHaveBeenCalled(); + + engine.stop(); + }); + }); + + describe('task.pending subscriptions', () => { + it('creates a task scanner with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: true, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + poll_minutes: 2, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueTaskScanner).toHaveBeenCalledWith( + expect.objectContaining({ + watchGlob: 'tasks/**/*.md', + pollMinutes: 2, + projectRoot: '/projects/test', + triggerName: 'task-queue', + }) + ); + + engine.stop(); + }); + + it('defaults poll_minutes to 1 when not specified', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: true, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueTaskScanner).toHaveBeenCalledWith( + expect.objectContaining({ + pollMinutes: 1, + }) + ); + + engine.stop(); + }); + + it('cleanup function is called on session teardown', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: true, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + engine.removeSession('session-1'); + + expect(taskScannerCleanup).toHaveBeenCalled(); + }); + + it('disabled task.pending subscription is skipped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: false, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueTaskScanner).not.toHaveBeenCalled(); + + engine.stop(); + }); + }); + + describe('getStatus', () => { + it('returns correct status for active sessions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + { + name: 'disabled', + event: 'time.interval', + enabled: false, + prompt: 'noop', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].sessionId).toBe('session-1'); + expect(status[0].sessionName).toBe('Test Session'); + expect(status[0].subscriptionCount).toBe(1); // Only enabled ones + expect(status[0].enabled).toBe(true); + + engine.stop(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-executor.test.ts b/src/__tests__/main/cue/cue-executor.test.ts new file mode 100644 index 000000000..09ae1553d --- /dev/null +++ b/src/__tests__/main/cue/cue-executor.test.ts @@ -0,0 +1,1017 @@ +/** + * Tests for the Cue executor module. + * + * Tests cover: + * - Prompt file resolution (absolute and relative paths) + * - Prompt file read failures + * - Template variable substitution with Cue event context + * - Agent argument building (follows process:spawn pattern) + * - Process spawning and stdout/stderr capture + * - Timeout enforcement with SIGTERM → SIGKILL escalation + * - Successful completion and failure detection + * - SSH remote execution wrapping + * - stopCueRun process termination + * - recordCueHistoryEntry construction + * - History entry field population and response truncation + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import type { ChildProcess } from 'child_process'; +import type { CueEvent, CueSubscription, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; +import type { TemplateContext } from '../../../shared/templateVariables'; + +// --- Mocks --- + +// Mock fs +const mockReadFileSync = vi.fn(); +vi.mock('fs', () => ({ + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => 'test-uuid-1234'), +})); + +// Mock substituteTemplateVariables +const mockSubstitute = vi.fn((template: string) => `substituted: ${template}`); +vi.mock('../../../shared/templateVariables', () => ({ + substituteTemplateVariables: (...args: unknown[]) => mockSubstitute(args[0] as string, args[1]), +})); + +// Mock agents module +const mockGetAgentDefinition = vi.fn(); +const mockGetAgentCapabilities = vi.fn(() => ({ + supportsResume: true, + supportsReadOnlyMode: true, + supportsJsonOutput: true, + supportsSessionId: true, + supportsImageInput: false, + supportsImageInputOnResume: false, + supportsSlashCommands: true, + supportsSessionStorage: true, + supportsCostTracking: true, + supportsContextUsage: true, + supportsThinking: false, + supportsStdin: false, + supportsRawStdin: false, + supportsModelSelection: false, + supportsModelDiscovery: false, + supportsBatchMode: true, + supportsYoloMode: true, + supportsExitCodes: true, + supportsWorkingDir: false, +})); +vi.mock('../../../main/agents', () => ({ + getAgentDefinition: (...args: unknown[]) => mockGetAgentDefinition(...args), + getAgentCapabilities: (...args: unknown[]) => mockGetAgentCapabilities(...args), +})); + +// Mock buildAgentArgs and applyAgentConfigOverrides +const mockBuildAgentArgs = vi.fn((_agent: unknown, _opts: unknown) => [ + '--print', + '--verbose', + '--output-format', + 'stream-json', + '--dangerously-skip-permissions', + '--', + 'prompt-content', +]); +const mockApplyOverrides = vi.fn((_agent: unknown, args: string[], _overrides: unknown) => ({ + args, + effectiveCustomEnvVars: undefined, + customArgsSource: 'none' as const, + customEnvSource: 'none' as const, + modelSource: 'default' as const, +})); +vi.mock('../../../main/utils/agent-args', () => ({ + buildAgentArgs: (...args: unknown[]) => mockBuildAgentArgs(...args), + applyAgentConfigOverrides: (...args: unknown[]) => mockApplyOverrides(...args), +})); + +// Mock wrapSpawnWithSsh +const mockWrapSpawnWithSsh = vi.fn(); +vi.mock('../../../main/utils/ssh-spawn-wrapper', () => ({ + wrapSpawnWithSsh: (...args: unknown[]) => mockWrapSpawnWithSsh(...args), +})); + +// Mock child_process.spawn +class MockChildProcess extends EventEmitter { + stdin = { + write: vi.fn(), + end: vi.fn(), + }; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + + kill(signal?: string) { + this.killed = true; + return true; + } + + constructor() { + super(); + // Set encoding methods on stdout/stderr + (this.stdout as any).setEncoding = vi.fn(); + (this.stderr as any).setEncoding = vi.fn(); + } +} + +let mockChild: MockChildProcess; +const mockSpawn = vi.fn(() => { + mockChild = new MockChildProcess(); + return mockChild as unknown as ChildProcess; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + default: { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + }, + }; +}); + +// Must import after mocks +import { + executeCuePrompt, + stopCueRun, + getActiveProcesses, + recordCueHistoryEntry, + type CueExecutionConfig, +} from '../../../main/cue/cue-executor'; + +// --- Helpers --- + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockSubscription(overrides: Partial = {}): CueSubscription { + return { + name: 'Watch config', + event: 'file.changed', + enabled: true, + prompt: 'prompts/on-config-change.md', + watch: '**/*.yaml', + ...overrides, + }; +} + +function createMockEvent(overrides: Partial = {}): CueEvent { + return { + id: 'event-1', + type: 'file.changed', + timestamp: '2026-03-01T00:00:00.000Z', + triggerName: 'Watch config', + payload: { + path: '/projects/test/config.yaml', + filename: 'config.yaml', + directory: '/projects/test', + extension: '.yaml', + }, + ...overrides, + }; +} + +function createMockTemplateContext(): TemplateContext { + return { + session: { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + }, + }; +} + +function createExecutionConfig(overrides: Partial = {}): CueExecutionConfig { + return { + runId: 'run-1', + session: createMockSession(), + subscription: createMockSubscription(), + event: createMockEvent(), + promptPath: 'prompts/on-config-change.md', + toolType: 'claude-code', + projectRoot: '/projects/test', + templateContext: createMockTemplateContext(), + timeoutMs: 30000, + onLog: vi.fn(), + ...overrides, + }; +} + +const defaultAgentDef = { + id: 'claude-code', + name: 'Claude Code', + binaryName: 'claude', + command: 'claude', + args: [ + '--print', + '--verbose', + '--output-format', + 'stream-json', + '--dangerously-skip-permissions', + ], +}; + +// --- Tests --- + +describe('cue-executor', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + getActiveProcesses().clear(); + + // Default mock implementations + mockReadFileSync.mockReturnValue('Prompt content: check {{CUE_FILE_PATH}}'); + mockGetAgentDefinition.mockReturnValue(defaultAgentDef); + mockSubstitute.mockImplementation((template: string) => `substituted: ${template}`); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('executeCuePrompt', () => { + it('should resolve relative prompt paths against projectRoot', async () => { + const config = createExecutionConfig({ + promptPath: 'prompts/check.md', + projectRoot: '/projects/test', + }); + + const resultPromise = executeCuePrompt(config); + // Let spawn happen + await vi.advanceTimersByTimeAsync(0); + + expect(mockReadFileSync).toHaveBeenCalledWith('/projects/test/prompts/check.md', 'utf-8'); + + // Close the process to resolve + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should use absolute prompt paths directly', async () => { + const config = createExecutionConfig({ + promptPath: '/absolute/path/prompt.md', + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockReadFileSync).toHaveBeenCalledWith('/absolute/path/prompt.md', 'utf-8'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should return failed result when prompt file cannot be read', async () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file'); + }); + + const config = createExecutionConfig(); + const result = await executeCuePrompt(config); + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Failed to read prompt file'); + expect(result.stderr).toContain('ENOENT'); + expect(result.exitCode).toBeNull(); + }); + + it('should populate Cue event data in template context', async () => { + const event = createMockEvent({ + type: 'file.changed', + payload: { + path: '/projects/test/src/app.ts', + filename: 'app.ts', + directory: '/projects/test/src', + extension: '.ts', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Verify template context was populated with cue data + expect(templateContext.cue).toEqual({ + eventType: 'file.changed', + eventTimestamp: event.timestamp, + triggerName: 'Watch config', + runId: 'run-1', + filePath: '/projects/test/src/app.ts', + fileName: 'app.ts', + fileDir: '/projects/test/src', + fileExt: '.ts', + fileChangeType: '', + sourceSession: '', + sourceOutput: '', + sourceStatus: '', + sourceExitCode: '', + sourceDuration: '', + sourceTriggeredBy: '', + }); + + // Verify substituteTemplateVariables was called + expect(mockSubstitute).toHaveBeenCalledWith( + 'Prompt content: check {{CUE_FILE_PATH}}', + templateContext + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should return failed result for unknown agent type', async () => { + mockGetAgentDefinition.mockReturnValue(undefined); + + const config = createExecutionConfig({ toolType: 'nonexistent' }); + const result = await executeCuePrompt(config); + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Unknown agent type: nonexistent'); + }); + + it('should build agent args using the same pipeline as process:spawn', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Verify buildAgentArgs was called with proper params + expect(mockBuildAgentArgs).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'claude-code', + binaryName: 'claude', + command: 'claude', + }), + expect.objectContaining({ + baseArgs: defaultAgentDef.args, + cwd: '/projects/test', + yoloMode: true, + }) + ); + + // Verify applyAgentConfigOverrides was called + expect(mockApplyOverrides).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should spawn the process with correct command and args', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockSpawn).toHaveBeenCalledWith( + 'claude', + expect.any(Array), + expect.objectContaining({ + cwd: '/projects/test', + stdio: ['pipe', 'pipe', 'pipe'], + }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should capture stdout and stderr from the process', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Emit some output + mockChild.stdout.emit('data', 'Hello '); + mockChild.stdout.emit('data', 'world'); + mockChild.stderr.emit('data', 'Warning: something'); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.stdout).toBe('Hello world'); + expect(result.stderr).toBe('Warning: something'); + }); + + it('should return completed status on exit code 0', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.status).toBe('completed'); + expect(result.exitCode).toBe(0); + expect(result.runId).toBe('run-1'); + expect(result.sessionId).toBe('session-1'); + expect(result.sessionName).toBe('Test Session'); + expect(result.subscriptionName).toBe('Watch config'); + }); + + it('should return failed status on non-zero exit code', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('close', 1); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.exitCode).toBe(1); + }); + + it('should handle spawn errors gracefully', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('error', new Error('spawn ENOENT')); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Spawn error: spawn ENOENT'); + expect(result.exitCode).toBeNull(); + }); + + it('should track the process in activeProcesses while running', async () => { + const config = createExecutionConfig({ runId: 'tracked-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(getActiveProcesses().has('tracked-run')).toBe(true); + + mockChild.emit('close', 0); + await resultPromise; + + expect(getActiveProcesses().has('tracked-run')).toBe(false); + }); + + it('should use custom path when provided', async () => { + const config = createExecutionConfig({ + customPath: '/custom/claude', + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockSpawn).toHaveBeenCalledWith( + '/custom/claude', + expect.any(Array), + expect.any(Object) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should close stdin for local execution', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // For local (non-SSH) execution, stdin should just be closed + expect(mockChild.stdin.end).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + + describe('timeout enforcement', () => { + it('should send SIGTERM when timeout expires', async () => { + const config = createExecutionConfig({ timeoutMs: 5000 }); + const killSpy = vi.spyOn(mockChild, 'kill'); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Wait: re-spy after child is created + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(5000); + + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + // Process exits after SIGTERM + mockChild.emit('close', null); + const result = await resultPromise; + + expect(result.status).toBe('timeout'); + }); + + it('should escalate to SIGKILL after SIGTERM + delay', async () => { + const config = createExecutionConfig({ timeoutMs: 5000 }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(5000); + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + // Reset to track SIGKILL — but killed is already true so SIGKILL won't fire + // since child.killed is true. That's correct behavior. + mockChild.killed = false; + + // Advance past SIGKILL delay + await vi.advanceTimersByTimeAsync(5000); + expect(childKill).toHaveBeenCalledWith('SIGKILL'); + + mockChild.emit('close', null); + await resultPromise; + }); + + it('should not timeout when timeoutMs is 0', async () => { + const config = createExecutionConfig({ timeoutMs: 0 }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance a lot of time + await vi.advanceTimersByTimeAsync(60000); + expect(childKill).not.toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + describe('SSH remote execution', () => { + it('should call wrapSpawnWithSsh when SSH is enabled', async () => { + const mockSshStore = { getSshRemotes: vi.fn(() => []) }; + + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['-o', 'BatchMode=yes', 'user@host', 'claude --print'], + cwd: '/Users/test', + customEnvVars: undefined, + prompt: undefined, + sshRemoteUsed: { id: 'remote-1', name: 'My Server', host: 'host.example.com' }, + }); + + const config = createExecutionConfig({ + sshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + sshStore: mockSshStore, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockWrapSpawnWithSsh).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'claude', + agentBinaryName: 'claude', + }), + { enabled: true, remoteId: 'remote-1' }, + mockSshStore + ); + + expect(mockSpawn).toHaveBeenCalledWith( + 'ssh', + expect.arrayContaining(['-o', 'BatchMode=yes']), + expect.objectContaining({ cwd: '/Users/test' }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should write prompt to stdin for SSH large prompt mode', async () => { + const mockSshStore = { getSshRemotes: vi.fn(() => []) }; + + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['user@host'], + cwd: '/Users/test', + customEnvVars: undefined, + prompt: 'large prompt content', // SSH returns prompt for stdin delivery + sshRemoteUsed: { id: 'remote-1', name: 'Server', host: 'host' }, + }); + + const config = createExecutionConfig({ + sshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + sshStore: mockSshStore, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockChild.stdin.write).toHaveBeenCalledWith('large prompt content'); + expect(mockChild.stdin.end).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + it('should pass custom model and args through config overrides', async () => { + const config = createExecutionConfig({ + customModel: 'claude-4-opus', + customArgs: '--max-tokens 1000', + customEnvVars: { API_KEY: 'test-key' }, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockApplyOverrides).toHaveBeenCalledWith( + expect.anything(), + expect.any(Array), + expect.objectContaining({ + sessionCustomModel: 'claude-4-opus', + sessionCustomArgs: '--max-tokens 1000', + sessionCustomEnvVars: { API_KEY: 'test-key' }, + }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should include event duration in the result', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Advance some time + await vi.advanceTimersByTimeAsync(1500); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.durationMs).toBeGreaterThanOrEqual(1500); + expect(result.startedAt).toBeTruthy(); + expect(result.endedAt).toBeTruthy(); + }); + + it('should populate github.pull_request event context correctly', async () => { + const subscription = createMockSubscription({ + name: 'PR watcher', + event: 'github.pull_request', + }); + const event = createMockEvent({ + type: 'github.pull_request', + triggerName: 'PR watcher', + payload: { + type: 'pull_request', + number: 42, + title: 'Add feature X', + author: 'octocat', + url: 'https://github.com/owner/repo/pull/42', + body: 'This PR adds feature X', + labels: 'enhancement,review-needed', + state: 'open', + repo: 'owner/repo', + head_branch: 'feature-x', + base_branch: 'main', + assignees: '', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, subscription, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.ghType).toBe('pull_request'); + expect(templateContext.cue?.ghNumber).toBe('42'); + expect(templateContext.cue?.ghTitle).toBe('Add feature X'); + expect(templateContext.cue?.ghAuthor).toBe('octocat'); + expect(templateContext.cue?.ghUrl).toBe('https://github.com/owner/repo/pull/42'); + expect(templateContext.cue?.ghBranch).toBe('feature-x'); + expect(templateContext.cue?.ghBaseBranch).toBe('main'); + expect(templateContext.cue?.ghRepo).toBe('owner/repo'); + // Base cue fields should still be populated + expect(templateContext.cue?.eventType).toBe('github.pull_request'); + expect(templateContext.cue?.triggerName).toBe('PR watcher'); + expect(templateContext.cue?.runId).toBe('run-1'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should populate github.issue event context correctly', async () => { + const subscription = createMockSubscription({ + name: 'Issue watcher', + event: 'github.issue', + }); + const event = createMockEvent({ + type: 'github.issue', + triggerName: 'Issue watcher', + payload: { + type: 'issue', + number: 99, + title: 'Bug report', + author: 'user1', + url: 'https://github.com/owner/repo/issues/99', + body: 'Found a bug', + labels: 'bug', + state: 'open', + repo: 'owner/repo', + assignees: 'dev1,dev2', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, subscription, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.ghType).toBe('issue'); + expect(templateContext.cue?.ghNumber).toBe('99'); + expect(templateContext.cue?.ghAssignees).toBe('dev1,dev2'); + // head_branch / base_branch not in payload → empty string + expect(templateContext.cue?.ghBranch).toBe(''); + expect(templateContext.cue?.ghBaseBranch).toBe(''); + // Base cue fields preserved + expect(templateContext.cue?.eventType).toBe('github.issue'); + expect(templateContext.cue?.triggerName).toBe('Issue watcher'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should populate file.changed changeType in template context', async () => { + const event = createMockEvent({ + type: 'file.changed', + payload: { + path: '/projects/test/new-file.ts', + filename: 'new-file.ts', + directory: '/projects/test', + extension: '.ts', + changeType: 'add', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.fileChangeType).toBe('add'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should populate agent.completed event context correctly', async () => { + const event = createMockEvent({ + type: 'agent.completed', + triggerName: 'On agent done', + payload: { + sourceSession: 'builder-session', + sourceOutput: 'Build completed successfully', + status: 'completed', + exitCode: 0, + durationMs: 15000, + triggeredBy: 'lint-on-save', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.sourceSession).toBe('builder-session'); + expect(templateContext.cue?.sourceOutput).toBe('Build completed successfully'); + expect(templateContext.cue?.sourceStatus).toBe('completed'); + expect(templateContext.cue?.sourceExitCode).toBe('0'); + expect(templateContext.cue?.sourceDuration).toBe('15000'); + expect(templateContext.cue?.sourceTriggeredBy).toBe('lint-on-save'); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + describe('stopCueRun', () => { + it('should return false for unknown runId', () => { + expect(stopCueRun('nonexistent')).toBe(false); + }); + + it('should send SIGTERM to a running process', async () => { + const config = createExecutionConfig({ runId: 'stop-test-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + const stopped = stopCueRun('stop-test-run'); + expect(stopped).toBe(true); + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + mockChild.emit('close', null); + await resultPromise; + }); + }); + + describe('recordCueHistoryEntry', () => { + it('should construct a proper CUE history entry', () => { + const result: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Watch config', + event: createMockEvent(), + status: 'completed', + stdout: 'Task completed successfully', + stderr: '', + exitCode: 0, + durationMs: 5000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:05.000Z', + }; + + const session = createMockSession(); + const entry = recordCueHistoryEntry(result, session); + + expect(entry.type).toBe('CUE'); + expect(entry.id).toBe('test-uuid-1234'); + expect(entry.summary).toBe('[CUE] "Watch config" (file.changed)'); + expect(entry.fullResponse).toBe('Task completed successfully'); + expect(entry.projectPath).toBe('/projects/test'); + expect(entry.sessionId).toBe('session-1'); + expect(entry.sessionName).toBe('Test Session'); + expect(entry.success).toBe(true); + expect(entry.elapsedTimeMs).toBe(5000); + expect(entry.cueTriggerName).toBe('Watch config'); + expect(entry.cueEventType).toBe('file.changed'); + }); + + it('should set success to false for failed runs', () => { + const result: CueRunResult = { + runId: 'run-2', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Periodic check', + event: createMockEvent({ type: 'time.interval' }), + status: 'failed', + stdout: '', + stderr: 'Error occurred', + exitCode: 1, + durationMs: 2000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:02.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.success).toBe(false); + expect(entry.summary).toBe('[CUE] "Periodic check" (time.interval)'); + }); + + it('should truncate long stdout in fullResponse', () => { + const longOutput = 'x'.repeat(15000); + const result: CueRunResult = { + runId: 'run-3', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Large output', + event: createMockEvent(), + status: 'completed', + stdout: longOutput, + stderr: '', + exitCode: 0, + durationMs: 1000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:01.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.fullResponse?.length).toBe(10000); + }); + + it('should set fullResponse to undefined when stdout is empty', () => { + const result: CueRunResult = { + runId: 'run-4', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Silent run', + event: createMockEvent(), + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 500, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:00.500Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.fullResponse).toBeUndefined(); + }); + + it('should populate cueSourceSession from agent.completed event payload', () => { + const result: CueRunResult = { + runId: 'run-5', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'On build done', + event: createMockEvent({ + type: 'agent.completed', + payload: { + sourceSession: 'builder-agent', + }, + }), + status: 'completed', + stdout: 'Done', + stderr: '', + exitCode: 0, + durationMs: 3000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:03.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.cueSourceSession).toBe('builder-agent'); + expect(entry.cueEventType).toBe('agent.completed'); + }); + + it('should set cueSourceSession to undefined when not present in payload', () => { + const result: CueRunResult = { + runId: 'run-6', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Timer check', + event: createMockEvent({ + type: 'time.interval', + payload: { interval_minutes: 5 }, + }), + status: 'completed', + stdout: 'OK', + stderr: '', + exitCode: 0, + durationMs: 1000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:01.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.cueSourceSession).toBeUndefined(); + }); + + it('should use projectRoot for projectPath, falling back to cwd', () => { + const session = createMockSession({ projectRoot: '', cwd: '/fallback/cwd' }); + const result: CueRunResult = { + runId: 'run-7', + sessionId: 'session-1', + sessionName: 'Test', + subscriptionName: 'Test', + event: createMockEvent(), + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:00.100Z', + }; + + const entry = recordCueHistoryEntry(result, session); + + // Empty string is falsy, so should fall back to cwd + expect(entry.projectPath).toBe('/fallback/cwd'); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-file-watcher.test.ts b/src/__tests__/main/cue/cue-file-watcher.test.ts new file mode 100644 index 000000000..7d4d8e5d9 --- /dev/null +++ b/src/__tests__/main/cue/cue-file-watcher.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for the Cue file watcher provider. + * + * Tests cover: + * - Chokidar watcher creation with correct options + * - Per-file debouncing of change events + * - CueEvent construction with correct payload + * - Cleanup of timers and watcher + * - Error handling + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock crypto.randomUUID +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => 'test-uuid-1234'), +})); + +// Mock chokidar +const mockOn = vi.fn().mockReturnThis(); +const mockClose = vi.fn(); +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: mockOn, + close: mockClose, + })), +})); + +import { createCueFileWatcher } from '../../../main/cue/cue-file-watcher'; +import type { CueEvent } from '../../../main/cue/cue-types'; +import * as chokidar from 'chokidar'; + +describe('cue-file-watcher', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates a chokidar watcher with correct options', () => { + createCueFileWatcher({ + watchGlob: 'src/**/*.ts', + projectRoot: '/projects/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test-trigger', + }); + + expect(chokidar.watch).toHaveBeenCalledWith('src/**/*.ts', { + cwd: '/projects/test', + ignoreInitial: true, + persistent: true, + }); + }); + + it('registers change, add, and unlink handlers', () => { + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test', + }); + + const registeredEvents = mockOn.mock.calls.map((call) => call[0]); + expect(registeredEvents).toContain('change'); + expect(registeredEvents).toContain('add'); + expect(registeredEvents).toContain('unlink'); + expect(registeredEvents).toContain('error'); + }); + + it('debounces events per file', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + expect(changeHandler).toBeDefined(); + + // Rapid changes to the same file + changeHandler('src/index.ts'); + changeHandler('src/index.ts'); + changeHandler('src/index.ts'); + + vi.advanceTimersByTime(5000); + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it('does not coalesce events from different files', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + + changeHandler('src/a.ts'); + changeHandler('src/b.ts'); + + vi.advanceTimersByTime(5000); + expect(onEvent).toHaveBeenCalledTimes(2); + }); + + it('constructs a CueEvent with correct payload for change events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'my-trigger', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + changeHandler('src/index.ts'); + vi.advanceTimersByTime(100); + + expect(onEvent).toHaveBeenCalledTimes(1); + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.id).toBe('test-uuid-1234'); + expect(event.type).toBe('file.changed'); + expect(event.triggerName).toBe('my-trigger'); + expect(event.payload.filename).toBe('index.ts'); + expect(event.payload.extension).toBe('.ts'); + expect(event.payload.changeType).toBe('change'); + }); + + it('reports correct changeType for add events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'test', + }); + + const addHandler = mockOn.mock.calls.find((call) => call[0] === 'add')?.[1]; + addHandler('src/new.ts'); + vi.advanceTimersByTime(100); + + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.payload.changeType).toBe('add'); + }); + + it('reports correct changeType for unlink events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'test', + }); + + const unlinkHandler = mockOn.mock.calls.find((call) => call[0] === 'unlink')?.[1]; + unlinkHandler('src/deleted.ts'); + vi.advanceTimersByTime(100); + + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.payload.changeType).toBe('unlink'); + }); + + it('cleanup function clears timers and closes watcher', () => { + const onEvent = vi.fn(); + const cleanup = createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + // Trigger a change to create a pending timer + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + changeHandler('src/index.ts'); + + cleanup(); + + // Advance past debounce — event should NOT fire since cleanup was called + vi.advanceTimersByTime(5000); + expect(onEvent).not.toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + }); + + it('handles watcher errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test', + }); + + const errorHandler = mockOn.mock.calls.find((call) => call[0] === 'error')?.[1]; + expect(errorHandler).toBeDefined(); + + // Should not throw + errorHandler(new Error('Watch error')); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/main/cue/cue-filter.test.ts b/src/__tests__/main/cue/cue-filter.test.ts new file mode 100644 index 000000000..6dad73aaf --- /dev/null +++ b/src/__tests__/main/cue/cue-filter.test.ts @@ -0,0 +1,242 @@ +/** + * Tests for the Cue filter matching engine. + * + * Tests cover: + * - Exact string matching + * - Negation (!value) + * - Numeric comparisons (>, <, >=, <=) + * - Glob pattern matching (*) + * - Boolean matching + * - Numeric equality + * - Dot-notation nested key access + * - AND logic (all conditions must pass) + * - Missing payload fields + * - describeFilter human-readable output + */ + +import { describe, it, expect } from 'vitest'; +import { matchesFilter, describeFilter } from '../../../main/cue/cue-filter'; + +describe('cue-filter', () => { + describe('matchesFilter', () => { + describe('exact string matching', () => { + it('matches exact string values', () => { + expect(matchesFilter({ extension: '.ts' }, { extension: '.ts' })).toBe(true); + }); + + it('rejects non-matching string values', () => { + expect(matchesFilter({ extension: '.js' }, { extension: '.ts' })).toBe(false); + }); + + it('coerces payload value to string for comparison', () => { + expect(matchesFilter({ count: 42 }, { count: '42' })).toBe(true); + }); + }); + + describe('negation (!value)', () => { + it('matches when value does not equal', () => { + expect(matchesFilter({ status: 'active' }, { status: '!archived' })).toBe(true); + }); + + it('rejects when value equals the negated term', () => { + expect(matchesFilter({ status: 'archived' }, { status: '!archived' })).toBe(false); + }); + }); + + describe('numeric comparisons', () => { + it('matches greater than', () => { + expect(matchesFilter({ size: 1500 }, { size: '>1000' })).toBe(true); + }); + + it('rejects not greater than', () => { + expect(matchesFilter({ size: 500 }, { size: '>1000' })).toBe(false); + }); + + it('rejects equal for greater than', () => { + expect(matchesFilter({ size: 1000 }, { size: '>1000' })).toBe(false); + }); + + it('matches less than', () => { + expect(matchesFilter({ priority: 3 }, { priority: '<5' })).toBe(true); + }); + + it('rejects not less than', () => { + expect(matchesFilter({ priority: 7 }, { priority: '<5' })).toBe(false); + }); + + it('matches greater than or equal', () => { + expect(matchesFilter({ score: 100 }, { score: '>=100' })).toBe(true); + expect(matchesFilter({ score: 101 }, { score: '>=100' })).toBe(true); + }); + + it('rejects less than for >=', () => { + expect(matchesFilter({ score: 99 }, { score: '>=100' })).toBe(false); + }); + + it('matches less than or equal', () => { + expect(matchesFilter({ count: 10 }, { count: '<=10' })).toBe(true); + expect(matchesFilter({ count: 9 }, { count: '<=10' })).toBe(true); + }); + + it('rejects greater than for <=', () => { + expect(matchesFilter({ count: 11 }, { count: '<=10' })).toBe(false); + }); + + it('handles string payload values with numeric comparison', () => { + expect(matchesFilter({ size: '1500' }, { size: '>1000' })).toBe(true); + }); + + it('rejects NaN payload values in numeric comparisons', () => { + expect(matchesFilter({ size: 'not-a-number' }, { size: '>1000' })).toBe(false); + expect(matchesFilter({ size: 'abc' }, { size: '<1000' })).toBe(false); + expect(matchesFilter({ size: 'xyz' }, { size: '>=100' })).toBe(false); + expect(matchesFilter({ size: '' }, { size: '<=100' })).toBe(false); + }); + + it('rejects NaN threshold values in numeric comparisons', () => { + expect(matchesFilter({ size: 500 }, { size: '>abc' })).toBe(false); + expect(matchesFilter({ size: 500 }, { size: '=foo' })).toBe(false); + expect(matchesFilter({ size: 500 }, { size: '<=bar' })).toBe(false); + }); + }); + + describe('glob pattern matching', () => { + it('matches simple glob patterns', () => { + expect(matchesFilter({ path: 'file.ts' }, { path: '*.ts' })).toBe(true); + }); + + it('rejects non-matching glob patterns', () => { + expect(matchesFilter({ path: 'file.js' }, { path: '*.ts' })).toBe(false); + }); + + it('matches complex glob patterns', () => { + expect(matchesFilter({ path: 'src/components/Button.tsx' }, { path: 'src/**/*.tsx' })).toBe( + true + ); + }); + + it('rejects non-matching complex patterns', () => { + expect(matchesFilter({ path: 'test/Button.tsx' }, { path: 'src/**/*.tsx' })).toBe(false); + }); + }); + + describe('boolean matching', () => { + it('matches true boolean', () => { + expect(matchesFilter({ active: true }, { active: true })).toBe(true); + }); + + it('rejects false when expecting true', () => { + expect(matchesFilter({ active: false }, { active: true })).toBe(false); + }); + + it('matches false boolean', () => { + expect(matchesFilter({ active: false }, { active: false })).toBe(true); + }); + + it('rejects true when expecting false', () => { + expect(matchesFilter({ active: true }, { active: false })).toBe(false); + }); + }); + + describe('numeric equality', () => { + it('matches exact numeric values', () => { + expect(matchesFilter({ exitCode: 0 }, { exitCode: 0 })).toBe(true); + }); + + it('rejects non-matching numeric values', () => { + expect(matchesFilter({ exitCode: 1 }, { exitCode: 0 })).toBe(false); + }); + }); + + describe('dot-notation nested access', () => { + it('resolves nested payload fields', () => { + const payload = { source: { status: 'completed' } }; + expect(matchesFilter(payload, { 'source.status': 'completed' })).toBe(true); + }); + + it('returns false for missing nested path', () => { + const payload = { source: {} }; + expect(matchesFilter(payload, { 'source.status': 'completed' })).toBe(false); + }); + + it('handles deeply nested access', () => { + const payload = { a: { b: { c: 'deep' } } }; + expect(matchesFilter(payload, { 'a.b.c': 'deep' })).toBe(true); + }); + }); + + describe('AND logic', () => { + it('requires all conditions to pass', () => { + const payload = { extension: '.ts', changeType: 'change', path: 'src/index.ts' }; + const filter = { extension: '.ts', changeType: 'change' }; + expect(matchesFilter(payload, filter)).toBe(true); + }); + + it('fails if any condition does not pass', () => { + const payload = { extension: '.js', changeType: 'change' }; + const filter = { extension: '.ts', changeType: 'change' }; + expect(matchesFilter(payload, filter)).toBe(false); + }); + }); + + describe('missing payload fields', () => { + it('fails when payload field is undefined', () => { + expect(matchesFilter({}, { extension: '.ts' })).toBe(false); + }); + + it('fails when nested payload field is undefined', () => { + expect(matchesFilter({ source: {} }, { 'source.missing': 'value' })).toBe(false); + }); + }); + + describe('empty filter', () => { + it('matches everything when filter is empty', () => { + expect(matchesFilter({ any: 'value' }, {})).toBe(true); + }); + }); + }); + + describe('describeFilter', () => { + it('describes exact string match', () => { + expect(describeFilter({ extension: '.ts' })).toBe('extension == ".ts"'); + }); + + it('describes negation', () => { + expect(describeFilter({ status: '!archived' })).toBe('status != archived'); + }); + + it('describes greater than', () => { + expect(describeFilter({ size: '>1000' })).toBe('size > 1000'); + }); + + it('describes less than', () => { + expect(describeFilter({ priority: '<5' })).toBe('priority < 5'); + }); + + it('describes greater than or equal', () => { + expect(describeFilter({ score: '>=100' })).toBe('score >= 100'); + }); + + it('describes less than or equal', () => { + expect(describeFilter({ count: '<=10' })).toBe('count <= 10'); + }); + + it('describes glob pattern', () => { + expect(describeFilter({ path: '*.ts' })).toBe('path matches *.ts'); + }); + + it('describes boolean', () => { + expect(describeFilter({ active: true })).toBe('active is true'); + }); + + it('describes numeric equality', () => { + expect(describeFilter({ exitCode: 0 })).toBe('exitCode == 0'); + }); + + it('joins multiple conditions with AND', () => { + const result = describeFilter({ extension: '.ts', status: '!archived' }); + expect(result).toBe('extension == ".ts" AND status != archived'); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-github-poller.test.ts b/src/__tests__/main/cue/cue-github-poller.test.ts new file mode 100644 index 000000000..b7282b8d5 --- /dev/null +++ b/src/__tests__/main/cue/cue-github-poller.test.ts @@ -0,0 +1,604 @@ +/** + * Tests for the Cue GitHub poller provider. + * + * Tests cover: + * - gh CLI availability check + * - Repo auto-detection + * - PR and issue polling with event emission + * - Seen-item tracking and first-run seeding + * - CueEvent payload shapes + * - Body truncation + * - Cleanup and timer management + * - Error handling + * + * Note: The poller uses execFile (not exec) to avoid shell injection. + * The mock here simulates execFile's callback-based API via promisify. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Hoisted mock references (vi.hoisted runs before vi.mock hoisting) +const { + mockExecFile, + mockIsGitHubItemSeen, + mockMarkGitHubItemSeen, + mockHasAnyGitHubSeen, + mockPruneGitHubSeen, +} = vi.hoisted(() => ({ + mockExecFile: vi.fn(), + mockIsGitHubItemSeen: vi.fn<(subId: string, key: string) => boolean>().mockReturnValue(false), + mockMarkGitHubItemSeen: vi.fn<(subId: string, key: string) => void>(), + mockHasAnyGitHubSeen: vi.fn<(subId: string) => boolean>().mockReturnValue(true), + mockPruneGitHubSeen: vi.fn<(olderThanMs: number) => void>(), +})); + +// Mock crypto.randomUUID +let uuidCounter = 0; +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `test-uuid-${++uuidCounter}`), +})); + +// Mock child_process.execFile (safe — no shell injection risk) +vi.mock('child_process', () => ({ + default: { execFile: mockExecFile }, + execFile: mockExecFile, +})); + +// Mock cue-db functions +vi.mock('../../../main/cue/cue-db', () => ({ + isGitHubItemSeen: (subId: string, key: string) => mockIsGitHubItemSeen(subId, key), + markGitHubItemSeen: (subId: string, key: string) => mockMarkGitHubItemSeen(subId, key), + hasAnyGitHubSeen: (subId: string) => mockHasAnyGitHubSeen(subId), + pruneGitHubSeen: (olderThanMs: number) => mockPruneGitHubSeen(olderThanMs), +})); + +import { + createCueGitHubPoller, + type CueGitHubPollerConfig, +} from '../../../main/cue/cue-github-poller'; + +// Helper: make mockExecFile (callback-style) resolve/reject +function setupExecFile(responses: Record) { + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + for (const [pattern, stdout] of Object.entries(responses)) { + if (key.includes(pattern)) { + cb(null, stdout, ''); + return; + } + } + cb(new Error(`Command not found: ${key}`), '', ''); + } + ); +} + +function setupExecFileReject(pattern: string, errorMsg: string) { + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + if (key.includes(pattern)) { + cb(new Error(errorMsg), '', ''); + return; + } + cb(null, '', ''); + } + ); +} + +const samplePRs = [ + { + number: 1, + title: 'Add feature', + author: { login: 'alice' }, + url: 'https://github.com/owner/repo/pull/1', + body: 'Feature description', + state: 'OPEN', + isDraft: false, + labels: [{ name: 'enhancement' }], + headRefName: 'feature-branch', + baseRefName: 'main', + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-02T00:00:00Z', + }, + { + number: 2, + title: 'Fix bug', + author: { login: 'bob' }, + url: 'https://github.com/owner/repo/pull/2', + body: 'Bug fix', + state: 'OPEN', + isDraft: true, + labels: [{ name: 'bug' }, { name: 'urgent' }], + headRefName: 'fix-branch', + baseRefName: 'main', + createdAt: '2026-03-01T12:00:00Z', + updatedAt: '2026-03-02T12:00:00Z', + }, + { + number: 3, + title: 'Docs update', + author: { login: 'charlie' }, + url: 'https://github.com/owner/repo/pull/3', + body: null, + state: 'OPEN', + isDraft: false, + labels: [], + headRefName: 'docs', + baseRefName: 'main', + createdAt: '2026-03-02T00:00:00Z', + updatedAt: '2026-03-03T00:00:00Z', + }, +]; + +const sampleIssues = [ + { + number: 10, + title: 'Bug report', + author: { login: 'dave' }, + url: 'https://github.com/owner/repo/issues/10', + body: 'Something is broken', + state: 'OPEN', + labels: [{ name: 'bug' }], + assignees: [{ login: 'alice' }, { login: 'bob' }], + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-02T00:00:00Z', + }, + { + number: 11, + title: 'Feature request', + author: { login: 'eve' }, + url: 'https://github.com/owner/repo/issues/11', + body: 'Please add this', + state: 'OPEN', + labels: [], + assignees: [], + createdAt: '2026-03-02T00:00:00Z', + updatedAt: '2026-03-03T00:00:00Z', + }, +]; + +function makeConfig(overrides: Partial = {}): CueGitHubPollerConfig { + return { + eventType: 'github.pull_request', + repo: 'owner/repo', + pollMinutes: 5, + projectRoot: '/projects/test', + onEvent: vi.fn(), + onLog: vi.fn(), + triggerName: 'test-trigger', + subscriptionId: 'session-1:test-sub', + ...overrides, + }; +} + +describe('cue-github-poller', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + uuidCounter = 0; + mockIsGitHubItemSeen.mockReturnValue(false); + mockHasAnyGitHubSeen.mockReturnValue(true); // not first run by default + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('gh CLI not available — warning logged, no events fired, no crash', async () => { + const config = makeConfig(); + setupExecFileReject('--version', 'gh not found'); + + const cleanup = createCueGitHubPoller(config); + + // Advance past initial 2s delay + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('GitHub CLI (gh) not found') + ); + expect(config.onEvent).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('repo auto-detection — resolves from gh repo view', async () => { + const config = makeConfig({ repo: undefined }); + setupExecFile({ + '--version': '2.0.0', + 'repo view': 'auto-owner/auto-repo\n', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + // Should have auto-detected repo and used it in pr list + expect(mockExecFile).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining(['repo', 'view']), + expect.anything(), + expect.any(Function) + ); + + cleanup(); + }); + + it('repo auto-detection failure — warning logged, poll skipped', async () => { + const config = makeConfig({ repo: undefined }); + setupExecFile({ '--version': '2.0.0' }); + // repo view will hit the default reject + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Could not auto-detect repo') + ); + expect(config.onEvent).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('PR polling — new items fire events', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).toHaveBeenCalledTimes(3); + + cleanup(); + }); + + it('PR polling — seen items are skipped', async () => { + mockIsGitHubItemSeen.mockImplementation(((_subId: string, itemKey: string) => { + return itemKey === 'pr:owner/repo:2'; // PR #2 already seen + }) as (subId: string, key: string) => boolean); + + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).toHaveBeenCalledTimes(2); + + cleanup(); + }); + + it('PR polling — marks items as seen with correct keys', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', 'pr:owner/repo:1'); + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', 'pr:owner/repo:2'); + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', 'pr:owner/repo:3'); + + cleanup(); + }); + + it('issue polling — new items fire events with assignees', async () => { + const config = makeConfig({ eventType: 'github.issue' }); + setupExecFile({ + '--version': '2.0.0', + 'issue list': JSON.stringify(sampleIssues), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).toHaveBeenCalledTimes(2); + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.payload.assignees).toBe('alice,bob'); + + cleanup(); + }); + + it('CueEvent payload shape for PRs', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify([samplePRs[0]]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.type).toBe('github.pull_request'); + expect(event.triggerName).toBe('test-trigger'); + expect(event.payload).toEqual({ + type: 'pull_request', + number: 1, + title: 'Add feature', + author: 'alice', + url: 'https://github.com/owner/repo/pull/1', + body: 'Feature description', + state: 'open', + draft: false, + labels: 'enhancement', + head_branch: 'feature-branch', + base_branch: 'main', + repo: 'owner/repo', + created_at: '2026-03-01T00:00:00Z', + updated_at: '2026-03-02T00:00:00Z', + }); + + cleanup(); + }); + + it('CueEvent payload shape for issues', async () => { + const config = makeConfig({ eventType: 'github.issue' }); + setupExecFile({ + '--version': '2.0.0', + 'issue list': JSON.stringify([sampleIssues[0]]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.type).toBe('github.issue'); + expect(event.payload).toEqual({ + type: 'issue', + number: 10, + title: 'Bug report', + author: 'dave', + url: 'https://github.com/owner/repo/issues/10', + body: 'Something is broken', + state: 'open', + labels: 'bug', + assignees: 'alice,bob', + repo: 'owner/repo', + created_at: '2026-03-01T00:00:00Z', + updated_at: '2026-03-02T00:00:00Z', + }); + + cleanup(); + }); + + it('body truncation — body exceeding 5000 chars is truncated', async () => { + const longBody = 'x'.repeat(6000); + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify([{ ...samplePRs[0], body: longBody }]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.payload.body).toHaveLength(5000); + + cleanup(); + }); + + it('first-run seeding — no events on first poll', async () => { + mockHasAnyGitHubSeen.mockReturnValue(false); // first run + + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).not.toHaveBeenCalled(); + expect(mockMarkGitHubItemSeen).toHaveBeenCalledTimes(3); + expect(config.onLog).toHaveBeenCalledWith( + 'info', + expect.stringContaining('seeded 3 existing pull_request(s)') + ); + + cleanup(); + }); + + it('second poll fires events after seeding', async () => { + // First poll: seeding (no seen records) + mockHasAnyGitHubSeen.mockReturnValueOnce(false); + // Second poll: has seen records now + mockHasAnyGitHubSeen.mockReturnValue(true); + + const newPR = { + ...samplePRs[0], + number: 99, + title: 'New PR', + }; + + const config = makeConfig({ pollMinutes: 1 }); + + let callCount = 0; + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + if (key.includes('--version')) { + cb(null, '2.0.0', ''); + } else if (key.includes('pr list')) { + callCount++; + if (callCount === 1) { + cb(null, JSON.stringify(samplePRs), ''); + } else { + cb(null, JSON.stringify([newPR]), ''); + } + } else { + cb(new Error('not found'), '', ''); + } + } + ); + + const cleanup = createCueGitHubPoller(config); + + // First poll at 2s + await vi.advanceTimersByTimeAsync(2000); + expect(config.onEvent).not.toHaveBeenCalled(); // seeded + + // Second poll at 2s + 1min + await vi.advanceTimersByTimeAsync(60000); + expect(config.onEvent).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('cleanup stops polling', async () => { + const config = makeConfig({ pollMinutes: 1 }); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + + // First poll + await vi.advanceTimersByTimeAsync(2000); + const callCountAfterFirst = (config.onEvent as ReturnType).mock.calls.length; + + cleanup(); + + // Advance past poll interval — no new polls should occur + await vi.advanceTimersByTimeAsync(600000); + expect((config.onEvent as ReturnType).mock.calls.length).toBe( + callCountAfterFirst + ); + }); + + it('initial poll delay — first poll at 2s, not immediately', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + + // At 0ms, nothing should have happened + expect(mockExecFile).not.toHaveBeenCalled(); + + // At 1999ms, still nothing + await vi.advanceTimersByTimeAsync(1999); + expect(mockExecFile).not.toHaveBeenCalled(); + + // At 2000ms, poll starts + await vi.advanceTimersByTimeAsync(1); + expect(mockExecFile).toHaveBeenCalled(); + + cleanup(); + }); + + it('poll interval — subsequent polls at configured interval', async () => { + const config = makeConfig({ pollMinutes: 2 }); + let pollCount = 0; + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + if (key.includes('--version')) { + cb(null, '2.0.0', ''); + } else if (key.includes('pr list')) { + pollCount++; + cb(null, JSON.stringify([]), ''); + } else { + cb(new Error('not found'), '', ''); + } + } + ); + + const cleanup = createCueGitHubPoller(config); + + // Initial poll at 2s + await vi.advanceTimersByTimeAsync(2000); + expect(pollCount).toBe(1); + + // Second poll at 2s + 2min + await vi.advanceTimersByTimeAsync(120000); + expect(pollCount).toBe(2); + + // Third poll at 2s + 4min + await vi.advanceTimersByTimeAsync(120000); + expect(pollCount).toBe(3); + + cleanup(); + }); + + it('gh parse error — invalid JSON from gh, error logged, no crash', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': 'not valid json{{{', + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('GitHub poll error') + ); + expect(config.onEvent).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('stopped during iteration — remaining items skipped', async () => { + const config = makeConfig(); + + // Track onEvent calls to call cleanup mid-iteration + let cleanupFn: (() => void) | null = null; + let eventCallCount = 0; + const originalOnEvent = vi.fn(() => { + eventCallCount++; + if (eventCallCount === 1 && cleanupFn) { + cleanupFn(); // Stop after first event + } + }); + config.onEvent = originalOnEvent; + + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + cleanupFn = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + // Should have fired 1 event then stopped (remaining 2 skipped) + expect(eventCallCount).toBe(1); + }); +}); diff --git a/src/__tests__/main/cue/cue-ipc-handlers.test.ts b/src/__tests__/main/cue/cue-ipc-handlers.test.ts new file mode 100644 index 000000000..0a16c42d9 --- /dev/null +++ b/src/__tests__/main/cue/cue-ipc-handlers.test.ts @@ -0,0 +1,370 @@ +/** + * Tests for Cue IPC handlers. + * + * Tests cover: + * - Handler registration with ipcMain.handle + * - Delegation to CueEngine methods (getStatus, getActiveRuns, etc.) + * - YAML read/write/validate operations + * - Engine enable/disable controls + * - Error handling when engine is not initialized + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Track registered IPC handlers +const registeredHandlers = new Map unknown>(); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((name: string) => `/mock-user-data/${name}`), + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + registeredHandlers.set(channel, handler); + }), + }, +})); + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + join: vi.fn((...args: string[]) => args.join('/')), + }; +}); + +vi.mock('js-yaml', () => ({ + load: vi.fn(), +})); + +vi.mock('../../../main/utils/ipcHandler', () => ({ + withIpcErrorLogging: vi.fn( + ( + _opts: unknown, + handler: (...args: unknown[]) => unknown + ): ((_event: unknown, ...args: unknown[]) => unknown) => { + return (_event: unknown, ...args: unknown[]) => handler(...args); + } + ), +})); + +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + validateCueConfig: vi.fn(), +})); + +vi.mock('../../../main/cue/cue-types', () => ({ + CUE_YAML_FILENAME: 'maestro-cue.yaml', +})); + +import { registerCueHandlers } from '../../../main/ipc/handlers/cue'; +import { validateCueConfig } from '../../../main/cue/cue-yaml-loader'; +import * as yaml from 'js-yaml'; + +// Create a mock CueEngine +function createMockEngine() { + return { + getStatus: vi.fn().mockReturnValue([]), + getActiveRuns: vi.fn().mockReturnValue([]), + getActivityLog: vi.fn().mockReturnValue([]), + start: vi.fn(), + stop: vi.fn(), + stopRun: vi.fn().mockReturnValue(true), + stopAll: vi.fn(), + refreshSession: vi.fn(), + isEnabled: vi.fn().mockReturnValue(false), + }; +} + +describe('Cue IPC Handlers', () => { + let mockEngine: ReturnType; + + beforeEach(() => { + registeredHandlers.clear(); + vi.clearAllMocks(); + mockEngine = createMockEngine(); + }); + + afterEach(() => { + registeredHandlers.clear(); + }); + + function registerAndGetHandler(channel: string) { + registerCueHandlers({ + getCueEngine: () => mockEngine as any, + }); + const handler = registeredHandlers.get(channel); + if (!handler) { + throw new Error(`Handler for channel "${channel}" not registered`); + } + return handler; + } + + describe('handler registration', () => { + it('should register all expected IPC channels', () => { + registerCueHandlers({ + getCueEngine: () => mockEngine as any, + }); + + const expectedChannels = [ + 'cue:getStatus', + 'cue:getActiveRuns', + 'cue:getActivityLog', + 'cue:enable', + 'cue:disable', + 'cue:stopRun', + 'cue:stopAll', + 'cue:refreshSession', + 'cue:readYaml', + 'cue:writeYaml', + 'cue:validateYaml', + 'cue:savePipelineLayout', + 'cue:loadPipelineLayout', + ]; + + for (const channel of expectedChannels) { + expect(registeredHandlers.has(channel)).toBe(true); + } + }); + }); + + describe('engine not initialized', () => { + it('should throw when engine is null', async () => { + registerCueHandlers({ + getCueEngine: () => null, + }); + + const handler = registeredHandlers.get('cue:getStatus')!; + await expect(handler(null)).rejects.toThrow('Cue engine not initialized'); + }); + }); + + describe('cue:getStatus', () => { + it('should delegate to engine.getStatus()', async () => { + const mockStatus = [ + { + sessionId: 's1', + sessionName: 'Test', + toolType: 'claude-code', + enabled: true, + subscriptionCount: 2, + activeRuns: 0, + }, + ]; + mockEngine.getStatus.mockReturnValue(mockStatus); + + const handler = registerAndGetHandler('cue:getStatus'); + const result = await handler(null); + expect(result).toEqual(mockStatus); + expect(mockEngine.getStatus).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:getActiveRuns', () => { + it('should delegate to engine.getActiveRuns()', async () => { + const mockRuns = [{ runId: 'r1', status: 'running' }]; + mockEngine.getActiveRuns.mockReturnValue(mockRuns); + + const handler = registerAndGetHandler('cue:getActiveRuns'); + const result = await handler(null); + expect(result).toEqual(mockRuns); + expect(mockEngine.getActiveRuns).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:getActivityLog', () => { + it('should delegate to engine.getActivityLog() with limit', async () => { + const mockLog = [{ runId: 'r1', status: 'completed' }]; + mockEngine.getActivityLog.mockReturnValue(mockLog); + + const handler = registerAndGetHandler('cue:getActivityLog'); + const result = await handler(null, { limit: 10 }); + expect(result).toEqual(mockLog); + expect(mockEngine.getActivityLog).toHaveBeenCalledWith(10); + }); + + it('should pass undefined limit when not provided', async () => { + const handler = registerAndGetHandler('cue:getActivityLog'); + await handler(null, {}); + expect(mockEngine.getActivityLog).toHaveBeenCalledWith(undefined); + }); + }); + + describe('cue:enable', () => { + it('should call engine.start()', async () => { + const handler = registerAndGetHandler('cue:enable'); + await handler(null); + expect(mockEngine.start).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:disable', () => { + it('should call engine.stop()', async () => { + const handler = registerAndGetHandler('cue:disable'); + await handler(null); + expect(mockEngine.stop).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:stopRun', () => { + it('should delegate to engine.stopRun() with runId', async () => { + mockEngine.stopRun.mockReturnValue(true); + const handler = registerAndGetHandler('cue:stopRun'); + const result = await handler(null, { runId: 'run-123' }); + expect(result).toBe(true); + expect(mockEngine.stopRun).toHaveBeenCalledWith('run-123'); + }); + + it('should return false when run not found', async () => { + mockEngine.stopRun.mockReturnValue(false); + const handler = registerAndGetHandler('cue:stopRun'); + const result = await handler(null, { runId: 'nonexistent' }); + expect(result).toBe(false); + }); + }); + + describe('cue:stopAll', () => { + it('should call engine.stopAll()', async () => { + const handler = registerAndGetHandler('cue:stopAll'); + await handler(null); + expect(mockEngine.stopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:refreshSession', () => { + it('should delegate to engine.refreshSession()', async () => { + const handler = registerAndGetHandler('cue:refreshSession'); + await handler(null, { sessionId: 's1', projectRoot: '/projects/test' }); + expect(mockEngine.refreshSession).toHaveBeenCalledWith('s1', '/projects/test'); + }); + }); + + describe('cue:readYaml', () => { + it('should return file content when file exists', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('subscriptions: []'); + + const handler = registerAndGetHandler('cue:readYaml'); + const result = await handler(null, { projectRoot: '/projects/test' }); + expect(result).toBe('subscriptions: []'); + expect(fs.existsSync).toHaveBeenCalledWith('/projects/test/maestro-cue.yaml'); + expect(fs.readFileSync).toHaveBeenCalledWith('/projects/test/maestro-cue.yaml', 'utf-8'); + }); + + it('should return null when file does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const handler = registerAndGetHandler('cue:readYaml'); + const result = await handler(null, { projectRoot: '/projects/test' }); + expect(result).toBeNull(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('cue:writeYaml', () => { + it('should write content to the correct file path', async () => { + const content = 'subscriptions:\n - name: test\n event: time.interval'; + + const handler = registerAndGetHandler('cue:writeYaml'); + await handler(null, { projectRoot: '/projects/test', content }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/projects/test/maestro-cue.yaml', + content, + 'utf-8' + ); + }); + }); + + describe('cue:validateYaml', () => { + it('should return valid result for valid YAML', async () => { + const content = 'subscriptions: []'; + vi.mocked(yaml.load).mockReturnValue({ subscriptions: [] }); + vi.mocked(validateCueConfig).mockReturnValue({ valid: true, errors: [] }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ valid: true, errors: [] }); + expect(yaml.load).toHaveBeenCalledWith(content); + expect(validateCueConfig).toHaveBeenCalledWith({ subscriptions: [] }); + }); + + it('should return errors for invalid config', async () => { + const content = 'subscriptions: invalid'; + vi.mocked(yaml.load).mockReturnValue({ subscriptions: 'invalid' }); + vi.mocked(validateCueConfig).mockReturnValue({ + valid: false, + errors: ['Config must have a "subscriptions" array'], + }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ + valid: false, + errors: ['Config must have a "subscriptions" array'], + }); + }); + + it('should return parse error for malformed YAML', async () => { + const content = '{{invalid yaml'; + vi.mocked(yaml.load).mockImplementation(() => { + throw new Error('bad indentation'); + }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ + valid: false, + errors: ['YAML parse error: bad indentation'], + }); + }); + }); + + describe('cue:savePipelineLayout', () => { + it('should write layout to JSON file', async () => { + const layout = { + pipelines: [{ id: 'p1', name: 'Pipeline 1', color: '#06b6d4', nodes: [], edges: [] }], + selectedPipelineId: 'p1', + viewport: { x: 0, y: 0, zoom: 1 }, + }; + + const handler = registerAndGetHandler('cue:savePipelineLayout'); + await handler(null, { layout }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('cue-pipeline-layout.json'), + JSON.stringify(layout, null, 2), + 'utf-8' + ); + }); + }); + + describe('cue:loadPipelineLayout', () => { + it('should return layout when file exists', async () => { + const layout = { + pipelines: [{ id: 'p1', name: 'Pipeline 1', color: '#06b6d4', nodes: [], edges: [] }], + selectedPipelineId: 'p1', + viewport: { x: 100, y: 200, zoom: 1.5 }, + }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(layout)); + + const handler = registerAndGetHandler('cue:loadPipelineLayout'); + const result = await handler(null); + expect(result).toEqual(layout); + }); + + it('should return null when file does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const handler = registerAndGetHandler('cue:loadPipelineLayout'); + const result = await handler(null); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-reconciler.test.ts b/src/__tests__/main/cue/cue-reconciler.test.ts new file mode 100644 index 000000000..772091987 --- /dev/null +++ b/src/__tests__/main/cue/cue-reconciler.test.ts @@ -0,0 +1,393 @@ +/** + * Tests for the Cue Time Event Reconciler (cue-reconciler.ts). + * + * Tests cover: + * - Missed interval calculation + * - Single catch-up event per subscription (no flooding) + * - Skipping file.changed and agent.completed events + * - Skipping disabled subscriptions + * - Reconciled payload metadata (reconciled: true, missedCount) + * - Zero-gap and negative-gap edge cases + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { reconcileMissedTimeEvents } from '../../../main/cue/cue-reconciler'; +import type { ReconcileConfig, ReconcileSessionInfo } from '../../../main/cue/cue-reconciler'; +import type { CueConfig, CueEvent, CueSubscription } from '../../../main/cue/cue-types'; + +function createConfig(subscriptions: CueSubscription[]): CueConfig { + return { + subscriptions, + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + }; +} + +describe('reconcileMissedTimeEvents', () => { + let dispatched: Array<{ sessionId: string; sub: CueSubscription; event: CueEvent }>; + let logged: Array<{ level: string; message: string }>; + + beforeEach(() => { + dispatched = []; + logged = []; + }); + + function makeConfig(overrides: Partial = {}): ReconcileConfig { + return { + sleepStartMs: Date.now() - 60 * 60 * 1000, // 1 hour ago + wakeTimeMs: Date.now(), + sessions: new Map(), + onDispatch: (sessionId, sub, event) => { + dispatched.push({ sessionId, sub, event }); + }, + onLog: (level, message) => { + logged.push({ level, message }); + }, + ...overrides, + }; + } + + it('should fire one catch-up event for a missed interval', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'every-15m', + event: 'time.interval', + enabled: true, + prompt: 'check status', + interval_minutes: 15, + }, + ]), + sessionName: 'Test Session', + }); + + // Sleep for 1 hour means 4 intervals of 15m were missed + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + // Should fire exactly one catch-up event (not 4) + expect(dispatched).toHaveLength(1); + expect(dispatched[0].sessionId).toBe('session-1'); + expect(dispatched[0].event.type).toBe('time.interval'); + expect(dispatched[0].event.triggerName).toBe('every-15m'); + expect(dispatched[0].event.payload.reconciled).toBe(true); + expect(dispatched[0].event.payload.missedCount).toBe(4); + }); + + it('should skip when no intervals were missed', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'every-2h', + event: 'time.interval', + enabled: true, + prompt: 'long check', + interval_minutes: 120, + }, + ]), + sessionName: 'Test Session', + }); + + // Sleep for 30 minutes — interval is 2 hours, so 0 missed + const config = makeConfig({ + sleepStartMs: Date.now() - 30 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should not reconcile file.changed subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'file-watcher', + event: 'file.changed', + enabled: true, + prompt: 'check files', + watch: 'src/**/*.ts', + }, + ]), + sessionName: 'Test Session', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should not reconcile agent.completed subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'chain-reaction', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'other-agent', + }, + ]), + sessionName: 'Test Session', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should skip disabled subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'disabled-timer', + event: 'time.interval', + enabled: false, + prompt: 'disabled', + interval_minutes: 5, + }, + ]), + sessionName: 'Test Session', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should handle multiple sessions with multiple subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'fast-timer', + event: 'time.interval', + enabled: true, + prompt: 'fast check', + interval_minutes: 10, + }, + { + name: 'slow-timer', + event: 'time.interval', + enabled: true, + prompt: 'slow check', + interval_minutes: 60, + }, + { + name: 'file-watcher', + event: 'file.changed', + enabled: true, + prompt: 'watch files', + watch: '*.ts', + }, + ]), + sessionName: 'Session A', + }); + sessions.set('session-2', { + config: createConfig([ + { + name: 'another-timer', + event: 'time.interval', + enabled: true, + prompt: 'another check', + interval_minutes: 30, + }, + ]), + sessionName: 'Session B', + }); + + // 90 minutes of sleep + const config = makeConfig({ + sleepStartMs: Date.now() - 90 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + // fast-timer: 90/10 = 9 missed → 1 catch-up + // slow-timer: 90/60 = 1 missed → 1 catch-up + // file-watcher: skipped (not time.interval) + // another-timer: 90/30 = 3 missed → 1 catch-up + expect(dispatched).toHaveLength(3); + + const fastTimer = dispatched.find((d) => d.event.triggerName === 'fast-timer'); + expect(fastTimer?.event.payload.missedCount).toBe(9); + + const slowTimer = dispatched.find((d) => d.event.triggerName === 'slow-timer'); + expect(slowTimer?.event.payload.missedCount).toBe(1); + + const anotherTimer = dispatched.find((d) => d.event.triggerName === 'another-timer'); + expect(anotherTimer?.event.payload.missedCount).toBe(3); + expect(anotherTimer?.sessionId).toBe('session-2'); + }); + + it('should include sleepDurationMs in the event payload', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 5, + }, + ]), + sessionName: 'Test', + }); + + const sleepDuration = 60 * 60 * 1000; // 1 hour + const config = makeConfig({ + sleepStartMs: Date.now() - sleepDuration, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched[0].event.payload.sleepDurationMs).toBe(sleepDuration); + }); + + it('should do nothing with zero gap', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 5, + }, + ]), + sessionName: 'Test', + }); + + const now = Date.now(); + const config = makeConfig({ + sleepStartMs: now, + wakeTimeMs: now, + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should do nothing with negative gap', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'timer', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 5, + }, + ]), + sessionName: 'Test', + }); + + const now = Date.now(); + const config = makeConfig({ + sleepStartMs: now, + wakeTimeMs: now - 1000, // Wake before sleep (shouldn't happen, but edge case) + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should log reconciliation for each fired catch-up', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'my-timer', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 10, + }, + ]), + sessionName: 'Test', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(logged.some((l) => l.message.includes('Reconciling "my-timer"'))).toBe(true); + expect(logged.some((l) => l.message.includes('6 interval(s) missed'))).toBe(true); + }); + + it('should skip subscriptions with zero interval_minutes', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'zero-interval', + event: 'time.interval', + enabled: true, + prompt: 'check', + interval_minutes: 0, + }, + ]), + sessionName: 'Test', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); +}); diff --git a/src/__tests__/main/cue/cue-sleep-wake.test.ts b/src/__tests__/main/cue/cue-sleep-wake.test.ts new file mode 100644 index 000000000..4071f8a27 --- /dev/null +++ b/src/__tests__/main/cue/cue-sleep-wake.test.ts @@ -0,0 +1,308 @@ +/** + * Tests for the CueEngine sleep/wake detection and reconciliation. + * + * Tests cover: + * - Heartbeat starts on engine.start() and stops on engine.stop() + * - Sleep detection triggers reconciler when gap >= 2 minutes + * - No reconciliation when gap < 2 minutes + * - Database pruning on start + * - Graceful handling of missing/uninitialized database + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Track cue-db calls +const mockInitCueDb = vi.fn(); +const mockCloseCueDb = vi.fn(); +const mockUpdateHeartbeat = vi.fn(); +const mockGetLastHeartbeat = vi.fn<() => number | null>(); +const mockPruneCueEvents = vi.fn(); + +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: (...args: unknown[]) => mockInitCueDb(...args), + closeCueDb: () => mockCloseCueDb(), + updateHeartbeat: () => mockUpdateHeartbeat(), + getLastHeartbeat: () => mockGetLastHeartbeat(), + pruneCueEvents: (...args: unknown[]) => mockPruneCueEvents(...args), +})); + +// Track reconciler calls +const mockReconcileMissedTimeEvents = vi.fn(); +vi.mock('../../../main/cue/cue-reconciler', () => ({ + reconcileMissedTimeEvents: (...args: unknown[]) => mockReconcileMissedTimeEvents(...args), +})); + +// 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 +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: vi.fn(() => 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'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [ + { + name: 'timer-sub', + event: 'time.interval', + enabled: true, + prompt: 'check status', + interval_minutes: 15, + }, + ], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine sleep/wake detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockLoadCueConfig.mockReturnValue(createMockConfig()); + mockGetLastHeartbeat.mockReturnValue(null); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should initialize the Cue database on start', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockInitCueDb).toHaveBeenCalledTimes(1); + expect(mockInitCueDb).toHaveBeenCalledWith(expect.any(Function)); + + engine.stop(); + }); + + it('should prune old events on start', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockPruneCueEvents).toHaveBeenCalledTimes(1); + // 7 days in milliseconds + expect(mockPruneCueEvents).toHaveBeenCalledWith(7 * 24 * 60 * 60 * 1000); + + engine.stop(); + }); + + it('should write heartbeat immediately on start', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('should write heartbeat every 30 seconds', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Initial call + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(1); + + // Advance 30 seconds + vi.advanceTimersByTime(30_000); + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(2); + + // Advance another 30 seconds + vi.advanceTimersByTime(30_000); + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(3); + + engine.stop(); + }); + + it('should stop heartbeat on engine stop', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const callCount = mockUpdateHeartbeat.mock.calls.length; + engine.stop(); + + // Advance time — no more heartbeats should fire + vi.advanceTimersByTime(60_000); + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(callCount); + }); + + it('should close the database on stop', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + expect(mockCloseCueDb).toHaveBeenCalledTimes(1); + }); + + it('should not reconcile on first start (no previous heartbeat)', () => { + mockGetLastHeartbeat.mockReturnValue(null); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockReconcileMissedTimeEvents).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('should not reconcile when gap is less than 2 minutes', () => { + // Last heartbeat was 60 seconds ago (below 120s threshold) + mockGetLastHeartbeat.mockReturnValue(Date.now() - 60_000); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockReconcileMissedTimeEvents).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('should reconcile when gap exceeds 2 minutes', () => { + // Last heartbeat was 10 minutes ago + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + mockGetLastHeartbeat.mockReturnValue(tenMinutesAgo); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockReconcileMissedTimeEvents).toHaveBeenCalledTimes(1); + const reconcileArgs = mockReconcileMissedTimeEvents.mock.calls[0][0]; + expect(reconcileArgs.sleepStartMs).toBe(tenMinutesAgo); + expect(reconcileArgs.sessions).toBeInstanceOf(Map); + expect(typeof reconcileArgs.onDispatch).toBe('function'); + expect(typeof reconcileArgs.onLog).toBe('function'); + + engine.stop(); + }); + + it('should log sleep detection with gap duration', () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + mockGetLastHeartbeat.mockReturnValue(fiveMinutesAgo); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Sleep detected (gap: 5m)') + ); + + engine.stop(); + }); + + it('should handle database initialization failure gracefully', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB init failed'); + }); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // Should not throw + expect(() => engine.start()).not.toThrow(); + + // Should log the warning + expect(deps.onLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Failed to initialize Cue database') + ); + + engine.stop(); + }); + + it('should handle heartbeat read failure gracefully during sleep detection', () => { + mockGetLastHeartbeat.mockImplementation(() => { + throw new Error('DB read failed'); + }); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // Should not throw + expect(() => engine.start()).not.toThrow(); + + engine.stop(); + }); + + it('should pass session info to the reconciler', () => { + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + mockGetLastHeartbeat.mockReturnValue(tenMinutesAgo); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const reconcileArgs = mockReconcileMissedTimeEvents.mock.calls[0][0]; + const sessions = reconcileArgs.sessions as Map; + + // Should contain the session from our mock + expect(sessions.size).toBe(1); + expect(sessions.has('session-1')).toBe(true); + + const sessionInfo = sessions.get('session-1') as { config: CueConfig; sessionName: string }; + expect(sessionInfo.sessionName).toBe('Test Session'); + expect(sessionInfo.config.subscriptions).toHaveLength(1); + + engine.stop(); + }); +}); diff --git a/src/__tests__/main/cue/cue-task-scanner.test.ts b/src/__tests__/main/cue/cue-task-scanner.test.ts new file mode 100644 index 000000000..1446fa6cb --- /dev/null +++ b/src/__tests__/main/cue/cue-task-scanner.test.ts @@ -0,0 +1,305 @@ +/** + * Tests for the Cue task scanner module. + * + * Tests cover: + * - extractPendingTasks: parsing markdown for unchecked tasks + * - createCueTaskScanner: polling lifecycle, hash tracking, event emission + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock fs +const mockReadFileSync = vi.fn(); +const mockReaddirSync = vi.fn(); +vi.mock('fs', () => ({ + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), + readdirSync: (...args: unknown[]) => mockReaddirSync(...args), +})); + +// Mock picomatch +vi.mock('picomatch', () => ({ + default: (pattern: string) => { + // Simple mock: match files ending in .md for "**/*.md" pattern + if (pattern === '**/*.md' || pattern === 'tasks/**/*.md') { + return (file: string) => file.endsWith('.md'); + } + return () => true; + }, +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), + createHash: () => ({ + update: (content: string) => ({ + digest: () => `hash-${content.length}`, + }), + }), +})); + +import { extractPendingTasks, createCueTaskScanner } from '../../../main/cue/cue-task-scanner'; + +describe('cue-task-scanner', () => { + describe('extractPendingTasks', () => { + it('extracts unchecked tasks from markdown', () => { + const content = `# Tasks +- [ ] First task +- [x] Completed task +- [ ] Second task +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(2); + expect(tasks[0]).toEqual({ line: 2, text: 'First task' }); + expect(tasks[1]).toEqual({ line: 4, text: 'Second task' }); + }); + + it('handles indented tasks', () => { + const content = `# Project + - [ ] Nested task + - [ ] Deeply nested +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(2); + expect(tasks[0].text).toBe('Nested task'); + expect(tasks[1].text).toBe('Deeply nested'); + }); + + it('handles different list markers', () => { + const content = `- [ ] Dash task +* [ ] Star task ++ [ ] Plus task +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(3); + }); + + it('returns empty array for no pending tasks', () => { + const content = `# Done +- [x] All done +- [x] Also done +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(0); + }); + + it('returns empty array for empty content', () => { + const tasks = extractPendingTasks(''); + expect(tasks).toHaveLength(0); + }); + + it('skips tasks with empty text', () => { + const content = `- [ ] +- [ ] Real task +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(1); + expect(tasks[0].text).toBe('Real task'); + }); + + it('does not match checked tasks', () => { + const content = `- [x] Done +- [X] Also done +- [ ] Not done +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(1); + expect(tasks[0].text).toBe('Not done'); + }); + }); + + describe('createCueTaskScanner', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns a cleanup function', () => { + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent: vi.fn(), + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + expect(typeof cleanup).toBe('function'); + cleanup(); + }); + + it('cleanup stops polling', () => { + const onEvent = vi.fn(); + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + cleanup(); + + // Advance past initial delay + vi.advanceTimersByTime(3000); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('seeds hashes on first scan without firing events', async () => { + const onEvent = vi.fn(); + + // Mock directory walk: one .md file with pending tasks + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + mockReadFileSync.mockReturnValue('- [ ] Pending task\n'); + + createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // Advance past initial delay + await vi.advanceTimersByTimeAsync(3000); + + // First scan seeds hashes — should NOT fire events + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('fires event on second scan when content has changed and has pending tasks', async () => { + const onEvent = vi.fn(); + + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + // First scan: seed with initial content + mockReadFileSync.mockReturnValueOnce('- [ ] Initial task\n'); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // First scan (seed) + await vi.advanceTimersByTimeAsync(3000); + expect(onEvent).not.toHaveBeenCalled(); + + // Second scan: content changed, has pending tasks + mockReadFileSync.mockReturnValue('- [ ] Initial task\n- [ ] New task\n'); + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(onEvent).toHaveBeenCalledTimes(1); + const event = onEvent.mock.calls[0][0]; + expect(event.type).toBe('task.pending'); + expect(event.triggerName).toBe('test-scanner'); + expect(event.payload.taskCount).toBe(2); + expect(event.payload.filename).toBe('task.md'); + + cleanup(); + }); + + it('does not fire when content unchanged', async () => { + const onEvent = vi.fn(); + + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + // Same content every scan + mockReadFileSync.mockReturnValue('- [ ] Same task\n'); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // First scan + second scan + await vi.advanceTimersByTimeAsync(3000); + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(onEvent).not.toHaveBeenCalled(); + cleanup(); + }); + + it('does not fire when content changed but no pending tasks', async () => { + const onEvent = vi.fn(); + + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + // First scan: has pending tasks + mockReadFileSync.mockReturnValueOnce('- [ ] Task\n'); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // Seed + await vi.advanceTimersByTimeAsync(3000); + + // Second scan: all tasks completed + mockReadFileSync.mockReturnValue('- [x] Task\n'); + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(onEvent).not.toHaveBeenCalled(); + cleanup(); + }); + + it('logs error when scan fails', async () => { + const onLog = vi.fn(); + + mockReaddirSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent: vi.fn(), + onLog, + triggerName: 'test-scanner', + }); + + await vi.advanceTimersByTimeAsync(3000); + + expect(onLog).toHaveBeenCalledWith('error', expect.stringContaining('Task scan error')); + + cleanup(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-yaml-loader.test.ts b/src/__tests__/main/cue/cue-yaml-loader.test.ts new file mode 100644 index 000000000..413a85977 --- /dev/null +++ b/src/__tests__/main/cue/cue-yaml-loader.test.ts @@ -0,0 +1,719 @@ +/** + * Tests for the Cue YAML loader module. + * + * Tests cover: + * - Loading and parsing maestro-cue.yaml files + * - Handling missing files + * - Merging with default settings + * - Validation of subscription fields per event type + * - YAML file watching with debounce + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock chokidar +const mockChokidarOn = vi.fn().mockReturnThis(); +const mockChokidarClose = vi.fn(); +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: mockChokidarOn, + close: mockChokidarClose, + })), +})); + +// Mock fs +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +vi.mock('fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// Must import after mocks +import { loadCueConfig, watchCueYaml, validateCueConfig } from '../../../main/cue/cue-yaml-loader'; +import * as chokidar from 'chokidar'; + +describe('cue-yaml-loader', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('loadCueConfig', () => { + it('returns null when file does not exist', () => { + mockExistsSync.mockReturnValue(false); + const result = loadCueConfig('/projects/test'); + expect(result).toBeNull(); + }); + + it('parses a valid YAML config with subscriptions and settings', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: daily-check + event: time.interval + enabled: true + prompt: Check all tests + interval_minutes: 60 + - name: watch-src + event: file.changed + enabled: true + prompt: Run lint + watch: "src/**/*.ts" +settings: + timeout_minutes: 15 + timeout_on_fail: continue +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions).toHaveLength(2); + expect(result!.subscriptions[0].name).toBe('daily-check'); + expect(result!.subscriptions[0].event).toBe('time.interval'); + expect(result!.subscriptions[0].interval_minutes).toBe(60); + expect(result!.subscriptions[1].name).toBe('watch-src'); + expect(result!.subscriptions[1].watch).toBe('src/**/*.ts'); + expect(result!.settings.timeout_minutes).toBe(15); + expect(result!.settings.timeout_on_fail).toBe('continue'); + }); + + it('uses default settings when settings section is missing', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.interval + prompt: Do stuff + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.settings.timeout_minutes).toBe(30); + expect(result!.settings.timeout_on_fail).toBe('break'); + expect(result!.settings.max_concurrent).toBe(1); + expect(result!.settings.queue_size).toBe(10); + }); + + it('defaults enabled to true when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.interval + prompt: Do stuff + interval_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].enabled).toBe(true); + }); + + it('respects enabled: false', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: disabled-sub + event: time.interval + enabled: false + prompt: Do stuff + interval_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].enabled).toBe(false); + }); + + it('returns null for empty YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(''); + const result = loadCueConfig('/projects/test'); + expect(result).toBeNull(); + }); + + it('throws on malformed YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{ invalid yaml ['); + expect(() => loadCueConfig('/projects/test')).toThrow(); + }); + + it('handles agent.completed with source_session array', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: fan-in-trigger + event: agent.completed + prompt: All agents done + source_session: + - agent-1 + - agent-2 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].source_session).toEqual(['agent-1', 'agent-2']); + }); + }); + + describe('watchCueYaml', () => { + it('watches the correct file path', () => { + watchCueYaml('/projects/test', vi.fn()); + expect(chokidar.watch).toHaveBeenCalledWith( + expect.stringContaining('maestro-cue.yaml'), + expect.objectContaining({ persistent: true, ignoreInitial: true }) + ); + }); + + it('calls onChange with debounce on file change', () => { + const onChange = vi.fn(); + watchCueYaml('/projects/test', onChange); + + // Simulate a 'change' event via the mock's on handler + const changeHandler = mockChokidarOn.mock.calls.find( + (call: unknown[]) => call[0] === 'change' + )?.[1]; + expect(changeHandler).toBeDefined(); + + changeHandler!(); + expect(onChange).not.toHaveBeenCalled(); // Not yet — debounced + + vi.advanceTimersByTime(1000); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('debounces multiple rapid changes', () => { + const onChange = vi.fn(); + watchCueYaml('/projects/test', onChange); + + const changeHandler = mockChokidarOn.mock.calls.find( + (call: unknown[]) => call[0] === 'change' + )?.[1]; + + changeHandler!(); + vi.advanceTimersByTime(500); + changeHandler!(); + vi.advanceTimersByTime(500); + changeHandler!(); + vi.advanceTimersByTime(1000); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('cleanup function closes watcher', () => { + const cleanup = watchCueYaml('/projects/test', vi.fn()); + cleanup(); + expect(mockChokidarClose).toHaveBeenCalled(); + }); + + it('registers handlers for add, change, and unlink events', () => { + watchCueYaml('/projects/test', vi.fn()); + const registeredEvents = mockChokidarOn.mock.calls.map((call: unknown[]) => call[0]); + expect(registeredEvents).toContain('add'); + expect(registeredEvents).toContain('change'); + expect(registeredEvents).toContain('unlink'); + }); + }); + + describe('validateCueConfig', () => { + it('returns valid for a correct config', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: 'test', event: 'time.interval', prompt: 'Do it', interval_minutes: 5 }, + ], + settings: { timeout_minutes: 30, timeout_on_fail: 'break' }, + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects non-object config', () => { + const result = validateCueConfig(null); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('non-null object'); + }); + + it('requires subscriptions array', () => { + const result = validateCueConfig({ settings: {} }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('subscriptions'); + }); + + it('requires name on subscriptions', () => { + const result = validateCueConfig({ + subscriptions: [{ event: 'time.interval', prompt: 'Test', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('"name"')])); + }); + + it('requires interval_minutes for time.interval', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.interval', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('interval_minutes')]) + ); + }); + + it('requires watch for file.changed', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'file.changed', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('watch')])); + }); + + it('requires source_session for agent.completed', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'agent.completed', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('source_session')]) + ); + }); + + it('rejects invalid timeout_on_fail value', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'invalid' }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('timeout_on_fail')]) + ); + }); + + it('accepts valid timeout_on_fail values', () => { + const breakResult = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'break' }, + }); + expect(breakResult.valid).toBe(true); + + const continueResult = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'continue' }, + }); + expect(continueResult.valid).toBe(true); + }); + + it('rejects invalid max_concurrent value', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 0 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('max_concurrent')]) + ); + }); + + it('rejects max_concurrent above 10', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 11 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('max_concurrent')]) + ); + }); + + it('rejects non-integer max_concurrent', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 1.5 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('max_concurrent')]) + ); + }); + + it('accepts valid max_concurrent values', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 5 }, + }); + expect(result.valid).toBe(true); + }); + + it('rejects negative queue_size', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { queue_size: -1 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('queue_size')]) + ); + }); + + it('rejects queue_size above 50', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { queue_size: 51 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('queue_size')]) + ); + }); + + it('accepts valid queue_size values including 0', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { queue_size: 0 }, + }); + expect(result.valid).toBe(true); + }); + + it('requires prompt to be a non-empty string', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.interval', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('"prompt"')])); + }); + + it('accepts valid filter with string/number/boolean values', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: { extension: '.ts', active: true, priority: 5 }, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects filter with nested object values', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: { nested: { deep: 'value' } }, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('filter key "nested"')]) + ); + }); + + it('rejects filter that is an array', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: ['not', 'valid'], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"filter" must be a plain object')]) + ); + }); + + it('rejects filter with null value', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: null, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"filter" must be a plain object')]) + ); + }); + }); + + describe('loadCueConfig with GitHub events', () => { + it('parses repo and poll_minutes from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: pr-watch + event: github.pull_request + prompt: Review the PR + repo: owner/repo + poll_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].repo).toBe('owner/repo'); + expect(result!.subscriptions[0].poll_minutes).toBe(10); + }); + + it('defaults poll_minutes to undefined when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: issue-watch + event: github.issue + prompt: Triage issue +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].poll_minutes).toBeUndefined(); + expect(result!.subscriptions[0].repo).toBeUndefined(); + }); + }); + + describe('validateCueConfig for GitHub events', () => { + it('accepts valid github.pull_request subscription', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'pr-watch', event: 'github.pull_request', prompt: 'Review it' }], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts github.pull_request with repo and poll_minutes', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'pr-watch', + event: 'github.pull_request', + prompt: 'Review it', + repo: 'owner/repo', + poll_minutes: 10, + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects github.pull_request with poll_minutes < 1', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'pr-watch', + event: 'github.pull_request', + prompt: 'Review', + poll_minutes: 0.5, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('poll_minutes')]) + ); + }); + + it('rejects github.pull_request with poll_minutes = 0', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'pr-watch', + event: 'github.pull_request', + prompt: 'Review', + poll_minutes: 0, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('poll_minutes')]) + ); + }); + + it('rejects github.issue with non-string repo', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'issue-watch', + event: 'github.issue', + prompt: 'Triage', + repo: 123, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"repo" must be a string')]) + ); + }); + + it('accepts github.issue with filter', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'issue-watch', + event: 'github.issue', + prompt: 'Triage', + filter: { author: 'octocat', labels: 'bug' }, + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('validateCueConfig for task.pending events', () => { + it('accepts valid task.pending subscription', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + prompt: 'Process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('requires watch for task.pending', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'task-queue', event: 'task.pending', prompt: 'Process tasks' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('watch')])); + }); + + it('accepts task.pending with poll_minutes', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + prompt: 'Process', + watch: 'tasks/**/*.md', + poll_minutes: 5, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects task.pending with poll_minutes < 1', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + prompt: 'Process', + watch: 'tasks/**/*.md', + poll_minutes: 0, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('poll_minutes')]) + ); + }); + }); + + describe('loadCueConfig with task.pending', () => { + it('parses watch and poll_minutes from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: task-queue + event: task.pending + prompt: Process the tasks + watch: "tasks/**/*.md" + poll_minutes: 2 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].event).toBe('task.pending'); + expect(result!.subscriptions[0].watch).toBe('tasks/**/*.md'); + expect(result!.subscriptions[0].poll_minutes).toBe(2); + }); + }); + + describe('loadCueConfig with filter', () => { + it('parses filter field from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: ts-only + event: file.changed + prompt: Review it + watch: "src/**/*" + filter: + extension: ".ts" + path: "!*.test.ts" +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].filter).toEqual({ + extension: '.ts', + path: '!*.test.ts', + }); + }); + + it('parses filter with boolean and numeric values', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: filtered + event: agent.completed + prompt: Do it + source_session: agent-1 + filter: + active: true + exitCode: 0 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].filter).toEqual({ + active: true, + exitCode: 0, + }); + }); + + it('ignores filter with invalid nested values', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: bad-filter + event: file.changed + prompt: Do it + watch: "src/**" + filter: + nested: + deep: value +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].filter).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/main/process-listeners/exit-listener.test.ts b/src/__tests__/main/process-listeners/exit-listener.test.ts index 7988edeeb..d45b9f104 100644 --- a/src/__tests__/main/process-listeners/exit-listener.test.ts +++ b/src/__tests__/main/process-listeners/exit-listener.test.ts @@ -350,6 +350,102 @@ describe('Exit Listener', () => { }); }); + describe('Cue Completion Notification', () => { + it('should notify Cue engine on regular session exit when enabled', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockCueEngine.hasCompletionSubscribers).toHaveBeenCalledWith('regular-session-123'); + expect(mockCueEngine.notifyAgentCompleted).toHaveBeenCalledWith('regular-session-123', { + status: 'completed', + exitCode: 0, + }); + }); + + it('should pass failed status when exit code is non-zero', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 1); + + expect(mockCueEngine.notifyAgentCompleted).toHaveBeenCalledWith('regular-session-123', { + status: 'failed', + exitCode: 1, + }); + }); + + it('should not notify when Cue feature is disabled', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => false; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockCueEngine.notifyAgentCompleted).not.toHaveBeenCalled(); + }); + + it('should not notify when no completion subscribers exist', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(false), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockCueEngine.hasCompletionSubscribers).toHaveBeenCalledWith('regular-session-123'); + expect(mockCueEngine.notifyAgentCompleted).not.toHaveBeenCalled(); + }); + + it('should not notify for group chat sessions', async () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + // Moderator session + handler?.('group-chat-test-chat-123-moderator-1234567890', 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.routeModeratorResponse).toHaveBeenCalled(); + }); + + // Moderator exits return early before reaching Cue notification + expect(mockCueEngine.notifyAgentCompleted).not.toHaveBeenCalled(); + }); + }); + describe('Error Handling', () => { beforeEach(() => { mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue({ diff --git a/src/__tests__/renderer/components/CueHelpModal.test.tsx b/src/__tests__/renderer/components/CueHelpModal.test.tsx new file mode 100644 index 000000000..b994b4769 --- /dev/null +++ b/src/__tests__/renderer/components/CueHelpModal.test.tsx @@ -0,0 +1,187 @@ +/** + * Tests for CueHelpContent component + * + * CueHelpContent displays comprehensive documentation about the Maestro Cue + * event-driven automation feature. It renders inline within the CueModal. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { CueHelpContent } from '../../../renderer/components/CueHelpModal'; +import type { Theme } from '../../../renderer/types'; + +// Mock formatShortcutKeys to return predictable output +vi.mock('../../../renderer/utils/shortcutFormatter', () => ({ + formatShortcutKeys: (keys: string[]) => keys.join('+'), + isMacOS: () => false, +})); + +// Sample theme for testing +const mockTheme: Theme = { + id: 'test-dark' as Theme['id'], + name: 'Test Dark', + mode: 'dark', + colors: { + bgMain: '#1a1a1a', + bgSidebar: '#252525', + bgActivity: '#2d2d2d', + border: '#444444', + textMain: '#ffffff', + textDim: '#888888', + accent: '#007acc', + accentDim: '#007acc40', + accentText: '#007acc', + accentForeground: '#ffffff', + error: '#ff4444', + success: '#44ff44', + warning: '#ffaa00', + }, +}; + +describe('CueHelpContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Content Sections', () => { + beforeEach(() => { + render(); + }); + + it('should render What is Maestro Cue section', () => { + expect(screen.getByText('What is Maestro Cue?')).toBeInTheDocument(); + expect(screen.getByText(/event-driven automation system/)).toBeInTheDocument(); + }); + + it('should render Getting Started section', () => { + expect(screen.getByText('Getting Started')).toBeInTheDocument(); + expect(screen.getByText(/maestro-cue.yaml/)).toBeInTheDocument(); + }); + + it('should render minimal YAML example', () => { + expect(screen.getByText(/My First Cue/)).toBeInTheDocument(); + }); + + it('should render Event Types section', () => { + expect(screen.getByText('Event Types')).toBeInTheDocument(); + }); + + it('should render all event types', () => { + expect(screen.getByText('Interval')).toBeInTheDocument(); + expect(screen.getByText('File Watch')).toBeInTheDocument(); + expect(screen.getByText('Agent Completed')).toBeInTheDocument(); + }); + + it('should render event type codes', () => { + expect(screen.getByText('time.interval')).toBeInTheDocument(); + expect(screen.getByText('file.changed')).toBeInTheDocument(); + expect(screen.getByText('agent.completed')).toBeInTheDocument(); + }); + + it('should render Template Variables section', () => { + expect(screen.getByText('Template Variables')).toBeInTheDocument(); + }); + + it('should render CUE template variables', () => { + expect(screen.getByText('{{CUE_EVENT_TYPE}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_EVENT_TIMESTAMP}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_TRIGGER_NAME}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_RUN_ID}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_FILE_PATH}}')).toBeInTheDocument(); + }); + + it('should render new file and agent completion template variables', () => { + expect(screen.getByText('{{CUE_FILE_CHANGE_TYPE}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_STATUS}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_EXIT_CODE}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_DURATION}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_TRIGGERED_BY}}')).toBeInTheDocument(); + }); + + it('should mention standard Maestro template variables', () => { + expect(screen.getByText('{{AGENT_NAME}}')).toBeInTheDocument(); + expect(screen.getByText('{{DATE}}')).toBeInTheDocument(); + }); + + it('should render Multi-Agent Orchestration section', () => { + expect(screen.getByText('Multi-Agent Orchestration')).toBeInTheDocument(); + }); + + it('should render fan-out and fan-in patterns', () => { + expect(screen.getByText(/Fan-Out:/)).toBeInTheDocument(); + expect(screen.getByText(/Fan-In:/)).toBeInTheDocument(); + }); + + it('should render Timeouts & Failure Handling section', () => { + expect(screen.getByText('Timeouts & Failure Handling')).toBeInTheDocument(); + expect(screen.getByText(/Default timeout is 30 minutes/)).toBeInTheDocument(); + }); + + it('should render Visual Pipeline Editor section', () => { + expect(screen.getByText('Visual Pipeline Editor')).toBeInTheDocument(); + }); + + it('should render Coordination Patterns section', () => { + expect(screen.getByText('Coordination Patterns')).toBeInTheDocument(); + }); + + it('should render all coordination pattern names', () => { + expect(screen.getByText('Scheduled Task')).toBeInTheDocument(); + expect(screen.getByText('File Enrichment')).toBeInTheDocument(); + expect(screen.getByText('Research Swarm')).toBeInTheDocument(); + expect(screen.getByText('Sequential Chain')).toBeInTheDocument(); + expect(screen.getByText('Debate')).toBeInTheDocument(); + }); + + it('should render Event Filtering section', () => { + expect(screen.getByText('Event Filtering')).toBeInTheDocument(); + }); + + it('should mention triggeredBy filter', () => { + const elements = screen.getAllByText(/triggeredBy/); + expect(elements.length).toBeGreaterThan(0); + }); + }); + + describe('Shortcut Keys', () => { + it('should render keyboard shortcut tip', () => { + render(); + + const kbdElements = document.querySelectorAll('kbd'); + expect(kbdElements.length).toBeGreaterThan(0); + expect(screen.getByText(/to open the Cue dashboard/)).toBeInTheDocument(); + }); + + it('should render custom shortcut keys when provided', () => { + render(); + + const kbdElements = document.querySelectorAll('kbd'); + const hasCustomShortcut = Array.from(kbdElements).some((kbd) => { + const text = kbd.textContent || ''; + return text.includes('C') || text.includes('c'); + }); + expect(hasCustomShortcut).toBe(true); + }); + }); + + describe('Structure', () => { + it('should render icons for each section', () => { + render(); + + const svgElements = document.querySelectorAll('svg'); + expect(svgElements.length).toBeGreaterThan(5); + }); + + it('should render code elements for technical content', () => { + render(); + + const codeElements = document.querySelectorAll('code'); + expect(codeElements.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/__tests__/renderer/components/CueModal.test.tsx b/src/__tests__/renderer/components/CueModal.test.tsx new file mode 100644 index 000000000..f854f5683 --- /dev/null +++ b/src/__tests__/renderer/components/CueModal.test.tsx @@ -0,0 +1,454 @@ +/** + * Tests for CueModal component + * + * Tests the Cue Modal dashboard including: + * - Sessions table rendering (empty state and populated) + * - Active runs section with stop controls + * - Activity log rendering with success/failure indicators + * - Master enable/disable toggle + * - Close button and backdrop click + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CueModal } from '../../../renderer/components/CueModal'; +import type { Theme } from '../../../renderer/types'; + +// Mock LayerStackContext +const mockRegisterLayer = vi.fn(() => 'layer-cue-modal'); +const mockUnregisterLayer = vi.fn(); + +vi.mock('../../../renderer/contexts/LayerStackContext', () => ({ + useLayerStack: () => ({ + registerLayer: mockRegisterLayer, + unregisterLayer: mockUnregisterLayer, + }), +})); + +// Mock modal priorities +vi.mock('../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { + CUE_MODAL: 460, + CUE_YAML_EDITOR: 463, + }, +})); + +// Mock CueYamlEditor +vi.mock('../../../renderer/components/CueYamlEditor', () => ({ + CueYamlEditor: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ?
YAML Editor Mock
: null, +})); + +// Mock CueGraphView (kept for reference - replaced by CuePipelineEditor) +vi.mock('../../../renderer/components/CueGraphView', () => ({ + CueGraphView: () => null, +})); + +// Mock CuePipelineEditor +vi.mock('../../../renderer/components/CuePipelineEditor', () => ({ + CuePipelineEditor: () =>
Pipeline Editor Mock
, +})); + +// Mock sessionStore +vi.mock('../../../renderer/stores/sessionStore', () => ({ + useSessionStore: (selector: (state: unknown) => unknown) => { + const mockState = { + sessions: [], + setActiveSessionId: vi.fn(), + }; + return selector(mockState); + }, +})); + +// Mock window.maestro.cue.getGraphData +const mockGetGraphData = vi.fn().mockResolvedValue([]); +if (!window.maestro) { + (window as unknown as Record).maestro = {}; +} +if (!(window.maestro as Record).cue) { + (window.maestro as Record).cue = {}; +} +(window.maestro.cue as Record).getGraphData = mockGetGraphData; + +// Mock useCue hook +const mockEnable = vi.fn().mockResolvedValue(undefined); +const mockDisable = vi.fn().mockResolvedValue(undefined); +const mockStopRun = vi.fn().mockResolvedValue(undefined); +const mockStopAll = vi.fn().mockResolvedValue(undefined); +const mockRefresh = vi.fn().mockResolvedValue(undefined); + +const defaultUseCueReturn = { + sessions: [], + activeRuns: [], + activityLog: [], + queueStatus: {} as Record, + loading: false, + enable: mockEnable, + disable: mockDisable, + stopRun: mockStopRun, + stopAll: mockStopAll, + refresh: mockRefresh, +}; + +let mockUseCueReturn = { ...defaultUseCueReturn }; + +vi.mock('../../../renderer/hooks/useCue', () => ({ + useCue: () => mockUseCueReturn, +})); + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + scrollbar: '#44475a', + scrollbarHover: '#6272a4', + }, +}; + +const mockSession = { + sessionId: 'sess-1', + sessionName: 'Test Session', + toolType: 'claude-code', + projectRoot: '/test/project', + enabled: true, + subscriptionCount: 3, + activeRuns: 1, + lastTriggered: new Date().toISOString(), +}; + +const mockActiveRun = { + runId: 'run-1', + sessionId: 'sess-1', + sessionName: 'Test Session', + subscriptionName: 'on-save', + event: { + id: 'evt-1', + type: 'file.changed' as const, + timestamp: new Date().toISOString(), + triggerName: 'on-save', + payload: { file: '/src/index.ts' }, + }, + status: 'running' as const, + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: '', +}; + +const mockCompletedRun = { + ...mockActiveRun, + runId: 'run-2', + status: 'completed' as const, + stdout: 'Done', + exitCode: 0, + durationMs: 5000, + endedAt: new Date().toISOString(), +}; + +const mockFailedRun = { + ...mockActiveRun, + runId: 'run-3', + status: 'failed' as const, + stderr: 'Error occurred', + exitCode: 1, + durationMs: 2000, + endedAt: new Date().toISOString(), +}; + +describe('CueModal', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseCueReturn = { ...defaultUseCueReturn }; + }); + + describe('rendering', () => { + it('should render the modal with header', () => { + render(); + + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + }); + + it('should register layer on mount and unregister on unmount', () => { + const { unmount } = render(); + + expect(mockRegisterLayer).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'modal', + priority: 460, + }) + ); + + unmount(); + expect(mockUnregisterLayer).toHaveBeenCalledWith('layer-cue-modal'); + }); + + it('should show loading state on dashboard tab', () => { + mockUseCueReturn = { ...defaultUseCueReturn, loading: true }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Loading Cue status...')).toBeInTheDocument(); + }); + }); + + describe('sessions table', () => { + it('should show empty state when no sessions have Cue configs', () => { + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/No sessions have a maestro-cue.yaml file/)).toBeInTheDocument(); + }); + + it('should render sessions with status indicators', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Test Session')).toBeInTheDocument(); + expect(screen.getByText('claude-code')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should show Paused status for disabled sessions', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [{ ...mockSession, enabled: false }], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Paused')).toBeInTheDocument(); + }); + }); + + describe('active runs', () => { + it('should show "No active runs" when empty', () => { + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('No active runs')).toBeInTheDocument(); + }); + + it('should render active runs with stop buttons', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('"on-save"')).toBeInTheDocument(); + expect(screen.getByTitle('Stop run')).toBeInTheDocument(); + }); + + it('should call stopRun when stop button is clicked', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + fireEvent.click(screen.getByTitle('Stop run')); + expect(mockStopRun).toHaveBeenCalledWith('run-1'); + }); + + it('should show Stop All button when multiple runs active', () => { + const secondRun = { ...mockActiveRun, runId: 'run-2', subscriptionName: 'on-timer' }; + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun, secondRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + const stopAllButton = screen.getByText('Stop All'); + expect(stopAllButton).toBeInTheDocument(); + + fireEvent.click(stopAllButton); + expect(mockStopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('activity log', () => { + it('should show "No activity yet" when empty', () => { + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('No activity yet')).toBeInTheDocument(); + }); + + it('should render completed runs with checkmark', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activityLog: [mockCompletedRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/completed in 5s/)).toBeInTheDocument(); + }); + + it('should render failed runs with cross mark', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activityLog: [mockFailedRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/failed/)).toBeInTheDocument(); + }); + }); + + describe('master toggle', () => { + it('should show Disabled when no sessions are enabled', () => { + render(); + + expect(screen.getByText('Disabled')).toBeInTheDocument(); + }); + + it('should show Enabled when sessions are enabled', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + expect(screen.getByText('Enabled')).toBeInTheDocument(); + }); + + it('should call disable when toggling off', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + fireEvent.click(screen.getByText('Enabled')); + expect(mockDisable).toHaveBeenCalledOnce(); + }); + + it('should call enable when toggling on', () => { + render(); + + fireEvent.click(screen.getByText('Disabled')); + expect(mockEnable).toHaveBeenCalledOnce(); + }); + }); + + describe('tabs', () => { + it('should render Dashboard and Pipeline Editor tabs', () => { + render(); + + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Pipeline Editor')).toBeInTheDocument(); + }); + + it('should show Pipeline Editor content by default', () => { + render(); + + expect(screen.getByTestId('cue-pipeline-editor')).toBeInTheDocument(); + // Dashboard content should not be visible by default + expect(screen.queryByText('Sessions with Cue')).not.toBeInTheDocument(); + }); + + it('should switch to dashboard when Dashboard tab is clicked', () => { + render(); + + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Sessions with Cue')).toBeInTheDocument(); + // Pipeline editor should not be visible + expect(screen.queryByTestId('cue-pipeline-editor')).not.toBeInTheDocument(); + }); + + it('should switch back to Pipeline Editor when Pipeline Editor tab is clicked', () => { + render(); + + // Switch to dashboard + fireEvent.click(screen.getByText('Dashboard')); + expect(screen.getByText('Sessions with Cue')).toBeInTheDocument(); + + // Switch back to pipeline editor + fireEvent.click(screen.getByText('Pipeline Editor')); + expect(screen.getByTestId('cue-pipeline-editor')).toBeInTheDocument(); + expect(screen.queryByText('Sessions with Cue')).not.toBeInTheDocument(); + }); + }); + + describe('toggle styling', () => { + it('should use theme accent color for enabled toggle', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + const enabledButton = screen.getByText('Enabled').closest('button'); + expect(enabledButton).toHaveStyle({ + color: mockTheme.colors.accent, + }); + + // The toggle pill should use theme accent + const togglePill = enabledButton?.querySelector('.rounded-full'); + expect(togglePill).toHaveStyle({ + backgroundColor: mockTheme.colors.accent, + }); + }); + + it('should use dim colors for disabled toggle', () => { + render(); + + const disabledButton = screen.getByText('Disabled').closest('button'); + expect(disabledButton).toHaveStyle({ + color: mockTheme.colors.textDim, + }); + }); + }); + + describe('close behavior', () => { + it('should call onClose when close button is clicked', () => { + render(); + + // The close button has an X icon + const buttons = screen.getAllByRole('button'); + const closeButton = buttons.find((b) => b.querySelector('.lucide-x')); + if (closeButton) { + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalledOnce(); + } + }); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/PipelineSelector.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/PipelineSelector.test.tsx new file mode 100644 index 000000000..deb667049 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/PipelineSelector.test.tsx @@ -0,0 +1,140 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PipelineSelector } from '../../../../renderer/components/CuePipelineEditor/PipelineSelector'; +import type { CuePipeline } from '../../../../shared/cue-pipeline-types'; + +const mockPipelines: CuePipeline[] = [ + { + id: 'p1', + name: 'Deploy Pipeline', + color: '#06b6d4', + nodes: [], + edges: [], + }, + { + id: 'p2', + name: 'Review Pipeline', + color: '#8b5cf6', + nodes: [], + edges: [], + }, +]; + +const defaultProps = { + pipelines: mockPipelines, + selectedPipelineId: null as string | null, + onSelect: vi.fn(), + onCreatePipeline: vi.fn(), + onDeletePipeline: vi.fn(), + onRenamePipeline: vi.fn(), +}; + +describe('PipelineSelector', () => { + it('should show "All Pipelines" when no pipeline is selected', () => { + render(); + expect(screen.getByText('All Pipelines')).toBeInTheDocument(); + }); + + it('should show selected pipeline name', () => { + render(); + expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument(); + }); + + it('should open dropdown on click and list all pipelines', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + // Dropdown shows All Pipelines option + each pipeline + expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument(); + expect(screen.getByText('Review Pipeline')).toBeInTheDocument(); + expect(screen.getByText('New Pipeline')).toBeInTheDocument(); + }); + + it('should call onSelect when a pipeline is clicked', () => { + const onSelect = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + fireEvent.click(screen.getByText('Deploy Pipeline')); + + expect(onSelect).toHaveBeenCalledWith('p1'); + }); + + it('should call onCreatePipeline when New Pipeline is clicked', () => { + const onCreatePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + fireEvent.click(screen.getByText('New Pipeline')); + + expect(onCreatePipeline).toHaveBeenCalled(); + }); + + it('should enter rename mode on double-click', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pipelineItem = screen.getByText('Deploy Pipeline').closest('div[class]')!; + fireEvent.doubleClick(pipelineItem); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + expect(input).toBeInTheDocument(); + }); + + it('should call onRenamePipeline on Enter in rename mode', () => { + const onRenamePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pipelineItem = screen.getByText('Deploy Pipeline').closest('div[class]')!; + fireEvent.doubleClick(pipelineItem); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + fireEvent.change(input, { target: { value: 'Renamed Pipeline' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onRenamePipeline).toHaveBeenCalledWith('p1', 'Renamed Pipeline'); + }); + + it('should cancel rename on Escape', () => { + const onRenamePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pipelineItem = screen.getByText('Deploy Pipeline').closest('div[class]')!; + fireEvent.doubleClick(pipelineItem); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(onRenamePipeline).not.toHaveBeenCalled(); + // Should be back to showing text, not input + expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument(); + }); + + it('should apply custom textColor and borderColor', () => { + const { container } = render( + + ); + + const button = container.querySelector('button')!; + // JSDOM normalizes hex to rgb + expect(button.style.color).toBe('rgb(255, 0, 0)'); + expect(button.style.border).toContain('rgb(0, 255, 0)'); + }); + + it('should use default colors when textColor and borderColor are not provided', () => { + const { container } = render(); + + const button = container.querySelector('button')!; + // Browser normalizes rgba spacing + expect(button.style.color).toContain('rgba'); + expect(button.style.color).toContain('0.9'); + expect(button.style.border).toContain('rgba'); + expect(button.style.border).toContain('0.12'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/AgentDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/AgentDrawer.test.tsx new file mode 100644 index 000000000..9f192a72d --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/AgentDrawer.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AgentDrawer } from '../../../../../renderer/components/CuePipelineEditor/drawers/AgentDrawer'; +import type { Theme } from '../../../../../renderer/types'; + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: '#bd93f940', + accentText: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + }, +}; + +const mockSessions = [ + { id: 'sess-1', name: 'Maestro', toolType: 'claude-code' }, + { id: 'sess-2', name: 'Codex Helper', toolType: 'codex' }, + { id: 'sess-3', name: 'Review Bot', toolType: 'claude-code' }, +]; + +describe('AgentDrawer', () => { + it('should render all sessions when open', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + expect(screen.getByText('Maestro')).toBeInTheDocument(); + expect(screen.getByText('Codex Helper')).toBeInTheDocument(); + expect(screen.getByText('Review Bot')).toBeInTheDocument(); + }); + + it('should filter sessions by name', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + fireEvent.change(input, { target: { value: 'maestro' } }); + + expect(screen.getByText('Maestro')).toBeInTheDocument(); + expect(screen.queryByText('Codex Helper')).not.toBeInTheDocument(); + expect(screen.queryByText('Review Bot')).not.toBeInTheDocument(); + }); + + it('should filter sessions by toolType', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + fireEvent.change(input, { target: { value: 'codex' } }); + + expect(screen.getByText('Codex Helper')).toBeInTheDocument(); + expect(screen.queryByText('Maestro')).not.toBeInTheDocument(); + }); + + it('should show empty state when no agents match', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + fireEvent.change(input, { target: { value: 'zzzznothing' } }); + + expect(screen.getByText('No agents match')).toBeInTheDocument(); + }); + + it('should show empty state when no sessions provided', () => { + render( {}} sessions={[]} theme={mockTheme} />); + + expect(screen.getByText('No agents available')).toBeInTheDocument(); + }); + + it('should show on-canvas indicator for agents already on canvas', () => { + const onCanvas = new Set(['sess-1']); + render( + {}} + sessions={mockSessions} + onCanvasSessionIds={onCanvas} + theme={mockTheme} + /> + ); + + const indicators = screen.getAllByTitle('On canvas'); + expect(indicators).toHaveLength(1); + }); + + it('should group agents by toolType when multiple types exist', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + // Group headers appear alongside individual agent toolType labels + // so we check for multiple instances (header + labels) + const claudeCodeElements = screen.getAllByText('claude-code'); + // 1 group header + 2 agent labels = 3 + expect(claudeCodeElements.length).toBe(3); + const codexElements = screen.getAllByText('codex'); + // 1 group header + 1 agent label = 2 + expect(codexElements.length).toBe(2); + }); + + it('should use theme colors for styling', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const header = screen.getByText('Agents'); + expect(header).toHaveStyle({ color: mockTheme.colors.textMain }); + }); + + it('should be hidden when not open', () => { + const { container } = render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(100%)'); + }); + + it('should be visible when open', () => { + const { container } = render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(0)'); + }); + + it('should make agent items draggable', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const maestro = screen.getByText('Maestro').closest('[draggable]'); + expect(maestro).toHaveAttribute('draggable', 'true'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx new file mode 100644 index 000000000..d727f5fa8 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TriggerDrawer } from '../../../../../renderer/components/CuePipelineEditor/drawers/TriggerDrawer'; +import type { Theme } from '../../../../../renderer/types'; + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: '#bd93f940', + accentText: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + }, +}; + +describe('TriggerDrawer', () => { + it('should render all trigger types when open', () => { + render( {}} theme={mockTheme} />); + + expect(screen.getByText('Scheduled')).toBeInTheDocument(); + expect(screen.getByText('File Change')).toBeInTheDocument(); + expect(screen.getByText('Agent Done')).toBeInTheDocument(); + expect(screen.getByText('Pull Request')).toBeInTheDocument(); + expect(screen.getByText('Issue')).toBeInTheDocument(); + expect(screen.getByText('Pending Task')).toBeInTheDocument(); + }); + + it('should render descriptions for each trigger', () => { + render( {}} theme={mockTheme} />); + + expect(screen.getByText('Run on a timer')).toBeInTheDocument(); + expect(screen.getByText('Watch for file modifications')).toBeInTheDocument(); + expect(screen.getByText('After an agent finishes')).toBeInTheDocument(); + }); + + it('should filter triggers by label', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'file' } }); + + expect(screen.getByText('File Change')).toBeInTheDocument(); + expect(screen.queryByText('Scheduled')).not.toBeInTheDocument(); + expect(screen.queryByText('Pull Request')).not.toBeInTheDocument(); + }); + + it('should filter triggers by event type', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'github' } }); + + expect(screen.getByText('Pull Request')).toBeInTheDocument(); + expect(screen.getByText('Issue')).toBeInTheDocument(); + expect(screen.queryByText('Scheduled')).not.toBeInTheDocument(); + }); + + it('should filter triggers by description', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'timer' } }); + + expect(screen.getByText('Scheduled')).toBeInTheDocument(); + expect(screen.queryByText('File Change')).not.toBeInTheDocument(); + }); + + it('should show empty state when no triggers match', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'zzzznothing' } }); + + expect(screen.getByText('No triggers match')).toBeInTheDocument(); + }); + + it('should use theme colors for styling', () => { + render( {}} theme={mockTheme} />); + + const header = screen.getByText('Triggers'); + expect(header).toHaveStyle({ color: mockTheme.colors.textMain }); + }); + + it('should be hidden when not open', () => { + const { container } = render( + {}} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(-100%)'); + }); + + it('should be visible when open', () => { + const { container } = render( + {}} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(0)'); + }); + + it('should make trigger items draggable', () => { + render( {}} theme={mockTheme} />); + + const scheduled = screen.getByText('Scheduled').closest('[draggable]'); + expect(scheduled).toHaveAttribute('draggable', 'true'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx new file mode 100644 index 000000000..e7bf97438 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { AgentNode } from '../../../../../renderer/components/CuePipelineEditor/nodes/AgentNode'; +import { ReactFlowProvider } from 'reactflow'; +import type { NodeProps } from 'reactflow'; +import type { AgentNodeDataProps } from '../../../../../renderer/components/CuePipelineEditor/nodes/AgentNode'; + +const defaultData: AgentNodeDataProps = { + sessionId: 'sess-1', + sessionName: 'Test Agent', + toolType: 'claude-code', + hasPrompt: false, + pipelineColor: '#06b6d4', + pipelineCount: 1, + pipelineColors: ['#06b6d4'], +}; + +function renderAgentNode(overrides: Partial = {}) { + const data = { ...defaultData, ...overrides }; + const props = { + id: 'test-node', + data, + type: 'agent', + selected: false, + isConnectable: true, + xPos: 0, + yPos: 0, + zIndex: 0, + dragging: false, + } as NodeProps; + + return render( + + + + ); +} + +describe('AgentNode', () => { + it('should render session name and tool type', () => { + const { getByText } = renderAgentNode(); + + expect(getByText('Test Agent')).toBeInTheDocument(); + expect(getByText('claude-code')).toBeInTheDocument(); + }); + + it('should not clip badge overflow (overflow: visible on root)', () => { + const { container } = renderAgentNode({ pipelineCount: 3 }); + + // Find the agent node root div (220px wide, position: relative) + const rootDiv = container.querySelector('div[style*="width: 220px"]') as HTMLElement; + expect(rootDiv).not.toBeNull(); + expect(rootDiv.style.overflow).toBe('visible'); + }); + + it('should render a drag handle with the drag-handle class', () => { + const { container } = renderAgentNode(); + const dragHandle = container.querySelector('.drag-handle'); + expect(dragHandle).not.toBeNull(); + }); + + it('should render a gear icon for configuration', () => { + const { container } = renderAgentNode(); + // Gear icon area has title="Configure" + const gearButton = container.querySelector('[title="Configure"]'); + expect(gearButton).not.toBeNull(); + }); + + it('should show pipeline count badge when pipelineCount > 1', () => { + const { getByText } = renderAgentNode({ pipelineCount: 3 }); + + expect(getByText('3')).toBeInTheDocument(); + }); + + it('should not show pipeline count badge when pipelineCount is 1', () => { + const { queryByText } = renderAgentNode({ pipelineCount: 1 }); + + // No badge number should be rendered + const badge = queryByText('1'); + expect(badge).toBeNull(); + }); + + it('should show multi-pipeline color dots when multiple colors', () => { + const { container } = renderAgentNode({ + pipelineColors: ['#06b6d4', '#8b5cf6', '#f59e0b'], + }); + + // Find color dots (8x8 circles) + const dots = container.querySelectorAll( + 'div[style*="border-radius: 50%"][style*="width: 8px"]' + ); + expect(dots.length).toBe(3); + }); + + it('should not show multi-pipeline dots with single color', () => { + const { container } = renderAgentNode({ + pipelineColors: ['#06b6d4'], + }); + + // No color strip should render + const dots = container.querySelectorAll( + 'div[style*="border-radius: 50%"][style*="width: 8px"]' + ); + expect(dots.length).toBe(0); + }); + + it('should show prompt icon when hasPrompt is true', () => { + const { container } = renderAgentNode({ hasPrompt: true }); + + // MessageSquare icon renders as an SVG + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts new file mode 100644 index 000000000..4fcb2b75f --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts @@ -0,0 +1,466 @@ +/** + * Tests for pipelineToYaml conversion utilities. + * + * Verifies that visual pipeline graphs correctly convert to + * CueSubscription objects and YAML strings. + */ + +import { describe, it, expect } from 'vitest'; +import { + pipelineToYamlSubscriptions, + pipelinesToYaml, +} from '../../../../../renderer/components/CuePipelineEditor/utils/pipelineToYaml'; +import type { CuePipeline } from '../../../../../shared/cue-pipeline-types'; + +function makePipeline(overrides: Partial = {}): CuePipeline { + return { + id: 'p1', + name: 'test-pipeline', + color: '#06b6d4', + nodes: [], + edges: [], + ...overrides, + }; +} + +describe('pipelineToYamlSubscriptions', () => { + it('returns empty array for pipeline with no nodes', () => { + const pipeline = makePipeline(); + expect(pipelineToYamlSubscriptions(pipeline)).toEqual([]); + }); + + it('returns empty array for trigger with no outgoing edges', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 5 }, + }, + }, + ], + }); + expect(pipelineToYamlSubscriptions(pipeline)).toEqual([]); + }); + + it('converts simple trigger -> agent chain', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 10 }, + }, + }, + { + id: 'agent-1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + prompt: 'Do the work', + }, + }, + ], + edges: [{ id: 'e1', source: 'trigger-1', target: 'agent-1', mode: 'pass' }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(1); + expect(subs[0].name).toBe('test-pipeline'); + expect(subs[0].event).toBe('time.interval'); + expect(subs[0].interval_minutes).toBe(10); + expect(subs[0].prompt).toBe('Do the work'); + }); + + it('converts trigger -> agent1 -> agent2 chain', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'file.changed', + label: 'File Change', + config: { watch: 'src/**/*.ts' }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'builder', + toolType: 'claude-code', + prompt: 'Build it', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + prompt: 'Test it', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(2); + + expect(subs[0].name).toBe('test-pipeline'); + expect(subs[0].event).toBe('file.changed'); + expect(subs[0].watch).toBe('src/**/*.ts'); + expect(subs[0].prompt).toBe('Build it'); + + expect(subs[1].name).toBe('test-pipeline-chain-1'); + expect(subs[1].event).toBe('agent.completed'); + expect(subs[1].source_session).toBe('builder'); + expect(subs[1].prompt).toBe('Test it'); + }); + + it('handles fan-out (trigger -> [agent1, agent2])', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 30 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: -100 }, + data: { + sessionId: 's1', + sessionName: 'worker-a', + toolType: 'claude-code', + prompt: 'Task A', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 300, y: 100 }, + data: { + sessionId: 's2', + sessionName: 'worker-b', + toolType: 'claude-code', + prompt: 'Task B', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 't1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(1); + expect(subs[0].fan_out).toEqual(['worker-a', 'worker-b']); + expect(subs[0].interval_minutes).toBe(30); + }); + + it('handles fan-in ([agent1, agent2] -> agent3)', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 5 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: -100 }, + data: { sessionId: 's1', sessionName: 'worker-a', toolType: 'claude-code', prompt: 'A' }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 300, y: 100 }, + data: { sessionId: 's2', sessionName: 'worker-b', toolType: 'claude-code', prompt: 'B' }, + }, + { + id: 'a3', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's3', + sessionName: 'aggregator', + toolType: 'claude-code', + prompt: 'Combine', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 't1', target: 'a2', mode: 'pass' }, + { id: 'e3', source: 'a1', target: 'a3', mode: 'pass' }, + { id: 'e4', source: 'a2', target: 'a3', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + + // Find the fan-in subscription (the one targeting aggregator) + const fanInSub = subs.find((s) => s.source_session && Array.isArray(s.source_session)); + expect(fanInSub).toBeDefined(); + expect(fanInSub!.event).toBe('agent.completed'); + expect(fanInSub!.source_session).toEqual(['worker-a', 'worker-b']); + expect(fanInSub!.prompt).toBe('Combine'); + }); + + it('maps github.pull_request trigger config', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'github.pull_request', + label: 'PR', + config: { repo: 'owner/repo', poll_minutes: 5 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'reviewer', + toolType: 'claude-code', + prompt: 'Review PR', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs[0].repo).toBe('owner/repo'); + expect(subs[0].poll_minutes).toBe(5); + expect(subs[0].event).toBe('github.pull_request'); + }); + + it('maps task.pending trigger config', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'task.pending', + label: 'Task', + config: { watch: 'docs/**/*.md' }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'tasker', + toolType: 'claude-code', + prompt: 'Complete tasks', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs[0].watch).toBe('docs/**/*.md'); + expect(subs[0].event).toBe('task.pending'); + }); +}); + +describe('pipelinesToYaml', () => { + it('produces valid YAML with subscriptions', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.interval', + label: 'Scheduled', + config: { interval_minutes: 15 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + prompt: 'Do stuff', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const yamlStr = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('# Pipeline: test-pipeline (color: #06b6d4)'); + expect(yamlStr).toContain('subscriptions:'); + expect(yamlStr).toContain('name: test-pipeline'); + expect(yamlStr).toContain('event: time.interval'); + expect(yamlStr).toContain('interval_minutes: 15'); + expect(yamlStr).toContain('prompt: Do stuff'); + }); + + it('includes settings block when provided', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.interval', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { sessionId: 's1', sessionName: 'w', toolType: 'claude-code', prompt: 'go' }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const yamlStr = pipelinesToYaml([pipeline], { timeout_minutes: 60, max_concurrent: 3 }); + expect(yamlStr).toContain('settings:'); + expect(yamlStr).toContain('timeout_minutes: 60'); + expect(yamlStr).toContain('max_concurrent: 3'); + }); + + it('adds debate mode edge comment', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.interval', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'debater', + toolType: 'claude-code', + prompt: 'argue', + }, + }, + ], + edges: [ + { + id: 'e1', + source: 't1', + target: 'a1', + mode: 'debate' as const, + debateConfig: { maxRounds: 5, timeoutPerRound: 120 }, + }, + ], + }); + + const yamlStr = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('mode: debate, max_rounds: 5, timeout_per_round: 120'); + }); + + it('handles multiple pipelines', () => { + const p1 = makePipeline({ + id: 'p1', + name: 'pipeline-a', + color: '#06b6d4', + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.interval', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { sessionId: 's1', sessionName: 'w1', toolType: 'claude-code', prompt: 'go 1' }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const p2 = makePipeline({ + id: 'p2', + name: 'pipeline-b', + color: '#8b5cf6', + nodes: [ + { + id: 't2', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'file.changed', label: 'Files', config: { watch: '**/*.md' } }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 300, y: 0 }, + data: { sessionId: 's2', sessionName: 'w2', toolType: 'claude-code', prompt: 'go 2' }, + }, + ], + edges: [{ id: 'e2', source: 't2', target: 'a2', mode: 'pass' }], + }); + + const yamlStr = pipelinesToYaml([p1, p2]); + expect(yamlStr).toContain('# Pipeline: pipeline-a'); + expect(yamlStr).toContain('# Pipeline: pipeline-b'); + expect(yamlStr).toContain('name: pipeline-a'); + expect(yamlStr).toContain('name: pipeline-b'); + }); + + it('returns empty subscriptions for empty pipelines array', () => { + const yamlStr = pipelinesToYaml([]); + expect(yamlStr).toContain('subscriptions: []'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts new file mode 100644 index 000000000..07c768167 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts @@ -0,0 +1,425 @@ +/** + * Tests for yamlToPipeline conversion utilities. + * + * Verifies that CueSubscription objects and CueGraphSession data + * correctly convert back into visual CuePipeline structures. + */ + +import { describe, it, expect } from 'vitest'; +import { + subscriptionsToPipelines, + graphSessionsToPipelines, +} from '../../../../../renderer/components/CuePipelineEditor/utils/yamlToPipeline'; +import type { CueSubscription, CueGraphSession } from '../../../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../../../shared/types'; + +const makeSessions = (...names: string[]): SessionInfo[] => + names.map((name, i) => ({ + id: `session-${i}`, + name, + toolType: 'claude-code' as const, + cwd: '/tmp', + projectRoot: '/tmp', + })); + +describe('subscriptionsToPipelines', () => { + it('returns empty array for no subscriptions', () => { + const result = subscriptionsToPipelines([], []); + expect(result).toEqual([]); + }); + + it('converts a simple trigger -> agent subscription', () => { + const subs: CueSubscription[] = [ + { + name: 'my-pipeline', + event: 'time.interval', + enabled: true, + prompt: 'Do the work', + interval_minutes: 10, + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + expect(pipelines[0].name).toBe('my-pipeline'); + + // Should have a trigger node and an agent node + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(triggers).toHaveLength(1); + expect(agents).toHaveLength(1); + + // Trigger should have correct event type and config + expect(triggers[0].data).toMatchObject({ + eventType: 'time.interval', + config: { interval_minutes: 10 }, + }); + + // Agent should have the prompt + expect(agents[0].data).toMatchObject({ + sessionName: 'worker', + prompt: 'Do the work', + }); + + // Should have one edge connecting them + expect(pipelines[0].edges).toHaveLength(1); + expect(pipelines[0].edges[0].source).toBe(triggers[0].id); + expect(pipelines[0].edges[0].target).toBe(agents[0].id); + }); + + it('converts trigger -> agent1 -> agent2 chain', () => { + const subs: CueSubscription[] = [ + { + name: 'chain-test', + event: 'file.changed', + enabled: true, + prompt: 'Build it', + watch: 'src/**/*.ts', + }, + { + name: 'chain-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test it', + source_session: 'builder', + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(triggers).toHaveLength(1); + expect(agents).toHaveLength(2); + + // Trigger config + expect(triggers[0].data).toMatchObject({ + eventType: 'file.changed', + config: { watch: 'src/**/*.ts' }, + }); + + // Should have edges: trigger -> builder, builder -> tester + expect(pipelines[0].edges).toHaveLength(2); + }); + + it('handles fan-out (trigger -> [agent1, agent2])', () => { + const subs: CueSubscription[] = [ + { + name: 'fanout-test', + event: 'time.interval', + enabled: true, + prompt: 'Task A', + interval_minutes: 30, + fan_out: ['worker-a', 'worker-b'], + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(triggers).toHaveLength(1); + expect(agents).toHaveLength(2); + + // Both agents should be connected to the trigger + expect(pipelines[0].edges).toHaveLength(2); + for (const edge of pipelines[0].edges) { + expect(edge.source).toBe(triggers[0].id); + } + + const agentNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + expect(agentNames).toContain('worker-a'); + expect(agentNames).toContain('worker-b'); + }); + + it('handles fan-in ([agent1, agent2] -> agent3)', () => { + const subs: CueSubscription[] = [ + { + name: 'fanin-test', + event: 'time.interval', + enabled: true, + prompt: 'Start', + interval_minutes: 5, + fan_out: ['worker-a', 'worker-b'], + }, + { + name: 'fanin-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Combine results', + source_session: ['worker-a', 'worker-b'], + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b', 'aggregator'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + // worker-a, worker-b, and the aggregator target + expect(agents.length).toBeGreaterThanOrEqual(3); + + // The aggregator should have 2 incoming edges (from worker-a and worker-b) + const aggregatorNode = agents.find( + (a) => (a.data as { sessionName: string }).sessionName === 'aggregator' + ); + expect(aggregatorNode).toBeDefined(); + + const incomingEdges = pipelines[0].edges.filter((e) => e.target === aggregatorNode!.id); + expect(incomingEdges).toHaveLength(2); + }); + + it('maps github.pull_request trigger config', () => { + const subs: CueSubscription[] = [ + { + name: 'pr-review', + event: 'github.pull_request', + enabled: true, + prompt: 'Review this PR', + repo: 'owner/repo', + poll_minutes: 5, + }, + ]; + const sessions = makeSessions('reviewer'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const trigger = pipelines[0].nodes.find((n) => n.type === 'trigger'); + expect(trigger).toBeDefined(); + expect(trigger!.data).toMatchObject({ + eventType: 'github.pull_request', + config: { repo: 'owner/repo', poll_minutes: 5 }, + }); + }); + + it('maps task.pending trigger config', () => { + const subs: CueSubscription[] = [ + { + name: 'task-handler', + event: 'task.pending', + enabled: true, + prompt: 'Complete tasks', + watch: 'docs/**/*.md', + }, + ]; + const sessions = makeSessions('tasker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const trigger = pipelines[0].nodes.find((n) => n.type === 'trigger'); + expect(trigger!.data).toMatchObject({ + eventType: 'task.pending', + config: { watch: 'docs/**/*.md' }, + }); + }); + + it('groups subscriptions into separate pipelines by name prefix', () => { + const subs: CueSubscription[] = [ + { + name: 'pipeline-a', + event: 'time.interval', + enabled: true, + prompt: 'Task A', + interval_minutes: 5, + }, + { + name: 'pipeline-b', + event: 'file.changed', + enabled: true, + prompt: 'Task B', + watch: '**/*.ts', + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(2); + expect(pipelines[0].name).toBe('pipeline-a'); + expect(pipelines[1].name).toBe('pipeline-b'); + }); + + it('assigns unique colors to each pipeline', () => { + const subs: CueSubscription[] = [ + { + name: 'p1', + event: 'time.interval', + enabled: true, + prompt: 'A', + interval_minutes: 5, + }, + { + name: 'p2', + event: 'time.interval', + enabled: true, + prompt: 'B', + interval_minutes: 10, + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines[0].color).not.toBe(pipelines[1].color); + }); + + it('auto-layouts nodes left-to-right', () => { + const subs: CueSubscription[] = [ + { + name: 'layout-test', + event: 'time.interval', + enabled: true, + prompt: 'Build', + interval_minutes: 5, + }, + { + name: 'layout-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test', + source_session: 'builder', + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + + // Trigger should be leftmost + expect(triggers[0].position.x).toBe(100); + // First agent should be further right + expect(agents[0].position.x).toBeGreaterThan(triggers[0].position.x); + // Second agent should be even further right (if present) + if (agents.length > 1) { + expect(agents[1].position.x).toBeGreaterThan(agents[0].position.x); + } + }); + + it('deduplicates agent nodes by session name', () => { + const subs: CueSubscription[] = [ + { + name: 'dedup-test', + event: 'time.interval', + enabled: true, + prompt: 'Start', + interval_minutes: 5, + fan_out: ['worker-a', 'worker-b'], + }, + { + name: 'dedup-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Combine', + source_session: ['worker-a', 'worker-b'], + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b', 'combiner'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + const sessionNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + + // worker-a and worker-b should appear only once each + const workerACount = sessionNames.filter((n) => n === 'worker-a').length; + const workerBCount = sessionNames.filter((n) => n === 'worker-b').length; + expect(workerACount).toBe(1); + expect(workerBCount).toBe(1); + }); + + it('sets default edge mode to pass', () => { + const subs: CueSubscription[] = [ + { + name: 'mode-test', + event: 'time.interval', + enabled: true, + prompt: 'Go', + interval_minutes: 5, + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + for (const edge of pipelines[0].edges) { + expect(edge.mode).toBe('pass'); + } + }); +}); + +describe('graphSessionsToPipelines', () => { + it('extracts subscriptions from graph sessions and converts', () => { + const graphSessions: CueGraphSession[] = [ + { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + subscriptions: [ + { + name: 'graph-test', + event: 'time.interval', + enabled: true, + prompt: 'Do work', + interval_minutes: 15, + }, + ], + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + expect(pipelines[0].name).toBe('graph-test'); + + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + expect(triggers).toHaveLength(1); + expect(triggers[0].data).toMatchObject({ + eventType: 'time.interval', + config: { interval_minutes: 15 }, + }); + }); + + it('combines subscriptions from multiple graph sessions', () => { + const graphSessions: CueGraphSession[] = [ + { + sessionId: 's1', + sessionName: 'builder', + toolType: 'claude-code', + subscriptions: [ + { + name: 'multi-test', + event: 'file.changed', + enabled: true, + prompt: 'Build', + watch: 'src/**/*', + }, + ], + }, + { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + subscriptions: [ + { + name: 'multi-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test', + source_session: 'builder', + }, + ], + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + expect(pipelines[0].name).toBe('multi-test'); + expect(pipelines[0].edges.length).toBeGreaterThanOrEqual(2); + }); + + it('returns empty array for no graph sessions', () => { + const result = graphSessionsToPipelines([], []); + expect(result).toEqual([]); + }); +}); diff --git a/src/__tests__/renderer/components/CueYamlEditor.test.tsx b/src/__tests__/renderer/components/CueYamlEditor.test.tsx new file mode 100644 index 000000000..ebd116b45 --- /dev/null +++ b/src/__tests__/renderer/components/CueYamlEditor.test.tsx @@ -0,0 +1,807 @@ +/** + * Tests for CueYamlEditor component + * + * Tests the Cue YAML editor including: + * - Loading existing YAML content on mount + * - YAML template shown when no file exists + * - Real-time validation with error display + * - AI assist chat with agent spawn and conversation resume + * - Save/Exit functionality with dirty state + * - Line numbers gutter + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { CueYamlEditor } from '../../../renderer/components/CueYamlEditor'; +import type { Theme } from '../../../renderer/types'; + +// Mock the Modal component +vi.mock('../../../renderer/components/ui/Modal', () => ({ + Modal: ({ + children, + footer, + title, + testId, + onClose, + }: { + children: React.ReactNode; + footer?: React.ReactNode; + title: string; + testId?: string; + onClose: () => void; + }) => ( +
+
{children}
+ {footer &&
{footer}
} +
+ ), + ModalFooter: ({ + onCancel, + onConfirm, + confirmLabel, + cancelLabel = 'Cancel', + confirmDisabled, + }: { + onCancel: () => void; + onConfirm: () => void; + confirmLabel: string; + cancelLabel?: string; + confirmDisabled: boolean; + theme: Theme; + }) => ( + <> + + + + ), +})); + +// Mock modal priorities +vi.mock('../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { + CUE_YAML_EDITOR: 463, + }, +})); + +// Mock sessionStore +const mockSession = { + id: 'sess-1', + toolType: 'claude-code', + cwd: '/test/project', + customPath: undefined, + customArgs: undefined, + customEnvVars: undefined, + customModel: undefined, + customContextWindow: undefined, + sessionSshRemoteConfig: undefined, +}; + +vi.mock('../../../renderer/stores/sessionStore', () => ({ + useSessionStore: vi.fn((selector: (s: any) => any) => selector({ sessions: [mockSession] })), + selectSessionById: (id: string) => (state: any) => state.sessions.find((s: any) => s.id === id), +})); + +// Mock buildSpawnConfigForAgent +const mockBuildSpawnConfig = vi.fn(); +vi.mock('../../../renderer/utils/sessionHelpers', () => ({ + buildSpawnConfigForAgent: (...args: any[]) => mockBuildSpawnConfig(...args), +})); + +// Mock IPC methods +const mockReadYaml = vi.fn(); +const mockWriteYaml = vi.fn(); +const mockValidateYaml = vi.fn(); +const mockRefreshSession = vi.fn(); +const mockSpawn = vi.fn(); +const mockOnData = vi.fn(); +const mockOnExit = vi.fn(); +const mockOnSessionId = vi.fn(); +const mockOnAgentError = vi.fn(); + +const existingWindowMaestro = (window as any).maestro; + +beforeEach(() => { + vi.clearAllMocks(); + + (window as any).maestro = { + ...existingWindowMaestro, + cue: { + ...existingWindowMaestro?.cue, + readYaml: mockReadYaml, + writeYaml: mockWriteYaml, + validateYaml: mockValidateYaml, + refreshSession: mockRefreshSession, + }, + process: { + ...existingWindowMaestro?.process, + spawn: mockSpawn, + onData: mockOnData, + onExit: mockOnExit, + onSessionId: mockOnSessionId, + onAgentError: mockOnAgentError, + }, + }; + + // Default: file doesn't exist, YAML is valid + mockReadYaml.mockResolvedValue(null); + mockWriteYaml.mockResolvedValue(undefined); + mockValidateYaml.mockResolvedValue({ valid: true, errors: [] }); + mockRefreshSession.mockResolvedValue(undefined); + mockSpawn.mockResolvedValue({ pid: 123, success: true }); + mockBuildSpawnConfig.mockResolvedValue({ + sessionId: 'sess-1-cue-assist-123', + toolType: 'claude-code', + cwd: '/test/project', + command: 'claude', + args: [], + prompt: 'test prompt', + }); + + // Default: listeners return cleanup functions + mockOnData.mockReturnValue(vi.fn()); + mockOnExit.mockReturnValue(vi.fn()); + mockOnSessionId.mockReturnValue(vi.fn()); + mockOnAgentError.mockReturnValue(vi.fn()); +}); + +afterEach(() => { + vi.restoreAllMocks(); + (window as any).maestro = existingWindowMaestro; +}); + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + scrollbar: '#44475a', + scrollbarHover: '#6272a4', + }, +}; + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + projectRoot: '/test/project', + sessionId: 'sess-1', + theme: mockTheme, +}; + +describe('CueYamlEditor', () => { + describe('rendering', () => { + it('should not render when isOpen is false', () => { + render(); + expect(screen.queryByTestId('cue-yaml-editor')).not.toBeInTheDocument(); + }); + + it('should render when isOpen is true', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('cue-yaml-editor')).toBeInTheDocument(); + }); + }); + + it('should show loading state initially', () => { + mockReadYaml.mockReturnValue(new Promise(() => {})); + render(); + + expect(screen.getByText('Loading YAML...')).toBeInTheDocument(); + }); + + it('should render AI assist chat section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('AI Assist')).toBeInTheDocument(); + }); + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + expect(screen.getByTestId('ai-chat-send')).toBeInTheDocument(); + expect(screen.getByTestId('ai-chat-history')).toBeInTheDocument(); + }); + + it('should render YAML editor section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('YAML Configuration')).toBeInTheDocument(); + }); + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + it('should render line numbers gutter', async () => { + mockReadYaml.mockResolvedValue('line1\nline2\nline3'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('line-numbers')).toBeInTheDocument(); + }); + expect(screen.getByTestId('line-numbers').textContent).toContain('1'); + expect(screen.getByTestId('line-numbers').textContent).toContain('2'); + expect(screen.getByTestId('line-numbers').textContent).toContain('3'); + }); + }); + + describe('YAML loading', () => { + it('should load existing YAML from projectRoot on mount', async () => { + const existingYaml = 'subscriptions:\n - name: "test"\n event: time.interval'; + mockReadYaml.mockResolvedValue(existingYaml); + + render(); + + await waitFor(() => { + expect(mockReadYaml).toHaveBeenCalledWith('/test/project'); + }); + expect(screen.getByTestId('yaml-editor')).toHaveValue(existingYaml); + }); + + it('should show template when no YAML file exists', async () => { + mockReadYaml.mockResolvedValue(null); + + render(); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('# maestro-cue.yaml'); + }); + }); + + it('should show template when readYaml throws', async () => { + mockReadYaml.mockRejectedValue(new Error('File read error')); + + render(); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('# maestro-cue.yaml'); + }); + }); + }); + + describe('validation', () => { + it('should show valid indicator when YAML is valid', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + mockValidateYaml.mockResolvedValue({ valid: true, errors: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Valid YAML')).toBeInTheDocument(); + }); + }); + + it('should show validation errors when YAML is invalid', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + mockValidateYaml.mockResolvedValue({ + valid: false, + errors: ['Missing required field: name'], + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'invalid: yaml: content' }, + }); + + await waitFor( + () => { + expect(screen.getByTestId('validation-errors')).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + + expect(screen.getByText('Missing required field: name')).toBeInTheDocument(); + expect(screen.getByText('1 error')).toBeInTheDocument(); + }); + + it('should show plural error count for multiple errors', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + mockValidateYaml.mockResolvedValue({ + valid: false, + errors: ['Error one', 'Error two'], + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'bad' }, + }); + + await waitFor( + () => { + expect(screen.getByText('2 errors')).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + }); + + it('should debounce validation calls', async () => { + vi.useFakeTimers(); + mockReadYaml.mockResolvedValue('initial'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const editor = screen.getByTestId('yaml-editor'); + fireEvent.change(editor, { target: { value: 'change1' } }); + fireEvent.change(editor, { target: { value: 'change2' } }); + fireEvent.change(editor, { target: { value: 'change3' } }); + + const callsBeforeDebounce = mockValidateYaml.mock.calls.length; + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + expect(mockValidateYaml.mock.calls.length).toBe(callsBeforeDebounce + 1); + expect(mockValidateYaml).toHaveBeenLastCalledWith('change3'); + + vi.useRealTimers(); + }); + }); + + describe('AI assist chat', () => { + it('should have disabled send button when input is empty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-send')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('ai-chat-send')).toBeDisabled(); + }); + + it('should enable send button when input has text', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Watch for file changes' }, + }); + + expect(screen.getByTestId('ai-chat-send')).not.toBeDisabled(); + }); + + it('should add user message to chat history on send', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up file watching' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByTestId('chat-message-user')).toBeInTheDocument(); + }); + expect(screen.getByText('Set up file watching')).toBeInTheDocument(); + }); + + it('should show busy indicator while agent is working', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up file watching' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByTestId('chat-busy-indicator')).toBeInTheDocument(); + }); + expect(screen.getByText('Agent is working...')).toBeInTheDocument(); + }); + + it('should clear input after sending', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up file watching' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect((screen.getByTestId('ai-chat-input') as HTMLTextAreaElement).value).toBe(''); + }); + }); + + it('should include system prompt on first message', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Run code review' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(mockBuildSpawnConfig).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining('configuring maestro-cue.yaml'), + }) + ); + }); + + // Should include the file path + const prompt = mockBuildSpawnConfig.mock.calls[0][0].prompt; + expect(prompt).toContain('/test/project/maestro-cue.yaml'); + expect(prompt).toContain('Run code review'); + }); + + it('should spawn agent process', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Run code review' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(mockSpawn).toHaveBeenCalled(); + }); + }); + + it('should freeze YAML editor while agent is working', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up automation' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.readOnly).toBe(true); + }); + }); + + it('should register onData, onExit, onSessionId, and onAgentError listeners', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up automation' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(mockOnData).toHaveBeenCalledWith(expect.any(Function)); + expect(mockOnExit).toHaveBeenCalledWith(expect.any(Function)); + expect(mockOnSessionId).toHaveBeenCalledWith(expect.any(Function)); + expect(mockOnAgentError).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + it('should show error message when agent config is unavailable', async () => { + mockBuildSpawnConfig.mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up automation' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByText(/Agent not available/)).toBeInTheDocument(); + }); + }); + + it('should show placeholder text when chat is empty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/Describe what you want to automate/)).toBeInTheDocument(); + }); + }); + + it('should disable input while agent is working', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Do something' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeDisabled(); + }); + }); + }); + + describe('save and cancel', () => { + it('should disable Save when content has not changed', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('should enable Save when content is modified and valid', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified content' }, + }); + + expect(screen.getByText('Save')).not.toBeDisabled(); + }); + + it('should disable Save when validation fails', async () => { + vi.useFakeTimers(); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + mockValidateYaml.mockResolvedValue({ valid: false, errors: ['Bad YAML'] }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'invalid' }, + }); + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getByText('Save')).toBeDisabled(); + + vi.useRealTimers(); + }); + + it('should call writeYaml and refreshSession on Save', async () => { + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'new content' }, + }); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockWriteYaml).toHaveBeenCalledWith('/test/project', 'new content'); + }); + expect(mockRefreshSession).toHaveBeenCalledWith('sess-1', '/test/project'); + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + }); + + it('should call onClose when Exit is clicked and content is not dirty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Exit')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Exit')); + + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + }); + + it('should prompt for confirmation when Exit is clicked with dirty content', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified' }, + }); + + fireEvent.click(screen.getByText('Exit')); + + expect(mockConfirm).toHaveBeenCalledWith('You have unsaved changes. Discard them?'); + expect(defaultProps.onClose).not.toHaveBeenCalled(); + + mockConfirm.mockRestore(); + }); + + it('should close when user confirms discard on Exit', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified' }, + }); + + fireEvent.click(screen.getByText('Exit')); + + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + + mockConfirm.mockRestore(); + }); + }); + + describe('pattern presets', () => { + it('should render pattern preset buttons', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-presets')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('pattern-scheduled-task')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-file-enrichment')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-reactive')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-research-swarm')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-sequential-chain')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-debate')).toBeInTheDocument(); + }); + + it('should render "Start from a pattern" heading', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Start from a pattern')).toBeInTheDocument(); + }); + }); + + it('should populate editor when a pattern is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-scheduled-task')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('pattern-scheduled-task')); + + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('Scheduled Task'); + expect(editor.value).toContain('time.interval'); + expect(editor.value).toContain('interval_minutes: 60'); + }); + + it('should prompt for confirmation when editor is dirty before applying pattern', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified content' }, + }); + + fireEvent.click(screen.getByTestId('pattern-file-enrichment')); + + expect(mockConfirm).toHaveBeenCalledWith( + 'Replace current YAML with this pattern? Unsaved changes will be lost.' + ); + + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toBe('modified content'); + + mockConfirm.mockRestore(); + }); + + it('should replace content when user confirms dirty pattern switch', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified content' }, + }); + + fireEvent.click(screen.getByTestId('pattern-debate')); + + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('Debate'); + expect(editor.value).toContain('debater-pro'); + + mockConfirm.mockRestore(); + }); + }); +}); diff --git a/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx b/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx index c4c1cf765..c4b4cf594 100644 --- a/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx +++ b/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { UnifiedHistoryTab } from '../../../../renderer/components/DirectorNotes/UnifiedHistoryTab'; import type { Theme } from '../../../../renderer/types'; +import { useSettingsStore } from '../../../../renderer/stores/settingsStore'; // Mock useSettings hook (mutable so individual tests can override) const mockDirNotesSettings = vi.hoisted(() => ({ @@ -108,7 +109,7 @@ vi.mock('../../../../renderer/components/History', () => ({ )} ), - HistoryFilterToggle: ({ activeFilters, onToggleFilter }: any) => ( + HistoryFilterToggle: ({ activeFilters, onToggleFilter, visibleTypes }: any) => (
+ {visibleTypes?.includes('CUE') && ( + + )}
), HistoryStatsBar: ({ stats }: any) => ( @@ -233,6 +243,9 @@ beforeEach(() => { }; mockHistoryUpdate.mockResolvedValue(true); mockGetUnifiedHistory.mockResolvedValue(createPaginatedResponse(createMockEntries())); + + // Default: maestroCue disabled + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); }); afterEach(() => { @@ -395,6 +408,29 @@ describe('UnifiedHistoryTab', () => { // USER entries should remain expect(screen.getByText('User performed action A')).toBeInTheDocument(); }); + + it('hides CUE filter when maestroCue is disabled', async () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('filter-auto')).toBeInTheDocument(); + expect(screen.getByTestId('filter-user')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('filter-cue')).not.toBeInTheDocument(); + }); + + it('shows CUE filter when maestroCue is enabled', async () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('filter-cue')).toBeInTheDocument(); + }); + }); }); describe('Activity Graph', () => { diff --git a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx index b1c4be32e..c2d274c45 100644 --- a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx +++ b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx @@ -97,6 +97,85 @@ describe('HistoryEntryItem', () => { expect(screen.getByText('USER')).toBeInTheDocument(); }); + it('shows CUE type pill for CUE entries', () => { + render( + + ); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('shows CUE pill with teal color', () => { + render( + + ); + const cuePill = screen.getByText('CUE').closest('span')!; + expect(cuePill).toHaveStyle({ color: '#06b6d4' }); + }); + + it('shows success indicator for successful CUE entries', () => { + render( + + ); + expect(screen.getByTitle('Task completed successfully')).toBeInTheDocument(); + }); + + it('shows failure indicator for failed CUE entries', () => { + render( + + ); + expect(screen.getByTitle('Task failed')).toBeInTheDocument(); + }); + + it('shows CUE event type metadata when present', () => { + render( + + ); + expect(screen.getByText('Triggered by: file_change')).toBeInTheDocument(); + }); + + it('does not show CUE metadata for non-CUE entries', () => { + render( + + ); + expect(screen.queryByText(/Triggered by:/)).not.toBeInTheDocument(); + }); + it('shows success indicator for successful AUTO entries', () => { render( { expect(userButton).toHaveStyle({ color: mockTheme.colors.textDim }); }); - it('renders both buttons even when no filters are active', () => { + it('renders all three buttons even when no filters are active', () => { render( ([])} @@ -145,5 +145,82 @@ describe('HistoryFilterToggle', () => { ); expect(screen.getByText('AUTO')).toBeInTheDocument(); expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('hides CUE button when visibleTypes excludes it', () => { + render( + (['AUTO', 'USER'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + visibleTypes={['AUTO', 'USER']} + /> + ); + expect(screen.getByText('AUTO')).toBeInTheDocument(); + expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.queryByText('CUE')).not.toBeInTheDocument(); + }); + + it('shows CUE button when visibleTypes includes it', () => { + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + visibleTypes={['AUTO', 'USER', 'CUE']} + /> + ); + expect(screen.getByText('AUTO')).toBeInTheDocument(); + expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('renders CUE filter button', () => { + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('calls onToggleFilter with CUE when CUE button is clicked', () => { + const onToggleFilter = vi.fn(); + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={onToggleFilter} + theme={mockTheme} + /> + ); + fireEvent.click(screen.getByText('CUE')); + expect(onToggleFilter).toHaveBeenCalledWith('CUE'); + }); + + it('styles active CUE button with teal colors', () => { + render( + (['CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + const cueButton = screen.getByText('CUE').closest('button')!; + expect(cueButton).toHaveStyle({ color: '#06b6d4' }); + }); + + it('shows CUE button as inactive when not in active filters', () => { + render( + (['AUTO', 'USER'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + const cueButton = screen.getByText('CUE').closest('button')!; + expect(cueButton.className).toContain('opacity-40'); }); }); diff --git a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx index 090436495..ebc06b205 100644 --- a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx +++ b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx @@ -207,6 +207,74 @@ describe('HistoryDetailModal', () => { ); expect(validatedIndicator).toBeInTheDocument(); }); + + it('should render CUE type with correct pill and teal color', () => { + render( + + ); + + const cuePill = screen.getByText('CUE'); + expect(cuePill).toBeInTheDocument(); + expect(cuePill.closest('span')).toHaveStyle({ color: '#06b6d4' }); + }); + + it('should show success indicator for CUE entries with success=true', () => { + render( + + ); + + const successIndicator = screen.getByTitle('Task completed successfully'); + expect(successIndicator).toBeInTheDocument(); + }); + + it('should show failure indicator for CUE entries with success=false', () => { + render( + + ); + + const failureIndicator = screen.getByTitle('Task failed'); + expect(failureIndicator).toBeInTheDocument(); + }); + + it('should display CUE trigger metadata when available', () => { + render( + + ); + + expect(screen.getByTitle('Trigger: lint-on-save')).toBeInTheDocument(); + }); + + it('should not display CUE trigger metadata for non-CUE entries', () => { + render( + + ); + + expect(screen.queryByTitle(/Trigger:/)).not.toBeInTheDocument(); + }); }); describe('Content Display', () => { @@ -810,6 +878,21 @@ describe('HistoryDetailModal', () => { expect(screen.getByText(/auto history entry/)).toBeInTheDocument(); }); + it('should show correct type in delete confirmation for CUE entry', () => { + render( + + ); + + fireEvent.click(screen.getByTitle('Delete this history entry')); + + expect(screen.getByText(/cue history entry/)).toBeInTheDocument(); + }); + it('should cancel delete when Cancel button is clicked', () => { render( ({ FileJson: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( ), + Zap: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ), })); // Create a mock theme @@ -86,6 +90,8 @@ describe('HistoryHelpModal', () => { beforeEach(() => { vi.clearAllMocks(); mockRegisterLayer.mockReturnValue('test-layer-id'); + // Default: maestroCue disabled + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); }); afterEach(() => { @@ -294,6 +300,42 @@ describe('HistoryHelpModal', () => { screen.getByText(/Entries automatically generated by the Auto Runner/) ).toBeInTheDocument(); }); + + it('does not render CUE entry type when maestroCue is disabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); + + const { container } = render(); + + const cueBadges = container.querySelectorAll('.rounded-full.text-\\[10px\\]'); + const cueBadge = Array.from(cueBadges).find((el) => el.textContent?.includes('CUE')); + expect(cueBadge).toBeFalsy(); + }); + + it('renders CUE entry type when maestroCue is enabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + const { container } = render(); + + const cueBadges = container.querySelectorAll('.rounded-full.text-\\[10px\\]'); + const cueBadge = Array.from(cueBadges).find((el) => el.textContent?.includes('CUE')); + expect(cueBadge).toBeTruthy(); + }); + + it('describes CUE entry triggers when maestroCue is enabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + render(); + + expect(screen.getByText(/Entries created by Maestro Cue automations/)).toBeInTheDocument(); + }); + + it('renders Zap icon in CUE badge when maestroCue is enabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + render(); + + expect(screen.getByTestId('zap-icon')).toBeInTheDocument(); + }); }); describe('Status Indicators Section', () => { diff --git a/src/__tests__/renderer/components/HistoryPanel.test.tsx b/src/__tests__/renderer/components/HistoryPanel.test.tsx index e7d72bb0c..aba01d28f 100644 --- a/src/__tests__/renderer/components/HistoryPanel.test.tsx +++ b/src/__tests__/renderer/components/HistoryPanel.test.tsx @@ -23,6 +23,7 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { HistoryPanel, HistoryPanelHandle } from '../../../renderer/components/HistoryPanel'; import type { Theme, Session, HistoryEntry, HistoryEntryType } from '../../../renderer/types'; import { useUIStore } from '../../../renderer/stores/uiStore'; +import { useSettingsStore } from '../../../renderer/stores/settingsStore'; // Mock child components vi.mock('../../../renderer/components/HistoryDetailModal', () => ({ @@ -167,6 +168,9 @@ describe('HistoryPanel', () => { // Reset uiStore state used by HistoryPanel useUIStore.setState({ historySearchFilterOpen: false }); + // Default: maestroCue disabled + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); + // Mock scrollIntoView for jsdom Element.prototype.scrollIntoView = vi.fn(); @@ -507,6 +511,66 @@ describe('HistoryPanel', () => { }); }); + it('should toggle CUE filter', async () => { + // Enable maestroCue so CUE filter button is visible + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + const autoEntry = createMockEntry({ type: 'AUTO', summary: 'Auto task' }); + const cueEntry = createMockEntry({ + id: 'cue-1', + type: 'CUE', + summary: 'Cue triggered task', + cueTriggerName: 'lint-on-save', + cueEventType: 'file_change', + }); + mockHistoryGetAll.mockResolvedValue([autoEntry, cueEntry]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Auto task')).toBeInTheDocument(); + expect(screen.getByText('Cue triggered task')).toBeInTheDocument(); + }); + + // Toggle off CUE + const cueFilter = screen.getByRole('button', { name: /CUE/i }); + fireEvent.click(cueFilter); + + await waitFor(() => { + expect(screen.getByText('Auto task')).toBeInTheDocument(); + expect(screen.queryByText('Cue triggered task')).not.toBeInTheDocument(); + }); + + // Toggle CUE back on + fireEvent.click(cueFilter); + + await waitFor(() => { + expect(screen.getByText('Cue triggered task')).toBeInTheDocument(); + }); + }); + + it('should hide CUE filter button when maestroCue is disabled', async () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); + + const cueEntry = createMockEntry({ + type: 'CUE', + summary: 'Cue triggered task', + }); + mockHistoryGetAll.mockResolvedValue([cueEntry]); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /AUTO/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /USER/i })).toBeInTheDocument(); + }); + + // CUE button should not be rendered + expect(screen.queryByRole('button', { name: /CUE/i })).not.toBeInTheDocument(); + // CUE entries should be filtered out (not in activeFilters) + expect(screen.queryByText('Cue triggered task')).not.toBeInTheDocument(); + }); + it('should filter by search text in summary', async () => { const entry1 = createMockEntry({ summary: 'Alpha task' }); const entry2 = createMockEntry({ summary: 'Beta task' }); @@ -1666,10 +1730,12 @@ describe('HistoryPanel', () => { await waitFor(() => { const autoFilter = screen.getByRole('button', { name: /AUTO/i }); const userFilter = screen.getByRole('button', { name: /USER/i }); + const cueFilter = screen.getByRole('button', { name: /CUE/i }); - // Both should be active by default + // All should be active by default expect(autoFilter).toHaveClass('opacity-100'); expect(userFilter).toHaveClass('opacity-100'); + expect(cueFilter).toHaveClass('opacity-100'); }); }); diff --git a/src/__tests__/renderer/components/LogViewer.test.tsx b/src/__tests__/renderer/components/LogViewer.test.tsx index 85f2ef46a..d150179ca 100644 --- a/src/__tests__/renderer/components/LogViewer.test.tsx +++ b/src/__tests__/renderer/components/LogViewer.test.tsx @@ -2,7 +2,7 @@ * LogViewer.tsx Test Suite * * Tests for the LogViewer component which displays Maestro system logs with: - * - Log level filtering (debug, info, warn, error, toast) + * - Log level filtering (debug, info, warn, error, toast, autorun, cue) * - Search functionality * - Expand/collapse log details * - Export and clear logs @@ -43,7 +43,7 @@ const mockTheme: Theme = { const createMockLog = ( overrides: Partial<{ timestamp: number; - level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'; + level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'; message: string; context?: string; data?: unknown; @@ -228,6 +228,8 @@ describe('LogViewer', () => { expect(screen.getByRole('button', { name: 'WARN' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'ERROR' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'TOAST' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'AUTORUN' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'CUE' })).toBeInTheDocument(); }); }); @@ -316,6 +318,45 @@ describe('LogViewer', () => { }); }); + it('should always enable cue level regardless of logLevel', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'CUE' })).not.toBeDisabled(); + }); + }); + + it('should filter cue logs by level when CUE toggle clicked', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ level: 'cue', message: 'Cue event fired' }), + createMockLog({ level: 'info', message: 'Info message' }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Cue event fired')).toBeInTheDocument(); + expect(screen.getByText('Info message')).toBeInTheDocument(); + }); + + // Click CUE to disable it + const cueButton = screen.getByRole('button', { name: 'CUE' }); + fireEvent.click(cueButton); + + await waitFor(() => { + expect(screen.queryByText('Cue event fired')).not.toBeInTheDocument(); + // Info should still be visible + expect(screen.getByText('Info message')).toBeInTheDocument(); + }); + + // Click CUE to re-enable it + fireEvent.click(cueButton); + + await waitFor(() => { + expect(screen.getByText('Cue event fired')).toBeInTheDocument(); + }); + }); + it('should persist level selections via callback', async () => { const onSelectedLevelsChange = vi.fn(); @@ -1064,6 +1105,83 @@ describe('LogViewer', () => { }); }); + it('should display agent pill for cue entries with context', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: '[CUE] "On PR Opened" triggered (pull_request.opened)', + context: 'My Cue Agent', + }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('My Cue Agent')).toBeInTheDocument(); + }); + }); + + it('should render cue agent pill with teal color', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: '[CUE] "Deploy Check" triggered (push)', + context: 'Cue Session', + }), + ]); + + render(); + + await waitFor(() => { + const agentPill = screen.getByText('Cue Session'); + expect(agentPill).toBeInTheDocument(); + expect(agentPill.closest('span')).toHaveStyle({ + backgroundColor: 'rgba(6, 182, 212, 0.2)', + color: '#06b6d4', + }); + }); + }); + + it('should not show context badge for cue entries (uses agent pill instead)', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: 'Cue triggered', + context: 'CueContext', + }), + ]); + + render(); + + await waitFor(() => { + // The context should appear as an agent pill, not as a context badge + const contextElement = screen.getByText('CueContext'); + expect(contextElement).toBeInTheDocument(); + // Verify it's styled as an agent pill (teal), not a context badge (accent color) + expect(contextElement.closest('span')).toHaveStyle({ color: '#06b6d4' }); + }); + }); + + it('should render cue level pill with teal color', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: 'Cue level test', + }), + ]); + + render(); + + await waitFor(() => { + const levelPill = screen.getByText('cue'); + expect(levelPill).toBeInTheDocument(); + expect(levelPill).toHaveStyle({ + color: '#06b6d4', + backgroundColor: 'rgba(6, 182, 212, 0.15)', + }); + }); + }); + it('should not show context badge for toast entries', async () => { getMockGetLogs().mockResolvedValue([ createMockLog({ diff --git a/src/__tests__/renderer/components/QuickActionsModal.test.tsx b/src/__tests__/renderer/components/QuickActionsModal.test.tsx index 3401f0252..da1312d10 100644 --- a/src/__tests__/renderer/components/QuickActionsModal.test.tsx +++ b/src/__tests__/renderer/components/QuickActionsModal.test.tsx @@ -1615,4 +1615,46 @@ describe('QuickActionsModal', () => { expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument(); }); }); + + describe('Configure Maestro Cue action', () => { + it('shows Configure Maestro Cue command with agent name when onConfigureCue is provided', () => { + const onConfigureCue = vi.fn(); + const props = createDefaultProps({ onConfigureCue }); + render(); + + expect(screen.getByText('Configure Maestro Cue: Test Session')).toBeInTheDocument(); + expect(screen.getByText('Open YAML editor for event-driven automation')).toBeInTheDocument(); + }); + + it('handles Configure Maestro Cue action - calls onConfigureCue with active session and closes modal', () => { + const onConfigureCue = vi.fn(); + const props = createDefaultProps({ onConfigureCue }); + render(); + + fireEvent.click(screen.getByText('Configure Maestro Cue: Test Session')); + + expect(onConfigureCue).toHaveBeenCalledWith( + expect.objectContaining({ id: 'session-1', name: 'Test Session' }) + ); + expect(props.setQuickActionOpen).toHaveBeenCalledWith(false); + }); + + it('does not show Configure Maestro Cue when onConfigureCue is not provided', () => { + const props = createDefaultProps(); + render(); + + expect(screen.queryByText(/Configure Maestro Cue/)).not.toBeInTheDocument(); + }); + + it('Configure Maestro Cue appears when searching for "cue"', () => { + const onConfigureCue = vi.fn(); + const props = createDefaultProps({ onConfigureCue }); + render(); + + const input = screen.getByPlaceholderText('Type a command or jump to agent...'); + fireEvent.change(input, { target: { value: 'cue' } }); + + expect(screen.getByText('Configure Maestro Cue: Test Session')).toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/SessionItemCue.test.tsx b/src/__tests__/renderer/components/SessionItemCue.test.tsx new file mode 100644 index 000000000..6b2f5a6a2 --- /dev/null +++ b/src/__tests__/renderer/components/SessionItemCue.test.tsx @@ -0,0 +1,153 @@ +/** + * @fileoverview Tests for SessionItem Cue status indicator + * + * Validates that the Zap icon appears next to session names when + * the session has active Cue subscriptions, with correct tooltip text. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SessionItem } from '../../../renderer/components/SessionItem'; +import type { Session, Theme } from '../../../renderer/types'; + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Activity: () => , + GitBranch: () => , + Bot: () => , + Bookmark: ({ fill }: { fill?: string }) => , + AlertCircle: () => , + Server: () => , + Zap: ({ + title, + style, + fill, + }: { + title?: string; + style?: Record; + fill?: string; + }) => , +})); + +const defaultTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + info: '#8be9fd', + }, +}; + +const createMockSession = (overrides: Partial = {}): Session => ({ + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/home/user/project', + projectRoot: '/home/user/project', + aiPid: 12345, + terminalPid: 12346, + aiLogs: [], + shellLogs: [], + isGitRepo: true, + fileTree: [], + fileExplorerExpanded: [], + messageQueue: [], + contextUsage: 30, + activeTimeMs: 60000, + ...overrides, +}); + +const defaultProps = { + variant: 'flat' as const, + theme: defaultTheme, + isActive: false, + isKeyboardSelected: false, + isDragging: false, + isEditing: false, + leftSidebarOpen: true, + onSelect: vi.fn(), + onDragStart: vi.fn(), + onContextMenu: vi.fn(), + onFinishRename: vi.fn(), + onStartRename: vi.fn(), + onToggleBookmark: vi.fn(), +}; + +describe('SessionItem Cue Indicator', () => { + it('shows Zap icon when cueSubscriptionCount > 0', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon).toBeInTheDocument(); + // Title is on the wrapper span, not the icon itself + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (3 subscriptions)' + ); + }); + + it('does not show Zap icon when cueSubscriptionCount is undefined', () => { + render(); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + + it('does not show Zap icon when cueSubscriptionCount is 0', () => { + render( + + ); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + + it('shows singular "subscription" for count of 1', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (1 subscription)' + ); + }); + + it('uses teal color for the Zap icon', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + // jsdom converts hex to rgb + expect(zapIcon.style.color).toBe('rgb(45, 212, 191)'); + }); + + it('does not show Zap icon when session is in editing mode', () => { + render( + + ); + + // In editing mode, the name row is replaced by an input field + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx index 91f18ee29..0151d34f7 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -69,6 +69,9 @@ vi.mock('lucide-react', () => ({ Music: () => , Command: () => , MessageSquare: () => , + Zap: ({ title, style }: { title?: string; style?: Record }) => ( + + ), })); // Mock gitService @@ -3126,4 +3129,97 @@ describe('SessionList', () => { expect(screen.queryByText('Rename')).not.toBeInTheDocument(); }); }); + + // ============================================================================ + // Cue Status Indicator Tests + // ============================================================================ + + describe('Cue Status Indicator', () => { + it('shows Zap icon for sessions with active Cue subscriptions when Encore Feature enabled', async () => { + const session = createMockSession({ id: 's1', name: 'Cue Session' }); + useSessionStore.setState({ sessions: [session] }); + useUIStore.setState({ leftSidebarOpen: true }); + useSettingsStore.setState({ + shortcuts: defaultShortcuts, + encoreFeatures: { directorNotes: false, maestroCue: true }, + }); + + // Mock Cue status to return session with subscriptions + (window.maestro as Record).cue = { + getStatus: vi.fn().mockResolvedValue([ + { + sessionId: 's1', + sessionName: 'Cue Session', + subscriptionCount: 3, + enabled: true, + activeRuns: 0, + }, + ]), + getActiveRuns: vi.fn().mockResolvedValue([]), + getActivityLog: vi.fn().mockResolvedValue([]), + onActivityUpdate: vi.fn().mockReturnValue(() => {}), + }; + + const props = createDefaultProps({ sortedSessions: [session] }); + render(); + + // Wait for async status fetch to complete + await waitFor(() => { + expect(screen.getByTestId('icon-zap')).toBeInTheDocument(); + }); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (3 subscriptions)' + ); + }); + + it('does not show Zap icon when Encore Feature is disabled', async () => { + const session = createMockSession({ id: 's1', name: 'No Cue Session' }); + useSessionStore.setState({ sessions: [session] }); + useUIStore.setState({ leftSidebarOpen: true }); + useSettingsStore.setState({ + shortcuts: defaultShortcuts, + encoreFeatures: { directorNotes: false, maestroCue: false }, + }); + + const props = createDefaultProps({ sortedSessions: [session] }); + render(); + + // Give async effects time to settle + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + + it('does not show Zap icon for sessions without Cue subscriptions', async () => { + const session = createMockSession({ id: 's1', name: 'No Sub Session' }); + useSessionStore.setState({ sessions: [session] }); + useUIStore.setState({ leftSidebarOpen: true }); + useSettingsStore.setState({ + shortcuts: defaultShortcuts, + encoreFeatures: { directorNotes: false, maestroCue: true }, + }); + + // Mock Cue status with no sessions having subscriptions + (window.maestro as Record).cue = { + getStatus: vi.fn().mockResolvedValue([]), + getActiveRuns: vi.fn().mockResolvedValue([]), + getActivityLog: vi.fn().mockResolvedValue([]), + onActivityUpdate: vi.fn().mockReturnValue(() => {}), + }; + + const props = createDefaultProps({ sortedSessions: [session] }); + render(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx index 6378957b4..64af2fe62 100644 --- a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx @@ -220,7 +220,8 @@ describe('EncoreTab', () => { await vi.advanceTimersByTimeAsync(50); }); - expect(screen.getByText('Beta')).toBeInTheDocument(); + const betaBadges = screen.getAllByText('Beta'); + expect(betaBadges.length).toBeGreaterThanOrEqual(1); }); it("should render subtitle description for Director's Notes", async () => { @@ -1426,4 +1427,69 @@ describe('EncoreTab', () => { expect(window.maestro.agents.getConfig).toHaveBeenCalledWith('codex'); }); }); + + describe('Maestro Cue feature section', () => { + it('should render Maestro Cue section with toggle', async () => { + render(); + + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + expect(screen.getByText(/Event-driven automation/)).toBeInTheDocument(); + }); + + it('should use theme accent for border when Maestro Cue is enabled', async () => { + mockUseSettingsOverrides = { + encoreFeatures: { directorNotes: false, maestroCue: true }, + }; + + const { container } = render(); + + // Find the Maestro Cue section container (second .rounded-lg.border div) + const sections = container.querySelectorAll('.rounded-lg.border'); + const cueSection = Array.from(sections).find((el) => el.textContent?.includes('Maestro Cue')); + expect(cueSection).toHaveStyle({ borderColor: mockTheme.colors.accent }); + }); + + it('should use theme border color when Maestro Cue is disabled', async () => { + mockUseSettingsOverrides = { + encoreFeatures: { directorNotes: false, maestroCue: false }, + }; + + const { container } = render(); + + const sections = container.querySelectorAll('.rounded-lg.border'); + const cueSection = Array.from(sections).find((el) => el.textContent?.includes('Maestro Cue')); + expect(cueSection).toHaveStyle({ borderColor: mockTheme.colors.border }); + }); + + it('should use theme accent for toggle when Maestro Cue is enabled', async () => { + mockUseSettingsOverrides = { + encoreFeatures: { directorNotes: false, maestroCue: true }, + }; + + const { container } = render(); + + // The toggle is a rounded-full div inside the Maestro Cue button + const sections = container.querySelectorAll('.rounded-lg.border'); + const cueSection = Array.from(sections).find((el) => el.textContent?.includes('Maestro Cue')); + const toggle = cueSection?.querySelector('.rounded-full'); + expect(toggle).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); + }); + + it('should call setEncoreFeatures with maestroCue toggled when clicked', async () => { + mockUseSettingsOverrides = { + encoreFeatures: { directorNotes: false, maestroCue: false }, + }; + + render(); + + // Click the Maestro Cue section button + const cueButton = screen.getByText('Maestro Cue').closest('button'); + expect(cueButton).toBeTruthy(); + fireEvent.click(cueButton!); + + expect(mockSetEncoreFeatures).toHaveBeenCalledWith( + expect.objectContaining({ maestroCue: true }) + ); + }); + }); }); diff --git a/src/__tests__/renderer/components/TerminalOutput.test.tsx b/src/__tests__/renderer/components/TerminalOutput.test.tsx index da3a49757..6da827375 100644 --- a/src/__tests__/renderer/components/TerminalOutput.test.tsx +++ b/src/__tests__/renderer/components/TerminalOutput.test.tsx @@ -1883,7 +1883,7 @@ describe('TerminalOutput', () => { expect(screen.getByText('npm run test')).toBeInTheDocument(); }); - it('renders tool with no extractable detail gracefully', () => { + it('renders tool with boolean input as key=value', () => { const logs: LogEntry[] = [ createLogEntry({ text: 'SomeUnknownTool', @@ -1905,8 +1905,88 @@ describe('TerminalOutput', () => { const props = createDefaultProps({ session }); render(); - // Tool name should still render even with no detail + // Tool name should render expect(screen.getByText('SomeUnknownTool')).toBeInTheDocument(); + // Generic summarizer shows boolean as key=value + expect(screen.getByText('someWeirdField=true')).toBeInTheDocument(); + }); + + it('renders any tool with string input fields generically', () => { + const logs: LogEntry[] = [ + createLogEntry({ + text: 'Skill', + source: 'tool', + metadata: { + toolState: { + status: 'running', + input: { skill_name: 'commit-push-pr' }, + }, + }, + }), + ]; + + const session = createDefaultSession({ + tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }], + activeTabId: 'tab-1', + }); + + const props = createDefaultProps({ session }); + render(); + + expect(screen.getByText('Skill')).toBeInTheDocument(); + expect(screen.getByText('commit-push-pr')).toBeInTheDocument(); + }); + + it('renders tool with multiple input fields joined', () => { + const logs: LogEntry[] = [ + createLogEntry({ + text: 'Grep', + source: 'tool', + metadata: { + toolState: { + status: 'completed', + input: { pattern: 'TODO', path: '/src', output_mode: 'content' }, + }, + }, + }), + ]; + + const session = createDefaultSession({ + tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }], + activeTabId: 'tab-1', + }); + + const props = createDefaultProps({ session }); + render(); + + expect(screen.getByText('Grep')).toBeInTheDocument(); + // Generic summarizer joins all string fields + expect(screen.getByText('TODO /src content')).toBeInTheDocument(); + }); + + it('renders tool with empty input gracefully', () => { + const logs: LogEntry[] = [ + createLogEntry({ + text: 'EmptyTool', + source: 'tool', + metadata: { + toolState: { + status: 'completed', + input: {}, + }, + }, + }), + ]; + + const session = createDefaultSession({ + tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }], + activeTabId: 'tab-1', + }); + + const props = createDefaultProps({ session }); + render(); + + expect(screen.getByText('EmptyTool')).toBeInTheDocument(); }); }); diff --git a/src/__tests__/renderer/hooks/useCue.test.ts b/src/__tests__/renderer/hooks/useCue.test.ts new file mode 100644 index 000000000..ce3f4459b --- /dev/null +++ b/src/__tests__/renderer/hooks/useCue.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for useCue hook + * + * This hook manages Cue state for the renderer, including session status, + * active runs, and activity log. Tests verify data fetching, actions, + * event subscriptions, and cleanup. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useCue } from '../../../renderer/hooks/useCue'; + +// Mock Cue API +const mockGetStatus = vi.fn(); +const mockGetActiveRuns = vi.fn(); +const mockGetActivityLog = vi.fn(); +const mockGetQueueStatus = vi.fn(); +const mockEnable = vi.fn(); +const mockDisable = vi.fn(); +const mockStopRun = vi.fn(); +const mockStopAll = vi.fn(); +const mockOnActivityUpdate = vi.fn(); + +const mockUnsubscribe = vi.fn(); + +// Mock setInterval/clearInterval to prevent polling during tests +const originalSetInterval = globalThis.setInterval; +const originalClearInterval = globalThis.clearInterval; + +beforeEach(() => { + vi.clearAllMocks(); + + globalThis.setInterval = vi.fn( + () => 999 as unknown as ReturnType + ) as unknown as typeof setInterval; + globalThis.clearInterval = vi.fn() as unknown as typeof clearInterval; + + mockGetStatus.mockResolvedValue([]); + mockGetActiveRuns.mockResolvedValue([]); + mockGetActivityLog.mockResolvedValue([]); + mockGetQueueStatus.mockResolvedValue({}); + mockEnable.mockResolvedValue(undefined); + mockDisable.mockResolvedValue(undefined); + mockStopRun.mockResolvedValue(true); + mockStopAll.mockResolvedValue(undefined); + mockOnActivityUpdate.mockReturnValue(mockUnsubscribe); + + (window as any).maestro = { + ...(window as any).maestro, + cue: { + getStatus: mockGetStatus, + getActiveRuns: mockGetActiveRuns, + getActivityLog: mockGetActivityLog, + getQueueStatus: mockGetQueueStatus, + enable: mockEnable, + disable: mockDisable, + stopRun: mockStopRun, + stopAll: mockStopAll, + onActivityUpdate: mockOnActivityUpdate, + }, + }; +}); + +afterEach(() => { + globalThis.setInterval = originalSetInterval; + globalThis.clearInterval = originalClearInterval; + vi.restoreAllMocks(); +}); + +const mockSession = { + sessionId: 'sess-1', + sessionName: 'Test Session', + toolType: 'claude-code', + enabled: true, + subscriptionCount: 3, + activeRuns: 1, + lastTriggered: '2026-03-01T00:00:00Z', +}; + +const mockRun = { + runId: 'run-1', + sessionId: 'sess-1', + sessionName: 'Test Session', + subscriptionName: 'on-save', + event: { + id: 'evt-1', + type: 'file.changed' as const, + timestamp: '2026-03-01T00:00:00Z', + triggerName: 'on-save', + payload: { file: '/src/index.ts' }, + }, + status: 'completed' as const, + stdout: 'Done', + stderr: '', + exitCode: 0, + durationMs: 5000, + startedAt: '2026-03-01T00:00:00Z', + endedAt: '2026-03-01T00:00:05Z', +}; + +// Helper: render hook and flush all pending microtasks so state settles +async function renderAndSettle() { + let hookResult: ReturnType, unknown>>; + await act(async () => { + hookResult = renderHook(() => useCue()); + // Allow microtasks (Promise.all resolution) to complete + await Promise.resolve(); + }); + return hookResult!; +} + +describe('useCue', () => { + describe('initial fetch', () => { + it('should fetch status, active runs, and activity log on mount', async () => { + mockGetStatus.mockResolvedValue([mockSession]); + mockGetActiveRuns.mockResolvedValue([]); + mockGetActivityLog.mockResolvedValue([mockRun]); + + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + expect(result.current.sessions).toEqual([mockSession]); + expect(result.current.activeRuns).toEqual([]); + expect(result.current.activityLog).toEqual([mockRun]); + expect(mockGetActivityLog).toHaveBeenCalledWith(100); + }); + + it('should set loading to false even if fetch fails', async () => { + mockGetStatus.mockRejectedValue(new Error('Network error')); + + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + }); + }); + + describe('actions', () => { + it('should call enable and refresh', async () => { + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + + await act(async () => { + await result.current.enable(); + }); + + expect(mockEnable).toHaveBeenCalledOnce(); + expect(mockGetStatus.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('should call disable and refresh', async () => { + const { result } = await renderAndSettle(); + + await act(async () => { + await result.current.disable(); + }); + + expect(mockDisable).toHaveBeenCalledOnce(); + }); + + it('should call stopRun with runId and refresh', async () => { + const { result } = await renderAndSettle(); + + await act(async () => { + await result.current.stopRun('run-1'); + }); + + expect(mockStopRun).toHaveBeenCalledWith('run-1'); + }); + + it('should call stopAll and refresh', async () => { + const { result } = await renderAndSettle(); + + await act(async () => { + await result.current.stopAll(); + }); + + expect(mockStopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('event subscription', () => { + it('should subscribe to activity updates on mount', async () => { + await renderAndSettle(); + + expect(mockOnActivityUpdate).toHaveBeenCalledOnce(); + }); + + it('should unsubscribe on unmount', async () => { + const { unmount } = await renderAndSettle(); + + expect(mockOnActivityUpdate).toHaveBeenCalledOnce(); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledOnce(); + }); + + it('should refresh when activity update is received', async () => { + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + + const activityCallback = mockOnActivityUpdate.mock.calls[0][0]; + mockGetStatus.mockClear(); + + await act(async () => { + activityCallback(mockRun); + await Promise.resolve(); + }); + + expect(mockGetStatus).toHaveBeenCalled(); + }); + }); + + describe('polling setup', () => { + it('should set up interval on mount', async () => { + await renderAndSettle(); + + expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), 10_000); + }); + + it('should clear interval on unmount', async () => { + const { unmount } = await renderAndSettle(); + + expect(globalThis.setInterval).toHaveBeenCalled(); + + unmount(); + + expect(globalThis.clearInterval).toHaveBeenCalled(); + }); + }); + + describe('return value shape', () => { + it('should return all expected properties', async () => { + const { result } = await renderAndSettle(); + + expect(result.current.loading).toBe(false); + expect(Array.isArray(result.current.sessions)).toBe(true); + expect(Array.isArray(result.current.activeRuns)).toBe(true); + expect(Array.isArray(result.current.activityLog)).toBe(true); + expect(typeof result.current.queueStatus).toBe('object'); + expect(typeof result.current.enable).toBe('function'); + expect(typeof result.current.disable).toBe('function'); + expect(typeof result.current.stopRun).toBe('function'); + expect(typeof result.current.stopAll).toBe('function'); + expect(typeof result.current.refresh).toBe('function'); + }); + }); +}); diff --git a/src/__tests__/renderer/hooks/useCueAutoDiscovery.test.ts b/src/__tests__/renderer/hooks/useCueAutoDiscovery.test.ts new file mode 100644 index 000000000..002598fed --- /dev/null +++ b/src/__tests__/renderer/hooks/useCueAutoDiscovery.test.ts @@ -0,0 +1,225 @@ +/** + * Tests for useCueAutoDiscovery hook + * + * This hook auto-discovers maestro-cue.yaml files when sessions are loaded, + * created, or removed. It gates all operations on the maestroCue encore feature. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useCueAutoDiscovery } from '../../../renderer/hooks/useCueAutoDiscovery'; +import { useSessionStore } from '../../../renderer/stores/sessionStore'; +import type { Session, EncoreFeatureFlags } from '../../../renderer/types'; + +// Mock Cue API +const mockRefreshSession = vi.fn(); +const mockDisable = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + + mockRefreshSession.mockResolvedValue(undefined); + mockDisable.mockResolvedValue(undefined); + + (window as any).maestro = { + ...(window as any).maestro, + cue: { + ...(window as any).maestro?.cue, + refreshSession: mockRefreshSession, + disable: mockDisable, + }, + }; + + // Reset session store + useSessionStore.setState({ sessionsLoaded: false }); +}); + +function makeSession(id: string, projectRoot: string): Session { + return { + id, + name: `session-${id}`, + projectRoot, + cwd: projectRoot, + } as unknown as Session; +} + +function makeEncoreFeatures(maestroCue: boolean): EncoreFeatureFlags { + return { maestroCue } as EncoreFeatureFlags; +} + +describe('useCueAutoDiscovery', () => { + describe('initial scan on app startup', () => { + it('should not call refreshSession before sessions are loaded', () => { + const sessions = [makeSession('s1', '/project/a')]; + const encoreFeatures = makeEncoreFeatures(true); + + renderHook(() => useCueAutoDiscovery(sessions, encoreFeatures)); + + expect(mockRefreshSession).not.toHaveBeenCalled(); + }); + + it('should scan all sessions once sessionsLoaded becomes true', async () => { + const sessions = [makeSession('s1', '/project/a'), makeSession('s2', '/project/b')]; + const encoreFeatures = makeEncoreFeatures(true); + + renderHook(() => useCueAutoDiscovery(sessions, encoreFeatures)); + + // Simulate sessions loaded + act(() => { + useSessionStore.setState({ sessionsLoaded: true }); + }); + + expect(mockRefreshSession).toHaveBeenCalledTimes(2); + expect(mockRefreshSession).toHaveBeenCalledWith('s1', '/project/a'); + expect(mockRefreshSession).toHaveBeenCalledWith('s2', '/project/b'); + }); + + it('should not scan sessions if maestroCue is disabled', () => { + const sessions = [makeSession('s1', '/project/a')]; + const encoreFeatures = makeEncoreFeatures(false); + + renderHook(() => useCueAutoDiscovery(sessions, encoreFeatures)); + + act(() => { + useSessionStore.setState({ sessionsLoaded: true }); + }); + + expect(mockRefreshSession).not.toHaveBeenCalled(); + }); + + it('should skip sessions without projectRoot', () => { + const sessions = [makeSession('s1', '/project/a'), makeSession('s2', '')]; + const encoreFeatures = makeEncoreFeatures(true); + + renderHook(() => useCueAutoDiscovery(sessions, encoreFeatures)); + + act(() => { + useSessionStore.setState({ sessionsLoaded: true }); + }); + + expect(mockRefreshSession).toHaveBeenCalledTimes(1); + expect(mockRefreshSession).toHaveBeenCalledWith('s1', '/project/a'); + }); + }); + + describe('session additions', () => { + it('should refresh new sessions when added', () => { + const initialSessions = [makeSession('s1', '/project/a')]; + const encoreFeatures = makeEncoreFeatures(true); + + useSessionStore.setState({ sessionsLoaded: true }); + + const { rerender } = renderHook( + ({ sessions, encore }) => useCueAutoDiscovery(sessions, encore), + { initialProps: { sessions: initialSessions, encore: encoreFeatures } } + ); + + mockRefreshSession.mockClear(); + + // Add a new session + const updatedSessions = [...initialSessions, makeSession('s2', '/project/b')]; + rerender({ sessions: updatedSessions, encore: encoreFeatures }); + + expect(mockRefreshSession).toHaveBeenCalledWith('s2', '/project/b'); + }); + }); + + describe('session removals', () => { + it('should notify engine when session is removed', () => { + const initialSessions = [makeSession('s1', '/project/a'), makeSession('s2', '/project/b')]; + const encoreFeatures = makeEncoreFeatures(true); + + useSessionStore.setState({ sessionsLoaded: true }); + + const { rerender } = renderHook( + ({ sessions, encore }) => useCueAutoDiscovery(sessions, encore), + { initialProps: { sessions: initialSessions, encore: encoreFeatures } } + ); + + mockRefreshSession.mockClear(); + + // Remove session s2 + const updatedSessions = [makeSession('s1', '/project/a')]; + rerender({ sessions: updatedSessions, encore: encoreFeatures }); + + expect(mockRefreshSession).toHaveBeenCalledWith('s2', ''); + }); + }); + + describe('encore feature toggle', () => { + it('should scan all sessions when maestroCue is toggled ON', () => { + const sessions = [makeSession('s1', '/project/a'), makeSession('s2', '/project/b')]; + + useSessionStore.setState({ sessionsLoaded: true }); + + const { rerender } = renderHook(({ sessions: s, encore }) => useCueAutoDiscovery(s, encore), { + initialProps: { sessions, encore: makeEncoreFeatures(false) }, + }); + + mockRefreshSession.mockClear(); + + // Toggle maestroCue ON + rerender({ sessions, encore: makeEncoreFeatures(true) }); + + expect(mockRefreshSession).toHaveBeenCalledTimes(2); + expect(mockRefreshSession).toHaveBeenCalledWith('s1', '/project/a'); + expect(mockRefreshSession).toHaveBeenCalledWith('s2', '/project/b'); + }); + + it('should call disable when maestroCue is toggled OFF', () => { + const sessions = [makeSession('s1', '/project/a')]; + + useSessionStore.setState({ sessionsLoaded: true }); + + const { rerender } = renderHook(({ sessions: s, encore }) => useCueAutoDiscovery(s, encore), { + initialProps: { sessions, encore: makeEncoreFeatures(true) }, + }); + + // Toggle maestroCue OFF + rerender({ sessions, encore: makeEncoreFeatures(false) }); + + expect(mockDisable).toHaveBeenCalledTimes(1); + }); + + it('should not trigger actions when feature toggle value unchanged', () => { + const sessions = [makeSession('s1', '/project/a')]; + + useSessionStore.setState({ sessionsLoaded: true }); + + const { rerender } = renderHook(({ sessions: s, encore }) => useCueAutoDiscovery(s, encore), { + initialProps: { sessions, encore: makeEncoreFeatures(true) }, + }); + + mockRefreshSession.mockClear(); + mockDisable.mockClear(); + + // Rerender with same feature state + rerender({ sessions, encore: makeEncoreFeatures(true) }); + + // Only the initial scan calls should exist, no toggle-related calls + expect(mockDisable).not.toHaveBeenCalled(); + }); + }); + + describe('gating behavior', () => { + it('should not refresh sessions when maestroCue is disabled even if sessions change', () => { + const initialSessions = [makeSession('s1', '/project/a')]; + const encoreFeatures = makeEncoreFeatures(false); + + useSessionStore.setState({ sessionsLoaded: true }); + + const { rerender } = renderHook( + ({ sessions, encore }) => useCueAutoDiscovery(sessions, encore), + { initialProps: { sessions: initialSessions, encore: encoreFeatures } } + ); + + mockRefreshSession.mockClear(); + + // Add a new session while feature is disabled + const updatedSessions = [...initialSessions, makeSession('s2', '/project/b')]; + rerender({ sessions: updatedSessions, encore: encoreFeatures }); + + expect(mockRefreshSession).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/renderer/stores/modalStore.test.ts b/src/__tests__/renderer/stores/modalStore.test.ts index b648e2b70..e683e7371 100644 --- a/src/__tests__/renderer/stores/modalStore.test.ts +++ b/src/__tests__/renderer/stores/modalStore.test.ts @@ -16,6 +16,7 @@ import { type ConfirmModalData, type RenameInstanceModalData, type LightboxData, + type CueYamlEditorData, } from '../../../renderer/stores/modalStore'; import type { Session } from '../../../renderer/types'; @@ -447,6 +448,8 @@ describe('modalStore', () => { 'symphony', 'updateCheck', 'windowsWarning', + 'cueModal', + 'cueYamlEditor', ]; // Open and close each to verify they all work @@ -1120,4 +1123,54 @@ describe('modalStore', () => { expect(result.current.quitConfirmModalOpen).toBe(false); }); }); + + describe('integration: cue YAML editor modal flow', () => { + it('openCueYamlEditor opens modal with session data', () => { + const actions = getModalActions(); + + actions.openCueYamlEditor('sess-123', '/projects/my-app'); + + const state = useModalStore.getState(); + expect(state.isOpen('cueYamlEditor')).toBe(true); + + const data = state.getData('cueYamlEditor') as CueYamlEditorData; + expect(data.sessionId).toBe('sess-123'); + expect(data.projectRoot).toBe('/projects/my-app'); + }); + + it('closeCueYamlEditor closes modal and clears data', () => { + const actions = getModalActions(); + + actions.openCueYamlEditor('sess-123', '/projects/my-app'); + actions.closeCueYamlEditor(); + + const state = useModalStore.getState(); + expect(state.isOpen('cueYamlEditor')).toBe(false); + expect(state.getData('cueYamlEditor')).toBeUndefined(); + }); + + it('useModalActions exposes cueYamlEditor reactive state', () => { + const { result } = renderHook(() => useModalActions()); + + expect(result.current.cueYamlEditorOpen).toBe(false); + expect(result.current.cueYamlEditorSessionId).toBeNull(); + expect(result.current.cueYamlEditorProjectRoot).toBeNull(); + + act(() => { + result.current.openCueYamlEditor('sess-456', '/projects/other'); + }); + + expect(result.current.cueYamlEditorOpen).toBe(true); + expect(result.current.cueYamlEditorSessionId).toBe('sess-456'); + expect(result.current.cueYamlEditorProjectRoot).toBe('/projects/other'); + + act(() => { + result.current.closeCueYamlEditor(); + }); + + expect(result.current.cueYamlEditorOpen).toBe(false); + expect(result.current.cueYamlEditorSessionId).toBeNull(); + expect(result.current.cueYamlEditorProjectRoot).toBeNull(); + }); + }); }); diff --git a/src/__tests__/renderer/utils/fileExplorer.test.ts b/src/__tests__/renderer/utils/fileExplorer.test.ts index 17d54db1e..6cf7ab301 100644 --- a/src/__tests__/renderer/utils/fileExplorer.test.ts +++ b/src/__tests__/renderer/utils/fileExplorer.test.ts @@ -457,6 +457,26 @@ describe('fileExplorer utils', () => { expect(result.find((n) => n.name === 'src')).toBeDefined(); }); + it('always shows maestro-cue.yaml even when it matches ignore patterns', async () => { + vi.mocked(window.maestro.fs.readDir).mockResolvedValueOnce([ + { name: 'maestro-cue.yaml', isFile: true, isDirectory: false }, + { name: 'other.yaml', isFile: true, isDirectory: false }, + { name: 'src', isFile: false, isDirectory: true }, + ]); + vi.mocked(window.maestro.fs.readDir).mockResolvedValue([]); + + // Use ignore patterns that would match yaml files + const result = await loadFileTree('/project', 10, 0, undefined, undefined, { + ignorePatterns: ['*.yaml'], + }); + + // maestro-cue.yaml should survive despite matching *.yaml + expect(result.find((n) => n.name === 'maestro-cue.yaml')).toBeDefined(); + // other.yaml should be filtered out + expect(result.find((n) => n.name === 'other.yaml')).toBeUndefined(); + expect(result.find((n) => n.name === 'src')).toBeDefined(); + }); + it('deduplicates entries returned by readDir', async () => { vi.mocked(window.maestro.fs.readDir).mockResolvedValueOnce([ { name: 'src', isFile: false, isDirectory: true, path: '/project/src' }, diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 9650e2174..06273e3b0 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -538,6 +538,20 @@ const mockMaestro = { checkCli: vi.fn().mockResolvedValue({ available: false }), validateApiKey: vi.fn().mockResolvedValue({ valid: false }), }, + cue: { + getStatus: vi.fn().mockResolvedValue([]), + getActiveRuns: vi.fn().mockResolvedValue([]), + getActivityLog: vi.fn().mockResolvedValue([]), + enable: vi.fn().mockResolvedValue(undefined), + disable: vi.fn().mockResolvedValue(undefined), + stopRun: vi.fn().mockResolvedValue(false), + stopAll: vi.fn().mockResolvedValue(undefined), + refreshSession: vi.fn().mockResolvedValue(undefined), + readYaml: vi.fn().mockResolvedValue(null), + writeYaml: vi.fn().mockResolvedValue(undefined), + validateYaml: vi.fn().mockResolvedValue({ valid: true, errors: [] }), + onActivityUpdate: vi.fn().mockReturnValue(() => {}), + }, // Synchronous platform string (replaces async os.getPlatform IPC) platform: 'darwin', }; diff --git a/src/__tests__/shared/templateVariables.test.ts b/src/__tests__/shared/templateVariables.test.ts index 6d1d8c3eb..90007c3ef 100644 --- a/src/__tests__/shared/templateVariables.test.ts +++ b/src/__tests__/shared/templateVariables.test.ts @@ -721,6 +721,47 @@ describe('substituteTemplateVariables', () => { }); }); + describe('Cue Variables', () => { + it('should replace file change type variable', () => { + const context = createTestContext({ + cue: { + eventType: 'file.changed', + fileChangeType: 'add', + }, + }); + const result = substituteTemplateVariables('Type: {{CUE_FILE_CHANGE_TYPE}}', context); + expect(result).toBe('Type: add'); + }); + + it('should replace agent.completed source metadata variables', () => { + const context = createTestContext({ + cue: { + eventType: 'agent.completed', + sourceSession: 'builder', + sourceOutput: 'Build succeeded', + sourceStatus: 'completed', + sourceExitCode: '0', + sourceDuration: '15000', + sourceTriggeredBy: 'lint-on-save', + }, + }); + const result = substituteTemplateVariables( + '{{CUE_SOURCE_STATUS}} exit={{CUE_SOURCE_EXIT_CODE}} dur={{CUE_SOURCE_DURATION}} by={{CUE_SOURCE_TRIGGERED_BY}}', + context + ); + expect(result).toBe('completed exit=0 dur=15000 by=lint-on-save'); + }); + + it('should default missing cue variables to empty string', () => { + const context = createTestContext({ cue: {} }); + const result = substituteTemplateVariables( + '[{{CUE_FILE_CHANGE_TYPE}}][{{CUE_SOURCE_STATUS}}][{{CUE_SOURCE_EXIT_CODE}}][{{CUE_SOURCE_DURATION}}][{{CUE_SOURCE_TRIGGERED_BY}}]', + context + ); + expect(result).toBe('[][][][][]'); + }); + }); + describe('Real-world Template Examples', () => { it('should substitute an Auto Run prompt template', () => { const context = createTestContext({ diff --git a/src/__tests__/web/mobile/MobileHistoryPanel.test.tsx b/src/__tests__/web/mobile/MobileHistoryPanel.test.tsx index 0cfe0caf2..fa400cc08 100644 --- a/src/__tests__/web/mobile/MobileHistoryPanel.test.tsx +++ b/src/__tests__/web/mobile/MobileHistoryPanel.test.tsx @@ -127,8 +127,10 @@ describe('MobileHistoryPanel', () => { it('exports HistoryEntryType type', () => { const autoType: HistoryEntryType = 'AUTO'; const userType: HistoryEntryType = 'USER'; + const cueType: HistoryEntryType = 'CUE'; expect(autoType).toBe('AUTO'); expect(userType).toBe('USER'); + expect(cueType).toBe('CUE'); }); it('exports HistoryEntry interface', () => { diff --git a/src/main/cue/cue-db.ts b/src/main/cue/cue-db.ts new file mode 100644 index 000000000..107965097 --- /dev/null +++ b/src/main/cue/cue-db.ts @@ -0,0 +1,320 @@ +/** + * Cue Database — lightweight SQLite persistence for Cue events and heartbeat. + * + * Uses the same `better-sqlite3` pattern as `src/main/stats/stats-db.ts`. + * Stores event history (for the activity journal) and a single-row heartbeat + * table used by the sleep/wake reconciler to detect missed intervals. + */ + +import Database from 'better-sqlite3'; +import * as path from 'path'; +import * as fs from 'fs'; +import { app } from 'electron'; + +const LOG_CONTEXT = '[CueDB]'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CueEventRecord { + id: string; + type: string; + triggerName: string; + sessionId: string; + subscriptionName: string; + status: string; + createdAt: number; + completedAt: number | null; + payload: string | null; +} + +// ============================================================================ +// Schema +// ============================================================================ + +const CREATE_CUE_EVENTS_SQL = ` + CREATE TABLE IF NOT EXISTS cue_events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + trigger_name TEXT NOT NULL, + session_id TEXT NOT NULL, + subscription_name TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + completed_at INTEGER, + payload TEXT + ) +`; + +const CREATE_CUE_EVENTS_INDEXES_SQL = ` + CREATE INDEX IF NOT EXISTS idx_cue_events_created ON cue_events(created_at); + CREATE INDEX IF NOT EXISTS idx_cue_events_session ON cue_events(session_id) +`; + +const CREATE_CUE_HEARTBEAT_SQL = ` + CREATE TABLE IF NOT EXISTS cue_heartbeat ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_seen INTEGER NOT NULL + ) +`; + +const CREATE_CUE_GITHUB_SEEN_SQL = ` + CREATE TABLE IF NOT EXISTS cue_github_seen ( + subscription_id TEXT NOT NULL, + item_key TEXT NOT NULL, + seen_at INTEGER NOT NULL, + PRIMARY KEY (subscription_id, item_key) + ) +`; + +const CREATE_CUE_GITHUB_SEEN_INDEX_SQL = ` + CREATE INDEX IF NOT EXISTS idx_cue_github_seen_at ON cue_github_seen(seen_at) +`; + +// ============================================================================ +// Module State +// ============================================================================ + +let db: Database.Database | null = null; +let logFn: ((level: string, message: string) => void) | null = null; + +function log(level: string, message: string): void { + if (logFn) { + logFn(level, `${LOG_CONTEXT} ${message}`); + } +} + +// ============================================================================ +// Lifecycle +// ============================================================================ + +/** + * Initialize the Cue database. Must be called before any other operations. + * Optionally accepts a logger callback for consistent logging with CueEngine. + */ +export function initCueDb( + onLog?: (level: string, message: string) => void, + dbPathOverride?: string +): void { + if (db) return; + + if (onLog) logFn = onLog; + + const dbPath = dbPathOverride ?? path.join(app.getPath('userData'), 'cue.db'); + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + // Create tables + db.prepare(CREATE_CUE_EVENTS_SQL).run(); + for (const sql of CREATE_CUE_EVENTS_INDEXES_SQL.split(';').filter((s) => s.trim())) { + db.prepare(sql).run(); + } + db.prepare(CREATE_CUE_HEARTBEAT_SQL).run(); + db.prepare(CREATE_CUE_GITHUB_SEEN_SQL).run(); + db.prepare(CREATE_CUE_GITHUB_SEEN_INDEX_SQL).run(); + + log('info', `Cue database initialized at ${dbPath}`); +} + +/** + * Close the Cue database connection. + */ +export function closeCueDb(): void { + if (db) { + db.close(); + db = null; + log('info', 'Cue database closed'); + } +} + +/** + * Check if the Cue database is initialized and ready. + */ +export function isCueDbReady(): boolean { + return db !== null; +} + +// ============================================================================ +// Internal accessor +// ============================================================================ + +function getDb(): Database.Database { + if (!db) throw new Error('Cue database not initialized — call initCueDb() first'); + return db; +} + +// ============================================================================ +// Event Journal +// ============================================================================ + +/** + * Record a new Cue event in the journal. + */ +export function recordCueEvent(event: { + id: string; + type: string; + triggerName: string; + sessionId: string; + subscriptionName: string; + status: string; + payload?: string; +}): void { + getDb() + .prepare( + `INSERT OR REPLACE INTO cue_events (id, type, trigger_name, session_id, subscription_name, status, created_at, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + event.id, + event.type, + event.triggerName, + event.sessionId, + event.subscriptionName, + event.status, + Date.now(), + event.payload ?? null + ); +} + +/** + * Update the status (and optionally completed_at) of a previously recorded event. + */ +export function updateCueEventStatus(id: string, status: string): void { + getDb() + .prepare(`UPDATE cue_events SET status = ?, completed_at = ? WHERE id = ?`) + .run(status, Date.now(), id); +} + +/** + * Retrieve recent Cue events created after a given timestamp. + */ +export function getRecentCueEvents(since: number, limit?: number): CueEventRecord[] { + const sql = limit + ? `SELECT * FROM cue_events WHERE created_at >= ? ORDER BY created_at DESC LIMIT ?` + : `SELECT * FROM cue_events WHERE created_at >= ? ORDER BY created_at DESC`; + + const rows = ( + limit ? getDb().prepare(sql).all(since, limit) : getDb().prepare(sql).all(since) + ) as Array<{ + id: string; + type: string; + trigger_name: string; + session_id: string; + subscription_name: string; + status: string; + created_at: number; + completed_at: number | null; + payload: string | null; + }>; + + return rows.map((row) => ({ + id: row.id, + type: row.type, + triggerName: row.trigger_name, + sessionId: row.session_id, + subscriptionName: row.subscription_name, + status: row.status, + createdAt: row.created_at, + completedAt: row.completed_at, + payload: row.payload, + })); +} + +// ============================================================================ +// Heartbeat +// ============================================================================ + +/** + * Write the current timestamp as the heartbeat. Uses an upsert on the + * single-row heartbeat table (id = 1). + */ +export function updateHeartbeat(): void { + getDb() + .prepare(`INSERT OR REPLACE INTO cue_heartbeat (id, last_seen) VALUES (1, ?)`) + .run(Date.now()); +} + +/** + * Read the last-seen heartbeat timestamp, or null if none exists. + */ +export function getLastHeartbeat(): number | null { + const row = getDb().prepare(`SELECT last_seen FROM cue_heartbeat WHERE id = 1`).get() as + | { last_seen: number } + | undefined; + return row?.last_seen ?? null; +} + +// ============================================================================ +// Housekeeping +// ============================================================================ + +/** + * Delete events older than the specified age in milliseconds. + */ +export function pruneCueEvents(olderThanMs: number): void { + const cutoff = Date.now() - olderThanMs; + const result = getDb().prepare(`DELETE FROM cue_events WHERE created_at < ?`).run(cutoff); + if (result.changes > 0) { + log('info', `Pruned ${result.changes} old Cue event(s)`); + } +} + +// ============================================================================ +// GitHub Seen Tracking +// ============================================================================ + +/** + * Check if a GitHub item has been seen for a given subscription. + */ +export function isGitHubItemSeen(subscriptionId: string, itemKey: string): boolean { + const row = getDb() + .prepare(`SELECT 1 FROM cue_github_seen WHERE subscription_id = ? AND item_key = ?`) + .get(subscriptionId, itemKey); + return row !== undefined; +} + +/** + * Mark a GitHub item as seen for a given subscription. + */ +export function markGitHubItemSeen(subscriptionId: string, itemKey: string): void { + getDb() + .prepare( + `INSERT OR IGNORE INTO cue_github_seen (subscription_id, item_key, seen_at) VALUES (?, ?, ?)` + ) + .run(subscriptionId, itemKey, Date.now()); +} + +/** + * Check if any GitHub items have been seen for a subscription. + * Used for first-run seeding detection. + */ +export function hasAnyGitHubSeen(subscriptionId: string): boolean { + const row = getDb() + .prepare(`SELECT 1 FROM cue_github_seen WHERE subscription_id = ? LIMIT 1`) + .get(subscriptionId); + return row !== undefined; +} + +/** + * Delete GitHub seen records older than the specified age in milliseconds. + */ +export function pruneGitHubSeen(olderThanMs: number): void { + const cutoff = Date.now() - olderThanMs; + const result = getDb().prepare(`DELETE FROM cue_github_seen WHERE seen_at < ?`).run(cutoff); + if (result.changes > 0) { + log('info', `Pruned ${result.changes} old GitHub seen record(s)`); + } +} + +/** + * Delete all GitHub seen records for a subscription. + */ +export function clearGitHubSeenForSubscription(subscriptionId: string): void { + getDb().prepare(`DELETE FROM cue_github_seen WHERE subscription_id = ?`).run(subscriptionId); +} diff --git a/src/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts new file mode 100644 index 000000000..a8a503bad --- /dev/null +++ b/src/main/cue/cue-engine.ts @@ -0,0 +1,1081 @@ +/** + * Cue Engine Core — the main coordinator for Maestro Cue event-driven automation. + * + * Discovers maestro-cue.yaml files per session, manages interval timers, + * file watchers, and agent completion listeners. Runs in the Electron main process. + * + * Supports agent completion chains: + * - Fan-out: a subscription fires its prompt against multiple target sessions + * - Fan-in: a subscription waits for multiple source sessions to complete before firing + * - Session bridging: completion events from user sessions (non-Cue) trigger Cue subscriptions + */ + +import * as crypto from 'crypto'; +import type { MainLogLevel } from '../../shared/logger-types'; +import type { SessionInfo } from '../../shared/types'; +import type { + AgentCompletionData, + CueConfig, + CueEvent, + CueGraphSession, + CueRunResult, + CueSessionStatus, + CueSubscription, +} from './cue-types'; +import { loadCueConfig, watchCueYaml } from './cue-yaml-loader'; +import { createCueFileWatcher } from './cue-file-watcher'; +import { createCueGitHubPoller } from './cue-github-poller'; +import { createCueTaskScanner } from './cue-task-scanner'; +import { matchesFilter, describeFilter } from './cue-filter'; +import { initCueDb, closeCueDb, updateHeartbeat, getLastHeartbeat, pruneCueEvents } from './cue-db'; +import { reconcileMissedTimeEvents } from './cue-reconciler'; +import type { ReconcileSessionInfo } from './cue-reconciler'; + +const ACTIVITY_LOG_MAX = 500; +const DEFAULT_FILE_DEBOUNCE_MS = 5000; +const SOURCE_OUTPUT_MAX_CHARS = 5000; +const HEARTBEAT_INTERVAL_MS = 30_000; // 30 seconds +const SLEEP_THRESHOLD_MS = 120_000; // 2 minutes +const EVENT_PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +/** Dependencies injected into the CueEngine */ +export interface CueEngineDeps { + getSessions: () => SessionInfo[]; + onCueRun: (sessionId: string, prompt: string, event: CueEvent) => Promise; + onLog: (level: MainLogLevel, message: string, data?: unknown) => void; +} + +/** Internal state per session with an active Cue config */ +interface SessionState { + config: CueConfig; + timers: ReturnType[]; + watchers: (() => void)[]; + yamlWatcher: (() => void) | null; + lastTriggered?: string; + nextTriggers: Map; // subscriptionName -> next trigger timestamp +} + +/** Active run tracking */ +interface ActiveRun { + result: CueRunResult; + abortController?: AbortController; +} + +/** Stored data for a single fan-in source completion */ +interface FanInSourceCompletion { + sessionId: string; + sessionName: string; + output: string; +} + +/** A queued event waiting for a concurrency slot */ +interface QueuedEvent { + event: CueEvent; + subscription: CueSubscription; + prompt: string; + subscriptionName: string; + queuedAt: number; +} + +export class CueEngine { + private enabled = false; + private sessions = new Map(); + private activeRuns = new Map(); + private activityLog: CueRunResult[] = []; + private fanInTrackers = new Map>(); + private fanInTimers = new Map>(); + private pendingYamlWatchers = new Map void>(); + private activeRunCount = new Map(); + private eventQueue = new Map(); + private heartbeatInterval: ReturnType | null = null; + private deps: CueEngineDeps; + + constructor(deps: CueEngineDeps) { + this.deps = deps; + } + + /** Enable the engine and scan all sessions for Cue configs */ + start(): void { + this.enabled = true; + this.deps.onLog('cue', '[CUE] Engine started'); + + // Initialize Cue database and prune old events + try { + initCueDb((level, msg) => this.deps.onLog(level as MainLogLevel, msg)); + pruneCueEvents(EVENT_PRUNE_AGE_MS); + } catch (error) { + this.deps.onLog('warn', `[CUE] Failed to initialize Cue database: ${error}`); + } + + const sessions = this.deps.getSessions(); + for (const session of sessions) { + this.initSession(session); + } + + // Detect sleep gap from previous heartbeat + this.detectSleepAndReconcile(); + + // Start heartbeat writer (30s interval) + this.startHeartbeat(); + } + + /** Disable the engine, clearing all timers and watchers */ + stop(): void { + this.enabled = false; + for (const [sessionId] of this.sessions) { + this.teardownSession(sessionId); + } + this.sessions.clear(); + + // Clean up pending yaml watchers (watching for config re-creation after deletion) + for (const [, cleanup] of this.pendingYamlWatchers) { + cleanup(); + } + this.pendingYamlWatchers.clear(); + + // Clear concurrency state + this.eventQueue.clear(); + this.activeRunCount.clear(); + + // Stop heartbeat and close database + this.stopHeartbeat(); + try { + closeCueDb(); + } catch { + // Non-fatal — database may not have been initialized + } + + this.deps.onLog('cue', '[CUE] Engine stopped'); + } + + /** Re-read the YAML for a specific session, tearing down old subscriptions */ + refreshSession(sessionId: string, projectRoot: string): void { + const hadSession = this.sessions.has(sessionId); + this.teardownSession(sessionId); + this.sessions.delete(sessionId); + + // Clean up any pending yaml watcher for this session + const pendingWatcher = this.pendingYamlWatchers.get(sessionId); + if (pendingWatcher) { + pendingWatcher(); + this.pendingYamlWatchers.delete(sessionId); + } + + const session = this.deps.getSessions().find((s) => s.id === sessionId); + if (!session) return; + + this.initSession({ ...session, projectRoot }); + + const newState = this.sessions.get(sessionId); + if (newState) { + // Config was successfully reloaded + const activeCount = newState.config.subscriptions.filter((s) => s.enabled !== false).length; + this.deps.onLog( + 'cue', + `[CUE] Config reloaded for "${session.name}" (${activeCount} subscriptions)`, + { type: 'configReloaded', sessionId } + ); + } else if (hadSession) { + // Config was removed — keep watching for re-creation + const yamlWatcher = watchCueYaml(projectRoot, () => { + this.refreshSession(sessionId, projectRoot); + }); + this.pendingYamlWatchers.set(sessionId, yamlWatcher); + this.deps.onLog('cue', `[CUE] Config removed for "${session.name}"`, { + type: 'configRemoved', + sessionId, + }); + } + } + + /** Teardown all subscriptions for a session */ + removeSession(sessionId: string): void { + this.teardownSession(sessionId); + this.sessions.delete(sessionId); + this.clearQueue(sessionId); + this.activeRunCount.delete(sessionId); + + const pendingWatcher = this.pendingYamlWatchers.get(sessionId); + if (pendingWatcher) { + pendingWatcher(); + this.pendingYamlWatchers.delete(sessionId); + } + + this.deps.onLog('cue', `[CUE] Session removed: ${sessionId}`); + } + + /** Returns status of all sessions with Cue configs */ + getStatus(): CueSessionStatus[] { + const result: CueSessionStatus[] = []; + const allSessions = this.deps.getSessions(); + + for (const [sessionId, state] of this.sessions) { + const session = allSessions.find((s) => s.id === sessionId); + if (!session) continue; + + const activeRunCount = [...this.activeRuns.values()].filter( + (r) => r.result.sessionId === sessionId + ).length; + + let nextTrigger: string | undefined; + if (state.nextTriggers.size > 0) { + const earliest = Math.min(...state.nextTriggers.values()); + nextTrigger = new Date(earliest).toISOString(); + } + + result.push({ + sessionId, + sessionName: session.name, + toolType: session.toolType, + projectRoot: session.projectRoot, + enabled: true, + subscriptionCount: state.config.subscriptions.filter((s) => s.enabled !== false).length, + activeRuns: activeRunCount, + lastTriggered: state.lastTriggered, + nextTrigger, + }); + } + + return result; + } + + /** Returns currently running Cue executions */ + getActiveRuns(): CueRunResult[] { + return [...this.activeRuns.values()].map((r) => r.result); + } + + /** Returns recent completed/failed runs */ + getActivityLog(limit?: number): CueRunResult[] { + if (limit !== undefined) { + return this.activityLog.slice(-limit); + } + return [...this.activityLog]; + } + + /** Stops a specific running execution */ + stopRun(runId: string): boolean { + const run = this.activeRuns.get(runId); + if (!run) return false; + + run.abortController?.abort(); + run.result.status = 'stopped'; + run.result.endedAt = new Date().toISOString(); + run.result.durationMs = Date.now() - new Date(run.result.startedAt).getTime(); + + const sessionId = run.result.sessionId; + this.activeRuns.delete(runId); + this.pushActivityLog(run.result); + + // Decrement active run count and drain queue (same as normal completion) + const count = this.activeRunCount.get(sessionId) ?? 1; + this.activeRunCount.set(sessionId, Math.max(0, count - 1)); + this.drainQueue(sessionId); + + this.deps.onLog('cue', `[CUE] Run stopped: ${runId}`); + return true; + } + + /** Stops all running executions and clears all queues */ + stopAll(): void { + for (const [runId] of this.activeRuns) { + this.stopRun(runId); + } + this.eventQueue.clear(); + this.activeRunCount.clear(); + } + + /** Returns master enabled state */ + isEnabled(): boolean { + return this.enabled; + } + + /** Returns queue depth per session (for the Cue Modal) */ + getQueueStatus(): Map { + const result = new Map(); + for (const [sessionId, queue] of this.eventQueue) { + if (queue.length > 0) { + result.set(sessionId, queue.length); + } + } + return result; + } + + /** Returns all sessions with their parsed subscriptions (for graph visualization) */ + getGraphData(): CueGraphSession[] { + const result: CueGraphSession[] = []; + const allSessions = this.deps.getSessions(); + + for (const [sessionId, state] of this.sessions) { + const session = allSessions.find((s) => s.id === sessionId); + if (!session) continue; + + result.push({ + sessionId, + sessionName: session.name, + toolType: session.toolType, + subscriptions: state.config.subscriptions, + }); + } + + return result; + } + + /** Clears queued events for a session */ + clearQueue(sessionId: string): void { + this.eventQueue.delete(sessionId); + } + + /** + * Check if any Cue subscriptions are listening for a given session's completion. + * Used to avoid emitting completion events for sessions nobody cares about. + */ + hasCompletionSubscribers(sessionId: string): boolean { + if (!this.enabled) return false; + + const allSessions = this.deps.getSessions(); + const completingSession = allSessions.find((s) => s.id === sessionId); + const completingName = completingSession?.name ?? sessionId; + + for (const [, state] of this.sessions) { + for (const sub of state.config.subscriptions) { + if (sub.event !== 'agent.completed' || sub.enabled === false) continue; + + const sources = Array.isArray(sub.source_session) + ? sub.source_session + : sub.source_session + ? [sub.source_session] + : []; + + if (sources.some((src) => src === sessionId || src === completingName)) { + return true; + } + } + } + + return false; + } + + /** Notify the engine that an agent session has completed (for agent.completed triggers) */ + notifyAgentCompleted(sessionId: string, completionData?: AgentCompletionData): void { + if (!this.enabled) return; + + // Resolve the completing session's name for matching + const allSessions = this.deps.getSessions(); + const completingSession = allSessions.find((s) => s.id === sessionId); + const completingName = completionData?.sessionName ?? completingSession?.name ?? sessionId; + + for (const [ownerSessionId, state] of this.sessions) { + for (const sub of state.config.subscriptions) { + if (sub.event !== 'agent.completed' || sub.enabled === false) continue; + + const sources = Array.isArray(sub.source_session) + ? sub.source_session + : sub.source_session + ? [sub.source_session] + : []; + + // Match by session name or ID + if (!sources.some((src) => src === sessionId || src === completingName)) continue; + + if (sources.length === 1) { + // Single source — fire immediately + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'agent.completed', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { + sourceSession: completingName, + sourceSessionId: sessionId, + status: completionData?.status ?? 'completed', + exitCode: completionData?.exitCode ?? null, + durationMs: completionData?.durationMs ?? 0, + sourceOutput: (completionData?.stdout ?? '').slice(-SOURCE_OUTPUT_MAX_CHARS), + triggeredBy: completionData?.triggeredBy, + }, + }; + + // 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 (agent.completed)`); + this.dispatchSubscription(ownerSessionId, sub, event, completingName); + } else { + // Fan-in: track completions with data + this.handleFanIn( + ownerSessionId, + state, + sub, + sources, + sessionId, + completingName, + completionData + ); + } + } + } + } + + /** Clear all fan-in state for a session (when Cue is disabled or session removed) */ + clearFanInState(sessionId: string): void { + for (const key of [...this.fanInTrackers.keys()]) { + if (key.startsWith(`${sessionId}:`)) { + this.fanInTrackers.delete(key); + const timer = this.fanInTimers.get(key); + if (timer) { + clearTimeout(timer); + this.fanInTimers.delete(key); + } + } + } + } + + // --- Private methods --- + + /** + * Dispatch a subscription, handling fan-out if configured. + * If the subscription has fan_out targets, fires against each target session. + * Otherwise fires against the owner session. + */ + private dispatchSubscription( + ownerSessionId: string, + sub: CueSubscription, + event: CueEvent, + sourceSessionName: string + ): void { + if (sub.fan_out && sub.fan_out.length > 0) { + // Fan-out: fire against each target session + const targetNames = sub.fan_out.join(', '); + this.deps.onLog('cue', `[CUE] Fan-out: "${sub.name}" → ${targetNames}`); + + const allSessions = this.deps.getSessions(); + for (let i = 0; i < sub.fan_out.length; i++) { + const targetName = sub.fan_out[i]; + const targetSession = allSessions.find((s) => s.name === targetName || s.id === targetName); + + if (!targetSession) { + this.deps.onLog('cue', `[CUE] Fan-out target not found: "${targetName}" — skipping`); + continue; + } + + const fanOutEvent: CueEvent = { + ...event, + id: crypto.randomUUID(), + payload: { + ...event.payload, + fanOutSource: sourceSessionName, + fanOutIndex: i, + }, + }; + this.executeCueRun(targetSession.id, sub.prompt, fanOutEvent, sub.name); + } + } else { + this.executeCueRun(ownerSessionId, sub.prompt, event, sub.name); + } + } + + /** + * Handle fan-in logic: track which sources have completed, fire when all done. + * Supports timeout handling based on the subscription's settings. + */ + private handleFanIn( + ownerSessionId: string, + state: SessionState, + sub: CueSubscription, + sources: string[], + completedSessionId: string, + completedSessionName: string, + completionData?: AgentCompletionData + ): void { + const key = `${ownerSessionId}:${sub.name}`; + + if (!this.fanInTrackers.has(key)) { + this.fanInTrackers.set(key, new Map()); + } + const tracker = this.fanInTrackers.get(key)!; + tracker.set(completedSessionId, { + sessionId: completedSessionId, + sessionName: completedSessionName, + output: (completionData?.stdout ?? '').slice(-SOURCE_OUTPUT_MAX_CHARS), + }); + + // Start timeout timer on first source completion + if (tracker.size === 1 && !this.fanInTimers.has(key)) { + const timeoutMs = (state.config.settings.timeout_minutes ?? 30) * 60 * 1000; + const timer = setTimeout(() => { + this.handleFanInTimeout(key, ownerSessionId, state, sub, sources); + }, timeoutMs); + this.fanInTimers.set(key, timer); + } + + const remaining = sources.length - tracker.size; + if (remaining > 0) { + this.deps.onLog( + 'cue', + `[CUE] Fan-in "${sub.name}": waiting for ${remaining} more session(s)` + ); + return; + } + + // All sources completed — clear timer and fire + const timer = this.fanInTimers.get(key); + if (timer) { + clearTimeout(timer); + this.fanInTimers.delete(key); + } + this.fanInTrackers.delete(key); + + const completions = [...tracker.values()]; + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'agent.completed', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { + completedSessions: completions.map((c) => c.sessionId), + sourceSession: completions.map((c) => c.sessionName).join(', '), + sourceOutput: completions.map((c) => c.output).join('\n---\n'), + }, + }; + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (agent.completed, fan-in complete)`); + this.dispatchSubscription( + ownerSessionId, + sub, + event, + completions.map((c) => c.sessionName).join(', ') + ); + } + + /** + * Handle fan-in timeout. Behavior depends on timeout_on_fail setting: + * - 'break': log failure and clear the tracker + * - 'continue': fire the downstream subscription with partial data + */ + private handleFanInTimeout( + key: string, + ownerSessionId: string, + state: SessionState, + sub: CueSubscription, + sources: string[] + ): void { + this.fanInTimers.delete(key); + const tracker = this.fanInTrackers.get(key); + if (!tracker) return; + + const completedNames = [...tracker.values()].map((c) => c.sessionName); + const completedIds = [...tracker.keys()]; + + // Determine which sources haven't completed yet + const allSessions = this.deps.getSessions(); + const timedOutSources = sources.filter((src) => { + const session = allSessions.find((s) => s.name === src || s.id === src); + const sessionId = session?.id ?? src; + return !completedIds.includes(sessionId) && !completedIds.includes(src); + }); + + if (state.config.settings.timeout_on_fail === 'continue') { + // Fire with partial data + const completions = [...tracker.values()]; + this.fanInTrackers.delete(key); + + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'agent.completed', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { + completedSessions: completions.map((c) => c.sessionId), + timedOutSessions: timedOutSources, + sourceSession: completions.map((c) => c.sessionName).join(', '), + sourceOutput: completions.map((c) => c.output).join('\n---\n'), + partial: true, + }, + }; + this.deps.onLog( + 'cue', + `[CUE] Fan-in "${sub.name}" timed out (continue mode) — firing with ${completedNames.length}/${sources.length} sources` + ); + this.dispatchSubscription(ownerSessionId, sub, event, completedNames.join(', ')); + } else { + // 'break' mode — log failure and clear + this.fanInTrackers.delete(key); + this.deps.onLog( + 'cue', + `[CUE] Fan-in "${sub.name}" timed out (break mode) — ${completedNames.length}/${sources.length} completed, waiting for: ${timedOutSources.join(', ')}` + ); + } + } + + private initSession(session: SessionInfo): void { + if (!this.enabled) return; + + const config = loadCueConfig(session.projectRoot); + if (!config) return; + + const state: SessionState = { + config, + timers: [], + watchers: [], + yamlWatcher: null, + nextTriggers: new Map(), + }; + + // Watch the YAML file for changes (hot reload) + state.yamlWatcher = watchCueYaml(session.projectRoot, () => { + this.refreshSession(session.id, session.projectRoot); + }); + + // Set up subscriptions + for (const sub of config.subscriptions) { + if (sub.enabled === false) continue; + + if (sub.event === 'time.interval' && sub.interval_minutes) { + this.setupTimerSubscription(session, state, sub); + } else if (sub.event === 'file.changed' && sub.watch) { + this.setupFileWatcherSubscription(session, state, sub); + } else if (sub.event === 'task.pending' && sub.watch) { + this.setupTaskScannerSubscription(session, state, sub); + } else if (sub.event === 'github.pull_request' || sub.event === 'github.issue') { + this.setupGitHubPollerSubscription(session, state, sub); + } + // agent.completed subscriptions are handled reactively via notifyAgentCompleted + } + + this.sessions.set(session.id, state); + this.deps.onLog( + 'cue', + `[CUE] Initialized session "${session.name}" with ${config.subscriptions.filter((s) => s.enabled !== false).length} active subscription(s)` + ); + } + + private setupTimerSubscription( + session: SessionInfo, + state: SessionState, + sub: { + name: string; + prompt: string; + interval_minutes?: number; + filter?: Record; + } + ): void { + const intervalMs = (sub.interval_minutes ?? 0) * 60 * 1000; + if (intervalMs <= 0) return; + + // Fire immediately on first setup + const immediateEvent: CueEvent = { + id: crypto.randomUUID(), + type: 'time.interval', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { interval_minutes: sub.interval_minutes }, + }; + + // Check payload filter (even for timer events) + if (!sub.filter || matchesFilter(immediateEvent.payload, sub.filter)) { + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (time.interval, initial)`); + this.executeCueRun(session.id, sub.prompt, immediateEvent, sub.name); + } else { + this.deps.onLog( + 'cue', + `[CUE] "${sub.name}" filter not matched (${describeFilter(sub.filter)})` + ); + } + + // Then on the interval + const timer = setInterval(() => { + if (!this.enabled) return; + + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'time.interval', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { interval_minutes: sub.interval_minutes }, + }; + + // Check payload filter + if (sub.filter && !matchesFilter(event.payload, sub.filter)) { + this.deps.onLog( + 'cue', + `[CUE] "${sub.name}" filter not matched (${describeFilter(sub.filter)})` + ); + return; + } + + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (time.interval)`); + state.lastTriggered = event.timestamp; + state.nextTriggers.set(sub.name, Date.now() + intervalMs); + this.executeCueRun(session.id, sub.prompt, event, sub.name); + }, intervalMs); + + state.nextTriggers.set(sub.name, Date.now() + intervalMs); + state.timers.push(timer); + } + + private setupFileWatcherSubscription( + session: SessionInfo, + state: SessionState, + sub: { + name: string; + prompt: string; + watch?: string; + filter?: Record; + } + ): void { + if (!sub.watch) return; + + const cleanup = createCueFileWatcher({ + watchGlob: sub.watch, + projectRoot: session.projectRoot, + debounceMs: DEFAULT_FILE_DEBOUNCE_MS, + triggerName: sub.name, + onEvent: (event) => { + if (!this.enabled) return; + + // Check payload filter + if (sub.filter && !matchesFilter(event.payload, sub.filter)) { + this.deps.onLog( + 'cue', + `[CUE] "${sub.name}" filter not matched (${describeFilter(sub.filter)})` + ); + return; + } + + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (file.changed)`); + state.lastTriggered = event.timestamp; + this.executeCueRun(session.id, sub.prompt, event, sub.name); + }, + }); + + state.watchers.push(cleanup); + } + + private setupGitHubPollerSubscription( + session: SessionInfo, + state: SessionState, + sub: CueSubscription + ): void { + const cleanup = createCueGitHubPoller({ + eventType: sub.event as 'github.pull_request' | 'github.issue', + repo: sub.repo, + pollMinutes: sub.poll_minutes ?? 5, + projectRoot: session.projectRoot, + triggerName: sub.name, + subscriptionId: `${session.id}:${sub.name}`, + onLog: (level, message) => this.deps.onLog(level as MainLogLevel, message), + onEvent: (event) => { + if (!this.enabled) return; + + // Check payload filter + if (sub.filter && !matchesFilter(event.payload, sub.filter)) { + this.deps.onLog( + 'cue', + `[CUE] "${sub.name}" filter not matched (${describeFilter(sub.filter)})` + ); + return; + } + + this.deps.onLog('cue', `[CUE] "${sub.name}" triggered (${sub.event})`); + state.lastTriggered = event.timestamp; + this.executeCueRun(session.id, sub.prompt, event, sub.name); + }, + }); + + state.watchers.push(cleanup); + } + + private setupTaskScannerSubscription( + session: SessionInfo, + state: SessionState, + sub: CueSubscription + ): void { + if (!sub.watch) return; + + const cleanup = createCueTaskScanner({ + watchGlob: sub.watch, + pollMinutes: sub.poll_minutes ?? 1, + projectRoot: session.projectRoot, + triggerName: sub.name, + onLog: (level, message) => this.deps.onLog(level as MainLogLevel, message), + onEvent: (event) => { + if (!this.enabled) return; + + // Check payload filter + if (sub.filter && !matchesFilter(event.payload, sub.filter)) { + this.deps.onLog( + 'cue', + `[CUE] "${sub.name}" filter not matched (${describeFilter(sub.filter)})` + ); + return; + } + + this.deps.onLog( + 'cue', + `[CUE] "${sub.name}" triggered (task.pending: ${event.payload.taskCount} task(s) in ${event.payload.filename})` + ); + state.lastTriggered = event.timestamp; + this.executeCueRun(session.id, sub.prompt, event, sub.name); + }, + }); + + state.watchers.push(cleanup); + } + + /** + * Gate for concurrency control. Checks if a slot is available for this session. + * If at limit, queues the event. Otherwise dispatches immediately. + */ + private executeCueRun( + sessionId: string, + prompt: string, + event: CueEvent, + subscriptionName: string + ): void { + // Look up the config for this session to get concurrency settings + const state = this.sessions.get(sessionId); + const maxConcurrent = state?.config.settings.max_concurrent ?? 1; + const queueSize = state?.config.settings.queue_size ?? 10; + const currentCount = this.activeRunCount.get(sessionId) ?? 0; + + if (currentCount >= maxConcurrent) { + // At concurrency limit — queue the event + const sessionName = + this.deps.getSessions().find((s) => s.id === sessionId)?.name ?? sessionId; + if (!this.eventQueue.has(sessionId)) { + this.eventQueue.set(sessionId, []); + } + const queue = this.eventQueue.get(sessionId)!; + + if (queue.length >= queueSize) { + // Drop the oldest entry + queue.shift(); + this.deps.onLog('cue', `[CUE] Queue full for "${sessionName}", dropping oldest event`); + } + + queue.push({ + event, + subscription: { name: subscriptionName, event: event.type, enabled: true, prompt }, + prompt, + subscriptionName, + queuedAt: Date.now(), + }); + + this.deps.onLog( + 'cue', + `[CUE] Event queued for "${sessionName}" (${queue.length}/${queueSize} in queue, ${currentCount}/${maxConcurrent} concurrent)` + ); + return; + } + + // Slot available — dispatch immediately + this.activeRunCount.set(sessionId, currentCount + 1); + this.doExecuteCueRun(sessionId, prompt, event, subscriptionName); + } + + /** + * Actually executes a Cue run. Called when a concurrency slot is available. + */ + private async doExecuteCueRun( + sessionId: string, + prompt: string, + event: CueEvent, + subscriptionName: string + ): Promise { + const session = this.deps.getSessions().find((s) => s.id === sessionId); + const runId = crypto.randomUUID(); + const abortController = new AbortController(); + + const result: CueRunResult = { + runId, + sessionId, + sessionName: session?.name ?? 'Unknown', + subscriptionName, + event, + status: 'running', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: '', + }; + + this.activeRuns.set(runId, { result, abortController }); + + try { + const runResult = await this.deps.onCueRun(sessionId, prompt, event); + result.status = runResult.status; + result.stdout = runResult.stdout; + result.stderr = runResult.stderr; + result.exitCode = runResult.exitCode; + } catch (error) { + result.status = 'failed'; + result.stderr = error instanceof Error ? error.message : String(error); + } finally { + result.endedAt = new Date().toISOString(); + result.durationMs = Date.now() - new Date(result.startedAt).getTime(); + this.activeRuns.delete(runId); + this.pushActivityLog(result); + + // Decrement active run count and drain queue + const count = this.activeRunCount.get(sessionId) ?? 1; + this.activeRunCount.set(sessionId, Math.max(0, count - 1)); + this.drainQueue(sessionId); + + // Emit completion event for agent completion chains + // This allows downstream subscriptions to react to this Cue run's completion + this.notifyAgentCompleted(sessionId, { + sessionName: result.sessionName, + status: result.status, + exitCode: result.exitCode, + durationMs: result.durationMs, + stdout: result.stdout, + triggeredBy: subscriptionName, + }); + } + } + + /** + * Drain the event queue for a session, dispatching events while slots are available. + * Drops stale events that have exceeded the timeout. + */ + private drainQueue(sessionId: string): void { + const queue = this.eventQueue.get(sessionId); + if (!queue || queue.length === 0) return; + + const state = this.sessions.get(sessionId); + const maxConcurrent = state?.config.settings.max_concurrent ?? 1; + const timeoutMs = (state?.config.settings.timeout_minutes ?? 30) * 60 * 1000; + const sessionName = this.deps.getSessions().find((s) => s.id === sessionId)?.name ?? sessionId; + + while (queue.length > 0) { + const currentCount = this.activeRunCount.get(sessionId) ?? 0; + if (currentCount >= maxConcurrent) break; + + const entry = queue.shift()!; + const ageMs = Date.now() - entry.queuedAt; + + // Check for stale events + if (ageMs > timeoutMs) { + const ageMinutes = Math.round(ageMs / 60000); + this.deps.onLog( + 'cue', + `[CUE] Dropping stale queued event for "${sessionName}" (queued ${ageMinutes}m ago)` + ); + continue; + } + + // Dispatch the queued event + this.activeRunCount.set(sessionId, currentCount + 1); + this.doExecuteCueRun(sessionId, entry.prompt, entry.event, entry.subscriptionName); + } + + // Clean up empty queue + if (queue.length === 0) { + this.eventQueue.delete(sessionId); + } + } + + private pushActivityLog(result: CueRunResult): void { + this.activityLog.push(result); + if (this.activityLog.length > ACTIVITY_LOG_MAX) { + this.activityLog = this.activityLog.slice(-ACTIVITY_LOG_MAX); + } + } + + private teardownSession(sessionId: string): void { + const state = this.sessions.get(sessionId); + if (!state) return; + + for (const timer of state.timers) { + clearInterval(timer); + } + for (const cleanup of state.watchers) { + cleanup(); + } + if (state.yamlWatcher) { + state.yamlWatcher(); + } + + // Clean up fan-in trackers and timers for this session + this.clearFanInState(sessionId); + } + + // --- Heartbeat & Sleep Detection --- + + private startHeartbeat(): void { + this.stopHeartbeat(); + try { + updateHeartbeat(); + } catch { + // Non-fatal if DB not ready + } + this.heartbeatInterval = setInterval(() => { + try { + updateHeartbeat(); + } catch { + // Non-fatal + } + }, HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + + /** + * Check the last heartbeat to detect if the machine slept. + * If a gap >= SLEEP_THRESHOLD_MS is found, run the reconciler. + */ + private detectSleepAndReconcile(): void { + try { + const lastHeartbeat = getLastHeartbeat(); + if (lastHeartbeat === null) return; // First ever start — nothing to reconcile + + const now = Date.now(); + const gapMs = now - lastHeartbeat; + + if (gapMs < SLEEP_THRESHOLD_MS) return; + + const gapMinutes = Math.round(gapMs / 60_000); + this.deps.onLog( + 'cue', + `[CUE] Sleep detected (gap: ${gapMinutes}m). Reconciling missed events.` + ); + + // Build session info map for the reconciler + const reconcileSessions = new Map(); + const allSessions = this.deps.getSessions(); + for (const [sessionId, state] of this.sessions) { + const session = allSessions.find((s) => s.id === sessionId); + reconcileSessions.set(sessionId, { + config: state.config, + sessionName: session?.name ?? sessionId, + }); + } + + reconcileMissedTimeEvents({ + sleepStartMs: lastHeartbeat, + wakeTimeMs: now, + sessions: reconcileSessions, + onDispatch: (sessionId, sub, event) => { + this.executeCueRun(sessionId, sub.prompt, event, sub.name); + }, + onLog: (level, message) => { + this.deps.onLog(level as MainLogLevel, message); + }, + }); + } catch (error) { + this.deps.onLog('warn', `[CUE] Sleep detection failed: ${error}`); + } + } +} diff --git a/src/main/cue/cue-executor.ts b/src/main/cue/cue-executor.ts new file mode 100644 index 000000000..0d12f991e --- /dev/null +++ b/src/main/cue/cue-executor.ts @@ -0,0 +1,440 @@ +/** + * Cue Executor — spawns background agent processes when Cue triggers fire. + * + * Reads prompt files, substitutes Cue-specific template variables, spawns the + * agent process, captures output, enforces timeouts, and records history entries. + * Follows the same spawn pattern as Auto Run (via process:spawn IPC handler). + */ + +import { spawn, type ChildProcess } from 'child_process'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { CueEvent, CueRunResult, CueRunStatus, CueSubscription } from './cue-types'; +import type { HistoryEntry, SessionInfo } from '../../shared/types'; +import { substituteTemplateVariables, type TemplateContext } from '../../shared/templateVariables'; +import { getAgentDefinition, getAgentCapabilities } from '../agents'; +import { buildAgentArgs, applyAgentConfigOverrides } from '../utils/agent-args'; +import { wrapSpawnWithSsh, type SshSpawnWrapConfig } from '../utils/ssh-spawn-wrapper'; +import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; + +const SIGKILL_DELAY_MS = 5000; +const MAX_HISTORY_RESPONSE_LENGTH = 10000; + +/** Configuration for executing a Cue-triggered prompt */ +export interface CueExecutionConfig { + runId: string; + session: SessionInfo; + subscription: CueSubscription; + event: CueEvent; + promptPath: string; + toolType: string; + projectRoot: string; + templateContext: TemplateContext; + timeoutMs: number; + sshRemoteConfig?: { enabled: boolean; remoteId: string | null }; + customPath?: string; + customArgs?: string; + customEnvVars?: Record; + customModel?: string; + onLog: (level: string, message: string) => void; + /** Optional SSH settings store for SSH remote execution */ + sshStore?: SshRemoteSettingsStore; + /** Optional agent-level config values (from agent config store) */ + agentConfigValues?: Record; +} + +/** Map of active Cue processes by runId */ +const activeProcesses = new Map(); + +/** + * Execute a Cue-triggered prompt by spawning an agent process. + * + * Steps: + * 1. Resolve and read the prompt file + * 2. Populate template context with Cue event data + * 3. Substitute template variables + * 4. Build agent spawn args (same pattern as process:spawn) + * 5. Apply SSH wrapping if configured + * 6. Spawn the process, capture stdout/stderr + * 7. Enforce timeout with SIGTERM → SIGKILL escalation + * 8. Return CueRunResult + */ +export async function executeCuePrompt(config: CueExecutionConfig): Promise { + const { + runId, + session, + subscription, + event, + promptPath, + toolType, + projectRoot, + templateContext, + timeoutMs, + sshRemoteConfig, + customPath, + customArgs, + customEnvVars, + customModel, + onLog, + sshStore, + agentConfigValues, + } = config; + + const startedAt = new Date().toISOString(); + const startTime = Date.now(); + + // 1. Resolve the prompt path + const resolvedPath = path.isAbsolute(promptPath) + ? promptPath + : path.join(projectRoot, promptPath); + + // 2. Read the prompt file + let promptContent: string; + try { + promptContent = fs.readFileSync(resolvedPath, 'utf-8'); + } catch (error) { + const message = `Failed to read prompt file: ${resolvedPath} - ${error instanceof Error ? error.message : String(error)}`; + onLog('error', message); + return { + runId, + sessionId: session.id, + sessionName: session.name, + subscriptionName: subscription.name, + event, + status: 'failed', + stdout: '', + stderr: message, + exitCode: null, + durationMs: Date.now() - startTime, + startedAt, + endedAt: new Date().toISOString(), + }; + } + + // 3. Populate the template context with Cue event data + templateContext.cue = { + eventType: event.type, + eventTimestamp: event.timestamp, + triggerName: subscription.name, + runId, + filePath: String(event.payload.path ?? ''), + fileName: String(event.payload.filename ?? ''), + fileDir: String(event.payload.directory ?? ''), + fileExt: String(event.payload.extension ?? ''), + fileChangeType: String(event.payload.changeType ?? ''), + sourceSession: String(event.payload.sourceSession ?? ''), + sourceOutput: String(event.payload.sourceOutput ?? ''), + sourceStatus: String(event.payload.status ?? ''), + sourceExitCode: String(event.payload.exitCode ?? ''), + sourceDuration: String(event.payload.durationMs ?? ''), + sourceTriggeredBy: String(event.payload.triggeredBy ?? ''), + }; + + // Populate task.pending-specific template context + if (event.type === 'task.pending') { + templateContext.cue = { + ...templateContext.cue, + taskFile: String(event.payload.path ?? ''), + taskFileName: String(event.payload.filename ?? ''), + taskFileDir: String(event.payload.directory ?? ''), + taskCount: String(event.payload.taskCount ?? '0'), + taskList: String(event.payload.taskList ?? ''), + taskContent: String(event.payload.content ?? ''), + }; + } + + // Populate GitHub-specific template context + if (event.type === 'github.pull_request' || event.type === 'github.issue') { + templateContext.cue = { + ...templateContext.cue, + ghType: String(event.payload.type ?? ''), + ghNumber: String(event.payload.number ?? ''), + ghTitle: String(event.payload.title ?? ''), + ghAuthor: String(event.payload.author ?? ''), + ghUrl: String(event.payload.url ?? ''), + ghBody: String(event.payload.body ?? ''), + ghLabels: String(event.payload.labels ?? ''), + ghState: String(event.payload.state ?? ''), + ghRepo: String(event.payload.repo ?? ''), + ghBranch: String(event.payload.head_branch ?? ''), + ghBaseBranch: String(event.payload.base_branch ?? ''), + ghAssignees: String(event.payload.assignees ?? ''), + }; + } + + // 4. Substitute template variables + const substitutedPrompt = substituteTemplateVariables(promptContent, templateContext); + + // 5. Look up agent definition and build args + const agentDef = getAgentDefinition(toolType); + if (!agentDef) { + const message = `Unknown agent type: ${toolType}`; + onLog('error', message); + return { + runId, + sessionId: session.id, + sessionName: session.name, + subscriptionName: subscription.name, + event, + status: 'failed', + stdout: '', + stderr: message, + exitCode: null, + durationMs: Date.now() - startTime, + startedAt, + endedAt: new Date().toISOString(), + }; + } + + // Build args following the same pipeline as process:spawn + // Cast to AgentConfig-like shape with available/path/capabilities for buildAgentArgs + const agentConfig = { + ...agentDef, + available: true, + path: customPath || agentDef.command, + capabilities: getAgentCapabilities(toolType), + }; + + let finalArgs = buildAgentArgs(agentConfig, { + baseArgs: agentDef.args, + prompt: substitutedPrompt, + cwd: projectRoot, + yoloMode: true, // Cue runs always use YOLO mode like Auto Run + }); + + // Apply config overrides (custom model, custom args, custom env vars) + const configResolution = applyAgentConfigOverrides(agentConfig, finalArgs, { + agentConfigValues: (agentConfigValues ?? {}) as Record, + sessionCustomModel: customModel, + sessionCustomArgs: customArgs, + sessionCustomEnvVars: customEnvVars, + }); + finalArgs = configResolution.args; + const effectiveEnvVars = configResolution.effectiveCustomEnvVars; + + // Determine the command to use + let command = customPath || agentDef.command; + + // 6. Apply SSH wrapping if configured + let spawnArgs = finalArgs; + let spawnCwd = projectRoot; + let spawnEnvVars = effectiveEnvVars; + let prompt: string | undefined = substitutedPrompt; + + let sendPromptViaStdin = false; + + if (sshRemoteConfig?.enabled) { + if (!sshStore) { + const message = `SSH is enabled for session "${session.name}" but SSH settings store is unavailable`; + onLog('error', message); + return { + runId, + sessionId: session.id, + sessionName: session.name, + subscriptionName: subscription.name, + event, + status: 'failed', + stdout: '', + stderr: message, + exitCode: null, + durationMs: Date.now() - startTime, + startedAt, + endedAt: new Date().toISOString(), + }; + } + + const sshWrapConfig: SshSpawnWrapConfig = { + command, + args: finalArgs, + cwd: projectRoot, + prompt: substitutedPrompt, + customEnvVars: effectiveEnvVars, + agentBinaryName: agentDef.binaryName, + promptArgs: agentDef.promptArgs, + noPromptSeparator: agentDef.noPromptSeparator, + }; + + const sshResult = await wrapSpawnWithSsh(sshWrapConfig, sshRemoteConfig, sshStore); + command = sshResult.command; + spawnArgs = sshResult.args; + spawnCwd = sshResult.cwd; + spawnEnvVars = sshResult.customEnvVars; + prompt = sshResult.prompt; + sendPromptViaStdin = Boolean(sshResult.prompt); + + if (sshResult.sshRemoteUsed) { + onLog( + 'cue', + `[CUE] Using SSH remote: ${sshResult.sshRemoteUsed.name || sshResult.sshRemoteUsed.host}` + ); + } + } + + // 7. Spawn the process + onLog('cue', `[CUE] Executing run ${runId}: "${subscription.name}" → ${command} (${event.type})`); + + return new Promise((resolve) => { + const env = { + ...process.env, + ...(spawnEnvVars || {}), + }; + + const child = spawn(command, spawnArgs, { + cwd: spawnCwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + activeProcesses.set(runId, child); + + let stdout = ''; + let stderr = ''; + let settled = false; + let timeoutTimer: ReturnType | undefined; + let killTimer: ReturnType | undefined; + + const finish = (status: CueRunStatus, exitCode: number | null) => { + if (settled) return; + settled = true; + + activeProcesses.delete(runId); + if (timeoutTimer) clearTimeout(timeoutTimer); + if (killTimer) clearTimeout(killTimer); + + resolve({ + runId, + sessionId: session.id, + sessionName: session.name, + subscriptionName: subscription.name, + event, + status, + stdout, + stderr, + exitCode, + durationMs: Date.now() - startTime, + startedAt, + endedAt: new Date().toISOString(), + }); + }; + + // Capture stdout + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (data: string) => { + stdout += data; + }); + + // Capture stderr + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (data: string) => { + stderr += data; + }); + + // Handle process exit + child.on('close', (code) => { + const status: CueRunStatus = code === 0 ? 'completed' : 'failed'; + finish(status, code); + }); + + // Handle spawn errors + child.on('error', (error) => { + stderr += `\nSpawn error: ${error.message}`; + finish('failed', null); + }); + + // Write prompt to stdin if not embedded in args + // For agents with promptArgs (like OpenCode -p), the prompt is in the args + // For others (like Claude --print), if prompt was passed via args separator, skip stdin + // When SSH wrapping returns a prompt, it means "send via stdin" + if (prompt && sendPromptViaStdin) { + // SSH large prompt mode — send via stdin + child.stdin?.write(prompt); + child.stdin?.end(); + } else { + // Local mode — prompt is already in the args (via buildAgentArgs) + child.stdin?.end(); + } + + // 8. Enforce timeout + if (timeoutMs > 0) { + timeoutTimer = setTimeout(() => { + if (settled) return; + onLog('cue', `[CUE] Run ${runId} timed out after ${timeoutMs}ms, sending SIGTERM`); + child.kill('SIGTERM'); + + // Escalate to SIGKILL after delay + killTimer = setTimeout(() => { + if (settled) return; + onLog('cue', `[CUE] Run ${runId} still alive, sending SIGKILL`); + child.kill('SIGKILL'); + }, SIGKILL_DELAY_MS); + + // If the process exits after SIGTERM, mark as timeout + child.removeAllListeners('close'); + child.on('close', (code) => { + finish('timeout', code); + }); + }, timeoutMs); + } + }); +} + +/** + * Stop a running Cue process by runId. + * Sends SIGTERM, then SIGKILL after 5 seconds. + * + * @returns true if the process was found and signaled, false if not found + */ +export function stopCueRun(runId: string): boolean { + const child = activeProcesses.get(runId); + if (!child) return false; + + child.kill('SIGTERM'); + + // Escalate to SIGKILL after delay if process hasn't exited + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + }, SIGKILL_DELAY_MS); + + return true; +} + +/** + * Get the map of currently active processes (for testing/monitoring). + */ +export function getActiveProcesses(): Map { + return activeProcesses; +} + +/** + * Construct a HistoryEntry for a completed Cue run. + * + * Follows the same pattern as Auto Run's history recording with type: 'AUTO', + * but uses type: 'CUE' and populates Cue-specific fields. + */ +export function recordCueHistoryEntry(result: CueRunResult, session: SessionInfo): HistoryEntry { + const fullResponse = + result.stdout.length > MAX_HISTORY_RESPONSE_LENGTH + ? result.stdout.substring(0, MAX_HISTORY_RESPONSE_LENGTH) + : result.stdout; + + return { + id: crypto.randomUUID(), + type: 'CUE', + timestamp: Date.now(), + summary: `[CUE] "${result.subscriptionName}" (${result.event.type})`, + fullResponse: fullResponse || undefined, + projectPath: session.projectRoot || session.cwd, + sessionId: session.id, + sessionName: session.name, + success: result.status === 'completed', + elapsedTimeMs: result.durationMs, + cueTriggerName: result.subscriptionName, + cueEventType: result.event.type, + cueSourceSession: result.event.payload.sourceSession + ? String(result.event.payload.sourceSession) + : undefined, + }; +} diff --git a/src/main/cue/cue-file-watcher.ts b/src/main/cue/cue-file-watcher.ts new file mode 100644 index 000000000..e37825442 --- /dev/null +++ b/src/main/cue/cue-file-watcher.ts @@ -0,0 +1,82 @@ +/** + * File watcher provider for Maestro Cue file.changed subscriptions. + * + * Wraps chokidar to watch glob patterns with per-file debouncing + * and produces CueEvent instances for the engine. + */ + +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as chokidar from 'chokidar'; +import type { CueEvent } from './cue-types'; + +export interface CueFileWatcherConfig { + watchGlob: string; + projectRoot: string; + debounceMs: number; + onEvent: (event: CueEvent) => void; + triggerName: string; +} + +/** + * Creates a chokidar file watcher for a Cue file.changed subscription. + * Returns a cleanup function to stop watching. + */ +export function createCueFileWatcher(config: CueFileWatcherConfig): () => void { + const { watchGlob, projectRoot, debounceMs, onEvent, triggerName } = config; + const debounceTimers = new Map>(); + + const watcher = chokidar.watch(watchGlob, { + cwd: projectRoot, + ignoreInitial: true, + persistent: true, + }); + + const handleEvent = (changeType: 'change' | 'add' | 'unlink') => (filePath: string) => { + const existingTimer = debounceTimers.get(filePath); + if (existingTimer) { + clearTimeout(existingTimer); + } + + debounceTimers.set( + filePath, + setTimeout(() => { + debounceTimers.delete(filePath); + + const absolutePath = path.resolve(projectRoot, filePath); + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'file.changed', + timestamp: new Date().toISOString(), + triggerName, + payload: { + path: absolutePath, + filename: path.basename(filePath), + directory: path.dirname(absolutePath), + extension: path.extname(filePath), + changeType, + }, + }; + + onEvent(event); + }, debounceMs) + ); + }; + + watcher.on('change', handleEvent('change')); + watcher.on('add', handleEvent('add')); + watcher.on('unlink', handleEvent('unlink')); + + watcher.on('error', (error) => { + // Log but don't crash — the parent engine will handle logging + console.error(`[CUE] File watcher error for "${triggerName}":`, error); + }); + + return () => { + for (const timer of debounceTimers.values()) { + clearTimeout(timer); + } + debounceTimers.clear(); + watcher.close(); + }; +} diff --git a/src/main/cue/cue-filter.ts b/src/main/cue/cue-filter.ts new file mode 100644 index 000000000..b208b9212 --- /dev/null +++ b/src/main/cue/cue-filter.ts @@ -0,0 +1,113 @@ +/** + * Filter matching engine for Maestro Cue event payload filtering. + * + * Evaluates filter expressions against event payloads. Supports exact match, + * negation, numeric comparison, glob patterns, and boolean matching. + * All filter conditions are AND'd — every condition must pass. + */ + +import picomatch from 'picomatch'; + +/** Convert a value to a finite number, returning null if not representable. */ +function toFiniteNumber(value: unknown): number | null { + const n = typeof value === 'number' ? value : Number(value); + return Number.isFinite(n) ? n : null; +} + +/** + * Resolve a dot-notation key to a value in a nested object. + * e.g., "source.status" accesses payload.source.status + */ +function resolveKey(obj: Record, key: string): unknown { + return key.split('.').reduce((acc, part) => { + if (acc !== null && acc !== undefined && typeof acc === 'object') { + return (acc as Record)[part]; + } + return undefined; + }, obj); +} + +/** + * Returns true if the payload matches ALL filter conditions. + * Each filter key is a payload field name (supports dot-notation for nested access). + * Each filter value is an expression evaluated against the payload field. + */ +export function matchesFilter( + payload: Record, + filter: Record +): boolean { + for (const [key, filterValue] of Object.entries(filter)) { + const payloadValue = resolveKey(payload, key); + + // Field must exist + if (payloadValue === undefined) return false; + + if (typeof filterValue === 'boolean') { + if (payloadValue !== filterValue) return false; + } else if (typeof filterValue === 'number') { + if (payloadValue !== filterValue) return false; + } else { + // String filter expression + if (filterValue.startsWith('>=')) { + const threshold = toFiniteNumber(filterValue.slice(2)); + const current = toFiniteNumber(payloadValue); + if (threshold === null || current === null || current < threshold) return false; + } else if (filterValue.startsWith('<=')) { + const threshold = toFiniteNumber(filterValue.slice(2)); + const current = toFiniteNumber(payloadValue); + if (threshold === null || current === null || current > threshold) return false; + } else if (filterValue.startsWith('>')) { + const threshold = toFiniteNumber(filterValue.slice(1)); + const current = toFiniteNumber(payloadValue); + if (threshold === null || current === null || current <= threshold) return false; + } else if (filterValue.startsWith('<')) { + const threshold = toFiniteNumber(filterValue.slice(1)); + const current = toFiniteNumber(payloadValue); + if (threshold === null || current === null || current >= threshold) return false; + } else if (filterValue.startsWith('!')) { + const remainder = filterValue.slice(1); + if (String(payloadValue) === remainder) return false; + } else if (filterValue.includes('*')) { + const isMatch = picomatch(filterValue); + if (!isMatch(String(payloadValue))) return false; + } else { + // Plain string — exact match + if (String(payloadValue) !== filterValue) return false; + } + } + } + + return true; +} + +/** + * Returns a human-readable description of a filter for logging. + * e.g., 'path matches *.ts AND status != archived' + */ +export function describeFilter(filter: Record): string { + const parts: string[] = []; + + for (const [key, value] of Object.entries(filter)) { + if (typeof value === 'boolean') { + parts.push(`${key} is ${value}`); + } else if (typeof value === 'number') { + parts.push(`${key} == ${value}`); + } else if (value.startsWith('>=')) { + parts.push(`${key} >= ${value.slice(2)}`); + } else if (value.startsWith('<=')) { + parts.push(`${key} <= ${value.slice(2)}`); + } else if (value.startsWith('>')) { + parts.push(`${key} > ${value.slice(1)}`); + } else if (value.startsWith('<')) { + parts.push(`${key} < ${value.slice(1)}`); + } else if (value.startsWith('!')) { + parts.push(`${key} != ${value.slice(1)}`); + } else if (value.includes('*')) { + parts.push(`${key} matches ${value}`); + } else { + parts.push(`${key} == "${value}"`); + } + } + + return parts.join(' AND '); +} diff --git a/src/main/cue/cue-github-poller.ts b/src/main/cue/cue-github-poller.ts new file mode 100644 index 000000000..1adc7ac8f --- /dev/null +++ b/src/main/cue/cue-github-poller.ts @@ -0,0 +1,270 @@ +/** + * GitHub poller provider for Maestro Cue github.pull_request and github.issue subscriptions. + * + * Polls GitHub CLI (`gh`) for new PRs/issues, tracks "seen" state in SQLite, + * and fires CueEvents for new items. Follows the same factory pattern as cue-file-watcher.ts. + */ + +import { execFile as cpExecFile } from 'child_process'; +import * as crypto from 'crypto'; +import type { CueEvent } from './cue-types'; +import { isGitHubItemSeen, markGitHubItemSeen, hasAnyGitHubSeen, pruneGitHubSeen } from './cue-db'; + +function execFileAsync( + cmd: string, + args: string[], + opts?: { cwd?: string; timeout?: number } +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + cpExecFile(cmd, args, opts ?? {}, (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve({ stdout: stdout.toString(), stderr: stderr.toString() }); + } + }); + }); +} + +export interface CueGitHubPollerConfig { + eventType: 'github.pull_request' | 'github.issue'; + repo?: string; + pollMinutes: number; + projectRoot: string; + onEvent: (event: CueEvent) => void; + onLog: (level: string, message: string) => void; + triggerName: string; + subscriptionId: string; +} + +/** + * Creates a GitHub poller for a Cue subscription. + * Returns a cleanup function to stop polling. + */ +export function createCueGitHubPoller(config: CueGitHubPollerConfig): () => void { + const { eventType, pollMinutes, projectRoot, onEvent, onLog, triggerName, subscriptionId } = + config; + + let stopped = false; + let initialTimeout: ReturnType | null = null; + let pollInterval: ReturnType | null = null; + let pruneInterval: ReturnType | null = null; + + // Cached state + let ghAvailable: boolean | null = null; + let resolvedRepo: string | null = config.repo ?? null; + + async function checkGhAvailable(): Promise { + if (ghAvailable !== null) return ghAvailable; + try { + await execFileAsync('gh', ['--version']); + ghAvailable = true; + } catch { + ghAvailable = false; + onLog('warn', `[CUE] GitHub CLI (gh) not found — skipping "${triggerName}"`); + } + return ghAvailable; + } + + async function resolveRepo(): Promise { + if (resolvedRepo) return resolvedRepo; + try { + const { stdout } = await execFileAsync( + 'gh', + ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], + { cwd: projectRoot, timeout: 10000 } + ); + resolvedRepo = stdout.trim(); + return resolvedRepo; + } catch { + onLog('warn', `[CUE] Could not auto-detect repo for "${triggerName}" — skipping poll`); + return null; + } + } + + async function pollPRs(repo: string): Promise { + const { stdout } = await execFileAsync( + 'gh', + [ + 'pr', + 'list', + '--repo', + repo, + '--json', + 'number,title,author,url,body,state,isDraft,labels,headRefName,baseRefName,createdAt,updatedAt', + '--state', + 'open', + '--limit', + '50', + ], + { cwd: projectRoot, timeout: 30000 } + ); + + const items = JSON.parse(stdout); + const isFirstRun = !hasAnyGitHubSeen(subscriptionId); + + for (const item of items) { + if (stopped) return; + const itemKey = `pr:${repo}:${item.number}`; + + if (isFirstRun) { + markGitHubItemSeen(subscriptionId, itemKey); + continue; + } + + if (isGitHubItemSeen(subscriptionId, itemKey)) continue; + + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'github.pull_request', + timestamp: new Date().toISOString(), + triggerName, + payload: { + type: 'pull_request', + number: item.number, + title: item.title, + author: item.author?.login ?? 'unknown', + url: item.url, + body: (item.body ?? '').slice(0, 5000), + state: item.state?.toLowerCase() ?? 'open', + draft: item.isDraft ?? false, + labels: (item.labels ?? []).map((l: { name: string }) => l.name).join(','), + head_branch: item.headRefName ?? '', + base_branch: item.baseRefName ?? '', + repo, + created_at: item.createdAt ?? '', + updated_at: item.updatedAt ?? '', + }, + }; + + onEvent(event); + markGitHubItemSeen(subscriptionId, itemKey); + } + + if (isFirstRun) { + onLog('info', `[CUE] "${triggerName}" seeded ${items.length} existing pull_request(s)`); + } + } + + async function pollIssues(repo: string): Promise { + const { stdout } = await execFileAsync( + 'gh', + [ + 'issue', + 'list', + '--repo', + repo, + '--json', + 'number,title,author,url,body,state,labels,assignees,createdAt,updatedAt', + '--state', + 'open', + '--limit', + '50', + ], + { cwd: projectRoot, timeout: 30000 } + ); + + const items = JSON.parse(stdout); + const isFirstRun = !hasAnyGitHubSeen(subscriptionId); + + for (const item of items) { + if (stopped) return; + const itemKey = `issue:${repo}:${item.number}`; + + if (isFirstRun) { + markGitHubItemSeen(subscriptionId, itemKey); + continue; + } + + if (isGitHubItemSeen(subscriptionId, itemKey)) continue; + + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'github.issue', + timestamp: new Date().toISOString(), + triggerName, + payload: { + type: 'issue', + number: item.number, + title: item.title, + author: item.author?.login ?? 'unknown', + url: item.url, + body: (item.body ?? '').slice(0, 5000), + state: item.state?.toLowerCase() ?? 'open', + labels: (item.labels ?? []).map((l: { name: string }) => l.name).join(','), + assignees: (item.assignees ?? []).map((a: { login: string }) => a.login).join(','), + repo, + created_at: item.createdAt ?? '', + updated_at: item.updatedAt ?? '', + }, + }; + + onEvent(event); + markGitHubItemSeen(subscriptionId, itemKey); + } + + if (isFirstRun) { + onLog('info', `[CUE] "${triggerName}" seeded ${items.length} existing issue(s)`); + } + } + + async function doPoll(): Promise { + if (stopped) return; + + try { + if (!(await checkGhAvailable())) return; + + const repo = await resolveRepo(); + if (!repo) return; + + if (eventType === 'github.pull_request') { + await pollPRs(repo); + } else { + await pollIssues(repo); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + onLog('error', `[CUE] GitHub poll error for "${triggerName}": ${message}`); + } + } + + // Initial poll after 2-second delay + initialTimeout = setTimeout(() => { + if (stopped) return; + doPoll().then(() => { + if (stopped) return; + // Start recurring poll + pollInterval = setInterval( + () => { + doPoll(); + }, + pollMinutes * 60 * 1000 + ); + }); + }, 2000); + + // Periodic prune every 24 hours (30-day retention) + pruneInterval = setInterval( + () => { + pruneGitHubSeen(30 * 24 * 60 * 60 * 1000); + }, + 24 * 60 * 60 * 1000 + ); + + // Cleanup function + return () => { + stopped = true; + if (initialTimeout) { + clearTimeout(initialTimeout); + initialTimeout = null; + } + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + if (pruneInterval) { + clearInterval(pruneInterval); + pruneInterval = null; + } + }; +} diff --git a/src/main/cue/cue-reconciler.ts b/src/main/cue/cue-reconciler.ts new file mode 100644 index 000000000..9de347b9a --- /dev/null +++ b/src/main/cue/cue-reconciler.ts @@ -0,0 +1,74 @@ +/** + * Cue Time Event Reconciler — catches up on missed time.interval events after sleep/wake. + * + * When the CueEngine detects a heartbeat gap (laptop sleep), this module calculates + * which interval subscriptions missed their scheduled runs and fires exactly one + * catch-up event per subscription (to avoid flooding the system). + * + * Does NOT reconcile file.changed or agent.completed events — file watchers re-initialize + * naturally and agent completions are durable through the fan-in tracker. + */ + +import * as crypto from 'crypto'; +import type { CueConfig, CueEvent, CueSubscription } from './cue-types'; + +export interface ReconcileSessionInfo { + config: CueConfig; + sessionName: string; +} + +export interface ReconcileConfig { + sleepStartMs: number; + wakeTimeMs: number; + sessions: Map; + onDispatch: (sessionId: string, subscription: CueSubscription, event: CueEvent) => void; + onLog: (level: string, message: string) => void; +} + +/** + * Reconcile missed time.interval events during a sleep gap. + * + * For each enabled time.interval subscription, calculates how many intervals + * were missed and fires exactly one catch-up event with metadata indicating + * how many intervals were skipped. + */ +export function reconcileMissedTimeEvents(config: ReconcileConfig): void { + const { sleepStartMs, wakeTimeMs, sessions, onDispatch, onLog } = config; + const gapMs = wakeTimeMs - sleepStartMs; + + if (gapMs <= 0) return; + + for (const [sessionId, sessionInfo] of sessions) { + for (const sub of sessionInfo.config.subscriptions) { + // Only reconcile time.interval subscriptions that are enabled + if (sub.event !== 'time.interval' || sub.enabled === false) continue; + if (!sub.interval_minutes || sub.interval_minutes <= 0) continue; + + const intervalMs = sub.interval_minutes * 60_000; + const missedCount = Math.floor(gapMs / intervalMs); + + if (missedCount === 0) continue; + + onLog( + 'cue', + `[CUE] Reconciling "${sub.name}": ${missedCount} interval(s) missed during sleep, firing catch-up` + ); + + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'time.interval', + timestamp: new Date().toISOString(), + triggerName: sub.name, + payload: { + interval_minutes: sub.interval_minutes, + reconciled: true, + missedCount, + sleepDurationMs: gapMs, + }, + }; + + // Route through normal dispatch path to respect concurrency limits + onDispatch(sessionId, sub, event); + } + } +} diff --git a/src/main/cue/cue-task-scanner.ts b/src/main/cue/cue-task-scanner.ts new file mode 100644 index 000000000..a57814be7 --- /dev/null +++ b/src/main/cue/cue-task-scanner.ts @@ -0,0 +1,194 @@ +/** + * Task scanner provider for Maestro Cue task.pending subscriptions. + * + * Polls markdown files matching a glob pattern for unchecked tasks (- [ ]), + * tracks content hashes to avoid re-triggering on unchanged files, + * and fires one CueEvent per file that has pending tasks. + * + * Follows the same factory pattern as cue-file-watcher.ts and cue-github-poller.ts. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import picomatch from 'picomatch'; +import type { CueEvent } from './cue-types'; + +export interface CueTaskScannerConfig { + watchGlob: string; + pollMinutes: number; + projectRoot: string; + onEvent: (event: CueEvent) => void; + onLog: (level: string, message: string) => void; + triggerName: string; +} + +/** A pending task extracted from a markdown file */ +export interface PendingTask { + line: number; + text: string; +} + +/** + * Parse a markdown file's content and extract all unchecked tasks. + * Returns the list of pending tasks with line numbers and text. + */ +export function extractPendingTasks(content: string): PendingTask[] { + const tasks: PendingTask[] = []; + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Match: optional whitespace, list marker (- * +), space, [ ], space, then task text + if (/^\s*[-*+]\s+\[ \]/.test(line)) { + const text = line.replace(/^\s*[-*+]\s+\[ \]\s*/, '').trim(); + if (text.length > 0) { + tasks.push({ line: i + 1, text }); + } + } + } + + return tasks; +} + +/** + * Recursively walk a directory and return all file paths (relative to root). + */ +function walkDir(dir: string, root: string): string[] { + const results: string[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + // Skip common non-content directories + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.next') { + continue; + } + results.push(...walkDir(fullPath, root)); + } else if (entry.isFile()) { + results.push(path.relative(root, fullPath)); + } + } + + return results; +} + +/** + * Creates a task scanner for a Cue task.pending subscription. + * Returns a cleanup function to stop scanning. + */ +export function createCueTaskScanner(config: CueTaskScannerConfig): () => void { + const { watchGlob, pollMinutes, projectRoot, onEvent, onLog, triggerName } = config; + + let stopped = false; + let initialTimeout: ReturnType | null = null; + let pollInterval: ReturnType | null = null; + + // Track content hashes per file to only trigger on changes + const fileHashes = new Map(); + + const isMatch = picomatch(watchGlob); + + function hashContent(content: string): string { + return crypto.createHash('sha256').update(content).digest('hex'); + } + + async function doScan(): Promise { + if (stopped) return; + + try { + const allFiles = walkDir(projectRoot, projectRoot); + const matchedFiles = allFiles.filter((f) => isMatch(f)); + + for (const relPath of matchedFiles) { + if (stopped) return; + + const absPath = path.resolve(projectRoot, relPath); + + let content: string; + try { + content = fs.readFileSync(absPath, 'utf-8'); + } catch { + continue; + } + + // Check if content changed since last scan + const hash = hashContent(content); + const prevHash = fileHashes.get(relPath); + + if (prevHash === hash) { + continue; + } + + fileHashes.set(relPath, hash); + + // On first scan, seed the hash but don't fire events + if (prevHash === undefined) { + continue; + } + + // Extract pending tasks + const pendingTasks = extractPendingTasks(content); + if (pendingTasks.length === 0) { + continue; + } + + const taskList = pendingTasks.map((t) => `L${t.line}: ${t.text}`).join('\n'); + + const event: CueEvent = { + id: crypto.randomUUID(), + type: 'task.pending', + timestamp: new Date().toISOString(), + triggerName, + payload: { + path: absPath, + filename: path.basename(relPath), + directory: path.dirname(absPath), + extension: path.extname(relPath), + taskCount: pendingTasks.length, + taskList, + tasks: pendingTasks, + content: content.slice(0, 10000), + }, + }; + + onEvent(event); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + onLog('error', `[CUE] Task scan error for "${triggerName}": ${message}`); + } + } + + // Initial scan after 2-second delay (same pattern as GitHub poller) + initialTimeout = setTimeout(() => { + if (stopped) return; + doScan().then(() => { + if (stopped) return; + pollInterval = setInterval( + () => { + doScan(); + }, + pollMinutes * 60 * 1000 + ); + }); + }, 2000); + + return () => { + stopped = true; + if (initialTimeout) { + clearTimeout(initialTimeout); + initialTimeout = null; + } + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + }; +} diff --git a/src/main/cue/cue-types.ts b/src/main/cue/cue-types.ts new file mode 100644 index 000000000..963535ad6 --- /dev/null +++ b/src/main/cue/cue-types.ts @@ -0,0 +1,119 @@ +/** + * Core type definitions for the Maestro Cue event-driven automation system. + * + * Cue triggers agent prompts in response to events: + * - time.interval: periodic timer-based triggers + * - file.changed: file system change triggers + * - agent.completed: triggers when another agent finishes + * - github.pull_request: triggers when new PRs are detected via GitHub CLI polling + * - github.issue: triggers when new issues are detected via GitHub CLI polling + * - task.pending: triggers when unchecked markdown tasks (- [ ]) are found in watched files + */ + +/** Event types that can trigger a Cue subscription */ +export type CueEventType = + | 'time.interval' + | 'file.changed' + | 'agent.completed' + | 'github.pull_request' + | 'github.issue' + | 'task.pending'; + +/** A Cue subscription defines a trigger-prompt pairing */ +export interface CueSubscription { + name: string; + event: CueEventType; + enabled: boolean; + prompt: string; + interval_minutes?: number; + watch?: string; + source_session?: string | string[]; + fan_out?: string[]; + filter?: Record; + repo?: string; + poll_minutes?: number; +} + +/** Global Cue settings */ +export interface CueSettings { + timeout_minutes: number; + timeout_on_fail: 'break' | 'continue'; + max_concurrent: number; + queue_size: number; +} + +/** Default Cue settings */ +export const DEFAULT_CUE_SETTINGS: CueSettings = { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, +}; + +/** Top-level Cue configuration (parsed from YAML) */ +export interface CueConfig { + subscriptions: CueSubscription[]; + settings: CueSettings; +} + +/** An event instance produced by a trigger */ +export interface CueEvent { + id: string; + type: CueEventType; + timestamp: string; + triggerName: string; + payload: Record; +} + +/** Status of a Cue run */ +export type CueRunStatus = 'running' | 'completed' | 'failed' | 'timeout' | 'stopped'; + +/** Result of a completed (or failed/timed-out) Cue run */ +export interface CueRunResult { + runId: string; + sessionId: string; + sessionName: string; + subscriptionName: string; + event: CueEvent; + status: CueRunStatus; + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; + startedAt: string; + endedAt: string; +} + +/** Status summary for a Cue-enabled session */ +export interface CueSessionStatus { + sessionId: string; + sessionName: string; + toolType: string; + projectRoot: string; + enabled: boolean; + subscriptionCount: number; + activeRuns: number; + lastTriggered?: string; + nextTrigger?: string; +} + +/** Data passed with an agent completion notification for chaining */ +export interface AgentCompletionData { + sessionName?: string; + status?: CueRunStatus; + exitCode?: number | null; + durationMs?: number; + stdout?: string; + triggeredBy?: string; +} + +/** Session data with subscriptions for the Cue Graph visualization */ +export interface CueGraphSession { + sessionId: string; + sessionName: string; + toolType: string; + subscriptions: CueSubscription[]; +} + +/** Default filename for Cue configuration */ +export const CUE_YAML_FILENAME = 'maestro-cue.yaml'; diff --git a/src/main/cue/cue-yaml-loader.ts b/src/main/cue/cue-yaml-loader.ts new file mode 100644 index 000000000..5ff9af37a --- /dev/null +++ b/src/main/cue/cue-yaml-loader.ts @@ -0,0 +1,303 @@ +/** + * YAML loader for Maestro Cue configuration files. + * + * Handles discovery, parsing, validation, and watching of maestro-cue.yaml files. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import * as chokidar from 'chokidar'; +import { + type CueConfig, + type CueSubscription, + type CueSettings, + DEFAULT_CUE_SETTINGS, + CUE_YAML_FILENAME, +} from './cue-types'; + +/** + * Loads and parses a maestro-cue.yaml file from the given project root. + * Returns null if the file doesn't exist. Throws on malformed YAML. + */ +export function loadCueConfig(projectRoot: string): CueConfig | null { + const filePath = path.join(projectRoot, CUE_YAML_FILENAME); + + if (!fs.existsSync(filePath)) { + return null; + } + + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = yaml.load(raw) as Record | null; + + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const subscriptions: CueSubscription[] = []; + const rawSubs = parsed.subscriptions; + if (Array.isArray(rawSubs)) { + for (const sub of rawSubs) { + if (sub && typeof sub === 'object') { + // Parse filter field: accept plain object with string/number/boolean values + let filter: Record | undefined; + if (sub.filter && typeof sub.filter === 'object' && !Array.isArray(sub.filter)) { + const filterObj: Record = {}; + let valid = true; + for (const [k, v] of Object.entries(sub.filter as Record)) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + filterObj[k] = v; + } else { + valid = false; + break; + } + } + if (valid) { + filter = filterObj; + } + } + + subscriptions.push({ + name: String(sub.name ?? ''), + event: String(sub.event ?? '') as CueSubscription['event'], + enabled: sub.enabled !== false, + prompt: String(sub.prompt ?? ''), + interval_minutes: + typeof sub.interval_minutes === 'number' ? sub.interval_minutes : undefined, + watch: typeof sub.watch === 'string' ? sub.watch : undefined, + source_session: + typeof sub.source_session === 'string' + ? sub.source_session + : Array.isArray(sub.source_session) && + sub.source_session.every((s: unknown) => typeof s === 'string') + ? sub.source_session + : undefined, + fan_out: + Array.isArray(sub.fan_out) && sub.fan_out.every((s: unknown) => typeof s === 'string') + ? sub.fan_out + : undefined, + filter, + repo: typeof sub.repo === 'string' ? sub.repo : undefined, + poll_minutes: typeof sub.poll_minutes === 'number' ? sub.poll_minutes : undefined, + }); + } + } + } + + const rawSettings = parsed.settings as Record | undefined; + const settings: CueSettings = { + timeout_minutes: + typeof rawSettings?.timeout_minutes === 'number' && + Number.isFinite(rawSettings.timeout_minutes) && + rawSettings.timeout_minutes > 0 + ? rawSettings.timeout_minutes + : DEFAULT_CUE_SETTINGS.timeout_minutes, + timeout_on_fail: + rawSettings?.timeout_on_fail === 'break' || rawSettings?.timeout_on_fail === 'continue' + ? rawSettings.timeout_on_fail + : DEFAULT_CUE_SETTINGS.timeout_on_fail, + max_concurrent: + typeof rawSettings?.max_concurrent === 'number' + ? rawSettings.max_concurrent + : DEFAULT_CUE_SETTINGS.max_concurrent, + queue_size: + typeof rawSettings?.queue_size === 'number' + ? rawSettings.queue_size + : DEFAULT_CUE_SETTINGS.queue_size, + }; + + return { subscriptions, settings }; +} + +/** + * Watches a maestro-cue.yaml file for changes. Returns a cleanup function. + * Calls onChange when the file is created, modified, or deleted. + * Debounces by 1 second. + */ +export function watchCueYaml(projectRoot: string, onChange: () => void): () => void { + const filePath = path.join(projectRoot, CUE_YAML_FILENAME); + let debounceTimer: ReturnType | null = null; + + const watcher = chokidar.watch(filePath, { + persistent: true, + ignoreInitial: true, + }); + + const debouncedOnChange = () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + onChange(); + }, 1000); + }; + + watcher.on('add', debouncedOnChange); + watcher.on('change', debouncedOnChange); + watcher.on('unlink', debouncedOnChange); + + return () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + watcher.close(); + }; +} + +/** + * Validates a CueConfig-shaped object. Returns validation result with error messages. + */ +export function validateCueConfig(config: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!config || typeof config !== 'object') { + return { valid: false, errors: ['Config must be a non-null object'] }; + } + + const cfg = config as Record; + + if (!Array.isArray(cfg.subscriptions)) { + errors.push('Config must have a "subscriptions" array'); + } else { + for (let i = 0; i < cfg.subscriptions.length; i++) { + const sub = cfg.subscriptions[i] as Record; + const prefix = `subscriptions[${i}]`; + + if (!sub || typeof sub !== 'object') { + errors.push(`${prefix}: must be an object`); + continue; + } + + if (!sub.name || typeof sub.name !== 'string') { + errors.push(`${prefix}: "name" is required and must be a string`); + } + + if (!sub.event || typeof sub.event !== 'string') { + errors.push(`${prefix}: "event" is required and must be a string`); + } + + if (!sub.prompt || typeof sub.prompt !== 'string') { + errors.push(`${prefix}: "prompt" is required and must be a non-empty string`); + } + + const event = sub.event as string; + if (event === 'time.interval') { + if (typeof sub.interval_minutes !== 'number' || sub.interval_minutes <= 0) { + errors.push( + `${prefix}: "interval_minutes" is required and must be a positive number for time.interval events` + ); + } + } else if (event === 'file.changed') { + if (!sub.watch || typeof sub.watch !== 'string') { + errors.push( + `${prefix}: "watch" is required and must be a non-empty string for file.changed events` + ); + } + } else if (event === 'agent.completed') { + if (!sub.source_session) { + errors.push(`${prefix}: "source_session" is required for agent.completed events`); + } else if (typeof sub.source_session !== 'string' && !Array.isArray(sub.source_session)) { + errors.push( + `${prefix}: "source_session" must be a string or array of strings for agent.completed events` + ); + } else if ( + Array.isArray(sub.source_session) && + !sub.source_session.every( + (s: unknown) => typeof s === 'string' && (s as string).length > 0 + ) + ) { + errors.push(`${prefix}: "source_session" array must contain only non-empty strings`); + } + } else if (event === 'task.pending') { + if (!sub.watch || typeof sub.watch !== 'string') { + errors.push( + `${prefix}: "watch" is required and must be a non-empty glob string for task.pending events` + ); + } + if (sub.poll_minutes !== undefined) { + if (typeof sub.poll_minutes !== 'number' || sub.poll_minutes < 1) { + errors.push(`${prefix}: "poll_minutes" must be a number >= 1 for task.pending events`); + } + } + } else if (event === 'github.pull_request' || event === 'github.issue') { + // repo is optional (auto-detected from git remote) + if (sub.repo !== undefined && typeof sub.repo !== 'string') { + errors.push( + `${prefix}: "repo" must be a string (e.g., "owner/repo") for ${event} events` + ); + } + if (sub.poll_minutes !== undefined) { + if (typeof sub.poll_minutes !== 'number' || sub.poll_minutes < 1) { + errors.push(`${prefix}: "poll_minutes" must be a number >= 1 for ${event} events`); + } + } + } + + // Validate filter field + if (sub.filter !== undefined) { + if (typeof sub.filter !== 'object' || sub.filter === null || Array.isArray(sub.filter)) { + errors.push(`${prefix}: "filter" must be a plain object`); + } else { + for (const [filterKey, filterVal] of Object.entries( + sub.filter as Record + )) { + if ( + typeof filterVal !== 'string' && + typeof filterVal !== 'number' && + typeof filterVal !== 'boolean' + ) { + errors.push( + `${prefix}: filter key "${filterKey}" must be a string, number, or boolean (got ${typeof filterVal})` + ); + } + } + } + } + } + } + + if (cfg.settings !== undefined) { + if (typeof cfg.settings !== 'object' || cfg.settings === null) { + errors.push('"settings" must be an object'); + } else { + const settings = cfg.settings as Record; + if (settings.timeout_minutes !== undefined) { + if ( + typeof settings.timeout_minutes !== 'number' || + !Number.isFinite(settings.timeout_minutes) || + settings.timeout_minutes <= 0 + ) { + errors.push('"settings.timeout_minutes" must be a positive number'); + } + } + if (settings.timeout_on_fail !== undefined) { + if (settings.timeout_on_fail !== 'break' && settings.timeout_on_fail !== 'continue') { + errors.push('"settings.timeout_on_fail" must be "break" or "continue"'); + } + } + if (settings.max_concurrent !== undefined) { + if ( + typeof settings.max_concurrent !== 'number' || + !Number.isInteger(settings.max_concurrent) || + settings.max_concurrent < 1 || + settings.max_concurrent > 10 + ) { + errors.push('"settings.max_concurrent" must be a positive integer between 1 and 10'); + } + } + if (settings.queue_size !== undefined) { + if ( + typeof settings.queue_size !== 'number' || + !Number.isInteger(settings.queue_size) || + settings.queue_size < 0 || + settings.queue_size > 50 + ) { + errors.push('"settings.queue_size" must be a non-negative integer between 0 and 50'); + } + } + } + } + + return { valid: errors.length === 0, errors }; +} diff --git a/src/main/index.ts b/src/main/index.ts index 0afff4436..df4f7287a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,8 @@ import crypto from 'crypto'; import { ProcessManager } from './process-manager'; import { WebServer } from './web-server'; import { AgentDetector } from './agents'; +import { CueEngine } from './cue/cue-engine'; +import type { ToolType } from '../shared/types'; import { logger } from './utils/logger'; import { tunnelManager } from './tunnel-manager'; import { powerManager } from './power-manager'; @@ -52,6 +54,7 @@ import { registerTabNamingHandlers, registerAgentErrorHandlers, registerDirectorNotesHandlers, + registerCueHandlers, registerWakatimeHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, @@ -242,6 +245,7 @@ let mainWindow: BrowserWindow | null = null; let processManager: ProcessManager | null = null; let webServer: WebServer | null = null; let agentDetector: AgentDetector | null = null; +let cueEngine: CueEngine | null = null; // Create safeSend with dependency injection (Phase 2 refactoring) const safeSend = createSafeSend(() => mainWindow); @@ -326,6 +330,92 @@ app.whenReady().then(async () => { logger.info(`Loaded custom agent paths: ${JSON.stringify(customPaths)}`, 'Startup'); } + // Initialize Cue Engine for event-driven automation + cueEngine = new CueEngine({ + getSessions: () => { + const stored = sessionsStore.get('sessions', []); + return stored.map((s: any) => ({ + id: s.id, + name: s.name, + toolType: s.toolType, + cwd: s.cwd || s.fullPath || os.homedir(), + projectRoot: s.cwd || s.fullPath || os.homedir(), + })); + }, + onCueRun: async (sessionId, prompt, event) => { + const session = sessionsStore.get('sessions', []).find((s: any) => s.id === sessionId); + if (!session) { + logger.error(`[CUE] Session not found: ${sessionId}`, 'Cue'); + return { + runId: event.id, + sessionId, + sessionName: '', + subscriptionName: event.triggerName, + event, + status: 'failed' as const, + stdout: '', + stderr: 'Session not found', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + } + + const { executeCuePrompt } = await import('./cue/cue-executor'); + const agentConfigs = agentConfigsStore.get('configs', {}) as Record; + const sessionConfig = agentConfigs[session.toolType] || {}; + const projectRoot = session.cwd || session.fullPath || os.homedir(); + const toolType = session.toolType as ToolType; + return await executeCuePrompt({ + runId: event.id, + session: { + id: session.id, + name: session.name, + toolType, + cwd: projectRoot, + projectRoot, + }, + subscription: { name: event.triggerName, event: event.type, enabled: true, prompt }, + event, + promptPath: prompt, + toolType, + projectRoot, + templateContext: { + session: { + id: session.id, + name: session.name, + toolType: session.toolType as string, + cwd: projectRoot, + projectRoot, + }, + }, + timeoutMs: 30 * 60 * 1000, // 30 minute default; engine handles timeout_minutes config + sshRemoteConfig: session.sshRemoteConfig, + customPath: sessionConfig.customPath, + customArgs: sessionConfig.customArgs, + customEnvVars: sessionConfig.customEnvVars, + customModel: sessionConfig.customModel, + onLog: (level, message) => { + if (level === 'error') { + logger.error(message, 'Cue'); + } else { + logger.cue(message, 'Cue'); + } + }, + sshStore: createSshRemoteStoreAdapter(store), + agentConfigValues: sessionConfig, + }); + }, + onLog: (_level, message, data) => { + logger.cue(message, 'Cue', data); + // Push activity updates to renderer + if (mainWindow && isWebContentsAvailable(mainWindow) && data) { + mainWindow.webContents.send('cue:activityUpdate', data); + } + }, + }); + logger.info('Core services initialized', 'Startup'); // Initialize history manager (handles migration from legacy format if needed) @@ -371,6 +461,13 @@ app.whenReady().then(async () => { logger.debug('Setting up process event listeners', 'Startup'); setupProcessListeners(); + // Start Cue engine if the Encore Feature flag is enabled + const encoreFeatures = store.get('encoreFeatures', {}) as Record; + if (encoreFeatures.maestroCue && cueEngine) { + logger.info('Maestro Cue Encore Feature enabled — starting Cue engine', 'Startup'); + cueEngine.start(); + } + // Set custom application menu to prevent macOS from injecting native // "Show Previous Tab" (Cmd+Shift+{) and "Show Next Tab" (Cmd+Shift+}) // menu items into the default Window menu. Without this, those keyboard @@ -435,7 +532,13 @@ const quitHandler = createQuitHandler({ getActiveGroomingSessionCount, cleanupAllGroomingSessions, closeStatsDB, - stopCliWatcher: () => cliWatcher.stop(), + stopCliWatcher: () => { + cliWatcher.stop(); + // Stop Cue engine on app quit + if (cueEngine?.isEnabled()) { + cueEngine.stop(); + } + }, }); quitHandler.setup(); @@ -483,6 +586,11 @@ function setupIpcHandlers() { getAgentDetector: () => agentDetector, }); + // Cue - event-driven automation engine + registerCueHandlers({ + getCueEngine: () => cueEngine, + }); + // Agent management operations - extracted to src/main/ipc/handlers/agents.ts registerAgentsHandlers({ getAgentDetector: () => agentDetector, @@ -735,6 +843,11 @@ function setupProcessListeners() { REGEX_SYNOPSIS_SESSION, }, logger, + getCueEngine: () => cueEngine, + isCueEnabled: () => { + const ef = store.get('encoreFeatures', {}) as Record; + return !!ef.maestroCue; + }, }); // WakaTime heartbeat listener (query-complete → heartbeat, exit → cleanup) diff --git a/src/main/ipc/handlers/cue.ts b/src/main/ipc/handlers/cue.ts new file mode 100644 index 000000000..8f106dce7 --- /dev/null +++ b/src/main/ipc/handlers/cue.ts @@ -0,0 +1,225 @@ +/** + * Cue IPC Handlers + * + * Provides IPC handlers for the Maestro Cue event-driven automation system: + * - Engine runtime controls (enable/disable, stop runs) + * - Status and activity log queries + * - YAML configuration management (read, write, validate) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { app, ipcMain } from 'electron'; +import * as yaml from 'js-yaml'; +import { withIpcErrorLogging, type CreateHandlerOptions } from '../../utils/ipcHandler'; +import { validateCueConfig } from '../../cue/cue-yaml-loader'; +import { CUE_YAML_FILENAME } from '../../cue/cue-types'; +import type { CueEngine } from '../../cue/cue-engine'; +import type { CueGraphSession, CueRunResult, CueSessionStatus } from '../../cue/cue-types'; +import type { PipelineLayoutState } from '../../../shared/cue-pipeline-types'; + +const LOG_CONTEXT = '[Cue]'; + +// Helper to create handler options with consistent context +const handlerOpts = (operation: string): Pick => ({ + context: LOG_CONTEXT, + operation, +}); + +/** + * Dependencies required for Cue handler registration + */ +export interface CueHandlerDependencies { + getCueEngine: () => CueEngine | null; +} + +/** + * Register all Cue IPC handlers. + * + * These handlers provide: + * - Engine status and activity log queries + * - Runtime engine controls (enable/disable) + * - Run management (stop individual or all) + * - YAML configuration management + */ +export function registerCueHandlers(deps: CueHandlerDependencies): void { + const { getCueEngine } = deps; + + const requireEngine = (): CueEngine => { + const engine = getCueEngine(); + if (!engine) { + throw new Error('Cue engine not initialized'); + } + return engine; + }; + + // Get status of all Cue-enabled sessions + ipcMain.handle( + 'cue:getStatus', + withIpcErrorLogging(handlerOpts('getStatus'), async (): Promise => { + return requireEngine().getStatus(); + }) + ); + + // Get currently active Cue runs + ipcMain.handle( + 'cue:getActiveRuns', + withIpcErrorLogging(handlerOpts('getActiveRuns'), async (): Promise => { + return requireEngine().getActiveRuns(); + }) + ); + + // Get activity log (recent completed/failed runs) + ipcMain.handle( + 'cue:getActivityLog', + withIpcErrorLogging( + handlerOpts('getActivityLog'), + async (options: { limit?: number }): Promise => { + return requireEngine().getActivityLog(options?.limit); + } + ) + ); + + // Enable the Cue engine (runtime control) + ipcMain.handle( + 'cue:enable', + withIpcErrorLogging(handlerOpts('enable'), async (): Promise => { + requireEngine().start(); + }) + ); + + // Disable the Cue engine (runtime control) + ipcMain.handle( + 'cue:disable', + withIpcErrorLogging(handlerOpts('disable'), async (): Promise => { + requireEngine().stop(); + }) + ); + + // Stop a specific running Cue execution + ipcMain.handle( + 'cue:stopRun', + withIpcErrorLogging( + handlerOpts('stopRun'), + async (options: { runId: string }): Promise => { + return requireEngine().stopRun(options.runId); + } + ) + ); + + // Stop all running Cue executions + ipcMain.handle( + 'cue:stopAll', + withIpcErrorLogging(handlerOpts('stopAll'), async (): Promise => { + requireEngine().stopAll(); + }) + ); + + // Get queue status per session + ipcMain.handle( + 'cue:getQueueStatus', + withIpcErrorLogging( + handlerOpts('getQueueStatus'), + async (): Promise> => { + const queueMap = requireEngine().getQueueStatus(); + const result: Record = {}; + for (const [sessionId, count] of queueMap) { + result[sessionId] = count; + } + return result; + } + ) + ); + + // Refresh a session's Cue configuration + ipcMain.handle( + 'cue:refreshSession', + withIpcErrorLogging( + handlerOpts('refreshSession'), + async (options: { sessionId: string; projectRoot: string }): Promise => { + requireEngine().refreshSession(options.sessionId, options.projectRoot); + } + ) + ); + + // Get all sessions with their subscriptions (for graph visualization) + ipcMain.handle( + 'cue:getGraphData', + withIpcErrorLogging(handlerOpts('getGraphData'), async (): Promise => { + return requireEngine().getGraphData(); + }) + ); + + // Read raw YAML content from a session's maestro-cue.yaml + ipcMain.handle( + 'cue:readYaml', + withIpcErrorLogging( + handlerOpts('readYaml'), + async (options: { projectRoot: string }): Promise => { + const filePath = path.join(options.projectRoot, CUE_YAML_FILENAME); + if (!fs.existsSync(filePath)) { + return null; + } + return fs.readFileSync(filePath, 'utf-8'); + } + ) + ); + + // Write YAML content to a session's maestro-cue.yaml + ipcMain.handle( + 'cue:writeYaml', + withIpcErrorLogging( + handlerOpts('writeYaml'), + async (options: { projectRoot: string; content: string }): Promise => { + const filePath = path.join(options.projectRoot, CUE_YAML_FILENAME); + fs.writeFileSync(filePath, options.content, 'utf-8'); + // The file watcher in CueEngine will automatically detect the change and refresh + } + ) + ); + + // Validate YAML content as a Cue configuration + ipcMain.handle( + 'cue:validateYaml', + withIpcErrorLogging( + handlerOpts('validateYaml'), + async (options: { content: string }): Promise<{ valid: boolean; errors: string[] }> => { + try { + const parsed = yaml.load(options.content); + return validateCueConfig(parsed); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { valid: false, errors: [`YAML parse error: ${message}`] }; + } + } + ) + ); + + const layoutFilePath = path.join(app.getPath('userData'), 'cue-pipeline-layout.json'); + + // Save pipeline layout (node positions, viewport, selected pipeline) + ipcMain.handle( + 'cue:savePipelineLayout', + withIpcErrorLogging( + handlerOpts('savePipelineLayout'), + async (options: { layout: PipelineLayoutState }): Promise => { + fs.writeFileSync(layoutFilePath, JSON.stringify(options.layout, null, 2), 'utf-8'); + } + ) + ); + + // Load saved pipeline layout + ipcMain.handle( + 'cue:loadPipelineLayout', + withIpcErrorLogging( + handlerOpts('loadPipelineLayout'), + async (): Promise => { + if (!fs.existsSync(layoutFilePath)) { + return null; + } + const content = fs.readFileSync(layoutFilePath, 'utf-8'); + return JSON.parse(content) as PipelineLayoutState; + } + ) + ); +} diff --git a/src/main/ipc/handlers/director-notes.ts b/src/main/ipc/handlers/director-notes.ts index 269cb2d2c..8b1aeee05 100644 --- a/src/main/ipc/handlers/director-notes.ts +++ b/src/main/ipc/handlers/director-notes.ts @@ -77,7 +77,7 @@ export interface DirectorNotesHandlerDependencies { export interface UnifiedHistoryOptions { lookbackDays: number; - filter?: 'AUTO' | 'USER' | null; // null = both + filter?: 'AUTO' | 'USER' | 'CUE' | null; // null = both /** Number of entries to return per page (default: 100) */ limit?: number; /** Number of entries to skip for pagination (default: 0) */ diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index ba41c326b..303e0e040 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -52,6 +52,7 @@ import { registerSymphonyHandlers, SymphonyHandlerDependencies } from './symphon import { registerAgentErrorHandlers } from './agent-error'; import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes'; +import { registerCueHandlers, CueHandlerDependencies } from './cue'; import { registerWakatimeHandlers } from './wakatime'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; @@ -96,6 +97,8 @@ export { registerTabNamingHandlers }; export type { TabNamingHandlerDependencies }; export { registerDirectorNotesHandlers }; export type { DirectorNotesHandlerDependencies }; +export { registerCueHandlers }; +export type { CueHandlerDependencies }; export { registerWakatimeHandlers }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; diff --git a/src/main/preload/cue.ts b/src/main/preload/cue.ts new file mode 100644 index 000000000..0f96770b2 --- /dev/null +++ b/src/main/preload/cue.ts @@ -0,0 +1,151 @@ +/** + * Preload API for Cue operations + * + * Provides the window.maestro.cue namespace for: + * - Engine status and activity log queries + * - Runtime engine controls (enable/disable) + * - Run management (stop individual or all) + * - YAML configuration management (read, write, validate) + * - Real-time activity updates via event listener + */ + +import { ipcRenderer } from 'electron'; + +/** Event types that can trigger a Cue subscription */ +export type CueEventType = + | 'time.interval' + | 'file.changed' + | 'agent.completed' + | 'github.pull_request' + | 'github.issue' + | 'task.pending'; + +/** Status of a Cue run */ +export type CueRunStatus = 'running' | 'completed' | 'failed' | 'timeout' | 'stopped'; + +/** An event instance produced by a trigger */ +export interface CueEvent { + id: string; + type: CueEventType; + timestamp: string; + triggerName: string; + payload: Record; +} + +/** Result of a completed (or failed/timed-out) Cue run */ +export interface CueRunResult { + runId: string; + sessionId: string; + sessionName: string; + subscriptionName: string; + event: CueEvent; + status: CueRunStatus; + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; + startedAt: string; + endedAt: string; +} + +/** Status summary for a Cue-enabled session */ +export interface CueSessionStatus { + sessionId: string; + sessionName: string; + toolType: string; + projectRoot: string; + enabled: boolean; + subscriptionCount: number; + activeRuns: number; + lastTriggered?: string; + nextTrigger?: string; +} + +/** + * Creates the Cue API object for preload exposure + */ +export function createCueApi() { + return { + // Get status of all Cue-enabled sessions + getStatus: (): Promise => ipcRenderer.invoke('cue:getStatus'), + + // Get all sessions with their subscriptions (for graph visualization) + getGraphData: (): Promise< + Array<{ + sessionId: string; + sessionName: string; + toolType: string; + subscriptions: Array<{ + name: string; + event: CueEventType; + enabled: boolean; + prompt: string; + interval_minutes?: number; + watch?: string; + source_session?: string | string[]; + fan_out?: string[]; + filter?: Record; + repo?: string; + poll_minutes?: number; + }>; + }> + > => ipcRenderer.invoke('cue:getGraphData'), + + // Get currently active Cue runs + getActiveRuns: (): Promise => ipcRenderer.invoke('cue:getActiveRuns'), + + // Get activity log (recent completed/failed runs) + getActivityLog: (limit?: number): Promise => + ipcRenderer.invoke('cue:getActivityLog', { limit }), + + // Enable the Cue engine (runtime control) + enable: (): Promise => ipcRenderer.invoke('cue:enable'), + + // Disable the Cue engine (runtime control) + disable: (): Promise => ipcRenderer.invoke('cue:disable'), + + // Stop a specific running Cue execution + stopRun: (runId: string): Promise => ipcRenderer.invoke('cue:stopRun', { runId }), + + // Stop all running Cue executions + stopAll: (): Promise => ipcRenderer.invoke('cue:stopAll'), + + // Get queue status per session + getQueueStatus: (): Promise> => ipcRenderer.invoke('cue:getQueueStatus'), + + // Refresh a session's Cue configuration + refreshSession: (sessionId: string, projectRoot: string): Promise => + ipcRenderer.invoke('cue:refreshSession', { sessionId, projectRoot }), + + // Read raw YAML content from a session's maestro-cue.yaml + readYaml: (projectRoot: string): Promise => + ipcRenderer.invoke('cue:readYaml', { projectRoot }), + + // Write YAML content to a session's maestro-cue.yaml + writeYaml: (projectRoot: string, content: string): Promise => + ipcRenderer.invoke('cue:writeYaml', { projectRoot, content }), + + // Validate YAML content as a Cue configuration + validateYaml: (content: string): Promise<{ valid: boolean; errors: string[] }> => + ipcRenderer.invoke('cue:validateYaml', { content }), + + // Save pipeline layout (node positions, viewport, pipeline selection) + savePipelineLayout: (layout: Record): Promise => + ipcRenderer.invoke('cue:savePipelineLayout', { layout }), + + // Load saved pipeline layout + loadPipelineLayout: (): Promise | null> => + ipcRenderer.invoke('cue:loadPipelineLayout'), + + // Listen for real-time activity updates from the main process + onActivityUpdate: (callback: (data: CueRunResult) => void): (() => void) => { + const handler = (_e: unknown, data: CueRunResult) => callback(data); + ipcRenderer.on('cue:activityUpdate', handler); + return () => { + ipcRenderer.removeListener('cue:activityUpdate', handler); + }; + }, + }; +} + +export type CueApi = ReturnType; diff --git a/src/main/preload/directorNotes.ts b/src/main/preload/directorNotes.ts index b4db375a6..2f9c5ec05 100644 --- a/src/main/preload/directorNotes.ts +++ b/src/main/preload/directorNotes.ts @@ -35,7 +35,7 @@ export interface PaginatedUnifiedHistoryResult { */ export interface UnifiedHistoryOptions { lookbackDays: number; - filter?: 'AUTO' | 'USER' | null; // null = both + filter?: 'AUTO' | 'USER' | 'CUE' | null; // null = both /** Number of entries to return per page (default: 100) */ limit?: number; /** Number of entries to skip for pagination (default: 0) */ @@ -47,7 +47,7 @@ export interface UnifiedHistoryOptions { */ export interface UnifiedHistoryEntry { id: string; - type: 'AUTO' | 'USER'; + type: 'AUTO' | 'USER' | 'CUE'; timestamp: number; summary: string; fullResponse?: string; diff --git a/src/main/preload/files.ts b/src/main/preload/files.ts index 1206cc184..e92fbd158 100644 --- a/src/main/preload/files.ts +++ b/src/main/preload/files.ts @@ -14,7 +14,7 @@ import { ipcRenderer } from 'electron'; */ export interface HistoryEntry { id: string; - type: 'AUTO' | 'USER'; + type: 'AUTO' | 'USER' | 'CUE'; timestamp: number; summary: string; fullResponse?: string; diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f8..79b461b6a 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -49,6 +49,7 @@ import { createAgentsApi } from './agents'; import { createSymphonyApi } from './symphony'; import { createTabNamingApi } from './tabNaming'; import { createDirectorNotesApi } from './directorNotes'; +import { createCueApi } from './cue'; import { createWakatimeApi } from './wakatime'; // Expose protected methods that allow the renderer process to use @@ -189,6 +190,9 @@ contextBridge.exposeInMainWorld('maestro', { // Director's Notes API (unified history + synopsis) directorNotes: createDirectorNotesApi(), + // Cue API (event-driven automation) + cue: createCueApi(), + // WakaTime API (CLI check, API key validation) wakatime: createWakatimeApi(), }); @@ -262,6 +266,8 @@ export { createTabNamingApi, // Director's Notes createDirectorNotesApi, + // Cue + createCueApi, // WakaTime createWakatimeApi, }; @@ -468,6 +474,15 @@ export type { SynopsisResult, SynopsisStats, } from './directorNotes'; +export type { + // From cue + CueApi, + CueRunResult, + CueSessionStatus, + CueEvent, + CueEventType, + CueRunStatus, +} from './cue'; export type { // From wakatime WakatimeApi, diff --git a/src/main/process-listeners/exit-listener.ts b/src/main/process-listeners/exit-listener.ts index b06b5a596..b30b34439 100644 --- a/src/main/process-listeners/exit-listener.ts +++ b/src/main/process-listeners/exit-listener.ts @@ -34,6 +34,8 @@ export function setupExitListener( | 'debugLog' | 'logger' | 'patterns' + | 'getCueEngine' + | 'isCueEnabled' > ): void { const { @@ -51,6 +53,8 @@ export function setupExitListener( debugLog, logger, patterns, + getCueEngine, + isCueEnabled, } = deps; const { REGEX_MODERATOR_SESSION } = patterns; @@ -440,5 +444,17 @@ export function setupExitListener( timestamp: Date.now(), }); } + + // Notify Cue engine that this agent session has completed. + // This triggers agent.completed subscriptions for completion chains. + if (isCueEnabled?.() && getCueEngine) { + const cueEngine = getCueEngine(); + if (cueEngine?.hasCompletionSubscribers(sessionId)) { + cueEngine.notifyAgentCompleted(sessionId, { + status: code === 0 ? 'completed' : 'failed', + exitCode: code, + }); + } + } }); } diff --git a/src/main/process-listeners/types.ts b/src/main/process-listeners/types.ts index bb6a0a252..19ee5652f 100644 --- a/src/main/process-listeners/types.ts +++ b/src/main/process-listeners/types.ts @@ -6,6 +6,7 @@ import type { ProcessManager } from '../process-manager'; import type { WebServer } from '../web-server'; import type { AgentDetector } from '../agents'; +import type { CueEngine } from '../cue/cue-engine'; import type { SafeSendFn } from '../utils/safe-send'; import type { StatsDB } from '../stats'; import type { GroupChat, GroupChatParticipant } from '../group-chat/group-chat-storage'; @@ -163,4 +164,8 @@ export interface ProcessListenerDependencies { warn: (message: string, context: string, data?: Record) => void; debug: (message: string, context: string, data?: Record) => void; }; + /** Function to get the Cue engine (for agent completion chain notifications) */ + getCueEngine?: () => CueEngine | null; + /** Function to check if the Maestro Cue Encore Feature is enabled */ + isCueEnabled?: () => boolean; } diff --git a/src/main/utils/logger.ts b/src/main/utils/logger.ts index e332e1de8..9d9828325 100644 --- a/src/main/utils/logger.ts +++ b/src/main/utils/logger.ts @@ -192,6 +192,10 @@ class Logger extends EventEmitter { // Auto Run logs for workflow tracking (orange in LogViewer) console.info(message, entry.data || ''); break; + case 'cue': + // Cue event-driven automation logs (teal in LogViewer) + console.info(message, entry.data || ''); + break; } } catch { // Silently ignore EPIPE errors - console is disconnected @@ -265,6 +269,17 @@ class Logger extends EventEmitter { }); } + cue(message: string, context?: string, data?: unknown): void { + // Cue logs are always logged (event-driven automation tracking) + this.addLog({ + timestamp: Date.now(), + level: 'cue', + message, + context, + data, + }); + } + getLogs(filter?: { level?: MainLogLevel; context?: string; limit?: number }): SystemLogEntry[] { let filtered = [...this.logs]; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 667edde5c..644174047 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -41,6 +41,10 @@ const DocumentGraphView = lazy(() => const DirectorNotesModal = lazy(() => import('./components/DirectorNotes').then((m) => ({ default: m.DirectorNotesModal })) ); +const CueModal = lazy(() => import('./components/CueModal').then((m) => ({ default: m.CueModal }))); +const CueYamlEditor = lazy(() => + import('./components/CueYamlEditor').then((m) => ({ default: m.CueYamlEditor })) +); // SymphonyContributionData type moved to useSymphonyContribution hook @@ -135,6 +139,7 @@ import { import { useMainPanelProps, useSessionListProps, useRightPanelProps } from './hooks/props'; import { useAgentListeners } from './hooks/agent/useAgentListeners'; import { useSymphonyContribution } from './hooks/symphony/useSymphonyContribution'; +import { useCueAutoDiscovery } from './hooks/useCueAutoDiscovery'; // Import contexts import { useLayerStack } from './contexts/LayerStackContext'; @@ -326,6 +331,14 @@ function MaestroConsoleInner() { // Director's Notes Modal directorNotesOpen, setDirectorNotesOpen, + // Maestro Cue Modal + cueModalOpen, + setCueModalOpen, + // Maestro Cue YAML Editor (standalone) + cueYamlEditorOpen, + cueYamlEditorSessionId, + cueYamlEditorProjectRoot, + closeCueYamlEditor, } = useModalActions(); // --- MOBILE LANDSCAPE MODE (reading-only view) --- @@ -745,6 +758,9 @@ function MaestroConsoleInner() { // --- SESSION RESTORATION (extracted hook, Phase 2E) --- const { initialLoadComplete } = useSessionRestoration(); + // --- CUE AUTO-DISCOVERY (gated by Encore Feature) --- + useCueAutoDiscovery(sessions, encoreFeatures); + // --- TAB HANDLERS (extracted hook) --- const { activeTab, @@ -869,6 +885,7 @@ function MaestroConsoleInner() { handleOpenMarketplace, handleEditAgent, handleOpenCreatePRSession, + handleConfigureCue, handleStartTour, handleSetLightboxImage, handleCloseLightbox, @@ -1959,6 +1976,7 @@ function MaestroConsoleInner() { setMarketplaceModalOpen, setSymphonyModalOpen, setDirectorNotesOpen, + setCueModalOpen, encoreFeatures, setShowNewGroupChatModal, deleteGroupChatWithConfirmation, @@ -2283,6 +2301,7 @@ function MaestroConsoleInner() { handleOpenWorktreeConfigSession, handleDeleteWorktreeSession, handleToggleWorktreeExpanded, + handleConfigureCue, openWizardModal, handleStartTour, @@ -2630,6 +2649,8 @@ function MaestroConsoleInner() { onOpenDirectorNotes={ encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined } + onOpenMaestroCue={encoreFeatures.maestroCue ? () => setCueModalOpen(true) : undefined} + onConfigureCue={encoreFeatures.maestroCue ? handleConfigureCue : undefined} autoScrollAiMode={autoScrollAiMode} setAutoScrollAiMode={setAutoScrollAiMode} onCloseTabSwitcher={handleCloseTabSwitcher} @@ -2821,6 +2842,34 @@ function MaestroConsoleInner() { )} + {/* --- MAESTRO CUE MODAL (lazy-loaded, Encore Feature) --- */} + {encoreFeatures.maestroCue && cueModalOpen && ( + + setCueModalOpen(false)} + cueShortcutKeys={shortcuts.maestroCue?.keys} + /> + + )} + + {/* --- MAESTRO CUE YAML EDITOR (standalone, lazy-loaded) --- */} + {encoreFeatures.maestroCue && + cueYamlEditorOpen && + cueYamlEditorSessionId && + cueYamlEditorProjectRoot && ( + + + + )} + {/* --- GIST PUBLISH MODAL --- */} {/* Supports both file preview tabs and tab context gist publishing */} {gistPublishModalOpen && (activeFileTab || tabGistContent) && ( diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 1d3240bd2..7b26bc2d4 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -862,6 +862,10 @@ export interface AppUtilityModalsProps { // Director's Notes onOpenDirectorNotes?: () => void; + // Maestro Cue + onOpenMaestroCue?: () => void; + onConfigureCue?: (session: Session) => void; + // Auto-scroll autoScrollAiMode?: boolean; setAutoScrollAiMode?: (value: boolean) => void; @@ -1063,6 +1067,9 @@ export const AppUtilityModals = memo(function AppUtilityModals({ onOpenSymphony, // Director's Notes onOpenDirectorNotes, + // Maestro Cue + onOpenMaestroCue, + onConfigureCue, // Auto-scroll autoScrollAiMode, setAutoScrollAiMode, @@ -1221,6 +1228,8 @@ export const AppUtilityModals = memo(function AppUtilityModals({ onOpenLastDocumentGraph={onOpenLastDocumentGraph} onOpenSymphony={onOpenSymphony} onOpenDirectorNotes={onOpenDirectorNotes} + onOpenMaestroCue={onOpenMaestroCue} + onConfigureCue={onConfigureCue} autoScrollAiMode={autoScrollAiMode} setAutoScrollAiMode={setAutoScrollAiMode} /> @@ -1997,6 +2006,9 @@ export interface AppModalsProps { onOpenSymphony?: () => void; // Director's Notes onOpenDirectorNotes?: () => void; + // Maestro Cue + onOpenMaestroCue?: () => void; + onConfigureCue?: (session: Session) => void; // Auto-scroll autoScrollAiMode?: boolean; setAutoScrollAiMode?: (value: boolean) => void; @@ -2364,6 +2376,9 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { onOpenSymphony, // Director's Notes onOpenDirectorNotes, + // Maestro Cue + onOpenMaestroCue, + onConfigureCue, // Auto-scroll autoScrollAiMode, setAutoScrollAiMode, @@ -2670,6 +2685,8 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { onOpenMarketplace={onOpenMarketplace} onOpenSymphony={onOpenSymphony} onOpenDirectorNotes={onOpenDirectorNotes} + onOpenMaestroCue={onOpenMaestroCue} + onConfigureCue={onConfigureCue} autoScrollAiMode={autoScrollAiMode} setAutoScrollAiMode={setAutoScrollAiMode} tabSwitcherOpen={tabSwitcherOpen} diff --git a/src/renderer/components/CueGraphView.tsx b/src/renderer/components/CueGraphView.tsx new file mode 100644 index 000000000..616e94dd2 --- /dev/null +++ b/src/renderer/components/CueGraphView.tsx @@ -0,0 +1,1164 @@ +/** + * CueGraphView — Canvas-based visualization of Maestro Cue subscription graph. + * + * Shows how triggers (events) connect to agents, and how agents chain to other agents + * via agent.completed subscriptions. Follows the same canvas-based rendering approach + * as the Document Graph MindMap for visual consistency. + * + * Features: + * - Trigger nodes (event sources) on the left + * - Agent nodes in the center/right + * - Edges showing subscription connections with labels + * - Pan/zoom with mouse + * - Click-and-drag to reposition individual nodes + * - Layout algorithm dropdown (Hierarchical, Force-Directed) + * - Double-click an agent node to switch focus and close the modal + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + forceSimulation, + forceLink, + forceManyBody, + forceCenter, + forceCollide, + forceX, + forceY, + type SimulationNodeDatum, + type SimulationLinkDatum, +} from 'd3-force'; +import { RefreshCw, Network, ChevronDown } from 'lucide-react'; +import type { Theme } from '../types'; +import { useSessionStore } from '../stores/sessionStore'; + +// ============================================================================ +// Types +// ============================================================================ + +interface CueGraphViewProps { + theme: Theme; + onClose: () => void; +} + +type GraphNodeType = 'agent' | 'trigger'; + +interface GraphNode extends SimulationNodeDatum { + id: string; + type: GraphNodeType; + label: string; + sublabel: string; + sessionId?: string; + toolType?: string; + subscriptionCount?: number; + eventType?: string; + width: number; + height: number; + /** Depth in the dependency graph (0 = trigger, 1+ = agents) */ + depth?: number; +} + +interface GraphEdge extends SimulationLinkDatum { + id: string; + label: string; + sourceId: string; + targetId: string; +} + +type CueLayoutType = 'hierarchical' | 'force'; + +const LAYOUT_LABELS: Record = { + hierarchical: { name: 'Hierarchical', description: 'Left-to-right layers' }, + force: { name: 'Force', description: 'Physics simulation' }, +}; + +// ============================================================================ +// Constants +// ============================================================================ + +const CUE_TEAL = '#06b6d4'; + +const AGENT_NODE_WIDTH = 180; +const AGENT_NODE_HEIGHT = 56; +const TRIGGER_NODE_WIDTH = 160; +const TRIGGER_NODE_HEIGHT = 44; +const NODE_BORDER_RADIUS = 10; + +const EVENT_COLORS: Record = { + 'time.interval': '#f59e0b', + 'file.changed': '#3b82f6', + 'agent.completed': '#22c55e', + 'github.pull_request': '#a855f7', + 'github.issue': '#f97316', + 'task.pending': CUE_TEAL, +}; + +const EVENT_LABELS: Record = { + 'time.interval': 'Timer', + 'file.changed': 'File Watch', + 'agent.completed': 'Agent Done', + 'github.pull_request': 'GitHub PR', + 'github.issue': 'GitHub Issue', + 'task.pending': 'Task Queue', +}; + +// ============================================================================ +// Graph Data Builder +// ============================================================================ + +interface GraphData { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +function buildGraphData( + graphSessions: Array<{ + sessionId: string; + sessionName: string; + toolType: string; + subscriptions: Array<{ + name: string; + event: string; + enabled: boolean; + source_session?: string | string[]; + fan_out?: string[]; + watch?: string; + interval_minutes?: number; + repo?: string; + poll_minutes?: number; + }>; + }>, + allSessions: Array<{ id: string; name: string; toolType: string }> +): GraphData { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const nodeIds = new Set(); + const triggerKeys = new Map(); // composite key → node id + + // Add agent nodes for all Cue-enabled sessions + for (const gs of graphSessions) { + const nodeId = `agent:${gs.sessionId}`; + if (!nodeIds.has(nodeId)) { + nodes.push({ + id: nodeId, + type: 'agent', + label: gs.sessionName, + sublabel: gs.toolType, + sessionId: gs.sessionId, + toolType: gs.toolType, + subscriptionCount: gs.subscriptions.filter((s) => s.enabled !== false).length, + width: AGENT_NODE_WIDTH, + height: AGENT_NODE_HEIGHT, + }); + nodeIds.add(nodeId); + } + } + + // Helper to ensure an agent node exists (for referenced agents not in graph sessions) + function ensureAgentNode(sessionName: string) { + // Try to find by name in graphSessions first + const gs = graphSessions.find((s) => s.sessionName === sessionName); + if (gs) return `agent:${gs.sessionId}`; + + // Try to find in all sessions + const session = allSessions.find((s) => s.name === sessionName); + const nodeId = session ? `agent:${session.id}` : `agent:ref:${sessionName}`; + + if (!nodeIds.has(nodeId)) { + nodes.push({ + id: nodeId, + type: 'agent', + label: sessionName, + sublabel: session?.toolType ?? 'unknown', + sessionId: session?.id, + toolType: session?.toolType, + width: AGENT_NODE_WIDTH, + height: AGENT_NODE_HEIGHT, + }); + nodeIds.add(nodeId); + } + return nodeId; + } + + // Process subscriptions + for (const gs of graphSessions) { + const agentNodeId = `agent:${gs.sessionId}`; + + for (const sub of gs.subscriptions) { + if (sub.enabled === false) continue; + + if (sub.event === 'agent.completed') { + // Agent → Agent connection + const sources = Array.isArray(sub.source_session) + ? sub.source_session + : sub.source_session + ? [sub.source_session] + : []; + + for (const sourceName of sources) { + const sourceNodeId = ensureAgentNode(sourceName); + edges.push({ + id: `edge:${sourceNodeId}→${agentNodeId}:${sub.name}`, + source: sourceNodeId, + target: agentNodeId, + sourceId: sourceNodeId, + targetId: agentNodeId, + label: sub.name, + }); + } + + // Also handle fan_out targets + if (sub.fan_out) { + for (const targetName of sub.fan_out) { + const targetNodeId = ensureAgentNode(targetName); + edges.push({ + id: `edge:${agentNodeId}→${targetNodeId}:${sub.name}:fanout`, + source: agentNodeId, + target: targetNodeId, + sourceId: agentNodeId, + targetId: targetNodeId, + label: `${sub.name} (fan-out)`, + }); + } + } + } else { + // Trigger → Agent connection + const triggerDetail = getTriggerDetail(sub); + const triggerKey = `${sub.event}:${triggerDetail}`; + + let triggerNodeId = triggerKeys.get(triggerKey); + if (!triggerNodeId) { + triggerNodeId = `trigger:${triggerKey}`; + triggerKeys.set(triggerKey, triggerNodeId); + + nodes.push({ + id: triggerNodeId, + type: 'trigger', + label: EVENT_LABELS[sub.event] ?? sub.event, + sublabel: triggerDetail, + eventType: sub.event, + width: TRIGGER_NODE_WIDTH, + height: TRIGGER_NODE_HEIGHT, + }); + nodeIds.add(triggerNodeId); + } + + // Edge from trigger to this agent + edges.push({ + id: `edge:${triggerNodeId}→${agentNodeId}:${sub.name}`, + source: triggerNodeId, + target: agentNodeId, + sourceId: triggerNodeId, + targetId: agentNodeId, + label: sub.name, + }); + + // Handle fan_out for non-agent.completed events + if (sub.fan_out) { + for (const targetName of sub.fan_out) { + const targetNodeId = ensureAgentNode(targetName); + edges.push({ + id: `edge:${triggerNodeId}→${targetNodeId}:${sub.name}:fanout`, + source: triggerNodeId, + target: targetNodeId, + sourceId: triggerNodeId, + targetId: targetNodeId, + label: `${sub.name} (fan-out)`, + }); + } + } + } + } + } + + return { nodes, edges }; +} + +function getTriggerDetail(sub: { + event: string; + watch?: string; + interval_minutes?: number; + repo?: string; + poll_minutes?: number; +}): string { + switch (sub.event) { + case 'time.interval': + return sub.interval_minutes ? `${sub.interval_minutes}m` : 'interval'; + case 'file.changed': + return sub.watch ?? '**/*'; + case 'github.pull_request': + case 'github.issue': + return sub.repo ?? 'repo'; + case 'task.pending': + return sub.watch ?? 'tasks'; + default: + return sub.event; + } +} + +// ============================================================================ +// Layout: Hierarchical (left-to-right layers) +// ============================================================================ + +function layoutHierarchical( + nodes: GraphNode[], + edges: GraphEdge[], + width: number, + height: number +): void { + if (nodes.length === 0) return; + + // Build adjacency for depth calculation (source → targets) + const outgoing = new Map(); + for (const edge of edges) { + const srcId = typeof edge.source === 'string' ? edge.source : (edge.source as GraphNode).id; + const tgtId = typeof edge.target === 'string' ? edge.target : (edge.target as GraphNode).id; + if (!outgoing.has(srcId)) outgoing.set(srcId, []); + outgoing.get(srcId)!.push(tgtId); + } + + // Assign depth via BFS from triggers (depth 0) + const depthMap = new Map(); + const triggers = nodes.filter((n) => n.type === 'trigger'); + const agents = nodes.filter((n) => n.type === 'agent'); + + // All triggers are depth 0 + for (const t of triggers) { + depthMap.set(t.id, 0); + } + + // BFS to assign depths to agents + const queue: string[] = triggers.map((t) => t.id); + while (queue.length > 0) { + const current = queue.shift()!; + const currentDepth = depthMap.get(current) ?? 0; + const targets = outgoing.get(current) ?? []; + for (const targetId of targets) { + const existingDepth = depthMap.get(targetId); + const newDepth = currentDepth + 1; + if (existingDepth === undefined || newDepth > existingDepth) { + depthMap.set(targetId, newDepth); + queue.push(targetId); + } + } + } + + // Agents without edges get depth 1 + for (const a of agents) { + if (!depthMap.has(a.id)) { + depthMap.set(a.id, 1); + } + } + + // Store depth on nodes + for (const node of nodes) { + node.depth = depthMap.get(node.id) ?? 0; + } + + // Group nodes by depth + const layers = new Map(); + for (const node of nodes) { + const d = node.depth!; + if (!layers.has(d)) layers.set(d, []); + layers.get(d)!.push(node); + } + + const sortedDepths = Array.from(layers.keys()).sort((a, b) => a - b); + const numLayers = sortedDepths.length; + if (numLayers === 0) return; + + // Calculate horizontal spacing + const horizontalPadding = 80; + const availableWidth = width - horizontalPadding * 2; + const layerSpacing = numLayers > 1 ? availableWidth / (numLayers - 1) : 0; + + // Position each layer + for (let i = 0; i < sortedDepths.length; i++) { + const depth = sortedDepths[i]; + const layerNodes = layers.get(depth)!; + + // X position for this layer + const layerX = numLayers === 1 ? width / 2 : horizontalPadding + i * layerSpacing; + + // Vertical spacing within layer + const verticalPadding = 40; + const maxNodeHeight = Math.max(...layerNodes.map((n) => n.height)); + const nodeSpacing = maxNodeHeight + 30; + const totalLayerHeight = layerNodes.length * nodeSpacing - 30; + const startY = (height - totalLayerHeight) / 2 + verticalPadding / 2; + + for (let j = 0; j < layerNodes.length; j++) { + layerNodes[j].x = layerX; + layerNodes[j].y = Math.max(verticalPadding, startY + j * nodeSpacing); + } + } +} + +// ============================================================================ +// Layout: Force-Directed (d3-force) +// ============================================================================ + +function layoutForce(nodes: GraphNode[], edges: GraphEdge[], width: number, height: number): void { + if (nodes.length === 0) return; + + // Seed initial positions: triggers left, agents right + for (const node of nodes) { + if (node.type === 'trigger') { + node.x = width * 0.25 + (Math.random() - 0.5) * 100; + node.y = height * 0.5 + (Math.random() - 0.5) * 200; + } else { + node.x = width * 0.7 + (Math.random() - 0.5) * 100; + node.y = height * 0.5 + (Math.random() - 0.5) * 200; + } + } + + const simulation = forceSimulation(nodes) + .force( + 'link', + forceLink(edges) + .id((d) => d.id) + .distance(220) + .strength(0.5) + ) + .force('charge', forceManyBody().strength(-500)) + .force('center', forceCenter(width / 2, height / 2)) + .force( + 'collide', + forceCollide().radius((d) => Math.max(d.width, d.height) * 0.8) + ) + .force( + 'x', + forceX() + .x((d) => (d.type === 'trigger' ? width * 0.25 : width * 0.7)) + .strength(0.15) + ) + .force('y', forceY(height / 2).strength(0.05)) + .stop(); + + // Run simulation synchronously + for (let i = 0; i < 300; i++) { + simulation.tick(); + } +} + +// ============================================================================ +// Layout dispatcher +// ============================================================================ + +function layoutGraph( + layoutType: CueLayoutType, + nodes: GraphNode[], + edges: GraphEdge[], + width: number, + height: number +): void { + if (layoutType === 'hierarchical') { + layoutHierarchical(nodes, edges, width, height); + } else { + layoutForce(nodes, edges, width, height); + } +} + +// ============================================================================ +// Canvas Rendering +// ============================================================================ + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number +): void { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +function truncateText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string { + if (ctx.measureText(text).width <= maxWidth) return text; + let truncated = text; + while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) { + truncated = truncated.slice(0, -1); + } + return truncated + '...'; +} + +function drawArrowhead( + ctx: CanvasRenderingContext2D, + toX: number, + toY: number, + angle: number, + size: number, + color: string +): void { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(toX, toY); + ctx.lineTo( + toX - size * Math.cos(angle - Math.PI / 6), + toY - size * Math.sin(angle - Math.PI / 6) + ); + ctx.lineTo( + toX - size * Math.cos(angle + Math.PI / 6), + toY - size * Math.sin(angle + Math.PI / 6) + ); + ctx.closePath(); + ctx.fill(); +} + +function renderGraph( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + edges: GraphEdge[], + theme: Theme, + transform: { zoom: number; panX: number; panY: number }, + hoveredNodeId: string | null, + selectedNodeId: string | null, + draggingNodeId: string | null, + canvasWidth: number, + canvasHeight: number +): void { + const dpr = window.devicePixelRatio || 1; + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + // Apply transform + ctx.save(); + ctx.translate(transform.panX, transform.panY); + ctx.scale(transform.zoom, transform.zoom); + + // Draw edges + for (const edge of edges) { + const source = edge.source as GraphNode; + const target = edge.target as GraphNode; + if (!source.x || !source.y || !target.x || !target.y) continue; + + const sx = source.x + source.width / 2; + const sy = source.y; + const tx = target.x - target.width / 2; + const ty = target.y; + + // Determine edge color based on source type + const edgeColor = + source.type === 'trigger' + ? (EVENT_COLORS[source.eventType ?? ''] ?? theme.colors.textDim) + : '#22c55e'; + + ctx.strokeStyle = edgeColor + '80'; + ctx.lineWidth = 2; + ctx.setLineDash([]); + + // Bezier curve + const dx = Math.abs(tx - sx); + const controlOffset = Math.min(dx * 0.4, 120); + + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.bezierCurveTo(sx + controlOffset, sy, tx - controlOffset, ty, tx, ty); + ctx.stroke(); + + // Arrowhead + const angle = Math.atan2(ty - (ty - 0), tx - (tx - controlOffset)); + drawArrowhead(ctx, tx, ty, angle, 8, edgeColor + 'cc'); + + // Edge label + const midX = (sx + tx) / 2; + const midY = (sy + ty) / 2 - 8; + ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.fillStyle = theme.colors.textDim; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const labelText = truncateText(ctx, edge.label, 120); + ctx.fillText(labelText, midX, midY); + } + + // Draw nodes + for (const node of nodes) { + if (node.x === undefined || node.y === undefined) continue; + + const nx = node.x - node.width / 2; + const ny = node.y - node.height / 2; + const isHovered = hoveredNodeId === node.id; + const isSelected = selectedNodeId === node.id; + const isDragging = draggingNodeId === node.id; + + if (node.type === 'trigger') { + // Trigger node - pill shape with event color + const color = EVENT_COLORS[node.eventType ?? ''] ?? CUE_TEAL; + + roundRect(ctx, nx, ny, node.width, node.height, NODE_BORDER_RADIUS); + ctx.fillStyle = color + '18'; + ctx.fill(); + ctx.strokeStyle = isHovered || isSelected || isDragging ? color : color + '60'; + ctx.lineWidth = isHovered || isSelected || isDragging ? 2 : 1; + ctx.stroke(); + + // Event type label + ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(node.label, node.x, node.y - 7); + + // Detail + ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.fillStyle = theme.colors.textDim; + const detailText = truncateText(ctx, node.sublabel, node.width - 20); + ctx.fillText(detailText, node.x, node.y + 8); + } else { + // Agent node - card style + const isAgentCompleted = edges.some( + (e) => (e.source as GraphNode).type === 'agent' && (e.target as GraphNode).id === node.id + ); + const accentColor = isAgentCompleted ? '#22c55e' : CUE_TEAL; + + // Background + roundRect(ctx, nx, ny, node.width, node.height, NODE_BORDER_RADIUS); + ctx.fillStyle = theme.colors.bgActivity; + ctx.fill(); + + // Border + ctx.strokeStyle = isHovered || isSelected || isDragging ? accentColor : theme.colors.border; + ctx.lineWidth = isHovered || isSelected || isDragging ? 2 : 1; + ctx.stroke(); + + // Accent bar at top + ctx.save(); + ctx.beginPath(); + ctx.moveTo(nx + NODE_BORDER_RADIUS, ny); + ctx.lineTo(nx + node.width - NODE_BORDER_RADIUS, ny); + ctx.quadraticCurveTo(nx + node.width, ny, nx + node.width, ny + NODE_BORDER_RADIUS); + ctx.lineTo(nx + node.width, ny + 4); + ctx.lineTo(nx, ny + 4); + ctx.lineTo(nx, ny + NODE_BORDER_RADIUS); + ctx.quadraticCurveTo(nx, ny, nx + NODE_BORDER_RADIUS, ny); + ctx.closePath(); + ctx.fillStyle = accentColor; + ctx.fill(); + ctx.restore(); + + // Agent name + ctx.font = 'bold 12px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.fillStyle = theme.colors.textMain; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const nameText = truncateText(ctx, node.label, node.width - 20); + ctx.fillText(nameText, node.x, node.y - 4); + + // Tool type + ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.fillStyle = theme.colors.textDim; + ctx.fillText(node.sublabel, node.x, node.y + 12); + + // Subscription count badge + if (node.subscriptionCount && node.subscriptionCount > 0) { + const badgeText = `${node.subscriptionCount}`; + ctx.font = 'bold 9px -apple-system, BlinkMacSystemFont, sans-serif'; + const badgeWidth = Math.max(ctx.measureText(badgeText).width + 8, 18); + const badgeX = nx + node.width - badgeWidth - 6; + const badgeY = ny + node.height - 16; + + roundRect(ctx, badgeX, badgeY, badgeWidth, 14, 7); + ctx.fillStyle = accentColor; + ctx.fill(); + + ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(badgeText, badgeX + badgeWidth / 2, badgeY + 7); + } + } + } + + ctx.restore(); +} + +// ============================================================================ +// Hit Testing +// ============================================================================ + +function hitTest( + nodes: GraphNode[], + x: number, + y: number, + transform: { zoom: number; panX: number; panY: number } +): GraphNode | null { + // Convert screen coords to graph coords + const gx = (x - transform.panX) / transform.zoom; + const gy = (y - transform.panY) / transform.zoom; + + // Check nodes in reverse order (top-most first) + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + if (node.x === undefined || node.y === undefined) continue; + + const nx = node.x - node.width / 2; + const ny = node.y - node.height / 2; + + if (gx >= nx && gx <= nx + node.width && gy >= ny && gy <= ny + node.height) { + return node; + } + } + + return null; +} + +// ============================================================================ +// Component +// ============================================================================ + +export function CueGraphView({ theme, onClose }: CueGraphViewProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [graphData, setGraphData] = useState(null); + const [loading, setLoading] = useState(true); + const [dimensions, setDimensions] = useState({ width: 800, height: 500 }); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [layoutType, setLayoutType] = useState('hierarchical'); + const [showLayoutDropdown, setShowLayoutDropdown] = useState(false); + + const transformRef = useRef({ zoom: 1, panX: 0, panY: 0 }); + const isPanningRef = useRef(false); + const lastMouseRef = useRef({ x: 0, y: 0 }); + const rafRef = useRef(0); + + // Node dragging state + const draggingNodeRef = useRef<{ + nodeId: string; + startX: number; + startY: number; + mouseX: number; + mouseY: number; + } | null>(null); + const [draggingNodeId, setDraggingNodeId] = useState(null); + const nodePositionOverrides = useRef>(new Map()); + + const sessions = useSessionStore((state) => state.sessions); + const setActiveSessionId = useSessionStore((state) => state.setActiveSessionId); + + // Fetch graph data + const fetchGraphData = useCallback(async () => { + setLoading(true); + try { + const data = await window.maestro.cue.getGraphData(); + const allSessionsSimple = sessions.map((s) => ({ + id: s.id, + name: s.name, + toolType: s.toolType, + })); + const graph = buildGraphData(data, allSessionsSimple); + layoutGraph(layoutType, graph.nodes, graph.edges, dimensions.width, dimensions.height); + // Clear position overrides on fresh data + nodePositionOverrides.current.clear(); + setGraphData(graph); + } catch { + setGraphData({ nodes: [], edges: [] }); + } finally { + setLoading(false); + } + }, [sessions, dimensions.width, dimensions.height, layoutType]); + + useEffect(() => { + fetchGraphData(); + }, [fetchGraphData]); + + // Observe container size + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + setDimensions({ width, height }); + } + } + }); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + + // Re-layout when dimensions or layout type change + useEffect(() => { + if (graphData && graphData.nodes.length > 0) { + layoutGraph( + layoutType, + graphData.nodes, + graphData.edges, + dimensions.width, + dimensions.height + ); + // Apply position overrides + for (const node of graphData.nodes) { + const override = nodePositionOverrides.current.get(node.id); + if (override) { + node.x = override.x; + node.y = override.y; + } + } + // Center the transform + transformRef.current = { zoom: 1, panX: 0, panY: 0 }; + requestDraw(); + } + }, [dimensions.width, dimensions.height, graphData, layoutType]); + + // Canvas setup and rendering + const requestDraw = useCallback(() => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + const canvas = canvasRef.current; + if (!canvas || !graphData) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + renderGraph( + ctx, + graphData.nodes, + graphData.edges, + theme, + transformRef.current, + hoveredNodeId, + selectedNodeId, + draggingNodeId, + dimensions.width, + dimensions.height + ); + }); + }, [graphData, theme, hoveredNodeId, selectedNodeId, draggingNodeId, dimensions]); + + useEffect(() => { + requestDraw(); + }, [requestDraw]); + + // Set canvas size with DPR + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = dimensions.width * dpr; + canvas.height = dimensions.height * dpr; + canvas.style.width = `${dimensions.width}px`; + canvas.style.height = `${dimensions.height}px`; + requestDraw(); + }, [dimensions, requestDraw]); + + // Close layout dropdown on click outside + useEffect(() => { + if (!showLayoutDropdown) return; + const handleClick = () => setShowLayoutDropdown(false); + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, [showLayoutDropdown]); + + // Mouse handlers + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect || !graphData) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const node = hitTest(graphData.nodes, x, y, transformRef.current); + if (node) { + setSelectedNodeId(node.id); + // Start node drag + draggingNodeRef.current = { + nodeId: node.id, + startX: node.x!, + startY: node.y!, + mouseX: e.clientX, + mouseY: e.clientY, + }; + setDraggingNodeId(node.id); + } else { + setSelectedNodeId(null); + isPanningRef.current = true; + lastMouseRef.current = { x: e.clientX, y: e.clientY }; + } + }, + [graphData] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (draggingNodeRef.current && graphData) { + // Dragging a node + const { nodeId, startX, startY, mouseX, mouseY } = draggingNodeRef.current; + const zoom = transformRef.current.zoom; + const deltaX = (e.clientX - mouseX) / zoom; + const deltaY = (e.clientY - mouseY) / zoom; + const newX = startX + deltaX; + const newY = startY + deltaY; + + // Update node position + const node = graphData.nodes.find((n) => n.id === nodeId); + if (node) { + node.x = newX; + node.y = newY; + nodePositionOverrides.current.set(nodeId, { x: newX, y: newY }); + } + + if (canvasRef.current) { + canvasRef.current.style.cursor = 'grabbing'; + } + requestDraw(); + return; + } + + if (isPanningRef.current) { + const dx = e.clientX - lastMouseRef.current.x; + const dy = e.clientY - lastMouseRef.current.y; + transformRef.current.panX += dx; + transformRef.current.panY += dy; + lastMouseRef.current = { x: e.clientX, y: e.clientY }; + requestDraw(); + return; + } + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect || !graphData) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const node = hitTest(graphData.nodes, x, y, transformRef.current); + const newHoveredId = node?.id ?? null; + if (newHoveredId !== hoveredNodeId) { + setHoveredNodeId(newHoveredId); + } + + // Cursor style + if (canvasRef.current) { + canvasRef.current.style.cursor = node ? 'grab' : 'default'; + } + }, + [graphData, hoveredNodeId, requestDraw] + ); + + const handleMouseUp = useCallback(() => { + draggingNodeRef.current = null; + setDraggingNodeId(null); + isPanningRef.current = false; + if (canvasRef.current) { + canvasRef.current.style.cursor = hoveredNodeId ? 'grab' : 'default'; + } + }, [hoveredNodeId]); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect || !graphData) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const node = hitTest(graphData.nodes, x, y, transformRef.current); + + if (node?.type === 'agent' && node.sessionId) { + setActiveSessionId(node.sessionId); + onClose(); + } + }, + [graphData, setActiveSessionId, onClose] + ); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const zoomFactor = e.deltaY < 0 ? 1.08 : 1 / 1.08; + const newZoom = Math.max(0.2, Math.min(3, transformRef.current.zoom * zoomFactor)); + + // Zoom toward mouse position + const scale = newZoom / transformRef.current.zoom; + transformRef.current.panX = mouseX - scale * (mouseX - transformRef.current.panX); + transformRef.current.panY = mouseY - scale * (mouseY - transformRef.current.panY); + transformRef.current.zoom = newZoom; + + requestDraw(); + }, + [requestDraw] + ); + + // Handle layout type change + const handleLayoutTypeChange = useCallback((type: CueLayoutType) => { + // Clear position overrides when changing layout + nodePositionOverrides.current.clear(); + setLayoutType(type); + setShowLayoutDropdown(false); + }, []); + + // Selected node info + const selectedNode = useMemo( + () => graphData?.nodes.find((n) => n.id === selectedNodeId) ?? null, + [graphData, selectedNodeId] + ); + + if (loading) { + return ( +
+ Loading Cue graph... +
+ ); + } + + if (!graphData || graphData.nodes.length === 0) { + return ( +
+ + No Cue subscriptions found. Create a maestro-cue.yaml in a project to see the graph. + +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ + {graphData.nodes.filter((n) => n.type === 'agent').length} agents + · + {graphData.nodes.filter((n) => n.type === 'trigger').length} triggers + · + {graphData.edges.length} connections + + + {/* Layout dropdown */} +
+ + + {showLayoutDropdown && ( +
+ {(['hierarchical', 'force'] as CueLayoutType[]).map((type) => ( + + ))} +
+ )} +
+
+
+ {selectedNode?.type === 'agent' && selectedNode.sessionId && ( + + )} + + + Double-click agent to switch focus + +
+
+ + {/* Canvas */} +
+ +
+
+ ); +} diff --git a/src/renderer/components/CueHelpModal.tsx b/src/renderer/components/CueHelpModal.tsx new file mode 100644 index 000000000..5a9077f15 --- /dev/null +++ b/src/renderer/components/CueHelpModal.tsx @@ -0,0 +1,899 @@ +import { + Zap, + FileText, + Radio, + Code, + GitBranch, + Clock, + Sparkles, + Layers, + Moon, + Filter, + GitMerge, +} from 'lucide-react'; +import type { Theme } from '../types'; +import { formatShortcutKeys } from '../utils/shortcutFormatter'; +import { DEFAULT_SHORTCUTS } from '../constants/shortcuts'; + +interface CueHelpContentProps { + theme: Theme; + cueShortcutKeys?: string[]; +} + +/** + * Help content for Maestro Cue, used inline within the CueModal. + */ +export function CueHelpContent({ theme, cueShortcutKeys }: CueHelpContentProps) { + return ( +
+ {/* Section 1: What is Maestro Cue? */} +
+
+ +

What is Maestro Cue?

+
+
+

+ Maestro Cue is an event-driven automation system. Define triggers in a YAML file, and + Maestro automatically executes prompts against your AI agents when events occur. The + conductor gives the cue — the agents respond. +

+
+
+ + {/* Section 2: Getting Started */} +
+
+ +

Getting Started

+
+
+

+ Use the Pipeline Editor tab to + visually build your automation pipelines. Drag triggers from the left drawer and agents + from the right drawer onto the canvas, then connect them to define your workflow. The + editor automatically generates and manages the underlying{' '} + + maestro-cue.yaml + {' '} + file. +

+
+ subscriptions: +
+ {' '}- name: "My First Cue" +
+ {' '}event: time.interval +
+ {' '}interval_minutes: 30 +
+ {' '}prompt: prompts/my-task.md +
+ {' '}enabled: true +
+
+
+ + {/* Section 3: Event Types */} +
+
+ +

Event Types

+
+
+
+

+ Interval{' '} + + time.interval + +

+

+ Runs your prompt on a timer. Set{' '} + + interval_minutes + {' '} + to control frequency. +

+
+
+

+ File Watch{' '} + + file.changed + +

+

+ Watches for file system changes matching a glob pattern. Set{' '} + + watch + {' '} + to a glob like{' '} + + src/**/*.ts + + . +

+
+
+

+ Agent Completed{' '} + + agent.completed + +

+

+ Triggers when another session finishes a task. Set{' '} + + source_session + {' '} + to the session name. Use{' '} + + filter: {'{'} triggeredBy: "sub-name" {'}'} + {' '} + to chain from a specific subscription only. +

+
+
+

+ GitHub Pull Request{' '} + + github.pull_request + +

+

+ Polls for new pull requests via the GitHub CLI. Optional:{' '} + + repo + {' '} + (auto-detected),{' '} + + poll_minutes + {' '} + (default 5). Requires{' '} + + gh + {' '} + CLI installed and authenticated. +

+
+
+

+ GitHub Issue{' '} + + github.issue + +

+

+ Polls for new issues via the GitHub CLI. Optional:{' '} + + repo + {' '} + (auto-detected),{' '} + + poll_minutes + {' '} + (default 5). Requires{' '} + + gh + {' '} + CLI installed and authenticated. +

+
+
+

+ Task Pending{' '} + + task.pending + +

+

+ Polls markdown files for unchecked tasks ( + + - [ ] + + ). Requires{' '} + + watch + {' '} + glob pattern. Optional{' '} + + poll_minutes + {' '} + (default 1). Fires per file when content changes and pending tasks exist. +

+
+
+
+ # Interval +
+ - name: "Periodic Check" +
+ {' '}event: time.interval +
+ {' '}interval_minutes: 15 +
+
+ # File Watch +
+ - name: "On File Change" +
+ {' '}event: file.changed +
+ {' '}watch: "src/**/*.ts" +
+
+ # Agent Completed +
+ - name: "Chain Reaction" +
+ {' '}event: agent.completed +
+ {' '}source_session: "my-agent" +
+
+ # GitHub PR +
+ - name: "Review PRs" +
+ {' '}event: github.pull_request +
+ {' '}poll_minutes: 5 +
+
+ # GitHub Issue +
+ - name: "Triage Issues" +
+ {' '}event: github.issue +
+ {' '}poll_minutes: 10 +
+
+ # Task Pending +
+ - name: "Process Tasks" +
+ {' '}event: task.pending +
+ {' '}watch: "tasks/**/*.md" +
+ {' '}poll_minutes: 1 +
+
+
+
+ + {/* Section 4: Event Filtering */} +
+
+ +

Event Filtering

+
+
+

+ Add a{' '} + + filter + {' '} + block to any subscription to narrow when it fires. All conditions must match (AND + logic). +

+ + + + + + + + + + {[ + ['"value"', 'Exact match', 'extension: ".ts"'], + ['"!value"', 'Not equal', 'status: "!archived"'], + ['">N"', 'Greater than', 'size: ">1000"'], + ['" ( + + + + + + ))} + +
+ Expression + + Meaning + + Example +
+ {expr} + + {meaning} + + {example} +
+
+ - name: "TypeScript changes only" +
+ {' '}event: file.changed +
+ {' '}watch: "src/**/*" +
+ {' '}filter: +
+ {' '}extension: ".ts" +
+ {' '}path: "!*.test.ts" +
+ {' '}prompt: prompts/ts-review.md +
+
+
+ + {/* Section 5: Template Variables */} +
+
+ +

Template Variables

+
+
+
+
+ {'{{CUE_EVENT_TYPE}}'} — Event + type (time.interval, file.changed, agent.completed, github.pull_request, github.issue, + task.pending) +
+
+ {'{{CUE_EVENT_TIMESTAMP}}'} — + Event timestamp +
+
+ {'{{CUE_TRIGGER_NAME}}'} — + Trigger/subscription name +
+
+ {'{{CUE_RUN_ID}}'} — Run UUID +
+
+ {'{{CUE_FILE_PATH}}'} — Changed + file path (file.changed) +
+
+ {'{{CUE_FILE_NAME}}'} — Changed + file name +
+
+ {'{{CUE_FILE_DIR}}'} — Changed + file directory +
+
+ {'{{CUE_FILE_EXT}}'} — Changed + file extension +
+
+ {'{{CUE_FILE_CHANGE_TYPE}}'} — + Change type: add, change, unlink (file.changed) +
+
+ {'{{CUE_SOURCE_SESSION}}'} — + Source session name (agent.completed) +
+
+ {'{{CUE_SOURCE_OUTPUT}}'} — Source + session output (agent.completed) +
+
+ {'{{CUE_SOURCE_STATUS}}'} — Source + run status: completed, failed, timeout (agent.completed) +
+
+ {'{{CUE_SOURCE_EXIT_CODE}}'} — + Source process exit code (agent.completed) +
+
+ {'{{CUE_SOURCE_DURATION}}'} — + Source run duration in ms (agent.completed) +
+
+ {'{{CUE_SOURCE_TRIGGERED_BY}}'} — + Subscription that triggered the source (agent.completed) +
+
+ {'{{CUE_TASK_FILE}}'} — File path + with pending tasks (task.pending) +
+
+ {'{{CUE_TASK_COUNT}}'} — Number of + pending tasks (task.pending) +
+
+ {'{{CUE_TASK_LIST}}'} — Formatted + task list with line numbers (task.pending) +
+
+ {'{{CUE_TASK_CONTENT}}'} — Full + file content, truncated (task.pending) +
+
+ {'{{CUE_GH_NUMBER}}'} — PR/issue + number (github.*) +
+
+ {'{{CUE_GH_TITLE}}'} — PR/issue + title (github.*) +
+
+ {'{{CUE_GH_AUTHOR}}'} — Author + login (github.*) +
+
+ {'{{CUE_GH_URL}}'} — HTML URL + (github.*) +
+
+ {'{{CUE_GH_LABELS}}'} — Labels, + comma-separated (github.*) +
+
+ {'{{CUE_GH_REPO}}'} — Repo + (owner/repo) (github.*) +
+
+ {'{{CUE_GH_BRANCH}}'} — Head + branch (github.pull_request) +
+
+
+ + + All standard Maestro template variables ( + + {'{{AGENT_NAME}}'} + + ,{' '} + + {'{{DATE}}'} + + , etc.) are also available in Cue prompts. + +
+
+
+ + {/* Section 5: Multi-Agent Orchestration */} +
+
+ +

Multi-Agent Orchestration

+
+
+
+

+ Fan-Out: Trigger multiple + sessions from a single event. Add{' '} + + fan_out: ["session-1", "session-2"] + {' '} + to your subscription. +

+
+
+

+ Fan-In: Wait for multiple + sessions to complete before triggering. Set{' '} + + source_session + {' '} + to an array:{' '} + + ["session-1", "session-2"] + + . +

+
+
+ {' '}Event ──┬── Agent A (fan-out) +
+ {' '}├── Agent B +
+ {' '}└── Agent C +
+
+ {' '}Agent A ──┐ +
+ {' '}Agent B ──┼── Event (fan-in) +
+ {' '}Agent C ──┘ +
+
+
+ + {/* Section: Coordination Patterns */} +
+
+ +

Coordination Patterns

+
+
+

Maestro Cue supports several coordination patterns for multi-agent workflows.

+ +
+

+ Scheduled Task — + Single agent running on a timer. +

+
+ [Timer] → [Agent] +
+
+ +
+

+ File Enrichment — + React to file system changes. +

+
+ [File Change] → [Agent] +
+
+ +
+

+ Research Swarm — + Fan-out to multiple agents, fan-in to synthesize. +

+
+ [Timer] → [Agent 1, Agent 2, Agent 3] → [Synthesizer] +
+
+ +
+

+ Sequential Chain — + Pipeline where each agent triggers the next. +

+
+ [Agent A] → [Agent B] → [Agent C] +
+
+ +
+

+ Debate — Multiple + perspectives, then moderator synthesizes. +

+
+ [Moderator] → [Pro, Con] → [Moderator] +
+
+ +
+

+ Task Queue — Watch + markdown files for unchecked tasks and process them. +

+
+ [tasks/*.md] → [Agent] (per file with pending tasks) +
+
+ +
+ + + Use the Pipeline Editor to visually build these patterns by dragging and connecting + triggers and agents. + +
+
+
+ + {/* Section 6: Timeouts & Failure Handling */} +
+
+ +

Timeouts & Failure Handling

+
+
+

+ Default timeout is 30 minutes. If a run times out, the chain breaks and the failure is + logged. +

+

+ Set{' '} + + timeout_on_fail: continue + {' '} + in settings to skip failed sources and proceed anyway. +

+
+ settings: +
+ {' '}timeout_minutes: 60 +
+ {' '}timeout_on_fail: continue +
+
+
+ + {/* Section 7: Concurrency Control */} +
+
+ +

Concurrency Control

+
+
+

+ By default, each session runs one Cue task at a time. Additional events are queued (up + to 10) and processed as slots free. +

+

Stale queued events (older than the timeout) are automatically dropped.

+
+ settings: +
+ {' '}max_concurrent: 3{' '}# Up to 3 parallel runs +
+ {' '}queue_size: 20{' '}# Queue up to 20 events +
+ {' '}timeout_minutes: 30 +
+
+
+ + {/* Section 8: Sleep & Recovery */} +
+
+ +

Sleep & Recovery

+
+
+

+ Maestro Cue automatically detects when your computer sleeps and catches up on missed + time-based triggers when it wakes. File watchers re-initialize automatically. +

+

+ Catch-up events are marked with a{' '} + + catch-up + {' '} + badge in the activity log so you can distinguish them from regular triggers. +

+
+
+ + {/* Section 9: Visual Pipeline Editor */} +
+
+ +

Visual Pipeline Editor

+
+
+

+ The Pipeline Editor provides a visual canvas for building automation workflows. Drag + triggers and agents onto the canvas, connect them with edges, and organize them into + named pipelines with distinct colors. +

+

+ Left drawer: Trigger types + (interval, file watch, agent completed, GitHub, task pending) +
+ Right drawer: Available agents + from your sessions +
+ Pipeline selector: Create, + rename, and switch between pipelines +

+
+ + + Tip: Press{' '} + + {formatShortcutKeys(cueShortcutKeys ?? DEFAULT_SHORTCUTS.maestroCue.keys)} + {' '} + to open the Cue dashboard. The Pipeline Editor is the default tab. + +
+
+
+
+ ); +} diff --git a/src/renderer/components/CueModal.tsx b/src/renderer/components/CueModal.tsx new file mode 100644 index 000000000..5b627d0d4 --- /dev/null +++ b/src/renderer/components/CueModal.tsx @@ -0,0 +1,767 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { + X, + Zap, + Square, + HelpCircle, + StopCircle, + LayoutDashboard, + GitFork, + ArrowLeft, +} from 'lucide-react'; +import type { Theme } from '../types'; +import { useLayerStack } from '../contexts/LayerStackContext'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { useCue } from '../hooks/useCue'; +import type { CueSessionStatus, CueRunResult } from '../hooks/useCue'; +// CueYamlEditor kept for future use - visual pipeline editor is the primary interface +// import { CueYamlEditor } from './CueYamlEditor'; +import { CueHelpContent } from './CueHelpModal'; +// Kept for reference - visual pipeline editor replaces this +// import { CueGraphView } from './CueGraphView'; +import { CuePipelineEditor } from './CuePipelineEditor'; +import { useSessionStore } from '../stores/sessionStore'; +import type { CuePipeline } from '../../shared/cue-pipeline-types'; +import { getPipelineColorForAgent } from './CuePipelineEditor/pipelineColors'; +import { graphSessionsToPipelines } from './CuePipelineEditor/utils/yamlToPipeline'; + +type CueModalTab = 'dashboard' | 'pipeline'; + +interface CueGraphSession { + sessionId: string; + sessionName: string; + toolType: string; + subscriptions: Array<{ + name: string; + event: string; + enabled: boolean; + prompt?: string; + source_session?: string | string[]; + fan_out?: string[]; + }>; +} + +interface CueModalProps { + theme: Theme; + onClose: () => void; + cueShortcutKeys?: string[]; +} + +const CUE_TEAL = '#06b6d4'; + +function formatRelativeTime(dateStr?: string): string { + if (!dateStr) return '—'; + const diff = Date.now() - new Date(dateStr).getTime(); + if (diff < 0) return 'just now'; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainSeconds = seconds % 60; + return `${minutes}m ${remainSeconds}s`; +} + +function formatElapsed(startedAt: string): string { + const diff = Date.now() - new Date(startedAt).getTime(); + return formatDuration(Math.max(0, diff)); +} + +function StatusDot({ status }: { status: 'active' | 'paused' | 'none' }) { + const color = status === 'active' ? '#22c55e' : status === 'paused' ? '#eab308' : '#6b7280'; + return ; +} + +function PipelineDot({ color, name }: { color: string; name: string }) { + return ( + + ); +} + +/** Maps subscription names to pipeline info by checking name prefixes. */ +function buildSubscriptionPipelineMap( + pipelines: CuePipeline[] +): Map { + const map = new Map(); + for (const pipeline of pipelines) { + // Pipeline subscriptions are named: pipelineName, pipelineName-chain-N + map.set(pipeline.name, { name: pipeline.name, color: pipeline.color }); + } + return map; +} + +/** Looks up the pipeline for a subscription name by matching the base name prefix. */ +function getPipelineForSubscription( + subscriptionName: string, + pipelineMap: Map +): { name: string; color: string } | null { + // Strip -chain-N suffix to get base pipeline name + const baseName = subscriptionName.replace(/-chain-\d+$/, '').replace(/-fanin$/, ''); + return pipelineMap.get(baseName) ?? null; +} + +function SessionsTable({ + sessions, + theme, + onViewInPipeline, + queueStatus, + pipelines, +}: { + sessions: CueSessionStatus[]; + theme: Theme; + onViewInPipeline: (session: CueSessionStatus) => void; + queueStatus: Record; + pipelines: CuePipeline[]; +}) { + if (sessions.length === 0) { + return ( +
+ No sessions have a maestro-cue.yaml file. Create one in your project root to get started. +
+ ); + } + + return ( + + + + + + + + + + + + + + + {sessions.map((s) => { + const status = !s.enabled ? 'paused' : s.subscriptionCount > 0 ? 'active' : 'none'; + return ( + + + + + + + + + + + ); + })} + +
SessionAgentPipelinesStatusLast TriggeredSubsQueue
+ {s.sessionName} + + {s.toolType} + + {(() => { + const colors = getPipelineColorForAgent(s.sessionId, pipelines); + if (colors.length === 0) { + return ; + } + const pipelineNames = pipelines + .filter((p) => colors.includes(p.color)) + .map((p) => p.name); + return ( + + {colors.map((color, i) => ( + + ))} + + ); + })()} + + + + + {status === 'active' ? 'Active' : status === 'paused' ? 'Paused' : 'No Config'} + + + + {formatRelativeTime(s.lastTriggered)} + + {s.subscriptionCount} + + {queueStatus[s.sessionId] ? `${queueStatus[s.sessionId]} queued` : '—'} + + +
+ ); +} + +function ActiveRunsList({ + runs, + theme, + onStopRun, + onStopAll, + subscriptionPipelineMap, +}: { + runs: CueRunResult[]; + theme: Theme; + onStopRun: (runId: string) => void; + onStopAll: () => void; + subscriptionPipelineMap: Map; +}) { + if (runs.length === 0) { + return ( +
+ No active runs +
+ ); + } + + return ( +
+ {runs.length > 1 && ( +
+ +
+ )} + {runs.map((run) => ( +
+ +
+ {(() => { + const pInfo = getPipelineForSubscription( + run.subscriptionName, + subscriptionPipelineMap + ); + return pInfo ? : null; + })()} + {run.sessionName} + + "{run.subscriptionName}" +
+ + {formatElapsed(run.startedAt)} + +
+ ))} +
+ ); +} + +function ActivityLog({ + log, + theme, + subscriptionPipelineMap, +}: { + log: CueRunResult[]; + theme: Theme; + subscriptionPipelineMap: Map; +}) { + const [visibleCount, setVisibleCount] = useState(100); + + if (log.length === 0) { + return ( +
+ No activity yet +
+ ); + } + + const visible = log.slice(0, visibleCount); + + return ( +
+ {visible.map((entry) => { + const isFailed = entry.status === 'failed' || entry.status === 'timeout'; + const eventType = entry.event.type; + const filePayload = + eventType === 'file.changed' && + (entry.event.payload?.filename || entry.event.payload?.path) + ? ` (${String(entry.event.payload.filename ?? entry.event.payload.path) + .split('/') + .pop()})` + : ''; + const taskPayload = + eventType === 'task.pending' && entry.event.payload?.filename + ? ` (${String(entry.event.payload.filename)}: ${String(entry.event.payload.taskCount ?? 0)} task(s))` + : ''; + const githubPayload = + (eventType === 'github.pull_request' || eventType === 'github.issue') && + entry.event.payload?.number + ? ` (#${String(entry.event.payload.number)} ${String(entry.event.payload.title ?? '')})` + : ''; + const isReconciled = entry.event.payload?.reconciled === true; + + return ( +
+ + {new Date(entry.startedAt).toLocaleTimeString()} + + {(() => { + const pInfo = getPipelineForSubscription( + entry.subscriptionName, + subscriptionPipelineMap + ); + return pInfo ? ( + + ) : ( + + ); + })()} + + "{entry.subscriptionName}" + {isReconciled && ( + + catch-up + + )} + + {' '} + triggered ({eventType}){filePayload} + {taskPayload} + {githubPayload} →{' '} + + {isFailed ? ( + {entry.status} ✗ + ) : entry.status === 'stopped' ? ( + stopped + ) : ( + + completed in {formatDuration(entry.durationMs)} ✓ + + )} + +
+ ); + })} + {log.length > visibleCount && ( + + )} +
+ ); +} + +export function CueModal({ theme, onClose, cueShortcutKeys }: CueModalProps) { + const { registerLayer, unregisterLayer } = useLayerStack(); + const layerIdRef = useRef(); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const { + sessions, + activeRuns, + activityLog, + queueStatus, + loading, + enable, + disable, + stopRun, + stopAll, + } = useCue(); + + const allSessions = useSessionStore((state) => state.sessions); + const setActiveSessionId = useSessionStore((state) => state.setActiveSessionId); + + const sessionInfoList = useMemo( + () => + allSessions.map((s) => ({ + id: s.id, + name: s.name, + toolType: s.toolType, + projectRoot: s.projectRoot, + })), + [allSessions] + ); + + const [graphSessions, setGraphSessions] = useState([]); + + const handleSwitchToSession = useCallback( + (id: string) => { + setActiveSessionId(id); + onClose(); + }, + [setActiveSessionId, onClose] + ); + + const isEnabled = sessions.some((s) => s.enabled); + + const handleToggle = useCallback(() => { + if (isEnabled) { + disable(); + } else { + enable(); + } + }, [isEnabled, enable, disable]); + + // Register layer on mount + useEffect(() => { + const id = registerLayer({ + type: 'modal', + priority: MODAL_PRIORITIES.CUE_MODAL, + blocksLowerLayers: true, + capturesFocus: true, + focusTrap: 'strict', + onEscape: () => { + onCloseRef.current(); + }, + }); + layerIdRef.current = id; + + return () => { + if (layerIdRef.current) { + unregisterLayer(layerIdRef.current); + } + }; + }, [registerLayer, unregisterLayer]); + + // Tab state + const [activeTab, setActiveTab] = useState('pipeline'); + + // Fetch graph data on mount and when tab changes (needed for both dashboard and pipeline tabs) + useEffect(() => { + let cancelled = false; + window.maestro.cue + .getGraphData() + .then((data: CueGraphSession[]) => { + if (!cancelled) setGraphSessions(data); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [activeTab]); + + // Compute pipelines from graph sessions for dashboard pipeline info + const dashboardPipelines = useMemo(() => { + if (graphSessions.length === 0) return []; + return graphSessionsToPipelines(graphSessions, sessionInfoList); + }, [graphSessions, sessionInfoList]); + + // Build subscription-to-pipeline lookup map + const subscriptionPipelineMap = useMemo( + () => buildSubscriptionPipelineMap(dashboardPipelines), + [dashboardPipelines] + ); + + // Help modal state + const [showHelp, setShowHelp] = useState(false); + + const handleViewInPipeline = useCallback((_session: CueSessionStatus) => { + setActiveTab('pipeline'); + }, []); + + // Active runs section is collapsible when empty + const [activeRunsExpanded, setActiveRunsExpanded] = useState(true); + + return ( + <> + {createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > + {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ {showHelp ? ( + <> + + +

+ Maestro Cue Guide +

+ + ) : ( + <> + +

+ Maestro Cue +

+ + {/* Tab bar */} +
+ + +
+ + )} +
+
+ {!showHelp && ( + <> + {/* Master toggle */} + + + {/* Help button */} + + + )} + + {/* Close button */} + +
+
+ + {/* Body */} + {showHelp ? ( +
+ +
+ ) : activeTab === 'dashboard' ? ( +
+ {loading ? ( +
+ Loading Cue status... +
+ ) : ( + <> + {/* Section 1: Sessions with Cue */} +
+

+ Sessions with Cue +

+ +
+ + {/* Section 2: Active Runs */} +
+ + {activeRunsExpanded && ( + + )} +
+ + {/* Section 3: Activity Log */} +
+

+ Activity Log +

+
+ +
+
+ + )} +
+ ) : ( + + )} +
+
, + document.body + )} + {/* CueYamlEditor kept for future use - visual pipeline editor is the primary interface */} + + ); +} diff --git a/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx new file mode 100644 index 000000000..cab211cf4 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/CuePipelineEditor.tsx @@ -0,0 +1,1558 @@ +/** + * CuePipelineEditor — React Flow-based visual pipeline editor for Maestro Cue. + * + * Replaces the canvas-based CueGraphView with a React Flow canvas that supports + * visual pipeline construction: dragging triggers and agents onto the canvas, + * connecting them, and managing named pipelines with distinct colors. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import ReactFlow, { + Background, + Controls, + MiniMap, + ReactFlowProvider, + MarkerType, + useReactFlow, + applyNodeChanges, + applyEdgeChanges, + type Node, + type Edge, + type OnNodesChange, + type OnEdgesChange, + type Connection, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import { Zap, Bot, Save, RotateCcw, Check, AlertTriangle } from 'lucide-react'; +import type { Theme } from '../../types'; +import type { + CuePipelineState, + CuePipeline, + PipelineNode, + PipelineEdge as PipelineEdgeType, + PipelineLayoutState, + TriggerNodeData, + AgentNodeData, + CueEventType, +} from '../../../shared/cue-pipeline-types'; +import { TriggerNode, type TriggerNodeDataProps } from './nodes/TriggerNode'; +import { AgentNode, type AgentNodeDataProps } from './nodes/AgentNode'; +import { edgeTypes } from './edges/PipelineEdge'; +import type { PipelineEdgeData } from './edges/PipelineEdge'; +import { TriggerDrawer } from './drawers/TriggerDrawer'; +import { AgentDrawer } from './drawers/AgentDrawer'; +import { PipelineSelector } from './PipelineSelector'; +import { getNextPipelineColor } from './pipelineColors'; +import { NodeConfigPanel } from './panels/NodeConfigPanel'; +import { EdgeConfigPanel } from './panels/EdgeConfigPanel'; +import { graphSessionsToPipelines } from './utils/yamlToPipeline'; +import { pipelinesToYaml } from './utils/pipelineToYaml'; + +interface CueGraphSession { + sessionId: string; + sessionName: string; + toolType: string; + subscriptions: Array<{ + name: string; + event: string; + enabled: boolean; + prompt?: string; + source_session?: string | string[]; + fan_out?: string[]; + }>; +} + +interface SessionInfo { + id: string; + name: string; + toolType: string; + projectRoot?: string; +} + +interface ActiveRunInfo { + subscriptionName: string; + sessionName: string; +} + +export interface CuePipelineEditorProps { + sessions: SessionInfo[]; + graphSessions: CueGraphSession[]; + onSwitchToSession: (id: string) => void; + onClose: () => void; + theme: Theme; + activeRuns?: ActiveRunInfo[]; +} + +const nodeTypes = { + trigger: TriggerNode, + agent: AgentNode, +}; + +const DEFAULT_TRIGGER_LABELS: Record = { + 'time.interval': 'Scheduled', + 'file.changed': 'File Change', + 'agent.completed': 'Agent Done', + 'github.pull_request': 'Pull Request', + 'github.issue': 'Issue', + 'task.pending': 'Pending Task', +}; + +function getTriggerConfigSummary(data: TriggerNodeData): string { + const { eventType, config } = data; + switch (eventType) { + case 'time.interval': + return config.interval_minutes ? `every ${config.interval_minutes}min` : 'interval'; + case 'file.changed': + return config.watch ?? '**/*'; + case 'github.pull_request': + case 'github.issue': + return config.repo ?? 'repo'; + case 'task.pending': + return config.watch ?? 'tasks'; + case 'agent.completed': + return 'agent done'; + default: + return ''; + } +} + +function convertToReactFlowNodes( + pipelines: CuePipelineState['pipelines'], + selectedPipelineId: string | null, + onConfigureNode?: (compositeId: string) => void +): Node[] { + const nodes: Node[] = []; + const agentPipelineMap = new Map(); + + // First pass: compute pipeline colors per agent (by sessionId) + for (const pipeline of pipelines) { + for (const pNode of pipeline.nodes) { + if (pNode.type === 'agent') { + const agentData = pNode.data as AgentNodeData; + const existing = agentPipelineMap.get(agentData.sessionId) ?? []; + if (!existing.includes(pipeline.color)) { + existing.push(pipeline.color); + } + agentPipelineMap.set(agentData.sessionId, existing); + } + } + } + + // Count pipelines per agent + const agentPipelineCount = new Map(); + for (const pipeline of pipelines) { + for (const pNode of pipeline.nodes) { + if (pNode.type === 'agent') { + const agentData = pNode.data as AgentNodeData; + agentPipelineCount.set( + agentData.sessionId, + (agentPipelineCount.get(agentData.sessionId) ?? 0) + 1 + ); + } + } + } + + // Track which agent sessionIds are in the selected pipeline (for shared agent dimming) + const selectedPipelineAgentIds = new Set(); + if (selectedPipelineId) { + const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId); + if (selectedPipeline) { + for (const pNode of selectedPipeline.nodes) { + if (pNode.type === 'agent') { + selectedPipelineAgentIds.add((pNode.data as AgentNodeData).sessionId); + } + } + } + } + + for (const pipeline of pipelines) { + const isActive = selectedPipelineId === null || pipeline.id === selectedPipelineId; + + for (const pNode of pipeline.nodes) { + if (pNode.type === 'trigger') { + // Triggers from non-selected pipelines are hidden + if (!isActive) continue; + + const triggerData = pNode.data as TriggerNodeData; + const compositeId = `${pipeline.id}:${pNode.id}`; + const nodeData: TriggerNodeDataProps = { + eventType: triggerData.eventType, + label: triggerData.label, + configSummary: getTriggerConfigSummary(triggerData), + onConfigure: onConfigureNode ? () => onConfigureNode(compositeId) : undefined, + }; + nodes.push({ + id: compositeId, + type: 'trigger', + position: pNode.position, + data: nodeData, + dragHandle: '.drag-handle', + }); + } else { + const agentData = pNode.data as AgentNodeData; + const isShared = (agentPipelineCount.get(agentData.sessionId) ?? 1) > 1; + + // Non-selected pipeline: hide non-shared agents, dim shared ones + if (!isActive) { + if (!isShared) continue; + if (!selectedPipelineAgentIds.has(agentData.sessionId)) continue; + } + + const pipelineColors = agentPipelineMap.get(agentData.sessionId) ?? [pipeline.color]; + const compositeId = `${pipeline.id}:${pNode.id}`; + const nodeData: AgentNodeDataProps = { + sessionId: agentData.sessionId, + sessionName: agentData.sessionName, + toolType: agentData.toolType, + hasPrompt: !!agentData.prompt, + pipelineColor: pipeline.color, + pipelineCount: agentPipelineCount.get(agentData.sessionId) ?? 1, + pipelineColors, + onConfigure: onConfigureNode ? () => onConfigureNode(compositeId) : undefined, + }; + nodes.push({ + id: compositeId, + type: 'agent', + position: pNode.position, + data: nodeData, + dragHandle: '.drag-handle', + style: !isActive ? { opacity: 0.4 } : undefined, + }); + } + } + } + + return nodes; +} + +function convertToReactFlowEdges( + pipelines: CuePipelineState['pipelines'], + selectedPipelineId: string | null, + runningPipelineIds?: Set +): Edge[] { + const edges: Edge[] = []; + + for (const pipeline of pipelines) { + const isActive = selectedPipelineId === null || pipeline.id === selectedPipelineId; + const isRunning = runningPipelineIds?.has(pipeline.id) ?? false; + + for (const pEdge of pipeline.edges) { + const edgeData: PipelineEdgeData = { + pipelineColor: pipeline.color, + mode: pEdge.mode, + isActivePipeline: isActive, + isRunning, + }; + edges.push({ + id: `${pipeline.id}:${pEdge.id}`, + source: `${pipeline.id}:${pEdge.source}`, + target: `${pipeline.id}:${pEdge.target}`, + type: 'pipeline', + data: edgeData, + markerEnd: { + type: MarkerType.ArrowClosed, + color: pipeline.color, + width: 16, + height: 16, + }, + }); + } + } + + return edges; +} + +/** Validates pipeline graph before save. Returns array of error messages. */ +function validatePipelines(pipelines: CuePipeline[]): string[] { + const errors: string[] = []; + + for (const pipeline of pipelines) { + const triggers = pipeline.nodes.filter((n) => n.type === 'trigger'); + const agents = pipeline.nodes.filter((n) => n.type === 'agent'); + + if (triggers.length === 0 && agents.length === 0) continue; // Empty pipeline, skip + + if (triggers.length === 0) { + errors.push(`"${pipeline.name}": needs at least one trigger`); + } + if (agents.length === 0) { + errors.push(`"${pipeline.name}": needs at least one agent`); + } + + // Check for disconnected agents (no incoming edge) + const targetsWithIncoming = new Set(pipeline.edges.map((e) => e.target)); + for (const agent of agents) { + if (!targetsWithIncoming.has(agent.id)) { + const name = (agent.data as AgentNodeData).sessionName; + errors.push(`"${pipeline.name}": agent "${name}" has no incoming connection`); + } + } + + // Check for cycles via topological sort + const adjList = new Map(); + const inDegree = new Map(); + for (const node of pipeline.nodes) { + adjList.set(node.id, []); + inDegree.set(node.id, 0); + } + for (const edge of pipeline.edges) { + adjList.get(edge.source)?.push(edge.target); + inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1); + } + const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id); + let visited = 0; + while (queue.length > 0) { + const id = queue.shift()!; + visited++; + for (const neighbor of adjList.get(id) ?? []) { + const newDeg = (inDegree.get(neighbor) ?? 1) - 1; + inDegree.set(neighbor, newDeg); + if (newDeg === 0) queue.push(neighbor); + } + } + if (visited < pipeline.nodes.length) { + errors.push(`"${pipeline.name}": contains a cycle`); + } + } + + return errors; +} + +function CuePipelineEditorInner({ + sessions, + graphSessions, + onSwitchToSession, + theme, + activeRuns: activeRunsProp, +}: CuePipelineEditorProps) { + const reactFlowInstance = useReactFlow(); + + const [pipelineState, setPipelineState] = useState({ + pipelines: [], + selectedPipelineId: null, + }); + + const [triggerDrawerOpen, setTriggerDrawerOpen] = useState(false); + const [agentDrawerOpen, setAgentDrawerOpen] = useState(false); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + + // Context menu state + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + nodeId: string; + pipelineId: string; + nodeType: 'trigger' | 'agent'; + } | null>(null); + + // Save/load state + const [isDirty, setIsDirty] = useState(false); + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle'); + const [validationErrors, setValidationErrors] = useState([]); + const savedStateRef = useRef(''); // JSON snapshot for dirty tracking + const layoutSaveTimerRef = useRef | null>(null); + + // Debounced layout persistence (positions + viewport) + const persistLayout = useCallback(() => { + if (layoutSaveTimerRef.current) clearTimeout(layoutSaveTimerRef.current); + layoutSaveTimerRef.current = setTimeout(() => { + const viewport = reactFlowInstance.getViewport(); + const layout: PipelineLayoutState = { + pipelines: pipelineState.pipelines, + selectedPipelineId: pipelineState.selectedPipelineId, + viewport, + }; + window.maestro.cue + .savePipelineLayout(layout as unknown as Record) + .catch(() => {}); + }, 500); + }, [pipelineState, reactFlowInstance]); + + // Clean up debounce timer on unmount + useEffect(() => { + return () => { + if (layoutSaveTimerRef.current) clearTimeout(layoutSaveTimerRef.current); + }; + }, []); + + // Track whether we've applied saved layout positions yet + const hasRestoredLayoutRef = useRef(false); + + // Load pipelines from saved layout (positions) merged with live graph data + useEffect(() => { + if (!graphSessions || graphSessions.length === 0) return; + + const loadLayout = async () => { + const livePipelines = graphSessionsToPipelines(graphSessions, sessions); + if (livePipelines.length === 0) return; + + let savedLayout: PipelineLayoutState | null = null; + if (!hasRestoredLayoutRef.current) { + try { + savedLayout = + (await window.maestro.cue.loadPipelineLayout()) as PipelineLayoutState | null; + } catch { + // No saved layout + } + } + + if (savedLayout && savedLayout.pipelines) { + // Merge: live data takes precedence for node existence, saved provides positions + const savedPositions = new Map(); + for (const sp of savedLayout.pipelines) { + 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; + }), + })); + + setPipelineState({ + pipelines: mergedPipelines, + selectedPipelineId: savedLayout.selectedPipelineId ?? mergedPipelines[0]?.id ?? null, + }); + savedStateRef.current = JSON.stringify(mergedPipelines); + + // Restore viewport if available + if (savedLayout.viewport && !hasRestoredLayoutRef.current) { + setTimeout(() => { + reactFlowInstance.setViewport(savedLayout!.viewport!); + }, 100); + } + } else { + setPipelineState({ pipelines: livePipelines, selectedPipelineId: livePipelines[0].id }); + savedStateRef.current = JSON.stringify(livePipelines); + } + + hasRestoredLayoutRef.current = true; + setIsDirty(false); + }; + + loadLayout(); + }, [graphSessions, sessions]); + + // Track dirty state when pipelines change + useEffect(() => { + const currentSnapshot = JSON.stringify(pipelineState.pipelines); + if (savedStateRef.current && currentSnapshot !== savedStateRef.current) { + setIsDirty(true); + setValidationErrors([]); + } + }, [pipelineState.pipelines]); + + const handleSave = useCallback(async () => { + // Validate before save + const errors = validatePipelines(pipelineState.pipelines); + setValidationErrors(errors); + if (errors.length > 0) return; + + setSaveStatus('saving'); + try { + const yamlContent = pipelinesToYaml(pipelineState.pipelines); + + // Find unique project roots from sessions involved in pipelines + const sessionNames = new Set(); + for (const pipeline of pipelineState.pipelines) { + for (const node of pipeline.nodes) { + if (node.type === 'agent') { + sessionNames.add((node.data as AgentNodeData).sessionName); + } + } + } + + const projectRoots = new Set(); + for (const session of sessions) { + if (session.projectRoot && sessionNames.has(session.name)) { + projectRoots.add(session.projectRoot); + } + } + + // If no specific project roots found, use first session's project root + if (projectRoots.size === 0 && sessions.length > 0) { + const firstWithRoot = sessions.find((s) => s.projectRoot); + if (firstWithRoot?.projectRoot) { + projectRoots.add(firstWithRoot.projectRoot); + } + } + + // Write YAML and refresh sessions + for (const root of projectRoots) { + await window.maestro.cue.writeYaml(root, yamlContent); + } + + // Refresh all sessions involved + for (const session of sessions) { + if ( + session.projectRoot && + (projectRoots.has(session.projectRoot) || sessionNames.has(session.name)) + ) { + await window.maestro.cue.refreshSession(session.id, session.projectRoot); + } + } + + savedStateRef.current = JSON.stringify(pipelineState.pipelines); + setIsDirty(false); + setSaveStatus('success'); + persistLayout(); + setTimeout(() => setSaveStatus('idle'), 2000); + } catch { + setSaveStatus('error'); + setTimeout(() => setSaveStatus('idle'), 3000); + } + }, [pipelineState.pipelines, sessions]); + + const handleDiscard = useCallback(async () => { + try { + const data = await window.maestro.cue.getGraphData(); + if (data && data.length > 0) { + const pipelines = graphSessionsToPipelines(data, sessions); + setPipelineState({ + pipelines, + selectedPipelineId: pipelines.length > 0 ? pipelines[0].id : null, + }); + savedStateRef.current = JSON.stringify(pipelines); + } else { + setPipelineState({ pipelines: [], selectedPipelineId: null }); + savedStateRef.current = '[]'; + } + setIsDirty(false); + setValidationErrors([]); + } catch { + // Error reloading - keep current state + } + }, [sessions]); + + const createPipeline = useCallback(() => { + setPipelineState((prev) => { + const newPipeline: CuePipeline = { + id: `pipeline-${Date.now()}`, + name: `Pipeline ${prev.pipelines.length + 1}`, + color: getNextPipelineColor(prev.pipelines), + nodes: [], + edges: [], + }; + return { + pipelines: [...prev.pipelines, newPipeline], + selectedPipelineId: newPipeline.id, + }; + }); + }, []); + + const deletePipeline = useCallback((id: string) => { + setPipelineState((prev) => { + const pipeline = prev.pipelines.find((p) => p.id === id); + if (!pipeline) return prev; + + // Check if nodes are shared with other pipelines + const otherPipelines = prev.pipelines.filter((p) => p.id !== id); + const otherNodeIds = new Set(); + for (const p of otherPipelines) { + for (const n of p.nodes) { + if (n.type === 'agent') { + otherNodeIds.add((n.data as AgentNodeData).sessionId); + } + } + } + + const hasNodes = pipeline.nodes.length > 0; + if (hasNodes && !window.confirm(`Delete pipeline "${pipeline.name}" and its nodes?`)) { + return prev; + } + + const newSelectedId = prev.selectedPipelineId === id ? null : prev.selectedPipelineId; + + return { + pipelines: otherPipelines, + selectedPipelineId: newSelectedId, + }; + }); + }, []); + + const renamePipeline = useCallback((id: string, name: string) => { + setPipelineState((prev) => ({ + ...prev, + pipelines: prev.pipelines.map((p) => (p.id === id ? { ...p, name } : p)), + })); + }, []); + + const selectPipeline = useCallback((id: string | null) => { + setPipelineState((prev) => ({ ...prev, selectedPipelineId: id })); + }, []); + + // Determine which pipelines have active runs + const runningPipelineIds = useMemo(() => { + const ids = new Set(); + if (!activeRunsProp || activeRunsProp.length === 0) return ids; + for (const run of activeRunsProp) { + // Match subscription name to pipeline name (strip -chain-N, -fanin suffixes) + const baseName = run.subscriptionName.replace(/-chain-\d+$/, '').replace(/-fanin$/, ''); + for (const pipeline of pipelineState.pipelines) { + if (pipeline.name === baseName) { + ids.add(pipeline.id); + } + } + } + return ids; + }, [activeRunsProp, pipelineState.pipelines]); + + const handleConfigureNode = useCallback((compositeId: string) => { + setSelectedNodeId(compositeId); + setSelectedEdgeId(null); + }, []); + + const nodes = useMemo( + () => + convertToReactFlowNodes( + pipelineState.pipelines, + pipelineState.selectedPipelineId, + handleConfigureNode + ), + [pipelineState.pipelines, pipelineState.selectedPipelineId, handleConfigureNode] + ); + + const edges = useMemo( + () => + convertToReactFlowEdges( + pipelineState.pipelines, + pipelineState.selectedPipelineId, + runningPipelineIds + ), + [pipelineState.pipelines, pipelineState.selectedPipelineId, runningPipelineIds] + ); + + // Collect session IDs currently on canvas for the agent drawer indicator + const onCanvasSessionIds = useMemo(() => { + const ids = new Set(); + for (const pipeline of pipelineState.pipelines) { + for (const pNode of pipeline.nodes) { + if (pNode.type === 'agent') { + ids.add((pNode.data as AgentNodeData).sessionId); + } + } + } + return ids; + }, [pipelineState.pipelines]); + + // Resolve selected node/edge from pipeline state using the composite IDs + const { selectedNode, selectedNodePipelineId } = useMemo(() => { + if (!selectedNodeId) return { selectedNode: null, selectedNodePipelineId: null }; + // selectedNodeId is composite: "pipelineId:nodeId" + const sepIdx = selectedNodeId.indexOf(':'); + if (sepIdx === -1) return { selectedNode: null, selectedNodePipelineId: null }; + const pipelineId = selectedNodeId.substring(0, sepIdx); + const nodeId = selectedNodeId.substring(sepIdx + 1); + const pipeline = pipelineState.pipelines.find((p) => p.id === pipelineId); + const node = pipeline?.nodes.find((n) => n.id === nodeId); + return { selectedNode: node ?? null, selectedNodePipelineId: node ? pipelineId : null }; + }, [selectedNodeId, pipelineState.pipelines]); + + const { selectedEdge, selectedEdgePipelineId, selectedEdgePipelineColor } = useMemo(() => { + if (!selectedEdgeId) + return { + selectedEdge: null, + selectedEdgePipelineId: null, + selectedEdgePipelineColor: '#06b6d4', + }; + const sepIdx = selectedEdgeId.indexOf(':'); + if (sepIdx === -1) + return { + selectedEdge: null, + selectedEdgePipelineId: null, + selectedEdgePipelineColor: '#06b6d4', + }; + const pipelineId = selectedEdgeId.substring(0, sepIdx); + const edgeLocalId = selectedEdgeId.substring(sepIdx + 1); + const pipeline = pipelineState.pipelines.find((p) => p.id === pipelineId); + const edge = pipeline?.edges.find((e) => e.id === edgeLocalId); + return { + selectedEdge: edge ?? null, + selectedEdgePipelineId: edge ? pipelineId : null, + selectedEdgePipelineColor: pipeline?.color ?? '#06b6d4', + }; + }, [selectedEdgeId, pipelineState.pipelines]); + + // Resolve source/target nodes for the selected edge + const { edgeSourceNode, edgeTargetNode } = useMemo(() => { + if (!selectedEdge || !selectedEdgePipelineId) + return { edgeSourceNode: null, edgeTargetNode: null }; + const pipeline = pipelineState.pipelines.find((p) => p.id === selectedEdgePipelineId); + if (!pipeline) return { edgeSourceNode: null, edgeTargetNode: null }; + return { + edgeSourceNode: pipeline.nodes.find((n) => n.id === selectedEdge.source) ?? null, + edgeTargetNode: pipeline.nodes.find((n) => n.id === selectedEdge.target) ?? null, + }; + }, [selectedEdge, selectedEdgePipelineId, pipelineState.pipelines]); + + const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => { + setSelectedNodeId(node.id); + setSelectedEdgeId(null); + setContextMenu(null); + }, []); + + const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => { + setSelectedEdgeId(edge.id); + setSelectedNodeId(null); + setContextMenu(null); + }, []); + + const onPaneClick = useCallback(() => { + setSelectedNodeId(null); + setSelectedEdgeId(null); + setContextMenu(null); + }, []); + + const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => { + event.preventDefault(); + const sepIdx = node.id.indexOf(':'); + if (sepIdx === -1) return; + const pipelineId = node.id.substring(0, sepIdx); + const nodeId = node.id.substring(sepIdx + 1); + setContextMenu({ + x: event.clientX, + y: event.clientY, + nodeId, + pipelineId, + nodeType: node.type as 'trigger' | 'agent', + }); + }, []); + + const handleContextMenuConfigure = useCallback(() => { + if (!contextMenu) return; + setSelectedNodeId(`${contextMenu.pipelineId}:${contextMenu.nodeId}`); + setSelectedEdgeId(null); + setContextMenu(null); + }, [contextMenu]); + + const handleContextMenuDelete = useCallback(() => { + if (!contextMenu) return; + setPipelineState((prev) => ({ + ...prev, + pipelines: prev.pipelines.map((p) => { + if (p.id !== contextMenu.pipelineId) return p; + return { + ...p, + nodes: p.nodes.filter((n) => n.id !== contextMenu.nodeId), + edges: p.edges.filter( + (e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId + ), + }; + }), + })); + setSelectedNodeId(null); + setContextMenu(null); + }, [contextMenu]); + + const handleContextMenuDuplicate = useCallback(() => { + if (!contextMenu || contextMenu.nodeType !== 'trigger') return; + setPipelineState((prev) => { + const pipeline = prev.pipelines.find((p) => p.id === contextMenu.pipelineId); + if (!pipeline) return prev; + const original = pipeline.nodes.find((n) => n.id === contextMenu.nodeId); + if (!original || original.type !== 'trigger') return prev; + const newNode: PipelineNode = { + id: `trigger-${Date.now()}`, + type: 'trigger', + position: { x: original.position.x + 50, y: original.position.y + 50 }, + data: { ...(original.data as TriggerNodeData) }, + }; + return { + ...prev, + pipelines: prev.pipelines.map((p) => { + if (p.id !== contextMenu.pipelineId) return p; + return { ...p, nodes: [...p.nodes, newNode] }; + }), + }; + }); + setContextMenu(null); + }, [contextMenu]); + + const onUpdateNode = useCallback( + (nodeId: string, data: Partial) => { + if (!selectedNodePipelineId) return; + setPipelineState((prev) => ({ + ...prev, + pipelines: prev.pipelines.map((p) => { + if (p.id !== selectedNodePipelineId) return p; + return { + ...p, + nodes: p.nodes.map((n) => { + if (n.id !== nodeId) return n; + return { ...n, data: { ...n.data, ...data } }; + }), + }; + }), + })); + }, + [selectedNodePipelineId] + ); + + const onDeleteNode = useCallback( + (nodeId: string) => { + if (!selectedNodePipelineId) return; + setPipelineState((prev) => ({ + ...prev, + pipelines: prev.pipelines.map((p) => { + if (p.id !== selectedNodePipelineId) return p; + return { + ...p, + nodes: p.nodes.filter((n) => n.id !== nodeId), + edges: p.edges.filter((e) => e.source !== nodeId && e.target !== nodeId), + }; + }), + })); + setSelectedNodeId(null); + }, + [selectedNodePipelineId] + ); + + const onUpdateEdge = useCallback( + (edgeId: string, updates: Partial) => { + if (!selectedEdgePipelineId) return; + setPipelineState((prev) => ({ + ...prev, + pipelines: prev.pipelines.map((p) => { + if (p.id !== selectedEdgePipelineId) return p; + return { + ...p, + edges: p.edges.map((e) => { + if (e.id !== edgeId) return e; + return { ...e, ...updates }; + }), + }; + }), + })); + }, + [selectedEdgePipelineId] + ); + + const onDeleteEdge = useCallback( + (edgeId: string) => { + if (!selectedEdgePipelineId) return; + setPipelineState((prev) => ({ + ...prev, + pipelines: prev.pipelines.map((p) => { + if (p.id !== selectedEdgePipelineId) return p; + return { + ...p, + edges: p.edges.filter((e) => e.id !== edgeId), + }; + }), + })); + setSelectedEdgeId(null); + }, + [selectedEdgePipelineId] + ); + + // Keyboard shortcuts: Delete/Backspace, Escape, Cmd+S, Cmd+Z + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't intercept if user is typing in an input + const target = e.target as HTMLElement; + const isInput = + target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT'; + + if (e.key === 'Delete' || e.key === 'Backspace') { + if (isInput) return; + + if (selectedNode && selectedNodePipelineId) { + e.preventDefault(); + onDeleteNode(selectedNode.id); + } else if (selectedEdge && selectedEdgePipelineId) { + e.preventDefault(); + onDeleteEdge(selectedEdge.id); + } + } else if (e.key === 'Escape') { + if (triggerDrawerOpen) { + setTriggerDrawerOpen(false); + } else if (agentDrawerOpen) { + setAgentDrawerOpen(false); + } else if (selectedNodeId || selectedEdgeId) { + setSelectedNodeId(null); + setSelectedEdgeId(null); + } + } else if (e.key === 's' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSave(); + } + // TODO: Cmd+Z / Ctrl+Z undo support + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + selectedNode, + selectedNodePipelineId, + selectedEdge, + selectedEdgePipelineId, + selectedNodeId, + selectedEdgeId, + onDeleteNode, + onDeleteEdge, + triggerDrawerOpen, + agentDrawerOpen, + handleSave, + ]); + + const onNodesChange: OnNodesChange = useCallback( + (changes) => { + // Apply position/selection changes from React Flow back to pipeline state + const updatedRFNodes = applyNodeChanges(changes, nodes); + const hasPositionChange = changes.some((c) => c.type === 'position' && c.dragging === false); + setPipelineState((prev) => { + const newPipelines = prev.pipelines.map((pipeline) => ({ + ...pipeline, + nodes: pipeline.nodes.map((pNode) => { + const rfNode = updatedRFNodes.find((n) => n.id === `${pipeline.id}:${pNode.id}`); + if (rfNode) { + return { ...pNode, position: rfNode.position }; + } + return pNode; + }), + })); + return { ...prev, pipelines: newPipelines }; + }); + // Debounce-save layout when a drag ends + if (hasPositionChange) { + persistLayout(); + } + }, + [nodes, persistLayout] + ); + + const onEdgesChange: OnEdgesChange = useCallback( + (changes) => { + applyEdgeChanges(changes, edges); + }, + [edges] + ); + + const onConnect = useCallback( + (connection: Connection) => { + if (!connection.source || !connection.target) return; + + // Validate: trigger nodes (source-only) should not be targets + const sourceNode = nodes.find((n) => n.id === connection.source); + const targetNode = nodes.find((n) => n.id === connection.target); + if (!sourceNode || !targetNode) return; + if (targetNode.type === 'trigger') return; // Can't connect into a trigger + + setPipelineState((prev) => { + // Find the pipeline that contains the source node + const sourcePipelineId = connection.source!.split(':')[0]; + const targetPipelineId = connection.target!.split(':')[0]; + if (sourcePipelineId !== targetPipelineId) return prev; // Cross-pipeline connections not supported + + const newPipelines = prev.pipelines.map((pipeline) => { + if (pipeline.id !== sourcePipelineId) return pipeline; + + const sourceNodeId = connection.source!.split(':').slice(1).join(':'); + const targetNodeId = connection.target!.split(':').slice(1).join(':'); + + const newEdge = { + id: `edge-${Date.now()}`, + source: sourceNodeId, + target: targetNodeId, + mode: 'pass' as const, + }; + + return { ...pipeline, edges: [...pipeline.edges, newEdge] }; + }); + + return { ...prev, pipelines: newPipelines }; + }); + }, + [nodes] + ); + + // Connection validation: prevent invalid edges + const isValidConnection = useCallback( + (connection: Connection) => { + if (!connection.source || !connection.target) return false; + // Prevent self-connections + if (connection.source === connection.target) return false; + + const sourceNode = nodes.find((n) => n.id === connection.source); + const targetNode = nodes.find((n) => n.id === connection.target); + if (!sourceNode || !targetNode) return false; + + // Prevent trigger-to-trigger connections + if (sourceNode.type === 'trigger' && targetNode.type === 'trigger') return false; + + // Prevent connecting into a trigger + if (targetNode.type === 'trigger') return false; + + // Prevent duplicate edges + const exists = edges.some( + (e) => e.source === connection.source && e.target === connection.target + ); + if (exists) return false; + + return true; + }, + [nodes, edges] + ); + + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + + const raw = event.dataTransfer.getData('application/cue-pipeline'); + if (!raw) return; + + let dropData: { + type: string; + eventType?: CueEventType; + label?: string; + sessionId?: string; + sessionName?: string; + toolType?: string; + }; + try { + dropData = JSON.parse(raw); + } catch { + return; + } + + const position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + setPipelineState((prev) => { + let targetPipeline: CuePipeline; + let pipelines = prev.pipelines; + const selectedId = prev.selectedPipelineId; + + if (selectedId) { + const found = pipelines.find((p) => p.id === selectedId); + if (found) { + targetPipeline = found; + } else { + return prev; + } + } else if (pipelines.length > 0) { + targetPipeline = pipelines[0]; + } else { + // Create a new pipeline + targetPipeline = { + id: `pipeline-${Date.now()}`, + name: 'Pipeline 1', + color: getNextPipelineColor([]), + nodes: [], + edges: [], + }; + pipelines = [targetPipeline]; + } + + let newNode: PipelineNode; + + if (dropData.type === 'trigger' && dropData.eventType) { + const triggerData: TriggerNodeData = { + eventType: dropData.eventType, + label: + dropData.label ?? DEFAULT_TRIGGER_LABELS[dropData.eventType] ?? dropData.eventType, + config: {}, + }; + newNode = { + id: `trigger-${Date.now()}`, + type: 'trigger', + position, + data: triggerData, + }; + } else if (dropData.type === 'agent' && dropData.sessionId) { + const agentData: AgentNodeData = { + sessionId: dropData.sessionId, + sessionName: dropData.sessionName ?? 'Agent', + toolType: dropData.toolType ?? 'unknown', + }; + newNode = { + id: `agent-${dropData.sessionId}-${Date.now()}`, + type: 'agent', + position, + data: agentData, + }; + } else { + return prev; + } + + const updatedPipelines = pipelines.map((p) => { + if (p.id === targetPipeline.id) { + return { ...p, nodes: [...p.nodes, newNode] }; + } + return p; + }); + + // If targetPipeline was newly created, it won't be in the map yet + if (!pipelines.some((p) => p.id === targetPipeline.id)) { + targetPipeline.nodes.push(newNode); + updatedPipelines.push(targetPipeline); + } + + // Auto-select the dropped node to open config panel + const compositeId = `${targetPipeline.id}:${newNode.id}`; + setTimeout(() => { + setSelectedNodeId(compositeId); + setSelectedEdgeId(null); + }, 50); + + return { + pipelines: updatedPipelines, + selectedPipelineId: prev.selectedPipelineId ?? targetPipeline.id, + }; + }); + }, + [reactFlowInstance] + ); + + return ( +
+ {/* Toolbar */} +
+
+ +
+
+ +
+
+ + + {/* Discard Changes */} + {isDirty && ( + + )} + + {/* Save */} + +
+
+ + {/* Validation errors */} + {validationErrors.length > 0 && ( +
+ + {validationErrors.map((err, i) => ( + + {err} + {i < validationErrors.length - 1 ? ';' : ''} + + ))} +
+ )} + + {/* Canvas area with drawers */} +
+ {/* Trigger drawer (left) */} + setTriggerDrawerOpen(false)} + theme={theme} + /> + + {/* Empty state overlay */} + {nodes.length === 0 && ( +
+
+
+
+ + Triggers +
+
+ + + Drag a trigger from the left drawer and an agent from the right drawer to create + your first pipeline + +
+
+ + Agents +
+
+
+
+ )} + + {/* React Flow Canvas */} + + + + { + // Extract pipeline color from node data + if (node.type === 'trigger') { + const data = node.data as TriggerNodeDataProps; + // Use event type color palette + const eventColors: Record = { + 'time.interval': '#f59e0b', + 'file.changed': '#3b82f6', + 'agent.completed': '#22c55e', + 'github.pull_request': '#a855f7', + 'github.issue': '#f97316', + 'task.pending': '#06b6d4', + }; + return eventColors[data.eventType] ?? theme.colors.accent; + } + if (node.type === 'agent') { + const data = node.data as AgentNodeDataProps; + return data.pipelineColor ?? theme.colors.accent; + } + return theme.colors.accent; + }} + /> + + + {/* Agent drawer (right) */} + setAgentDrawerOpen(false)} + sessions={sessions} + onCanvasSessionIds={onCanvasSessionIds} + theme={theme} + /> + + {/* Pipeline legend (shown in All Pipelines view) */} + {pipelineState.selectedPipelineId === null && pipelineState.pipelines.length > 0 && ( +
+ {pipelineState.pipelines.map((p) => ( + + ))} +
+ )} + + {/* Config panels */} + {selectedNode && !selectedEdge && ( + + )} + {selectedEdge && !selectedNode && ( + + )} + + {/* Node context menu */} + {contextMenu && ( +
+
+ + {contextMenu.nodeType === 'trigger' && ( + + )} +
+ +
+
+ )} +
+
+ ); +} + +export function CuePipelineEditor(props: CuePipelineEditorProps) { + return ( + + + + ); +} diff --git a/src/renderer/components/CuePipelineEditor/PipelineSelector.tsx b/src/renderer/components/CuePipelineEditor/PipelineSelector.tsx new file mode 100644 index 000000000..fd8b74649 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/PipelineSelector.tsx @@ -0,0 +1,299 @@ +/** + * PipelineSelector — Dropdown for selecting which pipeline to view/edit. + * + * Shows current selection with color dot, and a dropdown menu with + * All Pipelines, individual pipelines (with rename/delete), and New Pipeline. + */ + +import { useState, useRef, useCallback } from 'react'; +import { ChevronDown, Plus, X, Check, Layers } from 'lucide-react'; +import type { CuePipeline } from '../../../shared/cue-pipeline-types'; +import { useClickOutside } from '../../hooks/ui/useClickOutside'; +import { PIPELINE_COLORS } from './pipelineColors'; + +export interface PipelineSelectorProps { + pipelines: CuePipeline[]; + selectedPipelineId: string | null; + onSelect: (id: string | null) => void; + onCreatePipeline: () => void; + onDeletePipeline: (id: string) => void; + onRenamePipeline: (id: string, name: string) => void; + textColor?: string; + borderColor?: string; +} + +export function PipelineSelector({ + pipelines, + selectedPipelineId, + onSelect, + onCreatePipeline, + onDeletePipeline, + onRenamePipeline, + textColor = 'rgba(255,255,255,0.9)', + borderColor = 'rgba(255,255,255,0.12)', +}: PipelineSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + useClickOutside( + [dropdownRef as React.RefObject, buttonRef as React.RefObject], + () => setIsOpen(false), + isOpen + ); + + const selectedPipeline = selectedPipelineId + ? pipelines.find((p) => p.id === selectedPipelineId) + : null; + + const handleToggle = useCallback(() => { + setIsOpen((v) => !v); + setRenamingId(null); + }, []); + + const handleSelect = useCallback( + (id: string | null) => { + onSelect(id); + setIsOpen(false); + setRenamingId(null); + }, + [onSelect] + ); + + const handleStartRename = useCallback((pipeline: CuePipeline) => { + setRenamingId(pipeline.id); + setRenameValue(pipeline.name); + }, []); + + const handleFinishRename = useCallback(() => { + if (renamingId && renameValue.trim()) { + onRenamePipeline(renamingId, renameValue.trim()); + } + setRenamingId(null); + }, [renamingId, renameValue, onRenamePipeline]); + + const handleRenameKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleFinishRename(); + } else if (e.key === 'Escape') { + setRenamingId(null); + } + }, + [handleFinishRename] + ); + + const handleDelete = useCallback( + (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + onDeletePipeline(id); + }, + [onDeletePipeline] + ); + + const handleCreate = useCallback(() => { + onCreatePipeline(); + setIsOpen(false); + }, [onCreatePipeline]); + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown menu */} + {isOpen && ( +
+ {/* All Pipelines option */} + + + {/* Divider */} + {pipelines.length > 0 && ( +
+ )} + + {/* Pipeline list */} + {pipelines.map((pipeline) => ( +
{ + if (renamingId !== pipeline.id) { + handleSelect(pipeline.id); + } + }} + onDoubleClick={(e) => { + e.stopPropagation(); + handleStartRename(pipeline); + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.06)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + + {renamingId === pipeline.id ? ( + setRenameValue(e.target.value)} + onBlur={handleFinishRename} + onKeyDown={handleRenameKeyDown} + onClick={(e) => e.stopPropagation()} + className="flex-1 text-xs rounded px-1" + style={{ + backgroundColor: 'rgba(255,255,255,0.1)', + border: '1px solid rgba(255,255,255,0.2)', + color: 'rgba(255,255,255,0.9)', + outline: 'none', + }} + /> + ) : ( + {pipeline.name} + )} + {selectedPipelineId === pipeline.id && renamingId !== pipeline.id && ( + + )} + +
+ ))} + + {/* Divider */} +
+ + {/* New Pipeline button */} + +
+ )} +
+ ); +} + +/** Small multi-color icon representing "All Pipelines" */ +function MultiColorIcon() { + const colors = PIPELINE_COLORS.slice(0, 4); + return ( + + ); +} diff --git a/src/renderer/components/CuePipelineEditor/drawers/AgentDrawer.tsx b/src/renderer/components/CuePipelineEditor/drawers/AgentDrawer.tsx new file mode 100644 index 000000000..2cfcba1b0 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/drawers/AgentDrawer.tsx @@ -0,0 +1,230 @@ +import { memo, useState, useMemo } from 'react'; +import { Bot, Search, X } from 'lucide-react'; +import type { Theme } from '../../../types'; + +export interface AgentSessionInfo { + id: string; + name: string; + toolType: string; +} + +export interface AgentDrawerProps { + isOpen: boolean; + onClose: () => void; + sessions: AgentSessionInfo[]; + onCanvasSessionIds?: Set; + theme: Theme; +} + +function handleDragStart(e: React.DragEvent, session: AgentSessionInfo) { + e.dataTransfer.setData( + 'application/cue-pipeline', + JSON.stringify({ + type: 'agent', + sessionId: session.id, + sessionName: session.name, + toolType: session.toolType, + }) + ); + e.dataTransfer.effectAllowed = 'move'; +} + +export const AgentDrawer = memo(function AgentDrawer({ + isOpen, + onClose, + sessions, + onCanvasSessionIds, + theme, +}: AgentDrawerProps) { + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => { + if (!search.trim()) return sessions; + const q = search.toLowerCase(); + return sessions.filter( + (s) => s.name.toLowerCase().includes(q) || s.toolType.toLowerCase().includes(q) + ); + }, [sessions, search]); + + // Group by toolType + const grouped = useMemo(() => { + const groups = new Map(); + for (const s of filtered) { + const list = groups.get(s.toolType) ?? []; + list.push(s); + groups.set(s.toolType, list); + } + return groups; + }, [filtered]); + + return ( +
+ {/* Header */} +
+ Agents + +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Search agents..." + style={{ + flex: 1, + background: 'none', + border: 'none', + outline: 'none', + color: theme.colors.textMain, + fontSize: 12, + }} + /> +
+
+ + {/* Agent list */} +
+ {Array.from(grouped.entries()).map(([toolType, agents]) => ( +
+ {grouped.size > 1 && ( +
+ {toolType} +
+ )} + {agents.map((session) => { + const isOnCanvas = onCanvasSessionIds?.has(session.id) ?? false; + return ( +
handleDragStart(e, session)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '8px 10px', + marginBottom: 4, + borderRadius: 6, + backgroundColor: theme.colors.bgActivity, + cursor: 'grab', + transition: 'filter 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1.2)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1)'; + }} + > + +
+
+ {session.name} +
+
+ {session.toolType} +
+
+ {isOnCanvas && ( +
+ )} +
+ ); + })} +
+ ))} + {filtered.length === 0 && ( +
+ {search ? 'No agents match' : 'No agents available'} +
+ )} +
+
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx new file mode 100644 index 000000000..582b09d82 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx @@ -0,0 +1,232 @@ +import { memo, useState, useMemo } from 'react'; +import { + Clock, + FileText, + Zap, + GitPullRequest, + GitBranch, + CheckSquare, + Search, + X, +} from 'lucide-react'; +import type { CueEventType } from '../../../../shared/cue-pipeline-types'; +import type { Theme } from '../../../types'; + +export interface TriggerDrawerProps { + isOpen: boolean; + onClose: () => void; + theme: Theme; +} + +interface TriggerItem { + eventType: CueEventType; + label: string; + description: string; + icon: typeof Clock; + color: string; +} + +const TRIGGER_ITEMS: TriggerItem[] = [ + { + eventType: 'time.interval', + label: 'Scheduled', + description: 'Run on a timer', + icon: Clock, + color: '#f59e0b', + }, + { + eventType: 'file.changed', + label: 'File Change', + description: 'Watch for file modifications', + icon: FileText, + color: '#3b82f6', + }, + { + eventType: 'agent.completed', + label: 'Agent Done', + description: 'After an agent finishes', + icon: Zap, + color: '#22c55e', + }, + { + eventType: 'github.pull_request', + label: 'Pull Request', + description: 'GitHub PR events', + icon: GitPullRequest, + color: '#a855f7', + }, + { + eventType: 'github.issue', + label: 'Issue', + description: 'GitHub issue events', + icon: GitBranch, + color: '#f97316', + }, + { + eventType: 'task.pending', + label: 'Pending Task', + description: 'Markdown task checkboxes', + icon: CheckSquare, + color: '#06b6d4', + }, +]; + +function handleDragStart(e: React.DragEvent, item: TriggerItem) { + e.dataTransfer.setData( + 'application/cue-pipeline', + JSON.stringify({ type: 'trigger', eventType: item.eventType, label: item.label }) + ); + e.dataTransfer.effectAllowed = 'move'; +} + +export const TriggerDrawer = memo(function TriggerDrawer({ + isOpen, + onClose, + theme, +}: TriggerDrawerProps) { + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => { + if (!search.trim()) return TRIGGER_ITEMS; + const q = search.toLowerCase(); + return TRIGGER_ITEMS.filter( + (item) => + item.label.toLowerCase().includes(q) || + item.eventType.toLowerCase().includes(q) || + item.description.toLowerCase().includes(q) + ); + }, [search]); + + return ( +
+ {/* Header */} +
+ + Triggers + + +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Filter triggers..." + style={{ + flex: 1, + background: 'none', + border: 'none', + outline: 'none', + color: theme.colors.textMain, + fontSize: 12, + }} + /> +
+
+ + {/* Trigger list */} +
+ {filtered.map((item) => { + const Icon = item.icon; + return ( +
handleDragStart(e, item)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '8px 10px', + marginBottom: 4, + borderRadius: 6, + borderLeft: `3px solid ${item.color}`, + backgroundColor: theme.colors.bgActivity, + cursor: 'grab', + transition: 'filter 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1.2)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1)'; + }} + > + +
+
+ {item.label} +
+
{item.description}
+
+
+ ); + })} + {filtered.length === 0 && ( +
+ No triggers match +
+ )} +
+
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx b/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx new file mode 100644 index 000000000..e152aeaeb --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx @@ -0,0 +1,96 @@ +import { memo } from 'react'; +import { getBezierPath, BaseEdge, EdgeLabelRenderer, type EdgeProps } from 'reactflow'; +import { MessageCircle, FileText } from 'lucide-react'; +import type { EdgeMode } from '../../../../shared/cue-pipeline-types'; + +export interface PipelineEdgeData { + pipelineColor: string; + mode: EdgeMode; + isActivePipeline: boolean; + isRunning?: boolean; +} + +export const PipelineEdge = memo(function PipelineEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + selected, + markerEnd, +}: EdgeProps) { + const color = data?.pipelineColor ?? '#06b6d4'; + const mode = data?.mode ?? 'pass'; + const isActive = data?.isActivePipeline !== false; + const isRunning = data?.isRunning ?? false; + const opacity = isActive ? 1 : 0.25; + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + return ( + <> + + + {/* Mode label for non-pass modes */} + {mode !== 'pass' && ( + +
+ {mode === 'debate' && } + {mode === 'autorun' && } + {mode} +
+
+ )} + + {/* CSS animation for autorun dash */} + + + ); +}); + +export const edgeTypes = { + pipeline: PipelineEdge, +}; diff --git a/src/renderer/components/CuePipelineEditor/index.ts b/src/renderer/components/CuePipelineEditor/index.ts new file mode 100644 index 000000000..d63cca584 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/index.ts @@ -0,0 +1,2 @@ +export { CuePipelineEditor } from './CuePipelineEditor'; +export type { CuePipelineEditorProps } from './CuePipelineEditor'; diff --git a/src/renderer/components/CuePipelineEditor/nodes/AgentNode.tsx b/src/renderer/components/CuePipelineEditor/nodes/AgentNode.tsx new file mode 100644 index 000000000..951287d7b --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/nodes/AgentNode.tsx @@ -0,0 +1,194 @@ +import { memo } from 'react'; +import { Handle, Position, type NodeProps } from 'reactflow'; +import { MessageSquare, GripVertical, Settings } from 'lucide-react'; + +export interface AgentNodeDataProps { + sessionId: string; + sessionName: string; + toolType: string; + hasPrompt: boolean; + pipelineColor: string; + pipelineCount: number; + pipelineColors: string[]; + onConfigure?: () => void; +} + +export const AgentNode = memo(function AgentNode({ + data, + selected, +}: NodeProps) { + const accentColor = data.pipelineColor; + + return ( +
+ {/* Drag handle */} +
(e.currentTarget.style.color = '#ccc')} + onMouseLeave={(e) => (e.currentTarget.style.color = '#555')} + title="Drag to move" + > + +
+ + {/* Content */} +
+
+ + {data.sessionName} + + {data.hasPrompt && ( + + )} +
+ + {data.toolType} + + + {/* Multi-pipeline color strip */} + {data.pipelineColors.length > 1 && ( +
+ {data.pipelineColors.map((c, i) => ( +
+ ))} +
+ )} +
+ + {/* Gear icon */} +
{ + e.stopPropagation(); + data.onConfigure?.(); + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: selected ? accentColor : '#555', + flexShrink: 0, + padding: '0 8px', + borderRadius: '0 6px 6px 0', + transition: 'color 0.15s', + }} + onMouseEnter={(e) => (e.currentTarget.style.color = accentColor)} + onMouseLeave={(e) => (e.currentTarget.style.color = selected ? accentColor : '#555')} + title="Configure" + > + +
+ + {/* Pipeline count badge */} + {data.pipelineCount > 1 && ( +
+ {data.pipelineCount} +
+ )} + + + +
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx b/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx new file mode 100644 index 000000000..83cbc12b6 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx @@ -0,0 +1,169 @@ +import { memo } from 'react'; +import { Handle, Position, type NodeProps } from 'reactflow'; +import { + Clock, + FileText, + GitPullRequest, + GitBranch, + CheckSquare, + Zap, + GripVertical, + Settings, +} from 'lucide-react'; +import type { CueEventType } from '../../../../shared/cue-pipeline-types'; + +export interface TriggerNodeDataProps { + eventType: CueEventType; + label: string; + configSummary: string; + onConfigure?: () => void; +} + +const EVENT_COLORS: Record = { + 'time.interval': '#f59e0b', + 'file.changed': '#3b82f6', + 'agent.completed': '#22c55e', + 'github.pull_request': '#a855f7', + 'github.issue': '#f97316', + 'task.pending': '#06b6d4', +}; + +const EVENT_ICONS: Record = { + 'time.interval': Clock, + 'file.changed': FileText, + 'agent.completed': Zap, + 'github.pull_request': GitPullRequest, + 'github.issue': GitBranch, + 'task.pending': CheckSquare, +}; + +export const TriggerNode = memo(function TriggerNode({ + data, + selected, +}: NodeProps) { + const color = EVENT_COLORS[data.eventType] ?? '#06b6d4'; + const Icon = EVENT_ICONS[data.eventType] ?? Zap; + + return ( +
+ {/* Drag handle */} +
+ +
+ + {/* Content */} +
+
+ + + {data.label} + +
+ {data.configSummary && ( + + {data.configSummary} + + )} +
+ + {/* Gear icon */} +
{ + e.stopPropagation(); + data.onConfigure?.(); + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: selected ? color : `${color}60`, + flexShrink: 0, + padding: '4px 2px', + borderRadius: 4, + transition: 'color 0.15s', + }} + onMouseEnter={(e) => (e.currentTarget.style.color = color)} + onMouseLeave={(e) => (e.currentTarget.style.color = selected ? color : `${color}60`)} + title="Configure" + > + +
+ + +
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.tsx b/src/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.tsx new file mode 100644 index 000000000..25d7c823f --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.tsx @@ -0,0 +1,256 @@ +/** + * EdgeConfigPanel — Bottom panel for configuring selected pipeline edges. + * + * Provides mode selection (pass/debate/autorun) and mode-specific settings. + * All changes update immediately. + */ + +import { ArrowRight, MessageCircle, FileText, Trash2 } from 'lucide-react'; +import type { PipelineEdge, EdgeMode, PipelineNode } from '../../../../shared/cue-pipeline-types'; + +interface EdgeConfigPanelProps { + selectedEdge: PipelineEdge | null; + sourceNode: PipelineNode | null; + targetNode: PipelineNode | null; + pipelineColor: string; + onUpdateEdge: (edgeId: string, updates: Partial) => void; + onDeleteEdge: (edgeId: string) => void; +} + +function getNodeLabel(node: PipelineNode | null): string { + if (!node) return '?'; + if (node.type === 'trigger') { + return (node.data as { label: string }).label; + } + return (node.data as { sessionName: string }).sessionName; +} + +const MODES: Array<{ + mode: EdgeMode; + label: string; + icon: typeof ArrowRight; + description: string; +}> = [ + { + mode: 'pass', + label: 'Pass', + icon: ArrowRight, + description: 'Data passes through to next agent', + }, + { + mode: 'debate', + label: 'Debate', + icon: MessageCircle, + description: 'Multiple agents debate before passing result', + }, + { + mode: 'autorun', + label: 'Auto Run', + icon: FileText, + description: 'Agent creates auto-run documents for next agent', + }, +]; + +export function EdgeConfigPanel({ + selectedEdge, + sourceNode, + targetNode, + pipelineColor, + onUpdateEdge, + onDeleteEdge, +}: EdgeConfigPanelProps) { + if (!selectedEdge) return null; + + const currentMode = selectedEdge.mode; + + return ( +
+ + + {/* Header */} +
+
+ + Connection Settings + + + {getNodeLabel(sourceNode)} + + {getNodeLabel(targetNode)} + +
+ +
+ + {/* Content */} +
+ {/* Mode selector */} +
+ {MODES.map(({ mode, label, icon: Icon }) => { + const isActive = currentMode === mode; + return ( + + ); + })} +
+ + {/* Mode description */} +
+ {MODES.find((m) => m.mode === currentMode)?.description} +
+ + {/* Debate settings */} + {currentMode === 'debate' && ( +
+ + +
+ )} + + {/* Auto Run explanation */} + {currentMode === 'autorun' && ( +
+ The source agent will produce auto-run documents that the target agent will execute + sequentially. +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.tsx b/src/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.tsx new file mode 100644 index 000000000..7c91c67ec --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.tsx @@ -0,0 +1,461 @@ +/** + * NodeConfigPanel — Bottom panel for configuring selected trigger or agent nodes. + * + * Shows event-specific fields for triggers, prompt textarea for agents. + * All changes update immediately (debounced for text inputs). + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + Trash2, + Clock, + FileText, + Zap, + GitPullRequest, + GitBranch, + CheckSquare, + ExternalLink, +} from 'lucide-react'; +import type { + PipelineNode, + TriggerNodeData, + AgentNodeData, + CueEventType, + CuePipeline, +} from '../../../../shared/cue-pipeline-types'; +import { useDebouncedCallback } from '../../../hooks/utils'; + +interface NodeConfigPanelProps { + selectedNode: PipelineNode | null; + pipelines: CuePipeline[]; + onUpdateNode: (nodeId: string, data: Partial) => void; + onDeleteNode: (nodeId: string) => void; + onSwitchToAgent?: (sessionId: string) => void; +} + +const EVENT_ICONS: Record = { + 'time.interval': Clock, + 'file.changed': FileText, + 'agent.completed': Zap, + 'github.pull_request': GitPullRequest, + 'github.issue': GitBranch, + 'task.pending': CheckSquare, +}; + +const EVENT_LABELS: Record = { + 'time.interval': 'Scheduled Timer', + 'file.changed': 'File Change', + 'agent.completed': 'Agent Completed', + 'github.pull_request': 'Pull Request', + 'github.issue': 'GitHub Issue', + 'task.pending': 'Pending Task', +}; + +const inputStyle: React.CSSProperties = { + backgroundColor: '#2a2a3e', + border: '1px solid #444', + borderRadius: 4, + color: '#e4e4e7', + padding: '4px 8px', + fontSize: 12, + outline: 'none', + width: '100%', +}; + +const selectStyle: React.CSSProperties = { + ...inputStyle, + cursor: 'pointer', +}; + +const labelStyle: React.CSSProperties = { + color: '#9ca3af', + fontSize: 11, + fontWeight: 500, + marginBottom: 4, + display: 'block', +}; + +function TriggerConfig({ + node, + onUpdateNode, +}: { + node: PipelineNode; + onUpdateNode: NodeConfigPanelProps['onUpdateNode']; +}) { + const data = node.data as TriggerNodeData; + const [localConfig, setLocalConfig] = useState(data.config); + + useEffect(() => { + setLocalConfig(data.config); + }, [data.config]); + + const { debouncedCallback: debouncedUpdate } = useDebouncedCallback((...args: unknown[]) => { + const config = args[0] as TriggerNodeData['config']; + onUpdateNode(node.id, { config } as Partial); + }, 300); + + const updateConfig = useCallback( + (key: string, value: string | number) => { + const updated = { ...localConfig, [key]: value }; + setLocalConfig(updated); + debouncedUpdate(updated); + }, + [localConfig, debouncedUpdate] + ); + + const updateFilter = useCallback( + (key: string, value: string) => { + const updated = { + ...localConfig, + filter: { ...(localConfig.filter ?? {}), [key]: value }, + }; + setLocalConfig(updated); + debouncedUpdate(updated); + }, + [localConfig, debouncedUpdate] + ); + + switch (data.eventType) { + case 'time.interval': + return ( +
+ +
+ ); + case 'file.changed': + return ( +
+ + +
+ ); + case 'agent.completed': + return ( +
+ Source agent is determined by incoming edges. Connect a trigger or agent node to configure + the source. +
+ ); + case 'github.pull_request': + case 'github.issue': + return ( +
+ + +
+ ); + case 'task.pending': + return ( +
+ +
+ ); + default: + return null; + } +} + +function AgentConfig({ + node, + pipelines, + onUpdateNode, + onSwitchToAgent, +}: { + node: PipelineNode; + pipelines: CuePipeline[]; + onUpdateNode: NodeConfigPanelProps['onUpdateNode']; + onSwitchToAgent?: (sessionId: string) => void; +}) { + const data = node.data as AgentNodeData; + const [localPrompt, setLocalPrompt] = useState(data.prompt ?? ''); + const promptRef = useRef(null); + + useEffect(() => { + setLocalPrompt(data.prompt ?? ''); + }, [data.prompt]); + + const { debouncedCallback: debouncedUpdate } = useDebouncedCallback((...args: unknown[]) => { + const prompt = args[0] as string; + onUpdateNode(node.id, { prompt } as Partial); + }, 300); + + const handlePromptChange = useCallback( + (e: React.ChangeEvent) => { + setLocalPrompt(e.target.value); + debouncedUpdate(e.target.value); + }, + [debouncedUpdate] + ); + + // Find which pipelines contain this agent + const agentPipelines = pipelines.filter((p) => + p.nodes.some( + (n) => n.type === 'agent' && (n.data as AgentNodeData).sessionId === data.sessionId + ) + ); + + return ( +
+