schedule: fire --every now by default, add run-once for cron-style triggers (#689)
Interval schedules used to wait the full interval before their first run, which made `--every 1d` feel broken for fresh schedules. Now `--every` fires immediately on creation; `--cron` keeps waiting for the next slot. `--no-run-now` and `--run-now` override the defaults, and the parser rejects redundant combos. Adds `paseo schedule run-once <id>` to manually trigger a single run without advancing cadence or recomputing completion. The new `runOnCreate` field and `schedule/run-once` RPC are additive on the WebSocket schema.
This commit is contained in:
@@ -20,13 +20,16 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
||||
cwd?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
runNow?: boolean;
|
||||
}
|
||||
|
||||
export async function runCreateCommand(
|
||||
prompt: string,
|
||||
options: ScheduleCreateOptions,
|
||||
_command: Command,
|
||||
command: Command,
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const runNowSource = command.getOptionValueSource("runNow");
|
||||
const runNow = runNowSource === "cli" ? Boolean(options.runNow) : undefined;
|
||||
const input = parseScheduleCreateInput({
|
||||
prompt,
|
||||
every: options.every,
|
||||
@@ -39,6 +42,7 @@ export async function runCreateCommand(
|
||||
host: options.host,
|
||||
maxRuns: options.maxRuns,
|
||||
expiresIn: options.expiresIn,
|
||||
runNow,
|
||||
});
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { runLogsCommand } from "./logs.js";
|
||||
import { runPauseCommand } from "./pause.js";
|
||||
import { runResumeCommand } from "./resume.js";
|
||||
import { runDeleteCommand } from "./delete.js";
|
||||
import { runRunOnceCommand } from "./run-once.js";
|
||||
|
||||
export function createScheduleCommand(): Command {
|
||||
const schedule = new Command("schedule").description("Manage recurring schedules");
|
||||
@@ -30,6 +31,8 @@ export function createScheduleCommand(): Command {
|
||||
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
||||
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
|
||||
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
.option("--expires-in <duration>", "Time to live for the schedule"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
@@ -64,5 +67,12 @@ export function createScheduleCommand(): Command {
|
||||
schedule.command("delete").description("Delete a schedule").argument("<id>", "Schedule ID"),
|
||||
).action(withOutput(runDeleteCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
schedule
|
||||
.command("run-once")
|
||||
.description("Manually trigger a single run of a schedule without affecting cadence")
|
||||
.argument("<id>", "Schedule ID"),
|
||||
).action(withOutput(runRunOnceCommand));
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Command } from "commander";
|
||||
import type { SingleResult } from "../../output/index.js";
|
||||
import { scheduleSchema } from "./schema.js";
|
||||
import {
|
||||
connectScheduleClient,
|
||||
toScheduleCommandError,
|
||||
toScheduleRow,
|
||||
type ScheduleCommandOptions,
|
||||
type ScheduleRow,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function runRunOnceCommand(
|
||||
id: string,
|
||||
options: ScheduleCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ScheduleRow>> {
|
||||
const { client } = await connectScheduleClient(options.host);
|
||||
try {
|
||||
const payload = await client.scheduleRunOnce({ id });
|
||||
if (payload.error || !payload.schedule) {
|
||||
throw new Error(payload.error ?? `Failed to run schedule once: ${id}`);
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: toScheduleRow(payload.schedule),
|
||||
schema: scheduleSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
throw toScheduleCommandError("SCHEDULE_RUN_ONCE_FAILED", "run schedule once", error);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ const baseOptions = {
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const baseCron = {
|
||||
prompt: "do the thing",
|
||||
cron: "0 9 * * *",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
describe("parseScheduleCreateInput cwd/host validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/local/project");
|
||||
@@ -60,3 +66,43 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
|
||||
).toThrow(expect.objectContaining({ code: "MISSING_CWD" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScheduleCreateInput first-run timing", () => {
|
||||
test("--every with no run-now flag fires immediately on creation", () => {
|
||||
const input = parseScheduleCreateInput(baseOptions);
|
||||
expect(input.runOnCreate).toBe(true);
|
||||
});
|
||||
|
||||
test("--every with --no-run-now waits the interval", () => {
|
||||
const input = parseScheduleCreateInput({ ...baseOptions, runNow: false });
|
||||
expect(input.runOnCreate).toBe(false);
|
||||
});
|
||||
|
||||
test("--cron with no run-now flag waits for the next cron slot", () => {
|
||||
const input = parseScheduleCreateInput(baseCron);
|
||||
expect(input.runOnCreate).toBe(false);
|
||||
});
|
||||
|
||||
test("--cron with --run-now fires immediately on creation", () => {
|
||||
const input = parseScheduleCreateInput({ ...baseCron, runNow: true });
|
||||
expect(input.runOnCreate).toBe(true);
|
||||
});
|
||||
|
||||
test("--every with --run-now is rejected as redundant", () => {
|
||||
expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: "REDUNDANT_RUN_NOW",
|
||||
message: expect.stringContaining("--run-now is redundant with --every"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("--cron with --no-run-now is rejected as redundant", () => {
|
||||
expect(() => parseScheduleCreateInput({ ...baseCron, runNow: false })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: "REDUNDANT_NO_RUN_NOW",
|
||||
message: expect.stringContaining("--no-run-now is redundant with --cron"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,6 +135,7 @@ export function parseScheduleCreateInput(options: {
|
||||
host?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
runNow?: boolean;
|
||||
}): CreateScheduleInput {
|
||||
const prompt = options.prompt.trim();
|
||||
if (!prompt) {
|
||||
@@ -165,6 +166,8 @@ export function parseScheduleCreateInput(options: {
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const runOnCreate = resolveRunOnCreate(options.runNow, cadence.type);
|
||||
|
||||
const targetValue = options.target?.trim();
|
||||
const modeId = options.mode?.trim();
|
||||
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
|
||||
@@ -199,12 +202,34 @@ export function parseScheduleCreateInput(options: {
|
||||
prompt,
|
||||
cadence,
|
||||
target,
|
||||
runOnCreate,
|
||||
...(options.name?.trim() ? { name: options.name.trim() } : {}),
|
||||
...(maxRuns !== undefined ? { maxRuns } : {}),
|
||||
...(expiresAt ? { expiresAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRunOnCreate(
|
||||
runNow: boolean | undefined,
|
||||
cadenceType: ScheduleCadence["type"],
|
||||
): boolean {
|
||||
if (runNow === true && cadenceType === "every") {
|
||||
throw {
|
||||
code: "REDUNDANT_RUN_NOW",
|
||||
message: "--run-now is redundant with --every (interval schedules already fire on creation)",
|
||||
details: "Drop --run-now, or use --no-run-now to wait the full interval before the first run",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
if (runNow === false && cadenceType === "cron") {
|
||||
throw {
|
||||
code: "REDUNDANT_NO_RUN_NOW",
|
||||
message: "--no-run-now is redundant with --cron (cron schedules never fire on creation)",
|
||||
details: "Drop --no-run-now, or use --run-now to fire one immediate run on creation",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return runNow ?? cadenceType === "every";
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, flag: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface CreateScheduleInput {
|
||||
target: ScheduleTarget;
|
||||
maxRuns?: number;
|
||||
expiresAt?: string;
|
||||
runOnCreate?: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduleCreatePayload {
|
||||
@@ -129,6 +130,12 @@ export interface ScheduleDeletePayload {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleRunOncePayload {
|
||||
requestId: string;
|
||||
schedule: ScheduleRecord | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleDaemonClient {
|
||||
scheduleCreate(input: CreateScheduleInput): Promise<ScheduleCreatePayload>;
|
||||
scheduleList(): Promise<ScheduleListPayload>;
|
||||
@@ -137,5 +144,6 @@ export interface ScheduleDaemonClient {
|
||||
schedulePause(input: { id: string }): Promise<SchedulePausePayload>;
|
||||
scheduleResume(input: { id: string }): Promise<ScheduleResumePayload>;
|
||||
scheduleDelete(input: { id: string }): Promise<ScheduleDeletePayload>;
|
||||
scheduleRunOnce(input: { id: string }): Promise<ScheduleRunOncePayload>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user