refactor(cron): rename scheduler feature to cron

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
This commit is contained in:
Kris
2026-06-29 11:35:56 +05:30
co-authored by Claude Opus 4.8
parent 2d2f4e592b
commit 9feaa0d6e5
27 changed files with 1383 additions and 370 deletions
+6 -3
View File
@@ -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 <id>`, `--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 <default|auto_edit|yolo|plan>` 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<T>` 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`).
+165 -3
View File
@@ -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 <cmd>`, 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 <id> # 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 <path> # 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<T>` 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 |
|--------|----------|-------------|
+588
View File
@@ -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.
@@ -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<string, ScheduledJob>` and
`scheduledJobRuns?: Record<string, ScheduledJobRun>` to `AppState`, with
- **Integration point:** add `cronJobs?: Record<string, CronJob>` and
`cronJobRuns?: Record<string, CronJobRun>` 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.
+5 -5
View File
@@ -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;
@@ -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;
@@ -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<void> => 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<ScheduledJobInput>): ScheduledJob | null {
updateJob(id: string, patch: Partial<CronJobInput>): 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<ScheduledJobRun | null> {
async runNow(id: string): Promise<CronJobRun | null> {
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<ScheduledJobRun> {
const run: ScheduledJobRun = {
private async launch(job: CronJob, trigger: TriggerType): Promise<CronJobRun> {
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<string> {
private async resolvePrompt(job: CronJob): Promise<string> {
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<void> => {
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() });
}
}
@@ -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 {
+23 -23
View File
@@ -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<string, import('./types/scheduler.js').ScheduledJob> {
if (!this.state.scheduledJobs) this.state.scheduledJobs = {};
return this.state.scheduledJobs;
getCronJobs(): Record<string, import('./types/cron.js').CronJob> {
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<string, import('./types/scheduler.js').ScheduledJobRun> {
if (!this.state.scheduledJobRuns) this.state.scheduledJobRuns = {};
return this.state.scheduledJobRuns;
getCronJobRuns(): Record<string, import('./types/cron.js').CronJobRun> {
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();
}
+3 -3
View File
@@ -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<string, ScheduledJob>;
cronJobs?: Record<string, CronJob>;
/** Scheduled job run history, keyed by run ID. */
scheduledJobRuns?: Record<string, ScheduledJobRun>;
cronJobRuns?: Record<string, CronJobRun>;
}
// ========== Default Configuration ==========
+11 -11
View File
@@ -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. */
+10
View File
@@ -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;
}
+1 -1
View File
@@ -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';
-10
View File
@@ -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;
}
+4 -4
View File
@@ -166,10 +166,10 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'],
// Scheduled jobs (cron-style scheduler)
[SSE_EVENTS.SCHEDULER_JOBS_CHANGED, '_onSchedulerJobsChanged'],
[SSE_EVENTS.SCHEDULER_JOB_DELETED, '_onSchedulerJobsChanged'],
[SSE_EVENTS.SCHEDULER_RUN_CREATED, '_onSchedulerRunChanged'],
[SSE_EVENTS.SCHEDULER_RUN_UPDATED, '_onSchedulerRunChanged'],
[SSE_EVENTS.CRON_JOBS_CHANGED, '_onCronJobsChanged'],
[SSE_EVENTS.CRON_JOB_DELETED, '_onCronJobsChanged'],
[SSE_EVENTS.CRON_RUN_CREATED, '_onCronRunChanged'],
[SSE_EVENTS.CRON_RUN_UPDATED, '_onCronRunChanged'],
// Respawn
[SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'],
+5 -5
View File
@@ -276,11 +276,11 @@ const SSE_EVENTS = {
SCHEDULED_LOG: 'scheduled:log',
SCHEDULED_DELETED: 'scheduled:deleted',
// Scheduled jobs (cron-style scheduler)
SCHEDULER_JOBS_CHANGED: 'scheduler:jobsChanged',
SCHEDULER_JOB_DELETED: 'scheduler:jobDeleted',
SCHEDULER_RUN_CREATED: 'scheduler:runCreated',
SCHEDULER_RUN_UPDATED: 'scheduler:runUpdated',
// Cron jobs
CRON_JOBS_CHANGED: 'cron:jobsChanged',
CRON_JOB_DELETED: 'cron:jobDeleted',
CRON_RUN_CREATED: 'cron:runCreated',
CRON_RUN_UPDATED: 'cron:runUpdated',
// Respawn
RESPAWN_STARTED: 'respawn:started',
@@ -1,7 +1,7 @@
/**
* @fileoverview Scheduled Jobs UI (cron-style scheduler) mixed into
* @fileoverview Cron Jobs UI mixed into
* CodemanApp.prototype. Renders the job list + create/edit form in the
* #schedulerModal, and reacts to scheduler:* SSE events.
* #cronModal, and reacts to cron:* SSE events.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js, api-client.js, constants.js (escapeHtml)
@@ -10,54 +10,54 @@
Object.assign(CodemanApp.prototype, {
// ── SSE handlers ──────────────────────────────────────────────────────────
_onSchedulerJobsChanged(data) {
_onCronJobsChanged(data) {
if (data && Array.isArray(data.jobs)) {
this._schedulerJobs = data.jobs;
if (this._isSchedulerOpen()) this.renderSchedulerJobs();
} else if (this._isSchedulerOpen()) {
this.refreshScheduler();
this._cronJobs = data.jobs;
if (this._isCronOpen()) this.renderCronJobs();
} else if (this._isCronOpen()) {
this.refreshCron();
}
},
_onSchedulerRunChanged() {
_onCronRunChanged() {
// A run's status changed — refresh the list so lastStatus stays current.
if (this._isSchedulerOpen()) this.refreshScheduler();
if (this._isCronOpen()) this.refreshCron();
},
// ── Modal open/close ──────────────────────────────────────────────────────
_isSchedulerOpen() {
const el = document.getElementById('schedulerModal');
_isCronOpen() {
const el = document.getElementById('cronModal');
return !!el && el.classList.contains('active');
},
openScheduler() {
const el = document.getElementById('schedulerModal');
openCron() {
const el = document.getElementById('cronModal');
if (!el) return;
el.classList.add('active');
this.cancelSchedulerJobForm();
this.refreshScheduler();
this.cancelCronJobForm();
this.refreshCron();
},
closeScheduler() {
const el = document.getElementById('schedulerModal');
closeCron() {
const el = document.getElementById('cronModal');
if (el) el.classList.remove('active');
},
async refreshScheduler() {
const jobs = await this._apiJson('/api/scheduler/jobs');
this._schedulerJobs = Array.isArray(jobs) ? jobs : [];
this.renderSchedulerJobs();
async refreshCron() {
const jobs = await this._apiJson('/api/cron/jobs');
this._cronJobs = Array.isArray(jobs) ? jobs : [];
this.renderCronJobs();
},
// ── List rendering ────────────────────────────────────────────────────────
renderSchedulerJobs() {
const list = document.getElementById('schedulerJobList');
renderCronJobs() {
const list = document.getElementById('cronJobList');
if (!list) return;
const jobs = this._schedulerJobs || [];
const jobs = this._cronJobs || [];
if (jobs.length === 0) {
list.innerHTML = '<div class="form-hint">No scheduled jobs yet. Click “+ New Job”.</div>';
list.innerHTML = '<div class="form-hint">No cron jobs yet. Click “+ New Job”.</div>';
return;
}
const rows = jobs.map((j) => {
@@ -65,23 +65,23 @@ Object.assign(CodemanApp.prototype, {
const last = this._fmtTime(j.lastRunAt);
const status = j.lastStatus ? escapeHtml(j.lastStatus) : '—';
return `
<div class="scheduler-job-row">
<div class="scheduler-job-main">
<div class="scheduler-job-name">${escapeHtml(j.name || '(unnamed)')}
<span class="scheduler-badge">${escapeHtml(j.agentType)}</span>
<span class="scheduler-badge">${escapeHtml(this._fmtSchedule(j))}</span>
${j.enabled ? '' : '<span class="scheduler-badge scheduler-badge-off">disabled</span>'}
<div class="cron-job-row">
<div class="cron-job-main">
<div class="cron-job-name">${escapeHtml(j.name || '(unnamed)')}
<span class="cron-badge">${escapeHtml(j.agentType)}</span>
<span class="cron-badge">${escapeHtml(this._fmtSchedule(j))}</span>
${j.enabled ? '' : '<span class="cron-badge cron-badge-off">disabled</span>'}
</div>
<div class="scheduler-job-meta">
<div class="cron-job-meta">
<span title="${escapeHtml(j.workingDir || '')}">${escapeHtml(j.workingDir || '')}</span>
· next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status}
</div>
</div>
<div class="scheduler-job-actions">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.runSchedulerJob('${j.id}')">Run Now</button>
<button class="btn-toolbar btn-sm" onclick="app.toggleSchedulerJob('${j.id}', ${j.enabled ? 'false' : 'true'})">${j.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn-toolbar btn-sm" onclick="app.editSchedulerJob('${j.id}')">Edit</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="app.deleteSchedulerJob('${j.id}')">Delete</button>
<div class="cron-job-actions">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.runCronJob('${j.id}')">Run Now</button>
<button class="btn-toolbar btn-sm" onclick="app.toggleCronJob('${j.id}', ${j.enabled ? 'false' : 'true'})">${j.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn-toolbar btn-sm" onclick="app.editCronJob('${j.id}')">Edit</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="app.deleteCronJob('${j.id}')">Delete</button>
</div>
</div>`;
});
@@ -117,11 +117,11 @@ Object.assign(CodemanApp.prototype, {
// ── Create / edit form ────────────────────────────────────────────────────
openSchedulerJobForm(job) {
const form = document.getElementById('schedulerJobForm');
openCronJobForm(job) {
const form = document.getElementById('cronJobForm');
if (!form) return;
document.getElementById('schedulerFormError').textContent = '';
document.getElementById('schedulerFormTitle').textContent = job ? 'Edit Scheduled Job' : 'New Scheduled Job';
document.getElementById('cronFormError').textContent = '';
document.getElementById('cronFormTitle').textContent = job ? 'Edit Cron Job' : 'New Cron Job';
document.getElementById('schJobId').value = job ? job.id : '';
document.getElementById('schName').value = job ? job.name || '' : '';
document.getElementById('schAgentType').value = job ? job.agentType || 'claude' : 'claude';
@@ -143,28 +143,28 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('schEnabled').checked = job ? !!job.enabled : true;
document.getElementById('schNotes').value = job ? job.notes || '' : '';
this.onSchedulerPromptModeChange();
this.onSchedulerScheduleTypeChange();
this.onCronPromptModeChange();
this.onCronScheduleTypeChange();
form.classList.remove('hidden');
},
editSchedulerJob(id) {
const job = (this._schedulerJobs || []).find((j) => j.id === id);
if (job) this.openSchedulerJobForm(job);
editCronJob(id) {
const job = (this._cronJobs || []).find((j) => j.id === id);
if (job) this.openCronJobForm(job);
},
cancelSchedulerJobForm() {
const form = document.getElementById('schedulerJobForm');
cancelCronJobForm() {
const form = document.getElementById('cronJobForm');
if (form) form.classList.add('hidden');
},
onSchedulerPromptModeChange() {
onCronPromptModeChange() {
const mode = document.getElementById('schPromptMode').value;
document.getElementById('schPromptTextRow').classList.toggle('hidden', mode !== 'inline_text');
document.getElementById('schPromptFileRow').classList.toggle('hidden', mode !== 'prompt_file_path');
},
onSchedulerScheduleTypeChange() {
onCronScheduleTypeChange() {
const t = document.getElementById('schScheduleType').value;
document.getElementById('schRunAtRow').classList.toggle('hidden', t !== 'once');
document.getElementById('schIntervalRow').classList.toggle('hidden', t !== 'interval');
@@ -180,7 +180,7 @@ Object.assign(CodemanApp.prototype, {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
},
_collectSchedulerForm() {
_collectCronForm() {
const t = document.getElementById('schScheduleType').value;
const promptMode = document.getElementById('schPromptMode').value;
const body = {
@@ -213,10 +213,10 @@ Object.assign(CodemanApp.prototype, {
return body;
},
async saveSchedulerJob() {
const errEl = document.getElementById('schedulerFormError');
async saveCronJob() {
const errEl = document.getElementById('cronFormError');
errEl.textContent = '';
const body = this._collectSchedulerForm();
const body = this._collectCronForm();
if (!body.name) {
errEl.textContent = 'Name is required.';
return;
@@ -226,9 +226,7 @@ Object.assign(CodemanApp.prototype, {
return;
}
const id = document.getElementById('schJobId').value;
const res = id
? await this._apiPut(`/api/scheduler/jobs/${id}`, body)
: await this._apiPost('/api/scheduler/jobs', body);
const res = id ? await this._apiPut(`/api/cron/jobs/${id}`, body) : await this._apiPost('/api/cron/jobs', body);
if (!res || !res.ok) {
let msg = 'Failed to save job.';
try {
@@ -240,22 +238,22 @@ Object.assign(CodemanApp.prototype, {
errEl.textContent = msg;
return;
}
this.showToast?.(id ? 'Scheduled job updated' : 'Scheduled job created', 'success');
this.cancelSchedulerJobForm();
this.refreshScheduler();
this.showToast?.(id ? 'Cron job updated' : 'Cron job created', 'success');
this.cancelCronJobForm();
this.refreshCron();
},
// ── Actions ───────────────────────────────────────────────────────────────
async runSchedulerJob(id) {
const job = (this._schedulerJobs || []).find((j) => j.id === id);
async runCronJob(id) {
const job = (this._cronJobs || []).find((j) => j.id === id);
if (job) {
const active = this._countActiveAgents(job.agentType);
if (active > 0 && !confirm(`${active} ${job.agentType} session(s) already active. Run this job anyway?`)) {
return;
}
}
const res = await this._apiPost(`/api/scheduler/jobs/${id}/run`, {});
const res = await this._apiPost(`/api/cron/jobs/${id}/run`, {});
if (res && res.ok) {
this.showToast?.('Run started — opening session', 'success');
let data = null;
@@ -265,17 +263,17 @@ Object.assign(CodemanApp.prototype, {
/* ignore */
}
const run = data && (data.data ? data.data.run : data.run);
if (run && run.sessionId) this._focusScheduledSession(run.sessionId);
this.refreshScheduler();
if (run && run.sessionId) this._focusCronSession(run.sessionId);
this.refreshCron();
} else {
this.showToast?.('Failed to run job', 'error');
}
},
_focusScheduledSession(sessionId) {
_focusCronSession(sessionId) {
// Best-effort: switch to the created session tab if it exists.
if (this.sessions && this.sessions.has(sessionId) && typeof this.switchSession === 'function') {
this.closeScheduler();
this.closeCron();
this.switchSession(sessionId);
}
},
@@ -287,18 +285,18 @@ Object.assign(CodemanApp.prototype, {
return n;
},
async toggleSchedulerJob(id, enabled) {
const res = await this._apiPut(`/api/scheduler/jobs/${id}/enabled`, { enabled });
if (res && res.ok) this.refreshScheduler();
async toggleCronJob(id, enabled) {
const res = await this._apiPut(`/api/cron/jobs/${id}/enabled`, { enabled });
if (res && res.ok) this.refreshCron();
else this.showToast?.('Failed to update job', 'error');
},
async deleteSchedulerJob(id) {
if (!confirm('Delete this scheduled job and its run history?')) return;
const res = await this._apiDelete(`/api/scheduler/jobs/${id}`);
async deleteCronJob(id) {
if (!confirm('Delete this cron job and its run history?')) return;
const res = await this._apiDelete(`/api/cron/jobs/${id}`);
if (res && res.ok) {
this.showToast?.('Scheduled job deleted', 'success');
this.refreshScheduler();
this.showToast?.('Cron job deleted', 'success');
this.refreshCron();
} else {
this.showToast?.('Failed to delete job', 'error');
}
+18 -18
View File
@@ -541,7 +541,7 @@
<div class="toolbar-right">
<!-- Orchestrator button hidden until feature is ready -->
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">&#x2699; Orchestrator</button> -->
<button class="btn-toolbar btn-sm" onclick="app.openScheduler()" title="Scheduled Jobs">&#x23F0; Schedules</button>
<button class="btn-toolbar btn-sm" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -571,26 +571,26 @@
</div>
</div>
<!-- Scheduled Jobs Modal (cron-style scheduler) -->
<div class="modal" id="schedulerModal">
<div class="modal-backdrop" onclick="app.closeScheduler()"></div>
<!-- Cron Jobs Modal -->
<div class="modal" id="cronModal">
<div class="modal-backdrop" onclick="app.closeCron()"></div>
<div class="modal-content modal-lg">
<div class="modal-header">
<h3>Scheduled Jobs</h3>
<button class="modal-close" onclick="app.closeScheduler()" aria-label="Close scheduler">&times;</button>
<h3>Cron Jobs</h3>
<button class="modal-close" onclick="app.closeCron()" aria-label="Close cron">&times;</button>
</div>
<div class="modal-body">
<p class="form-hint" style="margin-bottom:10px;">Times use the server's local timezone.</p>
<div style="margin-bottom:10px;">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.openSchedulerJobForm()">+ New Job</button>
<button class="btn-toolbar btn-sm" onclick="app.refreshScheduler()">Refresh</button>
<button class="btn-toolbar btn-sm btn-primary" onclick="app.openCronJobForm()">+ New Job</button>
<button class="btn-toolbar btn-sm" onclick="app.refreshCron()">Refresh</button>
</div>
<!-- Job list -->
<div id="schedulerJobList" class="scheduler-job-list"></div>
<div id="cronJobList" class="cron-job-list"></div>
<!-- Create/Edit form (hidden until New/Edit) -->
<div id="schedulerJobForm" class="scheduler-job-form hidden">
<div class="form-section-header" id="schedulerFormTitle">New Scheduled Job</div>
<div id="cronJobForm" class="cron-job-form hidden">
<div class="form-section-header" id="cronFormTitle">New Cron Job</div>
<input type="hidden" id="schJobId">
<div class="form-row"><label>Name</label><input type="text" id="schName" placeholder="My nightly job"></div>
<div class="form-row"><label>Agent Type</label>
@@ -604,7 +604,7 @@
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
<div class="form-row"><label>Prompt Source</label>
<select id="schPromptMode" onchange="app.onSchedulerPromptModeChange()">
<select id="schPromptMode" onchange="app.onCronPromptModeChange()">
<option value="inline_text">Inline text</option>
<option value="prompt_file_path">Prompt file path</option>
</select>
@@ -618,7 +618,7 @@
</select>
</div>
<div class="form-row"><label>Schedule Type</label>
<select id="schScheduleType" onchange="app.onSchedulerScheduleTypeChange()">
<select id="schScheduleType" onchange="app.onCronScheduleTypeChange()">
<option value="once">Once</option>
<option value="interval">Interval</option>
<option value="daily">Daily</option>
@@ -629,7 +629,7 @@
<div class="form-row hidden" id="schIntervalRow"><label>Every (minutes)</label><input type="number" id="schIntervalMinutes" min="1" value="60"></div>
<div class="form-row hidden" id="schDailyRow"><label>Daily Time (HH:MM)</label><input type="time" id="schDailyTime"></div>
<div class="form-row hidden" id="schWeeklyDaysRow"><label>Weekdays</label>
<span id="schWeeklyDays" class="scheduler-weekdays">
<span id="schWeeklyDays" class="cron-weekdays">
<label><input type="checkbox" value="0">Sun</label>
<label><input type="checkbox" value="1">Mon</label>
<label><input type="checkbox" value="2">Tue</label>
@@ -650,10 +650,10 @@
<label class="switch"><input type="checkbox" id="schEnabled" checked><span class="slider"></span></label>
</div>
<div class="form-row"><label>Notes</label><input type="text" id="schNotes" placeholder="Optional"></div>
<div id="schedulerFormError" class="form-hint" style="color:var(--danger,#e55);"></div>
<div id="cronFormError" class="form-hint" style="color:var(--danger,#e55);"></div>
<div style="margin-top:10px;">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.saveSchedulerJob()">Save</button>
<button class="btn-toolbar btn-sm" onclick="app.cancelSchedulerJobForm()">Cancel</button>
<button class="btn-toolbar btn-sm btn-primary" onclick="app.saveCronJob()">Save</button>
<button class="btn-toolbar btn-sm" onclick="app.cancelCronJobForm()">Cancel</button>
</div>
</div>
</div>
@@ -2146,7 +2146,7 @@
<script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script>
<script defer src="orchestrator-panel.js"></script>
<script defer src="scheduler-ui.js"></script>
<script defer src="cron-ui.js"></script>
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
+12 -12
View File
@@ -11072,24 +11072,24 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5);
}
/* ── Scheduled Jobs (cron-style scheduler) ───────────────────────────────── */
.scheduler-job-list { display: flex; flex-direction: column; gap: 8px; }
.scheduler-job-row {
/* ── Cron Jobs ───────────────────────────────── */
.cron-job-list { display: flex; flex-direction: column; gap: 8px; }
.cron-job-row {
display: flex; justify-content: space-between; align-items: center; gap: 12px;
padding: 10px 12px; border: 1px solid var(--border, #333); border-radius: 8px;
background: var(--panel-bg, rgba(255,255,255,0.02));
}
.scheduler-job-main { min-width: 0; flex: 1; }
.scheduler-job-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.scheduler-job-meta { font-size: 12px; opacity: 0.7; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; }
.scheduler-job-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.scheduler-badge {
.cron-job-main { min-width: 0; flex: 1; }
.cron-job-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.cron-job-meta { font-size: 12px; opacity: 0.7; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; }
.cron-job-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.cron-badge {
font-size: 11px; padding: 1px 6px; border-radius: 10px;
background: var(--accent-bg, rgba(120,160,255,0.15)); opacity: 0.9;
}
.scheduler-badge-off { background: rgba(200,80,80,0.18); }
.scheduler-weekdays { display: flex; gap: 10px; flex-wrap: wrap; }
.scheduler-weekdays label { display: inline-flex; align-items: center; gap: 3px; font-weight: 400; }
.scheduler-job-form {
.cron-badge-off { background: rgba(200,80,80,0.18); }
.cron-weekdays { display: flex; gap: 10px; flex-wrap: wrap; }
.cron-weekdays label { display: inline-flex; align-items: center; gap: 3px; font-weight: 400; }
.cron-job-form {
margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border, #333);
}
+78
View File
@@ -0,0 +1,78 @@
/**
* @fileoverview Cron Jobs routes.
*
* CRUD + enable/disable + Run Now + run history for `CronJob`s. These are
* separate from the legacy `/api/scheduled` (ScheduledRun) endpoints — see
* docs/cron-discovery.md §0.
*/
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import type { CronPort } from '../ports/index.js';
export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
// ── Jobs ────────────────────────────────────────────────────────────────
app.get('/api/cron/jobs', async () => {
return ctx.cron.listJobs();
});
app.post('/api/cron/jobs', async (req) => {
const body = parseBody(CronJobSchema, req.body, 'Invalid cron job');
return { job: ctx.cron.createJob(body) };
});
app.get('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
const job = ctx.cron.getJob(id);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return job;
});
app.put('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(CronJobUpdateSchema, req.body, 'Invalid cron job update');
const job = ctx.cron.updateJob(id, body);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return { job };
});
app.delete('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
if (!ctx.cron.deleteJob(id)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
}
return {};
});
app.put('/api/cron/jobs/:id/enabled', async (req) => {
const { id } = req.params as { id: string };
const { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body');
const job = ctx.cron.setEnabled(id, enabled);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return { job };
});
// ── Run Now ──────────────────────────────────────────────────────────────
app.post('/api/cron/jobs/:id/run', async (req) => {
const { id } = req.params as { id: string };
const job = ctx.cron.getJob(id);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
const run = await ctx.cron.runNow(id);
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType) };
});
// ── Run history ──────────────────────────────────────────────────────────
app.get('/api/cron/jobs/:id/runs', async (req) => {
const { id } = req.params as { id: string };
return ctx.cron.listRuns(id);
});
app.get('/api/cron/runs', async () => {
return ctx.cron.listRuns();
});
}
+1 -1
View File
@@ -7,7 +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 { registerCronRoutes } from './cron-routes.js';
export { registerSystemRoutes } from './system-routes.js';
export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
-78
View File
@@ -1,78 +0,0 @@
/**
* @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();
});
}
+9 -9
View File
@@ -574,13 +574,13 @@ export const ScheduledRunSchema = z.object({
durationMinutes: z.number().int().min(1).max(14400).optional(),
});
// ========== Scheduled Jobs (cron-style scheduler) ==========
// ========== Cron Jobs ==========
/** '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({
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
workingDir: safePathSchema,
@@ -601,7 +601,7 @@ const ScheduledJobBaseSchema = z.object({
});
/** Cross-field validation: required fields depend on promptMode + scheduleType. */
function refineScheduledJob(val: z.infer<typeof ScheduledJobBaseSchema>, ctx: z.RefinementCtx): void {
function refineCronJob(val: z.infer<typeof CronJobBaseSchema>, ctx: z.RefinementCtx): void {
const add = (message: string, path: string) => ctx.addIssue({ code: 'custom', message, path: [path] });
if (val.promptMode === 'inline_text' && !val.promptText) {
@@ -624,14 +624,14 @@ function refineScheduledJob(val: z.infer<typeof ScheduledJobBaseSchema>, ctx: z.
}
}
/** POST /api/scheduler/jobs — full job definition. */
export const ScheduledJobSchema = ScheduledJobBaseSchema.superRefine(refineScheduledJob);
/** POST /api/cron/jobs — full job definition. */
export const CronJobSchema = CronJobBaseSchema.superRefine(refineCronJob);
/** PUT /api/scheduler/jobs/:id — partial update. */
export const ScheduledJobUpdateSchema = ScheduledJobBaseSchema.partial();
/** PUT /api/cron/jobs/:id — partial update. */
export const CronJobUpdateSchema = CronJobBaseSchema.partial();
/** PUT /api/scheduler/jobs/:id/enabled */
export const ScheduledJobEnabledSchema = z.object({ enabled: z.boolean() });
/** PUT /api/cron/jobs/:id/enabled */
export const CronJobEnabledSchema = z.object({ enabled: z.boolean() });
/** POST /api/cases/link */
export const LinkCaseSchema = z.object({
+13 -13
View File
@@ -152,10 +152,10 @@ import {
registerClipboardRoutes,
registerSearchRoutes,
registerOrchestratorRoutes,
registerSchedulerRoutes,
registerCronRoutes,
registerWsRoutes,
} from './routes/index.js';
import { SchedulerService } from '../scheduler/scheduler-service.js';
import { CronService } from '../cron/cron-service.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -177,7 +177,7 @@ import {
ITERATION_PAUSE_MS,
STATS_COLLECTION_INTERVAL_MS,
INACTIVITY_TIMEOUT_MS,
SCHEDULER_TICK_INTERVAL,
CRON_TICK_INTERVAL,
} from '../config/server-timing.js';
/**
@@ -226,8 +226,8 @@ export class WebServer extends EventEmitter {
// Store session listener references for explicit cleanup (prevents memory leaks)
private sessionListenerRefs: Map<string, SessionListenerRefs> = new Map();
private scheduledRuns: Map<string, ScheduledRun> = new Map();
/** Cron-style scheduler service (assigned in setupRoutes). */
private schedulerService!: SchedulerService;
/** Cron service (assigned in setupRoutes). */
private cronService!: CronService;
private sse: SseStreamManager;
private store = getStore();
private port: number;
@@ -879,11 +879,11 @@ export class WebServer extends EventEmitter {
registerSearchRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
// Cron-style scheduler: build the service from the same context, recompute
// Cron: 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 });
this.cronService = new CronService(ctx);
this.cronService.init();
registerCronRoutes(this.app, { ...ctx, cron: this.cronService });
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
}
@@ -1959,14 +1959,14 @@ export class WebServer extends EventEmitter {
{ description: 'scheduled runs cleanup' }
);
// Start the cron-style scheduler loop (fires due ScheduledJobs).
// Start the cron loop (fires due CronJobs).
this.cleanup.setInterval(
() => {
this.schedulerService.tickDueJobs().catch((err) => {
console.error('[scheduler] tick failed:', getErrorMessage(err));
this.cronService.tickDueJobs().catch((err) => {
console.error('[cron] tick failed:', getErrorMessage(err));
});
},
SCHEDULER_TICK_INTERVAL,
CRON_TICK_INTERVAL,
{ description: 'scheduled jobs due-checker' }
);
+12 -12
View File
@@ -240,16 +240,16 @@ export const ScheduledLog = 'scheduled:log' as const;
/** Scheduled run deleted. */
export const ScheduledDeleted = 'scheduled:deleted' as const;
// ─── Scheduled Jobs (cron-style scheduler) ───────────────────────────────────
// ─── Cron Jobs ───────────────────────────────────
/** The scheduled-jobs list changed (created/updated/enabled/run-status). Payload: { jobs }. */
export const SchedulerJobsChanged = 'scheduler:jobsChanged' as const;
export const CronJobsChanged = 'cron: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;
export const CronJobDeleted = 'cron:jobDeleted' as const;
/** A scheduled-job run (history record) was created. Payload: CronJobRun. */
export const CronRunCreated = 'cron:runCreated' as const;
/** A scheduled-job run (history record) was updated. Payload: CronJobRun. */
export const CronRunUpdated = 'cron:runUpdated' as const;
// ─── Teams ───────────────────────────────────────────────────────────────────
@@ -480,11 +480,11 @@ export const SseEvent = {
ScheduledLog,
ScheduledDeleted,
// Scheduled jobs (cron-style scheduler)
SchedulerJobsChanged,
SchedulerJobDeleted,
SchedulerRunCreated,
SchedulerRunUpdated,
// Cron jobs
CronJobsChanged,
CronJobDeleted,
CronRunCreated,
CronRunUpdated,
// Teams
TeamCreated,
+262
View File
@@ -0,0 +1,262 @@
/**
* @fileoverview Tests for CronService — the CRUD/bookkeeping + due-tick
* state machine of the cron. The pure next-run math lives in
* cron-time.test.ts; this exercises the service that sits on top of it.
*
* Launch attempts are steered down the "workingDir does not exist" failure path
* so no real Session/tmux objects are constructed — we assert the scheduling
* state machine (due detection, dedup guard, schedule advance, once-completion,
* concurrency skip, run-history recording), not the session layer it reuses.
*
* Port: N/A (no HTTP server).
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CronService, type CronDeps } from '../src/cron/cron-service.js';
import type { CronJob, CronJobRun } from '../src/types/cron.js';
import type { CronJobInput } from '../src/cron/cron-input.js';
const MISSING_DIR = '/nonexistent-codeman-cron-test-dir';
const flush = (): Promise<void> => new Promise((r) => setImmediate(r));
function makeStore() {
const jobs: Record<string, CronJob> = {};
const runs: Record<string, CronJobRun> = {};
return {
getCronJobs: () => jobs,
getCronJob: (id: string) => jobs[id] ?? null,
setCronJob: (id: string, j: CronJob) => {
jobs[id] = j;
},
removeCronJob: (id: string) => {
delete jobs[id];
},
getCronJobRuns: () => runs,
setCronJobRun: (id: string, r: CronJobRun) => {
runs[id] = r;
},
removeCronJobRun: (id: string) => {
delete runs[id];
},
incrementSessionsCreated: vi.fn(),
};
}
function makeService(sessions = new Map<string, { mode: string }>()) {
const store = makeStore();
const broadcast = vi.fn();
const deps = {
store,
broadcast,
sessions,
} as unknown as CronDeps;
return { service: new CronService(deps), store, broadcast, sessions };
}
function mkInput(overrides: Partial<CronJobInput> = {}): CronJobInput {
return {
name: 'job',
agentType: 'claude',
workingDir: MISSING_DIR,
promptMode: 'inline_text',
promptText: 'hello',
inputMode: 'typed',
scheduleType: 'interval',
intervalMinutes: 10,
enabled: true,
concurrencyPolicy: 'warn_only',
...overrides,
};
}
describe('CronService', () => {
let svc: ReturnType<typeof makeService>;
beforeEach(() => {
svc = makeService();
});
describe('createJob', () => {
it('computes nextRunAt for an enabled interval job', () => {
const before = Date.now();
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
expect(job.id).toBeTruthy();
expect(job.nextRunAt).not.toBeNull();
expect(job.nextRunAt!).toBeGreaterThanOrEqual(before + 10 * 60_000);
expect(job.lastRunAt).toBeNull();
expect(job.lastStatus).toBeNull();
});
it('leaves nextRunAt null for a disabled job', () => {
const job = svc.service.createJob(mkInput({ enabled: false }));
expect(job.nextRunAt).toBeNull();
});
it('uses the absolute runAt for a one-time job', () => {
const runAt = Date.now() + 3_600_000;
const job = svc.service.createJob(mkInput({ scheduleType: 'once', runAt, intervalMinutes: undefined }));
expect(job.nextRunAt).toBe(runAt);
});
});
describe('setEnabled', () => {
it('clears nextRunAt when disabling and recomputes when re-enabling', () => {
const job = svc.service.createJob(mkInput());
const disabled = svc.service.setEnabled(job.id, false);
expect(disabled!.nextRunAt).toBeNull();
const reenabled = svc.service.setEnabled(job.id, true);
expect(reenabled!.nextRunAt).not.toBeNull();
});
it('returns null for an unknown id', () => {
expect(svc.service.setEnabled('nope', true)).toBeNull();
});
});
describe('updateJob', () => {
it('re-arms the dup-guard and once-completion flags', () => {
const job = svc.service.createJob(
mkInput({ scheduleType: 'once', runAt: Date.now() + 1000, intervalMinutes: undefined })
);
job.lastDueKey = 'stale';
job.completedOnce = true;
svc.store.setCronJob(job.id, job);
const updated = svc.service.updateJob(job.id, { name: 'renamed' });
expect(updated!.name).toBe('renamed');
expect(updated!.lastDueKey).toBeNull();
expect(updated!.completedOnce).toBe(false);
expect(updated!.createdAt).toBe(job.createdAt);
});
});
describe('deleteJob', () => {
it('removes the job and its run history', async () => {
const job = svc.service.createJob(
mkInput({ scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
);
await svc.service.tickDueJobs(Date.now());
await flush();
expect(svc.service.listRuns(job.id).length).toBe(1);
expect(svc.service.deleteJob(job.id)).toBe(true);
expect(svc.service.getJob(job.id)).toBeNull();
expect(svc.service.listRuns(job.id).length).toBe(0);
});
it('returns false for an unknown id', () => {
expect(svc.service.deleteJob('nope')).toBe(false);
});
});
describe('listRuns', () => {
it('returns runs newest-first and filters by job id', async () => {
const a = svc.service.createJob(
mkInput({ name: 'a', scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
);
const b = svc.service.createJob(
mkInput({ name: 'b', scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
);
await svc.service.runNow(a.id);
await svc.service.runNow(b.id);
const all = svc.service.listRuns();
expect(all.length).toBe(2);
expect(all[0].startedAt).toBeGreaterThanOrEqual(all[1].startedAt);
expect(svc.service.listRuns(a.id).every((r) => r.cronJobId === a.id)).toBe(true);
});
});
describe('init', () => {
it('recomputes nextRunAt for enabled jobs missing one, but skips a completed once-job', () => {
const live = svc.service.createJob(mkInput());
live.nextRunAt = null;
svc.store.setCronJob(live.id, live);
const dead = svc.service.createJob(
mkInput({ scheduleType: 'once', runAt: Date.now(), intervalMinutes: undefined })
);
dead.completedOnce = true;
dead.nextRunAt = null;
svc.store.setCronJob(dead.id, dead);
svc.service.init();
expect(svc.service.getJob(live.id)!.nextRunAt).not.toBeNull();
expect(svc.service.getJob(dead.id)!.nextRunAt).toBeNull();
});
});
describe('tickDueJobs', () => {
it('fires a due one-time job exactly once and disables it', async () => {
const runAt = Date.now() - 5000;
const job = svc.service.createJob(mkInput({ scheduleType: 'once', runAt, intervalMinutes: undefined }));
await svc.service.tickDueJobs(Date.now());
await flush();
const after = svc.service.getJob(job.id)!;
expect(after.completedOnce).toBe(true);
expect(after.enabled).toBe(false);
expect(after.nextRunAt).toBeNull();
const runs = svc.service.listRuns(job.id);
expect(runs.length).toBe(1);
expect(runs[0].status).toBe('failed'); // workingDir missing → fails before session launch
// A second tick must not re-fire it.
await svc.service.tickDueJobs(Date.now());
await flush();
expect(svc.service.listRuns(job.id).length).toBe(1);
});
it('advances an interval job to a future nextRunAt after firing', async () => {
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
const fireAt = job.nextRunAt! + 1000;
await svc.service.tickDueJobs(fireAt);
await flush();
const after = svc.service.getJob(job.id)!;
expect(after.enabled).toBe(true);
expect(after.nextRunAt!).toBeGreaterThan(fireAt);
expect(after.lastDueKey).not.toBeNull();
expect(svc.service.listRuns(job.id).length).toBe(1);
});
it('does not fire a job whose nextRunAt is still in the future', async () => {
const job = svc.service.createJob(mkInput({ intervalMinutes: 60 }));
await svc.service.tickDueJobs(Date.now());
await flush();
expect(svc.service.listRuns(job.id).length).toBe(0);
});
it('skips an automatic run when concurrency policy is skip_if_same_agent_running', async () => {
const sessions = new Map<string, { mode: string }>([['s1', { mode: 'claude' }]]);
const local = makeService(sessions);
const job = local.service.createJob(
mkInput({ agentType: 'claude', concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
);
const fireAt = job.nextRunAt! + 1000;
await local.service.tickDueJobs(fireAt);
await flush();
// No run recorded, but the schedule still advanced past the skipped slot.
expect(local.service.listRuns(job.id).length).toBe(0);
const after = local.service.getJob(job.id)!;
expect(after.nextRunAt!).toBeGreaterThan(fireAt);
expect(after.lastDueKey).not.toBeNull();
});
});
describe('runNow', () => {
it('launches regardless of enabled/schedule state', async () => {
const job = svc.service.createJob(mkInput({ enabled: false }));
const run = await svc.service.runNow(job.id);
expect(run).not.toBeNull();
expect(run!.triggerType).toBe('manual_run_now');
// Disabled job stays disabled; a manual run doesn't arm the schedule.
expect(svc.service.getJob(job.id)!.enabled).toBe(false);
});
it('returns null for an unknown id', async () => {
expect(await svc.service.runNow('nope')).toBeNull();
});
});
});
@@ -1,14 +1,14 @@
/**
* Unit tests for the scheduler's pure next-run-time calculations.
* Unit tests for the cron'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';
import { parseHHMM, computeNextRunAt, dueKeyFor } from '../src/cron/cron-time.js';
import type { CronJob } from '../src/types/cron.js';
function baseJob(partial: Partial<ScheduledJob>): ScheduledJob {
function baseJob(partial: Partial<CronJob>): CronJob {
return {
id: 'j1',
name: 'test',