diff --git a/src/__tests__/main/cue/cue-completion-chains.test.ts b/src/__tests__/main/cue/cue-completion-chains.test.ts index 021cae8241..32fcc017fe 100644 --- a/src/__tests__/main/cue/cue-completion-chains.test.ts +++ b/src/__tests__/main/cue/cue-completion-chains.test.ts @@ -27,6 +27,7 @@ vi.mock('../../../main/cue/cue-yaml-loader', () => ({ : { ok: false as const, reason: 'missing' as const }; }, watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), + findAncestorCueConfigRoot: () => null, })); // Mock the file watcher diff --git a/src/__tests__/main/cue/cue-concurrency.test.ts b/src/__tests__/main/cue/cue-concurrency.test.ts index 48b27b968d..9ef233c1df 100644 --- a/src/__tests__/main/cue/cue-concurrency.test.ts +++ b/src/__tests__/main/cue/cue-concurrency.test.ts @@ -26,6 +26,7 @@ vi.mock('../../../main/cue/cue-yaml-loader', () => ({ : { ok: false as const, reason: 'missing' as const }; }, watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), + findAncestorCueConfigRoot: () => null, })); // Mock the file watcher diff --git a/src/__tests__/main/cue/cue-engine.test.ts b/src/__tests__/main/cue/cue-engine.test.ts index 93a2470d6b..c53d158ba1 100644 --- a/src/__tests__/main/cue/cue-engine.test.ts +++ b/src/__tests__/main/cue/cue-engine.test.ts @@ -29,6 +29,7 @@ vi.mock('../../../main/cue/cue-yaml-loader', () => ({ loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), loadCueConfigDetailed: (...args: unknown[]) => mockLoadCueConfigDetailed(args[0] as string), watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), + findAncestorCueConfigRoot: () => null, })); // Mock the file watcher diff --git a/src/__tests__/main/cue/cue-ipc-handlers.test.ts b/src/__tests__/main/cue/cue-ipc-handlers.test.ts index bc56185b41..425d8da460 100644 --- a/src/__tests__/main/cue/cue-ipc-handlers.test.ts +++ b/src/__tests__/main/cue/cue-ipc-handlers.test.ts @@ -44,6 +44,7 @@ vi.mock('../../../main/utils/ipcHandler', () => ({ vi.mock('../../../main/cue/cue-yaml-loader', () => ({ validateCueConfig: vi.fn(), + findAncestorCueConfigRoot: () => null, })); vi.mock('../../../main/cue/config/cue-config-repository', () => ({ diff --git a/src/__tests__/main/cue/cue-multi-hop-chains.test.ts b/src/__tests__/main/cue/cue-multi-hop-chains.test.ts index a366783e7e..b0ed651198 100644 --- a/src/__tests__/main/cue/cue-multi-hop-chains.test.ts +++ b/src/__tests__/main/cue/cue-multi-hop-chains.test.ts @@ -25,6 +25,7 @@ vi.mock('../../../main/cue/cue-yaml-loader', () => ({ : { ok: false as const, reason: 'missing' as const }; }, watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), + findAncestorCueConfigRoot: () => null, })); // Mock the file watcher diff --git a/src/__tests__/main/cue/cue-session-lifecycle.test.ts b/src/__tests__/main/cue/cue-session-lifecycle.test.ts index 9009770699..67fe812594 100644 --- a/src/__tests__/main/cue/cue-session-lifecycle.test.ts +++ b/src/__tests__/main/cue/cue-session-lifecycle.test.ts @@ -25,6 +25,7 @@ vi.mock('../../../main/cue/cue-yaml-loader', () => ({ : { ok: false as const, reason: 'missing' as const }; }, watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), + findAncestorCueConfigRoot: () => null, })); // Mock the file watcher diff --git a/src/__tests__/main/cue/cue-sleep-prevention.test.ts b/src/__tests__/main/cue/cue-sleep-prevention.test.ts index 662705b844..863e32b06f 100644 --- a/src/__tests__/main/cue/cue-sleep-prevention.test.ts +++ b/src/__tests__/main/cue/cue-sleep-prevention.test.ts @@ -23,6 +23,7 @@ vi.mock('../../../main/cue/cue-yaml-loader', () => ({ : { ok: false as const, reason: 'missing' as const }; }, watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), + findAncestorCueConfigRoot: () => null, })); // Mock the file watcher diff --git a/src/__tests__/main/cue/cue-sleep-wake.test.ts b/src/__tests__/main/cue/cue-sleep-wake.test.ts index e0868caccd..48f84f461f 100644 --- a/src/__tests__/main/cue/cue-sleep-wake.test.ts +++ b/src/__tests__/main/cue/cue-sleep-wake.test.ts @@ -49,6 +49,7 @@ vi.mock('../../../main/cue/cue-yaml-loader', () => ({ : { ok: false as const, reason: 'missing' as const }; }, watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), + findAncestorCueConfigRoot: () => null, })); // Mock the file watcher diff --git a/src/__tests__/main/cue/cue-startup.test.ts b/src/__tests__/main/cue/cue-startup.test.ts index fa1480b02d..dbf0359a9b 100644 --- a/src/__tests__/main/cue/cue-startup.test.ts +++ b/src/__tests__/main/cue/cue-startup.test.ts @@ -32,6 +32,7 @@ vi.mock('../../../main/cue/cue-yaml-loader', () => ({ : { ok: false as const, reason: 'missing' as const }; }, watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), + findAncestorCueConfigRoot: () => null, })); // Mock the file watcher diff --git a/src/__tests__/main/cue/cue-yaml-loader.test.ts b/src/__tests__/main/cue/cue-yaml-loader.test.ts index f3840c48bb..65dbaf594e 100644 --- a/src/__tests__/main/cue/cue-yaml-loader.test.ts +++ b/src/__tests__/main/cue/cue-yaml-loader.test.ts @@ -31,6 +31,7 @@ vi.mock('fs', () => ({ // Must import after mocks import { + findAncestorCueConfigRoot, loadCueConfig, loadCueConfigDetailed, watchCueYaml, @@ -1704,4 +1705,59 @@ subscriptions: ); }); }); + + describe('findAncestorCueConfigRoot', () => { + it('returns null when no ancestor has a cue config', () => { + mockExistsSync.mockReturnValue(false); + const result = findAncestorCueConfigRoot('/projects/parent/child'); + expect(result).toBeNull(); + }); + + it('finds a parent directory with .maestro/cue.yaml', () => { + mockExistsSync.mockImplementation((p: string) => { + const s = String(p); + // Parent has .maestro/cue.yaml, child does not + return s === '/projects/parent/.maestro/cue.yaml'; + }); + const result = findAncestorCueConfigRoot('/projects/parent/child'); + expect(result).toBe('/projects/parent'); + }); + + it('does not return the input directory itself', () => { + // Even if the input dir has a cue.yaml, it should only look at ancestors + mockExistsSync.mockImplementation((p: string) => { + return String(p) === '/projects/parent/child/.maestro/cue.yaml'; + }); + const result = findAncestorCueConfigRoot('/projects/parent/child'); + expect(result).toBeNull(); + }); + + it('finds grandparent config when parent has none', () => { + mockExistsSync.mockImplementation((p: string) => { + return String(p) === '/projects/.maestro/cue.yaml'; + }); + const result = findAncestorCueConfigRoot('/projects/parent/child'); + expect(result).toBe('/projects'); + }); + + it('stops after depth limit even if ancestor exists further up', () => { + // Place cue.yaml 6 levels up — beyond the 5-level search depth + mockExistsSync.mockImplementation((p: string) => { + return String(p) === '/a/.maestro/cue.yaml'; + }); + const result = findAncestorCueConfigRoot('/a/b/c/d/e/f/g'); + // /a is 6 levels up from /a/b/c/d/e/f/g — should not be found + expect(result).toBeNull(); + }); + + it('finds ancestor with legacy maestro-cue.yaml', () => { + mockExistsSync.mockImplementation((p: string) => { + const s = String(p); + // No canonical, but legacy exists at parent + return s === '/projects/parent/maestro-cue.yaml'; + }); + const result = findAncestorCueConfigRoot('/projects/parent/child'); + expect(result).toBe('/projects/parent'); + }); + }); }); diff --git a/src/__tests__/shared/cue-path-utils.test.ts b/src/__tests__/shared/cue-path-utils.test.ts new file mode 100644 index 0000000000..39e886beea --- /dev/null +++ b/src/__tests__/shared/cue-path-utils.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { computeCommonAncestorPath, isDescendantOrEqual } from '../../shared/cue-path-utils'; + +describe('computeCommonAncestorPath', () => { + it('returns null for empty input', () => { + expect(computeCommonAncestorPath([])).toBeNull(); + }); + + it('returns the path itself for a single-element array', () => { + expect(computeCommonAncestorPath(['/a/b/c'])).toBe('/a/b/c'); + }); + + it('returns the common parent for sibling directories', () => { + expect(computeCommonAncestorPath(['/a/b/c', '/a/b/d'])).toBe('/a/b'); + }); + + it('returns the parent when one path is a child of the other', () => { + expect(computeCommonAncestorPath(['/project', '/project/sub'])).toBe('/project'); + }); + + it('returns the parent for deeply nested children', () => { + expect(computeCommonAncestorPath(['/project', '/project/sub/deep', '/project/other'])).toBe( + '/project' + ); + }); + + it('returns filesystem root for completely unrelated paths', () => { + expect(computeCommonAncestorPath(['/a/b', '/c/d'])).toBe('/'); + }); + + it('handles identical paths', () => { + expect(computeCommonAncestorPath(['/a/b', '/a/b'])).toBe('/a/b'); + }); + + it('handles three paths with a shared prefix', () => { + expect( + computeCommonAncestorPath([ + '/home/user/project/A', + '/home/user/project/B', + '/home/user/project/C', + ]) + ).toBe('/home/user/project'); + }); +}); + +describe('isDescendantOrEqual', () => { + it('returns true when paths are identical', () => { + expect(isDescendantOrEqual('/a/b', '/a/b')).toBe(true); + }); + + it('returns true when child is a subdirectory of parent', () => { + expect(isDescendantOrEqual('/a/b/c', '/a/b')).toBe(true); + }); + + it('returns true for deeply nested descendant', () => { + expect(isDescendantOrEqual('/project/sub/deep/nested', '/project')).toBe(true); + }); + + it('returns false when child is not under parent', () => { + expect(isDescendantOrEqual('/a/b', '/c/d')).toBe(false); + }); + + it('returns false when parent is a subdirectory of child (reversed)', () => { + expect(isDescendantOrEqual('/a', '/a/b')).toBe(false); + }); + + it('returns false for partial prefix match that is not a directory boundary', () => { + // /a/bar is NOT a descendant of /a/b — the prefix match is not at a separator + expect(isDescendantOrEqual('/a/bar', '/a/b')).toBe(false); + }); + + it('handles trailing separators via normalization', () => { + expect(isDescendantOrEqual('/a/b/c', '/a/b/')).toBe(true); + expect(isDescendantOrEqual('/a/b/', '/a/b')).toBe(true); + }); +}); diff --git a/src/main/cue/cue-session-runtime-service.ts b/src/main/cue/cue-session-runtime-service.ts index c197f337da..b3a8ce7d28 100644 --- a/src/main/cue/cue-session-runtime-service.ts +++ b/src/main/cue/cue-session-runtime-service.ts @@ -1,10 +1,11 @@ import type { MainLogLevel } from '../../shared/logger-types'; import type { SessionInfo } from '../../shared/types'; -import { loadCueConfigDetailed, watchCueYaml } from './cue-yaml-loader'; +import { findAncestorCueConfigRoot, loadCueConfigDetailed, watchCueYaml } from './cue-yaml-loader'; import { createCueEvent, type CueEvent, type CueSubscription } from './cue-types'; import { countActiveSubscriptions, hasTimeBasedSubscriptions, + isSubscriptionParticipant, type SessionState, } from './cue-session-state'; import type { CueSessionRegistry } from './cue-session-registry'; @@ -97,7 +98,42 @@ export function createCueSessionRuntimeService( registry.unregister(session.id); } - const loadResult = loadCueConfigDetailed(session.projectRoot); + let loadResult = loadCueConfigDetailed(session.projectRoot); + let ancestorRoot: string | undefined; + + // When the session's own directory has no cue.yaml, check ancestor + // directories. This enables sub-agents (e.g. project/Digest) to + // participate in pipelines defined at a parent root (e.g. project/). + if (!loadResult.ok && loadResult.reason === 'missing') { + const ancestor = findAncestorCueConfigRoot(session.projectRoot); + if (ancestor) { + const ancestorResult = loadCueConfigDetailed(ancestor); + if (ancestorResult.ok) { + // Only include subscriptions that explicitly target this + // session (via agent_id or fan_out). Unowned (shared) + // subscriptions belong to the ancestor's own session — + // including them here would duplicate trigger sources. + const targeted = ancestorResult.config.subscriptions.filter( + (sub) => + sub.agent_id !== undefined && isSubscriptionParticipant(sub, session.id, session.name) + ); + + if (targeted.length > 0) { + loadResult = { + ok: true, + config: { ...ancestorResult.config, subscriptions: targeted }, + warnings: ancestorResult.warnings, + }; + ancestorRoot = ancestor; + deps.onLog( + 'cue', + `[CUE] "${session.name}" using ancestor config from "${ancestor}" (${targeted.length} targeted subscription(s))` + ); + } + } + } + } + if (!loadResult.ok) { // Distinguish missing (silent) from parse / validation failures (loud). if (loadResult.reason === 'parse-error') { @@ -130,12 +166,15 @@ export function createCueSessionRuntimeService( const state: SessionState = { config, + configRoot: ancestorRoot, triggerSources: [], yamlWatcher: null, sleepPrevented: false, }; - state.yamlWatcher = watchCueYaml(session.projectRoot, () => { + // Watch the cue.yaml at the config's actual location (ancestor or own root). + const watchRoot = ancestorRoot ?? session.projectRoot; + state.yamlWatcher = watchCueYaml(watchRoot, () => { deps.onRefreshRequested(session.id, session.projectRoot); }); diff --git a/src/main/cue/cue-session-state.ts b/src/main/cue/cue-session-state.ts index 093945db3a..b429e94b6d 100644 --- a/src/main/cue/cue-session-state.ts +++ b/src/main/cue/cue-session-state.ts @@ -11,6 +11,10 @@ import type { CueTriggerSource } from './triggers/cue-trigger-source'; */ export interface SessionState { config: CueConfig; + /** When the config was loaded from an ancestor directory (not the session's own + * projectRoot), this records the ancestor root so refreshes reload from the + * correct location. Undefined when the config lives at the session's own root. */ + configRoot?: string; triggerSources: CueTriggerSource[]; yamlWatcher: (() => void) | null; sleepPrevented: boolean; diff --git a/src/main/cue/cue-yaml-loader.ts b/src/main/cue/cue-yaml-loader.ts index d4905dc35b..b8fbebf1e7 100644 --- a/src/main/cue/cue-yaml-loader.ts +++ b/src/main/cue/cue-yaml-loader.ts @@ -5,9 +5,14 @@ * are implemented in responsibility-focused config modules. */ +import * as path from 'path'; import * as yaml from 'js-yaml'; import type { CueConfig } from './cue-types'; -import { readCueConfigFile, watchCueConfigFile } from './config/cue-config-repository'; +import { + readCueConfigFile, + resolveCueConfigPath, + watchCueConfigFile, +} from './config/cue-config-repository'; import { materializeCueConfig, parseCueConfigDocument } from './config/cue-config-normalizer'; import { partitionValidSubscriptions, @@ -166,3 +171,29 @@ export function watchCueYaml(projectRoot: string, onChange: () => void): () => v export function validateCueConfig(config: unknown): { valid: boolean; errors: string[] } { return validateCueConfigDocument(config); } + +/** Maximum number of parent directories to walk when searching for an ancestor config. */ +const ANCESTOR_SEARCH_DEPTH = 5; + +/** + * Walk parent directories from `projectRoot` looking for a cue.yaml. + * Returns the ancestor's project root if found, `null` otherwise. + * + * Stops at filesystem root or after {@link ANCESTOR_SEARCH_DEPTH} levels. + * Does NOT return `projectRoot` itself — only strict ancestors. + */ +export function findAncestorCueConfigRoot(projectRoot: string): string | null { + let current = path.resolve(projectRoot); + + for (let depth = 0; depth < ANCESTOR_SEARCH_DEPTH; depth++) { + const parent = path.dirname(current); + if (parent === current) break; // reached filesystem root + current = parent; + + if (resolveCueConfigPath(current) !== null) { + return current; + } + } + + return null; +} diff --git a/src/renderer/hooks/cue/usePipelinePersistence.ts b/src/renderer/hooks/cue/usePipelinePersistence.ts index 2ffc386621..612a88b075 100644 --- a/src/renderer/hooks/cue/usePipelinePersistence.ts +++ b/src/renderer/hooks/cue/usePipelinePersistence.ts @@ -25,6 +25,7 @@ import { cueService } from '../../services/cue'; import { captureException } from '../../utils/sentry'; import { notifyToast } from '../../stores/notificationStore'; import type { CuePipelineSessionInfo as SessionInfo } from '../../../shared/cue-pipeline-types'; +import { computeCommonAncestorPath, isDescendantOrEqual } from '../../../shared/cue-path-utils'; const SAVE_SUCCESS_IDLE_DELAY_MS = 2000; const SAVE_ERROR_IDLE_DELAY_MS = 3000; @@ -158,10 +159,22 @@ export function usePipelinePersistence({ continue; } if (roots.size > 1) { - errors.push( - `"${pipeline.name}": agents span multiple project roots (${[...roots].join(', ')}) — a Cue pipeline must live in a single project.` - ); - continue; + // When all roots are subdirectories of a common ancestor, the + // pipeline can live at that ancestor's .maestro/cue.yaml. This + // enables cross-directory pipelines (e.g. project/ + project/Digest) + // while preserving the single-owner invariant. + const commonRoot = computeCommonAncestorPath([...roots]); + const allDescendants = + commonRoot !== null && [...roots].every((r) => isDescendantOrEqual(r, commonRoot)); + if (!allDescendants) { + errors.push( + `"${pipeline.name}": agents span unrelated project roots (${[...roots].join(', ')}) — a Cue pipeline must live in a single project.` + ); + continue; + } + // Collapse to the common ancestor root for YAML output. + roots.clear(); + roots.add(commonRoot); } if (missingRoot) { errors.push( @@ -254,10 +267,16 @@ export function usePipelinePersistence({ rootsCleared++; } - // Refresh every session whose project root was touched so the engine - // reloads the freshly written YAML into its in-memory registry. + // Refresh every session whose project root was touched — or is a + // descendant of a touched root — so the engine reloads the freshly + // written YAML. Descendant sessions need refreshing because they + // inherit their config from the ancestor root via fallback. for (const session of sessions) { - if (session.projectRoot && touchedRoots.has(session.projectRoot)) { + if (!session.projectRoot) continue; + const needsRefresh = + touchedRoots.has(session.projectRoot) || + [...touchedRoots].some((root) => isDescendantOrEqual(session.projectRoot!, root)); + if (needsRefresh) { await cueService.refreshSession(session.id, session.projectRoot); } } diff --git a/src/shared/cue-path-utils.ts b/src/shared/cue-path-utils.ts new file mode 100644 index 0000000000..a45e7c9e47 --- /dev/null +++ b/src/shared/cue-path-utils.ts @@ -0,0 +1,49 @@ +/** + * Path utilities for cross-directory Cue pipeline support. + * + * Enables pipelines to span agents in subdirectories of a common project root + * by detecting ancestor/descendant relationships between project paths. + */ + +import * as path from 'path'; + +/** + * Given an array of absolute paths, return their longest common directory + * prefix. Returns `null` for empty input, or the single path for a + * single-element array. + * + * Example: `['/a/b/c', '/a/b/d']` → `'/a/b'` + */ +export function computeCommonAncestorPath(paths: string[]): string | null { + if (paths.length === 0) return null; + + const normalized = paths.map((p) => path.resolve(p)); + if (normalized.length === 1) return normalized[0]; + + const segments = normalized.map((p) => p.split(path.sep)); + const minLength = Math.min(...segments.map((s) => s.length)); + + let commonLength = 0; + for (let i = 0; i < minLength; i++) { + const segment = segments[0][i]; + if (segments.every((s) => s[i] === segment)) { + commonLength = i + 1; + } else { + break; + } + } + + if (commonLength === 0) return path.sep; + return segments[0].slice(0, commonLength).join(path.sep) || path.sep; +} + +/** + * Returns `true` if `child` is the same as or a subdirectory of `parent`. + * Both must be absolute paths. Uses normalized comparison. + */ +export function isDescendantOrEqual(child: string, parent: string): boolean { + const normalizedChild = path.resolve(child); + const normalizedParent = path.resolve(parent); + if (normalizedChild === normalizedParent) return true; + return normalizedChild.startsWith(normalizedParent + path.sep); +}