diff --git a/docs/autorun-playbooks.md b/docs/autorun-playbooks.md index 295a6a0040..769fc021a4 100644 --- a/docs/autorun-playbooks.md +++ b/docs/autorun-playbooks.md @@ -77,6 +77,35 @@ Auto Run supports running multiple documents in sequence: 5. Enable **Loop Mode** to cycle back to the first document after completing the last 6. Click **Go** to start running documents +## Scheduling a Run + +By default an Auto Run starts as soon as you press **Go**. The **Start** control +in the Auto Run window switches that to a specific date and time: + +| Option | Description | +| ----------------- | ------------------------------------------------------------------- | +| **Now** | The default. Press Go and the run starts immediately. | +| **At a set time** | Pick a date and time; the run fires once, then the schedule is gone | + +When a time is set the **Go** button becomes **Schedule**. The most common use +is starting a run after your provider's token limit resets, so you wake up to +finished work rather than a run that stalled at 2am. + +A few things worth knowing: + +- The time is your local time, and the schedule survives quitting and reopening + Maestro. +- The documents are pinned when you schedule, not when the run fires. Repointing + the agent's Auto Run folder afterwards will not swap out what runs. +- You can schedule while the agent is busy. Readiness is checked when the run + actually fires, not when you schedule it. +- Scheduling is one-shot. For a run that repeats, use a recurring + [Maestro Cue](./maestro-cue) task instead. + +Scheduled runs are stored as Maestro Cue tasks, so **Start** requires the +Maestro Cue Encore Feature. Pending runs appear under **Scheduled Tasks** in the +Cue window, which is also where you cancel one. + ## Model Override The run configuration modal has **Model** and **Effort** pickers, both defaulting to **Use agent default**. Picking a value runs _this Auto Run only_ on that model: every task spawn in the run uses it, the agent's own configured model is left alone (its interactive tabs keep using the default), and the override is forgotten when the run ends. The pickers reset to the default each time the modal opens, and are hidden for providers that expose no model or effort options. Worktree runs honor the override too, without changing the child worktree agent's own configured model. diff --git a/docs/maestro-cue-configuration.md b/docs/maestro-cue-configuration.md index 585f3c3e0a..e4849e8390 100644 --- a/docs/maestro-cue-configuration.md +++ b/docs/maestro-cue-configuration.md @@ -59,9 +59,10 @@ subscriptions: poll_minutes: number # Optional for github.*, task.pending # Action-specific fields - action: string # Optional. One of "prompt" (default), "notify", "command" + action: string # Optional. One of "prompt" (default), "notify", "command", "autorun" notify: object # Optional. Notify payload when action is "notify" (message, sticky, etc.) command: object # Optional. Command spec when action is "command" (mode, shell, cli, etc.) + auto_run: object # Optional. Auto Run payload when action is "autorun" (documents, prompt, etc.) # Global settings (all optional - sensible defaults applied) settings: @@ -149,9 +150,10 @@ Either `prompt` or `prompt_file` must be provided. If both are present, `prompt_ | `output_prompt` | string | - | Follow-up prompt sent after the main run completes successfully | | `output_prompt_file` | string | - | Path to a `.md` file for the output prompt (alternative to inline) | | `label` | string | - | Human-readable label displayed in the Cue dashboard and pipeline editor | -| `action` | string | `prompt` | Action to dispatch when the event fires: `prompt` (run the agent), `notify` (surface a toast through the owning agent - clicking it jumps there), or `command` (shell/cli call) | +| `action` | string | `prompt` | Action to dispatch when the event fires: `prompt` (run the agent), `notify` (surface a toast through the owning agent - clicking it jumps there), `command` (shell/cli call), or `autorun` (launch an Auto Run in the owning agent) | | `notify` | object | - | Notify payload when `action: notify`. Fields: `message` (string, required), `sticky` (boolean), `level` (`info` \| `success` \| `warning` \| `error`). The toast renders through the owning agent; clicking it jumps there | | `command` | object | - | Command spec when `action: command`. Fields include `mode` (`shell` \| `cli`), `shell`/`cli` invocation, and related options (see [Command Nodes](./maestro-cue-advanced)) | +| `auto_run` | object | - | Auto Run payload when `action: autorun`. Fields: `documents` (list of absolute `.md` paths, required), `reset_on_completion` (list of booleans, one per document), `prompt`, `loop_enabled`, `max_loops`, `model`, `effort` | ### Prompt Field diff --git a/src/__tests__/main/cue/config/cue-config-validator.test.ts b/src/__tests__/main/cue/config/cue-config-validator.test.ts index d1275207a1..1833cf7196 100644 --- a/src/__tests__/main/cue/config/cue-config-validator.test.ts +++ b/src/__tests__/main/cue/config/cue-config-validator.test.ts @@ -242,7 +242,78 @@ describe('validateSubscription - action: notify', () => { it('rejects unknown action values', () => { const found = errs({ ...base, action: 'explode' }); expect( - found.some((e) => /"action" must be "prompt", "command", or "notify" when provided/.test(e)) + found.some((e) => + /"action" must be "prompt", "command", "notify", or "autorun" when provided/.test(e) + ) + ).toBe(true); + }); +}); + +describe('validateSubscription - action: autorun', () => { + const base = { + name: 'autorun-1', + event: 'time.once', + action: 'autorun' as const, + agent_id: 'agent-xyz', + fire_at: '2026-05-22T14:30:00-05:00', + auto_run: { documents: ['/proj/Auto Run Docs/ship.md'] }, + }; + + it('accepts action: autorun without a prompt field', () => { + // An autorun subscription's work is its document list; `prompt` is + // deliberately absent. + expect(errs(base)).toEqual([]); + }); + + it('requires auto_run', () => { + const { auto_run: _omitted, ...withoutAutoRun } = base; + expect( + errs(withoutAutoRun).some((e) => + /"auto_run" is required and must be an object when action is "autorun"/.test(e) + ) + ).toBe(true); + }); + + it('rejects an empty document list', () => { + expect( + errs({ ...base, auto_run: { documents: [] } }).some((e) => + /"auto_run\.documents" is required and must be a non-empty array/.test(e) + ) + ).toBe(true); + }); + + it('rejects a reset_on_completion array that does not align with documents', () => { + // Misaligned flags would reset the wrong document. + expect( + errs({ + ...base, + auto_run: { documents: ['/a.md', '/b.md'], reset_on_completion: [true] }, + }).some((e) => /must have one entry per "auto_run\.documents" entry/.test(e)) + ).toBe(true); + }); + + it('requires agent_id', () => { + const { agent_id: _omitted, ...withoutAgent } = base; + expect( + errs(withoutAgent).some((e) => + /"agent_id" is required and must be a non-empty string when action is "autorun"/.test(e) + ) + ).toBe(true); + }); + + it('rejects fan_out - one Auto Run belongs to one agent', () => { + expect( + errs({ ...base, fan_out: ['other-agent'] }).some((e) => + /"fan_out" is not supported when action is "autorun"/.test(e) + ) + ).toBe(true); + }); + + it('rejects a non-positive max_loops', () => { + expect( + errs({ ...base, auto_run: { documents: ['/a.md'], max_loops: 0 } }).some((e) => + /"auto_run\.max_loops" must be a positive integer when provided/.test(e) + ) ).toBe(true); }); }); diff --git a/src/__tests__/main/cue/cue-autorun-executor.test.ts b/src/__tests__/main/cue/cue-autorun-executor.test.ts new file mode 100644 index 0000000000..7c0f4dad85 --- /dev/null +++ b/src/__tests__/main/cue/cue-autorun-executor.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for cue-autorun-executor. + * + * The status this executor returns is load-bearing rather than cosmetic: a + * `time.once` subscription is consumed on any terminal status, so `completed` + * vs `failed` decides whether a scheduled run that did NOT start survives on + * disk for the user to find. These tests pin that behavior, plus the fact that + * the documents launched are the ones captured at schedule time. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { BrowserWindow } from 'electron'; +import type { CueEvent, CueSubscription } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +const launchCueAutoRunMock = vi.fn(); +vi.mock('../../../main/cue/cue-autorun-bridge', () => ({ + launchCueAutoRun: (...args: unknown[]) => launchCueAutoRunMock(...args), +})); + +import { executeCueAutoRun } from '../../../main/cue/cue-autorun-executor'; + +function createSession(): SessionInfo { + return { + id: 'session-1', + name: 'Nightly', + toolType: 'claude-code', + cwd: '/tmp/project', + projectRoot: '/tmp/project', + }; +} + +function createSubscription(overrides: Partial = {}): CueSubscription { + return { + name: 'run-at-6am', + event: 'time.once', + enabled: true, + prompt: '', + action: 'autorun', + agent_id: 'session-1', + auto_run: { documents: ['/proj/Auto Run Docs/ship.md'] }, + ...overrides, + } as CueSubscription; +} + +const event = { type: 'time.once', payload: {} } as unknown as CueEvent; +const mainWindow = {} as BrowserWindow; + +describe('executeCueAutoRun', () => { + beforeEach(() => { + launchCueAutoRunMock.mockReset(); + }); + + it('launches the documents captured on the subscription', async () => { + launchCueAutoRunMock.mockResolvedValue({ success: true }); + + const result = await executeCueAutoRun({ + runId: 'run-1', + session: createSession(), + subscription: createSubscription(), + event, + autoRun: { + documents: ['/proj/Auto Run Docs/a.md', '/proj/Auto Run Docs/b.md'], + reset_on_completion: [false, true], + prompt: 'Work the tasks', + loop_enabled: true, + max_loops: 2, + }, + mainWindow, + onLog: vi.fn(), + }); + + expect(launchCueAutoRunMock).toHaveBeenCalledWith( + mainWindow, + expect.objectContaining({ + sessionId: 'session-1', + documents: [ + { filename: '/proj/Auto Run Docs/a.md', resetOnCompletion: false }, + { filename: '/proj/Auto Run Docs/b.md', resetOnCompletion: true }, + ], + prompt: 'Work the tasks', + loopEnabled: true, + maxLoops: 2, + }) + ); + expect(result.status).toBe('completed'); + expect(result.exitCode).toBe(0); + }); + + it('defaults resetOnCompletion to false when no flags were captured', async () => { + launchCueAutoRunMock.mockResolvedValue({ success: true }); + + await executeCueAutoRun({ + runId: 'run-1', + session: createSession(), + subscription: createSubscription(), + event, + autoRun: { documents: ['/proj/a.md'] }, + mainWindow, + onLog: vi.fn(), + }); + + expect(launchCueAutoRunMock.mock.calls[0][1].documents).toEqual([ + { filename: '/proj/a.md', resetOnCompletion: false }, + ]); + }); + + it('reports `failed` when the renderer does not accept the launch', async () => { + // This is the case the status semantics exist for: paired with + // `self_destruct_on_failure: false`, a `failed` status keeps the + // subscription on disk instead of silently consuming the user's + // scheduled run. + launchCueAutoRunMock.mockResolvedValue({ + success: false, + error: 'renderer webContents not available', + }); + + const result = await executeCueAutoRun({ + runId: 'run-1', + session: createSession(), + subscription: createSubscription(), + event, + autoRun: { documents: ['/proj/a.md'] }, + mainWindow, + onLog: vi.fn(), + }); + + expect(result.status).toBe('failed'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('renderer webContents not available'); + }); + + it('logs an explanation when the launch fails so the failure is triageable', async () => { + launchCueAutoRunMock.mockResolvedValue({ success: false, error: 'boom' }); + const onLog = vi.fn(); + + await executeCueAutoRun({ + runId: 'run-1', + session: createSession(), + subscription: createSubscription(), + event, + autoRun: { documents: ['/proj/a.md'] }, + mainWindow, + onLog, + }); + + const errorLogs = onLog.mock.calls.filter(([level]) => level === 'error'); + expect(errorLogs).toHaveLength(1); + expect(errorLogs[0][1]).toContain('boom'); + }); +}); diff --git a/src/__tests__/main/cue/cue-dispatch-service.test.ts b/src/__tests__/main/cue/cue-dispatch-service.test.ts index 69fc055661..25039b3a25 100644 --- a/src/__tests__/main/cue/cue-dispatch-service.test.ts +++ b/src/__tests__/main/cue/cue-dispatch-service.test.ts @@ -87,11 +87,43 @@ describe('createCueDispatchService', () => { undefined, undefined, // chainRootId undefined, // parentEventId - undefined // notify + undefined, // notify + undefined // autoRun ); }); }); + describe('action: autorun', () => { + it('threads the captured auto_run payload to executeRun', () => { + const { deps, executeRun } = makeDeps(); + const svc = createCueDispatchService(deps); + const autoRun = { documents: ['/proj/a.md'] }; + const sub = makeSub({ prompt: '', action: 'autorun', auto_run: autoRun }); + const event = createCueEvent('time.once', 'my-sub'); + + expect(svc.dispatchSubscription('owner', sub, event, 'src')).toBe(1); + // autoRun rides in the trailing positional slot, after notify. + const call = executeRun.mock.calls[0]; + expect(call[13]).toEqual(autoRun); + // The prompt slot is back-filled so the "no prompt -> skip" gate + // cannot silently drop a scheduled run. + expect(call[1]).toBe('/proj/a.md'); + }); + + it('refuses to dispatch an autorun subscription with no documents', () => { + const { deps, executeRun, logs } = makeDeps(); + const svc = createCueDispatchService(deps); + const sub = makeSub({ prompt: '', action: 'autorun', auto_run: { documents: [] } }); + const event = createCueEvent('time.once', 'my-sub'); + + expect(svc.dispatchSubscription('owner', sub, event, 'src')).toBe(0); + expect(executeRun).not.toHaveBeenCalled(); + // A scheduled run fires unattended, so a silent no-op is + // indistinguishable from never having fired at all. + expect(logs.some(([level, msg]) => level === 'error' && /no documents/.test(msg))).toBe(true); + }); + }); + describe('fan-out', () => { it('returns 2 when both targets have prompts', () => { const { deps, executeRun } = makeDeps(); diff --git a/src/__tests__/main/cue/cue-scheduled-tasks.test.ts b/src/__tests__/main/cue/cue-scheduled-tasks.test.ts index da674ddc78..e96b3adb43 100644 --- a/src/__tests__/main/cue/cue-scheduled-tasks.test.ts +++ b/src/__tests__/main/cue/cue-scheduled-tasks.test.ts @@ -58,14 +58,81 @@ describe('cue-scheduled-tasks', () => { expect(subs[1].notify).toEqual({ message: 'done', sticky: true }); }); - it('rejects a task with neither a prompt nor a notification', () => { + it('rejects a task with no prompt, notification, or Auto Run', () => { expect(() => buildScheduledTaskSubscriptions(agent, { agentId: agent.id, kind: 'once', fireAt: '2030-01-01T10:00:00.000Z', }) - ).toThrow(/prompt, a notification, or both/); + ).toThrow(/prompt, a notification, or an Auto Run/); + }); + + it('builds an autorun subscription carrying the captured document list', () => { + const subs = buildScheduledTaskSubscriptions(agent, { + agentId: agent.id, + kind: 'once', + fireAt: '2030-01-01T10:00:00.000Z', + keepOnFailure: true, + autoRun: { + documents: ['/proj/Auto Run Docs/ship-it.md'], + reset_on_completion: [true], + prompt: 'Work the tasks', + loop_enabled: true, + max_loops: 3, + }, + }); + + expect(subs).toHaveLength(1); + expect(subs[0]).toMatchObject({ + event: 'time.once', + action: 'autorun', + agent_id: 'agent-alpha', + fire_at: '2030-01-01T10:00:00.000Z', + // keepOnFailure must survive: a one-shot sub is consumed on any + // terminal status, so without this a failed launch deletes the + // schedule and leaves nothing to inspect. + self_destruct_on_failure: false, + auto_run: { + documents: ['/proj/Auto Run Docs/ship-it.md'], + reset_on_completion: [true], + prompt: 'Work the tasks', + loop_enabled: true, + max_loops: 3, + }, + }); + expect(subs[0].label).toBe('Auto Run: ship-it.md'); + }); + + it('refuses to pair an Auto Run with a prompt or a notification', () => { + expect(() => + buildScheduledTaskSubscriptions(agent, { + agentId: agent.id, + kind: 'once', + fireAt: '2030-01-01T10:00:00.000Z', + prompt: 'do a thing', + autoRun: { documents: ['/proj/a.md'] }, + }) + ).toThrow(/cannot also carry a prompt or a notification/); + }); + + it('round-trips an autorun task through cue.yaml', () => { + createScheduledTask(agent, { + agentId: agent.id, + kind: 'once', + fireAt: '2030-06-01T09:30:00.000Z', + keepOnFailure: true, + autoRun: { documents: ['/proj/Auto Run Docs/nightly.md'] }, + }); + + const written = readSubs(projectRoot); + expect(written).toHaveLength(1); + expect(written[0]).toMatchObject({ action: 'autorun' }); + + const { tasks } = collectScheduledTasks([agent]); + expect(tasks).toHaveLength(1); + expect(tasks[0].action).toBe('autorun'); + expect(tasks[0].autoRun?.documents).toEqual(['/proj/Auto Run Docs/nightly.md']); }); it('rejects an out-of-range interval', () => { diff --git a/src/__tests__/renderer/components/ScheduleRunSection.test.ts b/src/__tests__/renderer/components/ScheduleRunSection.test.ts new file mode 100644 index 0000000000..7823d86c6d --- /dev/null +++ b/src/__tests__/renderer/components/ScheduleRunSection.test.ts @@ -0,0 +1,67 @@ +/** + * Tests for the Auto Run "Start" control's date/time helpers. + * + * The timezone round-trip is the point. A `datetime-local` value is LOCAL wall + * clock, so reading it as UTC shifts every scheduled run by the machine's + * offset - "start at 6am" quietly becomes 1am, and the failure is invisible on + * a UTC dev box. + */ + +import { describe, it, expect } from 'vitest'; +import { + toDateTimeLocalValue, + fromDateTimeLocalValue, + validateScheduledStart, +} from '../../../renderer/components/ScheduleRunSection'; + +describe('ScheduleRunSection date helpers', () => { + it('round-trips a local date through the picker value', () => { + const original = new Date(2030, 5, 14, 6, 30, 0, 0); + const value = toDateTimeLocalValue(original); + + expect(value).toBe('2030-06-14T06:30'); + expect(fromDateTimeLocalValue(value)?.getTime()).toBe(original.getTime()); + }); + + it('reads the picker value as local wall clock, not UTC', () => { + const parsed = fromDateTimeLocalValue('2030-06-14T06:30'); + + // The whole point: 06:30 in the picker means 06:30 where the user is. + expect(parsed?.getHours()).toBe(6); + expect(parsed?.getMinutes()).toBe(30); + }); + + it('zero-pads single-digit months, days, hours, and minutes', () => { + expect(toDateTimeLocalValue(new Date(2030, 0, 5, 9, 7))).toBe('2030-01-05T09:07'); + }); + + it('returns null for an empty or unparseable value', () => { + expect(fromDateTimeLocalValue('')).toBeNull(); + expect(fromDateTimeLocalValue('not-a-date')).toBeNull(); + }); +}); + +describe('validateScheduledStart', () => { + const now = new Date(2030, 5, 14, 6, 0, 0, 0); + + it('treats an empty value as "now" and accepts it', () => { + expect(validateScheduledStart('', now)).toBeNull(); + }); + + it('accepts a time comfortably in the future', () => { + expect(validateScheduledStart('2030-06-14T08:00', now)).toBeNull(); + }); + + it('rejects a time in the past', () => { + expect(validateScheduledStart('2030-06-14T05:00', now)).toMatch(/at least a minute/); + }); + + it('rejects a time inside the minimum lead window', () => { + // 30s out - the run would fire before the user finished setting it up. + expect(validateScheduledStart('2030-06-14T06:00', now)).toMatch(/at least a minute/); + }); + + it('rejects an unparseable value', () => { + expect(validateScheduledStart('garbage', now)).toMatch(/valid date and time/); + }); +}); diff --git a/src/main/cue/config/cue-config-normalizer.ts b/src/main/cue/config/cue-config-normalizer.ts index d4e6b703a8..a0a984be75 100644 --- a/src/main/cue/config/cue-config-normalizer.ts +++ b/src/main/cue/config/cue-config-normalizer.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as yaml from 'js-yaml'; import { type CueAction, + type CueAutoRunConfig, type CueCommand, type CueConfig, type CueGitHubState, @@ -181,6 +182,41 @@ function normalizeNotify(rawNotify: unknown): CueNotifyConfig | undefined { return result; } +function normalizeAutoRun(rawAutoRun: unknown): CueAutoRunConfig | undefined { + if (!rawAutoRun || typeof rawAutoRun !== 'object' || Array.isArray(rawAutoRun)) { + return undefined; + } + const raw = rawAutoRun as Record; + const documents = + Array.isArray(raw.documents) && + raw.documents.every((value: unknown) => typeof value === 'string') + ? (raw.documents as string[]) + : undefined; + if (!documents || documents.length === 0) { + // A document-less autorun has nothing to launch. Returning undefined lets + // the validator report it as a config error rather than the engine firing + // a no-op run that looks successful in the activity log. + return undefined; + } + + const result: CueAutoRunConfig = { documents }; + if ( + Array.isArray(raw.reset_on_completion) && + raw.reset_on_completion.every((value: unknown) => typeof value === 'boolean') && + raw.reset_on_completion.length === documents.length + ) { + result.reset_on_completion = raw.reset_on_completion as boolean[]; + } + if (typeof raw.prompt === 'string') result.prompt = raw.prompt; + if (typeof raw.loop_enabled === 'boolean') result.loop_enabled = raw.loop_enabled; + if (typeof raw.max_loops === 'number' && Number.isInteger(raw.max_loops) && raw.max_loops >= 1) { + result.max_loops = raw.max_loops; + } + if (typeof raw.model === 'string') result.model = raw.model; + if (typeof raw.effort === 'string') result.effort = raw.effort; + return result; +} + function normalizeSubscription( sub: Record, projectRoot: string @@ -199,11 +235,15 @@ function normalizeSubscription( : undefined; const action: CueAction | undefined = - sub.action === 'command' || sub.action === 'prompt' || sub.action === 'notify' + sub.action === 'command' || + sub.action === 'prompt' || + sub.action === 'notify' || + sub.action === 'autorun' ? (sub.action as CueAction) : undefined; const command = normalizeCommand(sub.command); const notify = normalizeNotify(sub.notify); + const autoRun = normalizeAutoRun(sub.auto_run); const resolvedPrompt = promptSpec.inline ?? @@ -216,7 +256,17 @@ function normalizeSubscription( ? command.shell : command.cli.target : ''; - const prompt = action === 'command' && !resolvedPrompt ? commandSentinel : resolvedPrompt; + // Autorun carries its work in `auto_run`, not in `prompt`. Back-fill the + // same way `command` does so the dispatcher's "no prompt -> skip" gate + // can't silently drop a scheduled run, and so the activity log shows what + // was launched instead of a blank row. + const autoRunSentinel = autoRun ? autoRun.documents.join(', ') : ''; + const prompt = + action === 'command' && !resolvedPrompt + ? commandSentinel + : action === 'autorun' && !resolvedPrompt + ? autoRunSentinel + : resolvedPrompt; const outputPrompt = outputPromptSpec?.inline ?? (outputPromptSpec?.file ? readPromptFile(projectRoot, outputPromptSpec.file) : undefined); @@ -259,6 +309,7 @@ function normalizeSubscription( output_prompt: outputPrompt, action, command, + auto_run: autoRun, interval_minutes: typeof sub.interval_minutes === 'number' ? sub.interval_minutes : undefined, schedule_times: Array.isArray(sub.schedule_times) && diff --git a/src/main/cue/config/cue-config-validator.ts b/src/main/cue/config/cue-config-validator.ts index 3a4a41e47b..4d762897e8 100644 --- a/src/main/cue/config/cue-config-validator.ts +++ b/src/main/cue/config/cue-config-validator.ts @@ -89,6 +89,64 @@ function validateCommandField(value: unknown, prefix: string, errors: string[]): } } +/** + * Validate an `auto_run` field, required when `action === 'autorun'`. + * + * `documents` carries the absolute paths captured when the run was scheduled. + * It is validated as non-empty because an autorun subscription with nothing to + * run is indistinguishable at fire time from a run that silently did nothing, + * and the whole point of a scheduled Auto Run is that nobody is watching when + * it fires. + */ +function validateAutoRunField(value: unknown, prefix: string, errors: string[]): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + errors.push(`${prefix}: "auto_run" is required and must be an object when action is "autorun"`); + return; + } + const cfg = value as Record; + + const docs = cfg.documents; + if (!Array.isArray(docs) || docs.length === 0) { + errors.push(`${prefix}: "auto_run.documents" is required and must be a non-empty array`); + } else if (!docs.every((d: unknown) => typeof d === 'string' && d.trim().length > 0)) { + errors.push(`${prefix}: "auto_run.documents" must contain only non-empty strings`); + } + + const resets = cfg.reset_on_completion; + if (resets !== undefined) { + if (!Array.isArray(resets) || !resets.every((r: unknown) => typeof r === 'boolean')) { + errors.push( + `${prefix}: "auto_run.reset_on_completion" must be an array of booleans when provided` + ); + } else if (Array.isArray(docs) && resets.length !== docs.length) { + // Misaligned flags would silently reset the wrong document. + errors.push( + `${prefix}: "auto_run.reset_on_completion" must have one entry per "auto_run.documents" entry` + ); + } + } + + for (const key of ['prompt', 'model', 'effort'] as const) { + if (cfg[key] !== undefined && typeof cfg[key] !== 'string') { + errors.push(`${prefix}: "auto_run.${key}" must be a string when provided`); + } + } + + if (cfg.loop_enabled !== undefined && typeof cfg.loop_enabled !== 'boolean') { + errors.push(`${prefix}: "auto_run.loop_enabled" must be a boolean when provided`); + } + + if (cfg.max_loops !== undefined) { + if ( + typeof cfg.max_loops !== 'number' || + !Number.isInteger(cfg.max_loops) || + cfg.max_loops < 1 + ) { + errors.push(`${prefix}: "auto_run.max_loops" must be a positive integer when provided`); + } + } +} + /** * Validate a single subscription. Returns errors specific to this subscription * (with the supplied `prefix` prepended). Used both by the strict whole-config @@ -124,12 +182,21 @@ export function validateSubscription(sub: unknown, prefix: string): string[] { } const action = subRecord.action; - if (action !== undefined && action !== 'prompt' && action !== 'command' && action !== 'notify') { - errors.push(`${prefix}: "action" must be "prompt", "command", or "notify" when provided`); + if ( + action !== undefined && + action !== 'prompt' && + action !== 'command' && + action !== 'notify' && + action !== 'autorun' + ) { + errors.push( + `${prefix}: "action" must be "prompt", "command", "notify", or "autorun" when provided` + ); } const isCommand = action === 'command'; const isNotify = action === 'notify'; + const isAutoRun = action === 'autorun'; if (isCommand) { validateCommandField(subRecord.command, prefix, errors); @@ -170,6 +237,25 @@ export function validateSubscription(sub: unknown, prefix: string): string[] { if (Array.isArray(subRecord.fan_out) && subRecord.fan_out.length > 0) { errors.push(`${prefix}: "fan_out" is not supported when action is "notify"`); } + } else if (isAutoRun) { + // Autorun launches an Auto Run in the owning agent - no prompt, no + // command, no fan-out. The document set travels with the subscription + // so the run is pinned to what the user picked when they scheduled it. + validateAutoRunField(subRecord.auto_run, prefix, errors); + if (typeof subRecord.agent_id !== 'string' || subRecord.agent_id.trim().length === 0) { + errors.push( + `${prefix}: "agent_id" is required and must be a non-empty string when action is "autorun"` + ); + } + if (subRecord.command !== undefined) { + errors.push(`${prefix}: "command" is not supported when action is "autorun"`); + } + // One Auto Run belongs to one agent: fanning it out would launch the + // same document set concurrently in several agents against the same + // working tree. + if (Array.isArray(subRecord.fan_out) && subRecord.fan_out.length > 0) { + errors.push(`${prefix}: "fan_out" is not supported when action is "autorun"`); + } } else { // `fan_out_ids` is the rename-stable id mirror of `fan_out`. When // present it must be a string array of the same length so the diff --git a/src/main/cue/cue-autorun-bridge.ts b/src/main/cue/cue-autorun-bridge.ts new file mode 100644 index 0000000000..774d0dff21 --- /dev/null +++ b/src/main/cue/cue-autorun-bridge.ts @@ -0,0 +1,127 @@ +/** + * Cue -> renderer Auto Run bridge. + * + * Launching an Auto Run is a renderer-owned flow (it walks the document list, + * drives the batch processor, and can spawn a worktree child), so the main + * process cannot start one directly. The web/CLI surface already solved this: + * `remote:configureAutoRun` carries a launch request to the renderer and the + * renderer answers on a one-shot response channel. Cue's `action: autorun` + * runs entirely in the main process, so this helper reuses that same channel + * rather than looping back through the WebSocket server. + * + * Unlike {@link emitCueNotifyToast}, this bridge is NOT fire-and-forget. A + * scheduled Auto Run fires while nobody is watching, so the executor must be + * able to tell "the renderer accepted and started the run" from "the renderer + * never answered" - the second case has to be reported as a failure, or the + * one-shot subscription self-destructs and the user's 6am run vanishes with + * nothing left to inspect. + */ + +import { randomUUID } from 'crypto'; +import { BrowserWindow, ipcMain } from 'electron'; +import { isWebContentsAvailable } from '../utils/safe-send'; +import { logger } from '../utils/logger'; + +/** + * How long to wait for the renderer to accept a launch. + * + * Deliberately longer than the 10s used by the web-server callbacks: those + * answer a user who is sitting in front of a request, while this one may land + * on a renderer that is mid-worktree-creation. The wait covers ACCEPTANCE of + * the launch, not the run itself - the Auto Run keeps going long after this + * resolves. + */ +export const CUE_AUTORUN_LAUNCH_TIMEOUT_MS = 30_000; + +/** One document to run, in the shape `remote:configureAutoRun` expects. */ +export interface CueAutoRunDocument { + /** Absolute path, captured when the run was scheduled. */ + filename: string; + resetOnCompletion?: boolean; +} + +export interface CueAutoRunLaunchParams { + sessionId: string; + documents: CueAutoRunDocument[]; + prompt?: string; + loopEnabled?: boolean; + maxLoops?: number; + model?: string; + effort?: string; +} + +export interface CueAutoRunLaunchResult { + success: boolean; + error?: string; +} + +/** + * Ask the renderer to launch an Auto Run and wait for it to accept. + * + * Resolves `{ success: true }` only when the renderer actually reports the + * launch started. Every other path - no window, dead webContents, timeout, an + * explicit renderer-side rejection - resolves `{ success: false, error }`. + * Never rejects: the executor turns the result into a run status, and an + * exception escaping here would bypass that. + */ +export function launchCueAutoRun( + mainWindow: BrowserWindow | null, + params: CueAutoRunLaunchParams +): Promise { + if (!mainWindow) { + return Promise.resolve({ + success: false, + error: 'desktop window not available - Auto Run can only be launched by the renderer', + }); + } + + return new Promise((resolve) => { + const responseChannel = `remote:configureAutoRun:response:${randomUUID()}`; + let settled = false; + + const handleResponse = ( + _event: Electron.IpcMainEvent, + result: CueAutoRunLaunchResult | undefined + ) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + resolve(result ?? { success: false, error: 'renderer returned no result' }); + }; + + ipcMain.once(responseChannel, handleResponse); + + if (!isWebContentsAvailable(mainWindow)) { + settled = true; + ipcMain.removeListener(responseChannel, handleResponse); + resolve({ success: false, error: 'renderer webContents not available' }); + return; + } + + mainWindow.webContents.send( + 'remote:configureAutoRun', + params.sessionId, + { + documents: params.documents, + prompt: params.prompt, + loopEnabled: params.loopEnabled, + maxLoops: params.maxLoops, + ...(params.model && { model: params.model }), + ...(params.effort && { effort: params.effort }), + launch: true, + }, + responseChannel + ); + + const timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn(`Cue Auto Run launch timed out for session ${params.sessionId}`, 'Cue'); + resolve({ + success: false, + error: `renderer did not accept the launch within ${CUE_AUTORUN_LAUNCH_TIMEOUT_MS / 1000}s`, + }); + }, CUE_AUTORUN_LAUNCH_TIMEOUT_MS); + }); +} diff --git a/src/main/cue/cue-autorun-executor.ts b/src/main/cue/cue-autorun-executor.ts new file mode 100644 index 0000000000..6347fb1b1c --- /dev/null +++ b/src/main/cue/cue-autorun-executor.ts @@ -0,0 +1,120 @@ +/** + * Cue Auto Run Executor - runs an `action: autorun` subscription. + * + * Paired with a `time.once` trigger this is what backs "schedule this Auto Run + * for 6am": the Cue engine already owns fire timing, persistence across + * restarts, the missed-fire grace window, and the activity log, so scheduling + * an Auto Run needs none of its own. + * + * The executor hands the captured document list to the renderer via + * {@link launchCueAutoRun} and synthesizes a {@link CueRunResult} so the usual + * terminal-status pipeline runs (history entry, `time.once` self-destruct). + * + * Status semantics matter more here than in the other executors, because a + * `time.once` subscription is CONSUMED on a terminal status: + * + * - `completed` means the renderer accepted the launch. The Auto Run itself + * outlives this run record by design - Cue's job was to start it. + * - `failed` means it did not start. Scheduled Auto Run tasks are written + * with `self_destruct_on_failure: false`, so the subscription survives on + * disk for the user to inspect or re-trigger instead of silently + * evaporating. That is the difference between "my 6am run failed" and "my + * 6am run never existed". + */ + +import { BrowserWindow } from 'electron'; +import type { CueAutoRunConfig, CueEvent, CueRunResult, CueSubscription } from './cue-types'; +import type { SessionInfo } from '../../shared/types'; +import { launchCueAutoRun } from './cue-autorun-bridge'; + +export interface CueAutoRunExecutionConfig { + runId: string; + session: SessionInfo; + subscription: CueSubscription; + event: CueEvent; + /** Captured Auto Run payload - documents, prompt, loop settings. */ + autoRun: CueAutoRunConfig; + mainWindow: BrowserWindow | null; + onLog: (level: string, message: string) => void; +} + +/** + * Execute a Cue-triggered Auto Run launch. + * + * Never throws - a launch failure is reported as a `failed` `CueRunResult` so + * the completion pipeline still records it in the activity log. An exception + * escaping here would skip that record entirely, which is the one outcome a + * scheduled run cannot afford: no run, and no trace of why. + */ +export async function executeCueAutoRun(config: CueAutoRunExecutionConfig): Promise { + const { runId, session, subscription, event, autoRun } = config; + const startedAt = new Date().toISOString(); + + const documents = autoRun.documents.map((filename, index) => ({ + filename, + resetOnCompletion: autoRun.reset_on_completion?.[index] ?? false, + })); + + config.onLog( + 'cue', + `[CUE] Auto Run ${runId}: "${subscription.name}" -> agent ${session.id} ` + + `(${documents.length} document${documents.length === 1 ? '' : 's'}, ${event.type})` + ); + + const result = await launchCueAutoRun(config.mainWindow, { + sessionId: session.id, + documents, + prompt: autoRun.prompt, + loopEnabled: autoRun.loop_enabled, + maxLoops: autoRun.max_loops, + model: autoRun.model, + effort: autoRun.effort, + }); + + const endedAt = new Date().toISOString(); + const durationMs = Date.parse(endedAt) - Date.parse(startedAt); + const documentList = autoRun.documents.join(', '); + + if (!result.success) { + const reason = result.error ?? 'unknown error'; + config.onLog( + 'error', + `[CUE] Auto Run "${subscription.name}" did not start: ${reason}. ` + + `The subscription is kept so it can be inspected or re-triggered.` + ); + return { + runId, + sessionId: session.id, + sessionName: session.name, + subscriptionName: subscription.name, + pipelineName: subscription.pipeline_name, + event, + status: 'failed', + stdout: '', + stderr: `Auto Run launch failed: ${reason}`, + exitCode: 1, + durationMs, + startedAt, + endedAt, + }; + } + + return { + runId, + sessionId: session.id, + sessionName: session.name, + subscriptionName: subscription.name, + pipelineName: subscription.pipeline_name, + event, + // The launch was accepted. The Auto Run continues in the renderer and + // reports its own progress there - this record only ever describes the + // handoff, which is why the duration is milliseconds and not hours. + status: 'completed', + stdout: `Auto Run launched: ${documentList}`, + stderr: '', + exitCode: 0, + durationMs, + startedAt, + endedAt, + }; +} diff --git a/src/main/cue/cue-dispatch-service.ts b/src/main/cue/cue-dispatch-service.ts index b0d2fea307..69db8d072b 100644 --- a/src/main/cue/cue-dispatch-service.ts +++ b/src/main/cue/cue-dispatch-service.ts @@ -1,6 +1,12 @@ import * as crypto from 'crypto'; import type { MainLogLevel } from '../../shared/logger-types'; -import type { CueCommand, CueEvent, CueNotifyConfig, CueSubscription } from './cue-types'; +import type { + CueAutoRunConfig, + CueCommand, + CueEvent, + CueNotifyConfig, + CueSubscription, +} from './cue-types'; import { recordTriggerFired } from './cue-telemetry'; export interface CueDispatchServiceDeps { @@ -18,7 +24,8 @@ export interface CueDispatchServiceDeps { command?: CueCommand, chainRootId?: string, parentEventId?: string, - notify?: CueNotifyConfig + notify?: CueNotifyConfig, + autoRun?: CueAutoRunConfig ) => void; onLog: (level: MainLogLevel, message: string, data?: unknown) => void; /** @@ -169,10 +176,26 @@ export function createCueDispatchService(deps: CueDispatchServiceDeps): CueDispa // from the notify config + fallback chain. let prompt: string; let resolvedNotify: CueNotifyConfig | undefined; + let resolvedAutoRun: CueAutoRunConfig | undefined; if (sub.action === 'notify') { const message = resolveNotifyMessage(sub); prompt = message; resolvedNotify = { ...(sub.notify ?? {}), message }; + } else if (sub.action === 'autorun') { + // An autorun subscription's work is its document list, not a + // prompt. Bail loudly rather than dispatching a run that would + // reach the executor with nothing to launch - a scheduled run + // fires when nobody is watching, so a silent no-op looks + // exactly like a run that never fired at all. + if (!sub.auto_run || sub.auto_run.documents.length === 0) { + deps.onLog( + 'error', + `[CUE] "${sub.name}" has action='autorun' but no documents - skipping dispatch` + ); + return 0; + } + resolvedAutoRun = sub.auto_run; + prompt = sub.prompt?.trim() || sub.auto_run.documents.join(', '); } else { prompt = promptOverride ?? sub.prompt; if (!prompt) { @@ -193,7 +216,8 @@ export function createCueDispatchService(deps: CueDispatchServiceDeps): CueDispa sub.command, chainRootId, parentEventId, - resolvedNotify + resolvedNotify, + resolvedAutoRun ); return 1; }, diff --git a/src/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts index 2ac8a2a0f9..a58f6a5729 100644 --- a/src/main/cue/cue-engine.ts +++ b/src/main/cue/cue-engine.ts @@ -31,6 +31,7 @@ import { type AgentCompletionData, type CueCommand, type CueConfig, + type CueAutoRunConfig, type CueNotifyConfig, type CueEventType, type CueRunResult, @@ -130,6 +131,7 @@ export interface CueEngineDeps { action?: CueSubscription['action']; command?: CueCommand; notify?: CueNotifyConfig; + autoRun?: CueAutoRunConfig; }) => Promise; onStopCueRun?: (runId: string) => boolean; onLog: (level: MainLogLevel, message: string, data?: unknown) => void; @@ -426,7 +428,8 @@ export class CueEngine { command, chainRootId, parentEventId, - notify + notify, + autoRun ) => { this.runManager.execute( sessionId, @@ -442,7 +445,8 @@ export class CueEngine { pipelineName, chainRootId, parentEventId, - notify + notify, + autoRun ); }, onLog: meteredOnLog, diff --git a/src/main/cue/cue-run-manager.ts b/src/main/cue/cue-run-manager.ts index e7e95db535..b85473c44c 100644 --- a/src/main/cue/cue-run-manager.ts +++ b/src/main/cue/cue-run-manager.ts @@ -14,6 +14,7 @@ import * as crypto from 'crypto'; import type { MainLogLevel } from '../../shared/logger-types'; import type { CueLogPayload } from '../../shared/cue-log-types'; import type { + CueAutoRunConfig, CueCommand, CueEvent, CueNotifyConfig, @@ -102,6 +103,11 @@ export interface QueuedEvent { * need to re-derive anything. Optional - non-notify actions leave * this undefined. */ notify?: CueNotifyConfig; + /** Captured Auto Run payload for `action: autorun` runs. Travels with the + * run so the launch is pinned to the documents chosen when the run was + * scheduled, rather than re-resolved from the agent's (mutable) Auto Run + * folder at fire time. Optional - other actions leave this undefined. */ + autoRun?: CueAutoRunConfig; /** Phase 12A - DB row id for the persisted copy, when persistence is enabled. */ persistId?: string; /** Phase 01 - chain lineage propagated from the dispatching parent. When @@ -124,6 +130,7 @@ export interface CueRunManagerDeps { action?: CueSubscription['action']; command?: CueCommand; notify?: CueNotifyConfig; + autoRun?: CueAutoRunConfig; }) => Promise; onStopCueRun?: (runId: string) => boolean; onLog: (level: MainLogLevel, message: string, data?: unknown) => void; @@ -221,7 +228,13 @@ export interface CueRunManager { * concurrency-gated notify runs still surface the right toast body * and sticky flag when they drain. */ - notify?: CueNotifyConfig + notify?: CueNotifyConfig, + /** + * Captured Auto Run payload for `action: autorun` runs. Threaded + * through the queue alongside `notify` so a concurrency-gated + * scheduled run still launches the documents it was scheduled with. + */ + autoRun?: CueAutoRunConfig ): void; stopRun(runId: string): boolean; stopAll(): void; @@ -379,7 +392,8 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { entry.command, entry.chainRootId, entry.parentEventId, - entry.notify + entry.notify, + entry.autoRun ); } @@ -429,7 +443,8 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { command?: CueCommand, incomingChainRootId?: string, parentEventId?: string, - notify?: CueNotifyConfig + notify?: CueNotifyConfig, + autoRun?: CueAutoRunConfig ): Promise { const sessionName = getSessionName(sessionId); const settings = deps.getSessionSettings(sessionId); @@ -508,6 +523,7 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { action, command, notify, + autoRun, }); if (!activeRuns.has(runId)) { // Engine was stopped (or run was cleared) while onCueRun was in @@ -811,7 +827,8 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { pipelineName?: string, chainRootId?: string, parentEventId?: string, - notify?: CueNotifyConfig + notify?: CueNotifyConfig, + autoRun?: CueAutoRunConfig ): void { const settings = deps.getSessionSettings(sessionId); const maxConcurrent = settings?.max_concurrent ?? 1; @@ -901,6 +918,7 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { action, command, notify, + autoRun, persistId, chainRootId, parentEventId, @@ -948,7 +966,8 @@ export function createCueRunManager(deps: CueRunManagerDeps): CueRunManager { command, chainRootId, parentEventId, - notify + notify, + autoRun ); }, diff --git a/src/main/cue/cue-scheduled-tasks.ts b/src/main/cue/cue-scheduled-tasks.ts index 20bb93fed0..7afcf8c2bc 100644 --- a/src/main/cue/cue-scheduled-tasks.ts +++ b/src/main/cue/cue-scheduled-tasks.ts @@ -20,6 +20,7 @@ import { CUE_CONFIG_PATH, LEGACY_CUE_CONFIG_PATH, MAESTRO_DIR } from '../../shar import { DEFAULT_SCHEDULED_TASK_PIPELINE, MAX_SCHEDULE_MINUTES, + SCHEDULED_TASK_LABEL_MAX, eventForKind, isScheduledTaskEvent, kindForEvent, @@ -201,6 +202,7 @@ function toScheduledTask(sub: CueSubscription, agent: ScheduledTaskAgent): Sched graceMinutes: sub.grace_minutes, notifyMessage: sub.notify?.message, notifySticky: sub.notify?.sticky, + autoRun: sub.auto_run, nextFireAtMs: projectNextFire(sub), }; } @@ -245,6 +247,19 @@ export function collectScheduledTasks(agents: ScheduledTaskAgent[]): CollectSche return { tasks, warnings }; } +/** + * Human label for an Auto Run task: the document basenames, so the Scheduled + * Tasks row reads "Run ship-it.md" rather than an absolute path nobody can + * scan. Falls back to a count once the list stops fitting a label. + */ +function autoRunTaskLabel(documents: string[]): string { + const names = documents.map((doc) => path.basename(doc)); + const joined = names.join(', '); + return joined.length <= SCHEDULED_TASK_LABEL_MAX - 'Auto Run: '.length + ? `Auto Run: ${joined}` + : `Auto Run: ${names.length} documents`; +} + /** Validate `input` and build the subscription object(s) it describes. * A task with both a prompt and a notify becomes two subscriptions that share * a fire time, named `-prompt` and `-notify`. */ @@ -255,8 +270,15 @@ export function buildScheduledTaskSubscriptions( const promptText = input.prompt ?? ''; const hasPrompt = promptText.length > 0; const hasNotify = input.notify !== undefined && input.notify.message.length > 0; - if (!hasPrompt && !hasNotify) { - throw new Error('a scheduled task needs a prompt, a notification, or both'); + const hasAutoRun = (input.autoRun?.documents.length ?? 0) > 0; + if (!hasPrompt && !hasNotify && !hasAutoRun) { + throw new Error('a scheduled task needs a prompt, a notification, or an Auto Run'); + } + // An Auto Run already carries its own prompt box and drives the agent for + // the whole run, so pairing it with a sibling prompt/notify sub would fire + // two competing jobs at the same instant in the same agent. + if (hasAutoRun && (hasPrompt || hasNotify)) { + throw new Error('an Auto Run task cannot also carry a prompt or a notification'); } const timing: Record = {}; @@ -299,6 +321,7 @@ export function buildScheduledTaskSubscriptions( input.label ?? (hasPrompt ? promptText : undefined) ?? input.notify?.message ?? + (hasAutoRun ? autoRunTaskLabel(input.autoRun!.documents) : undefined) ?? `Task ${baseName}`; const label = truncateTaskLabel(labelSource); @@ -335,6 +358,28 @@ export function buildScheduledTaskSubscriptions( }); } + if (hasAutoRun) { + const autoRunConfig: Record = { documents: input.autoRun!.documents }; + const resets = input.autoRun!.reset_on_completion; + if (resets && resets.some(Boolean)) autoRunConfig.reset_on_completion = resets; + if (input.autoRun!.prompt) autoRunConfig.prompt = input.autoRun!.prompt; + if (input.autoRun!.loop_enabled) autoRunConfig.loop_enabled = true; + if (input.autoRun!.max_loops !== undefined) autoRunConfig.max_loops = input.autoRun!.max_loops; + if (input.autoRun!.model) autoRunConfig.model = input.autoRun!.model; + if (input.autoRun!.effort) autoRunConfig.effort = input.autoRun!.effort; + subs.push({ + name: baseName, + event, + enabled: true, + action: 'autorun', + ...timing, + agent_id: agent.id, + pipeline_name: pipelineName, + label, + auto_run: autoRunConfig, + }); + } + return subs; } diff --git a/src/main/cue/cue-types.ts b/src/main/cue/cue-types.ts index 550c7524ba..4e7730d527 100644 --- a/src/main/cue/cue-types.ts +++ b/src/main/cue/cue-types.ts @@ -1,6 +1,7 @@ import * as crypto from 'crypto'; export type { CueAction, + CueAutoRunConfig, CueCommand, CueCommandCliCall, CueCommandMode, diff --git a/src/main/index.ts b/src/main/index.ts index 2ce1d9b08d..1ad4a13d31 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -100,6 +100,7 @@ import { executeCuePrompt, recordCueHistoryEntry, stopCueRun } from './cue/cue-e import { executeCueShell, stopCueShellRun } from './cue/cue-shell-executor'; import { executeCueCli, stopCueCliRun } from './cue/cue-cli-executor'; import { executeCueNotify } from './cue/cue-notify-executor'; +import { executeCueAutoRun } from './cue/cue-autorun-executor'; import { reportCueAuthFailure } from './cue/cue-auth-detector'; import { getAgentDisplayName } from '../shared/agentMetadata'; import { logger } from './utils/logger'; @@ -1101,6 +1102,7 @@ app action, command, notify, + autoRun, }) => { const storedSessions = sessionsStore.get('sessions', []) as Array>; const storedSession = storedSessions.find((s) => s.id === sessionId); @@ -1123,6 +1125,80 @@ app conductorProfile: (store.get('conductorProfile', '') as string) || undefined, }; + // `action: autorun` launches an Auto Run in the owning agent. Handled + // before notify/command/prompt for the same reason they are: it never + // spawns an agent process here, so the agent-path resolution, SSH + // wrapping, and prompt plumbing below do not apply. The document list + // travels on the subscription rather than being re-read from the + // agent's Auto Run folder, so repointing that folder between + // scheduling and firing cannot swap the run out from under the user. + if (action === 'autorun') { + const sessionInfo = { + id: storedSession.id, + name: storedSession.name, + toolType: storedSession.toolType, + cwd: projectRoot, + projectRoot, + autoRunFolderPath: storedSession.autoRunFolderPath, + }; + const subscription = { + name: subscriptionName, + event: event.type, + enabled: true, + prompt, + action, + auto_run: autoRun, + agent_id: storedSession.id, + }; + const autoRunLog = (level: string, message: string) => { + if (level === 'error') logger.error(message, 'Cue'); + else if (level === 'warn') logger.warn(message, 'Cue'); + else if (level === 'debug') logger.debug(message, 'Cue'); + else logger.cue(message, 'Cue'); + }; + if (!autoRun || autoRun.documents.length === 0) { + // Reachable when a queued run is restored from the persisted + // queue across a restart: the queue schema has no column for + // the payload, so it comes back undefined. Report a failure + // rather than throwing - a `time.once` autorun task is written + // with `self_destruct_on_failure: false`, so failing here keeps + // the subscription on disk instead of consuming it silently. + const reason = + 'no documents on the run - the captured Auto Run payload did not survive the queue'; + autoRunLog('error', `[CUE] Auto Run "${subscriptionName}" did not start: ${reason}`); + const startedAt = new Date().toISOString(); + const failed = { + runId, + sessionId: storedSession.id, + sessionName: storedSession.name, + subscriptionName, + event, + status: 'failed' as const, + stdout: '', + stderr: `Auto Run launch failed: ${reason}`, + exitCode: 1, + durationMs: 0, + startedAt, + endedAt: startedAt, + }; + const failedHistory = recordCueHistoryEntry(failed, sessionInfo); + void historyManager.addEntry(storedSession.id, projectRoot, failedHistory); + return failed; + } + const autoRunResult = await executeCueAutoRun({ + runId, + session: sessionInfo, + subscription, + event, + autoRun, + mainWindow, + onLog: autoRunLog, + }); + const autoRunHistory = recordCueHistoryEntry(autoRunResult, sessionInfo); + void historyManager.addEntry(storedSession.id, projectRoot, autoRunHistory); + return autoRunResult; + } + // `action: notify` surfaces a toast through the owning agent instead of // spawning anything - handled before command/prompt so the spawn config, // SSH wrap, and history-recording paths below stay agent-only. The diff --git a/src/renderer/components/BatchRunnerModal.tsx b/src/renderer/components/BatchRunnerModal.tsx index 54cc1d29d9..ce82a16abf 100644 --- a/src/renderer/components/BatchRunnerModal.tsx +++ b/src/renderer/components/BatchRunnerModal.tsx @@ -17,6 +17,7 @@ import { PlayCircle, HelpCircle, Target, + Clock, } from 'lucide-react'; import { Spinner } from './ui/Spinner'; import type { Theme, BatchDocumentEntry, BatchRunConfig, TaskSelectionMode } from '../types'; @@ -32,10 +33,19 @@ import { DocumentsPanel } from './DocumentsPanel'; import { GoalConfigPanel } from './GoalConfigPanel'; import { ToggleButtonGroup } from './ToggleButtonGroup'; import { WorktreeRunSection } from './WorktreeRunSection'; +import { + ScheduleRunSection, + fromDateTimeLocalValue, + validateScheduledStart, +} from './ScheduleRunSection'; import { AutoRunnerHelpModal } from './AutoRun/AutoRunnerHelpModal'; import { useSessionStore, selectSessionById } from '../stores/sessionStore'; import { useBatchStore } from '../stores/batchStore'; import { useUIStore } from '../stores/uiStore'; +import { useSettingsStore } from '../stores/settingsStore'; +import { cueService } from '../services/cue'; +import { notifyToast } from '../stores/notificationStore'; +import { captureException } from '../utils/sentry'; import { usePlaybookManagement, useTaskSelectionRecommendation, @@ -46,6 +56,7 @@ import { validateAgentPromptHasTaskReference, } from '../hooks'; import { formatMetaKey } from '../utils/shortcutFormatter'; +import { joinPath } from '../../shared/formatters'; import { logger } from '../utils/logger'; import { ResizeHandles } from './ui/ResizeHandles'; @@ -150,6 +161,14 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { // changing the agent's own model, which Session settings already does. const [runModel, setRunModel] = useState(''); const [runEffort, setRunEffort] = useState(''); + + // Scheduled start. Empty string means "now" (the default, and the behavior + // this modal has always had). A non-empty value is a local `datetime-local` + // string; the run is handed to Maestro Cue as a one-shot `time.once` + // subscription instead of being launched here. + const [scheduledStart, setScheduledStart] = useState(''); + const [isScheduling, setIsScheduling] = useState(false); + const maestroCueEnabled = useSettingsStore((s) => s.encoreFeatures.maestroCue); const [availableModels, setAvailableModels] = useState([]); const [availableEfforts, setAvailableEfforts] = useState([]); @@ -406,11 +425,21 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { // targets are already disabled in the WorktreeRunSection dropdown.) const blocksLaunchWhileBusy = isAgentBusy && worktreeTarget === null; + // A scheduled run is only offered for Spec-Driven runs: the Cue autorun + // payload is a document list, and Goal-Driven runs have no documents. + const isScheduled = scheduledStart !== '' && !goalMode; + const scheduleError = isScheduled ? validateScheduledStart(scheduledStart) : null; + // Whether the Go button should be disabled, branching on the active mode. + // Scheduling deliberately ignores `blocksLaunchWhileBusy` and + // `isBatchRunningForSession`: those describe the agent right now, and a run + // scheduled for 6am has no reason to care what the agent is doing at 11pm. + // The engine re-checks readiness when the run actually fires. const isGoDisabled = isPreparingWorktree || - blocksLaunchWhileBusy || - isBatchRunningForSession || + isScheduling || + scheduleError !== null || + (!isScheduled && (blocksLaunchWhileBusy || isBatchRunningForSession)) || (goalMode ? isGoalEmpty : hasNoTasks || @@ -439,7 +468,88 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { onChange: setAutoRunMode, }); + /** + * Hand the run to Maestro Cue as a one-shot `time.once` subscription instead + * of launching it now. + * + * Document paths are resolved to absolute here, at SCHEDULE time, and travel + * with the subscription. The agent's Auto Run folder is a mutable setting, + * so resolving late would mean repointing that folder between scheduling and + * firing silently runs a different set of documents than the user picked. + * + * `keepOnFailure` leaves the subscription on disk when the launch fails. + * A `time.once` sub is consumed on any terminal status, so without it a + * failed 6am launch would delete itself and leave nothing to inspect - the + * user would just find that their run never happened. + */ + const handleSchedule = async () => { + const validationError = validateScheduledStart(scheduledStart); + if (validationError) { + notifyToast({ color: 'red', title: 'Cannot schedule run', message: validationError }); + return; + } + const fireAt = fromDateTimeLocalValue(scheduledStart); + if (!fireAt) return; + + if (!folderPath) { + notifyToast({ + color: 'red', + title: 'Cannot schedule run', + message: 'This agent has no Auto Run folder configured.', + }); + return; + } + + onSave(prompt); + + const validDocuments = documents.filter((doc) => !doc.isMissing); + if (validDocuments.length === 0) return; + + setIsScheduling(true); + try { + await cueService.createScheduledTask({ + agentId: sessionId, + kind: 'once', + fireAt: fireAt.toISOString(), + keepOnFailure: true, + autoRun: { + documents: validDocuments.map((doc) => joinPath(folderPath, `${doc.filename}.md`)), + reset_on_completion: validDocuments.map((doc) => doc.resetOnCompletion), + prompt, + loop_enabled: loopEnabled, + ...(loopEnabled && maxLoops ? { max_loops: maxLoops } : {}), + ...(runModel && { model: runModel }), + ...(runEffort && { effort: runEffort }), + }, + }); + notifyToast({ + color: 'green', + title: 'Auto Run scheduled', + message: `Starts ${fireAt.toLocaleString()}. Cancel it under Scheduled Tasks in the Cue window.`, + sessionId, + }); + onClose(); + } catch (err) { + captureException(err, { extra: { sessionId, scheduledStart } }); + notifyToast({ + color: 'red', + title: 'Could not schedule Auto Run', + message: err instanceof Error ? err.message : String(err), + }); + } finally { + setIsScheduling(false); + } + }; + const handleGo = async () => { + // A scheduled start hands the run to Cue rather than launching it. Checked + // first so none of the launch-time side effects below (worktree prep, + // batch dispatch) run for a run that is not starting yet. + if (isScheduled) { + await handleSchedule(); + return; + } + // Also save when running onSave(prompt); @@ -865,6 +975,18 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { /> )} + {/* Start: Now / At a set time. Spec-Driven only - a scheduled run is + stored as a Cue autorun subscription keyed on a document list, and + Goal-Driven runs have no documents to key on. */} + {!goalMode && ( + + )} + {/* Spec-Driven config: Fresh-context selector + Agent Prompt. Hidden in goal mode, where the agent prompt is built internally by the goal runner and "Fresh context per" has no meaning without documents. */} @@ -1211,29 +1333,45 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { title={ isPreparingWorktree ? 'Preparing worktree...' - : isBatchRunningForSession - ? 'An Auto Run is already active for this agent - stop it before launching another' - : blocksLaunchWhileBusy - ? 'Agent is thinking - finish or interrupt the current task before launching auto-run' - : goalMode - ? isGoalEmpty - ? 'Enter a goal to launch a Goal-Driven run' - : 'Start goal-driven auto-run' - : isPromptEmpty - ? 'Agent prompt cannot be empty' - : !hasValidPrompt - ? 'Agent prompt must reference Markdown tasks (e.g., checkbox syntax "- [ ]")' - : documents.length === 0 - ? 'No documents selected' - : documents.length === missingDocCount - ? 'All selected documents are missing' - : hasNoTasks - ? 'No unchecked tasks in documents' - : 'Start auto-run' + : scheduleError + ? scheduleError + : isScheduled + ? 'Schedule this Auto Run to start at the chosen time' + : isBatchRunningForSession + ? 'An Auto Run is already active for this agent - stop it before launching another' + : blocksLaunchWhileBusy + ? 'Agent is thinking - finish or interrupt the current task before launching auto-run' + : goalMode + ? isGoalEmpty + ? 'Enter a goal to launch a Goal-Driven run' + : 'Start goal-driven auto-run' + : isPromptEmpty + ? 'Agent prompt cannot be empty' + : !hasValidPrompt + ? 'Agent prompt must reference Markdown tasks (e.g., checkbox syntax "- [ ]")' + : documents.length === 0 + ? 'No documents selected' + : documents.length === missingDocCount + ? 'All selected documents are missing' + : hasNoTasks + ? 'No unchecked tasks in documents' + : 'Start auto-run' } > - {isPreparingWorktree ? : } - {isPreparingWorktree ? 'Preparing Worktree...' : 'Go'} + {isPreparingWorktree || isScheduling ? ( + + ) : isScheduled ? ( + + ) : ( + + )} + {isPreparingWorktree + ? 'Preparing Worktree...' + : isScheduling + ? 'Scheduling...' + : isScheduled + ? 'Schedule' + : 'Go'} diff --git a/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts b/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts index 6563afbf6a..2d70365cf9 100644 --- a/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts +++ b/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts @@ -20,7 +20,7 @@ import { cueCommandToCommandNodeFields, getNextPipelineColor, } from '../../../../shared/cue-pipeline-types'; -import type { CueCommand, CueSubscription } from '../../../../shared/cue'; +import type { CueAction, CueCommand, CueSubscription } from '../../../../shared/cue'; /** Minimal graph session input - compatible with both local and cue-types CueGraphSession */ interface GraphSessionInput { @@ -51,7 +51,9 @@ interface GraphSessionInput { include_output_from?: string[]; forward_output_from?: string[]; cli_output?: { target: string }; - action?: 'prompt' | 'command' | 'notify'; + // Mirrors CueAction rather than re-listing it: a re-declared union here + // silently rejects every CueSubscription the moment a new action lands. + action?: CueAction; command?: CueCommand; target_node_key?: string; fan_out_node_keys?: string[]; diff --git a/src/renderer/components/ScheduleRunSection.tsx b/src/renderer/components/ScheduleRunSection.tsx new file mode 100644 index 0000000000..9a35592c2c --- /dev/null +++ b/src/renderer/components/ScheduleRunSection.tsx @@ -0,0 +1,190 @@ +import React, { useCallback, useMemo } from 'react'; +import { Clock, Info } from 'lucide-react'; +import type { Theme } from '../types'; +import { parseScheduleTimestamp } from '../../shared/cue/scheduled-tasks'; + +/** + * "Start: Now / At a set time" control for the Auto Run window. + * + * Sits alongside `WorktreeRunSection` and mirrors its visual language: a + * section header, a bordered toggle container, and expanded content that only + * appears once the toggle is on. + * + * Scheduling is backed by Maestro Cue (a `time.once` subscription with + * `action: 'autorun'`), which is why the control is gated on the Cue Encore + * Feature. Cue already owns fire timing, persistence across restarts, the + * missed-fire grace window, and an activity log; a second scheduler inside the + * Auto Run panel would duplicate all four and disagree with Cue about at least + * one of them. + */ + +/** Local-time `datetime-local` value (`YYYY-MM-DDTHH:mm`) for a Date. */ +export function toDateTimeLocalValue(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}` + ); +} + +/** + * Parse a `datetime-local` value as LOCAL wall-clock time. + * + * Delegates to the shared Cue parser rather than `new Date(value)` so the CLI + * scheduler and this picker agree on what "7:00" means. The distinction is not + * cosmetic: treating the picker's value as UTC shifts every scheduled run by + * the machine's offset, which is how "start at 6am" quietly becomes 1am. + */ +export function fromDateTimeLocalValue(value: string): Date | null { + if (!value) return null; + return parseScheduleTimestamp(value); +} + +/** Smallest gap we accept between "now" and the scheduled time. */ +const MIN_LEAD_MS = 60_000; + +export interface ScheduleRunSectionProps { + theme: Theme; + /** Local `datetime-local` string, or '' when the run starts immediately. */ + value: string; + onChange: (value: string) => void; + /** False when the Maestro Cue Encore Feature is off. */ + cueEnabled: boolean; + /** Opens Settings so the user can turn Cue on. */ + onOpenEncoreSettings?: () => void; +} + +/** + * Validate a scheduled start. Returns an error string, or null when the value + * is a usable future time (or empty, meaning "now"). + */ +export function validateScheduledStart(value: string, now: Date = new Date()): string | null { + if (!value) return null; + const parsed = fromDateTimeLocalValue(value); + if (!parsed) return 'Pick a valid date and time.'; + const delta = parsed.getTime() - now.getTime(); + if (delta < MIN_LEAD_MS) return 'Pick a time at least a minute from now.'; + return null; +} + +export function ScheduleRunSection({ + theme, + value, + onChange, + cueEnabled, + onOpenEncoreSettings, +}: ScheduleRunSectionProps) { + const isEnabled = value !== ''; + + // `min` stops the picker offering times already in the past. It is an + // affordance, not the guard - a user can still type an earlier value, so + // `validateScheduledStart` re-checks before the run is scheduled. + const minValue = useMemo(() => toDateTimeLocalValue(new Date()), []); + + const error = useMemo(() => validateScheduledStart(value), [value]); + + const handleToggle = useCallback(() => { + if (!cueEnabled) return; + if (isEnabled) { + onChange(''); + } else { + // Default an hour out: the driving use case is "start once my token + // limit resets", which is always some hours away, and a default of + // "now" would make the toggle look like it did nothing. + const inAnHour = new Date(Date.now() + 60 * 60 * 1000); + inAnHour.setSeconds(0, 0); + onChange(toDateTimeLocalValue(inAnHour)); + } + }, [cueEnabled, isEnabled, onChange]); + + return ( +
+
+ + {!cueEnabled && onOpenEncoreSettings && ( + + )} +
+ +
+ + + {isEnabled && ( +
+ onChange(e.target.value)} + className="px-2 py-1.5 rounded border text-xs outline-none" + style={{ + backgroundColor: theme.colors.bgMain, + borderColor: error ? theme.colors.error : theme.colors.border, + color: theme.colors.textMain, + }} + /> + {error ? ( +

+ {error} +

+ ) : ( +
+ +

+ Runs once at this time, in your local timezone. The schedule survives restarting + Maestro and appears under Scheduled Tasks in the Cue window, where you can cancel + it. +

+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/shared/cue/contracts.ts b/src/shared/cue/contracts.ts index 3ff3f06be8..b74140547a 100644 --- a/src/shared/cue/contracts.ts +++ b/src/shared/cue/contracts.ts @@ -102,7 +102,7 @@ export type CueGitHubState = 'open' | 'closed' | 'merged' | 'all'; export const CUE_GITHUB_STATES: CueGitHubState[] = ['open', 'closed', 'merged', 'all']; /** What a subscription does when it fires. */ -export type CueAction = 'prompt' | 'command' | 'notify'; +export type CueAction = 'prompt' | 'command' | 'notify' | 'autorun'; /** Sub-mode of a `command` action. */ export type CueCommandMode = 'shell' | 'cli'; @@ -125,6 +125,41 @@ export interface CueNotifyConfig { sticky?: boolean; } +/** + * Auto Run config for `action: 'autorun'` subscriptions. + * + * An autorun subscription launches an Auto Run in the owning agent instead of + * spawning a prompt. Paired with a `time.once` trigger this is what backs + * "schedule this Auto Run for 6am" - the Cue engine already owns persistence, + * the missed-fire grace window, and the activity log, so scheduling does not + * need a second timer of its own. + * + * `documents` is deliberately a list of ABSOLUTE paths captured at schedule + * time rather than a folder resolved when the run fires. An agent's Auto Run + * folder is a mutable setting: resolving it late means repointing the folder + * between scheduling and firing silently runs the agent against a different + * set of documents than the user chose. + */ +export interface CueAutoRunConfig { + /** Absolute paths to the `.md` documents to run, in order. Non-empty. */ + documents: string[]; + /** Per-document "uncheck every task when the run finishes" flags, aligned + * index-for-index with {@link documents}. Absent means all-false. */ + reset_on_completion?: boolean[]; + /** Extra instructions prepended to the run, mirroring the Auto Run panel's + * prompt box. */ + prompt?: string; + /** Re-run the document set once every task is checked off. */ + loop_enabled?: boolean; + /** Loop ceiling. Only meaningful when `loop_enabled` is true. */ + max_loops?: number; + /** Run-scoped model override. Wins over the agent's configured model for + * this run only and is never written back to the session. */ + model?: string; + /** Run-scoped reasoning-effort override. Same scope rules as `model`. */ + effort?: string; +} + /** * A maestro-cli sub-command. Currently only `send` is supported, but the * shape leaves room for future sub-commands. @@ -191,6 +226,9 @@ export interface CueSubscription { /** Toast notification config for `action: 'notify'` subscriptions. * Required when `action === 'notify'`. See {@link CueNotifyConfig}. */ notify?: CueNotifyConfig; + /** Auto Run payload for `action: 'autorun'` subscriptions. + * Required when `action === 'autorun'`. See {@link CueAutoRunConfig}. */ + auto_run?: CueAutoRunConfig; watch?: string; source_session?: string | string[]; /** Stable session ID(s) for chain subscriptions (event === 'agent.completed'). diff --git a/src/shared/cue/scheduled-tasks.ts b/src/shared/cue/scheduled-tasks.ts index 7c4fe4ef80..3ae655d008 100644 --- a/src/shared/cue/scheduled-tasks.ts +++ b/src/shared/cue/scheduled-tasks.ts @@ -14,7 +14,7 @@ * `src/main/cue/cue-scheduled-tasks.ts`. */ -import type { CueAction, CueScheduleDay } from './contracts'; +import type { CueAction, CueAutoRunConfig, CueScheduleDay } from './contracts'; import { CUE_SCHEDULE_DAYS } from './contracts'; /** Cue events that make a subscription a scheduled task. */ @@ -65,6 +65,10 @@ export interface ScheduledTask { notifyMessage?: string; /** Whether the notify toast sticks until dismissed. */ notifySticky?: boolean; + /** Captured Auto Run payload when `action === 'autorun'`. Present so the + * Scheduled Tasks tab can show WHICH documents a pending run will launch + * rather than just "an Auto Run". */ + autoRun?: CueAutoRunConfig; /** * Epoch ms of the next projected fire, or `null` when it cannot be known * (an `interval` task's phase depends on engine run state, and an expired @@ -85,9 +89,13 @@ export interface ScheduledTaskCreateInput { scheduleDays?: CueScheduleDay[]; /** Required for `interval`. */ intervalMinutes?: number; - /** Prompt to send. One of `prompt` / `notify` is required. */ + /** Prompt to send. One of `prompt` / `notify` / `autoRun` is required. */ prompt?: string; notify?: { message: string; sticky?: boolean }; + /** Launch an Auto Run instead of sending a prompt. Mutually exclusive with + * `prompt` and `notify`: an Auto Run is the whole job, not a step + * alongside one. */ + autoRun?: CueAutoRunConfig; /** Subscription name. Auto-generated when omitted. */ name?: string; label?: string;