From 6ae86b53f6071626fa506f3dbaedd0913a56f47e Mon Sep 17 00:00:00 2001 From: Kris Date: Sat, 27 Jun 2026 08:10:40 +0530 Subject: [PATCH 1/7] 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 Claude-Session: https://claude.ai/code/session_01Rp7JhmQXcYJhmxFMdZuuah --- SCHEDULER_DISCOVERY.md | 138 +++++++++++ src/config/server-timing.ts | 13 ++ src/scheduler/scheduler-input.ts | 30 +++ src/scheduler/scheduler-service.ts | 357 +++++++++++++++++++++++++++++ src/scheduler/scheduler-time.ts | 82 +++++++ src/state-store.ts | 51 +++++ src/types/app-state.ts | 5 + src/types/scheduler.ts | 94 ++++++++ src/web/ports/index.ts | 1 + src/web/ports/scheduler-port.ts | 10 + src/web/routes/index.ts | 1 + src/web/routes/scheduler-routes.ts | 78 +++++++ src/web/schemas.ts | 59 +++++ src/web/server.ts | 23 ++ src/web/sse-events.ts | 17 ++ test/scheduler-time.test.ts | 128 +++++++++++ 16 files changed, 1087 insertions(+) create mode 100644 SCHEDULER_DISCOVERY.md create mode 100644 src/scheduler/scheduler-input.ts create mode 100644 src/scheduler/scheduler-service.ts create mode 100644 src/scheduler/scheduler-time.ts create mode 100644 src/types/scheduler.ts create mode 100644 src/web/ports/scheduler-port.ts create mode 100644 src/web/routes/scheduler-routes.ts create mode 100644 test/scheduler-time.test.ts diff --git a/SCHEDULER_DISCOVERY.md b/SCHEDULER_DISCOVERY.md new file mode 100644 index 00000000..b1f6c74e --- /dev/null +++ b/SCHEDULER_DISCOVERY.md @@ -0,0 +1,138 @@ +# SCHEDULER_DISCOVERY.md + +Phase 1 deliverable for the "Add Scheduling to Codeman" build brief. +This documents the existing Codeman architecture and the smallest integration +points for a cron-style scheduler. **No session/tmux logic will be rebuilt** — +the new code is purely a trigger + persistence + history layer on top of the +existing primitives. + +Stack: `aicodeman` v1.2.1 — Fastify 5 backend, `node-pty` + tmux sessions, +vanilla-JS SPA frontend served as static assets, JSON file state store, zod +validation, ports-based dependency injection. + +--- + +## 0. Critical finding: an existing `ScheduledRun` is NOT a scheduler + +Codeman already has a `ScheduledRun` concept (`/api/scheduled`, +`src/web/ports/infra-port.ts:14-26`, `src/web/server.ts:1480-1605`). It is a +**run-now, duration-bounded autonomous loop**: given `{prompt, workingDir, +durationMinutes}` it immediately spawns/kills throwaway sessions in a loop until +the duration elapses. It has **no** time-based triggering, recurrence +(once/interval/daily/weekly), enable/disable, next-run calculation, run history, +or persistence across restarts. + +Therefore the brief's core (the calendar/cron trigger layer) does **not** exist +and must be built. The execution primitives it sits on top of **do** exist and +will be reused. To honor brief §16 ("do not rename existing core concepts"), the +new feature is named **`ScheduledJob`** (with **`ScheduledJobRun`** history +records), kept distinct from the existing `ScheduledRun`. + +--- + +## 1. Where session creation happens + +- Canonical create flow: `POST /api/sessions`, + `src/web/routes/session-routes.ts:262-438`. + - `new Session({ workingDir, mode, ... })` (`src/session.ts:421-570`) + - `ctx.addSession(session)` → `ctx.setupSessionListeners(session)` → + `ctx.persistSessionState(session)` (all via `SessionPort`). +- `SessionPort` interface: `src/web/ports/session-port.ts:8-16`. +- **Integration point:** the scheduler service will mirror this exact sequence + (create → addSession → setupSessionListeners → start) via `SessionPort`, + not reimplement it. + +## 2. Where agent/session types are defined + +- `type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'` + (`src/types/session.ts:43-44`). `shell` covers the brief's "Terminal/custom". +- CLI availability resolvers in `src/utils/{claude,codex,gemini,opencode}-cli-resolver.ts`. +- **Integration point:** the job's `agentType` reuses `SessionMode` verbatim. + +## 3. Where input is sent into a session + +- Raw / paste: `session.write(data)` (`src/session.ts:2243-2247`) — direct PTY write. +- Typed (recommended): `session.writeViaMux(data)` (`src/session.ts:2301-2311`) + — tmux `send-keys`, falls back to PTY. Submit requires trailing `\r`. +- **Integration point:** prompt delivery uses `writeViaMux` (typed) by default, + `write` (paste) as the alternate `input_mode`. + +## 4. Where active sessions are listed + +- `ctx.sessions: ReadonlyMap` (`SessionPort`). +- Filters: `Array.from(ctx.sessions.values()).filter(s => s.mode === X)` and + `.isBusy()` / `.isIdle()` (`src/session-manager.ts:220-247`). +- **Integration point:** the §8 multi-session warning queries this map. + +## 5. Where session kill/delete is handled + +- `ctx.cleanupSession(sessionId, killMux?, reason?)` + (`SessionPort`; impl `src/web/server.ts:997-1152`). Underlying + `session.stop(killMux)` at `src/session.ts:2498-2585`. +- The scheduler does **not** kill sessions it launches (the brief wants them + visible in the normal session UI); cleanup stays user-driven. + +## 6. How session state is stored / 7. Existing persistence + +- JSON file store: `~/.codeman/state.json` (+ `state-inner.json` for Ralph). + `StateStore` class `src/state-store.ts:71`; `AppState` interface + `src/types/app-state.ts:99-114`. +- Pattern: declare a field on `AppState`, add typed get/set methods on + `StateStore` that mutate in-memory state and call the debounced `save()` + (500ms debounce, atomic temp-file+rename, `.bak` backup, circuit breaker). +- **Integration point:** add `scheduledJobs?: Record` and + `scheduledJobRuns?: Record` to `AppState`, with + matching `StateStore` accessors. No new DB (brief §6 forbids Postgres/Redis). + +## 8. Where backend routes live + +- Route modules: `src/web/routes/*.ts`; barrel `src/web/routes/index.ts`; + registered in `WebServer.setupRoutes()` `src/web/server.ts:858-876` with a + single `ctx` object from `createRouteContext()` (`src/web/server.ts:553-613`) + that satisfies all port interfaces. +- Validation: zod schemas in `src/web/schemas.ts`, applied via + `parseBody(Schema, req.body)` (`src/web/route-helpers.ts:101-111`). +- Errors: `createErrorResponse(ApiErrorCode.X, msg)` / `ApiResponse` + (`src/types/api.ts`), auto-mapped to HTTP status by a `preSerialization` hook + (`src/web/server.ts:644-659`). +- SSE: `ctx.broadcast(SseEvent.X, data)` (`EventPort`, + `src/web/sse-events.ts`); frontend mirror in `src/web/public/constants.js`. +- **Integration point:** new `scheduler-routes.ts` registered alongside the + others; new zod schema; new `SseEvent` constants for job list/run changes. + +## 9. Where frontend pages/components live + +- Vanilla-JS SPA: single `src/web/public/index.html` + feature mixin files + (`Object.assign(CodemanApp.prototype, {...})`). API via `api-client.js` + (`_apiJson/_apiPost/_apiDelete`). Build = esbuild minify + content-hash, no + bundler (`scripts/build.mjs`). +- UI is panels/modals toggled by JS classes; forms use `.form-row` / `.modal` + conventions (`styles.css`). SSE handler map in `app.js`. +- **Integration point:** add a new `scheduler-ui.js` mixin + a panel/modal in + `index.html` + nav entry, following the orchestrator/respawn panel pattern. + +## 10. Background-loop pattern (for the due-checker) + +- Established pattern: `this.cleanup.setInterval(fn, intervalMs, {description})` + in `WebServer.start()` (`src/web/server.ts:~1942-1966`), auto-disposed in + `WebServer.stop()` via `this.cleanup.dispose()` (`src/web/server.ts:2336`). + RalphLoop (`src/ralph-loop.ts:268-286`) shows the self-rescheduling guard idiom. +- **Integration point:** register a 30s scheduler tick via `cleanup.setInterval`; + no manual shutdown wiring needed. + +--- + +## Smallest integration points (summary) + +| New piece | Reuses | Location | +| --- | --- | --- | +| `ScheduledJob` / `ScheduledJobRun` types | — (new) | `src/types/scheduler.ts` | +| Persistence | `StateStore` / `AppState` | `src/types/app-state.ts`, `src/state-store.ts` | +| Next-run time math | — (new, pure, unit-tested) | `src/scheduler/scheduler-time.ts` | +| Launch + send prompt | `SessionPort` (`addSession`/listeners/`writeViaMux`) | `src/scheduler/scheduler-service.ts` | +| Background due loop | `cleanup.setInterval` pattern | `src/scheduler/scheduler-loop.ts` | +| Routes + schema | route/ports/zod/SSE patterns | `src/web/routes/scheduler-routes.ts`, `src/web/schemas.ts`, `src/web/sse-events.ts` | +| UI | panel/modal/mixin conventions | `src/web/public/scheduler-ui.js`, `index.html` | + +Nothing in the session, tmux, persistence, routing, or SSE subsystems is +rewritten — the scheduler is additive and calls existing services. diff --git a/src/config/server-timing.ts b/src/config/server-timing.ts index adbc6efe..0c47e8ec 100644 --- a/src/config/server-timing.ts +++ b/src/config/server-timing.ts @@ -51,6 +51,19 @@ export const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000; /** Completed scheduled run max age before cleanup (ms) */ export const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000; +// ============================================================================ +// Scheduled Jobs (cron-style scheduler) +// ============================================================================ + +/** How often the scheduler loop wakes to check for due jobs (ms). */ +export const SCHEDULER_TICK_INTERVAL = 30 * 1000; + +/** Max attempts (× 500ms) to poll a launched session for CLI readiness before sending the prompt. */ +export const SCHEDULER_READY_MAX_ATTEMPTS = 60; + +/** Extra settle delay after CLI readiness is detected, before sending the prompt (ms). */ +export const SCHEDULER_READY_SETTLE_MS = 2000; + /** Session limit retry wait before retrying (ms) */ export const SESSION_LIMIT_WAIT_MS = 5000; diff --git a/src/scheduler/scheduler-input.ts b/src/scheduler/scheduler-input.ts new file mode 100644 index 00000000..c0e73dcf --- /dev/null +++ b/src/scheduler/scheduler-input.ts @@ -0,0 +1,30 @@ +/** + * @fileoverview Input shape for creating/updating a scheduled job. This is the + * user-settable subset of `ScheduledJob` (server-maintained bookkeeping fields + * such as nextRunAt / lastStatus are excluded). Produced by the zod schema. + */ + +import type { ConcurrencyPolicy, InputMode, PromptMode, ScheduleType } from '../types/scheduler.js'; +import type { SessionMode } from '../types/session.js'; + +export type { ScheduledJob, ScheduledJobRun, ScheduledJobRunStatus, TriggerType } from '../types/scheduler.js'; + +export interface ScheduledJobInput { + name: string; + agentType: SessionMode; + workingDir: string; + launchCommand?: string; + promptMode: PromptMode; + promptText?: string; + promptFilePath?: string; + inputMode: InputMode; + scheduleType: ScheduleType; + runAt?: number; + intervalMinutes?: number; + dailyTime?: string; + weeklyDays?: number[]; + weeklyTime?: string; + enabled: boolean; + notes?: string; + concurrencyPolicy: ConcurrencyPolicy; +} diff --git a/src/scheduler/scheduler-service.ts b/src/scheduler/scheduler-service.ts new file mode 100644 index 00000000..6cbc2456 --- /dev/null +++ b/src/scheduler/scheduler-service.ts @@ -0,0 +1,357 @@ +/** + * @fileoverview Scheduler service: CRUD for scheduled jobs, manual Run Now, + * the background due-job tick, and run-history recording. + * + * It does NOT own session/tmux logic — it reuses Codeman's existing session + * layer (create → addSession → setupSessionListeners → startInteractive/Shell → + * send prompt via writeViaMux/write), mirroring the "quick start" route flow. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { readFile } from 'node:fs/promises'; +import { statSync } from 'node:fs'; +import { Session } from '../session.js'; +import { SseEvent } from '../web/sse-events.js'; +import { getErrorMessage } from '../types/api.js'; +import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js'; +import { SCHEDULER_READY_MAX_ATTEMPTS, SCHEDULER_READY_SETTLE_MS } from '../config/server-timing.js'; +import { computeNextRunAt, dueKeyFor } from './scheduler-time.js'; +import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js'; +import type { ScheduledJob, ScheduledJobRun, ScheduledJobRunStatus, TriggerType } from '../types/scheduler.js'; +import type { ScheduledJobInput } from './scheduler-input.js'; + +/** The subset of the route context the scheduler depends on. */ +export type SchedulerDeps = SessionPort & EventPort & ConfigPort & InfraPort; + +const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +export class SchedulerService { + constructor(private readonly deps: SchedulerDeps) {} + + private get store() { + return this.deps.store; + } + + // ───────────────────────────── Reads ───────────────────────────── + + listJobs(): ScheduledJob[] { + return Object.values(this.store.getScheduledJobs()); + } + + getJob(id: string): ScheduledJob | null { + return this.store.getScheduledJob(id); + } + + listRuns(jobId?: string): ScheduledJobRun[] { + const all = Object.values(this.store.getScheduledJobRuns()); + const filtered = jobId ? all.filter((r) => r.scheduledJobId === jobId) : all; + return filtered.sort((a, b) => b.startedAt - a.startedAt); + } + + /** Number of active sessions of a given agent type (for the multi-session warning). */ + countActiveAgents(agentType: string): number { + let n = 0; + for (const s of this.deps.sessions.values()) if (s.mode === agentType) n++; + return n; + } + + // ──────────────────────────── Mutations ─────────────────────────── + + createJob(input: ScheduledJobInput): ScheduledJob { + const now = Date.now(); + const job: ScheduledJob = { + id: uuidv4(), + name: input.name, + agentType: input.agentType, + workingDir: input.workingDir, + launchCommand: input.launchCommand, + promptMode: input.promptMode, + promptText: input.promptText, + promptFilePath: input.promptFilePath, + inputMode: input.inputMode, + scheduleType: input.scheduleType, + runAt: input.runAt, + intervalMinutes: input.intervalMinutes, + dailyTime: input.dailyTime, + weeklyDays: input.weeklyDays, + weeklyTime: input.weeklyTime, + enabled: input.enabled, + notes: input.notes, + concurrencyPolicy: input.concurrencyPolicy, + createdAt: now, + updatedAt: now, + lastRunAt: null, + nextRunAt: null, + lastStatus: null, + lastDueKey: null, + }; + job.nextRunAt = job.enabled ? computeNextRunAt(job, now) : null; + this.store.setScheduledJob(job.id, job); + this.broadcastListChanged(); + return job; + } + + updateJob(id: string, patch: Partial): ScheduledJob | null { + const existing = this.getJob(id); + if (!existing) return null; + const now = Date.now(); + const updated: ScheduledJob = { + ...existing, + ...patch, + id: existing.id, + createdAt: existing.createdAt, + updatedAt: now, + // Editing a job re-arms it: clear the one-time completion + dup-guard so a + // changed schedule can fire again. + completedOnce: false, + lastDueKey: null, + }; + updated.nextRunAt = updated.enabled ? computeNextRunAt(updated, now) : null; + this.store.setScheduledJob(updated.id, updated); + this.broadcastListChanged(); + return updated; + } + + setEnabled(id: string, enabled: boolean): ScheduledJob | null { + const existing = this.getJob(id); + if (!existing) return null; + const now = Date.now(); + existing.enabled = enabled; + existing.updatedAt = now; + existing.nextRunAt = enabled ? computeNextRunAt(existing, now) : null; + this.store.setScheduledJob(existing.id, existing); + this.broadcastListChanged(); + return existing; + } + + deleteJob(id: string): boolean { + if (!this.getJob(id)) return false; + this.store.removeScheduledJob(id); + for (const run of this.listRuns(id)) this.store.removeScheduledJobRun(run.id); + this.deps.broadcast(SseEvent.SchedulerJobDeleted, { id }); + this.broadcastListChanged(); + return true; + } + + // ──────────────────────────── Execution ─────────────────────────── + + /** Manual Run Now — always launches regardless of schedule/enabled state. */ + async runNow(id: string): Promise { + const job = this.getJob(id); + if (!job) return null; + return this.launch(job, 'manual_run_now'); + } + + /** + * Background tick: launch every enabled job whose next run is due. Advances + * each job's schedule and guards against double-launching the same due time. + */ + async tickDueJobs(now: number = Date.now()): Promise { + for (const job of this.listJobs()) { + if (!job.enabled || job.nextRunAt == null || job.nextRunAt > now) continue; + + const key = dueKeyFor(job.id, job.nextRunAt); + if (job.lastDueKey === key) { + // This due time was already consumed (overlap/restart) — just advance. + this.advanceAfterFire(job, now); + continue; + } + + // Optional concurrency policy for AUTOMATIC runs. + if (job.concurrencyPolicy === 'skip_if_same_agent_running' && this.countActiveAgents(job.agentType) > 0) { + job.lastDueKey = key; + this.advanceAfterFire(job, now); + continue; + } + + job.lastDueKey = key; + // Advance the schedule BEFORE launching so a slow launch can't be + // re-triggered by the next tick. + this.advanceAfterFire(job, now); + this.launch(job, 'scheduled').catch((err) => + console.error(`[scheduler] launch failed for job ${job.id}:`, getErrorMessage(err)) + ); + } + } + + /** Recompute nextRunAt for loaded jobs on boot (e.g. after a restart). */ + init(): void { + const now = Date.now(); + for (const job of this.listJobs()) { + const isDeadOnce = job.scheduleType === 'once' && job.completedOnce; + if (job.enabled && job.nextRunAt == null && !isDeadOnce) { + job.nextRunAt = computeNextRunAt(job, now); + this.store.setScheduledJob(job.id, job); + } + } + } + + // ──────────────────────────── Internals ─────────────────────────── + + private advanceAfterFire(job: ScheduledJob, now: number): void { + if (job.scheduleType === 'once') { + job.completedOnce = true; + job.enabled = false; + job.nextRunAt = null; + } else { + job.nextRunAt = computeNextRunAt(job, now); + } + job.updatedAt = now; + this.store.setScheduledJob(job.id, job); + this.broadcastListChanged(); + } + + private async launch(job: ScheduledJob, trigger: TriggerType): Promise { + const run: ScheduledJobRun = { + id: uuidv4(), + scheduledJobId: job.id, + sessionId: null, + sessionName: null, + startedAt: Date.now(), + finishedAt: null, + status: 'created', + triggerType: trigger, + createdSessionUrl: null, + }; + this.store.setScheduledJobRun(run.id, run); + this.deps.broadcast(SseEvent.SchedulerRunCreated, run); + + // Resolve the prompt. + let prompt: string; + try { + prompt = await this.resolvePrompt(job); + } catch (err) { + return this.failRun(job, run, `Prompt error: ${getErrorMessage(err)}`); + } + + // Validate working directory. + try { + if (!statSync(job.workingDir).isDirectory()) { + return this.failRun(job, run, 'workingDir is not a directory'); + } + } catch { + return this.failRun(job, run, 'workingDir does not exist'); + } + + // Respect the global session cap. + if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) { + return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); + } + + // Create + start the session (mirrors the quick-start route flow). + let session: Session; + try { + const mode = job.agentType; + const globalNice = await this.deps.getGlobalNiceConfig(); + const modelConfig = await this.deps.getModelConfig(); + const claudeModeConfig = await this.deps.getClaudeModeConfig(); + const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined; + session = new Session({ + workingDir: job.workingDir, + mode, + name: job.name, + mux: this.deps.mux, + useMux: true, + niceConfig: globalNice, + model, + claudeMode: claudeModeConfig.claudeMode, + allowedTools: claudeModeConfig.allowedTools, + }); + this.deps.addSession(session); + this.store.incrementSessionsCreated(); + this.deps.persistSessionState(session); + await this.deps.setupSessionListeners(session); + this.deps.broadcast(SseEvent.SessionCreated, this.deps.getSessionStateWithRespawn(session)); + if (mode === 'shell') { + await session.startShell(); + } else { + await session.startInteractive(); + } + this.deps.broadcast(SseEvent.SessionInteractive, { id: session.id, mode }); + } catch (err) { + return this.failRun(job, run, `Session launch failed: ${getErrorMessage(err)}`); + } + + run.sessionId = session.id; + run.sessionName = session.name; + run.createdSessionUrl = `/?session=${session.id}`; + run.status = 'session_started'; + this.store.setScheduledJobRun(run.id, run); + this.deps.broadcast(SseEvent.SchedulerRunUpdated, run); + this.updateJobLastStatus(job.id, 'session_started'); + + // Send the prompt once the CLI is ready (async; does not block the caller). + this.sendPromptWhenReady(session.id, prompt, job, run); + return run; + } + + private async resolvePrompt(job: ScheduledJob): Promise { + if (job.promptMode === 'prompt_file_path') { + if (!job.promptFilePath) throw new Error('prompt file path is empty'); + return readFile(job.promptFilePath, 'utf-8'); + } + return job.promptText ?? ''; + } + + private sendPromptWhenReady(sessionId: string, prompt: string, job: ScheduledJob, run: ScheduledJobRun): void { + setImmediate(() => { + const poll = async (): Promise => { + if (job.agentType !== 'shell') { + for (let attempt = 0; attempt < SCHEDULER_READY_MAX_ATTEMPTS; attempt++) { + await delay(500); + const s = this.deps.sessions.get(sessionId); + if (!s) return; // session was removed + const buf = s.getTerminalBuffer().slice(-2048); + if (buf.includes('❯') || buf.includes('tokens')) break; + } + await delay(SCHEDULER_READY_SETTLE_MS); + } else { + await delay(1000); + } + const s = this.deps.sessions.get(sessionId); + if (!s) return; + try { + const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`; + if (job.inputMode === 'paste') { + s.write(payload); + } else { + await s.writeViaMux(payload); + } + run.status = 'prompt_sent'; + run.finishedAt = Date.now(); + this.store.setScheduledJobRun(run.id, run); + this.deps.broadcast(SseEvent.SchedulerRunUpdated, run); + this.updateJobLastStatus(job.id, 'prompt_sent'); + } catch (err) { + this.failRun(job, run, `Failed to send prompt: ${getErrorMessage(err)}`); + } + }; + poll().catch((err) => console.error('[scheduler] sendPromptWhenReady error:', getErrorMessage(err))); + }); + } + + private failRun(job: ScheduledJob, run: ScheduledJobRun, message: string): ScheduledJobRun { + run.status = 'failed'; + run.errorMessage = message; + run.finishedAt = Date.now(); + this.store.setScheduledJobRun(run.id, run); + this.deps.broadcast(SseEvent.SchedulerRunUpdated, run); + this.updateJobLastStatus(job.id, 'failed'); + return run; + } + + private updateJobLastStatus(jobId: string, status: ScheduledJobRunStatus): void { + const fresh = this.store.getScheduledJob(jobId); + if (!fresh) return; + const now = Date.now(); + fresh.lastStatus = status; + fresh.lastRunAt = now; + fresh.updatedAt = now; + this.store.setScheduledJob(fresh.id, fresh); + this.broadcastListChanged(); + } + + private broadcastListChanged(): void { + this.deps.broadcast(SseEvent.SchedulerJobsChanged, { jobs: this.listJobs() }); + } +} diff --git a/src/scheduler/scheduler-time.ts b/src/scheduler/scheduler-time.ts new file mode 100644 index 00000000..3d006f00 --- /dev/null +++ b/src/scheduler/scheduler-time.ts @@ -0,0 +1,82 @@ +/** + * @fileoverview Pure next-run-time calculations for the scheduler. + * + * All functions are pure and take an explicit `after` timestamp (epoch ms) so + * they are deterministic and unit-testable. Times use the SERVER'S LOCAL + * timezone for v0.1 (per the build brief) — daily/weekly wall-clock times are + * interpreted via the host's local time. + */ + +import type { ScheduledJob } from '../types/scheduler.js'; + +/** Parse an 'HH:MM' (24-hour) string into hours/minutes, or null if invalid. */ +export function parseHHMM(value: string | undefined): { hours: number; minutes: number } | null { + if (!value) return null; + const m = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); + if (!m) return null; + const hours = Number(m[1]); + const minutes = Number(m[2]); + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; + return { hours, minutes }; +} + +/** + * Returns the epoch-ms timestamp for `hours:minutes` (local time) on the day of + * `base`, shifted by `dayOffset` days. + */ +function atLocalTime(base: number, hours: number, minutes: number, dayOffset: number): number { + const d = new Date(base); + d.setHours(hours, minutes, 0, 0); + d.setDate(d.getDate() + dayOffset); + return d.getTime(); +} + +/** + * Compute the next fire time strictly relevant to `after`, or null if the job + * has no future run (e.g. a completed one-time job, or invalid config). + * + * For `once`, returns the absolute `runAt` (even if already in the past, so a + * missed one-time job still fires once) until it has `completedOnce`. + */ +export function computeNextRunAt(job: ScheduledJob, after: number): number | null { + switch (job.scheduleType) { + case 'once': { + if (job.completedOnce) return null; + return typeof job.runAt === 'number' ? job.runAt : null; + } + case 'interval': { + const minutes = job.intervalMinutes; + if (!minutes || minutes <= 0) return null; + return after + minutes * 60_000; + } + case 'daily': { + const t = parseHHMM(job.dailyTime); + if (!t) return null; + let next = atLocalTime(after, t.hours, t.minutes, 0); + if (next <= after) next = atLocalTime(after, t.hours, t.minutes, 1); + return next; + } + case 'weekly': { + const t = parseHHMM(job.weeklyTime); + if (!t) return null; + const days = (job.weeklyDays ?? []).filter((d) => d >= 0 && d <= 6); + if (days.length === 0) return null; + for (let offset = 0; offset <= 7; offset++) { + const cand = atLocalTime(after, t.hours, t.minutes, offset); + if (cand > after && days.includes(new Date(cand).getDay())) return cand; + } + return null; + } + default: + return null; + } +} + +/** + * Duplicate-launch guard key: identifies a specific due time for a job. The + * scheduler records the key it last consumed so an overlapping or restarted + * loop will not launch the same due time twice. + */ +export function dueKeyFor(jobId: string, fireTime: number): string { + return `${jobId}:${fireTime}`; +} diff --git a/src/state-store.ts b/src/state-store.ts index ffab99f4..f5a24c44 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -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 { + 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 { + 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; diff --git a/src/types/app-state.ts b/src/types/app-state.ts index 56809a6b..d6797714 100644 --- a/src/types/app-state.ts +++ b/src/types/app-state.ts @@ -23,6 +23,7 @@ import type { SessionState } from './session.js'; import type { TaskState } from './task.js'; import type { RalphLoopState } from './ralph.js'; import type { RespawnConfig } from './respawn.js'; +import type { ScheduledJob, ScheduledJobRun } from './scheduler.js'; // ========== Global Stats Types ========== @@ -111,6 +112,10 @@ export interface AppState { tokenStats?: TokenStats; /** Orchestrator Loop state (phased plan execution) */ orchestrator?: import('./orchestrator.js').OrchestratorPersistState; + /** Cron-style scheduled jobs, keyed by job ID. */ + scheduledJobs?: Record; + /** Scheduled job run history, keyed by run ID. */ + scheduledJobRuns?: Record; } // ========== Default Configuration ========== diff --git a/src/types/scheduler.ts b/src/types/scheduler.ts new file mode 100644 index 00000000..d8b2552b --- /dev/null +++ b/src/types/scheduler.ts @@ -0,0 +1,94 @@ +/** + * @fileoverview Scheduled Jobs (cron-style scheduler) type definitions. + * + * NOTE: This is intentionally distinct from the existing `ScheduledRun` concept + * (see src/web/ports/infra-port.ts), which is a run-now, duration-bounded + * autonomous loop. A `ScheduledJob` is a SAVED, NAMED job with a recurring + * schedule (once/interval/daily/weekly), enable/disable, next-run calculation, + * and a history of `ScheduledJobRun` records. The two do not interact. + * + * Persisted to `~/.codeman/state.json` via StateStore (see AppState). + */ + +import type { SessionMode } from './session.js'; + +/** How a job's fire times are computed. */ +export type ScheduleType = 'once' | 'interval' | 'daily' | 'weekly'; + +/** Where the prompt text comes from. */ +export type PromptMode = 'inline_text' | 'prompt_file_path'; + +/** How the prompt is delivered into the session. */ +export type InputMode = 'paste' | 'typed'; + +/** Lifecycle status of a single job execution. */ +export type ScheduledJobRunStatus = 'created' | 'session_started' | 'prompt_sent' | 'failed'; + +/** What triggered a run. */ +export type TriggerType = 'scheduled' | 'manual_run_now'; + +/** What to do for an AUTOMATIC run when sessions of the same agent already exist. */ +export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running'; + +/** + * A saved, named scheduled job. + */ +export interface ScheduledJob { + id: string; + name: string; + /** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */ + agentType: SessionMode; + workingDir: string; + /** Optional custom launch command (only meaningful for 'shell' mode). */ + launchCommand?: string; + + promptMode: PromptMode; + promptText?: string; + promptFilePath?: string; + inputMode: InputMode; + + scheduleType: ScheduleType; + /** once: absolute epoch-ms fire time. */ + runAt?: number; + /** interval: minutes between fires. */ + intervalMinutes?: number; + /** daily: 'HH:MM' (24h, server-local time). */ + dailyTime?: string; + /** weekly: weekdays 0–6 (0=Sunday). */ + weeklyDays?: number[]; + /** weekly: 'HH:MM' (24h, server-local time). */ + weeklyTime?: string; + + enabled: boolean; + notes?: string; + /** Applies to automatic (scheduled) runs only. Manual Run Now always warns client-side. */ + concurrencyPolicy: ConcurrencyPolicy; + + // ── Bookkeeping (server-maintained) ───────────────────────────────────── + createdAt: number; + updatedAt: number; + lastRunAt: number | null; + nextRunAt: number | null; + lastStatus: ScheduledJobRunStatus | null; + /** Duplicate-launch guard: identifies the most recent due-time consumed. */ + lastDueKey: string | null; + /** True once a 'once' job has fired (it is also disabled). */ + completedOnce?: boolean; +} + +/** + * A single execution of a scheduled job (history record). + */ +export interface ScheduledJobRun { + id: string; + scheduledJobId: string; + sessionId: string | null; + sessionName: string | null; + startedAt: number; + finishedAt: number | null; + status: ScheduledJobRunStatus; + errorMessage?: string; + triggerType: TriggerType; + /** Best-effort deep link to the created session in the web UI. */ + createdSessionUrl: string | null; +} diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index 2373015f..3c952dc5 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -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'; diff --git a/src/web/ports/scheduler-port.ts b/src/web/ports/scheduler-port.ts new file mode 100644 index 00000000..d8632bb9 --- /dev/null +++ b/src/web/ports/scheduler-port.ts @@ -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; +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 9adaec30..05cbb5ef 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -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'; diff --git a/src/web/routes/scheduler-routes.ts b/src/web/routes/scheduler-routes.ts new file mode 100644 index 00000000..3330c70c --- /dev/null +++ b/src/web/routes/scheduler-routes.ts @@ -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(); + }); +} diff --git a/src/web/schemas.ts b/src/web/schemas.ts index a6dc1416..b2eca9a8 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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, 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'), diff --git a/src/web/server.ts b/src/web/server.ts index 61324c2b..4022b3b7 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 = new Map(); private scheduledRuns: Map = 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( () => { diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 5d57e781..f5c638e2 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -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, diff --git a/test/scheduler-time.test.ts b/test/scheduler-time.test.ts new file mode 100644 index 00000000..b1e60383 --- /dev/null +++ b/test/scheduler-time.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for the scheduler's pure next-run-time calculations. + * Timezone-independent: daily/weekly expectations are asserted via local + * Date getters rather than hardcoded epoch values. + */ + +import { describe, it, expect } from 'vitest'; +import { parseHHMM, computeNextRunAt, dueKeyFor } from '../src/scheduler/scheduler-time.js'; +import type { ScheduledJob } from '../src/types/scheduler.js'; + +function baseJob(partial: Partial): ScheduledJob { + return { + id: 'j1', + name: 'test', + agentType: 'claude', + workingDir: '/tmp', + promptMode: 'inline_text', + promptText: 'hi', + inputMode: 'typed', + scheduleType: 'once', + enabled: true, + concurrencyPolicy: 'warn_only', + createdAt: 0, + updatedAt: 0, + lastRunAt: null, + nextRunAt: null, + lastStatus: null, + lastDueKey: null, + ...partial, + }; +} + +describe('parseHHMM', () => { + it('parses valid 24h times', () => { + expect(parseHHMM('09:30')).toEqual({ hours: 9, minutes: 30 }); + expect(parseHHMM('23:59')).toEqual({ hours: 23, minutes: 59 }); + expect(parseHHMM('0:00')).toEqual({ hours: 0, minutes: 0 }); + }); + it('rejects invalid times', () => { + expect(parseHHMM('24:00')).toBeNull(); + expect(parseHHMM('12:60')).toBeNull(); + expect(parseHHMM('9:5')).toBeNull(); // minutes must be 2 digits + expect(parseHHMM('abc')).toBeNull(); + expect(parseHHMM(undefined)).toBeNull(); + }); +}); + +describe('computeNextRunAt — once', () => { + it('returns runAt even when already in the past (missed one-time job still fires)', () => { + const job = baseJob({ scheduleType: 'once', runAt: 1000 }); + expect(computeNextRunAt(job, 500)).toBe(1000); + expect(computeNextRunAt(job, 5000)).toBe(1000); + }); + it('returns null once completed', () => { + const job = baseJob({ scheduleType: 'once', runAt: 1000, completedOnce: true }); + expect(computeNextRunAt(job, 500)).toBeNull(); + }); + it('returns null with no runAt', () => { + expect(computeNextRunAt(baseJob({ scheduleType: 'once' }), 0)).toBeNull(); + }); +}); + +describe('computeNextRunAt — interval', () => { + it('adds intervalMinutes to the after time', () => { + const job = baseJob({ scheduleType: 'interval', intervalMinutes: 60 }); + expect(computeNextRunAt(job, 1000)).toBe(1000 + 60 * 60_000); + }); + it('returns null with no/invalid interval', () => { + expect(computeNextRunAt(baseJob({ scheduleType: 'interval' }), 0)).toBeNull(); + expect(computeNextRunAt(baseJob({ scheduleType: 'interval', intervalMinutes: 0 }), 0)).toBeNull(); + }); +}); + +describe('computeNextRunAt — daily', () => { + it('schedules today when the time is still ahead', () => { + const after = new Date(2026, 0, 1, 10, 0, 0).getTime(); + const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!; + const d = new Date(next); + expect(d.getHours()).toBe(14); + expect(d.getMinutes()).toBe(30); + expect(d.getDate()).toBe(1); + expect(next).toBeGreaterThan(after); + }); + it('rolls to tomorrow when the time has passed', () => { + const after = new Date(2026, 0, 1, 16, 0, 0).getTime(); + const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!; + const d = new Date(next); + expect(d.getHours()).toBe(14); + expect(d.getDate()).toBe(2); + expect(next).toBeGreaterThan(after); + }); + it('returns null with no time', () => { + expect(computeNextRunAt(baseJob({ scheduleType: 'daily' }), 0)).toBeNull(); + }); +}); + +describe('computeNextRunAt — weekly', () => { + it('finds the next selected weekday at the configured time', () => { + const after = new Date(2026, 0, 1, 12, 0, 0).getTime(); + const targetDay = (new Date(after).getDay() + 2) % 7; + const job = baseJob({ scheduleType: 'weekly', weeklyDays: [targetDay], weeklyTime: '08:00' }); + const next = computeNextRunAt(job, after)!; + const d = new Date(next); + expect(d.getDay()).toBe(targetDay); + expect(d.getHours()).toBe(8); + expect(next).toBeGreaterThan(after); + // Within the coming week. + expect(next - after).toBeLessThanOrEqual(7 * 24 * 60 * 60_000); + }); + it('picks the soonest of multiple selected days', () => { + const after = new Date(2026, 0, 1, 12, 0, 0).getTime(); + const soon = (new Date(after).getDay() + 1) % 7; + const later = (new Date(after).getDay() + 3) % 7; + const job = baseJob({ scheduleType: 'weekly', weeklyDays: [later, soon], weeklyTime: '09:00' }); + const next = computeNextRunAt(job, after)!; + expect(new Date(next).getDay()).toBe(soon); + }); + it('returns null with no days or no time', () => { + expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyTime: '09:00' }), 0)).toBeNull(); + expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyDays: [1] }), 0)).toBeNull(); + }); +}); + +describe('dueKeyFor', () => { + it('combines job id and fire time', () => { + expect(dueKeyFor('j1', 123)).toBe('j1:123'); + }); +}); From 2d2f4e592b23494e244939a2e57cd8427d4da999 Mon Sep 17 00:00:00 2001 From: Kris Date: Sat, 27 Jun 2026 08:15:36 +0530 Subject: [PATCH 2/7] feat(scheduler): add Scheduled Jobs UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scheduler-ui.js: job list + create/edit form + Run Now/Enable/Disable/Delete, reacting to scheduler:* SSE events; same-agent Run Now warning - index.html: "⏰ Schedules" toolbar button + #schedulerModal + script include - constants.js / app.js: frontend SSE event constants + handler map entries - styles.css: scheduler row/badge/form styles Follows Codeman's vanilla-JS mixin + .modal/.form-row conventions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rp7JhmQXcYJhmxFMdZuuah --- src/web/public/app.js | 6 + src/web/public/constants.js | 6 + src/web/public/index.html | 91 ++++++++++ src/web/public/scheduler-ui.js | 306 +++++++++++++++++++++++++++++++++ src/web/public/styles.css | 22 +++ 5 files changed, 431 insertions(+) create mode 100644 src/web/public/scheduler-ui.js diff --git a/src/web/public/app.js b/src/web/public/app.js index c63f12be..eb9a1754 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -165,6 +165,12 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'], [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'], + // Respawn [SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'], [SSE_EVENTS.RESPAWN_STOPPED, '_onRespawnStopped'], diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 33b74246..eabf8dcc 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -276,6 +276,12 @@ 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', + // Respawn RESPAWN_STARTED: 'respawn:started', RESPAWN_STOPPED: 'respawn:stopped', diff --git a/src/web/public/index.html b/src/web/public/index.html index 3f6edd2d..0d5d0b9b 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -541,6 +541,7 @@

Resume Conversation

+ v0.0.0
@@ -570,6 +571,95 @@

Keyboard Shortcuts

+ + +
@@ -2056,6 +2146,7 @@

Save Respawn Preset

+ diff --git a/src/web/public/scheduler-ui.js b/src/web/public/scheduler-ui.js new file mode 100644 index 00000000..4f343989 --- /dev/null +++ b/src/web/public/scheduler-ui.js @@ -0,0 +1,306 @@ +/** + * @fileoverview Scheduled Jobs UI (cron-style scheduler) mixed into + * CodemanApp.prototype. Renders the job list + create/edit form in the + * #schedulerModal, and reacts to scheduler:* SSE events. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js, api-client.js, constants.js (escapeHtml) + */ + +Object.assign(CodemanApp.prototype, { + // ── SSE handlers ────────────────────────────────────────────────────────── + + _onSchedulerJobsChanged(data) { + if (data && Array.isArray(data.jobs)) { + this._schedulerJobs = data.jobs; + if (this._isSchedulerOpen()) this.renderSchedulerJobs(); + } else if (this._isSchedulerOpen()) { + this.refreshScheduler(); + } + }, + + _onSchedulerRunChanged() { + // A run's status changed — refresh the list so lastStatus stays current. + if (this._isSchedulerOpen()) this.refreshScheduler(); + }, + + // ── Modal open/close ────────────────────────────────────────────────────── + + _isSchedulerOpen() { + const el = document.getElementById('schedulerModal'); + return !!el && el.classList.contains('active'); + }, + + openScheduler() { + const el = document.getElementById('schedulerModal'); + if (!el) return; + el.classList.add('active'); + this.cancelSchedulerJobForm(); + this.refreshScheduler(); + }, + + closeScheduler() { + const el = document.getElementById('schedulerModal'); + if (el) el.classList.remove('active'); + }, + + async refreshScheduler() { + const jobs = await this._apiJson('/api/scheduler/jobs'); + this._schedulerJobs = Array.isArray(jobs) ? jobs : []; + this.renderSchedulerJobs(); + }, + + // ── List rendering ──────────────────────────────────────────────────────── + + renderSchedulerJobs() { + const list = document.getElementById('schedulerJobList'); + if (!list) return; + const jobs = this._schedulerJobs || []; + if (jobs.length === 0) { + list.innerHTML = '
No scheduled jobs yet. Click “+ New Job”.
'; + return; + } + const rows = jobs.map((j) => { + const next = j.enabled ? this._fmtTime(j.nextRunAt) : '—'; + const last = this._fmtTime(j.lastRunAt); + const status = j.lastStatus ? escapeHtml(j.lastStatus) : '—'; + return ` +
+
+
${escapeHtml(j.name || '(unnamed)')} + ${escapeHtml(j.agentType)} + ${escapeHtml(this._fmtSchedule(j))} + ${j.enabled ? '' : 'disabled'} +
+
+ ${escapeHtml(j.workingDir || '')} + · next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status} +
+
+
+ + + + +
+
`; + }); + list.innerHTML = rows.join(''); + }, + + _fmtTime(ts) { + if (!ts) return '—'; + try { + return new Date(ts).toLocaleString(); + } catch { + return '—'; + } + }, + + _fmtSchedule(j) { + switch (j.scheduleType) { + case 'once': + return 'once'; + case 'interval': + return `every ${j.intervalMinutes}m`; + case 'daily': + return `daily ${j.dailyTime || ''}`; + case 'weekly': { + const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const days = (j.weeklyDays || []).map((d) => names[d] || d).join(','); + return `weekly ${days} ${j.weeklyTime || ''}`; + } + default: + return j.scheduleType || ''; + } + }, + + // ── Create / edit form ──────────────────────────────────────────────────── + + openSchedulerJobForm(job) { + const form = document.getElementById('schedulerJobForm'); + if (!form) return; + document.getElementById('schedulerFormError').textContent = ''; + document.getElementById('schedulerFormTitle').textContent = job ? 'Edit Scheduled Job' : 'New Scheduled Job'; + document.getElementById('schJobId').value = job ? job.id : ''; + document.getElementById('schName').value = job ? job.name || '' : ''; + document.getElementById('schAgentType').value = job ? job.agentType || 'claude' : 'claude'; + document.getElementById('schWorkingDir').value = job ? job.workingDir || '' : ''; + document.getElementById('schPromptMode').value = job ? job.promptMode || 'inline_text' : 'inline_text'; + document.getElementById('schPromptText').value = job ? job.promptText || '' : ''; + document.getElementById('schPromptFilePath').value = job ? job.promptFilePath || '' : ''; + document.getElementById('schInputMode').value = job ? job.inputMode || 'typed' : 'typed'; + document.getElementById('schScheduleType').value = job ? job.scheduleType || 'once' : 'once'; + document.getElementById('schRunAt').value = job && job.runAt ? this._toLocalInput(job.runAt) : ''; + document.getElementById('schIntervalMinutes').value = job && job.intervalMinutes ? job.intervalMinutes : 60; + document.getElementById('schDailyTime').value = job ? job.dailyTime || '' : ''; + document.getElementById('schWeeklyTime').value = job ? job.weeklyTime || '' : ''; + const weekly = (job && job.weeklyDays) || []; + document.querySelectorAll('#schWeeklyDays input[type=checkbox]').forEach((cb) => { + cb.checked = weekly.includes(Number(cb.value)); + }); + document.getElementById('schConcurrencyPolicy').value = job ? job.concurrencyPolicy || 'warn_only' : 'warn_only'; + document.getElementById('schEnabled').checked = job ? !!job.enabled : true; + document.getElementById('schNotes').value = job ? job.notes || '' : ''; + + this.onSchedulerPromptModeChange(); + this.onSchedulerScheduleTypeChange(); + form.classList.remove('hidden'); + }, + + editSchedulerJob(id) { + const job = (this._schedulerJobs || []).find((j) => j.id === id); + if (job) this.openSchedulerJobForm(job); + }, + + cancelSchedulerJobForm() { + const form = document.getElementById('schedulerJobForm'); + if (form) form.classList.add('hidden'); + }, + + onSchedulerPromptModeChange() { + 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() { + const t = document.getElementById('schScheduleType').value; + document.getElementById('schRunAtRow').classList.toggle('hidden', t !== 'once'); + document.getElementById('schIntervalRow').classList.toggle('hidden', t !== 'interval'); + document.getElementById('schDailyRow').classList.toggle('hidden', t !== 'daily'); + document.getElementById('schWeeklyDaysRow').classList.toggle('hidden', t !== 'weekly'); + document.getElementById('schWeeklyTimeRow').classList.toggle('hidden', t !== 'weekly'); + }, + + _toLocalInput(ts) { + // epoch-ms → 'YYYY-MM-DDTHH:MM' in local time for . + const d = new Date(ts); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + }, + + _collectSchedulerForm() { + const t = document.getElementById('schScheduleType').value; + const promptMode = document.getElementById('schPromptMode').value; + const body = { + name: document.getElementById('schName').value.trim(), + agentType: document.getElementById('schAgentType').value, + workingDir: document.getElementById('schWorkingDir').value.trim(), + promptMode, + inputMode: document.getElementById('schInputMode').value, + scheduleType: t, + concurrencyPolicy: document.getElementById('schConcurrencyPolicy').value, + enabled: document.getElementById('schEnabled').checked, + notes: document.getElementById('schNotes').value.trim() || undefined, + }; + if (promptMode === 'inline_text') body.promptText = document.getElementById('schPromptText').value; + else body.promptFilePath = document.getElementById('schPromptFilePath').value.trim(); + + if (t === 'once') { + const v = document.getElementById('schRunAt').value; + body.runAt = v ? new Date(v).getTime() : undefined; + } else if (t === 'interval') { + body.intervalMinutes = Number(document.getElementById('schIntervalMinutes').value); + } else if (t === 'daily') { + body.dailyTime = document.getElementById('schDailyTime').value; + } else if (t === 'weekly') { + body.weeklyTime = document.getElementById('schWeeklyTime').value; + body.weeklyDays = Array.from(document.querySelectorAll('#schWeeklyDays input:checked')).map((cb) => + Number(cb.value) + ); + } + return body; + }, + + async saveSchedulerJob() { + const errEl = document.getElementById('schedulerFormError'); + errEl.textContent = ''; + const body = this._collectSchedulerForm(); + if (!body.name) { + errEl.textContent = 'Name is required.'; + return; + } + if (!body.workingDir) { + errEl.textContent = 'Working directory is required.'; + 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); + if (!res || !res.ok) { + let msg = 'Failed to save job.'; + try { + const j = await res.json(); + if (j && j.error) msg = j.error; + } catch { + /* ignore */ + } + errEl.textContent = msg; + return; + } + this.showToast?.(id ? 'Scheduled job updated' : 'Scheduled job created', 'success'); + this.cancelSchedulerJobForm(); + this.refreshScheduler(); + }, + + // ── Actions ─────────────────────────────────────────────────────────────── + + async runSchedulerJob(id) { + const job = (this._schedulerJobs || []).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`, {}); + if (res && res.ok) { + this.showToast?.('Run started — opening session', 'success'); + let data = null; + try { + data = await res.json(); + } catch { + /* ignore */ + } + const run = data && (data.data ? data.data.run : data.run); + if (run && run.sessionId) this._focusScheduledSession(run.sessionId); + this.refreshScheduler(); + } else { + this.showToast?.('Failed to run job', 'error'); + } + }, + + _focusScheduledSession(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.switchSession(sessionId); + } + }, + + _countActiveAgents(agentType) { + if (!this.sessions) return 0; + let n = 0; + for (const s of this.sessions.values()) if (s && s.mode === agentType) n++; + return n; + }, + + async toggleSchedulerJob(id, enabled) { + const res = await this._apiPut(`/api/scheduler/jobs/${id}/enabled`, { enabled }); + if (res && res.ok) this.refreshScheduler(); + 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}`); + if (res && res.ok) { + this.showToast?.('Scheduled job deleted', 'success'); + this.refreshScheduler(); + } else { + this.showToast?.('Failed to delete job', 'error'); + } + }, +}); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index bf0d3cdc..6072802c 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -11071,3 +11071,25 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { background: linear-gradient(135deg, #7c3aed, #8b5cf6); 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 { + 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 { + 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 { + margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border, #333); +} From 9feaa0d6e5bdf308c37ff0b93b317cd5440a1cf2 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 29 Jun 2026 11:35:56 +0530 Subject: [PATCH 3/7] 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) Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU --- CLAUDE.md | 9 +- README.md | 168 ++++- docs/cron-build-brief.md | 588 ++++++++++++++++++ .../cron-discovery.md | 36 +- src/config/server-timing.ts | 10 +- .../scheduler-input.ts => cron/cron-input.ts} | 10 +- .../cron-service.ts} | 106 ++-- .../scheduler-time.ts => cron/cron-time.ts} | 8 +- src/state-store.ts | 46 +- src/types/app-state.ts | 6 +- src/types/{scheduler.ts => cron.ts} | 22 +- src/web/ports/cron-port.ts | 10 + src/web/ports/index.ts | 2 +- src/web/ports/scheduler-port.ts | 10 - src/web/public/app.js | 8 +- src/web/public/constants.js | 10 +- .../public/{scheduler-ui.js => cron-ui.js} | 148 +++-- src/web/public/index.html | 36 +- src/web/public/styles.css | 24 +- src/web/routes/cron-routes.ts | 78 +++ src/web/routes/index.ts | 2 +- src/web/routes/scheduler-routes.ts | 78 --- src/web/schemas.ts | 18 +- src/web/server.ts | 26 +- src/web/sse-events.ts | 24 +- test/cron-service.test.ts | 262 ++++++++ ...heduler-time.test.ts => cron-time.test.ts} | 8 +- 27 files changed, 1383 insertions(+), 370 deletions(-) create mode 100644 docs/cron-build-brief.md rename SCHEDULER_DISCOVERY.md => docs/cron-discovery.md (81%) rename src/{scheduler/scheduler-input.ts => cron/cron-input.ts} (64%) rename src/{scheduler/scheduler-service.ts => cron/cron-service.ts} (75%) rename src/{scheduler/scheduler-time.ts => cron/cron-time.ts} (90%) rename src/types/{scheduler.ts => cron.ts} (82%) create mode 100644 src/web/ports/cron-port.ts delete mode 100644 src/web/ports/scheduler-port.ts rename src/web/public/{scheduler-ui.js => cron-ui.js} (70%) create mode 100644 src/web/routes/cron-routes.ts delete mode 100644 src/web/routes/scheduler-routes.ts create mode 100644 test/cron-service.test.ts rename test/{scheduler-time.test.ts => cron-time.test.ts} (94%) diff --git a/CLAUDE.md b/CLAUDE.md index aeee11cc..240876f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | | **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first | | **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first | +| **Cron** | `src/cron/cron-service.ts`, `src/cron/cron-time.ts` (pure next-run math), `src/cron/cron-input.ts` | Cron-style `CronJob`s. Read `docs/cron-discovery.md` first; distinct from legacy `ScheduledRun` (`/api/scheduled`) — see Key Patterns | | **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts`, `src/workflow-run-watcher.ts` | `workflow-run-watcher` is STANDALONE (never touches `subagent-watcher`) — see Key Patterns | | **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | | | **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | | @@ -131,8 +132,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | | **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | | -| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (17 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | | -| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) | +| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (18 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | | +| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 9 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `cron-ui.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) | | **Types** | `src/types/index.ts` (barrel) → 17 domain files (incl. `workflow-run.ts`, `search.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -162,6 +163,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. +**Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`. + **External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). **Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. @@ -228,7 +231,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### API Routes -~150 handlers across 17 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. +~160 handlers across 18 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. **HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`). diff --git a/README.md b/README.md index 6bcfbaab..0f0610b2 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,73 @@ Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/e --- +## Using Codeman — A Human's Guide + +A start-to-finish walkthrough for driving Codeman from the browser. If you just installed, this is where to begin. + +### 1. Launch the server + +```bash +codeman web # localhost:3000 (loopback only — safe default) +codeman web --port 8080 # custom port (or set CODEMAN_PORT) +codeman web --https # self-signed TLS (only needed for remote access) +codeman web -H 0.0.0.0 # bind LAN — REQUIRES CODEMAN_PASSWORD (see Security) +``` + +Open the printed URL. The page is a single dashboard; everything below happens there. + +### 2. Create your first session + +Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in its own tmux-backed terminal. You choose: + +| Field | What it does | +|-------|--------------| +| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. | +| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Gemini`, or `Terminal` (plain shell). | +| **Model** | Per-session model (App Settings → Claude Model). A soft default — `/model` still works in-session. | +| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. | + +Hit start — Codeman spawns the CLI via a real PTY and streams it to your browser over SSE. + +### 3. Read the dashboard + +- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder. +- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline. +- **Side panels** — Respawn, Ralph, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar). + +### 4. Talk to the agent + +- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt). +- **Paste or drag-and-drop images** directly into the session. +- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, with auto-silence stop). +- **Attachments** — register external files/docs and preview Office/PDF inline. + +### 5. Make it autonomous + +| Mode | Use it for | Where | +|------|-----------|-------| +| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab | +| **Ralph / Todo** | A self-driving loop that tracks a todo list and keeps working until done. | Ralph tab | +| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel | +| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button | +| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) | + +### 6. Reach it from anywhere + +- **Phone/tablet** — the UI is fully touch-optimized; scan the desktop **QR code** to log in without typing a password. +- **Outside your network** — `./scripts/tunnel.sh start` opens a Cloudflare tunnel (set `CODEMAN_PASSWORD` first). +- **SSH** — the `sc` chooser attaches to any session from a terminal (`sc` interactive, `sc 2` quick-attach, `sc -l` list). + +### 7. Operate & maintain + +- **App Settings** — model, effort, theme/skin, notifications, display toggles, per-CLI options. +- **Self-update** — git-clone installs update in place from **Settings → Updates**. +- **Deploy your own changes** — see [Development](#development). + +> ⚠️ **Safety:** if you're working *inside* a Codeman-managed session (`echo $CODEMAN_MUX` → `1`), never run `tmux kill-session` / `pkill claude` directly — use the web UI or `./scripts/tmux-manager.sh`. + +--- + ## Mobile-Optimized Web UI The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work — not a desktop UI crammed onto a small screen. @@ -496,17 +563,103 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De --- +## Driving Codeman from an Agent — Programmatic Guide + +For AI agents and automation that control Codeman without a browser: an agent that spins up worker sessions, a CI bot, or **Claude Code running *inside* a Codeman session orchestrating other sessions**. Everything the UI does is HTTP + a CLI, so an agent can do it too. + +### Detect that you're inside Codeman + +When a CLI runs in a Codeman-managed session, these environment variables are set — read them instead of hardcoding anything: + +| Variable | Meaning | +|----------|---------| +| `CODEMAN_MUX=1` | You're in a managed tmux session. **Never** `tmux kill-session` / `pkill claude` / `pkill tmux` — you'll kill yourself or a sibling. | +| `CODEMAN_API_URL` | Base URL of the API (e.g. `https://127.0.0.1:3000`). Use it for every call below. | +| `CODEMAN_SESSION_ID` | *Your own* session id. Use it to avoid acting on yourself. | +| `CODEMAN_HOOK_SECRET_FILE` | Path to the hook secret (required on `/api/hook-event` while a managed tunnel is up). | + +### Rules of the road (read before you POST) + +1. **Single-line input only.** Programmatic input is sent as literal text **+ Enter** in one shot. Multi-line strings break the agent TUI (Ink) — send one line, or split into multiple calls. +2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt. +3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard). +4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`). +5. **`/api/v1/*`** is a stable alias of `/api/*`. + +### Recipes + +```bash +API="${CODEMAN_API_URL:-http://127.0.0.1:3000}" +# (add -u admin:"$CODEMAN_PASSWORD" to each call if a password is set) + +# 1. See what's running +curl -s "$API/api/sessions" | jq '.data // .' + +# 2. Spin up a worker session (a "case" = named working dir) +curl -s -X POST "$API/api/quick-start" \ + -H 'Content-Type: application/json' \ + -d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq + +# 3. Send a prompt into a session (exactly-once: clientId + seq) +curl -s -X POST "$API/api/sessions/$SID/input" \ + -H 'Content-Type: application/json' \ + -d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}' + +# 4. Read the terminal back +curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .' + +# 5. Stream live events (session output, agent activity, status) +curl -sN "$API/api/events" # Server-Sent Events + +# 6. Schedule recurring work (cron-style job) +curl -s -X POST "$API/api/cron/jobs" \ + -H 'Content-Type: application/json' \ + -d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj", + "promptMode":"inline_text","promptText":"Update dependencies and open a PR", + "inputMode":"typed","scheduleType":"daily","dailyTime":"03:00", + "enabled":true,"concurrencyPolicy":"warn_only"}' | jq + +# 7. Inspect background sub-agents and their transcripts +curl -s "$API/api/subagents" | jq '.data // .' +curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .' + +# 8. Whole-system snapshot (sessions, settings, respawn, stats) +curl -s "$API/api/status" | jq +``` + +### Or use the bundled CLI + +The same operations are available as commands (`codeman `, aliases in parentheses) — handy from a shell tool inside a session: + +```bash +codeman session start -d /path/to/repo # (s) start a session +codeman session list # list sessions +codeman session logs # tail output +codeman task add "fix the failing test" # (t) queue a task +codeman ralph start --min-hours 8 # (r) launch the autonomous loop +codeman attach # attach a Claude hook context +``` + +### Hooks (events flowing *back* to Codeman) + +Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing. + +> Full endpoint list and request/response shapes follow. + +--- + ## API -REST over Fastify — **~140 handlers across 15 route modules**, plus an SSE stream and a WebSocket terminal channel. A representative subset: +REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset: ### Sessions | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/api/sessions` | List all | -| `POST` | `/api/quick-start` | Create case + start session | +| `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) | +| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?}` — `clientId`+`seq` = exactly-once) | +| `GET` | `/api/sessions/:id/output` | Read terminal output | | `DELETE` | `/api/sessions/:id` | Delete session | -| `POST` | `/api/sessions/:id/input` | Send input | ### Respawn | Method | Endpoint | Description | @@ -529,6 +682,15 @@ REST over Fastify — **~140 handlers across 15 route modules**, plus an SSE str | `GET` | `/api/orchestrator/status` | Current phase + progress | | `POST` | `/api/orchestrator/stop` | Stop and clean up | +### Cron (scheduled jobs) +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` / `POST` | `/api/cron/jobs` | List / create cron jobs | +| `PUT` / `DELETE` | `/api/cron/jobs/:id` | Update / delete a job | +| `PUT` | `/api/cron/jobs/:id/enabled` | Enable / disable | +| `POST` | `/api/cron/jobs/:id/run` | Run now | +| `GET` | `/api/cron/jobs/:id/runs` | Run history | + ### Subagents | Method | Endpoint | Description | |--------|----------|-------------| diff --git a/docs/cron-build-brief.md b/docs/cron-build-brief.md new file mode 100644 index 00000000..6dac419d --- /dev/null +++ b/docs/cron-build-brief.md @@ -0,0 +1,588 @@ +# Claude Code Build Brief: Add Scheduling to Codeman + +## 0. Purpose of This Brief + +You are Claude Code working inside the Codeman repository. + +Your task is to add a **small, reliable scheduling layer** to Codeman while preserving Codeman's existing architecture and session-management behavior. + +This is not a greenfield rewrite. This is not a full product rebuild. This is a focused extension. + +The target user wants Codeman-like tmux/web/session management, but with first-class scheduled jobs for Claude, Codex, OpenCode, Terminal, or any other configurable coding-agent harness. + +--- + +## 1. Non-Negotiable Goal + +Add scheduling to Codeman so a user can define a scheduled coding-agent job that: + +1. Has a name. +2. Uses an existing Codeman-supported agent/session type where possible. +3. Has a working directory. +4. Has a prompt or prompt file. +5. Has a schedule. +6. Can be enabled or disabled. +7. Can be manually run now. +8. When due, creates a Codeman/tmux session. +9. Sends the configured prompt into that session. +10. Records last run, next run, status, and run history. + +The first working version should prioritize **scheduling correctness and reuse of Codeman's existing tmux/session system** over UI polish. + +--- + +## 2. Core Architectural Rule + +Do **not** rebuild Codeman's session layer. + +Reuse existing Codeman functionality for: + +- Creating sessions. +- Naming sessions. +- Launching Claude/Codex/OpenCode/Terminal sessions. +- Sending input into sessions. +- Displaying sessions in the web UI. +- Killing sessions. +- Tracking session status if already supported. + +If an internal API/service/function already exists, reuse it. + +If no reusable function exists, create a thin wrapper around the existing implementation rather than duplicating logic. + +--- + +## 3. Product Boundary + +This build is **Codeman + Scheduler**. + +It is not yet: + +- A full quota engine. +- A full lock manager. +- A replacement for Codeman's terminal UI. +- A new FastAPI application. +- A multi-tenant SaaS platform. +- A complex cron-management product. +- A full agent autonomy framework. + +Keep the build small and shippable. + +--- + +## 4. Required Working Scope for v0.1 + +Implement the following minimum features. + +### 4.1 Scheduled Jobs List + +Create a UI page showing all scheduled jobs. + +Each row/card should show: + +- Job name. +- Agent/session type. +- Working directory. +- Schedule type. +- Enabled/disabled state. +- Last run time. +- Next run time. +- Last run status. +- Actions: + - Run Now. + - Enable/Disable. + - Edit. + - Delete. + +### 4.2 Create/Edit Scheduled Job + +Create a form for scheduled jobs with these fields: + +- `name` +- `agent_type` + - Reuse Codeman's existing session/agent types where possible. + - Include at least Terminal/custom command if supported. +- `working_directory` +- `launch_command` if needed by Codeman's model. +- `prompt_mode` + - `inline_text` + - `prompt_file_path` +- `prompt_text` +- `prompt_file_path` +- `input_mode` + - `paste` + - `typed` +- `schedule_type` + - `once` + - `interval_minutes` + - `daily_time` + - `weekly_time` +- `run_at` for one-time jobs. +- `interval_minutes` for interval jobs. +- `daily_time` for daily jobs. +- `weekly_days` and `weekly_time` for weekly jobs. +- `enabled` +- `notes` optional. + +Do not build a complex visual cron editor in v0.1. + +### 4.3 Run Now + +Every scheduled job must support a `Run Now` action. + +Run Now should: + +1. Create a new session through Codeman's existing session creation logic. +2. Send the configured prompt into the session using Codeman's existing input mechanism. +3. Create a run-history record. +4. Update last-run fields. +5. Redirect or link the user to the created Codeman session. + +### 4.4 Background Scheduler Loop + +Add a small background scheduler loop that runs inside the Codeman backend process. + +The loop should: + +1. Wake every 15-60 seconds. +2. Load enabled schedules. +3. Find schedules where `next_run_at <= now`. +4. Create a scheduled run. +5. Launch the session using existing Codeman session logic. +6. Send the prompt. +7. Record run history. +8. Compute the next run time. +9. Avoid duplicate launches if the loop overlaps or restarts. + +Keep this simple and robust. + +### 4.5 Run History + +Every scheduled execution should create a run-history record. + +Track: + +- `id` +- `scheduled_job_id` +- `session_id` or Codeman session reference. +- `session_name` if applicable. +- `started_at` +- `finished_at` optional. +- `status` + - `created` + - `session_started` + - `prompt_sent` + - `failed` +- `error_message` optional. +- `trigger_type` + - `scheduled` + - `manual_run_now` +- `created_session_url` or route reference if easy. + +--- + +## 5. Scheduling Rules + +### 5.1 Once + +Run at a specific date/time. + +After successful launch: + +- Set `enabled = false`, or mark as completed. + +### 5.2 Interval + +Run every N minutes. + +Example: + +- Every 60 minutes. +- Every 240 minutes. + +After launch: + +- `next_run_at = now + interval_minutes`. + +### 5.3 Daily + +Run every day at HH:MM. + +After launch: + +- Compute the next occurrence of HH:MM after now. + +### 5.4 Weekly + +Run on selected weekdays at HH:MM. + +After launch: + +- Compute the next selected weekday/time after now. + +### 5.5 Timezone + +Use the server's local timezone for v0.1 unless Codeman already has timezone handling. + +Add a visible note in the UI: + +> Times use the server's local timezone. + +Do not overbuild timezone support in v0.1. + +--- + +## 6. Data Storage Decision + +First inspect Codeman's existing persistence model. + +If Codeman already has a database or persistence layer: + +- Reuse it. +- Add scheduled job and scheduled run models/tables/records using the existing pattern. + +If Codeman uses files or JSON state: + +- Use the same style for v0.1. +- Prefer simple persistence over introducing a heavy new dependency. + +If there is no appropriate persistence layer: + +- Add SQLite only if it fits the codebase cleanly. +- Otherwise use a JSON file store for the first version. + +Do not introduce Postgres, Redis, Celery, or a separate scheduler service. + +--- + +## 7. Concurrency and Duplicate-Run Guard + +Implement a basic duplicate-run guard. + +A schedule should not launch twice for the same due time. + +Minimum acceptable approach: + +- Before launching, create/update a run record with a `created` or `launching` state. +- Use a schedule-level `last_triggered_at` or `last_due_key` to avoid double launching. +- If launch fails, record failure clearly. + +Do not build distributed locks. Codeman is expected to be local/single-instance for v0.1. + +--- + +## 8. Multi-Session Warning + +When the user clicks `Run Now`, show a warning if there are already active sessions for the same agent type. + +Minimum behavior: + +- If active sessions exist, show a confirmation warning. +- User can continue anyway. + +For scheduled automatic runs: + +- Add a setting on the scheduled job: + - `warn_only` + - `skip_if_same_agent_running` + +Default: + +- `warn_only` for manual runs. +- `skip_if_same_agent_running = false` for automatic runs unless easy to implement. + +Do not build a complete quota engine in v0.1. + +--- + +## 9. Prompt Sending Rules + +The scheduler must support sending the configured prompt into the created session. + +Prompt source: + +1. Inline prompt text. +2. Prompt file path. + +Input mode: + +1. Paste mode. +2. Typed mode. + +If only one input mode is easy with Codeman's current internals, implement that first and structure the code so the other can be added later. + +Important: + +- Do not send prompts to a session if session creation failed. +- Record prompt-send success/failure in run history. +- Save enough metadata to understand what prompt was used. + +--- + +## 10. UI Bifurcation + +Keep UI changes cleanly separated. + +Add scheduler UI under a clear navigation item: + +- `Scheduled Jobs` + +Do not clutter the existing session dashboard. + +The existing session dashboard may show sessions created by scheduled jobs, but the scheduling controls should live in their own section. + +Recommended pages/routes: + +- `/schedules` +- `/schedules/new` +- `/schedules/:id` +- `/schedules/:id/edit` +- `/schedules/:id/run-now` +- `/schedules/:id/enable` +- `/schedules/:id/disable` +- `/schedules/:id/delete` + +Use Codeman's existing frontend conventions and routing style. + +--- + +## 11. Backend Bifurcation + +Keep scheduler code separate from existing session code. + +Recommended logical modules, adapted to Codeman's actual structure: + +- `scheduler/model` or equivalent. +- `scheduler/store` or equivalent. +- `scheduler/service` for schedule calculations and launch logic. +- `scheduler/loop` for the background due-job checker. +- `scheduler/routes` for API/UI endpoints. +- `scheduler/time` for next-run calculations. + +Do not mix scheduling logic directly into terminal rendering, xterm handling, or low-level tmux code. + +The scheduler service should call session services; it should not own tmux directly unless Codeman has no session abstraction. + +--- + +## 12. Required Discovery Phase Before Coding + +Before implementing, inspect the Codeman repo and produce a short architecture note in the terminal or in a file called: + +`docs/cron-discovery.md` + +This note must identify: + +1. Where session creation happens. +2. Where agent/session types are defined. +3. Where input is sent into a session. +4. Where active sessions are listed. +5. Where session kill/delete is handled. +6. How session state is stored. +7. Whether there is existing persistence. +8. Where backend routes live. +9. Where frontend pages/components live. +10. The smallest integration points for scheduling. + +Do not start coding until this discovery is complete. + +--- + +## 13. Implementation Phases + +### Phase 1: Discovery + +Deliverable: + +- `docs/cron-discovery.md` + +Must answer the 10 discovery questions above. + +### Phase 2: Data Model / Persistence + +Deliverable: + +- Scheduled job persistence. +- Scheduled run history persistence. +- Basic create/read/update/delete operations. + +### Phase 3: Scheduler Calculation Logic + +Deliverable: + +- Functions to compute `next_run_at` for: + - once + - interval + - daily + - weekly + +Add tests if the repo has an existing test setup. + +### Phase 4: Manual Run Now + +Deliverable: + +- Create scheduled job. +- Click Run Now. +- Codeman session is created. +- Prompt is sent. +- Run history is recorded. +- UI links to the session. + +This is the most important milestone. + +### Phase 5: Background Scheduler Loop + +Deliverable: + +- Enabled schedules launch automatically when due. +- Run history is recorded. +- `last_run_at` and `next_run_at` update. +- Duplicate launch guard exists. + +### Phase 6: UI Polish Only After Functionality + +Deliverable: + +- Scheduled jobs list is readable. +- Create/edit form is usable. +- Status labels are clear. +- Errors are visible. + +Do not polish before Phase 4 works. + +--- + +## 14. Acceptance Criteria + +The build is acceptable when all these pass. + +### Manual Run + +1. Create a schedule/job with inline prompt. +2. Click Run Now. +3. A new Codeman/tmux session starts. +4. Prompt is sent into that session. +5. The created session is visible in Codeman's normal session UI. +6. Run history shows success or failure. + +### One-Time Schedule + +1. Create a one-time schedule 2 minutes in the future. +2. Wait for it to become due. +3. Scheduler launches a session. +4. Prompt is sent. +5. Schedule does not repeatedly launch forever. + +### Interval Schedule + +1. Create interval schedule every 2 minutes. +2. It launches once when due. +3. It computes the next due time. +4. It does not launch duplicates for the same due time. + +### Daily Schedule + +1. Create daily schedule at a time a few minutes ahead. +2. It launches when due. +3. Next run becomes tomorrow at the same time. + +### Disable Schedule + +1. Disable a schedule. +2. It does not launch even when due. + +### Error Handling + +1. Invalid working directory produces visible error. +2. Invalid prompt file produces visible error. +3. Failed session launch creates failed run-history entry. + +--- + +## 15. Explicitly Out of Scope for v0.1 + +Do not implement these unless all required scope is already working: + +- Full quota engine. +- Advanced lock manager. +- Post-run git inspection reports. +- Complex recurring calendar UI. +- User accounts / RBAC. +- External distributed workers. +- Redis. +- Postgres. +- Celery. +- Kubernetes. +- A separate Python service. +- Full visual cron editor. +- AI-generated follow-up prompts. +- Automatic continuation after idle. +- Any attempt to bypass agent quotas or platform limits. + +--- + +## 16. Quality Rules + +Follow these rules while coding: + +1. Reuse existing Codeman services and conventions. +2. Keep scheduler code isolated. +3. Prefer boring, readable code over clever abstractions. +4. Add error messages that a human can understand. +5. Do not break existing Codeman sessions. +6. Do not rename existing core concepts unnecessarily. +7. Do not introduce large dependencies without strong reason. +8. Keep v0.1 local-first and single-instance. +9. Commit in logical chunks if git is available. +10. After coding, provide a final implementation summary. + +--- + +## 17. Final Response Required from Claude Code + +At the end, report: + +1. Files changed. +2. New routes/pages added. +3. New data structures added. +4. How the scheduler loop works. +5. How to run the app. +6. How to test manual Run Now. +7. How to test scheduled execution. +8. Known limitations. +9. Suggested v0.2 improvements. + +--- + +## 18. v0.2 Ideas, Not for Current Build + +Keep these in mind but do not build unless v0.1 is complete: + +- Quota-aware scheduling. +- Manual takeover locks. +- Post-idle inspection. +- Git diff reports. +- Schedule groups. +- Prompt templates. +- Agent-specific concurrency rules. +- Better timezone support. +- Audit events. +- More advanced cron expressions. + +--- + +## 19. Final Reminder + +The goal is to add **scheduling** to Codeman quickly and cleanly. + +Do not drift into building a new platform. + +The highest-priority path is: + +1. Discover existing Codeman integration points. +2. Add scheduled job persistence. +3. Add Run Now. +4. Add background due-job loop. +5. Add minimal UI. +6. Verify that scheduled jobs create real Codeman/tmux sessions and send prompts. + diff --git a/SCHEDULER_DISCOVERY.md b/docs/cron-discovery.md similarity index 81% rename from SCHEDULER_DISCOVERY.md rename to docs/cron-discovery.md index b1f6c74e..c8164dcd 100644 --- a/SCHEDULER_DISCOVERY.md +++ b/docs/cron-discovery.md @@ -1,8 +1,8 @@ -# SCHEDULER_DISCOVERY.md +# CRON_DISCOVERY.md Phase 1 deliverable for the "Add Scheduling to Codeman" build brief. This documents the existing Codeman architecture and the smallest integration -points for a cron-style scheduler. **No session/tmux logic will be rebuilt** — +points for a cron. **No session/tmux logic will be rebuilt** — the new code is purely a trigger + persistence + history layer on top of the existing primitives. @@ -12,7 +12,7 @@ validation, ports-based dependency injection. --- -## 0. Critical finding: an existing `ScheduledRun` is NOT a scheduler +## 0. Critical finding: an existing `ScheduledRun` is NOT a cron Codeman already has a `ScheduledRun` concept (`/api/scheduled`, `src/web/ports/infra-port.ts:14-26`, `src/web/server.ts:1480-1605`). It is a @@ -25,7 +25,7 @@ or persistence across restarts. Therefore the brief's core (the calendar/cron trigger layer) does **not** exist and must be built. The execution primitives it sits on top of **do** exist and will be reused. To honor brief §16 ("do not rename existing core concepts"), the -new feature is named **`ScheduledJob`** (with **`ScheduledJobRun`** history +new feature is named **`CronJob`** (with **`CronJobRun`** history records), kept distinct from the existing `ScheduledRun`. --- @@ -38,7 +38,7 @@ records), kept distinct from the existing `ScheduledRun`. - `ctx.addSession(session)` → `ctx.setupSessionListeners(session)` → `ctx.persistSessionState(session)` (all via `SessionPort`). - `SessionPort` interface: `src/web/ports/session-port.ts:8-16`. -- **Integration point:** the scheduler service will mirror this exact sequence +- **Integration point:** the cron service will mirror this exact sequence (create → addSession → setupSessionListeners → start) via `SessionPort`, not reimplement it. @@ -69,7 +69,7 @@ records), kept distinct from the existing `ScheduledRun`. - `ctx.cleanupSession(sessionId, killMux?, reason?)` (`SessionPort`; impl `src/web/server.ts:997-1152`). Underlying `session.stop(killMux)` at `src/session.ts:2498-2585`. -- The scheduler does **not** kill sessions it launches (the brief wants them +- The cron does **not** kill sessions it launches (the brief wants them visible in the normal session UI); cleanup stays user-driven. ## 6. How session state is stored / 7. Existing persistence @@ -80,8 +80,8 @@ records), kept distinct from the existing `ScheduledRun`. - Pattern: declare a field on `AppState`, add typed get/set methods on `StateStore` that mutate in-memory state and call the debounced `save()` (500ms debounce, atomic temp-file+rename, `.bak` backup, circuit breaker). -- **Integration point:** add `scheduledJobs?: Record` and - `scheduledJobRuns?: Record` to `AppState`, with +- **Integration point:** add `cronJobs?: Record` and + `cronJobRuns?: Record` to `AppState`, with matching `StateStore` accessors. No new DB (brief §6 forbids Postgres/Redis). ## 8. Where backend routes live @@ -97,7 +97,7 @@ records), kept distinct from the existing `ScheduledRun`. (`src/web/server.ts:644-659`). - SSE: `ctx.broadcast(SseEvent.X, data)` (`EventPort`, `src/web/sse-events.ts`); frontend mirror in `src/web/public/constants.js`. -- **Integration point:** new `scheduler-routes.ts` registered alongside the +- **Integration point:** new `cron-routes.ts` registered alongside the others; new zod schema; new `SseEvent` constants for job list/run changes. ## 9. Where frontend pages/components live @@ -108,7 +108,7 @@ records), kept distinct from the existing `ScheduledRun`. bundler (`scripts/build.mjs`). - UI is panels/modals toggled by JS classes; forms use `.form-row` / `.modal` conventions (`styles.css`). SSE handler map in `app.js`. -- **Integration point:** add a new `scheduler-ui.js` mixin + a panel/modal in +- **Integration point:** add a new `cron-ui.js` mixin + a panel/modal in `index.html` + nav entry, following the orchestrator/respawn panel pattern. ## 10. Background-loop pattern (for the due-checker) @@ -117,7 +117,7 @@ records), kept distinct from the existing `ScheduledRun`. in `WebServer.start()` (`src/web/server.ts:~1942-1966`), auto-disposed in `WebServer.stop()` via `this.cleanup.dispose()` (`src/web/server.ts:2336`). RalphLoop (`src/ralph-loop.ts:268-286`) shows the self-rescheduling guard idiom. -- **Integration point:** register a 30s scheduler tick via `cleanup.setInterval`; +- **Integration point:** register a 30s cron tick via `cleanup.setInterval`; no manual shutdown wiring needed. --- @@ -126,13 +126,13 @@ records), kept distinct from the existing `ScheduledRun`. | New piece | Reuses | Location | | --- | --- | --- | -| `ScheduledJob` / `ScheduledJobRun` types | — (new) | `src/types/scheduler.ts` | +| `CronJob` / `CronJobRun` types | — (new) | `src/types/cron.ts` | | Persistence | `StateStore` / `AppState` | `src/types/app-state.ts`, `src/state-store.ts` | -| Next-run time math | — (new, pure, unit-tested) | `src/scheduler/scheduler-time.ts` | -| Launch + send prompt | `SessionPort` (`addSession`/listeners/`writeViaMux`) | `src/scheduler/scheduler-service.ts` | -| Background due loop | `cleanup.setInterval` pattern | `src/scheduler/scheduler-loop.ts` | -| Routes + schema | route/ports/zod/SSE patterns | `src/web/routes/scheduler-routes.ts`, `src/web/schemas.ts`, `src/web/sse-events.ts` | -| UI | panel/modal/mixin conventions | `src/web/public/scheduler-ui.js`, `index.html` | +| Next-run time math | — (new, pure, unit-tested) | `src/cron/cron-time.ts` | +| Launch + send prompt | `SessionPort` (`addSession`/listeners/`writeViaMux`) | `src/cron/cron-service.ts` | +| Background due loop | `cleanup.setInterval` pattern | `src/cron/cron-loop.ts` | +| Routes + schema | route/ports/zod/SSE patterns | `src/web/routes/cron-routes.ts`, `src/web/schemas.ts`, `src/web/sse-events.ts` | +| UI | panel/modal/mixin conventions | `src/web/public/cron-ui.js`, `index.html` | Nothing in the session, tmux, persistence, routing, or SSE subsystems is -rewritten — the scheduler is additive and calls existing services. +rewritten — the cron is additive and calls existing services. diff --git a/src/config/server-timing.ts b/src/config/server-timing.ts index 0c47e8ec..d4e98ff2 100644 --- a/src/config/server-timing.ts +++ b/src/config/server-timing.ts @@ -52,17 +52,17 @@ export const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000; export const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000; // ============================================================================ -// Scheduled Jobs (cron-style scheduler) +// Cron Jobs // ============================================================================ -/** How often the scheduler loop wakes to check for due jobs (ms). */ -export const SCHEDULER_TICK_INTERVAL = 30 * 1000; +/** How often the cron loop wakes to check for due jobs (ms). */ +export const CRON_TICK_INTERVAL = 30 * 1000; /** Max attempts (× 500ms) to poll a launched session for CLI readiness before sending the prompt. */ -export const SCHEDULER_READY_MAX_ATTEMPTS = 60; +export const CRON_READY_MAX_ATTEMPTS = 60; /** Extra settle delay after CLI readiness is detected, before sending the prompt (ms). */ -export const SCHEDULER_READY_SETTLE_MS = 2000; +export const CRON_READY_SETTLE_MS = 2000; /** Session limit retry wait before retrying (ms) */ export const SESSION_LIMIT_WAIT_MS = 5000; diff --git a/src/scheduler/scheduler-input.ts b/src/cron/cron-input.ts similarity index 64% rename from src/scheduler/scheduler-input.ts rename to src/cron/cron-input.ts index c0e73dcf..c0783d6c 100644 --- a/src/scheduler/scheduler-input.ts +++ b/src/cron/cron-input.ts @@ -1,15 +1,15 @@ /** - * @fileoverview Input shape for creating/updating a scheduled job. This is the - * user-settable subset of `ScheduledJob` (server-maintained bookkeeping fields + * @fileoverview Input shape for creating/updating a cron job. This is the + * user-settable subset of `CronJob` (server-maintained bookkeeping fields * such as nextRunAt / lastStatus are excluded). Produced by the zod schema. */ -import type { ConcurrencyPolicy, InputMode, PromptMode, ScheduleType } from '../types/scheduler.js'; +import type { ConcurrencyPolicy, InputMode, PromptMode, ScheduleType } from '../types/cron.js'; import type { SessionMode } from '../types/session.js'; -export type { ScheduledJob, ScheduledJobRun, ScheduledJobRunStatus, TriggerType } from '../types/scheduler.js'; +export type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js'; -export interface ScheduledJobInput { +export interface CronJobInput { name: string; agentType: SessionMode; workingDir: string; diff --git a/src/scheduler/scheduler-service.ts b/src/cron/cron-service.ts similarity index 75% rename from src/scheduler/scheduler-service.ts rename to src/cron/cron-service.ts index 6cbc2456..690b5dfc 100644 --- a/src/scheduler/scheduler-service.ts +++ b/src/cron/cron-service.ts @@ -1,5 +1,5 @@ /** - * @fileoverview Scheduler service: CRUD for scheduled jobs, manual Run Now, + * @fileoverview Cron service: CRUD for cron jobs, manual Run Now, * the background due-job tick, and run-history recording. * * It does NOT own session/tmux logic — it reuses Codeman's existing session @@ -14,19 +14,19 @@ import { Session } from '../session.js'; import { SseEvent } from '../web/sse-events.js'; import { getErrorMessage } from '../types/api.js'; import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js'; -import { SCHEDULER_READY_MAX_ATTEMPTS, SCHEDULER_READY_SETTLE_MS } from '../config/server-timing.js'; -import { computeNextRunAt, dueKeyFor } from './scheduler-time.js'; +import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js'; +import { computeNextRunAt, dueKeyFor } from './cron-time.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js'; -import type { ScheduledJob, ScheduledJobRun, ScheduledJobRunStatus, TriggerType } from '../types/scheduler.js'; -import type { ScheduledJobInput } from './scheduler-input.js'; +import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js'; +import type { CronJobInput } from './cron-input.js'; -/** The subset of the route context the scheduler depends on. */ -export type SchedulerDeps = SessionPort & EventPort & ConfigPort & InfraPort; +/** The subset of the route context the cron depends on. */ +export type CronDeps = SessionPort & EventPort & ConfigPort & InfraPort; const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -export class SchedulerService { - constructor(private readonly deps: SchedulerDeps) {} +export class CronService { + constructor(private readonly deps: CronDeps) {} private get store() { return this.deps.store; @@ -34,17 +34,17 @@ export class SchedulerService { // ───────────────────────────── Reads ───────────────────────────── - listJobs(): ScheduledJob[] { - return Object.values(this.store.getScheduledJobs()); + listJobs(): CronJob[] { + return Object.values(this.store.getCronJobs()); } - getJob(id: string): ScheduledJob | null { - return this.store.getScheduledJob(id); + getJob(id: string): CronJob | null { + return this.store.getCronJob(id); } - listRuns(jobId?: string): ScheduledJobRun[] { - const all = Object.values(this.store.getScheduledJobRuns()); - const filtered = jobId ? all.filter((r) => r.scheduledJobId === jobId) : all; + listRuns(jobId?: string): CronJobRun[] { + const all = Object.values(this.store.getCronJobRuns()); + const filtered = jobId ? all.filter((r) => r.cronJobId === jobId) : all; return filtered.sort((a, b) => b.startedAt - a.startedAt); } @@ -57,9 +57,9 @@ export class SchedulerService { // ──────────────────────────── Mutations ─────────────────────────── - createJob(input: ScheduledJobInput): ScheduledJob { + createJob(input: CronJobInput): CronJob { const now = Date.now(); - const job: ScheduledJob = { + const job: CronJob = { id: uuidv4(), name: input.name, agentType: input.agentType, @@ -86,16 +86,16 @@ export class SchedulerService { lastDueKey: null, }; job.nextRunAt = job.enabled ? computeNextRunAt(job, now) : null; - this.store.setScheduledJob(job.id, job); + this.store.setCronJob(job.id, job); this.broadcastListChanged(); return job; } - updateJob(id: string, patch: Partial): ScheduledJob | null { + updateJob(id: string, patch: Partial): CronJob | null { const existing = this.getJob(id); if (!existing) return null; const now = Date.now(); - const updated: ScheduledJob = { + const updated: CronJob = { ...existing, ...patch, id: existing.id, @@ -107,28 +107,28 @@ export class SchedulerService { lastDueKey: null, }; updated.nextRunAt = updated.enabled ? computeNextRunAt(updated, now) : null; - this.store.setScheduledJob(updated.id, updated); + this.store.setCronJob(updated.id, updated); this.broadcastListChanged(); return updated; } - setEnabled(id: string, enabled: boolean): ScheduledJob | null { + setEnabled(id: string, enabled: boolean): CronJob | null { const existing = this.getJob(id); if (!existing) return null; const now = Date.now(); existing.enabled = enabled; existing.updatedAt = now; existing.nextRunAt = enabled ? computeNextRunAt(existing, now) : null; - this.store.setScheduledJob(existing.id, existing); + this.store.setCronJob(existing.id, existing); this.broadcastListChanged(); return existing; } deleteJob(id: string): boolean { if (!this.getJob(id)) return false; - this.store.removeScheduledJob(id); - for (const run of this.listRuns(id)) this.store.removeScheduledJobRun(run.id); - this.deps.broadcast(SseEvent.SchedulerJobDeleted, { id }); + this.store.removeCronJob(id); + for (const run of this.listRuns(id)) this.store.removeCronJobRun(run.id); + this.deps.broadcast(SseEvent.CronJobDeleted, { id }); this.broadcastListChanged(); return true; } @@ -136,7 +136,7 @@ export class SchedulerService { // ──────────────────────────── Execution ─────────────────────────── /** Manual Run Now — always launches regardless of schedule/enabled state. */ - async runNow(id: string): Promise { + async runNow(id: string): Promise { const job = this.getJob(id); if (!job) return null; return this.launch(job, 'manual_run_now'); @@ -169,7 +169,7 @@ export class SchedulerService { // re-triggered by the next tick. this.advanceAfterFire(job, now); this.launch(job, 'scheduled').catch((err) => - console.error(`[scheduler] launch failed for job ${job.id}:`, getErrorMessage(err)) + console.error(`[cron] launch failed for job ${job.id}:`, getErrorMessage(err)) ); } } @@ -181,14 +181,14 @@ export class SchedulerService { const isDeadOnce = job.scheduleType === 'once' && job.completedOnce; if (job.enabled && job.nextRunAt == null && !isDeadOnce) { job.nextRunAt = computeNextRunAt(job, now); - this.store.setScheduledJob(job.id, job); + this.store.setCronJob(job.id, job); } } } // ──────────────────────────── Internals ─────────────────────────── - private advanceAfterFire(job: ScheduledJob, now: number): void { + private advanceAfterFire(job: CronJob, now: number): void { if (job.scheduleType === 'once') { job.completedOnce = true; job.enabled = false; @@ -197,14 +197,14 @@ export class SchedulerService { job.nextRunAt = computeNextRunAt(job, now); } job.updatedAt = now; - this.store.setScheduledJob(job.id, job); + this.store.setCronJob(job.id, job); this.broadcastListChanged(); } - private async launch(job: ScheduledJob, trigger: TriggerType): Promise { - const run: ScheduledJobRun = { + private async launch(job: CronJob, trigger: TriggerType): Promise { + const run: CronJobRun = { id: uuidv4(), - scheduledJobId: job.id, + cronJobId: job.id, sessionId: null, sessionName: null, startedAt: Date.now(), @@ -213,8 +213,8 @@ export class SchedulerService { triggerType: trigger, createdSessionUrl: null, }; - this.store.setScheduledJobRun(run.id, run); - this.deps.broadcast(SseEvent.SchedulerRunCreated, run); + this.store.setCronJobRun(run.id, run); + this.deps.broadcast(SseEvent.CronRunCreated, run); // Resolve the prompt. let prompt: string; @@ -276,8 +276,8 @@ export class SchedulerService { run.sessionName = session.name; run.createdSessionUrl = `/?session=${session.id}`; run.status = 'session_started'; - this.store.setScheduledJobRun(run.id, run); - this.deps.broadcast(SseEvent.SchedulerRunUpdated, run); + this.store.setCronJobRun(run.id, run); + this.deps.broadcast(SseEvent.CronRunUpdated, run); this.updateJobLastStatus(job.id, 'session_started'); // Send the prompt once the CLI is ready (async; does not block the caller). @@ -285,7 +285,7 @@ export class SchedulerService { return run; } - private async resolvePrompt(job: ScheduledJob): Promise { + private async resolvePrompt(job: CronJob): Promise { if (job.promptMode === 'prompt_file_path') { if (!job.promptFilePath) throw new Error('prompt file path is empty'); return readFile(job.promptFilePath, 'utf-8'); @@ -293,18 +293,18 @@ export class SchedulerService { return job.promptText ?? ''; } - private sendPromptWhenReady(sessionId: string, prompt: string, job: ScheduledJob, run: ScheduledJobRun): void { + private sendPromptWhenReady(sessionId: string, prompt: string, job: CronJob, run: CronJobRun): void { setImmediate(() => { const poll = async (): Promise => { if (job.agentType !== 'shell') { - for (let attempt = 0; attempt < SCHEDULER_READY_MAX_ATTEMPTS; attempt++) { + for (let attempt = 0; attempt < CRON_READY_MAX_ATTEMPTS; attempt++) { await delay(500); const s = this.deps.sessions.get(sessionId); if (!s) return; // session was removed const buf = s.getTerminalBuffer().slice(-2048); if (buf.includes('❯') || buf.includes('tokens')) break; } - await delay(SCHEDULER_READY_SETTLE_MS); + await delay(CRON_READY_SETTLE_MS); } else { await delay(1000); } @@ -319,39 +319,39 @@ export class SchedulerService { } run.status = 'prompt_sent'; run.finishedAt = Date.now(); - this.store.setScheduledJobRun(run.id, run); - this.deps.broadcast(SseEvent.SchedulerRunUpdated, run); + this.store.setCronJobRun(run.id, run); + this.deps.broadcast(SseEvent.CronRunUpdated, run); this.updateJobLastStatus(job.id, 'prompt_sent'); } catch (err) { this.failRun(job, run, `Failed to send prompt: ${getErrorMessage(err)}`); } }; - poll().catch((err) => console.error('[scheduler] sendPromptWhenReady error:', getErrorMessage(err))); + poll().catch((err) => console.error('[cron] sendPromptWhenReady error:', getErrorMessage(err))); }); } - private failRun(job: ScheduledJob, run: ScheduledJobRun, message: string): ScheduledJobRun { + private failRun(job: CronJob, run: CronJobRun, message: string): CronJobRun { run.status = 'failed'; run.errorMessage = message; run.finishedAt = Date.now(); - this.store.setScheduledJobRun(run.id, run); - this.deps.broadcast(SseEvent.SchedulerRunUpdated, run); + this.store.setCronJobRun(run.id, run); + this.deps.broadcast(SseEvent.CronRunUpdated, run); this.updateJobLastStatus(job.id, 'failed'); return run; } - private updateJobLastStatus(jobId: string, status: ScheduledJobRunStatus): void { - const fresh = this.store.getScheduledJob(jobId); + private updateJobLastStatus(jobId: string, status: CronJobRunStatus): void { + const fresh = this.store.getCronJob(jobId); if (!fresh) return; const now = Date.now(); fresh.lastStatus = status; fresh.lastRunAt = now; fresh.updatedAt = now; - this.store.setScheduledJob(fresh.id, fresh); + this.store.setCronJob(fresh.id, fresh); this.broadcastListChanged(); } private broadcastListChanged(): void { - this.deps.broadcast(SseEvent.SchedulerJobsChanged, { jobs: this.listJobs() }); + this.deps.broadcast(SseEvent.CronJobsChanged, { jobs: this.listJobs() }); } } diff --git a/src/scheduler/scheduler-time.ts b/src/cron/cron-time.ts similarity index 90% rename from src/scheduler/scheduler-time.ts rename to src/cron/cron-time.ts index 3d006f00..39832ca0 100644 --- a/src/scheduler/scheduler-time.ts +++ b/src/cron/cron-time.ts @@ -1,5 +1,5 @@ /** - * @fileoverview Pure next-run-time calculations for the scheduler. + * @fileoverview Pure next-run-time calculations for the cron. * * All functions are pure and take an explicit `after` timestamp (epoch ms) so * they are deterministic and unit-testable. Times use the SERVER'S LOCAL @@ -7,7 +7,7 @@ * interpreted via the host's local time. */ -import type { ScheduledJob } from '../types/scheduler.js'; +import type { CronJob } from '../types/cron.js'; /** Parse an 'HH:MM' (24-hour) string into hours/minutes, or null if invalid. */ export function parseHHMM(value: string | undefined): { hours: number; minutes: number } | null { @@ -38,7 +38,7 @@ function atLocalTime(base: number, hours: number, minutes: number, dayOffset: nu * For `once`, returns the absolute `runAt` (even if already in the past, so a * missed one-time job still fires once) until it has `completedOnce`. */ -export function computeNextRunAt(job: ScheduledJob, after: number): number | null { +export function computeNextRunAt(job: CronJob, after: number): number | null { switch (job.scheduleType) { case 'once': { if (job.completedOnce) return null; @@ -74,7 +74,7 @@ export function computeNextRunAt(job: ScheduledJob, after: number): number | nul /** * Duplicate-launch guard key: identifies a specific due time for a job. The - * scheduler records the key it last consumed so an overlapping or restarted + * cron records the key it last consumed so an overlapping or restarted * loop will not launch the same due time twice. */ export function dueKeyFor(jobId: string, fireTime: number): string { diff --git a/src/state-store.ts b/src/state-store.ts index f5a24c44..1cb6378e 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -272,11 +272,11 @@ 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.cronJobs) { + parts.push(`"cronJobs":${JSON.stringify(this.state.cronJobs)}`); } - if (this.state.scheduledJobRuns) { - parts.push(`"scheduledJobRuns":${JSON.stringify(this.state.scheduledJobRuns)}`); + if (this.state.cronJobRuns) { + parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`); } return `{${parts.join(',')}}`; @@ -574,48 +574,48 @@ export class StateStore { this.save(); } - // ========== Scheduled Job Methods (cron-style scheduler) ========== + // ========== Cron Job Methods ========== /** Returns all scheduled jobs keyed by job ID. */ - getScheduledJobs(): Record { - if (!this.state.scheduledJobs) this.state.scheduledJobs = {}; - return this.state.scheduledJobs; + getCronJobs(): Record { + if (!this.state.cronJobs) this.state.cronJobs = {}; + return this.state.cronJobs; } /** 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; + getCronJob(id: string): import('./types/cron.js').CronJob | null { + return this.state.cronJobs?.[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; + setCronJob(id: string, job: import('./types/cron.js').CronJob): void { + if (!this.state.cronJobs) this.state.cronJobs = {}; + this.state.cronJobs[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]; + removeCronJob(id: string): void { + if (this.state.cronJobs) delete this.state.cronJobs[id]; this.save(); } /** Returns all scheduled job runs keyed by run ID. */ - getScheduledJobRuns(): Record { - if (!this.state.scheduledJobRuns) this.state.scheduledJobRuns = {}; - return this.state.scheduledJobRuns; + getCronJobRuns(): Record { + if (!this.state.cronJobRuns) this.state.cronJobRuns = {}; + return this.state.cronJobRuns; } /** 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; + setCronJobRun(id: string, run: import('./types/cron.js').CronJobRun): void { + if (!this.state.cronJobRuns) this.state.cronJobRuns = {}; + this.state.cronJobRuns[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]; + removeCronJobRun(id: string): void { + if (this.state.cronJobRuns) delete this.state.cronJobRuns[id]; this.save(); } diff --git a/src/types/app-state.ts b/src/types/app-state.ts index d6797714..e0b39838 100644 --- a/src/types/app-state.ts +++ b/src/types/app-state.ts @@ -23,7 +23,7 @@ import type { SessionState } from './session.js'; import type { TaskState } from './task.js'; import type { RalphLoopState } from './ralph.js'; import type { RespawnConfig } from './respawn.js'; -import type { ScheduledJob, ScheduledJobRun } from './scheduler.js'; +import type { CronJob, CronJobRun } from './cron.js'; // ========== Global Stats Types ========== @@ -113,9 +113,9 @@ export interface AppState { /** Orchestrator Loop state (phased plan execution) */ orchestrator?: import('./orchestrator.js').OrchestratorPersistState; /** Cron-style scheduled jobs, keyed by job ID. */ - scheduledJobs?: Record; + cronJobs?: Record; /** Scheduled job run history, keyed by run ID. */ - scheduledJobRuns?: Record; + cronJobRuns?: Record; } // ========== Default Configuration ========== diff --git a/src/types/scheduler.ts b/src/types/cron.ts similarity index 82% rename from src/types/scheduler.ts rename to src/types/cron.ts index d8b2552b..9e7de29f 100644 --- a/src/types/scheduler.ts +++ b/src/types/cron.ts @@ -1,11 +1,11 @@ /** - * @fileoverview Scheduled Jobs (cron-style scheduler) type definitions. + * @fileoverview Cron Jobs type definitions. * * NOTE: This is intentionally distinct from the existing `ScheduledRun` concept * (see src/web/ports/infra-port.ts), which is a run-now, duration-bounded - * autonomous loop. A `ScheduledJob` is a SAVED, NAMED job with a recurring + * autonomous loop. A `CronJob` is a SAVED, NAMED job with a recurring * schedule (once/interval/daily/weekly), enable/disable, next-run calculation, - * and a history of `ScheduledJobRun` records. The two do not interact. + * and a history of `CronJobRun` records. The two do not interact. * * Persisted to `~/.codeman/state.json` via StateStore (see AppState). */ @@ -22,7 +22,7 @@ export type PromptMode = 'inline_text' | 'prompt_file_path'; export type InputMode = 'paste' | 'typed'; /** Lifecycle status of a single job execution. */ -export type ScheduledJobRunStatus = 'created' | 'session_started' | 'prompt_sent' | 'failed'; +export type CronJobRunStatus = 'created' | 'session_started' | 'prompt_sent' | 'failed'; /** What triggered a run. */ export type TriggerType = 'scheduled' | 'manual_run_now'; @@ -31,9 +31,9 @@ export type TriggerType = 'scheduled' | 'manual_run_now'; export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running'; /** - * A saved, named scheduled job. + * A saved, named cron job. */ -export interface ScheduledJob { +export interface CronJob { id: string; name: string; /** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */ @@ -69,7 +69,7 @@ export interface ScheduledJob { updatedAt: number; lastRunAt: number | null; nextRunAt: number | null; - lastStatus: ScheduledJobRunStatus | null; + lastStatus: CronJobRunStatus | null; /** Duplicate-launch guard: identifies the most recent due-time consumed. */ lastDueKey: string | null; /** True once a 'once' job has fired (it is also disabled). */ @@ -77,16 +77,16 @@ export interface ScheduledJob { } /** - * A single execution of a scheduled job (history record). + * A single execution of a cron job (history record). */ -export interface ScheduledJobRun { +export interface CronJobRun { id: string; - scheduledJobId: string; + cronJobId: string; sessionId: string | null; sessionName: string | null; startedAt: number; finishedAt: number | null; - status: ScheduledJobRunStatus; + status: CronJobRunStatus; errorMessage?: string; triggerType: TriggerType; /** Best-effort deep link to the created session in the web UI. */ diff --git a/src/web/ports/cron-port.ts b/src/web/ports/cron-port.ts new file mode 100644 index 00000000..4dec6554 --- /dev/null +++ b/src/web/ports/cron-port.ts @@ -0,0 +1,10 @@ +/** + * @fileoverview Cron port — exposes the CronService to + * route handlers via the shared route context. + */ + +import type { CronService } from '../../cron/cron-service.js'; + +export interface CronPort { + readonly cron: CronService; +} diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index 3c952dc5..18cbe603 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -13,4 +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'; +export type { CronPort } from './cron-port.js'; diff --git a/src/web/ports/scheduler-port.ts b/src/web/ports/scheduler-port.ts deleted file mode 100644 index d8632bb9..00000000 --- a/src/web/ports/scheduler-port.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @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; -} diff --git a/src/web/public/app.js b/src/web/public/app.js index eb9a1754..74dcdd5a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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'], diff --git a/src/web/public/constants.js b/src/web/public/constants.js index eabf8dcc..4a98338e 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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', diff --git a/src/web/public/scheduler-ui.js b/src/web/public/cron-ui.js similarity index 70% rename from src/web/public/scheduler-ui.js rename to src/web/public/cron-ui.js index 4f343989..3bbfa29b 100644 --- a/src/web/public/scheduler-ui.js +++ b/src/web/public/cron-ui.js @@ -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 = '
No scheduled jobs yet. Click “+ New Job”.
'; + list.innerHTML = '
No cron jobs yet. Click “+ New Job”.
'; 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 ` -
-
-
${escapeHtml(j.name || '(unnamed)')} - ${escapeHtml(j.agentType)} - ${escapeHtml(this._fmtSchedule(j))} - ${j.enabled ? '' : 'disabled'} +
+
+
${escapeHtml(j.name || '(unnamed)')} + ${escapeHtml(j.agentType)} + ${escapeHtml(this._fmtSchedule(j))} + ${j.enabled ? '' : 'disabled'}
-
+
${escapeHtml(j.workingDir || '')} · next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status}
-
- - - - +
+ + + +
`; }); @@ -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'); } diff --git a/src/web/public/index.html b/src/web/public/index.html index 0d5d0b9b..9ddf7aab 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -541,7 +541,7 @@

Resume Conversation

- + v0.0.0
@@ -571,26 +571,26 @@

Keyboard Shortcuts

- -