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