Skip to content

Commit f77d443

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 f8c7ab4 commit f77d443

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
@@ -1,7 +1,7 @@
11
import { z } from "zod";
22
import { type McpSdkServerConfigWithInstance, createSdkMcpServer, tool } from "../agent/agent-sdk.ts";
33
import type { Scheduler } from "./service.ts";
4-
import { ScheduleInputSchema } from "./tool-schema.ts";
4+
import { JobUpdateInputSchema, ScheduleInputSchema } from "./tool-schema.ts";
55
import { JobDeliverySchema } from "./types.ts";
66

77
function ok(data: Record<string, unknown>): { content: Array<{ type: "text"; text: string }> } {
@@ -12,11 +12,12 @@ function err(message: string): { content: Array<{ type: "text"; text: string }>;
1212
return { content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }], isError: true };
1313
}
1414

15-
const TOOL_DESCRIPTION = `Create, list, delete, or trigger scheduled tasks. Lets you set up recurring jobs, one-shot reminders, and automated reports.
15+
const TOOL_DESCRIPTION = `Create, list, update, delete, or trigger scheduled tasks. Lets you set up recurring jobs, one-shot reminders, and automated reports.
1616
1717
Actions:
1818
- 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...).
1919
- list: List all scheduled tasks with status and next run time. Corrupt rows are logged and skipped.
20+
- 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.
2021
- delete: Remove a scheduled task by jobId or by name (case insensitive).
2122
- run: Trigger a task immediately. Only runs when status is active and no other job is currently executing. Returns the task output.
2223
@@ -59,19 +60,25 @@ export function createSchedulerToolServer(scheduler: Scheduler): McpSdkServerCon
5960
TOOL_DESCRIPTION,
6061
{
6162
action: z
62-
.enum(["create", "list", "delete", "run"])
63+
.enum(["create", "list", "update", "delete", "run"])
6364
.describe(
64-
"create: new scheduled task. list: enumerate tasks. delete: remove by jobId or name. run: trigger immediately (only when status=active and scheduler is idle).",
65+
"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).",
6566
),
66-
name: z.string().optional().describe("Job name (required for create)"),
67+
name: z.string().optional().describe("Job name (required for create; lookup key for update, delete, run)"),
6768
description: z.string().optional().describe("Job description"),
68-
schedule: ScheduleInputSchema.optional().describe("Schedule definition (required for create)"),
69+
schedule: ScheduleInputSchema.optional().describe(
70+
"Schedule definition (required for create, optional for update)",
71+
),
6972
task: z
7073
.string()
7174
.optional()
72-
.describe("The prompt for the agent when the job fires (required for create, 32 KB max)"),
75+
.describe("The prompt for the agent when the job fires (required for create, optional for update, 32 KB max)"),
7376
delivery: JobDeliverySchema.optional().describe("Where to deliver results"),
74-
jobId: z.string().optional().describe("Job ID (for delete or run)"),
77+
enabled: z
78+
.boolean()
79+
.optional()
80+
.describe("Whether the job fires (optional for update; pause/resume manage status separately)"),
81+
jobId: z.string().optional().describe("Job ID (for update, delete, or run)"),
7582
},
7683
async (input) => {
7784
try {
@@ -122,6 +129,38 @@ export function createSchedulerToolServer(scheduler: Scheduler): McpSdkServerCon
122129
});
123130
}
124131

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

0 commit comments

Comments
 (0)