# L4D2 Job Pages and Cancellation Follow-Up

## Goal

Make queued and running lifecycle jobs easier to inspect and stop from the web UI.

## Scope

- Add job list navigation for server pages and admin pages.
- Add a job detail page with persisted command logs streamed through the existing SSE endpoint.
- Add cancellation for queued jobs first.
- Add best-effort cancellation for running jobs by terminating the subprocess owned by `l4d2host.process.run_command()`.

## Slice 1: Job Browsing and Queued Cancel

### Behavior

- `/servers/<server_id>` shows recent jobs for that server and links to the full job history.
- `/servers/<server_id>/jobs` shows all jobs for that server, newest first.
- `/jobs/<job_id>` shows job metadata and live/replayed logs.
- `/admin/jobs` reuses the same job table markup and links every job to its detail page.
- `POST /jobs/<job_id>/cancel` cancels queued jobs only.
- Owners can view/cancel their own jobs.
- Admins can view/cancel any job.

### Implementation Notes

- Use one reusable Jinja partial for job tables.
- Show cancel buttons only for `queued` jobs in this slice.
- Cancelling a queued job sets `state="cancelled"`, `finished_at`, `updated_at`, and `exit_code=1`.
- Append a `stderr` job-log line explaining that the job was cancelled before execution.
- Do not revert `Server.desired_state`; cancellation prevents execution but is not rollback.

### Verification

- `pytest l4d2web/tests/test_pages.py -q`
- `pytest l4d2web/tests/test_job_logs.py -q`
- `pytest l4d2web/tests -q`

## Slice 2: Running Job Cancellation

### Behavior

- Running jobs expose the same cancel action.
- Cancelling a running job marks it `cancelling` while the subprocess is being terminated.
- Once the subprocess exits because of cancellation, the job finishes as `cancelled`.
- Cancellation is best-effort and is not rollback; partial runtime state may remain.
- Server actual state is refreshed after a cancelled server job when possible.

### Implementation Notes

- Add cancellation primitives in `l4d2host.process`.
- Launch subprocesses in their own process group/session when a cancel token is supplied.
- On cancellation, send terminate, wait briefly, then force kill.
- Thread the cancel token through `l4d2host` lifecycle APIs, `l4d2web.services.l4d2_facade`, and `l4d2web.services.job_worker`.
- Keep v1 single-process assumptions; cancellation requests are DB-backed, while process handles stay process-local.

### Verification

- `pytest l4d2host/tests/test_process.py -q`
- `pytest l4d2host/tests -q`
- `pytest l4d2web/tests/test_job_worker.py -q`
- `pytest l4d2web/tests/test_job_logs.py -q`
- `pytest l4d2web/tests -q`
