Merge PR #141 from chatgptkrylor/feat/scheduler: recurring cron-style scheduled jobs

Includes review fixes: multi-line prompt rejection, prompt-file confinement hardening (realpath + attachment-guard blocklist), per-job autoClosePreviousSession lifecycle, live-session-only concurrency counting, wired launchCommand.
This commit is contained in:
Codeman maintainer
2026-07-12 20:01:16 +02:00
28 changed files with 3783 additions and 6 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 |
|--------|----------|-------------|
+104
View File
@@ -0,0 +1,104 @@
# SPEEDRUN.md — Fast-execution protocol for Claude
Read this when the goal is **throughput**: get correct, verified work done with
minimum ceremony. This does **not** relax correctness or the safety rules in
`CLAUDE.md` — those still win. It removes _waste_, not _rigor_.
> Precedence: `CLAUDE.md` > explicit user instructions > this file. If anything
> here conflicts with `CLAUDE.md`, `CLAUDE.md` wins.
---
## The mindset
- **Act, don't announce.** No "I'm going to now…" preamble. Do the thing, report
the result.
- **Cheapest proof that the change works.** Pick the smallest check that actually
demonstrates correctness — not the biggest.
- **Batch aggressively.** Independent reads, greps, and edits go in **one**
message with parallel tool calls. Never serialize work that has no dependency.
- **Momentum over perfection.** Land a correct increment, verify it, move on.
Don't gold-plate untouched code.
---
## Loop (repeat until done)
1. **Orient once** — one parallel burst of reads/greps to load the context you
need. Don't re-read files the harness says are already current.
2. **Change** — make the edit(s). Batch independent edits.
3. **Verify cheaply** — the smallest check that proves _this_ change (see below).
4. **Advance** — next item. Only re-verify what you touched.
5. **Stop** at: list empty, a hard blocker, or a decision that's genuinely the
user's to make.
---
## Verification ladder — climb only as high as the change needs
| Change kind | Cheapest sufficient check |
|-------------|---------------------------|
| Types / signatures / imports | `tsc --noEmit` (or `--watch` already running) |
| One module's logic | `npm test -- test/<file>.test.ts` (the **one** relevant file) |
| A named behavior | `npm test -- -t "pattern"` |
| Route/handler | `app.inject()` route test, or one `curl` against the running dev server |
| Frontend render | Playwright load + assert (`waitUntil: 'domcontentloaded'`, wait 3–4s) |
| Broad / pre-merge | `npm run test:ci` (the CI-equivalent sweep) |
**Hard rules (never skip, even in a rush):**
- ⚠️ **Never run bare `npm test`** — it pulls in browser/visual suites that hang
or fail locally. Always pass a file or `-t`, or use `test:ci`.
- ⚠️ **Never COM without verifying the change actually works** first (curl the
endpoint / Playwright the UI). "Compiles" ≠ "works".
- ⚠️ **Session safety** — check `$CODEMAN_MUX`; never `tmux kill-session` /
`pkill claude` in a managed session.
- ⚠️ **Single-line prompts** for any programmatic session input.
---
## Speed tactics that pay off here
- **Parallel exploration**: dispatch `Explore` subagents (or one parallel grep
burst) instead of serial file-by-file reading when scope is uncertain.
- **`tsc --noEmit --watch`** in the background — instant type feedback, no repeat
cold starts.
- **Target one test file** — `fileParallelism: false` means the suite is serial;
running one file is dramatically faster than the sweep.
- **`curl localhost:3000/api/...`** beats spinning up a browser for backend
checks. Reserve Playwright for actual UI rendering.
- **Trust the harness** — if it says a file you just edited is current, don't
re-Read it to "confirm". The Edit already succeeded or it would have errored.
---
## Anti-patterns (these masquerade as speed, but cost time)
- Running the full test suite to check a one-file change.
- Re-reading files you already have in context.
- Narrating a plan you're about to execute anyway.
- Serial tool calls that have no dependency between them.
- Claiming "done / fixed / passing" **before** running the check that proves it.
- Deploying (COM) on green typecheck alone, without exercising the real flow.
---
## Stop-conditions (don't rush past these)
Stop and surface, don't guess, when you hit:
- A **destructive / hard-to-reverse** action (delete, overwrite, force-push).
- An **outward-facing** action (publishing, sending, deploying) not already
authorized.
- A **genuine product decision** the code can't answer.
- A **failing verification you can't explain** — debug it (see
`superpowers:systematic-debugging`), don't paper over it.
---
## Definition of done
A task is done when **all** hold:
- The change is made.
- The cheapest sufficient check **ran** and **passed** — evidence, not assertion.
- No new type errors / lint errors introduced (`tsc --noEmit`, `npm run lint`).
- You state plainly what was done and what proved it. If a step was skipped or a
test failed, say so — don't hedge, don't overclaim.
+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.
+142
View File
@@ -0,0 +1,142 @@
# 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. **No session/tmux logic will be rebuilt** —
the new code is purely a trigger + persistence + history layer on top of the
existing primitives.
Stack: `aicodeman` v1.2.1 — Fastify 5 backend, `node-pty` + tmux sessions,
vanilla-JS SPA frontend served as static assets, JSON file state store, zod
validation, ports-based dependency injection.
---
## 0. Critical finding: an existing `ScheduledRun` is NOT a 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
**run-now, duration-bounded autonomous loop**: given `{prompt, workingDir,
durationMinutes}` it immediately spawns/kills throwaway sessions in a loop until
the duration elapses. It has **no** time-based triggering, recurrence
(once/interval/daily/weekly), enable/disable, next-run calculation, run history,
or persistence across restarts.
Therefore the brief's core (the calendar/cron trigger layer) does **not** exist
and must be built. The execution primitives it sits on top of **do** exist and
will be reused. To honor brief §16 ("do not rename existing core concepts"), the
new feature is named **`CronJob`** (with **`CronJobRun`** history
records), kept distinct from the existing `ScheduledRun`.
---
## 1. Where session creation happens
- Canonical create flow: `POST /api/sessions`,
`src/web/routes/session-routes.ts:262-438`.
- `new Session({ workingDir, mode, ... })` (`src/session.ts:421-570`)
- `ctx.addSession(session)` → `ctx.setupSessionListeners(session)` →
`ctx.persistSessionState(session)` (all via `SessionPort`).
- `SessionPort` interface: `src/web/ports/session-port.ts:8-16`.
- **Integration point:** the cron service will mirror this exact sequence
(create → addSession → setupSessionListeners → start) via `SessionPort`,
not reimplement it.
## 2. Where agent/session types are defined
- `type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'`
(`src/types/session.ts:43-44`). `shell` covers the brief's "Terminal/custom".
- CLI availability resolvers in `src/utils/{claude,codex,gemini,opencode}-cli-resolver.ts`.
- **Integration point:** the job's `agentType` reuses `SessionMode` verbatim.
## 3. Where input is sent into a session
- Raw / paste: `session.write(data)` (`src/session.ts:2243-2247`) — direct PTY write.
- Typed (recommended): `session.writeViaMux(data)` (`src/session.ts:2301-2311`)
— tmux `send-keys`, falls back to PTY. Submit requires trailing `\r`.
- **Integration point:** prompt delivery uses `writeViaMux` (typed) by default,
`write` (paste) as the alternate `input_mode`.
## 4. Where active sessions are listed
- `ctx.sessions: ReadonlyMap<string, Session>` (`SessionPort`).
- Filters: `Array.from(ctx.sessions.values()).filter(s => s.mode === X)` and
`.isBusy()` / `.isIdle()` (`src/session-manager.ts:220-247`).
- **Integration point:** the §8 multi-session warning queries this map.
## 5. Where session kill/delete is handled
- `ctx.cleanupSession(sessionId, killMux?, reason?)`
(`SessionPort`; impl `src/web/server.ts:997-1152`). Underlying
`session.stop(killMux)` at `src/session.ts:2498-2585`.
- The cron does **not** kill sessions it launches (the brief wants them
visible in the normal session UI); cleanup stays user-driven.
_Superseded post-review:_ recurring jobs now default to
`autoClosePreviousSession: true` — the previous run's still-open session is
closed via `cleanupSession` when the next run fires (see
`docs/cron-guide.md` §8); opt out per job for fully user-driven cleanup.
## 6. How session state is stored / 7. Existing persistence
- JSON file store: `~/.codeman/state.json` (+ `state-inner.json` for Ralph).
`StateStore` class `src/state-store.ts:71`; `AppState` interface
`src/types/app-state.ts:99-114`.
- Pattern: declare a field on `AppState`, add typed get/set methods on
`StateStore` that mutate in-memory state and call the debounced `save()`
(500ms debounce, atomic temp-file+rename, `.bak` backup, circuit breaker).
- **Integration point:** add `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
- Route modules: `src/web/routes/*.ts`; barrel `src/web/routes/index.ts`;
registered in `WebServer.setupRoutes()` `src/web/server.ts:858-876` with a
single `ctx` object from `createRouteContext()` (`src/web/server.ts:553-613`)
that satisfies all port interfaces.
- Validation: zod schemas in `src/web/schemas.ts`, applied via
`parseBody(Schema, req.body)` (`src/web/route-helpers.ts:101-111`).
- Errors: `createErrorResponse(ApiErrorCode.X, msg)` / `ApiResponse`
(`src/types/api.ts`), auto-mapped to HTTP status by a `preSerialization` hook
(`src/web/server.ts:644-659`).
- SSE: `ctx.broadcast(SseEvent.X, data)` (`EventPort`,
`src/web/sse-events.ts`); frontend mirror in `src/web/public/constants.js`.
- **Integration point:** new `cron-routes.ts` registered alongside the
others; new zod schema; new `SseEvent` constants for job list/run changes.
## 9. Where frontend pages/components live
- Vanilla-JS SPA: single `src/web/public/index.html` + feature mixin files
(`Object.assign(CodemanApp.prototype, {...})`). API via `api-client.js`
(`_apiJson/_apiPost/_apiDelete`). Build = esbuild minify + content-hash, no
bundler (`scripts/build.mjs`).
- UI is panels/modals toggled by JS classes; forms use `.form-row` / `.modal`
conventions (`styles.css`). SSE handler map in `app.js`.
- **Integration point:** add a new `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)
- Established pattern: `this.cleanup.setInterval(fn, intervalMs, {description})`
in `WebServer.start()` (`src/web/server.ts:~1942-1966`), auto-disposed in
`WebServer.stop()` via `this.cleanup.dispose()` (`src/web/server.ts:2336`).
RalphLoop (`src/ralph-loop.ts:268-286`) shows the self-rescheduling guard idiom.
- **Integration point:** register a 30s cron tick via `cleanup.setInterval`;
no manual shutdown wiring needed.
---
## Smallest integration points (summary)
| New piece | Reuses | Location |
| --- | --- | --- |
| `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/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 cron is additive and calls existing services.
+426
View File
@@ -0,0 +1,426 @@
# Cron Jobs — User & Operator Guide
Codeman's **Cron** feature lets you save named, recurring jobs that automatically
spin up a Claude (or shell / OpenCode / Codex / Gemini) session on a schedule and
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
frontend in `src/web/public/cron-ui.js`.
> **Not to be confused with `ScheduledRun` (`/api/scheduled`).** That older,
> deliberately-separate concept is a _run-now, duration-bounded autonomous loop_
> (`{prompt, workingDir, durationMinutes}` → spawn/kill throwaway sessions until
> the duration elapses). It has no recurrence, no saved jobs, and no next-run
> calculation. The two systems never interact. This guide is only about **Cron
> jobs** (`Cron*`). See `docs/cron-discovery.md` §0.
---
## 1. Quick start
### In the browser
1. Click **⏰ Cron** in the header.
2. Click **+ New Job**.
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
**prompt** (inline text or a file path), pick a **schedule**, and leave
**Enabled** on.
4. **Save**. The job appears in the list with its computed **next run**.
5. Use **Run Now** to fire it immediately without waiting for the schedule.
### With curl
```bash
API=http://localhost:3000
# Create a daily job (03:00 server-local time)
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
# List jobs
curl -s "$API/api/cron/jobs" | jq
# Run one immediately
curl -s -X POST "$API/api/cron/jobs/<jobId>/run" | jq
# See a job's run history
curl -s "$API/api/cron/jobs/<jobId>/runs" | jq
```
---
## 2. Concepts
| Term | Meaning |
| -------------------------- | ------------------------------------------------------------------------------------------------ |
| **Cron job** (`CronJob`) | A saved, named definition: what agent to launch, where, with what prompt, on what schedule. |
| **Run** (`CronJobRun`) | One execution of a job — a history record with a status and a link to the session it created. |
| **Schedule type** | How fire times are computed: `once`, `interval`, `daily`, or `weekly`. |
| **Next run** (`nextRunAt`) | Server-computed epoch-ms of the next fire. `null` when the job is disabled or has no future run. |
| **Due tick** | A background loop (every 30s) that launches any enabled job whose `nextRunAt` has passed. |
A job is essentially a **trigger + persistence + history layer on top of the
existing session primitives**. When a job fires, the cron service does exactly
what the "quick start" route does — `new Session(...)` → `addSession` →
`setupSessionListeners` → `startInteractive()`/`startShell()` → deliver the
prompt. It does **not** reimplement any tmux/PTY logic.
---
## 3. The job form — every field
These map 1:1 to `CronJobSchema` (`src/web/schemas.ts`) and the `CronJob` type
(`src/types/cron.ts`).
| Field | Required | Values / limits | Notes |
| -------------------------- | ----------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name` | ✅ | 1–200 chars | Display name; also used as the created session's name. |
| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` | Reuses Codeman's `SessionMode`. `shell` = a plain terminal. |
| `workingDir` | ✅ | valid path (allowlist-validated) | Validated at **create/update** (must exist, be a directory, and not resolve into a blocked tree — `/etc`, `/root`, `/proc`, `/sys`, `/dev`, or `/` itself) and again **at fire time**. |
| `launchCommand` | — | ≤ 2000 chars, single line | `shell` mode only: sent as the **first input line** once the shell is up, before the prompt. Ignored for other agent types. |
| `promptMode` | ✅ | `inline_text` \| `prompt_file_path` | See §5. |
| `promptText` | conditional | ≤ 100000 chars, **single line** | Required when `promptMode = inline_text`. Newlines are rejected (see §6). |
| `promptFilePath` | conditional | valid path | Required when `promptMode = prompt_file_path`. Confined to `workingDir` (see §5). |
| `inputMode` | ✅ | `paste` \| `typed` | How the prompt is delivered. See §6. |
| `scheduleType` | ✅ | `once` \| `interval` \| `daily` \| `weekly` | See §4. |
| `runAt` | conditional | epoch-ms (positive int) | Required for `once`. |
| `intervalMinutes` | conditional | 1–525600 (≤ 1 year) | Required for `interval`. |
| `dailyTime` | conditional | `HH:MM` (24h) | Required for `daily`. Server-local time. |
| `weeklyDays` | conditional | array of 1–7 ints, each 0–6 (0 = Sunday) | Required for `weekly`. |
| `weeklyTime` | conditional | `HH:MM` (24h) | Required for `weekly`. Server-local time. |
| `enabled` | ✅ | boolean | Disabled jobs never auto-fire (but **Run Now** still works). |
| `notes` | — | ≤ 2000 chars | Free-form. |
| `concurrencyPolicy` | ✅ | `warn_only` \| `skip_if_same_agent_running` | Applies to **automatic** runs only. See §7. |
| `autoClosePreviousSession` | — | boolean (default **true**) | Recurring schedules only (ignored for `once`): when the next run fires, the still-open session created by this job's **previous** run is closed first via the normal cleanup path. See §8. |
**Cross-field validation** (`refineCronJob` in `schemas.ts`): the conditional
fields above are enforced by a Zod `superRefine` on create. A missing dependent
field (e.g. `scheduleType: "once"` with no `runAt`) is rejected with
`INVALID_INPUT` and a field-specific message.
> ⚠️ **Update caveat.** `PUT /api/cron/jobs/:id` uses a `.partial()` schema that
> does **not** re-run the cross-field `superRefine`. To keep partial edits safe,
> `updateJob()` re-validates the **merged** job against the full `CronJobSchema`
> and throws `400` if the result is inconsistent (e.g. switching to `once`
> without a `runAt`). So the store is never left with a half-valid job.
---
## 4. Schedule types
Next-run math lives in `src/cron/cron-time.ts` (pure, unit-tested in
`test/cron-time.test.ts`). **All wall-clock times use the server's local
timezone** (v0.1 decision).
### `once`
- Fires a single time at the absolute `runAt` epoch-ms.
- A **missed** one-time job (server was down at `runAt`) **still fires once** on
the next tick — `computeNextRunAt` returns `runAt` even if it's in the past,
until the job has fired.
- After firing, the job **self-disables**: `completedOnce = true`, `enabled =
false`, `nextRunAt = null`.
### `interval`
- Fires every `intervalMinutes`, computed as `fireTime + intervalMinutes`.
- ⚠️ **Drift**: the next run re-anchors to the actual fire time, not to an ideal
cadence — a slow tick or restart shifts subsequent runs slightly later. This is
an accepted limitation.
### `daily`
- Fires at `dailyTime` (`HH:MM`) every day, server-local.
- If today's time has already passed, the next run is tomorrow at that time.
### `weekly`
- Fires at `weeklyTime` on each weekday in `weeklyDays` (0 = Sunday … 6 =
Saturday), server-local.
- The next run is the soonest upcoming matching weekday/time within the next 7
days.
---
## 5. Prompt source (`promptMode`)
### `inline_text`
The prompt is the literal `promptText`. Simplest option.
### `prompt_file_path`
The prompt is read from a file at fire time. **This path is security-hardened**
because a job config is attacker-controllable and the file's contents are
injected into an agent session (an exfiltration sink over SSE/terminal).
`resolveSafePromptPath()` enforces, in order:
1. **`realpath` resolution** — symlinks are resolved to their true target, for
the prompt file **and for `workingDir` itself**.
2. **`workingDir` is not a trust boundary** — because it is user-supplied, the
resolved `workingDir` is itself rejected if it is `/` or resolves into a
blocked tree (`/etc`, `/root`, operator extras) or a pseudo-filesystem
(`/proc`, `/sys`, `/dev`). This closes the `workingDir: '/proc'` +
`promptFilePath: '/proc/self/environ'` env-exfil trick. The same rule is
enforced earlier, at job create/update.
3. **Blocklist** (defense-in-depth) — sensitive trees (`/etc`, `/root`,
`/proc`, `/sys`, `/dev`, known secret locations) are rejected for the
resolved prompt file.
4. **Allowlist (primary gate)** — the resolved path **must live inside the job's
(resolved) `workingDir`** (`validateSessionFilePath`). A symlink escaping the
workspace fails here.
5. **Regular-file check** — directories, FIFOs, and `/dev/*` character devices
are rejected (they would hang or OOM an unbounded read).
6. **Size cap** — files larger than **1 MiB** (`MAX_PROMPT_FILE_BYTES`) are
rejected.
7. **Single-line check** — after trailing newlines are stripped, the file
content must be a single line (see §6).
If any check fails, the run is recorded as **`failed`** with the reason; no
session is created.
---
## 6. Prompt delivery (`inputMode`)
Once the CLI is ready (see §8), the prompt is written to the session with a
trailing carriage return:
| Mode | Mechanism | Use when |
| ------- | --------------------------------------------------------------- | ------------------------------------------------ |
| `typed` | `session.writeViaMux()` — tmux `send-keys -l` (literal) + Enter | Default; behaves like a human typing the prompt. |
| `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman,
> multi-line delivery would be silently corrupted (Ink-based TUIs treat a
> newline as submit; typed mode fuses lines). So newlines are **rejected**: the
> schema and the form refuse a multi-line `promptText`, and at fire time a
> prompt file whose content is multi-line (after stripping trailing newlines)
> fails the run with a clear `errorMessage`. Put multi-line instructions in a
> file the agent is told to read itself (e.g. "read TASKS.md and do it").
---
## 7. Concurrency policy (automatic runs)
`concurrencyPolicy` governs what happens when a **scheduled** run is due and
sessions of the same `agentType` already exist:
| Policy | Behavior |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `warn_only` | Always launch. (The count is surfaced but not blocking.) |
| `skip_if_same_agent_running` | If ≥ 1 **other, live** session of that mode is active, **skip** this fire — record a `skipped` run and (for recurring schedules) advance the schedule without launching. |
Notes on `skip_if_same_agent_running`:
- Only **live** sessions block: a tab whose CLI already exited (status
`stopped`/`error`) does not count.
- Sessions created by **this job's own previous runs never block it** —
otherwise a recurring job would deadlock on the session it created last time
and fire exactly once.
- A skipped **`once`** job is **not consumed**: it stays armed and retries on
the next tick until the blocking session goes away, then fires its single run.
- A skip is **not** a run: it sets `lastStatus = 'skipped'` but does **not**
advance `lastRunAt`.
- Consecutive skips are **coalesced** — a perpetually-skipped interval job writes
**one** skip record per streak, not one every tick, so it can't bloat
`state.json`.
**Run Now ignores this policy on the server.** The browser shows a `confirm()`
warning if same-type sessions are active, but if you proceed (or call the API
directly), the job launches unconditionally.
---
## 8. What happens when a job fires
Sequence in `CronService.launch()`:
1. A `CronJobRun` is created with status **`created`** and broadcast
(`cron:runCreated`).
2. The prompt is resolved (inline or file, single-line enforced). Failure →
**`failed`**.
3. `workingDir` is checked (`statSync().isDirectory()`). Missing/not-a-dir →
**`failed`**.
4. **Auto-close previous session** (recurring schedules, unless
`autoClosePreviousSession: false`): any still-open session created by this
job's previous runs is closed via the normal session-cleanup path.
5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS = 50`). At cap →
**`failed`**.
6. A `Session` is created **with `useMux: true`** (so it runs inside tmux),
registered, listeners attached, and started via `startInteractive()`
(`startShell()` for `shell` mode). Model/claudeMode come from global config.
Run status → **`session_started`**.
7. **Readiness wait** (async, non-blocking): for non-shell agents the service
polls the terminal buffer up to **60 × 500ms** for a `❯` prompt or the string
`tokens`, then settles **2000ms** (`CRON_READY_SETTLE_MS`). Shell mode waits
1000ms, then sends the optional `launchCommand` as the first input line
(+1000ms settle).
8. The prompt is delivered (`typed`/`paste`, trailing `\r`). Run status →
**`prompt_sent`**; `finishedAt` stamped. Delivery failure (e.g. the mux
session is gone) → **`failed`**.
The created session is a **normal, persistent interactive session** — it appears
as its own tab and keeps running after the prompt is sent. The run's
`createdSessionUrl` is a deep link (`/?session=<id>`); the UI focuses it
automatically after **Run Now**.
> ⚠️ **Session-cap math if you disable auto-close.** With
> `autoClosePreviousSession: false`, nothing ever closes the sessions a
> recurring job creates — an interval job every 30 min creates 48 tabs/day and
> hits the global 50-session cap in ~25 hours (sooner with existing tabs), after
> which **every** fire of **every** job fails with "Maximum concurrent sessions
> reached" until you delete tabs by hand. Leave auto-close on for unattended
> recurring jobs, or clean up sessions yourself.
### The background tick
`tickDueJobs()` runs every **30s** (`CRON_TICK_INTERVAL`, registered in
`server.ts`). For each enabled job whose `nextRunAt ≤ now`:
- **Duplicate-launch guard**: `lastDueKey = jobId:fireTime`. If this due time was
already consumed (overlap/restart), the job is just advanced, not relaunched.
- The schedule is **advanced _before_ launching** so a slow launch can't be
re-triggered by the next tick.
- On boot, `init()` recomputes `nextRunAt` for loaded jobs (dead `once` jobs stay
dead).
---
## 9. Run history & statuses
Each job keeps a history of `CronJobRun` records. Statuses (`CronJobRunStatus`):
| Status | Meaning |
| ----------------- | ------------------------------------------------------------- |
| `created` | Run record created; prompt/session not yet started. |
| `session_started` | Session launched successfully. |
| `prompt_sent` | Prompt delivered — the happy-path terminal state. |
| `failed` | Something went wrong (see `errorMessage`). |
| `skipped` | A scheduled fire was skipped by `skip_if_same_agent_running`. |
Each run also records `triggerType` (`scheduled` or `manual_run_now`),
`sessionId`/`sessionName`, timestamps, and `createdSessionUrl`.
**History is capped globally** at **500 records** (`MAX_CRON_RUN_HISTORY`); the
oldest are pruned first. Deleting a job also deletes its run records.
---
## 10. API reference
All responses use the standard `ApiResponse<T>` envelope (`{success, data}` /
`{success, error, errorCode}`). `/api/v1/*` is a stable alias.
| Method | Endpoint | Body | Returns |
| -------- | ---------------------------- | ---------------------- | --------------------------------- |
| `GET` | `/api/cron/jobs` | — | `CronJob[]` |
| `POST` | `/api/cron/jobs` | `CronJobSchema` | `{ job }` |
| `GET` | `/api/cron/jobs/:id` | — | `CronJob` (404 if missing) |
| `PUT` | `/api/cron/jobs/:id` | partial `CronJob` | `{ job }` (400 if merge invalid) |
| `DELETE` | `/api/cron/jobs/:id` | — | `{}` |
| `PUT` | `/api/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job }` |
| `POST` | `/api/cron/jobs/:id/run` | — | `{ run, activeAgents }` |
| `GET` | `/api/cron/jobs/:id/runs` | — | `CronJobRun[]` (newest first) |
| `GET` | `/api/cron/runs` | — | all `CronJobRun[]` (newest first) |
---
## 11. SSE events
Emitted on `/api/events`, mirrored in `SSE_EVENTS` (`constants.js`):
| Event | Payload | When |
| ------------------ | ------------ | -------------------------------------------------------------------- |
| `cron:jobsChanged` | `{ jobs }` | Any job created / updated / enabled / status change. |
| `cron:jobDeleted` | `{ id }` | A job was deleted. |
| `cron:runCreated` | `CronJobRun` | A run (incl. skips) started. |
| `cron:runUpdated` | `CronJobRun` | A run advanced state (`session_started` / `prompt_sent` / `failed`). |
---
## 12. State & persistence
Persisted in `~/.codeman/state.json` via `StateStore`:
- `AppState.cronJobs` — map of `id → CronJob`.
- `AppState.cronJobRuns` — map of `id → CronJobRun`.
Jobs and their schedules survive restarts; `init()` recomputes `nextRunAt` on
boot. Sessions the jobs create persist through the normal session-recovery path.
---
## 13. Limits & constants
| Constant | Value | Source |
| ------------------------ | --------------------- | ------------------------------------------------ |
| Due-tick interval | 30s | `CRON_TICK_INTERVAL` (`config/server-timing.ts`) |
| Readiness poll | 60 × 500ms | `CRON_READY_MAX_ATTEMPTS` |
| Readiness settle | 2000ms | `CRON_READY_SETTLE_MS` |
| Run-history cap (global) | 500 | `MAX_CRON_RUN_HISTORY` (`config/map-limits.ts`) |
| Saved-jobs cap | 100 | `MAX_CRON_JOBS` (`config/map-limits.ts`) |
| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` |
| Prompt-file size cap | 1 MiB | `MAX_PROMPT_FILE_BYTES` (`cron-service.ts`) |
| `name` length | 1–200 | `CronJobSchema` |
| `promptText` length | ≤ 100000 | `CronJobSchema` |
| `intervalMinutes` | 1–525600 | `CronJobSchema` |
| `weeklyDays` | 1–7 entries, each 0–6 | `CronJobSchema` |
---
## 14. Known limitations
- **Server-local timezone only** — `daily`/`weekly` times are interpreted in the
host's local time; there is no per-job timezone.
- **Interval drift** — `interval` re-anchors to the actual fire time; long-running
intervals slowly shift.
- **Single-line prompts** — multi-line prompts are rejected (schema, form, and
at fire time for prompt files); tell the agent to read a file itself for
multi-line instructions.
- **`runNow` / tick race** — a manual Run Now firing at the same instant as a
scheduled tick is theoretically possible; benign (you may get two sessions).
- **`{enabled:true}` on a dead `once` job** — re-enabling a fired one-time job
without changing its schedule leaves it enabled-but-dead (won't fire); change
the schedule to re-arm.
---
## 15. Troubleshooting
| Symptom | Likely cause | Fix |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Job never fires | Disabled, or `nextRunAt: null` | Check **Enabled**; verify the schedule fields are complete. |
| Run shows `failed` immediately | Bad `workingDir`, prompt-file rejected, or session cap hit | Read `errorMessage` on the run; confirm the dir exists and the prompt file is inside it and < 1 MiB. |
| Run shows `skipped` | `skip_if_same_agent_running` + another live same-type session (this job's own sessions and dead tabs don't count) | Switch to `warn_only`, or wait for the other session to end. |
| Run fails with "single line" | Multi-line prompt text / prompt file | Keep the prompt to one line; point the agent at a file to read for long instructions. |
| Sessions pile up between runs | `autoClosePreviousSession: false` | Re-enable auto-close, or delete old tabs before the 50-session cap bites (see §8). |
| Wrong fire time | Timezone assumption | Times are **server-local** — check the host clock/TZ. |
| One-time job won't re-fire | `completedOnce` set | Edit the schedule (any real schedule change re-arms it). |
---
## 16. Related docs
- `docs/cron-discovery.md` — architecture / integration-point analysis (why the
feature reuses the session layer and stays distinct from `ScheduledRun`).
- `docs/cron-build-brief.md` — the original build brief / requirements.
- `CLAUDE.md` → **Key Patterns → Cron** — the one-paragraph engineering summary.
- Tests: `test/cron-time.test.ts` (schedule math), `test/cron-service.test.ts`
(CRUD, tick, concurrency, security).
+13
View File
@@ -43,6 +43,19 @@ export const MAX_SSE_CLIENTS = 100;
*/
export const MAX_TODOS_PER_SESSION = 500;
/**
* Maximum cron-job run-history records retained across all jobs. Oldest runs
* (by startedAt) are pruned when exceeded — bounds state.json growth from
* frequently-firing or perpetually-skipped jobs.
*/
export const MAX_CRON_RUN_HISTORY = 500;
/**
* Maximum saved cron jobs. Jobs persist to state.json, so an unbounded count
* would grow it without limit; creation past the cap is rejected with 400.
*/
export const MAX_CRON_JOBS = 100;
// ============================================================================
// Pending Tool Calls Limits
// ============================================================================
+13
View File
@@ -51,6 +51,19 @@ export const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
/** Completed scheduled run max age before cleanup (ms) */
export const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
// ============================================================================
// Cron Jobs
// ============================================================================
/** 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 CRON_READY_MAX_ATTEMPTS = 60;
/** Extra settle delay after CLI readiness is detected, before sending the prompt (ms). */
export const CRON_READY_SETTLE_MS = 2000;
/** Session limit retry wait before retrying (ms) */
export const SESSION_LIMIT_WAIT_MS = 5000;
+32
View File
@@ -0,0 +1,32 @@
/**
* @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/cron.js';
import type { SessionMode } from '../types/session.js';
export type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
export interface CronJobInput {
name: string;
agentType: SessionMode;
workingDir: string;
launchCommand?: string;
promptMode: PromptMode;
promptText?: string;
promptFilePath?: string;
inputMode: InputMode;
scheduleType: ScheduleType;
runAt?: number;
intervalMinutes?: number;
dailyTime?: string;
weeklyDays?: number[];
weeklyTime?: string;
enabled: boolean;
notes?: string;
concurrencyPolicy: ConcurrencyPolicy;
/** Default true. Ignored for 'once' schedules. */
autoClosePreviousSession?: boolean;
}
+637
View File
@@ -0,0 +1,637 @@
/**
* @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
* layer (create → addSession → setupSessionListeners → startInteractive/Shell →
* send prompt via writeViaMux/write), mirroring the "quick start" route flow.
*/
import { v4 as uuidv4 } from 'uuid';
import { readFile } from 'node:fs/promises';
import { statSync, realpathSync } from 'node:fs';
import { Session } from '../session.js';
import { SseEvent } from '../web/sse-events.js';
import { CronJobSchema } from '../web/schemas.js';
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
import {
DEFAULT_BLOCKED_TREES,
isBlockedAttachmentPath,
loadAttachmentGuardConfig,
} from '../config/attachment-guard.js';
import { validateSessionFilePath } from '../web/route-helpers.js';
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.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 cron depends on. */
export type CronDeps = SessionPort & EventPort & ConfigPort & InfraPort;
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/** Hard ceiling on a prompt-file read (defends against unbounded-read DoS). */
const MAX_PROMPT_FILE_BYTES = 1024 * 1024;
/**
* Pseudo-filesystem trees a cron job may never touch, ON TOP of the shared
* attachment blocklist. `/proc` in particular defeats the workingDir
* confinement trick (`workingDir: '/proc'` + `promptFilePath:
* '/proc/self/environ'` would read the SERVER's own environment).
*/
const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev'];
/** Sync blocklist for the create/update workingDir gate (no settings extras). */
const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES];
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
const HAS_NEWLINE = /[\r\n]/;
/** Order-insensitive equality for the weekly-days arrays. */
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
const x = [...(a ?? [])].sort((p, q) => p - q);
const y = [...(b ?? [])].sort((p, q) => p - q);
return x.length === y.length && x.every((v, i) => v === y[i]);
}
export class CronService {
constructor(private readonly deps: CronDeps) {}
private get store() {
return this.deps.store;
}
// ───────────────────────────── Reads ─────────────────────────────
listJobs(): CronJob[] {
return Object.values(this.store.getCronJobs());
}
getJob(id: string): CronJob | null {
return this.store.getCronJob(id);
}
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);
}
/**
* Number of LIVE sessions of a given agent type (for the multi-session
* warning and the skip_if_same_agent_running policy). Sessions whose CLI has
* exited (`stopped`/`error` — the tab is still open but nothing is running)
* don't count. When `excludeJobId` is given, sessions created by that job's
* own runs are also excluded — otherwise a recurring job with the skip
* policy would deadlock on its own previous (never-closed) session and fire
* exactly once, forever skipping after that.
*/
countActiveAgents(agentType: string, excludeJobId?: string): number {
const ownSessionIds = excludeJobId
? new Set(
this.listRuns(excludeJobId)
.map((r) => r.sessionId)
.filter((id): id is string => id !== null)
)
: null;
let n = 0;
for (const [id, s] of this.deps.sessions.entries()) {
if (s.mode !== agentType) continue;
if (s.status === 'stopped' || s.status === 'error') continue;
if (ownSessionIds?.has(id)) continue;
n++;
}
return n;
}
// ──────────────────────────── Mutations ───────────────────────────
createJob(input: CronJobInput): CronJob {
if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) {
throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`);
}
this.assertValidWorkingDir(input.workingDir);
const now = Date.now();
const job: CronJob = {
id: uuidv4(),
name: input.name,
agentType: input.agentType,
workingDir: input.workingDir,
launchCommand: input.launchCommand,
promptMode: input.promptMode,
promptText: input.promptText,
promptFilePath: input.promptFilePath,
inputMode: input.inputMode,
scheduleType: input.scheduleType,
runAt: input.runAt,
intervalMinutes: input.intervalMinutes,
dailyTime: input.dailyTime,
weeklyDays: input.weeklyDays,
weeklyTime: input.weeklyTime,
enabled: input.enabled,
notes: input.notes,
concurrencyPolicy: input.concurrencyPolicy,
autoClosePreviousSession: input.autoClosePreviousSession ?? true,
createdAt: now,
updatedAt: now,
lastRunAt: null,
nextRunAt: null,
lastStatus: null,
lastDueKey: null,
};
job.nextRunAt = job.enabled ? computeNextRunAt(job, now) : null;
this.store.setCronJob(job.id, job);
this.broadcastListChanged();
return job;
}
updateJob(id: string, patch: Partial<CronJobInput>): CronJob | null {
const existing = this.getJob(id);
if (!existing) return null;
const now = Date.now();
// A completed one-time job is only re-armed when the SCHEDULE actually
// CHANGES — otherwise a cosmetic edit would silently resurrect a job that
// already fired. We compare VALUES, not field-presence: the edit form
// round-trips the full job (incl. unchanged scheduleType/runAt) on every
// save, so a presence check would always re-arm. Only a real schedule
// change re-arms.
const changed = <T>(next: T | undefined, prev: T): boolean => next !== undefined && next !== prev;
const scheduleChanged =
changed(patch.scheduleType, existing.scheduleType) ||
changed(patch.runAt, existing.runAt) ||
changed(patch.intervalMinutes, existing.intervalMinutes) ||
changed(patch.dailyTime, existing.dailyTime) ||
changed(patch.weeklyTime, existing.weeklyTime) ||
(patch.weeklyDays !== undefined && !sameDays(patch.weeklyDays, existing.weeklyDays));
const reArm = existing.scheduleType !== 'once' || !existing.completedOnce || scheduleChanged;
const updated: CronJob = {
...existing,
...patch,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: now,
completedOnce: reArm ? false : existing.completedOnce,
lastDueKey: null,
};
// The PUT schema is `.partial()`, so its cross-field rules don't run on a
// partial body. Re-validate the MERGED job against the full schema so a
// partial edit can't leave an enabled job with an inconsistent schedule
// (e.g. switching to `once` without a `runAt` → a dead `nextRunAt:null`).
const check = CronJobSchema.safeParse(updated);
if (!check.success) {
throw this.badRequest(check.error.issues[0]?.message ?? 'Invalid cron job update');
}
if (patch.workingDir !== undefined) this.assertValidWorkingDir(patch.workingDir);
updated.nextRunAt = updated.enabled ? computeNextRunAt(updated, now) : null;
this.store.setCronJob(updated.id, updated);
this.broadcastListChanged();
return updated;
}
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.setCronJob(existing.id, existing);
this.broadcastListChanged();
return existing;
}
deleteJob(id: string): boolean {
if (!this.getJob(id)) return false;
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;
}
// ──────────────────────────── Execution ───────────────────────────
/** Manual Run Now — always launches regardless of schedule/enabled state. */
async runNow(id: string): Promise<CronJobRun | null> {
const job = this.getJob(id);
if (!job) return null;
return this.launch(job, 'manual_run_now');
}
/**
* Background tick: launch every enabled job whose next run is due. Advances
* each job's schedule and guards against double-launching the same due time.
*/
async tickDueJobs(now: number = Date.now()): Promise<void> {
for (const job of this.listJobs()) {
if (!job.enabled || job.nextRunAt == null || job.nextRunAt > now) continue;
const key = dueKeyFor(job.id, job.nextRunAt);
if (job.lastDueKey === key) {
// This due time was already consumed (overlap/restart) — just advance.
this.advanceAfterFire(job, now);
continue;
}
// Optional concurrency policy for AUTOMATIC runs. Only LIVE sessions
// block, and this job's own previous sessions never do (see
// countActiveAgents) — otherwise a recurring job would deadlock on the
// session it created last time.
if (job.concurrencyPolicy === 'skip_if_same_agent_running' && this.countActiveAgents(job.agentType, job.id) > 0) {
// Record the skip so the job's run history isn't silently empty when it
// keeps getting skipped (otherwise it looks like the job never ran).
this.recordSkippedRun(job);
if (job.scheduleType === 'once') {
// A skipped one-time job is NOT consumed: leave nextRunAt armed (and
// the due key unconsumed) so the next tick retries once the blocking
// session goes away.
continue;
}
job.lastDueKey = key;
this.advanceAfterFire(job, now);
continue;
}
job.lastDueKey = key;
// Advance the schedule BEFORE launching so a slow launch can't be
// re-triggered by the next tick.
this.advanceAfterFire(job, now);
this.launch(job, 'scheduled').catch((err) =>
console.error(`[cron] launch failed for job ${job.id}:`, getErrorMessage(err))
);
}
}
/** Recompute nextRunAt for loaded jobs on boot (e.g. after a restart). */
init(): void {
const now = Date.now();
for (const job of this.listJobs()) {
const isDeadOnce = job.scheduleType === 'once' && job.completedOnce;
if (job.enabled && job.nextRunAt == null && !isDeadOnce) {
job.nextRunAt = computeNextRunAt(job, now);
this.store.setCronJob(job.id, job);
}
}
}
// ──────────────────────────── Internals ───────────────────────────
private advanceAfterFire(job: CronJob, now: number): void {
if (job.scheduleType === 'once') {
job.completedOnce = true;
job.enabled = false;
job.nextRunAt = null;
} else {
job.nextRunAt = computeNextRunAt(job, now);
}
job.updatedAt = now;
this.store.setCronJob(job.id, job);
this.broadcastListChanged();
}
private async launch(job: CronJob, trigger: TriggerType): Promise<CronJobRun> {
const run: CronJobRun = {
id: uuidv4(),
cronJobId: job.id,
sessionId: null,
sessionName: null,
startedAt: Date.now(),
finishedAt: null,
status: 'created',
triggerType: trigger,
createdSessionUrl: null,
};
this.store.setCronJobRun(run.id, run);
this.pruneRunHistory();
this.deps.broadcast(SseEvent.CronRunCreated, run);
// Resolve the prompt.
let prompt: string;
try {
prompt = await this.resolvePrompt(job);
} catch (err) {
return this.failRun(job, run, `Prompt error: ${getErrorMessage(err)}`);
}
// Validate working directory.
try {
if (!statSync(job.workingDir).isDirectory()) {
return this.failRun(job, run, 'workingDir is not a directory');
}
} catch {
return this.failRun(job, run, 'workingDir does not exist');
}
// Recurring jobs: close the still-open session created by this job's
// previous run before launching the next (default ON, opt-out via
// autoClosePreviousSession:false) — otherwise an unattended interval/daily
// job accumulates a new tab per fire until the global session cap.
if (job.scheduleType !== 'once' && job.autoClosePreviousSession !== false) {
await this.closePreviousRunSessions(job, run.id);
}
// Respect the global session cap.
if (this.deps.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
}
// Create + start the session (mirrors the quick-start route flow).
let session: Session;
try {
const mode = job.agentType;
const globalNice = await this.deps.getGlobalNiceConfig();
const modelConfig = await this.deps.getModelConfig();
const claudeModeConfig = await this.deps.getClaudeModeConfig();
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
session = new Session({
workingDir: job.workingDir,
mode,
name: job.name,
mux: this.deps.mux,
useMux: true,
niceConfig: globalNice,
model,
claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools,
});
this.deps.addSession(session);
this.store.incrementSessionsCreated();
this.deps.persistSessionState(session);
await this.deps.setupSessionListeners(session);
this.deps.broadcast(SseEvent.SessionCreated, this.deps.getSessionStateWithRespawn(session));
if (mode === 'shell') {
await session.startShell();
} else {
await session.startInteractive();
}
this.deps.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
} catch (err) {
return this.failRun(job, run, `Session launch failed: ${getErrorMessage(err)}`);
}
run.sessionId = session.id;
run.sessionName = session.name;
run.createdSessionUrl = `/?session=${session.id}`;
run.status = 'session_started';
this.store.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).
this.sendPromptWhenReady(session.id, prompt, job, run);
return run;
}
/**
* Resolves the prompt text and enforces the single-line constraint: prompt
* delivery rides writeViaMux/PTY writes where a newline is Enter, so a
* multi-line prompt would be silently corrupted (typed mode fuses lines,
* paste mode submits the first line and dribbles the rest in as separate
* messages). Rather than mangle an unattended agent's instructions, fail the
* run with a clear error. A prompt FILE may end with trailing newline(s)
* (every editor writes one) — those are stripped before the check.
*/
private async resolvePrompt(job: CronJob): Promise<string> {
if (job.promptMode === 'prompt_file_path') {
if (!job.promptFilePath) throw new Error('prompt file path is empty');
const safePath = await this.resolveSafePromptPath(job.promptFilePath, job.workingDir);
const content = (await readFile(safePath, 'utf-8')).replace(/[\r\n]+$/, '');
if (HAS_NEWLINE.test(content)) {
throw new Error('prompt file must contain a single line — multi-line prompts are not supported');
}
return content;
}
const text = job.promptText ?? '';
if (HAS_NEWLINE.test(text)) {
// Schema-rejected since this check was added; guards legacy persisted jobs.
throw new Error('promptText must be a single line — multi-line prompts are not supported');
}
return text;
}
/**
* Guards a prompt-file path before it is read. The path is user-supplied via
* the API and its contents are injected into an agent session (an exfil sink
* over SSE/terminal), so an unconfined read would let a hostile job config
* pull arbitrary host files — including the SERVER PROCESS'S OWN secrets via
* `/proc/self/environ` — into the session.
*
* A denylist is the wrong posture for an exfil sink (it kept missing `/proc`,
* `/dev`, other users' `~/.ssh`, modern cloud creds…). So the PRIMARY gate is
* an allowlist: the prompt file must resolve INSIDE the job's working
* directory. A symlink escaping the workspace fails this because we check the
* realpath-resolved target. We additionally require a regular file (rejects
* directories, FIFOs, and `/dev/*` character devices that would hang or OOM
* the unbounded read) within a sane size cap, and keep the shared blocklist as
* cheap defense-in-depth. Returns the symlink-resolved path to read.
*/
private async resolveSafePromptPath(rawPath: string, workingDir: string): Promise<string> {
let resolved: string;
try {
resolved = realpathSync(rawPath);
} catch {
throw new Error('prompt file path could not be resolved');
}
// workingDir is USER-CONTROLLED, so it is not a trust boundary by itself:
// realpath-resolve it (a symlinked workspace must not defeat containment)
// and reject blocked/pseudo-fs trees — otherwise workingDir '/proc' would
// make '/proc/self/environ' pass the containment check below.
let realWorkingDir: string;
try {
realWorkingDir = realpathSync(workingDir);
} catch {
throw new Error('job working directory could not be resolved');
}
const guard = await loadAttachmentGuardConfig();
const blockedTrees = [...guard.blockedTrees, ...CRON_PSEUDO_FS_TREES];
if (realWorkingDir === '/' || isBlockedAttachmentPath(realWorkingDir, blockedTrees)) {
throw new Error('job working directory is blocked');
}
// Defense-in-depth blocklist (secret locations, /etc, /root, pseudo-fs).
if (isBlockedAttachmentPath(resolved, blockedTrees)) {
throw new Error('prompt file path is blocked');
}
// Primary gate: the prompt file must live inside the job's workspace.
if (!validateSessionFilePath(realWorkingDir, resolved)) {
throw new Error('prompt file path must be inside the job working directory');
}
// Reject non-regular files and oversized files (DoS via unbounded read).
let info;
try {
info = statSync(resolved);
} catch {
throw new Error('prompt file path could not be resolved');
}
if (!info.isFile()) throw new Error('prompt file path is not a regular file');
if (info.size > MAX_PROMPT_FILE_BYTES) throw new Error('prompt file is too large');
return resolved;
}
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 < 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(CRON_READY_SETTLE_MS);
} else {
await delay(1000);
// Shell mode: deliver the optional custom launch command as the
// first input line (single-line, schema-enforced), then give it a
// moment to start before the prompt follows.
if (job.launchCommand) {
const shell = this.deps.sessions.get(sessionId);
if (!shell) return;
const sent = await shell.writeViaMux(`${job.launchCommand}\r`);
if (!sent) {
this.failRun(job, run, 'Failed to send launch command: mux write failed');
return;
}
await delay(1000);
}
}
const s = this.deps.sessions.get(sessionId);
if (!s) return;
try {
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
let delivered = true;
if (job.inputMode === 'paste') {
s.write(payload);
} else {
delivered = await s.writeViaMux(payload);
}
if (!delivered) {
this.failRun(job, run, 'Failed to send prompt: mux write failed');
return;
}
run.status = 'prompt_sent';
run.finishedAt = Date.now();
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('[cron] sendPromptWhenReady error:', getErrorMessage(err)));
});
}
/** 400-shaped error for route handlers (mirrors parseBody's error contract). */
private badRequest(msg: string): Error {
return Object.assign(new Error(msg), {
statusCode: 400,
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg),
});
}
/**
* Create/update gate for a job's workingDir: must exist, be a directory, and
* not resolve into a blocked or pseudo-filesystem tree (nor the fs root).
* The user-supplied workingDir doubles as the prompt-file confinement root,
* so an unrestricted value would defeat that boundary (e.g. '/proc').
*/
private assertValidWorkingDir(workingDir: string): void {
let real: string;
try {
real = realpathSync(workingDir);
} catch {
throw this.badRequest('workingDir does not exist');
}
if (!statSync(real).isDirectory()) throw this.badRequest('workingDir is not a directory');
if (real === '/' || isBlockedAttachmentPath(real, CRON_WORKING_DIR_BLOCKED_TREES)) {
throw this.badRequest('workingDir is not allowed (blocked or pseudo-filesystem tree)');
}
}
/** Close still-open sessions created by this job's previous runs (normal cleanup path). */
private async closePreviousRunSessions(job: CronJob, currentRunId: string): Promise<void> {
for (const prev of this.listRuns(job.id)) {
if (prev.id === currentRunId || !prev.sessionId) continue;
if (!this.deps.sessions.has(prev.sessionId)) continue;
try {
await this.deps.cleanupSession(prev.sessionId, true, 'cron: superseded by the next run of this job');
} catch (err) {
console.error(`[cron] failed to auto-close previous session ${prev.sessionId}:`, getErrorMessage(err));
}
}
}
private failRun(job: CronJob, run: CronJobRun, message: string): CronJobRun {
run.status = 'failed';
run.errorMessage = message;
run.finishedAt = Date.now();
this.store.setCronJobRun(run.id, run);
this.deps.broadcast(SseEvent.CronRunUpdated, run);
this.updateJobLastStatus(job.id, 'failed');
return run;
}
private recordSkippedRun(job: CronJob): void {
// Coalesce consecutive skips: if the job is already in a skip streak, don't
// record again — a perpetually-skipped interval job would otherwise write a
// run every tick forever and bloat state.json.
if (this.listRuns(job.id)[0]?.status === 'skipped') return;
const now = Date.now();
const run: CronJobRun = {
id: uuidv4(),
cronJobId: job.id,
sessionId: null,
sessionName: null,
startedAt: now,
finishedAt: now,
status: 'skipped',
errorMessage: `Skipped: a ${job.agentType} agent is already running (concurrency policy)`,
triggerType: 'scheduled',
createdSessionUrl: null,
};
this.store.setCronJobRun(run.id, run);
this.pruneRunHistory();
this.deps.broadcast(SseEvent.CronRunCreated, run);
// A skip is NOT a run: surface it as the lastStatus, but do NOT advance
// lastRunAt (no session was created).
this.updateJobLastStatus(job.id, 'skipped', { touchLastRun: false });
}
/** Prune the oldest run records (by startedAt) once the global cap is exceeded. */
private pruneRunHistory(): void {
const runs = Object.values(this.store.getCronJobRuns());
if (runs.length <= MAX_CRON_RUN_HISTORY) return;
runs.sort((a, b) => a.startedAt - b.startedAt);
for (const run of runs.slice(0, runs.length - MAX_CRON_RUN_HISTORY)) {
this.store.removeCronJobRun(run.id);
}
}
private updateJobLastStatus(jobId: string, status: CronJobRunStatus, opts: { touchLastRun?: boolean } = {}): void {
const fresh = this.store.getCronJob(jobId);
if (!fresh) return;
const now = Date.now();
fresh.lastStatus = status;
if (opts.touchLastRun !== false) fresh.lastRunAt = now;
fresh.updatedAt = now;
this.store.setCronJob(fresh.id, fresh);
this.broadcastListChanged();
}
private broadcastListChanged(): void {
this.deps.broadcast(SseEvent.CronJobsChanged, { jobs: this.listJobs() });
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* @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
* timezone for v0.1 (per the build brief) — daily/weekly wall-clock times are
* interpreted via the host's local time.
*/
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 {
if (!value) return null;
const m = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
if (!m) return null;
const hours = Number(m[1]);
const minutes = Number(m[2]);
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
return { hours, minutes };
}
/**
* Returns the epoch-ms timestamp for `hours:minutes` (local time) on the day of
* `base`, shifted by `dayOffset` days.
*/
function atLocalTime(base: number, hours: number, minutes: number, dayOffset: number): number {
const d = new Date(base);
d.setHours(hours, minutes, 0, 0);
d.setDate(d.getDate() + dayOffset);
return d.getTime();
}
/**
* Compute the next fire time strictly relevant to `after`, or null if the job
* has no future run (e.g. a completed one-time job, or invalid config).
*
* For `once`, returns the absolute `runAt` (even if already in the past, so a
* missed one-time job still fires once) until it has `completedOnce`.
*/
export function computeNextRunAt(job: CronJob, after: number): number | null {
switch (job.scheduleType) {
case 'once': {
if (job.completedOnce) return null;
return typeof job.runAt === 'number' ? job.runAt : null;
}
case 'interval': {
const minutes = job.intervalMinutes;
if (!minutes || minutes <= 0) return null;
return after + minutes * 60_000;
}
case 'daily': {
const t = parseHHMM(job.dailyTime);
if (!t) return null;
let next = atLocalTime(after, t.hours, t.minutes, 0);
if (next <= after) next = atLocalTime(after, t.hours, t.minutes, 1);
return next;
}
case 'weekly': {
const t = parseHHMM(job.weeklyTime);
if (!t) return null;
const days = (job.weeklyDays ?? []).filter((d) => d >= 0 && d <= 6);
if (days.length === 0) return null;
for (let offset = 0; offset <= 7; offset++) {
const cand = atLocalTime(after, t.hours, t.minutes, offset);
if (cand > after && days.includes(new Date(cand).getDay())) return cand;
}
return null;
}
default:
return null;
}
}
/**
* Duplicate-launch guard key: identifies a specific due time for a job. The
* 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 {
return `${jobId}:${fireTime}`;
}
+51
View File
@@ -272,6 +272,12 @@ export class StateStore {
if (this.state.tokenStats) {
parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`);
}
if (this.state.cronJobs) {
parts.push(`"cronJobs":${JSON.stringify(this.state.cronJobs)}`);
}
if (this.state.cronJobRuns) {
parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`);
}
return `{${parts.join(',')}}`;
}
@@ -568,6 +574,51 @@ export class StateStore {
this.save();
}
// ========== Cron Job Methods ==========
/** Returns all scheduled jobs keyed by job ID. */
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. */
getCronJob(id: string): import('./types/cron.js').CronJob | null {
return this.state.cronJobs?.[id] ?? null;
}
/** Sets a scheduled job and triggers a debounced save. */
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. */
removeCronJob(id: string): void {
if (this.state.cronJobs) delete this.state.cronJobs[id];
this.save();
}
/** Returns all scheduled job runs keyed by run ID. */
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. */
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. */
removeCronJobRun(id: string): void {
if (this.state.cronJobRuns) delete this.state.cronJobRuns[id];
this.save();
}
/** Returns the application configuration. */
getConfig() {
return this.state.config;
+5
View File
@@ -23,6 +23,7 @@ import type { SessionState } from './session.js';
import type { TaskState } from './task.js';
import type { RalphLoopState } from './ralph.js';
import type { RespawnConfig } from './respawn.js';
import type { CronJob, CronJobRun } from './cron.js';
// ========== Global Stats Types ==========
@@ -111,6 +112,10 @@ export interface AppState {
tokenStats?: TokenStats;
/** Orchestrator Loop state (phased plan execution) */
orchestrator?: import('./orchestrator.js').OrchestratorPersistState;
/** Cron-style scheduled jobs, keyed by job ID. */
cronJobs?: Record<string, CronJob>;
/** Scheduled job run history, keyed by run ID. */
cronJobRuns?: Record<string, CronJobRun>;
}
// ========== Default Configuration ==========
+101
View File
@@ -0,0 +1,101 @@
/**
* @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 `CronJob` is a SAVED, NAMED job with a recurring
* schedule (once/interval/daily/weekly), enable/disable, next-run calculation,
* and a history of `CronJobRun` records. The two do not interact.
*
* Persisted to `~/.codeman/state.json` via StateStore (see AppState).
*/
import type { SessionMode } from './session.js';
/** How a job's fire times are computed. */
export type ScheduleType = 'once' | 'interval' | 'daily' | 'weekly';
/** Where the prompt text comes from. */
export type PromptMode = 'inline_text' | 'prompt_file_path';
/** How the prompt is delivered into the session. */
export type InputMode = 'paste' | 'typed';
/** Lifecycle status of a single job execution. */
export type CronJobRunStatus = 'created' | 'session_started' | 'prompt_sent' | 'failed' | 'skipped';
/** What triggered a run. */
export type TriggerType = 'scheduled' | 'manual_run_now';
/** What to do for an AUTOMATIC run when sessions of the same agent already exist. */
export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running';
/**
* A saved, named cron job.
*/
export interface CronJob {
id: string;
name: string;
/** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */
agentType: SessionMode;
workingDir: string;
/** Optional custom launch command (only meaningful for 'shell' mode). */
launchCommand?: string;
promptMode: PromptMode;
promptText?: string;
promptFilePath?: string;
inputMode: InputMode;
scheduleType: ScheduleType;
/** once: absolute epoch-ms fire time. */
runAt?: number;
/** interval: minutes between fires. */
intervalMinutes?: number;
/** daily: 'HH:MM' (24h, server-local time). */
dailyTime?: string;
/** weekly: weekdays 0–6 (0=Sunday). */
weeklyDays?: number[];
/** weekly: 'HH:MM' (24h, server-local time). */
weeklyTime?: string;
enabled: boolean;
notes?: string;
/** Applies to automatic (scheduled) runs only. Manual Run Now always warns client-side. */
concurrencyPolicy: ConcurrencyPolicy;
/**
* Close the still-open session created by this job's previous run before the
* next run launches (via the normal session-cleanup path), so unattended
* recurring jobs don't accumulate tabs until the global session cap.
* Default true. Ignored for 'once' schedules.
*/
autoClosePreviousSession?: boolean;
// ── Bookkeeping (server-maintained) ─────────────────────────────────────
createdAt: number;
updatedAt: number;
lastRunAt: number | null;
nextRunAt: number | 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). */
completedOnce?: boolean;
}
/**
* A single execution of a cron job (history record).
*/
export interface CronJobRun {
id: string;
cronJobId: string;
sessionId: string | null;
sessionName: string | null;
startedAt: number;
finishedAt: number | null;
status: CronJobRunStatus;
errorMessage?: string;
triggerType: TriggerType;
/** Best-effort deep link to the created session in the web UI. */
createdSessionUrl: string | null;
}
+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
View File
@@ -13,3 +13,4 @@ export type { ConfigPort } from './config-port.js';
export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
export type { CronPort } from './cron-port.js';
+6
View File
@@ -183,6 +183,12 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'],
[SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'],
// Scheduled jobs (cron-style scheduler)
[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'],
[SSE_EVENTS.RESPAWN_STOPPED, '_onRespawnStopped'],
+6
View File
@@ -307,6 +307,12 @@ const SSE_EVENTS = {
SCHEDULED_LOG: 'scheduled:log',
SCHEDULED_DELETED: 'scheduled:deleted',
// 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',
RESPAWN_STOPPED: 'respawn:stopped',
+328
View File
@@ -0,0 +1,328 @@
/**
* @fileoverview Cron Jobs UI mixed into
* CodemanApp.prototype. Renders the job list + create/edit form in the
* #cronModal, and reacts to cron:* SSE events.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js, api-client.js, constants.js (escapeHtml)
*/
Object.assign(CodemanApp.prototype, {
// ── SSE handlers ──────────────────────────────────────────────────────────
_onCronJobsChanged(data) {
if (data && Array.isArray(data.jobs)) {
this._cronJobs = data.jobs;
if (this._isCronOpen()) this.renderCronJobs();
} else if (this._isCronOpen()) {
this.refreshCron();
}
},
_onCronRunChanged() {
// A run's status changed — refresh the list so lastStatus stays current.
if (this._isCronOpen()) this.refreshCron();
},
// ── Modal open/close ──────────────────────────────────────────────────────
_isCronOpen() {
const el = document.getElementById('cronModal');
return !!el && el.classList.contains('active');
},
openCron() {
const el = document.getElementById('cronModal');
if (!el) return;
el.classList.add('active');
this.cancelCronJobForm();
this.refreshCron();
},
closeCron() {
const el = document.getElementById('cronModal');
if (el) el.classList.remove('active');
},
async refreshCron() {
const jobs = await this._apiJson('/api/cron/jobs');
this._cronJobs = Array.isArray(jobs) ? jobs : [];
this.renderCronJobs();
},
// ── List rendering ────────────────────────────────────────────────────────
renderCronJobs() {
const list = document.getElementById('cronJobList');
if (!list) return;
const jobs = this._cronJobs || [];
if (jobs.length === 0) {
list.innerHTML = '<div class="form-hint">No cron jobs yet. Click “+ New Job”.</div>';
return;
}
const rows = jobs.map((j) => {
const next = j.enabled ? this._fmtTime(j.nextRunAt) : '—';
const last = this._fmtTime(j.lastRunAt);
const status = j.lastStatus ? escapeHtml(j.lastStatus) : '—';
return `
<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="cron-job-meta">
<span title="${escapeHtml(j.workingDir || '')}">${escapeHtml(j.workingDir || '')}</span>
· next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status}
</div>
</div>
<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>`;
});
list.innerHTML = rows.join('');
},
_fmtTime(ts) {
if (!ts) return '—';
try {
return new Date(ts).toLocaleString();
} catch {
return '—';
}
},
_fmtSchedule(j) {
switch (j.scheduleType) {
case 'once':
return 'once';
case 'interval':
return `every ${j.intervalMinutes}m`;
case 'daily':
return `daily ${j.dailyTime || ''}`;
case 'weekly': {
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const days = (j.weeklyDays || []).map((d) => names[d] || d).join(',');
return `weekly ${days} ${j.weeklyTime || ''}`;
}
default:
return j.scheduleType || '';
}
},
// ── Create / edit form ────────────────────────────────────────────────────
openCronJobForm(job) {
const form = document.getElementById('cronJobForm');
if (!form) return;
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';
document.getElementById('schWorkingDir').value = job ? job.workingDir || '' : '';
document.getElementById('schLaunchCommand').value = job ? job.launchCommand || '' : '';
document.getElementById('schPromptMode').value = job ? job.promptMode || 'inline_text' : 'inline_text';
document.getElementById('schPromptText').value = job ? job.promptText || '' : '';
document.getElementById('schPromptFilePath').value = job ? job.promptFilePath || '' : '';
document.getElementById('schInputMode').value = job ? job.inputMode || 'typed' : 'typed';
document.getElementById('schScheduleType').value = job ? job.scheduleType || 'once' : 'once';
document.getElementById('schRunAt').value = job && job.runAt ? this._toLocalInput(job.runAt) : '';
document.getElementById('schIntervalMinutes').value = job && job.intervalMinutes ? job.intervalMinutes : 60;
document.getElementById('schDailyTime').value = job ? job.dailyTime || '' : '';
document.getElementById('schWeeklyTime').value = job ? job.weeklyTime || '' : '';
const weekly = (job && job.weeklyDays) || [];
document.querySelectorAll('#schWeeklyDays input[type=checkbox]').forEach((cb) => {
cb.checked = weekly.includes(Number(cb.value));
});
document.getElementById('schConcurrencyPolicy').value = job ? job.concurrencyPolicy || 'warn_only' : 'warn_only';
document.getElementById('schAutoClosePrev').checked = job ? job.autoClosePreviousSession !== false : true;
document.getElementById('schEnabled').checked = job ? !!job.enabled : true;
document.getElementById('schNotes').value = job ? job.notes || '' : '';
this.onCronAgentTypeChange();
this.onCronPromptModeChange();
this.onCronScheduleTypeChange();
form.classList.remove('hidden');
},
editCronJob(id) {
const job = (this._cronJobs || []).find((j) => j.id === id);
if (job) this.openCronJobForm(job);
},
cancelCronJobForm() {
const form = document.getElementById('cronJobForm');
if (form) form.classList.add('hidden');
},
onCronAgentTypeChange() {
// Launch command is only meaningful for shell mode (first input line).
const isShell = document.getElementById('schAgentType').value === 'shell';
document.getElementById('schLaunchCommandRow').classList.toggle('hidden', !isShell);
},
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');
},
onCronScheduleTypeChange() {
const t = document.getElementById('schScheduleType').value;
document.getElementById('schRunAtRow').classList.toggle('hidden', t !== 'once');
document.getElementById('schIntervalRow').classList.toggle('hidden', t !== 'interval');
document.getElementById('schDailyRow').classList.toggle('hidden', t !== 'daily');
document.getElementById('schWeeklyDaysRow').classList.toggle('hidden', t !== 'weekly');
document.getElementById('schWeeklyTimeRow').classList.toggle('hidden', t !== 'weekly');
},
_toLocalInput(ts) {
// epoch-ms → 'YYYY-MM-DDTHH:MM' in local time for <input datetime-local>.
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
},
_collectCronForm() {
const t = document.getElementById('schScheduleType').value;
const promptMode = document.getElementById('schPromptMode').value;
const body = {
name: document.getElementById('schName').value.trim(),
agentType: document.getElementById('schAgentType').value,
workingDir: document.getElementById('schWorkingDir').value.trim(),
promptMode,
inputMode: document.getElementById('schInputMode').value,
scheduleType: t,
concurrencyPolicy: document.getElementById('schConcurrencyPolicy').value,
autoClosePreviousSession: document.getElementById('schAutoClosePrev').checked,
enabled: document.getElementById('schEnabled').checked,
notes: document.getElementById('schNotes').value.trim() || undefined,
};
// Always sent for shell (an emptied field must clear a saved command on edit).
if (body.agentType === 'shell') body.launchCommand = document.getElementById('schLaunchCommand').value.trim();
if (promptMode === 'inline_text') {
// Prompt delivery is single-line only; trailing newlines are harmless, strip them.
body.promptText = document.getElementById('schPromptText').value.replace(/[\r\n]+$/, '');
} else {
body.promptFilePath = document.getElementById('schPromptFilePath').value.trim();
}
if (t === 'once') {
const v = document.getElementById('schRunAt').value;
body.runAt = v ? new Date(v).getTime() : undefined;
} else if (t === 'interval') {
body.intervalMinutes = Number(document.getElementById('schIntervalMinutes').value);
} else if (t === 'daily') {
body.dailyTime = document.getElementById('schDailyTime').value;
} else if (t === 'weekly') {
body.weeklyTime = document.getElementById('schWeeklyTime').value;
body.weeklyDays = Array.from(document.querySelectorAll('#schWeeklyDays input:checked')).map((cb) =>
Number(cb.value)
);
}
return body;
},
async saveCronJob() {
const errEl = document.getElementById('cronFormError');
errEl.textContent = '';
const body = this._collectCronForm();
if (!body.name) {
errEl.textContent = 'Name is required.';
return;
}
if (!body.workingDir) {
errEl.textContent = 'Working directory is required.';
return;
}
if (body.promptText !== undefined && /[\r\n]/.test(body.promptText)) {
errEl.textContent = 'Prompt must be a single line — multi-line prompts are not supported.';
return;
}
const id = document.getElementById('schJobId').value;
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 {
const j = await res.json();
if (j && j.error) msg = j.error;
} catch {
/* ignore */
}
errEl.textContent = msg;
return;
}
this.showToast?.(id ? 'Cron job updated' : 'Cron job created', 'success');
this.cancelCronJobForm();
this.refreshCron();
},
// ── Actions ───────────────────────────────────────────────────────────────
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/cron/jobs/${id}/run`, {});
if (res && res.ok) {
this.showToast?.('Run started — opening session', 'success');
let data = null;
try {
data = await res.json();
} catch {
/* ignore */
}
const run = data && (data.data ? data.data.run : data.run);
if (run && run.sessionId) this._focusCronSession(run.sessionId);
this.refreshCron();
} else {
this.showToast?.('Failed to run job', 'error');
}
},
_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.closeCron();
this.switchSession(sessionId);
}
},
_countActiveAgents(agentType) {
// Mirrors the server's countActiveAgents: only LIVE sessions count — a
// tab whose CLI already exited (stopped/error) doesn't block anything.
if (!this.sessions) return 0;
let n = 0;
for (const s of this.sessions.values()) {
if (s && s.mode === agentType && s.status !== 'stopped' && s.status !== 'error') n++;
}
return n;
},
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 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?.('Cron job deleted', 'success');
this.refreshCron();
} else {
this.showToast?.('Failed to delete job', 'error');
}
},
});
+95
View File
@@ -541,6 +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.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -571,6 +572,99 @@
</div>
</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>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.openCronJobForm()">+ New Job</button>
<button class="btn-toolbar btn-sm" onclick="app.refreshCron()">Refresh</button>
</div>
<!-- Job list -->
<div id="cronJobList" class="cron-job-list"></div>
<!-- Create/Edit form (hidden until New/Edit) -->
<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>
<select id="schAgentType" onchange="app.onCronAgentTypeChange()">
<option value="claude">Claude</option>
<option value="shell">Terminal / Shell</option>
<option value="opencode">OpenCode</option>
<option value="codex">Codex</option>
<option value="gemini">Gemini</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
<div class="form-row hidden" id="schLaunchCommandRow"><label>Launch Command</label><input type="text" id="schLaunchCommand" placeholder="Optional — runs as the first command in the new shell"></div>
<div class="form-row"><label>Prompt Source</label>
<select id="schPromptMode" onchange="app.onCronPromptModeChange()">
<option value="inline_text">Inline text</option>
<option value="prompt_file_path">Prompt file path</option>
</select>
</div>
<div class="form-row" id="schPromptTextRow"><label>Prompt</label><textarea id="schPromptText" rows="4" placeholder="Prompt to send into the session"></textarea></div>
<div class="form-row hidden" id="schPromptFileRow"><label>Prompt File Path</label><input type="text" id="schPromptFilePath" placeholder="/absolute/path/to/prompt.md"></div>
<div class="form-row"><label>Input Mode</label>
<select id="schInputMode">
<option value="typed">Typed (via tmux)</option>
<option value="paste">Paste (direct)</option>
</select>
</div>
<div class="form-row"><label>Schedule Type</label>
<select id="schScheduleType" onchange="app.onCronScheduleTypeChange()">
<option value="once">Once</option>
<option value="interval">Interval</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
<div class="form-row" id="schRunAtRow"><label>Run At</label><input type="datetime-local" id="schRunAt"></div>
<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="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>
<label><input type="checkbox" value="3">Wed</label>
<label><input type="checkbox" value="4">Thu</label>
<label><input type="checkbox" value="5">Fri</label>
<label><input type="checkbox" value="6">Sat</label>
</span>
</div>
<div class="form-row hidden" id="schWeeklyTimeRow"><label>Weekly Time (HH:MM)</label><input type="time" id="schWeeklyTime"></div>
<div class="form-row"><label>On auto-run, if same agent running</label>
<select id="schConcurrencyPolicy">
<option value="warn_only">Run anyway</option>
<option value="skip_if_same_agent_running">Skip this run</option>
</select>
</div>
<div class="form-row form-row-switch"><label title="Recurring schedules only: when the next run fires, the still-open session created by this job's previous run is closed first">Auto-close previous run's session</label>
<label class="switch"><input type="checkbox" id="schAutoClosePrev" checked><span class="slider"></span></label>
</div>
<div class="form-row form-row-switch"><label>Enabled</label>
<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="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.saveCronJob()">Save</button>
<button class="btn-toolbar btn-sm" onclick="app.cancelCronJobForm()">Cancel</button>
</div>
</div>
</div>
</div>
</div>
<!-- Monitor Panel (Tmux Sessions + Background Tasks) -->
<div class="monitor-panel" id="monitorPanel">
<div class="monitor-panel-header" id="monitorPanelHeader">
@@ -2064,6 +2158,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="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>
+22
View File
@@ -11071,3 +11071,25 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
background: linear-gradient(135deg, #7c3aed, #8b5cf6);
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5);
}
/* ── 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));
}
.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;
}
.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);
}
+80
View File
@@ -0,0 +1,80 @@
/**
* @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) => {
// No custom errorMessage: surface the schema's field-specific messages
// (e.g. "runAt is required for a one-time schedule").
const body = parseBody(CronJobSchema, req.body);
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);
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, job.id) };
});
// ── 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
View File
@@ -7,6 +7,7 @@ export { registerTeamRoutes } from './team-routes.js';
export { registerMuxRoutes } from './mux-routes.js';
export { registerFileRoutes } from './file-routes.js';
export { registerScheduledRoutes } from './scheduled-routes.js';
export { 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';
+67
View File
@@ -603,6 +603,73 @@ export const ScheduledRunSchema = z.object({
durationMinutes: z.number().int().min(1).max(14400).optional(),
});
// ========== 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)');
/** Prompt delivery is single-line only (writeViaMux/Ink constraint) — reject newlines outright. */
const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
promptText: z
.string()
.max(100000)
.refine(noNewlines, 'promptText must be a single line (multi-line prompts are not supported)')
.optional(),
promptFilePath: safePathSchema.optional(),
inputMode: z.enum(['paste', 'typed']),
scheduleType: z.enum(['once', 'interval', 'daily', 'weekly']),
runAt: z.number().int().positive().optional(),
intervalMinutes: z.number().int().min(1).max(525600).optional(),
dailyTime: hhmmSchema.optional(),
weeklyDays: z.array(z.number().int().min(0).max(6)).min(1).max(7).optional(),
weeklyTime: hhmmSchema.optional(),
enabled: z.boolean(),
notes: z.string().max(2000).optional(),
concurrencyPolicy: z.enum(['warn_only', 'skip_if_same_agent_running']),
autoClosePreviousSession: z.boolean().optional(),
});
/** Cross-field validation: required fields depend on promptMode + scheduleType. */
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) {
add('promptText is required when promptMode is inline_text', 'promptText');
}
if (val.promptMode === 'prompt_file_path' && !val.promptFilePath) {
add('promptFilePath is required when promptMode is prompt_file_path', 'promptFilePath');
}
if (val.scheduleType === 'once' && val.runAt === undefined) {
add('runAt is required for a one-time schedule', 'runAt');
}
if (val.scheduleType === 'interval' && val.intervalMinutes === undefined) {
add('intervalMinutes is required for an interval schedule', 'intervalMinutes');
}
if (val.scheduleType === 'daily' && !val.dailyTime) {
add('dailyTime is required for a daily schedule', 'dailyTime');
}
if (val.scheduleType === 'weekly' && (!val.weeklyTime || !val.weeklyDays?.length)) {
add('weeklyDays and weeklyTime are required for a weekly schedule', 'weeklyTime');
}
}
/** POST /api/cron/jobs — full job definition. */
export const CronJobSchema = CronJobBaseSchema.superRefine(refineCronJob);
/** PUT /api/cron/jobs/:id — partial update. */
export const CronJobUpdateSchema = CronJobBaseSchema.partial();
/** PUT /api/cron/jobs/:id/enabled */
export const CronJobEnabledSchema = z.object({ enabled: z.boolean() });
/** POST /api/cases/link */
export const LinkCaseSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
+23
View File
@@ -153,8 +153,10 @@ import {
registerClipboardRoutes,
registerSearchRoutes,
registerOrchestratorRoutes,
registerCronRoutes,
registerWsRoutes,
} from './routes/index.js';
import { CronService } from '../cron/cron-service.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -176,6 +178,7 @@ import {
ITERATION_PAUSE_MS,
STATS_COLLECTION_INTERVAL_MS,
INACTIVITY_TIMEOUT_MS,
CRON_TICK_INTERVAL,
} from '../config/server-timing.js';
/**
@@ -224,6 +227,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 service (assigned in setupRoutes). */
private cronService!: CronService;
private sse: SseStreamManager;
private store = getStore();
private port: number;
@@ -892,6 +897,13 @@ export class WebServer extends EventEmitter {
registerClipboardRoutes(this.app, ctx);
registerSearchRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.
this.cronService = new CronService(ctx);
this.cronService.init();
registerCronRoutes(this.app, { ...ctx, cron: this.cronService });
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
}
@@ -1972,6 +1984,17 @@ export class WebServer extends EventEmitter {
{ description: 'scheduled runs cleanup' }
);
// Start the cron loop (fires due CronJobs).
this.cleanup.setInterval(
() => {
this.cronService.tickDueJobs().catch((err) => {
console.error('[cron] tick failed:', getErrorMessage(err));
});
},
CRON_TICK_INTERVAL,
{ description: 'scheduled jobs due-checker' }
);
// Start SSE client health check timer (prevents memory leaks from dead connections)
this.cleanup.setInterval(
() => {
+17
View File
@@ -240,6 +240,17 @@ export const ScheduledLog = 'scheduled:log' as const;
/** Scheduled run deleted. */
export const ScheduledDeleted = 'scheduled:deleted' as const;
// ─── Cron Jobs ───────────────────────────────────
/** The scheduled-jobs list changed (created/updated/enabled/run-status). Payload: { jobs }. */
export const CronJobsChanged = 'cron:jobsChanged' as const;
/** A scheduled job was deleted. Payload: { id }. */
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 ───────────────────────────────────────────────────────────────────
/** Agent team created. */
@@ -469,6 +480,12 @@ export const SseEvent = {
ScheduledLog,
ScheduledDeleted,
// Cron jobs
CronJobsChanged,
CronJobDeleted,
CronRunCreated,
CronRunUpdated,
// Teams
TeamCreated,
TeamUpdated,
+634
View File
@@ -0,0 +1,634 @@
/**
* @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 "Session launch failed" path (the mock
* deps lack the session-construction config getters) so no real Session/tmux
* objects are constructed — we assert the scheduling state machine (due
* detection, dedup guard, schedule advance, once-completion, concurrency skip,
* auto-close, run-history recording), not the session layer it reuses.
*
* Port: N/A (no HTTP server).
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CronService, type CronDeps } from '../src/cron/cron-service.js';
import { CronJobSchema } from '../src/web/schemas.js';
import { MAX_CRON_JOBS } from '../src/config/map-limits.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';
/** A real dir: createJob/updateJob validate workingDir existence up front. */
const VALID_DIR = mkdtempSync(join(tmpdir(), 'codeman-cron-wd-'));
const flush = (): Promise<void> => new Promise((r) => setImmediate(r));
type FakeSession = { mode: string; status?: string };
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, FakeSession>()) {
const store = makeStore();
const broadcast = vi.fn();
const cleanupSession = vi.fn(async (id: string) => {
sessions.delete(id);
});
const deps = {
store,
broadcast,
sessions,
cleanupSession,
} as unknown as CronDeps;
return { service: new CronService(deps), store, broadcast, sessions, cleanupSession };
}
function mkInput(overrides: Partial<CronJobInput> = {}): CronJobInput {
return {
name: 'job',
agentType: 'claude',
workingDir: VALID_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('clears the dup-guard and preserves createdAt', () => {
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
job.lastDueKey = 'stale';
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!.createdAt).toBe(job.createdAt);
});
it('does NOT re-fire a completed once-job when editing a non-schedule field', 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.getJob(job.id)!.completedOnce).toBe(true);
const updated = svc.service.updateJob(job.id, { name: 'renamed' });
expect(updated!.name).toBe('renamed');
// Cosmetic edit must not resurrect a fired one-time job.
expect(updated!.completedOnce).toBe(true);
expect(updated!.nextRunAt).toBeNull();
});
it('does NOT resurrect a fired once-job when the edit form round-trips the unchanged schedule', async () => {
// Reproduces the real UI flow: the edit modal re-sends the FULL job
// (scheduleType + runAt unchanged) on every save. A field-presence check
// would wrongly re-arm; we compare VALUES, so an unchanged schedule does not.
const runAt = Date.now() - 1000;
const job = svc.service.createJob(mkInput({ scheduleType: 'once', runAt, intervalMinutes: undefined }));
await svc.service.tickDueJobs(Date.now());
await flush();
expect(svc.service.getJob(job.id)!.completedOnce).toBe(true);
// Full-body edit changing only the name; schedule values identical.
const updated = svc.service.updateJob(job.id, { name: 'renamed', scheduleType: 'once', runAt, enabled: false });
expect(updated!.completedOnce).toBe(true);
// Even re-enabling afterward must not bring the dead job back to life.
const reenabled = svc.service.setEnabled(job.id, true);
expect(reenabled!.nextRunAt).toBeNull();
});
it('re-arms a completed once-job when the schedule itself is edited', 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.getJob(job.id)!.completedOnce).toBe(true);
const future = Date.now() + 3_600_000;
const updated = svc.service.updateJob(job.id, { runAt: future, enabled: true });
expect(updated!.completedOnce).toBe(false);
expect(updated!.nextRunAt).toBe(future);
});
it('rejects a partial update that leaves an inconsistent schedule (no dead enabled job)', () => {
const job = svc.service.createJob(mkInput({ scheduleType: 'interval', intervalMinutes: 10 }));
// Switch to 'once' WITHOUT a runAt → would otherwise yield a dead nextRunAt:null.
expect(() => svc.service.updateJob(job.id, { scheduleType: 'once', intervalMinutes: undefined })).toThrow();
// The stored job is left untouched.
const after = svc.service.getJob(job.id)!;
expect(after.scheduleType).toBe('interval');
expect(after.nextRunAt).not.toBeNull();
});
});
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'); // mock deps can't construct a Session → fails at 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();
// A 'skipped' run is recorded (so the history isn't silently empty), and
// the schedule still advanced past the skipped slot.
const runs = local.service.listRuns(job.id);
expect(runs.length).toBe(1);
expect(runs[0].status).toBe('skipped');
const after = local.service.getJob(job.id)!;
expect(after.lastStatus).toBe('skipped');
// A skip is not a run: lastRunAt must NOT advance.
expect(after.lastRunAt).toBeNull();
expect(after.nextRunAt!).toBeGreaterThan(fireAt);
expect(after.lastDueKey).not.toBeNull();
});
it('coalesces consecutive skips — a perpetually-skipped job does not flood run history', 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 })
);
// Drive 50 due ticks; the same-mode session keeps it skipped every time.
for (let i = 0; i < 50; i++) {
const due = local.service.getJob(job.id)!.nextRunAt! + 1;
await local.service.tickDueJobs(due);
await flush();
}
// Exactly ONE skipped run is recorded for the whole skip streak.
expect(local.service.listRuns(job.id).length).toBe(1);
expect(local.service.listRuns(job.id)[0].status).toBe('skipped');
});
});
describe('resolvePrompt path guard', () => {
// A real workspace dir for the in-workspace / confinement cases.
let ws: string;
beforeEach(() => {
ws = mkdtempSync(join(tmpdir(), 'codeman-cron-ws-'));
});
const fileJob = (promptFilePath: string, workingDir: string) =>
svc.service.createJob(
mkInput({ promptMode: 'prompt_file_path', promptFilePath, promptText: undefined, workingDir })
);
it('blocks a sensitive system file via the blocklist (/etc/passwd)', async () => {
const run = await svc.service.runNow(fileJob('/etc/passwd', ws).id);
expect(run!.status).toBe('failed');
// Fails at prompt resolution (blocked) — content is never read.
expect(run!.errorMessage).toMatch(/Prompt error/i);
expect(run!.errorMessage).toMatch(/block/i);
expect(svc.sessions.size).toBe(0);
});
it('blocks /proc/self/environ (server-process env exfil) via the pseudo-fs blocklist', async () => {
const run = await svc.service.runNow(fileJob('/proc/self/environ', ws).id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/Prompt error/i);
expect(run!.errorMessage).toMatch(/block/i);
});
it.skipIf(!existsSync('/proc/self/environ'))(
'blocks the workingDir=/proc + /proc/self/environ confinement bypass at fire time',
async () => {
// Create-time validation rejects a /proc workingDir, so simulate a
// legacy/hand-edited job by mutating the stored record directly.
const job = fileJob('/proc/self/environ', ws);
const stored = svc.store.getCronJob(job.id)!;
stored.workingDir = '/proc';
svc.store.setCronJob(stored.id, stored);
const run = await svc.service.runNow(job.id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/Prompt error/i);
expect(run!.errorMessage).toMatch(/blocked/i);
}
);
it('blocks a regular file that lives OUTSIDE the job workspace', async () => {
const outside = mkdtempSync(join(tmpdir(), 'codeman-cron-outside-'));
const file = join(outside, 'prompt.md');
writeFileSync(file, 'do the thing');
const run = await svc.service.runNow(fileJob(file, ws).id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/inside the job working directory/i);
});
it('blocks a non-regular file (directory) inside the workspace', async () => {
const sub = join(ws, 'adir');
mkdirSync(sub);
const run = await svc.service.runNow(fileJob(sub, ws).id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/not a regular file/i);
});
it('blocks an oversized prompt file (unbounded-read DoS)', async () => {
const file = join(ws, 'huge.md');
writeFileSync(file, Buffer.alloc(1024 * 1024 + 1, 0x61));
const run = await svc.service.runNow(fileJob(file, ws).id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/too large/i);
});
it('fails cleanly (no throw) when the prompt file does not exist', async () => {
const run = await svc.service.runNow(fileJob(join(ws, 'nope.md'), ws).id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/Prompt error/i);
});
it('allows a regular prompt file INSIDE the job workspace (passes resolution)', async () => {
const file = join(ws, 'prompt.md');
writeFileSync(file, 'do the thing');
const run = await svc.service.runNow(fileJob(file, ws).id);
// Got past prompt resolution + workingDir checks; fails only at the
// (mock-incomplete) session-launch step — NOT a prompt error.
expect(run!.status).toBe('failed');
expect(run!.errorMessage).not.toMatch(/Prompt error/i);
expect(run!.errorMessage).toMatch(/Session launch failed/i);
});
it('blocks a symlink inside the workspace that escapes to /etc/passwd', async () => {
const link = join(ws, 'sneaky.md');
symlinkSync('/etc/passwd', link);
const run = await svc.service.runNow(fileJob(link, ws).id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/Prompt error/i);
});
});
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();
});
});
describe('workingDir validation (create/update)', () => {
it('rejects a nonexistent workingDir at create', () => {
expect(() => svc.service.createJob(mkInput({ workingDir: MISSING_DIR }))).toThrow(/does not exist/);
});
it('rejects blocked trees and the filesystem root at create', () => {
expect(() => svc.service.createJob(mkInput({ workingDir: '/etc' }))).toThrow(/not allowed/);
expect(() => svc.service.createJob(mkInput({ workingDir: '/' }))).toThrow(/not allowed/);
});
it('rejects an invalid workingDir on update and leaves the job untouched', () => {
const job = svc.service.createJob(mkInput());
expect(() => svc.service.updateJob(job.id, { workingDir: MISSING_DIR })).toThrow(/does not exist/);
expect(svc.service.getJob(job.id)!.workingDir).toBe(VALID_DIR);
});
});
describe('single-line prompt enforcement', () => {
it('schema rejects a multi-line promptText and launchCommand', () => {
expect(CronJobSchema.safeParse(mkInput({ promptText: 'a\nb' })).success).toBe(false);
expect(CronJobSchema.safeParse(mkInput({ agentType: 'shell', launchCommand: 'a\nb' })).success).toBe(false);
expect(CronJobSchema.safeParse(mkInput()).success).toBe(true);
});
it('fails the run when a legacy job carries a multi-line promptText', async () => {
const job = svc.service.createJob(mkInput());
const stored = svc.store.getCronJob(job.id)!;
stored.promptText = 'line one\nline two';
svc.store.setCronJob(stored.id, stored);
const run = await svc.service.runNow(job.id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/single line/i);
});
it('fails the run when the prompt file is multi-line', async () => {
const ws = mkdtempSync(join(tmpdir(), 'codeman-cron-ml-'));
const file = join(ws, 'prompt.md');
writeFileSync(file, 'line one\nline two\n');
const job = svc.service.createJob(
mkInput({ promptMode: 'prompt_file_path', promptFilePath: file, promptText: undefined, workingDir: ws })
);
const run = await svc.service.runNow(job.id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/single line/i);
});
it('tolerates trailing newlines in a prompt file (every editor writes one)', async () => {
const ws = mkdtempSync(join(tmpdir(), 'codeman-cron-tn-'));
const file = join(ws, 'prompt.md');
writeFileSync(file, 'do the thing\n');
const job = svc.service.createJob(
mkInput({ promptMode: 'prompt_file_path', promptFilePath: file, promptText: undefined, workingDir: ws })
);
const run = await svc.service.runNow(job.id);
// Past prompt resolution; fails only at the mock-incomplete session step.
expect(run!.errorMessage).toMatch(/Session launch failed/i);
});
});
describe('concurrency-skip session filtering', () => {
const prevRun = (jobId: string, sessionId: string): CronJobRun => ({
id: `r-${sessionId}`,
cronJobId: jobId,
sessionId,
sessionName: 'job',
startedAt: Date.now() - 60_000,
finishedAt: Date.now() - 59_000,
status: 'prompt_sent',
triggerType: 'scheduled',
createdSessionUrl: null,
});
it('does not skip when the only same-mode sessions are stopped/error (dead tabs)', async () => {
const sessions = new Map<string, FakeSession>([
['dead1', { mode: 'claude', status: 'stopped' }],
['dead2', { mode: 'claude', status: 'error' }],
]);
const local = makeService(sessions);
const job = local.service.createJob(
mkInput({ concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
);
await local.service.tickDueJobs(job.nextRunAt! + 1000);
await flush();
const runs = local.service.listRuns(job.id);
expect(runs.length).toBe(1);
expect(runs[0].status).toBe('failed'); // launched (mock session step), NOT skipped
});
it("does not skip on the job's own previous-run session (no self-deadlock)", async () => {
const sessions = new Map<string, FakeSession>([['own-1', { mode: 'claude' }]]);
const local = makeService(sessions);
const job = local.service.createJob(
mkInput({ concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
);
local.store.setCronJobRun('r-own-1', prevRun(job.id, 'own-1'));
await local.service.tickDueJobs(job.nextRunAt! + 1000);
await flush();
expect(local.service.listRuns(job.id)[0].status).not.toBe('skipped');
});
it('does not consume a skipped once-job — it stays armed and fires when unblocked', async () => {
const sessions = new Map<string, FakeSession>([['s1', { mode: 'claude' }]]);
const local = makeService(sessions);
const runAt = Date.now() - 1000;
const job = local.service.createJob(
mkInput({
scheduleType: 'once',
runAt,
intervalMinutes: undefined,
concurrencyPolicy: 'skip_if_same_agent_running',
})
);
await local.service.tickDueJobs(Date.now());
await flush();
let after = local.service.getJob(job.id)!;
expect(after.lastStatus).toBe('skipped');
expect(after.completedOnce).toBeFalsy();
expect(after.enabled).toBe(true);
expect(after.nextRunAt).toBe(runAt); // still armed
// The blocking session goes away → the next tick fires the single run.
sessions.delete('s1');
await local.service.tickDueJobs(Date.now());
await flush();
after = local.service.getJob(job.id)!;
expect(after.completedOnce).toBe(true);
expect(after.enabled).toBe(false);
expect(local.service.listRuns(job.id).some((r) => r.status === 'failed')).toBe(true); // it launched
});
});
describe('autoClosePreviousSession', () => {
const prevRun = (jobId: string, sessionId: string): CronJobRun => ({
id: `r-${sessionId}`,
cronJobId: jobId,
sessionId,
sessionName: 'job',
startedAt: Date.now() - 60_000,
finishedAt: Date.now() - 59_000,
status: 'prompt_sent',
triggerType: 'scheduled',
createdSessionUrl: null,
});
it("closes the previous run's still-open session before launching (default on)", async () => {
const sessions = new Map<string, FakeSession>([['prev-1', { mode: 'claude' }]]);
const local = makeService(sessions);
const job = local.service.createJob(mkInput({ intervalMinutes: 10 }));
local.store.setCronJobRun('r-prev-1', prevRun(job.id, 'prev-1'));
await local.service.runNow(job.id);
expect(local.cleanupSession).toHaveBeenCalledWith('prev-1', true, expect.stringContaining('cron'));
expect(sessions.has('prev-1')).toBe(false);
});
it('does not close anything when autoClosePreviousSession is false', async () => {
const sessions = new Map<string, FakeSession>([['prev-1', { mode: 'claude' }]]);
const local = makeService(sessions);
const job = local.service.createJob(mkInput({ intervalMinutes: 10, autoClosePreviousSession: false }));
local.store.setCronJobRun('r-prev-1', prevRun(job.id, 'prev-1'));
await local.service.runNow(job.id);
expect(local.cleanupSession).not.toHaveBeenCalled();
expect(sessions.has('prev-1')).toBe(true);
});
it('never auto-closes for a once schedule', async () => {
const sessions = new Map<string, FakeSession>([['prev-1', { mode: 'claude' }]]);
const local = makeService(sessions);
const job = local.service.createJob(
mkInput({ scheduleType: 'once', runAt: Date.now() + 3_600_000, intervalMinutes: undefined })
);
local.store.setCronJobRun('r-prev-1', prevRun(job.id, 'prev-1'));
await local.service.runNow(job.id);
expect(local.cleanupSession).not.toHaveBeenCalled();
});
});
describe('job-count cap', () => {
it(`rejects creation beyond MAX_CRON_JOBS (${MAX_CRON_JOBS})`, () => {
for (let i = 0; i < MAX_CRON_JOBS; i++) svc.service.createJob(mkInput({ name: `j${i}` }));
expect(() => svc.service.createJob(mkInput({ name: 'overflow' }))).toThrow(/Maximum number of cron jobs/);
});
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* 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/cron/cron-time.js';
import type { CronJob } from '../src/types/cron.js';
function baseJob(partial: Partial<CronJob>): CronJob {
return {
id: 'j1',
name: 'test',
agentType: 'claude',
workingDir: '/tmp',
promptMode: 'inline_text',
promptText: 'hi',
inputMode: 'typed',
scheduleType: 'once',
enabled: true,
concurrencyPolicy: 'warn_only',
createdAt: 0,
updatedAt: 0,
lastRunAt: null,
nextRunAt: null,
lastStatus: null,
lastDueKey: null,
...partial,
};
}
describe('parseHHMM', () => {
it('parses valid 24h times', () => {
expect(parseHHMM('09:30')).toEqual({ hours: 9, minutes: 30 });
expect(parseHHMM('23:59')).toEqual({ hours: 23, minutes: 59 });
expect(parseHHMM('0:00')).toEqual({ hours: 0, minutes: 0 });
});
it('rejects invalid times', () => {
expect(parseHHMM('24:00')).toBeNull();
expect(parseHHMM('12:60')).toBeNull();
expect(parseHHMM('9:5')).toBeNull(); // minutes must be 2 digits
expect(parseHHMM('abc')).toBeNull();
expect(parseHHMM(undefined)).toBeNull();
});
});
describe('computeNextRunAt — once', () => {
it('returns runAt even when already in the past (missed one-time job still fires)', () => {
const job = baseJob({ scheduleType: 'once', runAt: 1000 });
expect(computeNextRunAt(job, 500)).toBe(1000);
expect(computeNextRunAt(job, 5000)).toBe(1000);
});
it('returns null once completed', () => {
const job = baseJob({ scheduleType: 'once', runAt: 1000, completedOnce: true });
expect(computeNextRunAt(job, 500)).toBeNull();
});
it('returns null with no runAt', () => {
expect(computeNextRunAt(baseJob({ scheduleType: 'once' }), 0)).toBeNull();
});
});
describe('computeNextRunAt — interval', () => {
it('adds intervalMinutes to the after time', () => {
const job = baseJob({ scheduleType: 'interval', intervalMinutes: 60 });
expect(computeNextRunAt(job, 1000)).toBe(1000 + 60 * 60_000);
});
it('returns null with no/invalid interval', () => {
expect(computeNextRunAt(baseJob({ scheduleType: 'interval' }), 0)).toBeNull();
expect(computeNextRunAt(baseJob({ scheduleType: 'interval', intervalMinutes: 0 }), 0)).toBeNull();
});
});
describe('computeNextRunAt — daily', () => {
it('schedules today when the time is still ahead', () => {
const after = new Date(2026, 0, 1, 10, 0, 0).getTime();
const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!;
const d = new Date(next);
expect(d.getHours()).toBe(14);
expect(d.getMinutes()).toBe(30);
expect(d.getDate()).toBe(1);
expect(next).toBeGreaterThan(after);
});
it('rolls to tomorrow when the time has passed', () => {
const after = new Date(2026, 0, 1, 16, 0, 0).getTime();
const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!;
const d = new Date(next);
expect(d.getHours()).toBe(14);
expect(d.getDate()).toBe(2);
expect(next).toBeGreaterThan(after);
});
it('returns null with no time', () => {
expect(computeNextRunAt(baseJob({ scheduleType: 'daily' }), 0)).toBeNull();
});
});
describe('computeNextRunAt — weekly', () => {
it('finds the next selected weekday at the configured time', () => {
const after = new Date(2026, 0, 1, 12, 0, 0).getTime();
const targetDay = (new Date(after).getDay() + 2) % 7;
const job = baseJob({ scheduleType: 'weekly', weeklyDays: [targetDay], weeklyTime: '08:00' });
const next = computeNextRunAt(job, after)!;
const d = new Date(next);
expect(d.getDay()).toBe(targetDay);
expect(d.getHours()).toBe(8);
expect(next).toBeGreaterThan(after);
// Within the coming week.
expect(next - after).toBeLessThanOrEqual(7 * 24 * 60 * 60_000);
});
it('picks the soonest of multiple selected days', () => {
const after = new Date(2026, 0, 1, 12, 0, 0).getTime();
const soon = (new Date(after).getDay() + 1) % 7;
const later = (new Date(after).getDay() + 3) % 7;
const job = baseJob({ scheduleType: 'weekly', weeklyDays: [later, soon], weeklyTime: '09:00' });
const next = computeNextRunAt(job, after)!;
expect(new Date(next).getDay()).toBe(soon);
});
it('returns null with no days or no time', () => {
expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyTime: '09:00' }), 0)).toBeNull();
expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyDays: [1] }), 0)).toBeNull();
});
});
describe('dueKeyFor', () => {
it('combines job id and fire time', () => {
expect(dueKeyFor('j1', 123)).toBe('j1:123');
});
});