Skip to content

Commit 453e29a

Browse files
committed
feat(scheduler): add update action to phantom_schedule tool
Adds an 'update' action to the phantom_schedule MCP tool so jobs can be edited in place without deleting and recreating, preserving run_count, last_run_at, last_run_status, consecutive_errors, and the stable jobId. Updatable fields: name, description, task, schedule, delivery, enabled. Schedule changes recompute next_run_at automatically. Closes #86
1 parent 0c6f0c5 commit 453e29a

5 files changed

Lines changed: 257 additions & 6 deletions

File tree

src/scheduler/__tests__/service.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,4 +462,120 @@ describe("Scheduler", () => {
462462
expect(row.next).toBeNull();
463463
scheduler.stop();
464464
});
465+
466+
test("updateJob updates task only, preserves run history", async () => {
467+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
468+
const job = scheduler.createJob({
469+
name: "Updatable",
470+
schedule: { kind: "every", intervalMs: 60_000 },
471+
task: "Original task",
472+
});
473+
474+
// Simulate some run history
475+
await scheduler.runJobNow(job.id);
476+
const beforeUpdate = scheduler.getJob(job.id);
477+
expect(beforeUpdate?.runCount).toBe(1);
478+
expect(beforeUpdate?.lastRunAt).toBeTruthy();
479+
480+
const updated = scheduler.updateJob(job.id, { task: "Updated task" });
481+
expect(updated).not.toBeNull();
482+
expect(updated?.task).toBe("Updated task");
483+
expect(updated?.runCount).toBe(1);
484+
expect(updated?.lastRunAt).toBe(beforeUpdate?.lastRunAt);
485+
expect(updated?.lastRunStatus).toBe("ok");
486+
});
487+
488+
test("updateJob updates schedule and recomputes next_run_at", () => {
489+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
490+
const job = scheduler.createJob({
491+
name: "Schedule Update",
492+
schedule: { kind: "every", intervalMs: 60_000 },
493+
task: "Task",
494+
});
495+
496+
const originalNextRun = job.nextRunAt;
497+
const updated = scheduler.updateJob(job.id, { schedule: { kind: "every", intervalMs: 120_000 } });
498+
499+
expect(updated).not.toBeNull();
500+
expect(updated?.schedule).toEqual({ kind: "every", intervalMs: 120_000 });
501+
expect(updated?.nextRunAt).not.toBe(originalNextRun);
502+
expect(updated?.nextRunAt).toBeTruthy();
503+
});
504+
505+
test("updateJob updates delivery", () => {
506+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
507+
const job = scheduler.createJob({
508+
name: "Delivery Update",
509+
schedule: { kind: "every", intervalMs: 60_000 },
510+
task: "Task",
511+
});
512+
513+
const updated = scheduler.updateJob(job.id, { delivery: { channel: "slack", target: "C04ABC123" } });
514+
expect(updated).not.toBeNull();
515+
expect(updated?.delivery).toEqual({ channel: "slack", target: "C04ABC123" });
516+
});
517+
518+
test("updateJob updates name", () => {
519+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
520+
const job = scheduler.createJob({
521+
name: "Old Name",
522+
schedule: { kind: "every", intervalMs: 60_000 },
523+
task: "Task",
524+
});
525+
526+
const updated = scheduler.updateJob(job.id, { name: "New Name" });
527+
expect(updated).not.toBeNull();
528+
expect(updated?.name).toBe("New Name");
529+
});
530+
531+
test("updateJob updates enabled flag", () => {
532+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
533+
const job = scheduler.createJob({
534+
name: "Toggle",
535+
schedule: { kind: "every", intervalMs: 60_000 },
536+
task: "Task",
537+
enabled: true,
538+
});
539+
540+
const disabled = scheduler.updateJob(job.id, { enabled: false });
541+
expect(disabled).not.toBeNull();
542+
expect(disabled?.enabled).toBe(false);
543+
544+
const enabled = scheduler.updateJob(job.id, { enabled: true });
545+
expect(enabled).not.toBeNull();
546+
expect(enabled?.enabled).toBe(true);
547+
});
548+
549+
test("updateJob returns null for nonexistent job", () => {
550+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
551+
const updated = scheduler.updateJob("nonexistent-id", { task: "New task" });
552+
expect(updated).toBeNull();
553+
});
554+
555+
test("updateJob rejects invalid schedule", () => {
556+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
557+
const job = scheduler.createJob({
558+
name: "Bad Schedule",
559+
schedule: { kind: "every", intervalMs: 60_000 },
560+
task: "Task",
561+
});
562+
563+
const past = new Date(Date.now() - 3_600_000).toISOString();
564+
expect(() => {
565+
scheduler.updateJob(job.id, { schedule: { kind: "at", at: past } });
566+
}).toThrow("invalid schedule");
567+
});
568+
569+
test("updateJob rejects invalid delivery target", () => {
570+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
571+
const job = scheduler.createJob({
572+
name: "Bad Delivery",
573+
schedule: { kind: "every", intervalMs: 60_000 },
574+
task: "Task",
575+
});
576+
577+
expect(() => {
578+
scheduler.updateJob(job.id, { delivery: { channel: "slack", target: "invalid" } });
579+
}).toThrow("invalid delivery.target");
580+
});
465581
});

src/scheduler/service.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ import { executeJob } from "./executor.ts";
77
import { type SchedulerHealthSummary, computeHealthSummary } from "./health.ts";
88
import { cleanupOldTerminalJobs, staggerMissedJobs } from "./recovery.ts";
99
import { rowToJob } from "./row-mapper.ts";
10-
import { computeNextRunAt, serializeScheduleValue } from "./schedule.ts";
11-
import type { JobCreateInput, JobRow, ScheduledJob } from "./types.ts";
10+
import { computeNextRunAt, serializeScheduleValue, validateSchedule } from "./schedule.ts";
11+
import {
12+
type JobCreateInput,
13+
type JobRow,
14+
type JobUpdateInput,
15+
type ScheduledJob,
16+
isValidSlackTarget,
17+
} from "./types.ts";
1218

1319
// Upper bound on the setTimeout delay we pass when arming the next wake-up.
1420
// Both Node and Bun use a 32-bit signed integer for the setTimeout delay, so
@@ -124,6 +130,79 @@ export class Scheduler {
124130
return created;
125131
}
126132

133+
updateJob(id: string, input: JobUpdateInput): ScheduledJob | null {
134+
const job = this.getJob(id);
135+
if (!job) return null;
136+
137+
// Validate schedule if changed
138+
if (input.schedule) {
139+
const scheduleError = validateSchedule(input.schedule);
140+
if (scheduleError) {
141+
throw new Error(`invalid schedule: ${scheduleError}`);
142+
}
143+
}
144+
145+
// Validate delivery if changed
146+
if (input.delivery?.channel === "slack" && input.delivery.target) {
147+
if (!isValidSlackTarget(input.delivery.target)) {
148+
throw new Error(
149+
`invalid delivery.target '${input.delivery.target}': must be "owner", a Slack channel id (C...), or a Slack user id (U...)`,
150+
);
151+
}
152+
}
153+
154+
// Build dynamic UPDATE statement
155+
const updates: string[] = [];
156+
const values: (string | number | null)[] = [];
157+
158+
if (input.name !== undefined) {
159+
updates.push("name = ?");
160+
values.push(input.name);
161+
}
162+
if (input.description !== undefined) {
163+
updates.push("description = ?");
164+
values.push(input.description);
165+
}
166+
if (input.task !== undefined) {
167+
updates.push("task = ?");
168+
values.push(input.task);
169+
}
170+
if (input.enabled !== undefined) {
171+
updates.push("enabled = ?");
172+
values.push(input.enabled ? 1 : 0);
173+
}
174+
if (input.schedule) {
175+
updates.push("schedule_kind = ?");
176+
updates.push("schedule_value = ?");
177+
values.push(input.schedule.kind);
178+
values.push(serializeScheduleValue(input.schedule));
179+
// Recompute next_run_at when schedule changes
180+
const nextRun = computeNextRunAt(input.schedule);
181+
updates.push("next_run_at = ?");
182+
values.push(nextRun ? nextRun.toISOString() : null);
183+
}
184+
if (input.delivery) {
185+
updates.push("delivery_channel = ?");
186+
updates.push("delivery_target = ?");
187+
values.push(input.delivery.channel);
188+
values.push(input.delivery.target);
189+
}
190+
191+
if (updates.length === 0) {
192+
// No fields to update, return current job
193+
return job;
194+
}
195+
196+
updates.push("updated_at = datetime('now')");
197+
values.push(id);
198+
199+
this.db.run(`UPDATE scheduled_jobs SET ${updates.join(", ")} WHERE id = ?`, values);
200+
201+
this.armTimer();
202+
203+
return this.getJob(id);
204+
}
205+
127206
deleteJob(id: string): boolean {
128207
const result = this.db.run("DELETE FROM scheduled_jobs WHERE id = ?", [id]);
129208
if (result.changes > 0) {

src/scheduler/tool-schema.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,18 @@ export const JobCreateInputSchema = z.object({
2929
});
3030

3131
export type JobCreateInputParsed = z.infer<typeof JobCreateInputSchema>;
32+
33+
export const JobUpdateInputSchema = z.object({
34+
name: z.string().min(1).max(200).optional(),
35+
description: z.string().max(1000).optional(),
36+
schedule: ScheduleInputSchema.optional(),
37+
task: z
38+
.string()
39+
.min(1)
40+
.max(32 * 1024)
41+
.optional(),
42+
delivery: JobDeliverySchema.optional(),
43+
enabled: z.boolean().optional(),
44+
});
45+
46+
export type JobUpdateInputParsed = z.infer<typeof JobUpdateInputSchema>;

src/scheduler/tool.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ function err(message: string): { content: Array<{ type: "text"; text: string }>;
1313
return { content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }], isError: true };
1414
}
1515

16-
const TOOL_DESCRIPTION = `Create, list, delete, or trigger scheduled tasks. Lets you set up recurring jobs, one-shot reminders, and automated reports.
16+
const TOOL_DESCRIPTION = `Create, list, delete, update, or trigger scheduled tasks. Lets you set up recurring jobs, one-shot reminders, and automated reports.
1717
1818
Actions:
1919
- create: Create a new scheduled task. Returns the job id and next run time. Rejects invalid schedules, past timestamps, duplicate names, task text over 32 KB, and delivery targets that are not "owner", a channel id (C...), or a user id (U...).
2020
- list: List all scheduled tasks with status and next run time. Corrupt rows are logged and skipped.
2121
- delete: Remove a scheduled task by jobId or by name (case insensitive).
22+
- update: Update a scheduled task by jobId or by name. Preserves run history (run_count, last_run_at, last_run_status, consecutive_errors). Only provided fields are updated. If schedule is changed, next_run_at is recomputed.
2223
- run: Trigger a task immediately. Only runs when status is active and no other job is currently executing. Returns the task output.
2324
2425
Schedule types:
@@ -60,9 +61,9 @@ export function createSchedulerToolServer(scheduler: Scheduler): McpSdkServerCon
6061
TOOL_DESCRIPTION,
6162
{
6263
action: z
63-
.enum(["create", "list", "delete", "run"])
64+
.enum(["create", "list", "delete", "update", "run"])
6465
.describe(
65-
"create: new scheduled task. list: enumerate tasks. delete: remove by jobId or name. run: trigger immediately (only when status=active and scheduler is idle).",
66+
"create: new scheduled task. list: enumerate tasks. delete: remove by jobId or name. update: modify by jobId or name. run: trigger immediately (only when status=active and scheduler is idle).",
6667
),
6768
name: z.string().optional().describe("Job name (required for create)"),
6869
description: z.string().optional().describe("Job description"),
@@ -72,7 +73,8 @@ export function createSchedulerToolServer(scheduler: Scheduler): McpSdkServerCon
7273
.optional()
7374
.describe("The prompt for the agent when the job fires (required for create, 32 KB max)"),
7475
delivery: JobDeliverySchema.optional().describe("Where to deliver results"),
75-
jobId: z.string().optional().describe("Job ID (for delete or run)"),
76+
enabled: z.boolean().optional().describe("Enable or disable the job (for update)"),
77+
jobId: z.string().optional().describe("Job ID (for delete, update, or run)"),
7678
},
7779
async (input) => {
7880
try {
@@ -131,6 +133,36 @@ export function createSchedulerToolServer(scheduler: Scheduler): McpSdkServerCon
131133
return ok({ deleted, id: targetId });
132134
}
133135

136+
case "update": {
137+
const targetId = input.jobId ?? scheduler.findJobIdByName(input.name);
138+
if (!targetId) return err("Provide jobId or name to update");
139+
140+
// Build update object with only provided fields.
141+
// If name was used for lookup (no jobId), exclude it from updates
142+
// to avoid updating the name to itself.
143+
const usedNameForLookup = !input.jobId && input.name;
144+
const updateFields: Record<string, unknown> = {};
145+
if (input.name !== undefined && !usedNameForLookup) updateFields.name = input.name;
146+
if (input.description !== undefined) updateFields.description = input.description;
147+
if (input.schedule !== undefined) updateFields.schedule = input.schedule;
148+
if (input.task !== undefined) updateFields.task = input.task;
149+
if (input.delivery !== undefined) updateFields.delivery = input.delivery;
150+
if (input.enabled !== undefined) updateFields.enabled = input.enabled;
151+
152+
const updated = scheduler.updateJob(targetId, updateFields);
153+
if (!updated) return err(`Job not found: ${targetId}`);
154+
155+
return ok({
156+
updated: true,
157+
id: updated.id,
158+
name: updated.name,
159+
schedule: updated.schedule,
160+
nextRunAt: updated.nextRunAt,
161+
delivery: updated.delivery,
162+
enabled: updated.enabled,
163+
});
164+
}
165+
134166
case "run": {
135167
const targetId = input.jobId ?? scheduler.findJobIdByName(input.name);
136168
if (!targetId) return err("Provide jobId or name to run");

src/scheduler/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ export type JobCreateInput = {
7272
createdBy?: string;
7373
};
7474

75+
export type JobUpdateInput = {
76+
name?: string;
77+
description?: string;
78+
schedule?: Schedule;
79+
task?: string;
80+
delivery?: JobDelivery;
81+
enabled?: boolean;
82+
};
83+
7584
export type JobRow = {
7685
id: string;
7786
name: string;

0 commit comments

Comments
 (0)