Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
87 changes: 87 additions & 0 deletions src/__tests__/main/cue/cue-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ vi.mock('../../../main/cue/cue-github-poller', () => ({

// Mock the task scanner
const mockCreateCueTaskScanner = vi.fn<(config: unknown) => () => void>();
const mockScanTaskFilesOnce =
vi.fn<(watchGlob: string, projectRoot: string) => Array<Record<string, unknown>>>();
vi.mock('../../../main/cue/cue-task-scanner', () => ({
createCueTaskScanner: (...args: unknown[]) => mockCreateCueTaskScanner(args[0]),
scanTaskFilesOnce: (...args: unknown[]) =>
mockScanTaskFilesOnce(args[0] as string, args[1] as string),
}));

// Mock the database
Expand Down Expand Up @@ -113,6 +117,7 @@ describe('CueEngine', () => {

yamlWatcherCleanup = vi.fn();
mockWatchCueYaml.mockReturnValue(yamlWatcherCleanup);
mockScanTaskFilesOnce.mockReturnValue([]);

// Default loadCueConfigDetailed: derive from loadCueConfig for tests that
// only configure mockLoadCueConfig. Tests that need to inject warnings or
Expand Down Expand Up @@ -1537,6 +1542,88 @@ describe('CueEngine', () => {

engine.stop();
});

it('manual trigger scans the watched file and populates the task payload', () => {
const config = createMockConfig({
subscriptions: [
{
name: 'task-queue',
event: 'task.pending',
enabled: true,
prompt: 'process tasks',
watch: 'tasks/**/*.md',
},
],
});
mockLoadCueConfig.mockReturnValue(config);
mockScanTaskFilesOnce.mockReturnValue([
{
path: '/projects/test/tasks/queue.md',
filename: 'queue.md',
taskCount: 2,
taskList: 'L3: first task\nL5: second task',
tasks: [
{ line: 3, text: 'first task' },
{ line: 5, text: 'second task' },
],
},
]);

const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();

const result = engine.triggerSubscription('task-queue');
expect(result).toBe(true);

// Scanned with the sub's watch glob against the owner's projectRoot.
expect(mockScanTaskFilesOnce).toHaveBeenCalledWith('tasks/**/*.md', '/projects/test');

// The scanned task fields land in the dispatched event payload so the
// prompt's {{CUE_TASK_COUNT}}/{{CUE_TASK_LIST}} resolve to real tasks.
expect(deps.onCueRun).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
payload: expect.objectContaining({
manual: true,
taskCount: 2,
taskList: 'L3: first task\nL5: second task',
path: '/projects/test/tasks/queue.md',
}),
}),
})
);

engine.stop();
});

it('manual trigger with no pending tasks still dispatches without task fields', () => {
const config = createMockConfig({
subscriptions: [
{
name: 'task-queue',
event: 'task.pending',
enabled: true,
prompt: 'process tasks',
watch: 'tasks/**/*.md',
},
],
});
mockLoadCueConfig.mockReturnValue(config);
mockScanTaskFilesOnce.mockReturnValue([]);

const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();

engine.triggerSubscription('task-queue');

const callArgs = (deps.onCueRun as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(callArgs.event.payload).toMatchObject({ manual: true });
expect(callArgs.event.payload).not.toHaveProperty('taskCount');

engine.stop();
});
});

describe('getStatus', () => {
Expand Down
30 changes: 30 additions & 0 deletions src/main/cue/cue-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
parseCueSubscriptionId,
pipelineKeyForSubscription,
} from '../../shared/cue/subscription-id';
import { scanTaskFilesOnce } from './cue-task-scanner';

const MAX_CHAIN_DEPTH = 10;

Expand Down Expand Up @@ -1152,8 +1153,37 @@ export class CueEngine {

let totalDispatched = 0;
for (const { ownerSessionId, state, sub } of toDispatch) {
// task.pending subs carry their tasks in the event payload. A manual
// trigger has no scanner run behind it, so scan the watched file(s)
// now — otherwise the prompt's {{CUE_TASK_COUNT}}/{{CUE_TASK_LIST}}
// fall back to "0"/"" and the run sees no work to do.
let taskPayload: Record<string, unknown> = {};
if (sub.event === 'task.pending' && sub.watch) {
const projectRoot = this.deps
.getSessions()
.find((s) => s.id === ownerSessionId)?.projectRoot;
if (projectRoot) {
const payloads = scanTaskFilesOnce(sub.watch, projectRoot);
Comment thread
scriptease marked this conversation as resolved.
Outdated
if (payloads.length > 0) {
taskPayload = payloads[0];
if (payloads.length > 1) {
this.deps.onLog(
'cue',
`[CUE] "${sub.name}" manual trigger: ${payloads.length} files match "${sub.watch}"; using ${String(taskPayload.filename)}`
);
}
Comment thread
scriptease marked this conversation as resolved.
Outdated
} else {
this.deps.onLog(
'cue',
`[CUE] "${sub.name}" manual trigger: no pending tasks in "${sub.watch}"`
);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const event = createCueEvent(sub.event, sub.name, {
manual: true,
...taskPayload,
...(sourceAgentId ? { sourceAgentId } : {}),
...(promptOverride ? { cliPrompt: promptOverride } : {}),
});
Expand Down
80 changes: 66 additions & 14 deletions src/main/cue/cue-task-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,69 @@ export function extractPendingTasks(content: string): PendingTask[] {
return tasks;
}

/**
* Build the `task.pending` event payload for a single file's content.
* Returns null when the file has no pending tasks (nothing to fire).
*
* Shared by the polling scanner and the manual-trigger path so both produce
* identical payloads (path/taskCount/taskList/tasks/content).
*/
export function buildTaskPendingPayload(
absPath: string,
relPath: string,
content: string
): Record<string, unknown> | null {
const pendingTasks = extractPendingTasks(content);
if (pendingTasks.length === 0) {
return null;
}

const taskList = pendingTasks.map((t) => `L${t.line}: ${t.text}`).join('\n');

return {
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),
};
}

/**
* One-shot scan: walk `projectRoot`, read every file matching `watchGlob`, and
* return a `task.pending` payload for each file that currently has pending
* tasks. Unlike the polling scanner this ignores content hashes and seeding —
* it always reflects the live on-disk state. Used by the manual trigger
* (`maestro-cli cue trigger`) so a hand-fired run sees the same tasks a poll
* would.
*/
export function scanTaskFilesOnce(
watchGlob: string,
projectRoot: string
): Array<Record<string, unknown>> {
const isMatch = picomatch(watchGlob);
const allFiles = walkDir(projectRoot, projectRoot);
const payloads: Array<Record<string, unknown>> = [];

for (const relPath of allFiles) {
if (!isMatch(relPath)) continue;
const absPath = path.resolve(projectRoot, relPath);
let content: string;
try {
content = fs.readFileSync(absPath, 'utf-8');
} catch {
continue;
}
const payload = buildTaskPendingPayload(absPath, relPath, content);
if (payload) payloads.push(payload);
}

return payloads;
}

/**
* Recursively walk a directory and return all file paths (relative to root).
*/
Expand Down Expand Up @@ -148,23 +211,12 @@ export function createCueTaskScanner(config: CueTaskScannerConfig): () => void {
}

// Extract pending tasks
const pendingTasks = extractPendingTasks(content);
if (pendingTasks.length === 0) {
const payload = buildTaskPendingPayload(absPath, relPath, content);
if (!payload) {
continue;
}

const taskList = pendingTasks.map((t) => `L${t.line}: ${t.text}`).join('\n');

const event = createCueEvent('task.pending', triggerName, {
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),
});
const event = createCueEvent('task.pending', triggerName, payload);

onEvent(event);
}
Expand Down