diff --git a/docs/api-reference.md b/docs/api-reference.md index 47c22e0be..f19cf3353 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -87,6 +87,93 @@ the HTTP status. Adding a new error code is non-breaking; removing or renaming one is a major change. +## Cron jobs + +Saved jobs and their launch history are separate from the legacy `/api/scheduled` +duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...` +is the unversioned alias. These routes use the response envelope above; the table +lists the value inside `data` on success. + +| Method | Path | Request body | Response `data` | +| --- | --- | --- | --- | +| GET | `/api/v1/cron/jobs` | None | `CronJob[]` | +| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` | +| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` | +| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` | +| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` | +| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` | +| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` | +| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` | +| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` | + +### Job request fields + +The create body requires `name`, `agentType`, `workingDir`, `promptMode`, +`inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields +are required according to the selected prompt and schedule: + +| Field | Type / validation | +| --- | --- | +| `name` | String, 1–200 characters | +| `agentType` | A supported session mode (including `shell`) | +| `workingDir` | Existing, allowed working-directory path | +| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs | +| `promptMode` | `inline_text` or `prompt_file_path` | +| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters | +| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires | +| `inputMode` | `paste` or `typed` | +| `scheduleType` | `once`, `interval`, `daily`, or `weekly` | +| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds | +| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 | +| `dailyTime` | Required for `daily`; `HH:MM` in server-local time | +| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) | +| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time | +| `enabled` | Boolean | +| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only | +| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` | +| `notes` | Optional string, at most 2000 characters | + +`PUT /jobs/:id` accepts any subset of these fields, then validates the merged job. +When changing `promptMode` or `scheduleType`, supply the fields the new mode needs. +`Run Now` works even when the job is disabled, bypasses the scheduled concurrency +policy, and does not change the schedule. `activeAgents` counts live sessions of +the same agent type, excluding sessions created by this job. +For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now` +also closes the previous run's session before launching, even if it is still working. + +### Job and run response fields + +`CronJob` contains the request fields plus server-maintained `id`, optional +`owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`, +`lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix +milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`. +`lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format. + +`CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`, +`startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`, +`triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`. +Run times are also Unix milliseconds. Status is one of `created`, +`session_started`, `prompt_sent`, `failed`, or `skipped`. + +Prompt delivery continues asynchronously after session launch, so `Run Now` can +return `session_started` before the prompt is sent. Read run history for subsequent +updates, but do not assume a terminal status will follow: if the session is closed +during the readiness wait or the server restarts before delivery, the run can remain +`session_started` indefinitely with `finishedAt: null`. +`finishedAt` refers to the launch/prompt-delivery attempt, **not completion +of the agent's task**; `prompt_sent` does not prove that the task succeeded. + +In multi-user mode, list/history endpoints filter to accessible jobs. An unknown +or inaccessible job returns `NOT_FOUND`. Job creation and updates can return +`403 FORBIDDEN` for a working directory outside the owner's workspace or a shell / +launch-command job without the required privilege grant. Invalid definitions or +working directories return `INVALID_INPUT`; launch/delivery failures are recorded +on the run, so inspect its `status` and `errorMessage` even after an HTTP success. + +See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules. +See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and +`cron:runUpdated` SSE events. + ## Long-polling (agent wait) Three calls block until something happens instead of answering immediately. They diff --git a/docs/cron-guide.md b/docs/cron-guide.md index a865196bf..92ba3320a 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi 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`). +- **UI**: the **⏰ Cron** button in the bottom toolbar → 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`, @@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_ ### In the browser -1. Click **⏰ Cron** in the header. +1. Click **⏰ Cron** in the bottom toolbar. 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 diff --git a/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md index 699c89ee7..e45f694ff 100644 --- a/docs/wiki/Cron-Jobs.md +++ b/docs/wiki/Cron-Jobs.md @@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update dependencies and open a PR.* -The ⏰ **Cron** header button is opt-in. Turn it on in -**App Settings → Header & Panels**. +The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under +**App Settings → Header & Panels → Scheduling**. ## Creating a job @@ -96,11 +96,19 @@ The skip policy has the details you would want it to have: Every fire is recorded per job, with a status: -| Status | Meaning | -| --------- | -------------------------------------------------------------------- | -| `created` | The run started and a session was created. | -| `skipped` | The concurrency policy blocked it. Not counted as a run. | -| `failed` | The prompt could not be resolved, or the working directory was gone. | +| Status | Meaning | +| ----------------- | ----------------------------------------------------------------- | +| `created` | The run record was created; the session has not started yet. | +| `session_started` | The session started; prompt delivery is still pending. | +| `prompt_sent` | The prompt was sent to the session. | +| `skipped` | The concurrency policy blocked it. Not counted as a run. | +| `failed` | Prompt resolution, session launch, or prompt delivery failed. | + +History records whether the session started and the prompt was delivered, **not whether +the agent's task succeeded**. `prompt_sent` is not a task-completion signal. +If the session is closed during the readiness wait or the server restarts before +delivery, a run can remain `session_started` indefinitely with `finishedAt: null`; +clients must not poll forever waiting for a terminal status. The schedule is advanced **before** the session launches, so a slow start cannot cause the same job to re-trigger. diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index 7cd94f114..b4fb3cae7 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -92,6 +92,32 @@ Roughly 235 handlers across 26 route modules. By domain: Each route module documents its own endpoints in its file header. +## Cron jobs + +Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their +versioned endpoints are: + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/api/v1/cron/jobs` | List jobs | +| POST | `/api/v1/cron/jobs` | Create a job | +| GET | `/api/v1/cron/jobs/:id` | Read a job | +| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) | +| DELETE | `/api/v1/cron/jobs/:id` | Delete a job | +| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` | +| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule | +| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history | +| GET | `/api/v1/cron/runs` | Read all accessible run history | + +Responses use the envelope above: job lists/history have arrays in `data`, a +single-job GET has the job itself, create/update/enable have `{ job }`, delete has +`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous; +`session_started` and `prompt_sent` describe launch/delivery, not task success. + +The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs) +lists all request and response fields, validation, and ownership restrictions. +[Creating a job](Cron-Jobs) covers the UI and schedule semantics. + ## Long-polling instead of polling Three calls block until something happens, so an agent driving Codeman from a shell can wait diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 1d7a30230..27c4a7a7f 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -254,7 +254,7 @@ Two extras depending on the device: | Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) | | Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) | | Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) | -| Cron | Header ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) | +| Cron | Bottom toolbar ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) | | Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) | | Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) | | File Viewer | Header | [Working With Files](Working-With-Files) | diff --git a/test/cron-docs.test.ts b/test/cron-docs.test.ts new file mode 100644 index 000000000..ce410c931 --- /dev/null +++ b/test/cron-docs.test.ts @@ -0,0 +1,56 @@ +/** @fileoverview Guards cron docs against drift from routes, schema, statuses, and UI entry points. */ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { CronJobSchema } from '../src/web/schemas.js'; + +const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); +const reference = read('docs/api-reference.md'); +const wiki = read('docs/wiki/HTTP-API.md'); +const guide = read('docs/wiki/Cron-Jobs.md'); + +const cronSection = (text: string) => text.split('## Cron jobs\n')[1]?.split('\n## ')[0] ?? ''; + +describe('cron documentation', () => { + it('documents every cron route in both API references', () => { + const routes = [...read('src/web/routes/cron-routes.ts').matchAll(/app\.(get|post|put|delete)\('([^']+)'/g)]; + expect(routes).toHaveLength(9); + for (const [, method, path] of routes) { + const row = `| ${method.toUpperCase()} | \`${path.replace('/api/', '/api/v1/')}\` |`; + expect(cronSection(reference)).toContain(row); + expect(cronSection(wiki)).toContain(row); + } + }); + + it('documents every accepted job field and validates the guide example', () => { + for (const field of Object.keys(CronJobSchema.shape)) { + expect(cronSection(reference)).toContain(`| \`${field}\` |`); + } + const example = guide.match(/-d '(\{[\s\S]*?\})'/)?.[1]; + expect(example).toBeDefined(); + expect(CronJobSchema.safeParse(JSON.parse(example!)).success).toBe(true); + }); + + it('documents all run statuses without treating prompt delivery as task success', () => { + const statusType = read('src/types/cron.ts').match(/export type CronJobRunStatus = ([^;]+);/)?.[1]; + expect(statusType).toBeDefined(); + for (const [, status] of statusType!.matchAll(/'([^']+)'/g)) { + expect(guide).toContain(`| \`${status}\``); + expect(cronSection(reference)).toContain(`\`${status}\``); + } + expect(guide.replace(/\s+/g, ' ')).toContain("not whether the agent's task succeeded"); + expect(guide).toContain('bottom toolbar'); + expect(guide).toContain('App Settings → Header & Panels → Scheduling'); + }); + + it('keeps linked pages consistent and explains non-terminal launch history', () => { + expect(read('docs/wiki/The-Dashboard.md')).toMatch(/\| Cron\s*\| Bottom toolbar/); + expect(read('docs/cron-guide.md')).not.toMatch(/Cron\*\* (?:button )?in the header/); + for (const text of [reference, guide]) { + const normalized = text.replace(/\s+/g, ' '); + expect(normalized).toContain('session is closed during the readiness wait'); + expect(normalized).toContain('server restarts before delivery'); + expect(normalized).toContain('`session_started` indefinitely with `finishedAt: null`'); + } + expect(reference).toContain("also closes the previous run's session before launching"); + }); +});