mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm" onclick="app.openCron()" title="Cron Jobs">⏰ 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">×</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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user