# Step 6 — Jobs and stats Read `00-shared.md`, the plan (§2 host facts, §3, §4.2, §9.1 sparklines, §12, §14 in full, §15 `jobs_enabled`/`journal_lines`, §16, §17 "Stats refactor" and "Jobs"), `src/main.rs` (`lock_holder`, `cmd_*` helpers, `GenerateOptions`), `src/curate/telemetry.rs` (`stats`), `systemd/`, and the handoffs for steps 1–5. This is plan §19 item 6. ## Deliverables 1. `src/jobs.rs` (§14.1): the `Job` enum, `parse` (regex `^[a-z0-9-]+$` and the listed forms only), `name`, `unit`, `description`, `takes_lock`, `dangerous`. Register in `lib.rs`. 2. `daily-epub job run ` (§14.2) in `main.rs`: parse (unknown → exit 2 with the catalogue), take the lock via `lock_holder` when `takes_lock()`, find-or-insert the `requested` jobs row → `running`, run the mapped command in-process with the same functions `main` uses, set `ok`/`failed`, `finished_at`, `run_id` (generate, from `GenerateOutcome.run_id`), a one-line `message`; non-zero exit on failure. The job lifecycle helpers live in `src/jobs.rs` as db functions or on `Db`. 3. `systemd/daily-epub-job@.service` (copy of the generate unit with the `%i` description and `job run %i`), `systemd/50-daily-epub.rules` exactly as §14.3, `SupplementaryGroups=systemd-journal` on `daily-epub.service`. 4. `JobRunner` (§14.4): finalize the trait from step 1's stub; `SystemdRunner` with `tokio::process::Command`, 10 s timeout, stderr in the error; `MockRunner` with recorded calls and scripted statuses; `DisabledRunner` when `jobs_enabled = false`. `serve` picks the runner from config. 5. `src/web/dashboard/jobs.rs` + `dashboard/jobs.html` + `dashboard/job.html`: the catalogue cards (Start with `data-confirm` for `dangerous()`, a date input on the `generate-` card), the jobs table, `POST /dashboard/jobs/{name}` (409 + flash for a duplicate `requested`/`running` unit; insert; `runner.start`; failed start marks the row), `GET /dashboard/jobs/{id}` (row, live `UnitStatus`, journal tail of `server.journal_lines` in `
`, the 30-second "unit exited before the job
   started" rule). `reload_if_changed` on every job start (§4.2). `app.js`
   refreshes the job page every 5 s while `requested`/`running`.
6. Stats (§12): refactor `telemetry::stats` into `stats_data -> StatsData` +
   `render_stats_text(&StatsData) -> String` with byte-identical CLI output (a
   test pins it against a seeded DB before and after); `GET /dashboard/stats`
   with `?days=14|30|90`, the tables, three server-rendered SVG sparklines via
   `_sparkline.html`, the retriever yield table.
7. Overview sparklines (§9.1): cost per run, selected per run, generation
   seconds over the last 30 non-dry runs, using the same partial.
8. Tests (§17 "Stats refactor", "Jobs"): `Job::parse` accepts the catalogue and
   the dated form, rejects `../x`, uppercase and unknown names; `POST
   /dashboard/jobs/{name}` inserts a row and calls `MockRunner::start` with the
   right unit; a running duplicate is refused; a failed start marks the row;
   `job run` in-process (a `--skip-llm`-style config with mocked providers, e.g.
   `features-prune` or `profile-rebuild` against a temp DB) flips
   `requested → running → ok`; the polkit rule file is present and its regex
   matches the unit name format (string test); stats text unchanged.