# Schedules

**Schedule = a pipeline + input + trigger.** Forge runs the named pipeline with
your input whenever the trigger fires. The pipeline is the only execution unit;
Schedule just decides *when* to fire.

**Note:** Jobs (the older subsystem) is deprecated — its UI is hidden and the
scheduler no longer ticks. Any existing rows in the `jobs` table are inert.
Use Schedules for all timed automation. If a pipeline needs iteration, write
it inside the pipeline yaml using `for_each:` — Schedule just fires the
pipeline; iteration logic stays in the workflow.

## Creating a Schedule (3 steps)

Open **Schedules** tab → **+ New schedule** in Forge web or extension:

1. **Pick a pipeline** from the list. Each pipeline shows its description.
2. **Fill input**. The form depends on body kind:
   - **Pipeline** body: auto-renders from the pipeline's declared `input:`
     block. Required fields are marked `*`. Long-form fields render as textarea.
   - **Skill** body: a fixed two-field form — `project` (which Forge project
     the skill runs in) and `user_prompt` (the user message sent to Claude).
     The skill is loaded via `--append-system-prompt`.
   - **Connector Tool** body: a JSON textarea passed verbatim as the
     tool's input. The tool's `input_schema` (if declared) is shown
     below the editor for reference.
3. **Name, trigger, action**:
   - **Trigger**:
     - `Every N minutes` — period polling
     - `Cron` — full cron expression (e.g. `0 9 * * 1-5` = 9am Mon-Fri)
     - `Once at <time>` — single shot, auto-disables after firing
     - `Manual only` — never auto-fires; user must click Run now
   - **Action** (what to do with the body's output):
     - `None` (default) — body self-handles its output; nothing else fires
     - `Chat` — append the body output to a Chat session as a synthetic
       assistant message. Configure `session_id` (pick from the dropdown)
       and an optional `prefix`. The message header is auto-prepended with
       the schedule name + fire time so the recipient can tell where it
       came from.
     - `Email` — send the body output via SMTP. Configure SMTP transport
       once in `~/.forge/data/settings.yaml`:

       ```yaml
       smtpHost: smtp.gmail.com
       smtpPort: 587
       smtpSecure: false           # true for 465 (implicit TLS), false for 587 (STARTTLS)
       smtpUser: forge@example.com
       smtpPassword: <app-password>  # encrypted at rest
       smtpFrom: Forge <forge@example.com>
       ```

       Per-schedule action config:

       ```
       to                 alice@example.com   (or list)
       subject_template   "Daily bugs — {date}"
       body_template      "{body_output}"
       ```

       Templates support `{date}` (YYYY-MM-DD) and `{body_output}`.
       Requires `nodemailer` installed in the Forge repo
       (`pnpm add nodemailer`).
     - `Telegram` — post the body output via the same Telegram bot
       Forge uses for task notifications. Reuses `telegramBotToken`
       from Settings. `chat_id` is optional in the action config —
       blank falls back to `settings.telegramChatId`. Optional
       `prefix` is prepended; default `parse_mode: Markdown`.

   Actions fire only when the body completes with status `done` and
   produces non-empty output (unless `action_skip_on_empty` is false).
   The `schedule_runs.action_status` column records `done | failed |
   skipped` independently of the body status.

## Schedule states

Computed from `enabled` + inflight pipelines + last run status:

| State | Meaning | UI |
|---|---|---|
| `idle` | Enabled, nothing running, last run OK (or never run) | green |
| `running` | Enabled, ≥1 pipeline this schedule launched is in-flight | orange (pulsing) |
| `last_failed` | Enabled, last run ended in failure | red |
| `paused` | `enabled=false` | gray |

A paused Schedule with inflight pipelines shows `paused` plus a count badge
("3 still running") so you know the running work is still going even though
no new pipelines will be triggered.

## Actions

The action bar adapts to current state:

| Button | Effect |
|---|---|
| **Run now** | Manually fire — `trigger='manual'`. If something's already running, asks to cancel-and-rerun |
| **Cancel** | Kill the in-flight pipelines this Schedule launched. Does NOT change `enabled` |
| **Pause** | Set `enabled=false`. Running pipelines keep going |
| **Resume** | Set `enabled=true` (only shown when paused and nothing inflight) |
| **Stop** | Pause + Cancel in one shot — full halt |
| **Edit** | Modify input / trigger / name. Pipeline can't be changed (create a new Schedule) |
| **Delete** | Remove the Schedule. Optionally cancel running pipelines too |

## Tracked pipelines

Expanding a Schedule row shows the pipelines it has launched. Click any row to
open the Pipeline view for node-level details. The list shows current running
pipelines plus the most recent completed one (full history is in the pipeline
view).

## API

```
GET    /api/schedules                              list + decorate state
POST   /api/schedules                              create
GET    /api/schedules/<id>                         single
PATCH  /api/schedules/<id>                         update input/trigger/name/enabled
DELETE /api/schedules/<id>?cancel_inflight=1       remove

POST   /api/schedules/<id>/run?cancel_inflight=1   manual fire
POST   /api/schedules/<id>/cancel                  kill all inflight
POST   /api/schedules/<id>/stop                    pause + cancel
GET    /api/schedules/<id>/runs?limit=20           history

GET    /api/pipelines/<name>/schema                input fields (for UI form)
```

## Storage

- `schedules` — configuration. Key fields:
  - `body_kind` (`pipeline | skill | connector_tool`) + `body_ref` —
    what this schedule fires
  - `input` (JSON) — params for the body
  - `skills` (JSON `string[]`) — extra Forge skills attached to the
    dispatched body. Forwarded as `--append-system-prompt` so Claude
    sees "use /&lt;skill&gt; as appropriate"; missing skills auto-installed
    into the target project at dispatch (same plumbing as `Job.skills`).
    Ignored for `body_kind=connector_tool` (no Claude task spawned).
    For `body_kind=skill`, merged with `body_ref` (de-duped).
  - `action_kind` (`none | chat | email | telegram`) + `action_config`
    (JSON) — what to do with the body's output. **Phase 1 only ships
    `body_kind=pipeline` and `action_kind=none`**; other kinds land in
    later phases.
  - `schedule_kind` + interval/at/cron — trigger
- `schedule_runs` — every body a Schedule has launched:
  - `target_id` — pipeline_id (or future task/dispatch id)
  - `status` — body status (`started / done / failed / cancelled`)
  - `body_output` — captured body output, fed into action
  - `action_status` — independent of body status

V1 (`pipeline_schedules` / `pipeline_schedule_runs` tables, with
`pipeline_name` column) is auto-migrated into V2 on first boot.

## Scheduler tick

A 60s timer runs in `lib/schedules/scheduler.ts`. Each tick:

1. Reconcile any stale "started" runs against the actual pipeline JSON state
2. For every enabled, non-manual Schedule whose `next_run_at <= now`:
   - Advance `next_run_at` for the next firing
   - Skip if this Schedule already has an inflight pipeline
   - Otherwise call `startPipeline(pipeline_name, input)` and write a
     `pipeline_schedule_runs` row

No sequential / on_failure / dedup / retry logic at the Schedule level — those
are pipeline responsibilities.
