reviewloop 0.2.1

Reproducible, guardrailed automation for academic review workflows on paperreview.ai
Documentation
# Widget state JSON schema

The reviewloop daemon writes `<state_dir>/widget-state.json` on every tick. The
path resolves to `core.widget_state_dir` when configured, otherwise
`core.state_dir`. The macOS Widget extension reads this file and renders the
contents.

## Schema v1 (current)

| Field | Type | Nullability | Semantics |
|---|---|---|---|
| `schema_version` | integer | required | Always `1` for v1 documents. The Swift decoder MUST reject documents with a higher version it cannot handle. |
| `generated_at` | RFC3339 UTC timestamp string | required | UTC timestamp of when this snapshot was written. Rust currently emits whole-second timestamps like `2026-05-06T12:00:00Z`. |
| `project_id` | string | required | The daemon's `project_id`. May be empty for legacy installs; v0.2.0+ projects should have one. |
| `summary.active_count` | integer | required | Total active jobs for the project (`QUEUED`, `SUBMITTED`, and `PROCESSING`) before the `active_jobs` display cap is applied. |
| `summary.failed_recent_24h` | integer | required | Count of the capped recent-failure result (the five newest non-cancelled `FAILED`, `FAILED_NEEDS_MANUAL`, or `TIMEOUT` jobs) whose `updated_at` is in the last 24 hours. Excludes user cancellations (`last_error = 'cancelled by user'` or `last_error LIKE 'cancelled by user:%'`). |
| `summary.completed_today` | integer | required | Jobs completed since 00:00 UTC of the current calendar date, computed from `COMPLETED` jobs whose `updated_at` starts with today's UTC date. |
| `active_jobs` | array (max 10) | required | Active jobs for the project. Rust orders by `next_poll_at` ascending with `null` first; equal poll times preserve database order (`created_at` ascending). |
| `active_jobs[].paper_id` | string | required | Paper-id from project config. Swift uses this as the row identity. |
| `active_jobs[].status` | string | required | One of `"QUEUED"`, `"SUBMITTED"`, `"PROCESSING"`. |
| `active_jobs[].attempt` | integer | required | Number of attempts so far. |
| `active_jobs[].next_poll_at` | RFC3339 UTC timestamp string | nullable | When the daemon will next attempt this job. `null` when no poll is scheduled, including queued jobs. |
| `active_jobs[].started_at` | RFC3339 UTC timestamp string | nullable | When the current processing attempt started. `null` for jobs that have not started. |
| `recent_failures` | array (max 5) | required | Recent non-cancelled failures for the project, ordered by `updated_at` descending. |
| `recent_failures[].paper_id` | string | required | Paper-id. Swift uses this as the row identity. |
| `recent_failures[].status` | string | required | One of `"FAILED"`, `"FAILED_NEEDS_MANUAL"`, `"TIMEOUT"`. |
| `recent_failures[].last_error` | string (truncated to 80 Unicode scalar values) | required | Error message snippet. Rust emits `"(unknown error)"` when the database value is null and truncates without appending an ellipsis. |
| `recent_failures[].occurred_at` | RFC3339 UTC timestamp string | required | The failed job's `updated_at` timestamp. |
| `last_tick_at` | RFC3339 UTC timestamp string | nullable | Timestamp of the most recent daemon event for this project. `null` if no event has been recorded. |
| `last_tick_error` | object | nullable | Most recent `tick_failed` event, but only when it is still the latest event and is not older than three minutes. `null` after recovery or when stale. |
| `last_tick_error.at` | RFC3339 UTC timestamp string | required when `last_tick_error` is present | Timestamp of the surfaced `tick_failed` event. |
| `last_tick_error.message` | string | required when `last_tick_error` is present | Error message from the surfaced `tick_failed` event, or `"(no error message)"` if the event payload omitted one. |
| `tick_health` | string | required | One of `"normal"`, `"stale"`, `"stuck"`, `"unknown"`. |

## Sample document

```json
{
  "schema_version": 1,
  "generated_at": "2026-05-06T12:00:00Z",
  "project_id": "test-proj",
  "summary": {
    "active_count": 2,
    "failed_recent_24h": 1,
    "completed_today": 3
  },
  "active_jobs": [
    {
      "paper_id": "paper-a",
      "status": "PROCESSING",
      "attempt": 2,
      "next_poll_at": "2026-05-06T12:05:00Z",
      "started_at": "2026-05-06T11:50:00Z"
    }
  ],
  "recent_failures": [
    {
      "paper_id": "paper-b",
      "status": "FAILED",
      "last_error": "rate limit exceeded",
      "occurred_at": "2026-05-06T11:55:00Z"
    }
  ],
  "last_tick_at": "2026-05-06T11:59:50Z",
  "last_tick_error": {
    "at": "2026-05-06T11:59:55Z",
    "message": "daemon lost connection"
  },
  "tick_health": "normal"
}
```

## Schema bump procedure

When evolving to v2:

1. Add new fields with default values that older Swift can ignore.
2. Bump `schema_version` to `2` only if a breaking change is unavoidable.
3. Update Swift's `WidgetState.swift` to handle BOTH versions (decode based on `schemaVersion` field; provide null defaults for missing v2 fields when reading v1).
4. Ship the Swift widget update **before** the Rust daemon starts emitting v2 documents. Order matters: the user must update the widget app before the daemon writes incompatible JSON.
5. After enough time has passed, drop v1 support from Swift and update this doc.

## Cross-platform notes

- All timestamps are RFC3339 in UTC. Swift parses with `ISO8601DateFormatter`.
- Empty arrays MUST be `[]`, not `null`, to keep Swift's array decoders happy.
- snake_case in JSON; Swift uses `CodingKeys` to map to camelCase struct fields.