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
@@ -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();
});
}