5.6 KiB
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_APPOINTMENTSto 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
skipstable. 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.