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
+51
View File
@@ -272,6 +272,12 @@ export class StateStore {
if (this.state.tokenStats) {
parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`);
}
if (this.state.scheduledJobs) {
parts.push(`"scheduledJobs":${JSON.stringify(this.state.scheduledJobs)}`);
}
if (this.state.scheduledJobRuns) {
parts.push(`"scheduledJobRuns":${JSON.stringify(this.state.scheduledJobRuns)}`);
}
return `{${parts.join(',')}}`;
}
@@ -568,6 +574,51 @@ export class StateStore {
this.save();
}
// ========== Scheduled Job Methods (cron-style scheduler) ==========
/** Returns all scheduled jobs keyed by job ID. */
getScheduledJobs(): Record<string, import('./types/scheduler.js').ScheduledJob> {
if (!this.state.scheduledJobs) this.state.scheduledJobs = {};
return this.state.scheduledJobs;
}
/** Returns a scheduled job by ID, or null if not found. */
getScheduledJob(id: string): import('./types/scheduler.js').ScheduledJob | null {
return this.state.scheduledJobs?.[id] ?? null;
}
/** Sets a scheduled job and triggers a debounced save. */
setScheduledJob(id: string, job: import('./types/scheduler.js').ScheduledJob): void {
if (!this.state.scheduledJobs) this.state.scheduledJobs = {};
this.state.scheduledJobs[id] = job;
this.save();
}
/** Removes a scheduled job and triggers a debounced save. */
removeScheduledJob(id: string): void {
if (this.state.scheduledJobs) delete this.state.scheduledJobs[id];
this.save();
}
/** Returns all scheduled job runs keyed by run ID. */
getScheduledJobRuns(): Record<string, import('./types/scheduler.js').ScheduledJobRun> {
if (!this.state.scheduledJobRuns) this.state.scheduledJobRuns = {};
return this.state.scheduledJobRuns;
}
/** Sets a scheduled job run (history record) and triggers a debounced save. */
setScheduledJobRun(id: string, run: import('./types/scheduler.js').ScheduledJobRun): void {
if (!this.state.scheduledJobRuns) this.state.scheduledJobRuns = {};
this.state.scheduledJobRuns[id] = run;
this.save();
}
/** Removes a scheduled job run and triggers a debounced save. */
removeScheduledJobRun(id: string): void {
if (this.state.scheduledJobRuns) delete this.state.scheduledJobRuns[id];
this.save();
}
/** Returns the application configuration. */
getConfig() {
return this.state.config;