# Jobs

A **Job** runs a connector tool on a schedule, dedups items, and dispatches
each new item to a Pipeline or a Chat session.

Typical examples:
- Every 30 min, search Mantis for open bugs in version 25.4 → for each new
  bug, trigger the `bug-triage` pipeline bound to project `my-app`.
- Every 5 min, list Teams messages in the current chat → for each new
  message, POST to a chat session that drafts a reply.

Job is a separate primitive from Task and Pipeline. The CLI keeps
`forge task` (single agent invocation) and `forge pipeline` (DAG of tasks)
unchanged. `forge jobs` is new.

## Anatomy

| Field | Purpose |
|---|---|
| `source_connector`, `source_tool` | Which connector + tool to call each tick (e.g. `mantis.search_bugs`) |
| `source_input` | JSON passed verbatim to the tool |
| `items_path` | Dotted path into the tool's response to find the item array (`bugs`, empty = whole response if already an array) |
| `dedup_field` | Field on each item used as the dedup key (e.g. `id`) |
| `schedule_interval_minutes` | How often the scheduler ticks this job |
| `dispatch_type` | `pipeline` or `chat` |
| `dispatch_params` | Shape depends on dispatch_type — see below |

### `dispatch_type: pipeline`

```json
{
  "workflow_name": "bug-triage",
  "project_path": "/Users/me/code/my-app",
  "project_name": "my-app",
  "input_template": {
    "bug_id": "{{item.id}}",
    "summary": "{{item.summary}}"
  }
}
```

Per new item, Forge renders `input_template` and calls the existing
`triggerPipeline`. The pipeline shows up in the regular Pipelines view.

### `dispatch_type: chat`

```json
{
  "agent_profile": "my-litellm",
  "session_title_template": "Bug {{item.id}}: {{item.summary}}",
  "message_template": "Triage bug {{item.id}}.\nSummary: {{item.summary}}",
  "reuse_session": false
}
```

Per new item, Forge creates (or reuses, if `reuse_session: true`) a chat
session and POSTs a message rendered from `message_template`. The chat
agent gets the message and decides what tools to call.

## Template syntax

Only `{{item.<dotted.path>}}` is supported in v1. Missing paths render to
empty string; objects render via `JSON.stringify`. `{{item}}` alone dumps
the full item.

## Backfill guard

New jobs default to `mark_existing_as_seen: true` — on first tick, every
item the connector returns is added to `job_seen` but **no dispatch
happens**. This stops a fresh job from firing on historical data. Set to
`false` at create time to dispatch immediately on first tick.

## CLI

```
forge jobs                              # list
forge jobs show <id>                    # full JSON detail
forge jobs runs <id>                    # recent ticks (summary line per run)
forge jobs dispatches <id> <run_id>     # per-item dispatches for one run (target ids)
forge jobs run <id>                     # fire now (manual trigger)
forge jobs enable <id>
forge jobs disable <id>
forge jobs reset <id>                   # wipe dedup state — next tick re-processes everything
forge jobs rm <id>
```

## Tracking results

| Want to know | Where |
|---|---|
| Job list, schedule, enabled, last/next run | Extension → Jobs tab |
| Last 10 ticks summary (seen/new/dispatched, error) | Expand a job in Jobs tab |
| Which items got dispatched in a single tick | Expand a run row — shows item_key + preview + target id + open-link |
| What the resulting pipeline run did | Click "open →" on a pipeline dispatch — deep-links to Forge web Pipelines view |
| What the resulting chat session said | Click "open →" on a chat dispatch — switches the side panel to chat tab and loads that session |
| Run / dispatch detail from terminal | `forge jobs runs <id>` then `forge jobs dispatches <id> <run_id>` |
| Raw rows | sqlite: `select * from job_runs / job_dispatches` |
| Server-side log lines (every tick / error / dispatch) | Forge web → Logs view, search `[jobs]` (per-job: `[jobs] <job_id>`). The Jobs view has "View logs" buttons that pre-fill this filter. CLI: `tail -f ~/.forge/data/forge.log \| grep '\[jobs\]'`. |
| Why a run shows 0 dispatches | Read the italic note under the run row — the scheduler writes a `notes` field explaining backfill / non-JSON response / items_path mismatch. |

Create + edit happen via the Forge extension Jobs tab, or by `curl`ing
`POST /api/jobs`. There is no YAML-on-disk format for Jobs (definitions
live in sqlite).

## Lifecycle

1. Scheduler ticks every 60s. For each enabled job where `next_run_at`
   has elapsed:
2. Skip if a previous tick is still running (idempotent).
3. Advance `next_run_at = now + schedule_interval_minutes`.
4. Spawn a background tick:
   - Call the connector via the chat tool-dispatcher (handles
     `http` / `shell` / `browser` protocols uniformly).
   - Parse the response — find items via `items_path`.
   - For each item, compute `dedup_key = item[dedup_field]`,
     `INSERT OR IGNORE INTO job_seen`. If new: dispatch.
   - Update the `job_runs` row with counts + status.

## Browser-protocol caveat

If `source_connector` is a `protocol: browser` connector (Mantis, GitLab,
Teams, PMDB), the job tick can only fire **when the extension bridge is
connected** — the tool needs a live tab and `chrome.scripting`. A tick
with no extension fails with `connector ... failed: No extension
connected to the bridge`. The run is marked errored; the next tick
retries.

For 24/7 background polling, prefer connectors with `protocol: http`
(e.g. `github-api`) — those run server-side and don't need a browser.

## Tables

```
jobs                — definition (one row per job)
job_runs            — one row per tick (success / failure)
job_seen            — dedup keys per job (PRIMARY KEY (job_id, dedup_key))
job_dispatches      — one row per per-item dispatch attempt (link to pipeline run / chat session)
```

All cascaded on `ON DELETE`.
