Skip to content

Commit 0bf35ec

Browse files
committed
scheduler: add phantom_schedule update action so editing a job preserves run history (#86)
Adds `action: "update"` to phantom_schedule. The caller can change `task`, `description`, `schedule`, `delivery`, or `enabled` on an existing job by jobId or name. The history columns (last_run_at, last_run_status, last_run_duration_ms, last_run_error, run_count, consecutive_errors, created_at) and the stable jobId are preserved. If schedule changes, next_run_at is recomputed via the same computeNextRunAt path resumeJob uses, then armTimer() so an earlier next-fire wakes the timer in time. Closes #86.
1 parent ff74713 commit 0bf35ec

5 files changed

Lines changed: 273 additions & 10 deletions

File tree

src/scheduler/__tests__/service.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,97 @@ describe("Scheduler", () => {
444444
expect(row.enabled).toBe(0);
445445
});
446446

447+
test("updateJob changes task in place and preserves run history", async () => {
448+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
449+
const job = scheduler.createJob({
450+
name: "TaskEditable",
451+
schedule: { kind: "every", intervalMs: 60_000 },
452+
task: "Original task text",
453+
});
454+
// Run once so run_count and last_run_at are populated. The point of
455+
// the update path is that this history survives a task edit.
456+
await scheduler.runJobNow(job.id);
457+
const before = scheduler.getJob(job.id);
458+
expect(before?.runCount).toBe(1);
459+
expect(before?.lastRunAt).toBeTruthy();
460+
461+
const updated = scheduler.updateJob(job.id, { task: "New task text" });
462+
expect(updated?.task).toBe("New task text");
463+
expect(updated?.runCount).toBe(1);
464+
expect(updated?.lastRunAt).toBe(before?.lastRunAt ?? null);
465+
expect(updated?.id).toBe(job.id);
466+
});
467+
468+
test("updateJob recomputes next_run_at when schedule changes", () => {
469+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
470+
const job = scheduler.createJob({
471+
name: "ScheduleEditable",
472+
schedule: { kind: "every", intervalMs: 24 * 60 * 60 * 1000 },
473+
task: "Daily",
474+
});
475+
const originalNext = job.nextRunAt ? new Date(job.nextRunAt).getTime() : 0;
476+
477+
const updated = scheduler.updateJob(job.id, {
478+
schedule: { kind: "every", intervalMs: 60_000 },
479+
});
480+
expect(updated?.schedule).toEqual({ kind: "every", intervalMs: 60_000 });
481+
const newNext = updated?.nextRunAt ? new Date(updated.nextRunAt).getTime() : 0;
482+
expect(newNext).toBeLessThan(originalNext);
483+
expect(newNext).toBeGreaterThan(Date.now() - 5_000);
484+
expect(newNext).toBeLessThan(Date.now() + 120_000);
485+
});
486+
487+
test("updateJob can flip enabled to false and back to true", () => {
488+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
489+
const job = scheduler.createJob({
490+
name: "EnabledToggle",
491+
schedule: { kind: "every", intervalMs: 60_000 },
492+
task: "Toggle me",
493+
});
494+
expect(job.enabled).toBe(true);
495+
496+
const disabled = scheduler.updateJob(job.id, { enabled: false });
497+
expect(disabled?.enabled).toBe(false);
498+
499+
const enabled = scheduler.updateJob(job.id, { enabled: true });
500+
expect(enabled?.enabled).toBe(true);
501+
});
502+
503+
test("updateJob can change delivery target", () => {
504+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
505+
const job = scheduler.createJob({
506+
name: "DeliveryEditable",
507+
schedule: { kind: "every", intervalMs: 60_000 },
508+
task: "Deliver",
509+
});
510+
expect(job.delivery).toEqual({ channel: "slack", target: "owner" });
511+
512+
const updated = scheduler.updateJob(job.id, {
513+
delivery: { channel: "slack", target: "C04ABC123" },
514+
});
515+
expect(updated?.delivery).toEqual({ channel: "slack", target: "C04ABC123" });
516+
});
517+
518+
test("updateJob rejects an invalid slack target", () => {
519+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
520+
const job = scheduler.createJob({
521+
name: "BadTargetReject",
522+
schedule: { kind: "every", intervalMs: 60_000 },
523+
task: "Try bad target",
524+
});
525+
expect(() =>
526+
scheduler.updateJob(job.id, {
527+
delivery: { channel: "slack", target: "#general" },
528+
}),
529+
).toThrow(/invalid delivery.target/);
530+
});
531+
532+
test("updateJob returns null for unknown id", () => {
533+
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
534+
const result = scheduler.updateJob("does-not-exist", { task: "anything" });
535+
expect(result).toBeNull();
536+
});
537+
447538
test("paused job is excluded from the armTimer MIN query", async () => {
448539
const scheduler = new Scheduler({ db, runtime: mockRuntime as never });
449540
await scheduler.start();

src/scheduler/__tests__/tool.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
22
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
33
import { runMigrations } from "../../db/migrate.ts";
44
import { Scheduler } from "../service.ts";
5+
import { JobUpdateInputSchema } from "../tool-schema.ts";
56
import { createSchedulerToolServer } from "../tool.ts";
67

78
function createMockRuntime() {
@@ -106,4 +107,28 @@ describe("createSchedulerToolServer", () => {
106107
expect(mcpServers["phantom-scheduler"].type).toBe("sdk");
107108
expect(mcpServers["phantom-scheduler"].name).toBe("phantom-scheduler");
108109
});
110+
111+
test("update action via scheduler edits a job by name", () => {
112+
const job = scheduler.createJob({
113+
name: "Editable",
114+
schedule: { kind: "every", intervalMs: 60_000 },
115+
task: "Original",
116+
});
117+
const targetId = scheduler.findJobIdByName("Editable");
118+
expect(targetId).toBe(job.id);
119+
120+
const updated = scheduler.updateJob(targetId as string, { task: "Edited" });
121+
expect(updated?.task).toBe("Edited");
122+
expect(updated?.id).toBe(job.id);
123+
});
124+
125+
test("JobUpdateInputSchema rejects an empty partial", () => {
126+
const result = JobUpdateInputSchema.safeParse({});
127+
expect(result.success).toBe(false);
128+
});
129+
130+
test("JobUpdateInputSchema accepts a single-field partial", () => {
131+
const result = JobUpdateInputSchema.safeParse({ task: "Just the task" });
132+
expect(result.success).toBe(true);
133+
});
109134
});

src/scheduler/service.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ 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 type { JobUpdateInputParsed } from "./tool-schema.ts";
12+
import { type JobCreateInput, type JobRow, type ScheduledJob, isValidSlackTarget } from "./types.ts";
1213

1314
// Upper bound on the setTimeout delay we pass when arming the next wake-up.
1415
// Both Node and Bun use a 32-bit signed integer for the setTimeout delay, so
@@ -180,6 +181,82 @@ export class Scheduler {
180181
return this.getJob(id);
181182
}
182183

184+
/**
185+
* Edit a job's user-authored columns in place. History is preserved:
186+
* last_run_at, last_run_status, run_count, consecutive_errors, created_at,
187+
* and the stable `id` are never touched. The caller chooses which subset
188+
* of {task, description, schedule, delivery, enabled} to change; the
189+
* refine() on JobUpdateInputSchema enforces that at least one is present,
190+
* so an empty partial here is treated as a no-op (defense in depth).
191+
*
192+
* When `schedule` changes we recompute next_run_at via the same
193+
* computeNextRunAt path resumeJob uses, then armTimer() so a schedule
194+
* that pulls the next fire earlier wakes the timer in time. Returns the
195+
* fresh row, or null if `id` does not exist.
196+
*/
197+
updateJob(id: string, partial: JobUpdateInputParsed): ScheduledJob | null {
198+
const job = this.getJob(id);
199+
if (!job) return null;
200+
201+
const sets: string[] = [];
202+
const params: Array<string | number | null> = [];
203+
204+
if (partial.task !== undefined) {
205+
sets.push("task = ?");
206+
params.push(partial.task);
207+
}
208+
209+
if (partial.description !== undefined) {
210+
sets.push("description = ?");
211+
params.push(partial.description);
212+
}
213+
214+
if (partial.delivery !== undefined) {
215+
// Mirror create-validation's slack-target check so an update cannot
216+
// install a malformed target the create path would have rejected.
217+
if (partial.delivery.channel === "slack" && !isValidSlackTarget(partial.delivery.target)) {
218+
throw new Error(
219+
`invalid delivery.target '${partial.delivery.target}': must be "owner", a Slack channel id (C...), or a Slack user id (U...)`,
220+
);
221+
}
222+
sets.push("delivery_channel = ?");
223+
params.push(partial.delivery.channel);
224+
sets.push("delivery_target = ?");
225+
params.push(partial.delivery.target);
226+
}
227+
228+
if (partial.enabled !== undefined) {
229+
sets.push("enabled = ?");
230+
params.push(partial.enabled ? 1 : 0);
231+
}
232+
233+
if (partial.schedule !== undefined) {
234+
const scheduleError = validateSchedule(partial.schedule);
235+
if (scheduleError) {
236+
throw new Error(`invalid schedule: ${scheduleError}`);
237+
}
238+
const nextRun = computeNextRunAt(partial.schedule);
239+
const nextRunIso = nextRun ? nextRun.toISOString() : null;
240+
sets.push("schedule_kind = ?");
241+
params.push(partial.schedule.kind);
242+
sets.push("schedule_value = ?");
243+
params.push(serializeScheduleValue(partial.schedule));
244+
sets.push("next_run_at = ?");
245+
params.push(nextRunIso);
246+
}
247+
248+
if (sets.length === 0) {
249+
return job;
250+
}
251+
252+
sets.push("updated_at = datetime('now')");
253+
params.push(id);
254+
255+
this.db.run(`UPDATE scheduled_jobs SET ${sets.join(", ")} WHERE id = ?`, params);
256+
this.armTimer();
257+
return this.getJob(id);
258+
}
259+
183260
/**
184261
* Defensive read: one corrupt row (a future kind, a truncated write) must
185262
* not brick the whole list. Bad rows are logged and skipped. See M8.

src/scheduler/tool-schema.ts

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

3131
export type JobCreateInputParsed = z.infer<typeof JobCreateInputSchema>;
32+
33+
// Partial-update shape for Scheduler.updateJob. All fields optional with a
34+
// refine() that rejects an empty object so the caller cannot silently no-op.
35+
// Excludes identity columns (name, createdBy) and history columns
36+
// (last_run_*, run_count, consecutive_errors, created_at): name is the
37+
// human-readable lookup handle and renaming mid-life breaks cross-references,
38+
// history is owned by the executor and would erase the run trail an update
39+
// is supposed to preserve.
40+
export const JobUpdateInputSchema = z
41+
.object({
42+
description: z.string().max(1000).optional(),
43+
schedule: ScheduleInputSchema.optional(),
44+
task: z
45+
.string()
46+
.min(1)
47+
.max(32 * 1024)
48+
.optional(),
49+
delivery: JobDeliverySchema.optional(),
50+
enabled: z.boolean().optional(),
51+
})
52+
.refine(
53+
(v) =>
54+
v.description !== undefined ||
55+
v.schedule !== undefined ||
56+
v.task !== undefined ||
57+
v.delivery !== undefined ||
58+
v.enabled !== undefined,
59+
{ message: "update requires at least one of: description, schedule, task, delivery, enabled" },
60+
);
61+
62+
export type JobUpdateInputParsed = z.infer<typeof JobUpdateInputSchema>;

src/scheduler/tool.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
22
import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk";
33
import { z } from "zod";
44
import type { Scheduler } from "./service.ts";
5-
import { ScheduleInputSchema } from "./tool-schema.ts";
5+
import { JobUpdateInputSchema, ScheduleInputSchema } from "./tool-schema.ts";
66
import { JobDeliverySchema } from "./types.ts";
77

88
function ok(data: Record<string, unknown>): { content: Array<{ type: "text"; text: string }> } {
@@ -13,11 +13,12 @@ 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, update, delete, 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.
21+
- update: Edit task, description, schedule, delivery, or enabled on an existing job by jobId or name. Run history (last_run_at, run_count, consecutive_errors) and the stable jobId are preserved. If schedule changes, next_run_at is recomputed. Requires at least one field; name is not editable.
2122
- delete: Remove a scheduled task by jobId or by name (case insensitive).
2223
- run: Trigger a task immediately. Only runs when status is active and no other job is currently executing. Returns the task output.
2324
@@ -60,19 +61,25 @@ export function createSchedulerToolServer(scheduler: Scheduler): McpSdkServerCon
6061
TOOL_DESCRIPTION,
6162
{
6263
action: z
63-
.enum(["create", "list", "delete", "run"])
64+
.enum(["create", "list", "update", "delete", "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. update: edit user-authored fields by jobId or name (preserves run history). delete: remove by jobId or name. run: trigger immediately (only when status=active and scheduler is idle).",
6667
),
67-
name: z.string().optional().describe("Job name (required for create)"),
68+
name: z.string().optional().describe("Job name (required for create; lookup key for update, delete, run)"),
6869
description: z.string().optional().describe("Job description"),
69-
schedule: ScheduleInputSchema.optional().describe("Schedule definition (required for create)"),
70+
schedule: ScheduleInputSchema.optional().describe(
71+
"Schedule definition (required for create, optional for update)",
72+
),
7073
task: z
7174
.string()
7275
.optional()
73-
.describe("The prompt for the agent when the job fires (required for create, 32 KB max)"),
76+
.describe("The prompt for the agent when the job fires (required for create, optional for update, 32 KB max)"),
7477
delivery: JobDeliverySchema.optional().describe("Where to deliver results"),
75-
jobId: z.string().optional().describe("Job ID (for delete or run)"),
78+
enabled: z
79+
.boolean()
80+
.optional()
81+
.describe("Whether the job fires (optional for update; pause/resume manage status separately)"),
82+
jobId: z.string().optional().describe("Job ID (for update, delete, or run)"),
7683
},
7784
async (input) => {
7885
try {
@@ -123,6 +130,38 @@ export function createSchedulerToolServer(scheduler: Scheduler): McpSdkServerCon
123130
});
124131
}
125132

133+
case "update": {
134+
const targetId = input.jobId ?? scheduler.findJobIdByName(input.name);
135+
if (!targetId) return err("Provide jobId or name to update");
136+
137+
const partialResult = JobUpdateInputSchema.safeParse({
138+
description: input.description,
139+
schedule: input.schedule,
140+
task: input.task,
141+
delivery: input.delivery,
142+
enabled: input.enabled,
143+
});
144+
if (!partialResult.success) {
145+
return err(partialResult.error.issues.map((i) => i.message).join("; "));
146+
}
147+
148+
const updated = scheduler.updateJob(targetId, partialResult.data);
149+
if (!updated) return err(`Job not found: ${targetId}`);
150+
151+
return ok({
152+
updated: true,
153+
id: updated.id,
154+
name: updated.name,
155+
schedule: updated.schedule,
156+
task: updated.task,
157+
description: updated.description,
158+
delivery: updated.delivery,
159+
enabled: updated.enabled,
160+
nextRunAt: updated.nextRunAt,
161+
runCount: updated.runCount,
162+
});
163+
}
164+
126165
case "delete": {
127166
const targetId = input.jobId ?? scheduler.findJobIdByName(input.name);
128167
if (!targetId) return err("Provide jobId or name to delete");

0 commit comments

Comments
 (0)