st-agent-worker/README.md
Miloš Jovanović 63e01c5b6d add README.md
2026-09-10 14:19:33 +02:00

133 lines
5.6 KiB
Markdown

# st-agent-worker
Assigns Solidarity Tech agents to department members automatically.
When someone is given the Department Rep role in Solidarity Tech, an ST
automation calls this service. The service looks up that person's chapter
(campus) and department, finds every eligible member of the same
chapter/department pair, and assigns the rep as their agent.
## Rules
- Only members whose appointment type is Senate or Recall are assigned.
Set `ELIGIBLE_APPOINTMENTS` to change this.
- Matching is on primary chapteri (UCLAFA, BFA, SCFA...) plus Department 1.
A rep at one campus is never assigned members at another.
- Reps are excluded from assignment. Organizer leads (OC members, organizing
staff) assign their agents by hand in the ST web interface.
- **Fill-only**: a member who already has an agent is left alone and
recorded in the `skips` table. No agent is ever reassigned automatically.
See below for consequences of this.
### Known consequence of fill-only
Some departments currently have two reps. Whichever rep runs first takes
the whole department; the second gets nobody. The reconciler logs a warning
in both cases that can be seen in the logs and reconciled manually.
_Potential fix_ In the future, round-robin splitting could be implemented. `RepsFor` already returns all reps for a department sorted by ID, so adding it is a
change to how the agent is picked, and wouldn't require a code restructuring.
## Architecture
Four components do the work:
- `server.go` — HTTP handler. Verifies the shared secret, reads the rep's
ST user ID from the query string, looks up their chapter and department
live, takes a per-department lock, returns 200, and runs the
reconciliation in the background. The 200 is returned before any work
happens, because ST times out and retries otherwise.
- `client.go` — Solidarity Tech API client. Holds the rate limiter and
handles 429 responses. Nothing above it builds a URL.
- `store.go` — SQLite database handle. Four tables: `people` (cached roster),
`runs` (locks and progress), `skips` (members left alone), `sync_state` (last
sync timestamp).
- `reconcile.go` — the loop. Reads department members from the cache,
checks each one's current agent live against the API, assigns or skips.
Roster data is cached because it changes more slowly, and API rate limits would make a read/write of the whole roster take two hours. Instead, agent assignments are read live on every check, so an assignment made by hand in the dashboard
during a run can be respected right away. See _Rate limits_ below.
### Locking
`runs` has a partial unique index on `(chapter_id, department)` where
status is `running`. A second webhook for the same department fails the
insert and exits. On startup, any row still marked `running` belongs to a
dead process and is marked `crashed`.
### Rate limits
The ST API allows 60 requests per 30 seconds per API key, which averages
2 per second and permits bursts. The client is configured to match this.
## Configuration
| Variable | Meaning |
| --- | --- |
| `ST_API_KEY` | Solidarity Tech API key |
| `WEBHOOK_SECRET` | Shared secret, passed as `?s=` on the webhook URL |
| `DB_PATH` | SQLite file. `/data/worker.db` in the container |
| `DRY_RUN` | Any non-empty value logs writes instead of sending them |
| `ELIGIBLE_APPOINTMENTS` | Comma-separated labels, defaults to `senate,recall` |
The ST webhook action has no support for custom headers, so the secret
travels in the query string. Do not log raw query strings.
## Endpoints
- `POST /webhook?id={user_id}&s={secret}` — triggered by the ST automation
when someone is assigned the Department Rep role.
- `POST /debug/sync?s={secret}` — forces a roster sync. Useful after a bulk
import rather than waiting for the hourly update.
- `GET /healthz` — returns 200 once the server is listening.
## Health checks
The startup sync runs before the server binds, and a cold start with an
empty volume pulls the full roster, which takes about 90 seconds. Give the
container a `start_period` of at least 120 seconds or it will be marked
unhealthy before it comes up.
After deploying, check:
curl https://$HOST/healthz
and confirm the log shows a completed sync. Then:
sqlite3 /data/worker.db "
SELECT COUNT(*) total, SUM(is_eligible) eligible, SUM(is_rep) reps
FROM people;"
Should give roughly 17,500 total, 14,700 eligible.
Log lines to watch for:
- `WARNING: run N: assigned nobody` — every member already had an agent.
Expected for the second rep in a co-repped department, unexpected
otherwise.
- `N eligible users synced with no department` — those members cannot be
assigned to anyone. These are likely Senate or Recall duplicates, or
people we don't have information for.
## Initial backfill
The webhook only fires when a rep is newly assigned the role, so reps who
already had it when this was deployed never trigger it. To assign their
departments, call the endpoint for each:
sqlite3 worker.db "SELECT id FROM people WHERE is_rep = 1;" | while read id; do
curl -s -o /dev/null -X POST "https://$HOST/webhook?id=$id&s=$WEBHOOK_SECRET"
sleep 1
done
Run it with `DRY_RUN` set first and count the assignments in the log.
## Not built yet
- A nightly sweep to reassign reps. Without it, a rep who steps down keeps
their assignments and their replacement inherits nobody. Every member added
after their department's rep signed up also goes unassigned until
something triggers that department again. **FIRST PRIORITY**
- Schema migrations. The schema is created with `CREATE TABLE IF NOT
EXISTS`, which does not add columns to an existing table. Changing the
schema currently means deleting the database and re-syncing.