diff --git a/src/cloud/api/index.ts b/src/cloud/api/index.ts index aff72577..1b2e9f59 100644 --- a/src/cloud/api/index.ts +++ b/src/cloud/api/index.ts @@ -41,3 +41,14 @@ export type { RickyLinearSession, SessionEndReason, } from './linear-agent-types.js'; + +export { + listRickyWorkflowSchedules, + scheduleRickyWorkflow, +} from './workflow-schedules.js'; +export type { + ListRickyWorkflowSchedulesResult, + RickyWorkflowSchedule, + ScheduleRickyWorkflowOptions, + ScheduleRickyWorkflowResult, +} from './workflow-schedules.js'; diff --git a/src/cloud/api/workflow-schedules.ts b/src/cloud/api/workflow-schedules.ts new file mode 100644 index 00000000..2149a68f --- /dev/null +++ b/src/cloud/api/workflow-schedules.ts @@ -0,0 +1,73 @@ +export type RickyWorkflowScheduleType = 'cron' | 'once'; + +export interface RickyWorkflowSchedule { + id: string; + name: string; + description?: string | null; + scheduleType: RickyWorkflowScheduleType; + cronExpression?: string | null; + scheduledAt?: string | Date | null; + timezone?: string; + status: string; + lastTriggeredRunId?: string | null; + lastTriggeredAt?: string | Date | null; + createdAt?: string | Date; + updatedAt?: string | Date; +} + +export interface ScheduleRickyWorkflowOptions { + name?: string; + cronExpression?: string; + scheduledAt?: string; + timezone?: string; +} + +export interface ScheduleRickyWorkflowResult { + schedule: RickyWorkflowSchedule; +} + +export interface ListRickyWorkflowSchedulesResult { + schedules: RickyWorkflowSchedule[]; +} + +type RelaySdkWorkflowSchedules = { + scheduleWorkflow: ( + workflowPath: string, + options: { + name?: string; + cron?: string; + at?: string; + timezone?: string; + }, + ) => Promise; + listWorkflowSchedules: () => Promise; +}; + +async function relayWorkflowSchedules(): Promise { + const sdk = await import('@agent-relay/sdk/workflows') as unknown as Partial; + if (typeof sdk.scheduleWorkflow !== 'function' || typeof sdk.listWorkflowSchedules !== 'function') { + throw new Error( + 'Installed @agent-relay/sdk does not expose workflow scheduling yet. Upgrade to the Relay SDK version that includes scheduleWorkflow.', + ); + } + return sdk as RelaySdkWorkflowSchedules; +} + +export async function scheduleRickyWorkflow( + workflowPath: string, + options: ScheduleRickyWorkflowOptions, +): Promise { + const sdk = await relayWorkflowSchedules(); + const schedule = await sdk.scheduleWorkflow(workflowPath, { + ...(options.name ? { name: options.name } : {}), + ...(options.cronExpression ? { cron: options.cronExpression } : {}), + ...(options.scheduledAt ? { at: options.scheduledAt } : {}), + ...(options.timezone ? { timezone: options.timezone } : {}), + }); + return { schedule }; +} + +export async function listRickyWorkflowSchedules(): Promise { + const sdk = await relayWorkflowSchedules(); + return { schedules: await sdk.listWorkflowSchedules() }; +} diff --git a/src/surfaces/cli/commands/cli-main.test.ts b/src/surfaces/cli/commands/cli-main.test.ts index ba63074b..e15fe324 100644 --- a/src/surfaces/cli/commands/cli-main.test.ts +++ b/src/surfaces/cli/commands/cli-main.test.ts @@ -360,6 +360,43 @@ describe('parseArgs', () => { connectTarget: 'linear', }); }); + + it('parses schedule commands', () => { + expect(parseArgs([ + 'schedule', + 'workflows/generated/package-checks.ts', + '--cron', + '0 9 * * 1', + '--name', + 'Package checks', + '--timezone', + 'America/New_York', + ])).toMatchObject({ + command: 'schedule', + surface: 'schedule', + artifact: 'workflows/generated/package-checks.ts', + cronExpression: '0 9 * * 1', + workflowName: 'Package checks', + timezone: 'America/New_York', + }); + expect(parseArgs(['schedule', 'workflows/generated/package-checks.ts', '--at', '2026-05-10T09:00:00Z'])).toMatchObject({ + command: 'schedule', + scheduledAt: '2026-05-10T09:00:00Z', + }); + expect(parseArgs(['schedule', 'list', '--json'])).toEqual({ + command: 'schedules', + surface: 'schedule', + json: true, + }); + expect(parseArgs(['schedules', '--json'])).toEqual({ + command: 'schedules', + surface: 'schedule', + json: true, + }); + expect(parseArgs(['schedule', 'workflows/generated/package-checks.ts'])).toMatchObject({ + errors: ['schedule requires --cron or --at.'], + }); + }); }); // --------------------------------------------------------------------------- @@ -400,6 +437,8 @@ describe('renderHelp', () => { expect(helpText).not.toContain('ricky workflow --spec-file --mode cloud --run'); expect(helpText).toContain('ricky run --background'); expect(helpText).toContain('ricky run --cloud'); + expect(helpText).toContain('ricky schedule --cron "0 * * * *"'); + expect(helpText).toContain('ricky schedules'); expect(helpText).toContain('ricky run --start-from '); expect(helpText).toContain('ricky status --run '); expect(helpText).toContain('Without --run: artifact path on disk'); @@ -2669,6 +2708,69 @@ describe('cliMain', () => { expect(runner).not.toHaveBeenCalled(); }); + it('schedules a cloud workflow without invoking the interactive runner', async () => { + const runner = vi.fn(); + const scheduleWorkflow = vi.fn().mockResolvedValue({ + schedule: { + id: 'sched-1', + name: 'Package checks', + scheduleType: 'cron', + cronExpression: '0 9 * * 1', + timezone: 'UTC', + status: 'active', + }, + }); + + const result = await cliMain({ + argv: [ + 'schedule', + 'workflows/generated/package-checks.ts', + '--cron', + '0 9 * * 1', + '--name', + 'Package checks', + ], + runInteractive: runner, + scheduleWorkflow, + }); + + expect(result.exitCode).toBe(0); + expect(scheduleWorkflow).toHaveBeenCalledWith('workflows/generated/package-checks.ts', { + name: 'Package checks', + cronExpression: '0 9 * * 1', + }); + expect(result.output.join('\n')).toContain('Ricky cloud schedule created'); + expect(result.output.join('\n')).toContain('sched-1'); + expect(runner).not.toHaveBeenCalled(); + }); + + it('lists cloud workflow schedules without invoking the interactive runner', async () => { + const runner = vi.fn(); + const listWorkflowSchedules = vi.fn().mockResolvedValue({ + schedules: [ + { + id: 'sched-1', + name: 'Package checks', + scheduleType: 'cron', + cronExpression: '0 9 * * 1', + status: 'active', + }, + ], + }); + + const result = await cliMain({ + argv: ['schedules', '--json'], + runInteractive: runner, + listWorkflowSchedules, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.output.join('\n'))).toMatchObject({ + schedules: [{ id: 'sched-1', name: 'Package checks' }], + }); + expect(runner).not.toHaveBeenCalled(); + }); + // ------------------------------------------------------------------------- // Proof: spec fixture coverage — inline, spec-file, stdin, missing, recovery // ------------------------------------------------------------------------- diff --git a/src/surfaces/cli/commands/cli-main.ts b/src/surfaces/cli/commands/cli-main.ts index 55e917a6..40f65a8b 100644 --- a/src/surfaces/cli/commands/cli-main.ts +++ b/src/surfaces/cli/commands/cli-main.ts @@ -16,6 +16,10 @@ import type { CloudIntegrationConnector } from '../entrypoint/interactive-cli.js import type { ProviderStatus, RickyMode } from '../cli/mode-selector.js'; import type { RawHandoff, SpecInput } from '../../../local/request-normalizer.js'; import type { CloudGenerateRequest, CloudGenerateRequestBody, CloudWorkflowSpecPayload } from '../../../cloud/api/request-types.js'; +import type { + ListRickyWorkflowSchedulesResult, + ScheduleRickyWorkflowResult, +} from '../../../cloud/api/workflow-schedules.js'; import type { ConnectProviderOptions, ConnectProviderResult, StoredAuth, WhoAmIResponse } from '@agent-relay/cloud'; import type { LocalRunMonitorState } from '../flows/local-run-monitor.js'; import { legacyLocalRunStatePath, localRunStatePath } from '../flows/local-run-monitor.js'; @@ -46,13 +50,17 @@ import { resolvePreferWorkforcePersonaWorkflowWriter } from '../flows/workforce- import { DEFAULT_AUTO_FIX_ATTEMPTS } from '../../../shared/constants.js'; import { getLinearConnectGuidance } from '../../linear/connect.js'; import { linearStatusSummary, renderLinearStatus } from '../../linear/status.js'; +import { + listRickyWorkflowSchedules, + scheduleRickyWorkflow, +} from '../../../cloud/api/workflow-schedules.js'; // --------------------------------------------------------------------------- // Parsed CLI arguments // --------------------------------------------------------------------------- export interface ParsedArgs { - command: 'run' | 'help' | 'version' | 'status' | 'connect'; + command: 'run' | 'help' | 'version' | 'status' | 'connect' | 'schedule' | 'schedules'; surface?: PowerUserSurface; mode?: RickyMode; connectTarget?: ConnectTarget; @@ -64,6 +72,9 @@ export interface ParsedArgs { artifact?: string; stdin?: boolean; workflowName?: string; + cronExpression?: string; + scheduledAt?: string; + timezone?: string; runRequested?: boolean; noRun?: boolean; background?: boolean; @@ -98,6 +109,16 @@ export interface CliMainResult { export type RelayCloudConnectProvider = (options: ConnectProviderOptions) => Promise; export type RelayCloudAuthenticator = () => Promise; +export type RickyScheduleWorkflow = ( + workflowPath: string, + options: { + name?: string; + cronExpression?: string; + scheduledAt?: string; + timezone?: string; + }, +) => Promise; +export type RickyListWorkflowSchedules = () => Promise; export interface CliProgressSpinner { text: string; @@ -138,6 +159,10 @@ export interface CliMainDeps extends InteractiveCliDeps { connectTimeoutMs?: number; /** Spinner factory override for focused progress tests. */ createProgressSpinner?: CliProgressSpinnerFactory; + /** Cloud workflow scheduling override for deterministic schedule command tests. */ + scheduleWorkflow?: RickyScheduleWorkflow; + /** Cloud workflow schedule listing override for deterministic schedule command tests. */ + listWorkflowSchedules?: RickyListWorkflowSchedules; } let cachedPackageVersion: string | undefined; @@ -160,6 +185,9 @@ export function parseArgs(argv: string[]): ParsedArgs { if (parsed.artifact !== undefined) result.artifact = parsed.artifact; if (parsed.stdin) result.stdin = true; if (parsed.workflowName !== undefined) result.workflowName = parsed.workflowName; + if (parsed.cronExpression !== undefined) result.cronExpression = parsed.cronExpression; + if (parsed.scheduledAt !== undefined) result.scheduledAt = parsed.scheduledAt; + if (parsed.timezone !== undefined) result.timezone = parsed.timezone; if (parsed.runRequested) result.runRequested = true; if (parsed.noRun) result.noRun = true; if (parsed.background) result.background = true; @@ -300,6 +328,8 @@ export function renderHelp(): string[] { ' ricky local --spec Write a workflow artifact', ' ricky run Run attached in this terminal', ' ricky run --cloud Run it in AgentWorkforce Cloud', + ' ricky schedule --cron "0 * * * *" Schedule a Cloud workflow run', + ' ricky schedules List Cloud workflow schedules', ' ricky run --background Run it in the background', ' ricky status --run Check progress', ' ricky status linear Check Linear readiness', @@ -325,6 +355,9 @@ export function renderHelp(): string[] { ' ricky --mode local --stdin Generate from stdin', ' ricky run Execute existing artifact', ' ricky run --cloud Execute existing artifact in Cloud', + ' ricky schedule --cron "0 * * * *" Schedule recurring Cloud execution', + ' ricky schedule --at Schedule one-time Cloud execution', + ' ricky schedules List scheduled Cloud workflows', ' ricky run --start-from Resume an existing workflow from a failed step', ' ricky help This help text', ' ricky version Version', @@ -341,6 +374,9 @@ export function renderHelp(): string[] { ' --foreground Keep the local run attached to this process', ' --start-from Resume a workflow from a specific step', ' --previous-run-id Reuse prior run context when resuming', + ' --cron Schedule recurring execution with cron syntax', + ' --at Schedule one-time execution at an ISO timestamp', + ' --timezone IANA timezone for schedule evaluation', ' --json Print results as JSON', ' --help, -h Show help', ' --version, -v Show version', @@ -380,6 +416,8 @@ export function renderHelp(): string[] { ' ricky --mode local --spec-file ./my-spec.md', ' printf "%s\\n" "run workflows/release.workflow.ts" | ricky --mode local --stdin', ' ricky run workflows/generated/package-checks.ts --background', + ' ricky schedule workflows/generated/package-checks.ts --cron "0 9 * * 1"', + ' ricky schedules', ]; } @@ -394,6 +432,100 @@ function renderCliArgumentRecovery(errors: string[]): string[] { ]; } +async function renderScheduleCommand(parsed: ParsedArgs, deps: CliMainDeps): Promise { + if (!parsed.artifact) { + return { + exitCode: 1, + output: renderCliArgumentRecovery(['schedule requires a workflow artifact path.']), + }; + } + + const scheduler = deps.scheduleWorkflow ?? scheduleRickyWorkflow; + try { + const result = await scheduler(parsed.artifact, { + ...(parsed.workflowName ? { name: parsed.workflowName } : {}), + ...(parsed.cronExpression ? { cronExpression: parsed.cronExpression } : {}), + ...(parsed.scheduledAt ? { scheduledAt: parsed.scheduledAt } : {}), + ...(parsed.timezone ? { timezone: parsed.timezone } : {}), + }); + if (parsed.json) { + return { exitCode: 0, output: [JSON.stringify(result, null, 2)] }; + } + if (parsed.quiet) { + return { + exitCode: 0, + output: [`Ricky schedule: ${result.schedule.id} ${result.schedule.status}.`], + }; + } + return { + exitCode: 0, + output: renderScheduleCreated(result.schedule), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + exitCode: 1, + output: parsed.json + ? [JSON.stringify({ status: 'failed', error: message }, null, 2)] + : renderCliArgumentRecovery([`Could not schedule workflow: ${message}`]), + }; + } +} + +async function renderSchedulesCommand(parsed: ParsedArgs, deps: CliMainDeps): Promise { + const lister = deps.listWorkflowSchedules ?? listRickyWorkflowSchedules; + try { + const result = await lister(); + if (parsed.json) { + return { exitCode: 0, output: [JSON.stringify(result, null, 2)] }; + } + if (result.schedules.length === 0) { + return { exitCode: 0, output: ['No scheduled Ricky workflows.'] }; + } + return { + exitCode: 0, + output: [ + 'Scheduled Ricky workflows', + ...result.schedules.map((schedule) => { + const cadence = schedule.scheduleType === 'cron' + ? `cron ${schedule.cronExpression ?? '(missing)'}` + : `at ${formatScheduleDate(schedule.scheduledAt)}`; + return ` ${schedule.id} ${schedule.status} ${cadence} ${schedule.name}`; + }), + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + exitCode: 1, + output: parsed.json + ? [JSON.stringify({ status: 'failed', error: message }, null, 2)] + : renderCliArgumentRecovery([`Could not list workflow schedules: ${message}`]), + }; + } +} + +function renderScheduleCreated(schedule: ScheduleRickyWorkflowResult['schedule']): string[] { + return [ + 'Ricky cloud schedule created', + ` ID: ${schedule.id}`, + ` Name: ${schedule.name}`, + ` Status: ${schedule.status}`, + ` Type: ${schedule.scheduleType}`, + ...(schedule.scheduleType === 'cron' + ? [` Cron: ${schedule.cronExpression ?? '(missing)'}`] + : [` At: ${formatScheduleDate(schedule.scheduledAt)}`]), + ...(schedule.timezone ? [` Timezone: ${schedule.timezone}`] : []), + '', + 'List schedules with `ricky schedules`.', + ]; +} + +function formatScheduleDate(value: string | Date | null | undefined): string { + if (!value) return '(missing)'; + return value instanceof Date ? value.toISOString() : value; +} + async function readStreamText(input: NodeJS.ReadableStream): Promise { input.setEncoding('utf8'); let text = ''; @@ -1572,6 +1704,26 @@ export async function cliMain(deps: CliMainDeps = {}): Promise { return renderConnect(parsed, deps); } + if (parsed.command === 'schedule') { + if (parsed.errors && parsed.errors.length > 0) { + return { + exitCode: 1, + output: renderCliArgumentRecovery(parsed.errors), + }; + } + return renderScheduleCommand(parsed, deps); + } + + if (parsed.command === 'schedules') { + if (parsed.errors && parsed.errors.length > 0) { + return { + exitCode: 1, + output: renderCliArgumentRecovery(parsed.errors), + }; + } + return renderSchedulesCommand(parsed, deps); + } + if (parsed.errors && parsed.errors.length > 0) { return { exitCode: 1, diff --git a/src/surfaces/cli/flows/power-user-parser.ts b/src/surfaces/cli/flows/power-user-parser.ts index c21ba8ba..2c318363 100644 --- a/src/surfaces/cli/flows/power-user-parser.ts +++ b/src/surfaces/cli/flows/power-user-parser.ts @@ -2,8 +2,8 @@ import type { RickyMode } from '../cli/mode-selector.js'; import { isRickyMode } from '../cli/mode-selector.js'; import { DEFAULT_AUTO_FIX_ATTEMPTS } from '../../../shared/constants.js'; -export type PowerUserCommand = 'run' | 'help' | 'version' | 'status' | 'connect'; -export type PowerUserSurface = 'legacy' | 'local' | 'cloud' | 'workflow' | 'status' | 'connect'; +export type PowerUserCommand = 'run' | 'help' | 'version' | 'status' | 'connect' | 'schedule' | 'schedules'; +export type PowerUserSurface = 'legacy' | 'local' | 'cloud' | 'workflow' | 'status' | 'connect' | 'schedule'; export type ConnectTarget = 'cloud' | 'agents' | 'integrations' | 'linear'; export type StatusTarget = 'linear'; @@ -23,6 +23,9 @@ export interface PowerUserParsedArgs { artifact?: string; stdin?: boolean; workflowName?: string; + cronExpression?: string; + scheduledAt?: string; + timezone?: string; runRequested?: boolean; noRun?: boolean; background?: boolean; @@ -81,6 +84,14 @@ export function parsePowerUserArgs(argv: string[]): PowerUserParsedArgs { return parseConnect(argv.slice(1)); } + if (first === 'schedule') { + return parseSchedule(argv.slice(1)); + } + + if (first === 'schedules') { + return withCommonFlags({ command: 'schedules', surface: 'schedule' }, argv.slice(1)); + } + const surface = first === 'local' || first === 'cloud' || first === 'workflow' ? first : 'legacy'; const effectiveArgv = surface === 'legacy' ? argv : argv.slice(1); const explicitMode = readMode(effectiveArgv); @@ -188,6 +199,51 @@ function parseConnect(argv: string[]): PowerUserParsedArgs { }; } +function parseSchedule(argv: string[]): PowerUserParsedArgs { + if (argv[0]?.trim().toLowerCase() === 'list') { + return withCommonFlags({ command: 'schedules', surface: 'schedule' }, argv.slice(1)); + } + + const base = withCommonFlags({ command: 'schedule', surface: 'schedule' }, argv); + const artifact = readScheduleArtifact(argv); + const workflowName = readFlagValue(argv, '--name'); + const cronExpression = readFlagValue(argv, '--cron'); + const scheduledAt = readFlagValue(argv, '--at') ?? readFlagValue(argv, '--scheduled-at'); + const timezone = readFlagValue(argv, '--timezone'); + const errors: string[] = [...(base.errors ?? [])]; + + for (const flag of ['--name', '--cron', '--at', '--scheduled-at', '--timezone']) { + if (argv.includes(flag) && readFlagValue(argv, flag) === undefined) { + errors.push(`${flag} requires a value.`); + } + } + if (!artifact) errors.push('schedule requires a workflow artifact path.'); + if (cronExpression && scheduledAt) errors.push('schedule requires exactly one of --cron or --at.'); + if (!cronExpression && !scheduledAt) errors.push('schedule requires --cron or --at.'); + + return { + ...base, + ...(artifact ? { artifact } : {}), + ...(workflowName ? { workflowName } : {}), + ...(cronExpression ? { cronExpression } : {}), + ...(scheduledAt ? { scheduledAt } : {}), + ...(timezone ? { timezone } : {}), + ...(errors.length > 0 ? { errors } : {}), + }; +} + +function readScheduleArtifact(argv: string[]): string | undefined { + const valueFlags = new Set(['--name', '--cron', '--at', '--scheduled-at', '--timezone']); + for (let index = 0; index < argv.length; index += 1) { + const candidate = argv[index]; + const previous = argv[index - 1]; + if (!candidate || candidate.startsWith('--')) continue; + if (previous && valueFlags.has(previous)) continue; + return candidate; + } + return undefined; +} + function resolveCloudTargets( target: ConnectTarget, hasCloudFlag: boolean,