diff --git a/CLAUDE.md b/CLAUDE.md index aeee11cc..240876f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | | **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first | | **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first | +| **Cron** | `src/cron/cron-service.ts`, `src/cron/cron-time.ts` (pure next-run math), `src/cron/cron-input.ts` | Cron-style `CronJob`s. Read `docs/cron-discovery.md` first; distinct from legacy `ScheduledRun` (`/api/scheduled`) — see Key Patterns | | **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts`, `src/workflow-run-watcher.ts` | `workflow-run-watcher` is STANDALONE (never touches `subagent-watcher`) — see Key Patterns | | **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | | | **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | | @@ -131,8 +132,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | | **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | | -| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (17 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | | -| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) | +| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (18 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | | +| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 9 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `cron-ui.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) | | **Types** | `src/types/index.ts` (barrel) → 17 domain files (incl. `workflow-run.ts`, `search.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -162,6 +163,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. +**Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`. + **External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). **Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. @@ -228,7 +231,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### API Routes -~150 handlers across 17 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. +~160 handlers across 18 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. **HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`). diff --git a/README.md b/README.md index 6bcfbaab..0f0610b2 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,73 @@ Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/e --- +## Using Codeman — A Human's Guide + +A start-to-finish walkthrough for driving Codeman from the browser. If you just installed, this is where to begin. + +### 1. Launch the server + +```bash +codeman web # localhost:3000 (loopback only — safe default) +codeman web --port 8080 # custom port (or set CODEMAN_PORT) +codeman web --https # self-signed TLS (only needed for remote access) +codeman web -H 0.0.0.0 # bind LAN — REQUIRES CODEMAN_PASSWORD (see Security) +``` + +Open the printed URL. The page is a single dashboard; everything below happens there. + +### 2. Create your first session + +Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in its own tmux-backed terminal. You choose: + +| Field | What it does | +|-------|--------------| +| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. | +| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Gemini`, or `Terminal` (plain shell). | +| **Model** | Per-session model (App Settings → Claude Model). A soft default — `/model` still works in-session. | +| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. | + +Hit start — Codeman spawns the CLI via a real PTY and streams it to your browser over SSE. + +### 3. Read the dashboard + +- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder. +- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline. +- **Side panels** — Respawn, Ralph, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar). + +### 4. Talk to the agent + +- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt). +- **Paste or drag-and-drop images** directly into the session. +- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, with auto-silence stop). +- **Attachments** — register external files/docs and preview Office/PDF inline. + +### 5. Make it autonomous + +| Mode | Use it for | Where | +|------|-----------|-------| +| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab | +| **Ralph / Todo** | A self-driving loop that tracks a todo list and keeps working until done. | Ralph tab | +| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel | +| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button | +| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) | + +### 6. Reach it from anywhere + +- **Phone/tablet** — the UI is fully touch-optimized; scan the desktop **QR code** to log in without typing a password. +- **Outside your network** — `./scripts/tunnel.sh start` opens a Cloudflare tunnel (set `CODEMAN_PASSWORD` first). +- **SSH** — the `sc` chooser attaches to any session from a terminal (`sc` interactive, `sc 2` quick-attach, `sc -l` list). + +### 7. Operate & maintain + +- **App Settings** — model, effort, theme/skin, notifications, display toggles, per-CLI options. +- **Self-update** — git-clone installs update in place from **Settings → Updates**. +- **Deploy your own changes** — see [Development](#development). + +> ⚠️ **Safety:** if you're working *inside* a Codeman-managed session (`echo $CODEMAN_MUX` → `1`), never run `tmux kill-session` / `pkill claude` directly — use the web UI or `./scripts/tmux-manager.sh`. + +--- + ## Mobile-Optimized Web UI The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work — not a desktop UI crammed onto a small screen. @@ -496,17 +563,103 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De --- +## Driving Codeman from an Agent — Programmatic Guide + +For AI agents and automation that control Codeman without a browser: an agent that spins up worker sessions, a CI bot, or **Claude Code running *inside* a Codeman session orchestrating other sessions**. Everything the UI does is HTTP + a CLI, so an agent can do it too. + +### Detect that you're inside Codeman + +When a CLI runs in a Codeman-managed session, these environment variables are set — read them instead of hardcoding anything: + +| Variable | Meaning | +|----------|---------| +| `CODEMAN_MUX=1` | You're in a managed tmux session. **Never** `tmux kill-session` / `pkill claude` / `pkill tmux` — you'll kill yourself or a sibling. | +| `CODEMAN_API_URL` | Base URL of the API (e.g. `https://127.0.0.1:3000`). Use it for every call below. | +| `CODEMAN_SESSION_ID` | *Your own* session id. Use it to avoid acting on yourself. | +| `CODEMAN_HOOK_SECRET_FILE` | Path to the hook secret (required on `/api/hook-event` while a managed tunnel is up). | + +### Rules of the road (read before you POST) + +1. **Single-line input only.** Programmatic input is sent as literal text **+ Enter** in one shot. Multi-line strings break the agent TUI (Ink) — send one line, or split into multiple calls. +2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt. +3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard). +4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`). +5. **`/api/v1/*`** is a stable alias of `/api/*`. + +### Recipes + +```bash +API="${CODEMAN_API_URL:-http://127.0.0.1:3000}" +# (add -u admin:"$CODEMAN_PASSWORD" to each call if a password is set) + +# 1. See what's running +curl -s "$API/api/sessions" | jq '.data // .' + +# 2. Spin up a worker session (a "case" = named working dir) +curl -s -X POST "$API/api/quick-start" \ + -H 'Content-Type: application/json' \ + -d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq + +# 3. Send a prompt into a session (exactly-once: clientId + seq) +curl -s -X POST "$API/api/sessions/$SID/input" \ + -H 'Content-Type: application/json' \ + -d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}' + +# 4. Read the terminal back +curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .' + +# 5. Stream live events (session output, agent activity, status) +curl -sN "$API/api/events" # Server-Sent Events + +# 6. Schedule recurring work (cron-style job) +curl -s -X POST "$API/api/cron/jobs" \ + -H 'Content-Type: application/json' \ + -d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj", + "promptMode":"inline_text","promptText":"Update dependencies and open a PR", + "inputMode":"typed","scheduleType":"daily","dailyTime":"03:00", + "enabled":true,"concurrencyPolicy":"warn_only"}' | jq + +# 7. Inspect background sub-agents and their transcripts +curl -s "$API/api/subagents" | jq '.data // .' +curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .' + +# 8. Whole-system snapshot (sessions, settings, respawn, stats) +curl -s "$API/api/status" | jq +``` + +### Or use the bundled CLI + +The same operations are available as commands (`codeman `, aliases in parentheses) — handy from a shell tool inside a session: + +```bash +codeman session start -d /path/to/repo # (s) start a session +codeman session list # list sessions +codeman session logs # tail output +codeman task add "fix the failing test" # (t) queue a task +codeman ralph start --min-hours 8 # (r) launch the autonomous loop +codeman attach # attach a Claude hook context +``` + +### Hooks (events flowing *back* to Codeman) + +Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing. + +> Full endpoint list and request/response shapes follow. + +--- + ## API -REST over Fastify — **~140 handlers across 15 route modules**, plus an SSE stream and a WebSocket terminal channel. A representative subset: +REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset: ### Sessions | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/api/sessions` | List all | -| `POST` | `/api/quick-start` | Create case + start session | +| `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) | +| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?}` — `clientId`+`seq` = exactly-once) | +| `GET` | `/api/sessions/:id/output` | Read terminal output | | `DELETE` | `/api/sessions/:id` | Delete session | -| `POST` | `/api/sessions/:id/input` | Send input | ### Respawn | Method | Endpoint | Description | @@ -529,6 +682,15 @@ REST over Fastify — **~140 handlers across 15 route modules**, plus an SSE str | `GET` | `/api/orchestrator/status` | Current phase + progress | | `POST` | `/api/orchestrator/stop` | Stop and clean up | +### Cron (scheduled jobs) +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` / `POST` | `/api/cron/jobs` | List / create cron jobs | +| `PUT` / `DELETE` | `/api/cron/jobs/:id` | Update / delete a job | +| `PUT` | `/api/cron/jobs/:id/enabled` | Enable / disable | +| `POST` | `/api/cron/jobs/:id/run` | Run now | +| `GET` | `/api/cron/jobs/:id/runs` | Run history | + ### Subagents | Method | Endpoint | Description | |--------|----------|-------------| diff --git a/SPEEDRUN.md b/SPEEDRUN.md new file mode 100644 index 00000000..6f4cd12d --- /dev/null +++ b/SPEEDRUN.md @@ -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/.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. diff --git a/docs/cron-build-brief.md b/docs/cron-build-brief.md new file mode 100644 index 00000000..6dac419d --- /dev/null +++ b/docs/cron-build-brief.md @@ -0,0 +1,588 @@ +# Claude Code Build Brief: Add Scheduling to Codeman + +## 0. Purpose of This Brief + +You are Claude Code working inside the Codeman repository. + +Your task is to add a **small, reliable scheduling layer** to Codeman while preserving Codeman's existing architecture and session-management behavior. + +This is not a greenfield rewrite. This is not a full product rebuild. This is a focused extension. + +The target user wants Codeman-like tmux/web/session management, but with first-class scheduled jobs for Claude, Codex, OpenCode, Terminal, or any other configurable coding-agent harness. + +--- + +## 1. Non-Negotiable Goal + +Add scheduling to Codeman so a user can define a scheduled coding-agent job that: + +1. Has a name. +2. Uses an existing Codeman-supported agent/session type where possible. +3. Has a working directory. +4. Has a prompt or prompt file. +5. Has a schedule. +6. Can be enabled or disabled. +7. Can be manually run now. +8. When due, creates a Codeman/tmux session. +9. Sends the configured prompt into that session. +10. Records last run, next run, status, and run history. + +The first working version should prioritize **scheduling correctness and reuse of Codeman's existing tmux/session system** over UI polish. + +--- + +## 2. Core Architectural Rule + +Do **not** rebuild Codeman's session layer. + +Reuse existing Codeman functionality for: + +- Creating sessions. +- Naming sessions. +- Launching Claude/Codex/OpenCode/Terminal sessions. +- Sending input into sessions. +- Displaying sessions in the web UI. +- Killing sessions. +- Tracking session status if already supported. + +If an internal API/service/function already exists, reuse it. + +If no reusable function exists, create a thin wrapper around the existing implementation rather than duplicating logic. + +--- + +## 3. Product Boundary + +This build is **Codeman + Scheduler**. + +It is not yet: + +- A full quota engine. +- A full lock manager. +- A replacement for Codeman's terminal UI. +- A new FastAPI application. +- A multi-tenant SaaS platform. +- A complex cron-management product. +- A full agent autonomy framework. + +Keep the build small and shippable. + +--- + +## 4. Required Working Scope for v0.1 + +Implement the following minimum features. + +### 4.1 Scheduled Jobs List + +Create a UI page showing all scheduled jobs. + +Each row/card should show: + +- Job name. +- Agent/session type. +- Working directory. +- Schedule type. +- Enabled/disabled state. +- Last run time. +- Next run time. +- Last run status. +- Actions: + - Run Now. + - Enable/Disable. + - Edit. + - Delete. + +### 4.2 Create/Edit Scheduled Job + +Create a form for scheduled jobs with these fields: + +- `name` +- `agent_type` + - Reuse Codeman's existing session/agent types where possible. + - Include at least Terminal/custom command if supported. +- `working_directory` +- `launch_command` if needed by Codeman's model. +- `prompt_mode` + - `inline_text` + - `prompt_file_path` +- `prompt_text` +- `prompt_file_path` +- `input_mode` + - `paste` + - `typed` +- `schedule_type` + - `once` + - `interval_minutes` + - `daily_time` + - `weekly_time` +- `run_at` for one-time jobs. +- `interval_minutes` for interval jobs. +- `daily_time` for daily jobs. +- `weekly_days` and `weekly_time` for weekly jobs. +- `enabled` +- `notes` optional. + +Do not build a complex visual cron editor in v0.1. + +### 4.3 Run Now + +Every scheduled job must support a `Run Now` action. + +Run Now should: + +1. Create a new session through Codeman's existing session creation logic. +2. Send the configured prompt into the session using Codeman's existing input mechanism. +3. Create a run-history record. +4. Update last-run fields. +5. Redirect or link the user to the created Codeman session. + +### 4.4 Background Scheduler Loop + +Add a small background scheduler loop that runs inside the Codeman backend process. + +The loop should: + +1. Wake every 15-60 seconds. +2. Load enabled schedules. +3. Find schedules where `next_run_at <= now`. +4. Create a scheduled run. +5. Launch the session using existing Codeman session logic. +6. Send the prompt. +7. Record run history. +8. Compute the next run time. +9. Avoid duplicate launches if the loop overlaps or restarts. + +Keep this simple and robust. + +### 4.5 Run History + +Every scheduled execution should create a run-history record. + +Track: + +- `id` +- `scheduled_job_id` +- `session_id` or Codeman session reference. +- `session_name` if applicable. +- `started_at` +- `finished_at` optional. +- `status` + - `created` + - `session_started` + - `prompt_sent` + - `failed` +- `error_message` optional. +- `trigger_type` + - `scheduled` + - `manual_run_now` +- `created_session_url` or route reference if easy. + +--- + +## 5. Scheduling Rules + +### 5.1 Once + +Run at a specific date/time. + +After successful launch: + +- Set `enabled = false`, or mark as completed. + +### 5.2 Interval + +Run every N minutes. + +Example: + +- Every 60 minutes. +- Every 240 minutes. + +After launch: + +- `next_run_at = now + interval_minutes`. + +### 5.3 Daily + +Run every day at HH:MM. + +After launch: + +- Compute the next occurrence of HH:MM after now. + +### 5.4 Weekly + +Run on selected weekdays at HH:MM. + +After launch: + +- Compute the next selected weekday/time after now. + +### 5.5 Timezone + +Use the server's local timezone for v0.1 unless Codeman already has timezone handling. + +Add a visible note in the UI: + +> Times use the server's local timezone. + +Do not overbuild timezone support in v0.1. + +--- + +## 6. Data Storage Decision + +First inspect Codeman's existing persistence model. + +If Codeman already has a database or persistence layer: + +- Reuse it. +- Add scheduled job and scheduled run models/tables/records using the existing pattern. + +If Codeman uses files or JSON state: + +- Use the same style for v0.1. +- Prefer simple persistence over introducing a heavy new dependency. + +If there is no appropriate persistence layer: + +- Add SQLite only if it fits the codebase cleanly. +- Otherwise use a JSON file store for the first version. + +Do not introduce Postgres, Redis, Celery, or a separate scheduler service. + +--- + +## 7. Concurrency and Duplicate-Run Guard + +Implement a basic duplicate-run guard. + +A schedule should not launch twice for the same due time. + +Minimum acceptable approach: + +- Before launching, create/update a run record with a `created` or `launching` state. +- Use a schedule-level `last_triggered_at` or `last_due_key` to avoid double launching. +- If launch fails, record failure clearly. + +Do not build distributed locks. Codeman is expected to be local/single-instance for v0.1. + +--- + +## 8. Multi-Session Warning + +When the user clicks `Run Now`, show a warning if there are already active sessions for the same agent type. + +Minimum behavior: + +- If active sessions exist, show a confirmation warning. +- User can continue anyway. + +For scheduled automatic runs: + +- Add a setting on the scheduled job: + - `warn_only` + - `skip_if_same_agent_running` + +Default: + +- `warn_only` for manual runs. +- `skip_if_same_agent_running = false` for automatic runs unless easy to implement. + +Do not build a complete quota engine in v0.1. + +--- + +## 9. Prompt Sending Rules + +The scheduler must support sending the configured prompt into the created session. + +Prompt source: + +1. Inline prompt text. +2. Prompt file path. + +Input mode: + +1. Paste mode. +2. Typed mode. + +If only one input mode is easy with Codeman's current internals, implement that first and structure the code so the other can be added later. + +Important: + +- Do not send prompts to a session if session creation failed. +- Record prompt-send success/failure in run history. +- Save enough metadata to understand what prompt was used. + +--- + +## 10. UI Bifurcation + +Keep UI changes cleanly separated. + +Add scheduler UI under a clear navigation item: + +- `Scheduled Jobs` + +Do not clutter the existing session dashboard. + +The existing session dashboard may show sessions created by scheduled jobs, but the scheduling controls should live in their own section. + +Recommended pages/routes: + +- `/schedules` +- `/schedules/new` +- `/schedules/:id` +- `/schedules/:id/edit` +- `/schedules/:id/run-now` +- `/schedules/:id/enable` +- `/schedules/:id/disable` +- `/schedules/:id/delete` + +Use Codeman's existing frontend conventions and routing style. + +--- + +## 11. Backend Bifurcation + +Keep scheduler code separate from existing session code. + +Recommended logical modules, adapted to Codeman's actual structure: + +- `scheduler/model` or equivalent. +- `scheduler/store` or equivalent. +- `scheduler/service` for schedule calculations and launch logic. +- `scheduler/loop` for the background due-job checker. +- `scheduler/routes` for API/UI endpoints. +- `scheduler/time` for next-run calculations. + +Do not mix scheduling logic directly into terminal rendering, xterm handling, or low-level tmux code. + +The scheduler service should call session services; it should not own tmux directly unless Codeman has no session abstraction. + +--- + +## 12. Required Discovery Phase Before Coding + +Before implementing, inspect the Codeman repo and produce a short architecture note in the terminal or in a file called: + +`docs/cron-discovery.md` + +This note must identify: + +1. Where session creation happens. +2. Where agent/session types are defined. +3. Where input is sent into a session. +4. Where active sessions are listed. +5. Where session kill/delete is handled. +6. How session state is stored. +7. Whether there is existing persistence. +8. Where backend routes live. +9. Where frontend pages/components live. +10. The smallest integration points for scheduling. + +Do not start coding until this discovery is complete. + +--- + +## 13. Implementation Phases + +### Phase 1: Discovery + +Deliverable: + +- `docs/cron-discovery.md` + +Must answer the 10 discovery questions above. + +### Phase 2: Data Model / Persistence + +Deliverable: + +- Scheduled job persistence. +- Scheduled run history persistence. +- Basic create/read/update/delete operations. + +### Phase 3: Scheduler Calculation Logic + +Deliverable: + +- Functions to compute `next_run_at` for: + - once + - interval + - daily + - weekly + +Add tests if the repo has an existing test setup. + +### Phase 4: Manual Run Now + +Deliverable: + +- Create scheduled job. +- Click Run Now. +- Codeman session is created. +- Prompt is sent. +- Run history is recorded. +- UI links to the session. + +This is the most important milestone. + +### Phase 5: Background Scheduler Loop + +Deliverable: + +- Enabled schedules launch automatically when due. +- Run history is recorded. +- `last_run_at` and `next_run_at` update. +- Duplicate launch guard exists. + +### Phase 6: UI Polish Only After Functionality + +Deliverable: + +- Scheduled jobs list is readable. +- Create/edit form is usable. +- Status labels are clear. +- Errors are visible. + +Do not polish before Phase 4 works. + +--- + +## 14. Acceptance Criteria + +The build is acceptable when all these pass. + +### Manual Run + +1. Create a schedule/job with inline prompt. +2. Click Run Now. +3. A new Codeman/tmux session starts. +4. Prompt is sent into that session. +5. The created session is visible in Codeman's normal session UI. +6. Run history shows success or failure. + +### One-Time Schedule + +1. Create a one-time schedule 2 minutes in the future. +2. Wait for it to become due. +3. Scheduler launches a session. +4. Prompt is sent. +5. Schedule does not repeatedly launch forever. + +### Interval Schedule + +1. Create interval schedule every 2 minutes. +2. It launches once when due. +3. It computes the next due time. +4. It does not launch duplicates for the same due time. + +### Daily Schedule + +1. Create daily schedule at a time a few minutes ahead. +2. It launches when due. +3. Next run becomes tomorrow at the same time. + +### Disable Schedule + +1. Disable a schedule. +2. It does not launch even when due. + +### Error Handling + +1. Invalid working directory produces visible error. +2. Invalid prompt file produces visible error. +3. Failed session launch creates failed run-history entry. + +--- + +## 15. Explicitly Out of Scope for v0.1 + +Do not implement these unless all required scope is already working: + +- Full quota engine. +- Advanced lock manager. +- Post-run git inspection reports. +- Complex recurring calendar UI. +- User accounts / RBAC. +- External distributed workers. +- Redis. +- Postgres. +- Celery. +- Kubernetes. +- A separate Python service. +- Full visual cron editor. +- AI-generated follow-up prompts. +- Automatic continuation after idle. +- Any attempt to bypass agent quotas or platform limits. + +--- + +## 16. Quality Rules + +Follow these rules while coding: + +1. Reuse existing Codeman services and conventions. +2. Keep scheduler code isolated. +3. Prefer boring, readable code over clever abstractions. +4. Add error messages that a human can understand. +5. Do not break existing Codeman sessions. +6. Do not rename existing core concepts unnecessarily. +7. Do not introduce large dependencies without strong reason. +8. Keep v0.1 local-first and single-instance. +9. Commit in logical chunks if git is available. +10. After coding, provide a final implementation summary. + +--- + +## 17. Final Response Required from Claude Code + +At the end, report: + +1. Files changed. +2. New routes/pages added. +3. New data structures added. +4. How the scheduler loop works. +5. How to run the app. +6. How to test manual Run Now. +7. How to test scheduled execution. +8. Known limitations. +9. Suggested v0.2 improvements. + +--- + +## 18. v0.2 Ideas, Not for Current Build + +Keep these in mind but do not build unless v0.1 is complete: + +- Quota-aware scheduling. +- Manual takeover locks. +- Post-idle inspection. +- Git diff reports. +- Schedule groups. +- Prompt templates. +- Agent-specific concurrency rules. +- Better timezone support. +- Audit events. +- More advanced cron expressions. + +--- + +## 19. Final Reminder + +The goal is to add **scheduling** to Codeman quickly and cleanly. + +Do not drift into building a new platform. + +The highest-priority path is: + +1. Discover existing Codeman integration points. +2. Add scheduled job persistence. +3. Add Run Now. +4. Add background due-job loop. +5. Add minimal UI. +6. Verify that scheduled jobs create real Codeman/tmux sessions and send prompts. + diff --git a/docs/cron-discovery.md b/docs/cron-discovery.md new file mode 100644 index 00000000..c8164dcd --- /dev/null +++ b/docs/cron-discovery.md @@ -0,0 +1,138 @@ +# 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` (`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. + +## 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` and + `cronJobRuns?: Record` to `AppState`, with + matching `StateStore` accessors. No new DB (brief §6 forbids Postgres/Redis). + +## 8. Where backend routes live + +- Route modules: `src/web/routes/*.ts`; barrel `src/web/routes/index.ts`; + registered in `WebServer.setupRoutes()` `src/web/server.ts:858-876` with a + single `ctx` object from `createRouteContext()` (`src/web/server.ts:553-613`) + that satisfies all port interfaces. +- Validation: zod schemas in `src/web/schemas.ts`, applied via + `parseBody(Schema, req.body)` (`src/web/route-helpers.ts:101-111`). +- Errors: `createErrorResponse(ApiErrorCode.X, msg)` / `ApiResponse` + (`src/types/api.ts`), auto-mapped to HTTP status by a `preSerialization` hook + (`src/web/server.ts:644-659`). +- SSE: `ctx.broadcast(SseEvent.X, data)` (`EventPort`, + `src/web/sse-events.ts`); frontend mirror in `src/web/public/constants.js`. +- **Integration point:** new `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. diff --git a/docs/cron-guide.md b/docs/cron-guide.md new file mode 100644 index 00000000..023c27d7 --- /dev/null +++ b/docs/cron-guide.md @@ -0,0 +1,382 @@ +# 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//run" | jq + +# See a job's run history +curl -s "$API/api/cron/jobs//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) | Must exist and be a directory **at fire time** or the run fails. | +| `launchCommand` | — | ≤ 2000 chars | Only meaningful for `shell` mode (custom launch command). | +| `promptMode` | ✅ | `inline_text` \| `prompt_file_path` | See §5. | +| `promptText` | conditional | ≤ 100000 chars | Required when `promptMode = inline_text`. | +| `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. | + +**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. +2. **Blocklist** (defense-in-depth) — sensitive trees (`/etc`, `/root`, known + secret locations) are rejected. +3. **Allowlist (primary gate)** — the resolved path **must live inside the job's + `workingDir`** (`validateSessionFilePath`). A symlink escaping the workspace + fails here. +4. **Regular-file check** — directories, FIFOs, and `/dev/*` character devices + are rejected (they would hang or OOM an unbounded read). +5. **Size cap** — files larger than **1 MiB** (`MAX_PROMPT_FILE_BYTES`) are + rejected. + +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.** Like all programmatic input in Codeman, a multi-line +> `promptText` is delivered broken (Ink-based TUIs treat the first newline as +> submit). Keep prompts to a single line, or put multi-line instructions in a +> file the agent reads itself. + +--- + +## 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 session of that mode is active, **skip** this fire — record a `skipped` run and advance the schedule without launching. | + +Notes on `skip_if_same_agent_running`: +- It counts **all** sessions of that mode, not just sessions this job created. +- 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). Failure → **`failed`**. +3. `workingDir` is checked (`statSync().isDirectory()`). Missing/not-a-dir → + **`failed`**. +4. The global session cap is checked (`MAX_CONCURRENT_SESSIONS = 50`). At cap → + **`failed`**. +5. 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`**. +6. **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 just + waits 1000ms. +7. The prompt is delivered (`typed`/`paste`, trailing `\r`). Run status → + **`prompt_sent`**; `finishedAt` stamped. Delivery failure → **`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=`); the UI focuses it +automatically after **Run Now**. + +### 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` 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`) | +| 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 `promptText` is delivered broken; use a + prompt file the agent reads 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` + an active same-type session | Switch to `warn_only`, or wait for the other session to end. | +| Prompt looks truncated | Multi-line `promptText` | Use a single line, or a prompt file. | +| 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). diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index d67082b4..b8fc5529 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -43,6 +43,13 @@ 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; + // ============================================================================ // Pending Tool Calls Limits // ============================================================================ diff --git a/src/config/server-timing.ts b/src/config/server-timing.ts index adbc6efe..d4e98ff2 100644 --- a/src/config/server-timing.ts +++ b/src/config/server-timing.ts @@ -51,6 +51,19 @@ export const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000; /** Completed scheduled run max age before cleanup (ms) */ export const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000; +// ============================================================================ +// 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; diff --git a/src/cron/cron-input.ts b/src/cron/cron-input.ts new file mode 100644 index 00000000..c0783d6c --- /dev/null +++ b/src/cron/cron-input.ts @@ -0,0 +1,30 @@ +/** + * @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; +} diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts new file mode 100644 index 00000000..e274ee2b --- /dev/null +++ b/src/cron/cron-service.ts @@ -0,0 +1,489 @@ +/** + * @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_RUN_HISTORY } from '../config/map-limits.js'; +import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js'; +import { 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 => 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; + +/** 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 active sessions of a given agent type (for the multi-session warning). */ + countActiveAgents(agentType: string): number { + let n = 0; + for (const s of this.deps.sessions.values()) if (s.mode === agentType) n++; + return n; + } + + // ──────────────────────────── Mutations ─────────────────────────── + + createJob(input: CronJobInput): CronJob { + 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, + 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): 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 = (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) { + const msg = check.error.issues[0]?.message ?? 'Invalid cron job update'; + throw Object.assign(new Error(msg), { + statusCode: 400, + body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg), + }); + } + + 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 { + const job = this.getJob(id); + if (!job) return null; + return this.launch(job, 'manual_run_now'); + } + + /** + * Background tick: launch every enabled job whose next run is due. Advances + * each job's schedule and guards against double-launching the same due time. + */ + async tickDueJobs(now: number = Date.now()): Promise { + for (const job of this.listJobs()) { + if (!job.enabled || job.nextRunAt == null || job.nextRunAt > now) continue; + + const key = dueKeyFor(job.id, job.nextRunAt); + if (job.lastDueKey === key) { + // This due time was already consumed (overlap/restart) — just advance. + this.advanceAfterFire(job, now); + continue; + } + + // Optional concurrency policy for AUTOMATIC runs. + if (job.concurrencyPolicy === 'skip_if_same_agent_running' && this.countActiveAgents(job.agentType) > 0) { + job.lastDueKey = key; + // 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); + 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 { + 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'); + } + + // 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; + } + + private async resolvePrompt(job: CronJob): Promise { + 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); + return readFile(safePath, 'utf-8'); + } + return job.promptText ?? ''; + } + + /** + * 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 { + let resolved: string; + try { + resolved = realpathSync(rawPath); + } catch { + throw new Error('prompt file path could not be resolved'); + } + + // Defense-in-depth blocklist (secret locations, /etc, /root). + const guard = await loadAttachmentGuardConfig(); + if (isBlockedAttachmentPath(resolved, guard.blockedTrees)) { + throw new Error('prompt file path is blocked'); + } + + // Primary gate: the prompt file must live inside the job's workspace. + if (!validateSessionFilePath(workingDir, 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 => { + 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); + } + const s = this.deps.sessions.get(sessionId); + if (!s) return; + try { + const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`; + if (job.inputMode === 'paste') { + s.write(payload); + } else { + await s.writeViaMux(payload); + } + run.status = 'prompt_sent'; + run.finishedAt = Date.now(); + this.store.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))); + }); + } + + 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() }); + } +} diff --git a/src/cron/cron-time.ts b/src/cron/cron-time.ts new file mode 100644 index 00000000..39832ca0 --- /dev/null +++ b/src/cron/cron-time.ts @@ -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}`; +} diff --git a/src/state-store.ts b/src/state-store.ts index ffab99f4..1cb6378e 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -272,6 +272,12 @@ export class StateStore { if (this.state.tokenStats) { parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`); } + if (this.state.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 { + 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 { + 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; diff --git a/src/types/app-state.ts b/src/types/app-state.ts index 56809a6b..e0b39838 100644 --- a/src/types/app-state.ts +++ b/src/types/app-state.ts @@ -23,6 +23,7 @@ import type { SessionState } from './session.js'; import type { TaskState } from './task.js'; import type { RalphLoopState } from './ralph.js'; import type { RespawnConfig } from './respawn.js'; +import type { 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; + /** Scheduled job run history, keyed by run ID. */ + cronJobRuns?: Record; } // ========== Default Configuration ========== diff --git a/src/types/cron.ts b/src/types/cron.ts new file mode 100644 index 00000000..18e96a99 --- /dev/null +++ b/src/types/cron.ts @@ -0,0 +1,94 @@ +/** + * @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; + + // ── 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; +} diff --git a/src/web/ports/cron-port.ts b/src/web/ports/cron-port.ts new file mode 100644 index 00000000..4dec6554 --- /dev/null +++ b/src/web/ports/cron-port.ts @@ -0,0 +1,10 @@ +/** + * @fileoverview Cron port — exposes the CronService to + * route handlers via the shared route context. + */ + +import type { CronService } from '../../cron/cron-service.js'; + +export interface CronPort { + readonly cron: CronService; +} diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index 2373015f..18cbe603 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -13,3 +13,4 @@ export type { ConfigPort } from './config-port.js'; export type { InfraPort, ScheduledRun } from './infra-port.js'; export type { AuthPort } from './auth-port.js'; export type { OrchestratorPort } from './orchestrator-port.js'; +export type { CronPort } from './cron-port.js'; diff --git a/src/web/public/app.js b/src/web/public/app.js index c63f12be..74dcdd5a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -165,6 +165,12 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'], [SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'], + // Scheduled jobs (cron-style scheduler) + [SSE_EVENTS.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'], diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 33b74246..4a98338e 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -276,6 +276,12 @@ const SSE_EVENTS = { SCHEDULED_LOG: 'scheduled:log', SCHEDULED_DELETED: 'scheduled:deleted', + // 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', diff --git a/src/web/public/cron-ui.js b/src/web/public/cron-ui.js new file mode 100644 index 00000000..3bbfa29b --- /dev/null +++ b/src/web/public/cron-ui.js @@ -0,0 +1,304 @@ +/** + * @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 = '
No cron jobs yet. Click “+ New Job”.
'; + return; + } + const rows = jobs.map((j) => { + const next = j.enabled ? this._fmtTime(j.nextRunAt) : '—'; + const last = this._fmtTime(j.lastRunAt); + const status = j.lastStatus ? escapeHtml(j.lastStatus) : '—'; + return ` +
+
+
${escapeHtml(j.name || '(unnamed)')} + ${escapeHtml(j.agentType)} + ${escapeHtml(this._fmtSchedule(j))} + ${j.enabled ? '' : 'disabled'} +
+
+ ${escapeHtml(j.workingDir || '')} + · next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status} +
+
+
+ + + + +
+
`; + }); + list.innerHTML = rows.join(''); + }, + + _fmtTime(ts) { + if (!ts) return '—'; + try { + return new Date(ts).toLocaleString(); + } catch { + return '—'; + } + }, + + _fmtSchedule(j) { + switch (j.scheduleType) { + case 'once': + return 'once'; + case 'interval': + return `every ${j.intervalMinutes}m`; + case 'daily': + return `daily ${j.dailyTime || ''}`; + case 'weekly': { + const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const days = (j.weeklyDays || []).map((d) => names[d] || d).join(','); + return `weekly ${days} ${j.weeklyTime || ''}`; + } + default: + return j.scheduleType || ''; + } + }, + + // ── Create / edit form ──────────────────────────────────────────────────── + + 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('schPromptMode').value = job ? job.promptMode || 'inline_text' : 'inline_text'; + document.getElementById('schPromptText').value = job ? job.promptText || '' : ''; + document.getElementById('schPromptFilePath').value = job ? job.promptFilePath || '' : ''; + document.getElementById('schInputMode').value = job ? job.inputMode || 'typed' : 'typed'; + document.getElementById('schScheduleType').value = job ? job.scheduleType || 'once' : 'once'; + document.getElementById('schRunAt').value = job && job.runAt ? this._toLocalInput(job.runAt) : ''; + document.getElementById('schIntervalMinutes').value = job && job.intervalMinutes ? job.intervalMinutes : 60; + document.getElementById('schDailyTime').value = job ? job.dailyTime || '' : ''; + document.getElementById('schWeeklyTime').value = job ? job.weeklyTime || '' : ''; + const weekly = (job && job.weeklyDays) || []; + document.querySelectorAll('#schWeeklyDays input[type=checkbox]').forEach((cb) => { + cb.checked = weekly.includes(Number(cb.value)); + }); + document.getElementById('schConcurrencyPolicy').value = job ? job.concurrencyPolicy || 'warn_only' : 'warn_only'; + document.getElementById('schEnabled').checked = job ? !!job.enabled : true; + document.getElementById('schNotes').value = job ? job.notes || '' : ''; + + this.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'); + }, + + 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 . + 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, + enabled: document.getElementById('schEnabled').checked, + notes: document.getElementById('schNotes').value.trim() || undefined, + }; + if (promptMode === 'inline_text') body.promptText = document.getElementById('schPromptText').value; + else body.promptFilePath = document.getElementById('schPromptFilePath').value.trim(); + + if (t === 'once') { + const v = document.getElementById('schRunAt').value; + body.runAt = v ? new Date(v).getTime() : undefined; + } else if (t === 'interval') { + body.intervalMinutes = Number(document.getElementById('schIntervalMinutes').value); + } else if (t === 'daily') { + body.dailyTime = document.getElementById('schDailyTime').value; + } else if (t === 'weekly') { + body.weeklyTime = document.getElementById('schWeeklyTime').value; + body.weeklyDays = Array.from(document.querySelectorAll('#schWeeklyDays input:checked')).map((cb) => + Number(cb.value) + ); + } + return body; + }, + + async 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; + } + 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) { + if (!this.sessions) return 0; + let n = 0; + for (const s of this.sessions.values()) if (s && s.mode === agentType) 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'); + } + }, +}); diff --git a/src/web/public/index.html b/src/web/public/index.html index 3f6edd2d..9ddf7aab 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -541,6 +541,7 @@

Resume Conversation

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

Keyboard Shortcuts

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

Save Respawn Preset

+ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index bf0d3cdc..6e00baa5 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -11071,3 +11071,25 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { background: linear-gradient(135deg, #7c3aed, #8b5cf6); box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5); } + +/* ── 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); +} diff --git a/src/web/routes/cron-routes.ts b/src/web/routes/cron-routes.ts new file mode 100644 index 00000000..b411c5c5 --- /dev/null +++ b/src/web/routes/cron-routes.ts @@ -0,0 +1,78 @@ +/** + * @fileoverview Cron Jobs routes. + * + * CRUD + enable/disable + Run Now + run history for `CronJob`s. These are + * separate from the legacy `/api/scheduled` (ScheduledRun) endpoints — see + * docs/cron-discovery.md §0. + */ + +import { FastifyInstance } from 'fastify'; +import { ApiErrorCode, createErrorResponse } from '../../types.js'; +import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js'; +import { parseBody } from '../route-helpers.js'; +import type { CronPort } from '../ports/index.js'; + +export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void { + // ── Jobs ──────────────────────────────────────────────────────────────── + + app.get('/api/cron/jobs', async () => { + return ctx.cron.listJobs(); + }); + + app.post('/api/cron/jobs', async (req) => { + const body = parseBody(CronJobSchema, req.body, 'Invalid cron job'); + return { job: ctx.cron.createJob(body) }; + }); + + app.get('/api/cron/jobs/:id', async (req) => { + const { id } = req.params as { id: string }; + const job = ctx.cron.getJob(id); + if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); + return job; + }); + + app.put('/api/cron/jobs/:id', async (req) => { + const { id } = req.params as { id: string }; + const body = parseBody(CronJobUpdateSchema, req.body, 'Invalid cron job update'); + const job = ctx.cron.updateJob(id, body); + if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); + return { job }; + }); + + app.delete('/api/cron/jobs/:id', async (req) => { + const { id } = req.params as { id: string }; + if (!ctx.cron.deleteJob(id)) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); + } + return {}; + }); + + app.put('/api/cron/jobs/:id/enabled', async (req) => { + const { id } = req.params as { id: string }; + const { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body'); + const job = ctx.cron.setEnabled(id, enabled); + if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); + return { job }; + }); + + // ── Run Now ────────────────────────────────────────────────────────────── + + app.post('/api/cron/jobs/:id/run', async (req) => { + const { id } = req.params as { id: string }; + const job = ctx.cron.getJob(id); + if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found'); + const run = await ctx.cron.runNow(id); + return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType) }; + }); + + // ── Run history ────────────────────────────────────────────────────────── + + app.get('/api/cron/jobs/:id/runs', async (req) => { + const { id } = req.params as { id: string }; + return ctx.cron.listRuns(id); + }); + + app.get('/api/cron/runs', async () => { + return ctx.cron.listRuns(); + }); +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 9adaec30..62108a11 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -7,6 +7,7 @@ export { registerTeamRoutes } from './team-routes.js'; export { registerMuxRoutes } from './mux-routes.js'; export { registerFileRoutes } from './file-routes.js'; export { registerScheduledRoutes } from './scheduled-routes.js'; +export { 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'; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index a6dc1416..4544c676 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -574,6 +574,65 @@ export const ScheduledRunSchema = z.object({ durationMinutes: z.number().int().min(1).max(14400).optional(), }); +// ========== Cron Jobs ========== + +/** 'HH:MM' 24-hour time. */ +const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)'); + +/** Shared field shape for creating/updating a scheduled job. */ +const 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).optional(), + promptMode: z.enum(['inline_text', 'prompt_file_path']), + promptText: z.string().max(100000).optional(), + promptFilePath: safePathSchema.optional(), + inputMode: z.enum(['paste', 'typed']), + scheduleType: z.enum(['once', 'interval', 'daily', 'weekly']), + runAt: z.number().int().positive().optional(), + intervalMinutes: z.number().int().min(1).max(525600).optional(), + dailyTime: hhmmSchema.optional(), + weeklyDays: z.array(z.number().int().min(0).max(6)).min(1).max(7).optional(), + weeklyTime: hhmmSchema.optional(), + enabled: z.boolean(), + notes: z.string().max(2000).optional(), + concurrencyPolicy: z.enum(['warn_only', 'skip_if_same_agent_running']), +}); + +/** Cross-field validation: required fields depend on promptMode + scheduleType. */ +function refineCronJob(val: z.infer, ctx: z.RefinementCtx): void { + const add = (message: string, path: string) => ctx.addIssue({ code: 'custom', message, path: [path] }); + + if (val.promptMode === 'inline_text' && !val.promptText) { + add('promptText is required when promptMode is inline_text', 'promptText'); + } + if (val.promptMode === 'prompt_file_path' && !val.promptFilePath) { + add('promptFilePath is required when promptMode is prompt_file_path', 'promptFilePath'); + } + if (val.scheduleType === 'once' && val.runAt === undefined) { + add('runAt is required for a one-time schedule', 'runAt'); + } + if (val.scheduleType === 'interval' && val.intervalMinutes === undefined) { + add('intervalMinutes is required for an interval schedule', 'intervalMinutes'); + } + if (val.scheduleType === 'daily' && !val.dailyTime) { + add('dailyTime is required for a daily schedule', 'dailyTime'); + } + if (val.scheduleType === 'weekly' && (!val.weeklyTime || !val.weeklyDays?.length)) { + add('weeklyDays and weeklyTime are required for a weekly schedule', 'weeklyTime'); + } +} + +/** POST /api/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'), diff --git a/src/web/server.ts b/src/web/server.ts index 61324c2b..2534f701 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -152,8 +152,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)); @@ -175,6 +177,7 @@ import { ITERATION_PAUSE_MS, STATS_COLLECTION_INTERVAL_MS, INACTIVITY_TIMEOUT_MS, + CRON_TICK_INTERVAL, } from '../config/server-timing.js'; /** @@ -223,6 +226,8 @@ export class WebServer extends EventEmitter { // Store session listener references for explicit cleanup (prevents memory leaks) private sessionListenerRefs: Map = new Map(); private scheduledRuns: Map = new Map(); + /** Cron service (assigned in setupRoutes). */ + private cronService!: CronService; private sse: SseStreamManager; private store = getStore(); private port: number; @@ -873,6 +878,13 @@ export class WebServer extends EventEmitter { registerClipboardRoutes(this.app, ctx); registerSearchRoutes(this.app, ctx); registerOrchestratorRoutes(this.app, ctx); + + // Cron: 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()); } @@ -1947,6 +1959,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( () => { diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 5d57e781..cb0b40d4 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -240,6 +240,17 @@ export const ScheduledLog = 'scheduled:log' as const; /** Scheduled run deleted. */ export const ScheduledDeleted = 'scheduled:deleted' as const; +// ─── 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, diff --git a/test/cron-service.test.ts b/test/cron-service.test.ts new file mode 100644 index 00000000..af83bed5 --- /dev/null +++ b/test/cron-service.test.ts @@ -0,0 +1,423 @@ +/** + * @fileoverview Tests for CronService — the CRUD/bookkeeping + due-tick + * state machine of the cron. The pure next-run math lives in + * cron-time.test.ts; this exercises the service that sits on top of it. + * + * Launch attempts are steered down the "workingDir does not exist" failure path + * so no real Session/tmux objects are constructed — we assert the scheduling + * state machine (due detection, dedup guard, schedule advance, once-completion, + * concurrency skip, run-history recording), not the session layer it reuses. + * + * Port: N/A (no HTTP server). + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { 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 type { CronJob, CronJobRun } from '../src/types/cron.js'; +import type { CronJobInput } from '../src/cron/cron-input.js'; + +const MISSING_DIR = '/nonexistent-codeman-cron-test-dir'; +const flush = (): Promise => new Promise((r) => setImmediate(r)); + +function makeStore() { + const jobs: Record = {}; + const runs: Record = {}; + 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()) { + const store = makeStore(); + const broadcast = vi.fn(); + const deps = { + store, + broadcast, + sessions, + } as unknown as CronDeps; + return { service: new CronService(deps), store, broadcast, sessions }; +} + +function mkInput(overrides: Partial = {}): CronJobInput { + return { + name: 'job', + agentType: 'claude', + workingDir: MISSING_DIR, + promptMode: 'inline_text', + promptText: 'hello', + inputMode: 'typed', + scheduleType: 'interval', + intervalMinutes: 10, + enabled: true, + concurrencyPolicy: 'warn_only', + ...overrides, + }; +} + +describe('CronService', () => { + let svc: ReturnType; + + 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'); // workingDir missing → fails before session launch + + // A second tick must not re-fire it. + await svc.service.tickDueJobs(Date.now()); + await flush(); + expect(svc.service.listRuns(job.id).length).toBe(1); + }); + + it('advances an interval job to a future nextRunAt after firing', async () => { + const job = svc.service.createJob(mkInput({ intervalMinutes: 10 })); + const fireAt = job.nextRunAt! + 1000; + + await svc.service.tickDueJobs(fireAt); + await flush(); + + const after = svc.service.getJob(job.id)!; + expect(after.enabled).toBe(true); + expect(after.nextRunAt!).toBeGreaterThan(fireAt); + expect(after.lastDueKey).not.toBeNull(); + expect(svc.service.listRuns(job.id).length).toBe(1); + }); + + it('does not fire a job whose nextRunAt is still in the future', async () => { + const job = svc.service.createJob(mkInput({ intervalMinutes: 60 })); + await svc.service.tickDueJobs(Date.now()); + await flush(); + expect(svc.service.listRuns(job.id).length).toBe(0); + }); + + it('skips an automatic run when concurrency policy is skip_if_same_agent_running', async () => { + const sessions = new Map([['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([['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 workspace confinement', 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(/inside the job working directory/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(); + }); + }); +}); diff --git a/test/cron-time.test.ts b/test/cron-time.test.ts new file mode 100644 index 00000000..bf7efcc4 --- /dev/null +++ b/test/cron-time.test.ts @@ -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 { + 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'); + }); +});