feat(scheduler): add cron-style scheduled jobs (backend)

Adds a saved/named scheduling layer on top of Codeman's existing session
primitives. Distinct from the legacy run-now ScheduledRun concept.

- types/scheduler.ts: ScheduledJob + ScheduledJobRun
- state-store: persist scheduledJobs/scheduledJobRuns in ~/.codeman/state.json
- scheduler/scheduler-time.ts: pure once/interval/daily/weekly next-run math
- scheduler/scheduler-service.ts: CRUD, Run Now, due-checker tick, run history;
  reuses SessionPort (create -> start -> writeViaMux) for launches
- web/routes/scheduler-routes.ts: /api/scheduler/jobs CRUD + run + history
- web/schemas.ts: zod validation with schedule-type-aware refinements
- web/sse-events.ts: scheduler:* events
- server.ts: wire service into route context + 30s background tick loop
- test/scheduler-time.test.ts: 14 unit tests for next-run calculations

Phase 1 discovery recorded in SCHEDULER_DISCOVERY.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rp7JhmQXcYJhmxFMdZuuah
This commit is contained in:
Kris
2026-06-27 08:10:40 +05:30
co-authored by Claude Opus 4.8
parent abb6447f66
commit 6ae86b53f6
16 changed files with 1087 additions and 0 deletions
+1
View File
@@ -13,3 +13,4 @@ export type { ConfigPort } from './config-port.js';
export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
export type { SchedulerPort } from './scheduler-port.js';
+10
View File
@@ -0,0 +1,10 @@
/**
* @fileoverview Scheduler port — exposes the cron-style SchedulerService to
* route handlers via the shared route context.
*/
import type { SchedulerService } from '../../scheduler/scheduler-service.js';
export interface SchedulerPort {
readonly scheduler: SchedulerService;
}
+1
View File
@@ -7,6 +7,7 @@ export { registerTeamRoutes } from './team-routes.js';
export { registerMuxRoutes } from './mux-routes.js';
export { registerFileRoutes } from './file-routes.js';
export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerSchedulerRoutes } from './scheduler-routes.js';
export { registerSystemRoutes } from './system-routes.js';
export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
+78
View File
@@ -0,0 +1,78 @@
/**
* @fileoverview Scheduled Jobs routes (cron-style scheduler).
*
* CRUD + enable/disable + Run Now + run history for `ScheduledJob`s. These are
* separate from the legacy `/api/scheduled` (ScheduledRun) endpoints — see
* SCHEDULER_DISCOVERY.md §0.
*/
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { ScheduledJobSchema, ScheduledJobUpdateSchema, ScheduledJobEnabledSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import type { SchedulerPort } from '../ports/index.js';
export function registerSchedulerRoutes(app: FastifyInstance, ctx: SchedulerPort): void {
// ── Jobs ────────────────────────────────────────────────────────────────
app.get('/api/scheduler/jobs', async () => {
return ctx.scheduler.listJobs();
});
app.post('/api/scheduler/jobs', async (req) => {
const body = parseBody(ScheduledJobSchema, req.body, 'Invalid scheduled job');
return { job: ctx.scheduler.createJob(body) };
});
app.get('/api/scheduler/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
const job = ctx.scheduler.getJob(id);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled job not found');
return job;
});
app.put('/api/scheduler/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(ScheduledJobUpdateSchema, req.body, 'Invalid scheduled job update');
const job = ctx.scheduler.updateJob(id, body);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled job not found');
return { job };
});
app.delete('/api/scheduler/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
if (!ctx.scheduler.deleteJob(id)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled job not found');
}
return {};
});
app.put('/api/scheduler/jobs/:id/enabled', async (req) => {
const { id } = req.params as { id: string };
const { enabled } = parseBody(ScheduledJobEnabledSchema, req.body, 'Invalid request body');
const job = ctx.scheduler.setEnabled(id, enabled);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled job not found');
return { job };
});
// ── Run Now ──────────────────────────────────────────────────────────────
app.post('/api/scheduler/jobs/:id/run', async (req) => {
const { id } = req.params as { id: string };
const job = ctx.scheduler.getJob(id);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled job not found');
const run = await ctx.scheduler.runNow(id);
return { run, activeAgents: ctx.scheduler.countActiveAgents(job.agentType) };
});
// ── Run history ──────────────────────────────────────────────────────────
app.get('/api/scheduler/jobs/:id/runs', async (req) => {
const { id } = req.params as { id: string };
return ctx.scheduler.listRuns(id);
});
app.get('/api/scheduler/runs', async () => {
return ctx.scheduler.listRuns();
});
}
+59
View File
@@ -574,6 +574,65 @@ export const ScheduledRunSchema = z.object({
durationMinutes: z.number().int().min(1).max(14400).optional(),
});
// ========== Scheduled Jobs (cron-style scheduler) ==========
/** 'HH:MM' 24-hour time. */
const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)');
/** Shared field shape for creating/updating a scheduled job. */
const ScheduledJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
promptText: z.string().max(100000).optional(),
promptFilePath: safePathSchema.optional(),
inputMode: z.enum(['paste', 'typed']),
scheduleType: z.enum(['once', 'interval', 'daily', 'weekly']),
runAt: z.number().int().positive().optional(),
intervalMinutes: z.number().int().min(1).max(525600).optional(),
dailyTime: hhmmSchema.optional(),
weeklyDays: z.array(z.number().int().min(0).max(6)).min(1).max(7).optional(),
weeklyTime: hhmmSchema.optional(),
enabled: z.boolean(),
notes: z.string().max(2000).optional(),
concurrencyPolicy: z.enum(['warn_only', 'skip_if_same_agent_running']),
});
/** Cross-field validation: required fields depend on promptMode + scheduleType. */
function refineScheduledJob(val: z.infer<typeof ScheduledJobBaseSchema>, ctx: z.RefinementCtx): void {
const add = (message: string, path: string) => ctx.addIssue({ code: 'custom', message, path: [path] });
if (val.promptMode === 'inline_text' && !val.promptText) {
add('promptText is required when promptMode is inline_text', 'promptText');
}
if (val.promptMode === 'prompt_file_path' && !val.promptFilePath) {
add('promptFilePath is required when promptMode is prompt_file_path', 'promptFilePath');
}
if (val.scheduleType === 'once' && val.runAt === undefined) {
add('runAt is required for a one-time schedule', 'runAt');
}
if (val.scheduleType === 'interval' && val.intervalMinutes === undefined) {
add('intervalMinutes is required for an interval schedule', 'intervalMinutes');
}
if (val.scheduleType === 'daily' && !val.dailyTime) {
add('dailyTime is required for a daily schedule', 'dailyTime');
}
if (val.scheduleType === 'weekly' && (!val.weeklyTime || !val.weeklyDays?.length)) {
add('weeklyDays and weeklyTime are required for a weekly schedule', 'weeklyTime');
}
}
/** POST /api/scheduler/jobs — full job definition. */
export const ScheduledJobSchema = ScheduledJobBaseSchema.superRefine(refineScheduledJob);
/** PUT /api/scheduler/jobs/:id — partial update. */
export const ScheduledJobUpdateSchema = ScheduledJobBaseSchema.partial();
/** PUT /api/scheduler/jobs/:id/enabled */
export const ScheduledJobEnabledSchema = z.object({ enabled: z.boolean() });
/** POST /api/cases/link */
export const LinkCaseSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
+23
View File
@@ -152,8 +152,10 @@ import {
registerClipboardRoutes,
registerSearchRoutes,
registerOrchestratorRoutes,
registerSchedulerRoutes,
registerWsRoutes,
} from './routes/index.js';
import { SchedulerService } from '../scheduler/scheduler-service.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -175,6 +177,7 @@ import {
ITERATION_PAUSE_MS,
STATS_COLLECTION_INTERVAL_MS,
INACTIVITY_TIMEOUT_MS,
SCHEDULER_TICK_INTERVAL,
} from '../config/server-timing.js';
/**
@@ -223,6 +226,8 @@ export class WebServer extends EventEmitter {
// Store session listener references for explicit cleanup (prevents memory leaks)
private sessionListenerRefs: Map<string, SessionListenerRefs> = new Map();
private scheduledRuns: Map<string, ScheduledRun> = new Map();
/** Cron-style scheduler service (assigned in setupRoutes). */
private schedulerService!: SchedulerService;
private sse: SseStreamManager;
private store = getStore();
private port: number;
@@ -873,6 +878,13 @@ export class WebServer extends EventEmitter {
registerClipboardRoutes(this.app, ctx);
registerSearchRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
// Cron-style scheduler: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.
this.schedulerService = new SchedulerService(ctx);
this.schedulerService.init();
registerSchedulerRoutes(this.app, { ...ctx, scheduler: this.schedulerService });
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
}
@@ -1947,6 +1959,17 @@ export class WebServer extends EventEmitter {
{ description: 'scheduled runs cleanup' }
);
// Start the cron-style scheduler loop (fires due ScheduledJobs).
this.cleanup.setInterval(
() => {
this.schedulerService.tickDueJobs().catch((err) => {
console.error('[scheduler] tick failed:', getErrorMessage(err));
});
},
SCHEDULER_TICK_INTERVAL,
{ description: 'scheduled jobs due-checker' }
);
// Start SSE client health check timer (prevents memory leaks from dead connections)
this.cleanup.setInterval(
() => {
+17
View File
@@ -240,6 +240,17 @@ export const ScheduledLog = 'scheduled:log' as const;
/** Scheduled run deleted. */
export const ScheduledDeleted = 'scheduled:deleted' as const;
// ─── Scheduled Jobs (cron-style scheduler) ───────────────────────────────────
/** The scheduled-jobs list changed (created/updated/enabled/run-status). Payload: { jobs }. */
export const SchedulerJobsChanged = 'scheduler:jobsChanged' as const;
/** A scheduled job was deleted. Payload: { id }. */
export const SchedulerJobDeleted = 'scheduler:jobDeleted' as const;
/** A scheduled-job run (history record) was created. Payload: ScheduledJobRun. */
export const SchedulerRunCreated = 'scheduler:runCreated' as const;
/** A scheduled-job run (history record) was updated. Payload: ScheduledJobRun. */
export const SchedulerRunUpdated = 'scheduler:runUpdated' as const;
// ─── Teams ───────────────────────────────────────────────────────────────────
/** Agent team created. */
@@ -469,6 +480,12 @@ export const SseEvent = {
ScheduledLog,
ScheduledDeleted,
// Scheduled jobs (cron-style scheduler)
SchedulerJobsChanged,
SchedulerJobDeleted,
SchedulerRunCreated,
SchedulerRunUpdated,
// Teams
TeamCreated,
TeamUpdated,