From 9e3ebd96652741000ce21299a54f5395c263989b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 4 May 2026 19:39:13 +0800 Subject: [PATCH] schedule: add `paseo schedule update` to edit schedules in place (#694) * schedule: add `paseo schedule update` to edit schedules in place Editing a schedule today requires delete+recreate, losing run history and the schedule id. This adds an additive RPC and CLI command that patches name, prompt, cadence, new-agent target fields, max-runs, and expires-in without touching runs or in-flight executions. nextRunAt is recomputed only when the cadence actually changes. * schedule(cli): share cadence flag parser between create and update Both paths were turning --every/--cron into a ScheduleCadence with near-identical code. Extract `parseCadenceFromFlags` so the literals and the exclusivity check live in one place; create wraps it to require a value, update lets it stay optional. --- packages/cli/src/commands/schedule/index.ts | 23 ++ .../cli/src/commands/schedule/shared.test.ts | 126 +++++++++- packages/cli/src/commands/schedule/shared.ts | 171 ++++++++++++- packages/cli/src/commands/schedule/types.ts | 24 ++ packages/cli/src/commands/schedule/update.ts | 66 +++++ packages/server/src/client/daemon-client.ts | 46 ++++ .../server/src/server/schedule/rpc-schemas.ts | 28 +++ .../src/server/schedule/service.test.ts | 238 ++++++++++++++++++ .../server/src/server/schedule/service.ts | 81 ++++++ .../server/src/server/schedule/store.test.ts | 38 +++ packages/server/src/server/schedule/types.ts | 17 ++ packages/server/src/server/session.ts | 31 ++- packages/server/src/shared/messages.ts | 6 + 13 files changed, 887 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/commands/schedule/update.ts diff --git a/packages/cli/src/commands/schedule/index.ts b/packages/cli/src/commands/schedule/index.ts index 57bf42881..54b48555f 100644 --- a/packages/cli/src/commands/schedule/index.ts +++ b/packages/cli/src/commands/schedule/index.ts @@ -9,6 +9,7 @@ import { runPauseCommand } from "./pause.js"; import { runResumeCommand } from "./resume.js"; import { runDeleteCommand } from "./delete.js"; import { runRunOnceCommand } from "./run-once.js"; +import { runUpdateCommand } from "./update.js"; export function createScheduleCommand(): Command { const schedule = new Command("schedule").description("Manage recurring schedules"); @@ -74,5 +75,27 @@ export function createScheduleCommand(): Command { .argument("", "Schedule ID"), ).action(withOutput(runRunOnceCommand)); + addJsonAndDaemonHostOptions( + schedule + .command("update") + .description("Update an existing schedule in place") + .argument("", "Schedule ID") + .option("--every ", "Switch to fixed interval cadence (for example: 5m, 1h)") + .option("--cron ", "Switch to cron cadence expression") + .option("--name ", "Rename the schedule (empty string clears the name)") + .option("--prompt ", "Replace the schedule prompt") + .option( + "--provider ", + "New agent provider, or provider/model (only for new-agent target)", + ) + .option("--model ", "New agent model (only for new-agent target)") + .option("--mode ", "New agent provider mode (only for new-agent target)") + .option("--cwd ", "New working directory (only for new-agent target)") + .option("--max-runs ", "Set or change maximum number of runs") + .option("--no-max-runs", "Clear the max-runs limit") + .option("--expires-in ", "Set or change time to live for the schedule") + .option("--no-expires-in", "Clear the expiration"), + ).action(withOutput(runUpdateCommand)); + return schedule; } diff --git a/packages/cli/src/commands/schedule/shared.test.ts b/packages/cli/src/commands/schedule/shared.test.ts index 9eda718cf..7cd2db79a 100644 --- a/packages/cli/src/commands/schedule/shared.test.ts +++ b/packages/cli/src/commands/schedule/shared.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { parseScheduleCreateInput } from "./shared.js"; +import { parseScheduleCreateInput, parseScheduleUpdateInput } from "./shared.js"; const baseOptions = { prompt: "do the thing", @@ -106,3 +106,127 @@ describe("parseScheduleCreateInput first-run timing", () => { ); }); }); + +describe("parseScheduleUpdateInput", () => { + test("rejects calls with no fields to update", () => { + expect(() => parseScheduleUpdateInput({ id: "abc" })).toThrow( + expect.objectContaining({ code: "NO_UPDATES" }), + ); + }); + + test("parses prompt and name updates", () => { + expect(parseScheduleUpdateInput({ id: "abc", prompt: " hello ", name: " named " })).toEqual( + { + id: "abc", + name: "named", + prompt: "hello", + }, + ); + }); + + test("name set to empty string clears the name", () => { + expect(parseScheduleUpdateInput({ id: "abc", name: "" })).toEqual({ + id: "abc", + name: null, + }); + }); + + test("rejects empty prompt", () => { + expect(() => parseScheduleUpdateInput({ id: "abc", prompt: " " })).toThrow( + expect.objectContaining({ code: "INVALID_PROMPT" }), + ); + }); + + test("parses --every cadence", () => { + expect(parseScheduleUpdateInput({ id: "abc", every: "5m" })).toEqual({ + id: "abc", + cadence: { type: "every", everyMs: 5 * 60_000 }, + }); + }); + + test("parses --cron cadence", () => { + expect(parseScheduleUpdateInput({ id: "abc", cron: "30 9 * * *" })).toEqual({ + id: "abc", + cadence: { type: "cron", expression: "30 9 * * *" }, + }); + }); + + test("rejects passing both --every and --cron", () => { + expect(() => parseScheduleUpdateInput({ id: "abc", every: "5m", cron: "0 9 * * *" })).toThrow( + expect.objectContaining({ code: "INVALID_CADENCE" }), + ); + }); + + test("parses provider/model shorthand and explicit mode", () => { + expect( + parseScheduleUpdateInput({ + id: "abc", + provider: "codex/gpt-5", + mode: "full-access", + cwd: "/tmp/proj", + }), + ).toEqual({ + id: "abc", + newAgentConfig: { + provider: "codex", + model: "gpt-5", + modeId: "full-access", + cwd: "/tmp/proj", + }, + }); + }); + + test("--mode with empty value clears the modeId", () => { + expect(parseScheduleUpdateInput({ id: "abc", mode: "" })).toEqual({ + id: "abc", + newAgentConfig: { modeId: null }, + }); + }); + + test("rejects empty --cwd", () => { + expect(() => parseScheduleUpdateInput({ id: "abc", cwd: " " })).toThrow( + expect.objectContaining({ code: "INVALID_CWD" }), + ); + }); + + test("--max-runs sets a positive integer; --no-max-runs clears", () => { + expect(parseScheduleUpdateInput({ id: "abc", maxRuns: "3" })).toEqual({ + id: "abc", + maxRuns: 3, + }); + expect(parseScheduleUpdateInput({ id: "abc", clearMaxRuns: true })).toEqual({ + id: "abc", + maxRuns: null, + }); + }); + + test("rejects passing both --max-runs and --no-max-runs", () => { + expect(() => parseScheduleUpdateInput({ id: "abc", maxRuns: "3", clearMaxRuns: true })).toThrow( + expect.objectContaining({ code: "CONFLICTING_MAX_RUNS" }), + ); + }); + + test("--expires-in computes an absolute timestamp; --no-expires-in clears", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + try { + expect(parseScheduleUpdateInput({ id: "abc", expiresIn: "1h" })).toEqual({ + id: "abc", + expiresAt: "2026-01-01T01:00:00.000Z", + }); + } finally { + vi.useRealTimers(); + } + + expect(parseScheduleUpdateInput({ id: "abc", clearExpires: true })).toEqual({ + id: "abc", + expiresAt: null, + }); + }); + + test("rejects passing both --expires-in and --no-expires-in", () => { + expect(() => + parseScheduleUpdateInput({ id: "abc", expiresIn: "1h", clearExpires: true }), + ).toThrow(expect.objectContaining({ code: "CONFLICTING_EXPIRES" })); + }); +}); diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index 0f55de5df..0c610666b 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -7,6 +7,8 @@ import type { ScheduleListItem, ScheduleRecord, ScheduleTarget, + UpdateScheduleInput, + UpdateScheduleNewAgentConfig, } from "./types.js"; import { parseDuration } from "../../utils/duration.js"; import { resolveProviderAndModel } from "../../utils/provider-model.js"; @@ -145,18 +147,14 @@ export function parseScheduleCreateInput(options: { } satisfies CommandError; } - const cadenceCount = Number(options.every !== undefined) + Number(options.cron !== undefined); - if (cadenceCount !== 1) { + const cadence = parseCadenceFromFlags(options.every, options.cron); + if (!cadence) { throw { code: "INVALID_CADENCE", message: "Specify exactly one of --every or --cron", } satisfies CommandError; } - const cadence: ScheduleCadence = options.every - ? { type: "every", everyMs: parseDuration(options.every) } - : { type: "cron", expression: options.cron!.trim() }; - const cwdInput = options.cwd?.trim(); if (options.host !== undefined && !cwdInput) { throw { @@ -230,6 +228,167 @@ function resolveRunOnCreate( return runNow ?? cadenceType === "every"; } +export interface ScheduleUpdateOptionsInput { + id: string; + every?: string; + cron?: string; + name?: string; + prompt?: string; + provider?: string; + model?: string; + mode?: string; + cwd?: string; + maxRuns?: string; + expiresIn?: string; + clearMaxRuns?: boolean; + clearExpires?: boolean; +} + +export function parseScheduleUpdateInput(options: ScheduleUpdateOptionsInput): UpdateScheduleInput { + const id = options.id.trim(); + if (!id) { + throw { + code: "INVALID_SCHEDULE_ID", + message: "Schedule id cannot be empty", + } satisfies CommandError; + } + + const cadence = parseCadenceFromFlags(options.every, options.cron); + const newAgentConfig = buildNewAgentConfigPatch(options); + const maxRuns = parseUpdateMaxRuns(options); + const expiresAt = parseUpdateExpiresAt(options); + const name = parseUpdateName(options); + const prompt = parseUpdatePrompt(options); + + if ( + name === undefined && + prompt === undefined && + cadence === undefined && + newAgentConfig === undefined && + maxRuns === undefined && + expiresAt === undefined + ) { + throw { + code: "NO_UPDATES", + message: "Specify at least one field to update", + } satisfies CommandError; + } + + return { + id, + ...(name !== undefined ? { name } : {}), + ...(prompt !== undefined ? { prompt } : {}), + ...(cadence !== undefined ? { cadence } : {}), + ...(newAgentConfig !== undefined ? { newAgentConfig } : {}), + ...(maxRuns !== undefined ? { maxRuns } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + }; +} + +function parseCadenceFromFlags( + every: string | undefined, + cron: string | undefined, +): ScheduleCadence | undefined { + if (every !== undefined && cron !== undefined) { + throw { + code: "INVALID_CADENCE", + message: "Specify at most one of --every or --cron", + } satisfies CommandError; + } + if (every !== undefined) { + return { type: "every", everyMs: parseDuration(every) }; + } + if (cron !== undefined) { + return { type: "cron", expression: cron.trim() }; + } + return undefined; +} + +function parseUpdateMaxRuns(options: ScheduleUpdateOptionsInput): number | null | undefined { + if (options.maxRuns !== undefined && options.clearMaxRuns) { + throw { + code: "CONFLICTING_MAX_RUNS", + message: "Use either --max-runs or --no-max-runs, not both", + } satisfies CommandError; + } + if (options.clearMaxRuns) { + return null; + } + if (options.maxRuns !== undefined) { + return parsePositiveInt(options.maxRuns, "--max-runs"); + } + return undefined; +} + +function parseUpdateExpiresAt(options: ScheduleUpdateOptionsInput): string | null | undefined { + if (options.expiresIn !== undefined && options.clearExpires) { + throw { + code: "CONFLICTING_EXPIRES", + message: "Use either --expires-in or --no-expires-in, not both", + } satisfies CommandError; + } + if (options.clearExpires) { + return null; + } + if (options.expiresIn !== undefined) { + return new Date(Date.now() + parseDuration(options.expiresIn)).toISOString(); + } + return undefined; +} + +function parseUpdateName(options: ScheduleUpdateOptionsInput): string | null | undefined { + if (options.name === undefined) { + return undefined; + } + const trimmed = options.name.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function parseUpdatePrompt(options: ScheduleUpdateOptionsInput): string | undefined { + if (options.prompt === undefined) { + return undefined; + } + const trimmed = options.prompt.trim(); + if (!trimmed) { + throw { + code: "INVALID_PROMPT", + message: "--prompt cannot be empty", + } satisfies CommandError; + } + return trimmed; +} + +function buildNewAgentConfigPatch( + options: ScheduleUpdateOptionsInput, +): UpdateScheduleNewAgentConfig | undefined { + const patch: UpdateScheduleNewAgentConfig = {}; + if (options.provider !== undefined || options.model !== undefined) { + const resolved = resolveProviderAndModel({ + provider: options.provider, + model: options.model, + }); + patch.provider = resolved.provider; + if (resolved.model !== undefined) { + patch.model = resolved.model; + } + } + if (options.mode !== undefined) { + const trimmed = options.mode.trim(); + patch.modeId = trimmed.length > 0 ? trimmed : null; + } + if (options.cwd !== undefined) { + const trimmed = options.cwd.trim(); + if (!trimmed) { + throw { + code: "INVALID_CWD", + message: "--cwd cannot be empty", + } satisfies CommandError; + } + patch.cwd = trimmed; + } + return Object.keys(patch).length > 0 ? patch : undefined; +} + function parsePositiveInt(value: string, flag: string): number { const parsed = Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0) { diff --git a/packages/cli/src/commands/schedule/types.ts b/packages/cli/src/commands/schedule/types.ts index e4d9b5d8d..c6722ae04 100644 --- a/packages/cli/src/commands/schedule/types.ts +++ b/packages/cli/src/commands/schedule/types.ts @@ -136,6 +136,29 @@ export interface ScheduleRunOncePayload { error: string | null; } +export interface UpdateScheduleNewAgentConfig { + provider?: string; + model?: string | null; + modeId?: string | null; + cwd?: string; +} + +export interface UpdateScheduleInput { + id: string; + name?: string | null; + prompt?: string; + cadence?: ScheduleCadence; + newAgentConfig?: UpdateScheduleNewAgentConfig; + maxRuns?: number | null; + expiresAt?: string | null; +} + +export interface ScheduleUpdatePayload { + requestId: string; + schedule: ScheduleRecord | null; + error: string | null; +} + export interface ScheduleDaemonClient { scheduleCreate(input: CreateScheduleInput): Promise; scheduleList(): Promise; @@ -145,5 +168,6 @@ export interface ScheduleDaemonClient { scheduleResume(input: { id: string }): Promise; scheduleDelete(input: { id: string }): Promise; scheduleRunOnce(input: { id: string }): Promise; + scheduleUpdate(input: UpdateScheduleInput): Promise; close(): Promise; } diff --git a/packages/cli/src/commands/schedule/update.ts b/packages/cli/src/commands/schedule/update.ts new file mode 100644 index 000000000..159092de9 --- /dev/null +++ b/packages/cli/src/commands/schedule/update.ts @@ -0,0 +1,66 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { + createScheduleInspectRows, + createScheduleInspectSchema, + type ScheduleInspectRow, +} from "./schema.js"; +import { + connectScheduleClient, + parseScheduleUpdateInput, + toScheduleCommandError, + type ScheduleCommandOptions, +} from "./shared.js"; + +export interface ScheduleUpdateOptions extends ScheduleCommandOptions { + every?: string; + cron?: string; + name?: string; + prompt?: string; + provider?: string; + model?: string; + mode?: string; + cwd?: string; + maxRuns?: string; + noMaxRuns?: boolean; + expiresIn?: string; + noExpiresIn?: boolean; +} + +export async function runUpdateCommand( + id: string, + options: ScheduleUpdateOptions, + _command: Command, +): Promise> { + const input = parseScheduleUpdateInput({ + id, + every: options.every, + cron: options.cron, + name: options.name, + prompt: options.prompt, + provider: options.provider, + model: options.model, + mode: options.mode, + cwd: options.cwd, + maxRuns: options.maxRuns, + expiresIn: options.expiresIn, + clearMaxRuns: options.noMaxRuns, + clearExpires: options.noExpiresIn, + }); + const { client } = await connectScheduleClient(options.host); + try { + const payload = await client.scheduleUpdate(input); + if (payload.error || !payload.schedule) { + throw new Error(payload.error ?? `Failed to update schedule: ${id}`); + } + return { + type: "list", + data: createScheduleInspectRows(payload.schedule), + schema: createScheduleInspectSchema(payload.schedule), + }; + } catch (error) { + throw toScheduleCommandError("SCHEDULE_UPDATE_FAILED", "update schedule", error); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index d99571f90..db327bb76 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -392,6 +392,10 @@ type ScheduleRunOncePayload = Extract< SessionOutboundMessage, { type: "schedule/run-once/response" } >["payload"]; +type ScheduleUpdatePayload = Extract< + SessionOutboundMessage, + { type: "schedule/update/response" } +>["payload"]; export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; @@ -552,6 +556,30 @@ export interface InspectScheduleOptions { id: string; requestId?: string; } +export interface UpdateScheduleNewAgentConfig { + provider?: string; + model?: string | null; + modeId?: string | null; + cwd?: string; +} +export interface UpdateScheduleOptions { + id: string; + name?: string | null; + prompt?: string; + cadence?: + | { + type: "every"; + everyMs: number; + } + | { + type: "cron"; + expression: string; + }; + newAgentConfig?: UpdateScheduleNewAgentConfig; + maxRuns?: number | null; + expiresAt?: string | null; + requestId?: string; +} type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"]; type OpenInEditorPayload = OpenInEditorResponseMessage["payload"]; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; @@ -3762,6 +3790,24 @@ export class DaemonClient { }); } + async scheduleUpdate(options: UpdateScheduleOptions): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options.requestId, + message: { + type: "schedule/update", + scheduleId: options.id, + ...(options.name !== undefined ? { name: options.name } : {}), + ...(options.prompt !== undefined ? { prompt: options.prompt } : {}), + ...(options.cadence !== undefined ? { cadence: options.cadence } : {}), + ...(options.newAgentConfig !== undefined ? { newAgentConfig: options.newAgentConfig } : {}), + ...(options.maxRuns !== undefined ? { maxRuns: options.maxRuns } : {}), + ...(options.expiresAt !== undefined ? { expiresAt: options.expiresAt } : {}), + }, + responseType: "schedule/update/response", + timeout: 10000, + }); + } + async loopRun(options: RunLoopOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, diff --git a/packages/server/src/server/schedule/rpc-schemas.ts b/packages/server/src/server/schedule/rpc-schemas.ts index e6cdb0070..80b306578 100644 --- a/packages/server/src/server/schedule/rpc-schemas.ts +++ b/packages/server/src/server/schedule/rpc-schemas.ts @@ -75,6 +75,25 @@ export const ScheduleRunOnceRequestSchema = z.object({ scheduleId: z.string(), }); +const ScheduleUpdateNewAgentConfigSchema = z.object({ + provider: z.string().trim().min(1).optional(), + model: z.string().trim().min(1).nullable().optional(), + modeId: z.string().trim().min(1).nullable().optional(), + cwd: z.string().trim().min(1).optional(), +}); + +export const ScheduleUpdateRequestSchema = z.object({ + type: z.literal("schedule/update"), + requestId: z.string(), + scheduleId: z.string(), + name: z.string().nullable().optional(), + prompt: z.string().min(1).optional(), + cadence: ScheduleCadenceSchema.optional(), + newAgentConfig: ScheduleUpdateNewAgentConfigSchema.optional(), + maxRuns: z.number().int().positive().nullable().optional(), + expiresAt: z.string().nullable().optional(), +}); + export const ScheduleCreateResponseSchema = z.object({ type: z.literal("schedule/create/response"), payload: z.object({ @@ -146,3 +165,12 @@ export const ScheduleRunOnceResponseSchema = z.object({ error: z.string().nullable(), }), }); + +export const ScheduleUpdateResponseSchema = z.object({ + type: z.literal("schedule/update/response"), + payload: z.object({ + requestId: z.string(), + schedule: StoredScheduleSchema.nullable(), + error: z.string().nullable(), + }), +}); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 27fc8150c..3b39b3089 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -493,6 +493,244 @@ describe("ScheduleService", () => { }); }); + test("update mutates cadence, prompt, name, and target fields in place", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + name: "morning", + prompt: "first prompt", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir, modeId: "default" }, + }, + }); + expect(created.runs).toEqual([]); + + now = new Date("2026-01-01T00:00:30.000Z"); + const updated = await service.update({ + id: created.id, + prompt: "second prompt", + name: "renamed", + cadence: { type: "every", everyMs: 5 * 60_000 }, + newAgentConfig: { + provider: "codex", + model: "gpt-5", + modeId: "full-access", + cwd: "/new/path", + }, + }); + + expect(updated.prompt).toBe("second prompt"); + expect(updated.name).toBe("renamed"); + expect(updated.cadence).toEqual({ type: "every", everyMs: 5 * 60_000 }); + expect(updated.target).toEqual({ + type: "new-agent", + config: { + provider: "codex", + cwd: "/new/path", + model: "gpt-5", + modeId: "full-access", + }, + }); + expect(updated.nextRunAt).toBe("2026-01-01T00:05:30.000Z"); + expect(updated.updatedAt).toBe("2026-01-01T00:00:30.000Z"); + expect(updated.createdAt).toBe(created.createdAt); + }); + + test("update switches between every and cron cadences and recomputes nextRunAt", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + expect(created.nextRunAt).toBe("2026-01-01T00:00:00.000Z"); + + const cron = await service.update({ + id: created.id, + cadence: { type: "cron", expression: "30 9 * * *" }, + }); + expect(cron.cadence).toEqual({ type: "cron", expression: "30 9 * * *" }); + expect(cron.nextRunAt).toBe("2026-01-01T09:30:00.000Z"); + + const back = await service.update({ + id: created.id, + cadence: { type: "every", everyMs: 2 * 60_000 }, + }); + expect(back.cadence).toEqual({ type: "every", everyMs: 2 * 60_000 }); + expect(back.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + }); + + test("update preserves nextRunAt and run history when cadence is unchanged", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ agentId: null, output: "ran" }), + }); + + const created = await service.create({ + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + const after = await service.inspect(created.id); + expect(after.runs).toHaveLength(1); + + now = new Date("2026-01-01T00:01:30.000Z"); + const updated = await service.update({ id: created.id, prompt: "new prompt" }); + + expect(updated.prompt).toBe("new prompt"); + expect(updated.cadence).toEqual(created.cadence); + expect(updated.nextRunAt).toBe(after.nextRunAt); + expect(updated.runs).toEqual(after.runs); + expect(updated.lastRunAt).toBe(after.lastRunAt); + }); + + test("update clears the schedule name when given an empty string", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + name: "named", + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + expect(created.name).toBe("named"); + + const cleared = await service.update({ id: created.id, name: "" }); + expect(cleared.name).toBeNull(); + + const renamed = await service.update({ id: created.id, name: "again" }); + expect(renamed.name).toBe("again"); + }); + + test("update rejects new-agent fields on agent-target schedules", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + prompt: "agent target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "00000000-0000-0000-0000-000000000005" }, + }); + + await expect( + service.update({ + id: created.id, + newAgentConfig: { provider: "codex" }, + }), + ).rejects.toThrow("only valid for new-agent target schedules"); + }); + + test("update changes individual new-agent fields independently", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir, model: "sonnet", modeId: "default" }, + }, + }); + + const modeOnly = await service.update({ + id: created.id, + newAgentConfig: { modeId: "bypassPermissions" }, + }); + expect(modeOnly.target).toMatchObject({ + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + model: "sonnet", + modeId: "bypassPermissions", + }, + }); + + const clearModel = await service.update({ + id: created.id, + newAgentConfig: { model: null }, + }); + if (clearModel.target.type !== "new-agent") { + throw new Error("target type changed unexpectedly"); + } + expect(clearModel.target.config.model).toBeUndefined(); + expect(clearModel.target.config.modeId).toBe("bypassPermissions"); + }); + + test("update returns a schedule that round-trips through the store", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + + await service.update({ + id: created.id, + cadence: { type: "cron", expression: "0 9 * * *" }, + newAgentConfig: { provider: "codex", modeId: "full-access" }, + }); + + const reloaded = await service.inspect(created.id); + expect(reloaded.cadence).toEqual({ type: "cron", expression: "0 9 * * *" }); + expect(reloaded.target).toEqual({ + type: "new-agent", + config: { provider: "codex", cwd: tempDir, modeId: "full-access" }, + }); + }); + test("runOnce rejects completed schedules", async () => { const service = new ScheduleService({ paseoHome: tempDir, diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 7d913da82..d961ab91f 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -13,7 +13,10 @@ import type { CreateScheduleInput, ScheduleExecutionResult, ScheduleRun, + ScheduleTarget, StoredSchedule, + UpdateScheduleInput, + UpdateScheduleNewAgentConfig, } from "./types.js"; const SCHEDULE_TICK_INTERVAL_MS = 1000; @@ -34,6 +37,44 @@ function normalizePrompt(prompt: string): string { return trimmed; } +function applyNewAgentConfig( + target: Extract, + patch: UpdateScheduleNewAgentConfig, +): Extract { + const config = { ...target.config }; + if (patch.provider !== undefined) { + const trimmed = patch.provider.trim(); + if (!trimmed) { + throw new Error("provider cannot be empty"); + } + config.provider = trimmed; + } + if (patch.cwd !== undefined) { + const trimmed = patch.cwd.trim(); + if (!trimmed) { + throw new Error("cwd cannot be empty"); + } + config.cwd = trimmed; + } + if (patch.model !== undefined) { + const trimmed = patch.model?.trim(); + if (trimmed) { + config.model = trimmed; + } else { + delete config.model; + } + } + if (patch.modeId !== undefined) { + const trimmed = patch.modeId?.trim(); + if (trimmed) { + config.modeId = trimmed; + } else { + delete config.modeId; + } + } + return { ...target, config }; +} + function normalizeMaxRuns(value: number | null | undefined): number | null { if (value == null) { return null; @@ -219,6 +260,46 @@ export class ScheduleService { return resumed; } + async update(input: UpdateScheduleInput): Promise { + const schedule = await this.inspect(input.id); + const now = this.now(); + let updated: StoredSchedule = schedule; + + if (input.prompt !== undefined) { + updated = { ...updated, prompt: normalizePrompt(input.prompt) }; + } + + if (input.name !== undefined) { + updated = { ...updated, name: trimOptionalName(input.name) }; + } + + if (input.cadence !== undefined) { + validateScheduleCadence(input.cadence); + const nextRunAt = + updated.status === "active" ? computeNextRunAt(input.cadence, now).toISOString() : null; + updated = { ...updated, cadence: input.cadence, nextRunAt }; + } + + if (input.newAgentConfig !== undefined) { + if (updated.target.type !== "new-agent") { + throw new Error("new-agent config updates are only valid for new-agent target schedules"); + } + updated = { ...updated, target: applyNewAgentConfig(updated.target, input.newAgentConfig) }; + } + + if (input.maxRuns !== undefined) { + updated = { ...updated, maxRuns: normalizeMaxRuns(input.maxRuns) }; + } + + if (input.expiresAt !== undefined) { + updated = { ...updated, expiresAt: input.expiresAt }; + } + + updated = { ...updated, updatedAt: now.toISOString() }; + await this.store.put(updated); + return updated; + } + async delete(id: string): Promise { await this.store.delete(id); } diff --git a/packages/server/src/server/schedule/store.test.ts b/packages/server/src/server/schedule/store.test.ts index 03c1f6476..e170aa441 100644 --- a/packages/server/src/server/schedule/store.test.ts +++ b/packages/server/src/server/schedule/store.test.ts @@ -47,6 +47,44 @@ describe("ScheduleStore", () => { expect(listed).toEqual([created]); }); + test("put round-trips an updated schedule to disk", async () => { + const created = await store.create({ + name: "before", + prompt: "before", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir }, + }, + status: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: "2026-01-01T00:01:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }); + + const updated = { + ...created, + name: "after", + prompt: "after", + cadence: { type: "cron" as const, expression: "0 9 * * *" }, + target: { + type: "new-agent" as const, + config: { provider: "codex", cwd: "/elsewhere", modeId: "full-access" }, + }, + nextRunAt: "2026-01-01T09:00:00.000Z", + updatedAt: "2026-01-01T00:00:30.000Z", + }; + await store.put(updated); + + const reloaded = await new ScheduleStore(tempDir).get(created.id); + expect(reloaded).toEqual(updated); + }); + test("deletes schedules from disk", async () => { const created = await store.create({ name: null, diff --git a/packages/server/src/server/schedule/types.ts b/packages/server/src/server/schedule/types.ts index 11be629ba..dd24e175b 100644 --- a/packages/server/src/server/schedule/types.ts +++ b/packages/server/src/server/schedule/types.ts @@ -93,6 +93,23 @@ export interface CreateScheduleInput { runOnCreate?: boolean | null; } +export interface UpdateScheduleNewAgentConfig { + provider?: string; + model?: string | null; + modeId?: string | null; + cwd?: string; +} + +export interface UpdateScheduleInput { + id: string; + name?: string | null; + prompt?: string; + cadence?: ScheduleCadence; + newAgentConfig?: UpdateScheduleNewAgentConfig; + maxRuns?: number | null; + expiresAt?: string | null; +} + export interface ScheduleExecutionResult { agentId: string | null; output: string | null; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a16b08ff9..b2f5dfe2a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2163,6 +2163,8 @@ export class Session { return this.handleScheduleDeleteRequest(msg); case "schedule/run-once": return this.handleScheduleRunOnceRequest(msg); + case "schedule/update": + return this.handleScheduleUpdateRequest(msg); default: return undefined; } @@ -8405,7 +8407,8 @@ export class Session { | "schedule/pause" | "schedule/resume" | "schedule/delete" - | "schedule/run-once"; + | "schedule/run-once" + | "schedule/update"; } >, error: unknown, @@ -8579,6 +8582,32 @@ export class Session { } } + private async handleScheduleUpdateRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.update({ + id: request.scheduleId, + ...(request.name !== undefined ? { name: request.name } : {}), + ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), + ...(request.cadence !== undefined ? { cadence: request.cadence } : {}), + ...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}), + ...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}), + ...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}), + }); + this.emit({ + type: "schedule/update/response", + payload: { + requestId: request.requestId, + schedule, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + private emitLoopRpcError( request: Extract< SessionInboundMessage, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index b8c720149..e780af3b8 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -29,6 +29,7 @@ import { ScheduleResumeRequestSchema, ScheduleDeleteRequestSchema, ScheduleRunOnceRequestSchema, + ScheduleUpdateRequestSchema, ScheduleCreateResponseSchema, ScheduleListResponseSchema, ScheduleInspectResponseSchema, @@ -37,6 +38,7 @@ import { ScheduleResumeResponseSchema, ScheduleDeleteResponseSchema, ScheduleRunOnceResponseSchema, + ScheduleUpdateResponseSchema, } from "../server/schedule/rpc-schemas.js"; import { LoopRunRequestSchema, @@ -1768,6 +1770,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ScheduleResumeRequestSchema, ScheduleDeleteRequestSchema, ScheduleRunOnceRequestSchema, + ScheduleUpdateRequestSchema, LoopRunRequestSchema, LoopListRequestSchema, LoopInspectRequestSchema, @@ -3382,6 +3385,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ScheduleResumeResponseSchema, ScheduleDeleteResponseSchema, ScheduleRunOnceResponseSchema, + ScheduleUpdateResponseSchema, LoopRunResponseSchema, LoopListResponseSchema, LoopInspectResponseSchema, @@ -3488,6 +3492,7 @@ export type SchedulePauseResponse = z.infer; export type ScheduleResumeResponse = z.infer; export type ScheduleDeleteResponse = z.infer; export type ScheduleRunOnceResponse = z.infer; +export type ScheduleUpdateResponse = z.infer; export type LoopRunResponse = z.infer; export type LoopListResponse = z.infer; export type LoopInspectResponse = z.infer; @@ -3547,6 +3552,7 @@ export type SchedulePauseRequest = z.infer; export type ScheduleResumeRequest = z.infer; export type ScheduleDeleteRequest = z.infer; export type ScheduleRunOnceRequest = z.infer; +export type ScheduleUpdateRequest = z.infer; export type LoopRunRequest = z.infer; export type LoopListRequest = z.infer; export type LoopInspectRequest = z.infer;