Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/__tests__/main/cue/cue-completion-chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/main/cue/cue-concurrency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/main/cue/cue-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/main/cue/cue-ipc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/main/cue/cue-multi-hop-chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/main/cue/cue-session-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/main/cue/cue-sleep-prevention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/main/cue/cue-sleep-wake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/main/cue/cue-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions src/__tests__/main/cue/cue-yaml-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock('fs', () => ({

// Must import after mocks
import {
findAncestorCueConfigRoot,
loadCueConfig,
loadCueConfigDetailed,
watchCueYaml,
Expand Down Expand Up @@ -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');
});
});
});
76 changes: 76 additions & 0 deletions src/__tests__/shared/cue-path-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 42 additions & 3 deletions src/main/cue/cue-session-runtime-service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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);
});

Expand Down
4 changes: 4 additions & 0 deletions src/main/cue/cue-session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 32 additions & 1 deletion src/main/cue/cue-yaml-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Loading