refactor(cron): rename scheduler feature to cron

Rename the recurring-jobs feature scheduler->cron to disambiguate from the
legacy ScheduledRun system (/api/scheduled), which is left untouched:

- ScheduledJob->CronJob, SchedulerService->CronService
- /api/scheduler/jobs -> /api/cron/jobs; SSE scheduler:* -> cron:*
- state keys cronJobs/cronJobRuns
- files moved to src/cron/, cron-routes.ts, cron-port.ts, types/cron.ts
- frontend cron-ui.js, #cronModal, menu "Cron"
- docs moved to docs/cron-discovery.md + docs/cron-build-brief.md, README guides
- new tests: cron-service.test.ts, cron-time.test.ts

Green: tsc, lint, frontend-syntax, format, 30 cron + 9 legacy scheduled-runs tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
This commit is contained in:
Kris
2026-06-29 11:35:56 +05:30
co-authored by Claude Opus 4.8
parent 2d2f4e592b
commit 9feaa0d6e5
27 changed files with 1383 additions and 370 deletions
+4 -4
View File
@@ -166,10 +166,10 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'],
// Scheduled jobs (cron-style scheduler)
[SSE_EVENTS.SCHEDULER_JOBS_CHANGED, '_onSchedulerJobsChanged'],
[SSE_EVENTS.SCHEDULER_JOB_DELETED, '_onSchedulerJobsChanged'],
[SSE_EVENTS.SCHEDULER_RUN_CREATED, '_onSchedulerRunChanged'],
[SSE_EVENTS.SCHEDULER_RUN_UPDATED, '_onSchedulerRunChanged'],
[SSE_EVENTS.CRON_JOBS_CHANGED, '_onCronJobsChanged'],
[SSE_EVENTS.CRON_JOB_DELETED, '_onCronJobsChanged'],
[SSE_EVENTS.CRON_RUN_CREATED, '_onCronRunChanged'],
[SSE_EVENTS.CRON_RUN_UPDATED, '_onCronRunChanged'],
// Respawn
[SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'],
+5 -5
View File
@@ -276,11 +276,11 @@ const SSE_EVENTS = {
SCHEDULED_LOG: 'scheduled:log',
SCHEDULED_DELETED: 'scheduled:deleted',
// Scheduled jobs (cron-style scheduler)
SCHEDULER_JOBS_CHANGED: 'scheduler:jobsChanged',
SCHEDULER_JOB_DELETED: 'scheduler:jobDeleted',
SCHEDULER_RUN_CREATED: 'scheduler:runCreated',
SCHEDULER_RUN_UPDATED: 'scheduler:runUpdated',
// Cron jobs
CRON_JOBS_CHANGED: 'cron:jobsChanged',
CRON_JOB_DELETED: 'cron:jobDeleted',
CRON_RUN_CREATED: 'cron:runCreated',
CRON_RUN_UPDATED: 'cron:runUpdated',
// Respawn
RESPAWN_STARTED: 'respawn:started',
@@ -1,7 +1,7 @@
/**
* @fileoverview Scheduled Jobs UI (cron-style scheduler) mixed into
* @fileoverview Cron Jobs UI mixed into
* CodemanApp.prototype. Renders the job list + create/edit form in the
* #schedulerModal, and reacts to scheduler:* SSE events.
* #cronModal, and reacts to cron:* SSE events.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js, api-client.js, constants.js (escapeHtml)
@@ -10,54 +10,54 @@
Object.assign(CodemanApp.prototype, {
// ── SSE handlers ──────────────────────────────────────────────────────────
_onSchedulerJobsChanged(data) {
_onCronJobsChanged(data) {
if (data && Array.isArray(data.jobs)) {
this._schedulerJobs = data.jobs;
if (this._isSchedulerOpen()) this.renderSchedulerJobs();
} else if (this._isSchedulerOpen()) {
this.refreshScheduler();
this._cronJobs = data.jobs;
if (this._isCronOpen()) this.renderCronJobs();
} else if (this._isCronOpen()) {
this.refreshCron();
}
},
_onSchedulerRunChanged() {
_onCronRunChanged() {
// A run's status changed — refresh the list so lastStatus stays current.
if (this._isSchedulerOpen()) this.refreshScheduler();
if (this._isCronOpen()) this.refreshCron();
},
// ── Modal open/close ──────────────────────────────────────────────────────
_isSchedulerOpen() {
const el = document.getElementById('schedulerModal');
_isCronOpen() {
const el = document.getElementById('cronModal');
return !!el && el.classList.contains('active');
},
openScheduler() {
const el = document.getElementById('schedulerModal');
openCron() {
const el = document.getElementById('cronModal');
if (!el) return;
el.classList.add('active');
this.cancelSchedulerJobForm();
this.refreshScheduler();
this.cancelCronJobForm();
this.refreshCron();
},
closeScheduler() {
const el = document.getElementById('schedulerModal');
closeCron() {
const el = document.getElementById('cronModal');
if (el) el.classList.remove('active');
},
async refreshScheduler() {
const jobs = await this._apiJson('/api/scheduler/jobs');
this._schedulerJobs = Array.isArray(jobs) ? jobs : [];
this.renderSchedulerJobs();
async refreshCron() {
const jobs = await this._apiJson('/api/cron/jobs');
this._cronJobs = Array.isArray(jobs) ? jobs : [];
this.renderCronJobs();
},
// ── List rendering ────────────────────────────────────────────────────────
renderSchedulerJobs() {
const list = document.getElementById('schedulerJobList');
renderCronJobs() {
const list = document.getElementById('cronJobList');
if (!list) return;
const jobs = this._schedulerJobs || [];
const jobs = this._cronJobs || [];
if (jobs.length === 0) {
list.innerHTML = '<div class="form-hint">No scheduled jobs yet. Click “+ New Job”.</div>';
list.innerHTML = '<div class="form-hint">No cron jobs yet. Click “+ New Job”.</div>';
return;
}
const rows = jobs.map((j) => {
@@ -65,23 +65,23 @@ Object.assign(CodemanApp.prototype, {
const last = this._fmtTime(j.lastRunAt);
const status = j.lastStatus ? escapeHtml(j.lastStatus) : '—';
return `
<div class="scheduler-job-row">
<div class="scheduler-job-main">
<div class="scheduler-job-name">${escapeHtml(j.name || '(unnamed)')}
<span class="scheduler-badge">${escapeHtml(j.agentType)}</span>
<span class="scheduler-badge">${escapeHtml(this._fmtSchedule(j))}</span>
${j.enabled ? '' : '<span class="scheduler-badge scheduler-badge-off">disabled</span>'}
<div class="cron-job-row">
<div class="cron-job-main">
<div class="cron-job-name">${escapeHtml(j.name || '(unnamed)')}
<span class="cron-badge">${escapeHtml(j.agentType)}</span>
<span class="cron-badge">${escapeHtml(this._fmtSchedule(j))}</span>
${j.enabled ? '' : '<span class="cron-badge cron-badge-off">disabled</span>'}
</div>
<div class="scheduler-job-meta">
<div class="cron-job-meta">
<span title="${escapeHtml(j.workingDir || '')}">${escapeHtml(j.workingDir || '')}</span>
· next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status}
</div>
</div>
<div class="scheduler-job-actions">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.runSchedulerJob('${j.id}')">Run Now</button>
<button class="btn-toolbar btn-sm" onclick="app.toggleSchedulerJob('${j.id}', ${j.enabled ? 'false' : 'true'})">${j.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn-toolbar btn-sm" onclick="app.editSchedulerJob('${j.id}')">Edit</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="app.deleteSchedulerJob('${j.id}')">Delete</button>
<div class="cron-job-actions">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.runCronJob('${j.id}')">Run Now</button>
<button class="btn-toolbar btn-sm" onclick="app.toggleCronJob('${j.id}', ${j.enabled ? 'false' : 'true'})">${j.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn-toolbar btn-sm" onclick="app.editCronJob('${j.id}')">Edit</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="app.deleteCronJob('${j.id}')">Delete</button>
</div>
</div>`;
});
@@ -117,11 +117,11 @@ Object.assign(CodemanApp.prototype, {
// ── Create / edit form ────────────────────────────────────────────────────
openSchedulerJobForm(job) {
const form = document.getElementById('schedulerJobForm');
openCronJobForm(job) {
const form = document.getElementById('cronJobForm');
if (!form) return;
document.getElementById('schedulerFormError').textContent = '';
document.getElementById('schedulerFormTitle').textContent = job ? 'Edit Scheduled Job' : 'New Scheduled Job';
document.getElementById('cronFormError').textContent = '';
document.getElementById('cronFormTitle').textContent = job ? 'Edit Cron Job' : 'New Cron Job';
document.getElementById('schJobId').value = job ? job.id : '';
document.getElementById('schName').value = job ? job.name || '' : '';
document.getElementById('schAgentType').value = job ? job.agentType || 'claude' : 'claude';
@@ -143,28 +143,28 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('schEnabled').checked = job ? !!job.enabled : true;
document.getElementById('schNotes').value = job ? job.notes || '' : '';
this.onSchedulerPromptModeChange();
this.onSchedulerScheduleTypeChange();
this.onCronPromptModeChange();
this.onCronScheduleTypeChange();
form.classList.remove('hidden');
},
editSchedulerJob(id) {
const job = (this._schedulerJobs || []).find((j) => j.id === id);
if (job) this.openSchedulerJobForm(job);
editCronJob(id) {
const job = (this._cronJobs || []).find((j) => j.id === id);
if (job) this.openCronJobForm(job);
},
cancelSchedulerJobForm() {
const form = document.getElementById('schedulerJobForm');
cancelCronJobForm() {
const form = document.getElementById('cronJobForm');
if (form) form.classList.add('hidden');
},
onSchedulerPromptModeChange() {
onCronPromptModeChange() {
const mode = document.getElementById('schPromptMode').value;
document.getElementById('schPromptTextRow').classList.toggle('hidden', mode !== 'inline_text');
document.getElementById('schPromptFileRow').classList.toggle('hidden', mode !== 'prompt_file_path');
},
onSchedulerScheduleTypeChange() {
onCronScheduleTypeChange() {
const t = document.getElementById('schScheduleType').value;
document.getElementById('schRunAtRow').classList.toggle('hidden', t !== 'once');
document.getElementById('schIntervalRow').classList.toggle('hidden', t !== 'interval');
@@ -180,7 +180,7 @@ Object.assign(CodemanApp.prototype, {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
},
_collectSchedulerForm() {
_collectCronForm() {
const t = document.getElementById('schScheduleType').value;
const promptMode = document.getElementById('schPromptMode').value;
const body = {
@@ -213,10 +213,10 @@ Object.assign(CodemanApp.prototype, {
return body;
},
async saveSchedulerJob() {
const errEl = document.getElementById('schedulerFormError');
async saveCronJob() {
const errEl = document.getElementById('cronFormError');
errEl.textContent = '';
const body = this._collectSchedulerForm();
const body = this._collectCronForm();
if (!body.name) {
errEl.textContent = 'Name is required.';
return;
@@ -226,9 +226,7 @@ Object.assign(CodemanApp.prototype, {
return;
}
const id = document.getElementById('schJobId').value;
const res = id
? await this._apiPut(`/api/scheduler/jobs/${id}`, body)
: await this._apiPost('/api/scheduler/jobs', body);
const res = id ? await this._apiPut(`/api/cron/jobs/${id}`, body) : await this._apiPost('/api/cron/jobs', body);
if (!res || !res.ok) {
let msg = 'Failed to save job.';
try {
@@ -240,22 +238,22 @@ Object.assign(CodemanApp.prototype, {
errEl.textContent = msg;
return;
}
this.showToast?.(id ? 'Scheduled job updated' : 'Scheduled job created', 'success');
this.cancelSchedulerJobForm();
this.refreshScheduler();
this.showToast?.(id ? 'Cron job updated' : 'Cron job created', 'success');
this.cancelCronJobForm();
this.refreshCron();
},
// ── Actions ───────────────────────────────────────────────────────────────
async runSchedulerJob(id) {
const job = (this._schedulerJobs || []).find((j) => j.id === id);
async runCronJob(id) {
const job = (this._cronJobs || []).find((j) => j.id === id);
if (job) {
const active = this._countActiveAgents(job.agentType);
if (active > 0 && !confirm(`${active} ${job.agentType} session(s) already active. Run this job anyway?`)) {
return;
}
}
const res = await this._apiPost(`/api/scheduler/jobs/${id}/run`, {});
const res = await this._apiPost(`/api/cron/jobs/${id}/run`, {});
if (res && res.ok) {
this.showToast?.('Run started — opening session', 'success');
let data = null;
@@ -265,17 +263,17 @@ Object.assign(CodemanApp.prototype, {
/* ignore */
}
const run = data && (data.data ? data.data.run : data.run);
if (run && run.sessionId) this._focusScheduledSession(run.sessionId);
this.refreshScheduler();
if (run && run.sessionId) this._focusCronSession(run.sessionId);
this.refreshCron();
} else {
this.showToast?.('Failed to run job', 'error');
}
},
_focusScheduledSession(sessionId) {
_focusCronSession(sessionId) {
// Best-effort: switch to the created session tab if it exists.
if (this.sessions && this.sessions.has(sessionId) && typeof this.switchSession === 'function') {
this.closeScheduler();
this.closeCron();
this.switchSession(sessionId);
}
},
@@ -287,18 +285,18 @@ Object.assign(CodemanApp.prototype, {
return n;
},
async toggleSchedulerJob(id, enabled) {
const res = await this._apiPut(`/api/scheduler/jobs/${id}/enabled`, { enabled });
if (res && res.ok) this.refreshScheduler();
async toggleCronJob(id, enabled) {
const res = await this._apiPut(`/api/cron/jobs/${id}/enabled`, { enabled });
if (res && res.ok) this.refreshCron();
else this.showToast?.('Failed to update job', 'error');
},
async deleteSchedulerJob(id) {
if (!confirm('Delete this scheduled job and its run history?')) return;
const res = await this._apiDelete(`/api/scheduler/jobs/${id}`);
async deleteCronJob(id) {
if (!confirm('Delete this cron job and its run history?')) return;
const res = await this._apiDelete(`/api/cron/jobs/${id}`);
if (res && res.ok) {
this.showToast?.('Scheduled job deleted', 'success');
this.refreshScheduler();
this.showToast?.('Cron job deleted', 'success');
this.refreshCron();
} else {
this.showToast?.('Failed to delete job', 'error');
}
+18 -18
View File
@@ -541,7 +541,7 @@
<div class="toolbar-right">
<!-- Orchestrator button hidden until feature is ready -->
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">&#x2699; Orchestrator</button> -->
<button class="btn-toolbar btn-sm" onclick="app.openScheduler()" title="Scheduled Jobs">&#x23F0; Schedules</button>
<button class="btn-toolbar btn-sm" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -571,26 +571,26 @@
</div>
</div>
<!-- Scheduled Jobs Modal (cron-style scheduler) -->
<div class="modal" id="schedulerModal">
<div class="modal-backdrop" onclick="app.closeScheduler()"></div>
<!-- Cron Jobs Modal -->
<div class="modal" id="cronModal">
<div class="modal-backdrop" onclick="app.closeCron()"></div>
<div class="modal-content modal-lg">
<div class="modal-header">
<h3>Scheduled Jobs</h3>
<button class="modal-close" onclick="app.closeScheduler()" aria-label="Close scheduler">&times;</button>
<h3>Cron Jobs</h3>
<button class="modal-close" onclick="app.closeCron()" aria-label="Close cron">&times;</button>
</div>
<div class="modal-body">
<p class="form-hint" style="margin-bottom:10px;">Times use the server's local timezone.</p>
<div style="margin-bottom:10px;">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.openSchedulerJobForm()">+ New Job</button>
<button class="btn-toolbar btn-sm" onclick="app.refreshScheduler()">Refresh</button>
<button class="btn-toolbar btn-sm btn-primary" onclick="app.openCronJobForm()">+ New Job</button>
<button class="btn-toolbar btn-sm" onclick="app.refreshCron()">Refresh</button>
</div>
<!-- Job list -->
<div id="schedulerJobList" class="scheduler-job-list"></div>
<div id="cronJobList" class="cron-job-list"></div>
<!-- Create/Edit form (hidden until New/Edit) -->
<div id="schedulerJobForm" class="scheduler-job-form hidden">
<div class="form-section-header" id="schedulerFormTitle">New Scheduled Job</div>
<div id="cronJobForm" class="cron-job-form hidden">
<div class="form-section-header" id="cronFormTitle">New Cron Job</div>
<input type="hidden" id="schJobId">
<div class="form-row"><label>Name</label><input type="text" id="schName" placeholder="My nightly job"></div>
<div class="form-row"><label>Agent Type</label>
@@ -604,7 +604,7 @@
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
<div class="form-row"><label>Prompt Source</label>
<select id="schPromptMode" onchange="app.onSchedulerPromptModeChange()">
<select id="schPromptMode" onchange="app.onCronPromptModeChange()">
<option value="inline_text">Inline text</option>
<option value="prompt_file_path">Prompt file path</option>
</select>
@@ -618,7 +618,7 @@
</select>
</div>
<div class="form-row"><label>Schedule Type</label>
<select id="schScheduleType" onchange="app.onSchedulerScheduleTypeChange()">
<select id="schScheduleType" onchange="app.onCronScheduleTypeChange()">
<option value="once">Once</option>
<option value="interval">Interval</option>
<option value="daily">Daily</option>
@@ -629,7 +629,7 @@
<div class="form-row hidden" id="schIntervalRow"><label>Every (minutes)</label><input type="number" id="schIntervalMinutes" min="1" value="60"></div>
<div class="form-row hidden" id="schDailyRow"><label>Daily Time (HH:MM)</label><input type="time" id="schDailyTime"></div>
<div class="form-row hidden" id="schWeeklyDaysRow"><label>Weekdays</label>
<span id="schWeeklyDays" class="scheduler-weekdays">
<span id="schWeeklyDays" class="cron-weekdays">
<label><input type="checkbox" value="0">Sun</label>
<label><input type="checkbox" value="1">Mon</label>
<label><input type="checkbox" value="2">Tue</label>
@@ -650,10 +650,10 @@
<label class="switch"><input type="checkbox" id="schEnabled" checked><span class="slider"></span></label>
</div>
<div class="form-row"><label>Notes</label><input type="text" id="schNotes" placeholder="Optional"></div>
<div id="schedulerFormError" class="form-hint" style="color:var(--danger,#e55);"></div>
<div id="cronFormError" class="form-hint" style="color:var(--danger,#e55);"></div>
<div style="margin-top:10px;">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.saveSchedulerJob()">Save</button>
<button class="btn-toolbar btn-sm" onclick="app.cancelSchedulerJobForm()">Cancel</button>
<button class="btn-toolbar btn-sm btn-primary" onclick="app.saveCronJob()">Save</button>
<button class="btn-toolbar btn-sm" onclick="app.cancelCronJobForm()">Cancel</button>
</div>
</div>
</div>
@@ -2146,7 +2146,7 @@
<script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script>
<script defer src="orchestrator-panel.js"></script>
<script defer src="scheduler-ui.js"></script>
<script defer src="cron-ui.js"></script>
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
+12 -12
View File
@@ -11072,24 +11072,24 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5);
}
/* ── Scheduled Jobs (cron-style scheduler) ───────────────────────────────── */
.scheduler-job-list { display: flex; flex-direction: column; gap: 8px; }
.scheduler-job-row {
/* ── Cron Jobs ───────────────────────────────── */
.cron-job-list { display: flex; flex-direction: column; gap: 8px; }
.cron-job-row {
display: flex; justify-content: space-between; align-items: center; gap: 12px;
padding: 10px 12px; border: 1px solid var(--border, #333); border-radius: 8px;
background: var(--panel-bg, rgba(255,255,255,0.02));
}
.scheduler-job-main { min-width: 0; flex: 1; }
.scheduler-job-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.scheduler-job-meta { font-size: 12px; opacity: 0.7; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; }
.scheduler-job-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.scheduler-badge {
.cron-job-main { min-width: 0; flex: 1; }
.cron-job-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.cron-job-meta { font-size: 12px; opacity: 0.7; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; }
.cron-job-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.cron-badge {
font-size: 11px; padding: 1px 6px; border-radius: 10px;
background: var(--accent-bg, rgba(120,160,255,0.15)); opacity: 0.9;
}
.scheduler-badge-off { background: rgba(200,80,80,0.18); }
.scheduler-weekdays { display: flex; gap: 10px; flex-wrap: wrap; }
.scheduler-weekdays label { display: inline-flex; align-items: center; gap: 3px; font-weight: 400; }
.scheduler-job-form {
.cron-badge-off { background: rgba(200,80,80,0.18); }
.cron-weekdays { display: flex; gap: 10px; flex-wrap: wrap; }
.cron-weekdays label { display: inline-flex; align-items: center; gap: 3px; font-weight: 400; }
.cron-job-form {
margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border, #333);
}