a love letter to tangled (android, iOS, and a search API)
19
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: migrate to charm log

* add tests and deployment/qa guides

+294 -19
+100
docs/api/deploy.md
··· 1 + --- 2 + title: "Deployment Guide" 3 + updated: 2026-03-23 4 + --- 5 + 6 + # Railway Deployment Guide 7 + 8 + Deploy the Twister API and indexer as Railway services alongside the existing Tap instance. 9 + 10 + ## Prerequisites 11 + 12 + - Railway project with Tap already deployed 13 + - Turso database created with auth token 14 + - GitHub repository connected to Railway 15 + 16 + ## Service Layout 17 + 18 + | Service | Start Command | Health Check | Public | Port | 19 + | ------- | ----------------- | -------------- | ------ | ---- | 20 + | tap | (pre-existing) | `GET /health` | no | — | 21 + | api | `twister api` | `GET /healthz` | yes | 8080 | 22 + | indexer | `twister indexer` | `GET /health` | no | 9090 | 23 + 24 + All services use the same Docker image. Railway overrides `CMD` with the per-service start command. 25 + 26 + ## Step 1 — Create Services 27 + 28 + In the Railway dashboard, create two new services from the same GitHub repo: 29 + 30 + 1. **api** — set start command to `twister api` 31 + 2. **indexer** — set start command to `twister indexer` 32 + 33 + Both services build from `packages/api/Dockerfile`. 34 + 35 + ## Step 2 — Set Environment Variables 36 + 37 + ### Shared (set on both services) 38 + 39 + ```sh 40 + TURSO_DATABASE_URL=libsql://twister-prod-<org>.turso.io 41 + TURSO_AUTH_TOKEN=<turso-jwt> 42 + LOG_LEVEL=info 43 + LOG_FORMAT=json 44 + ``` 45 + 46 + ### API only 47 + 48 + ```sh 49 + HTTP_BIND_ADDR=:8080 50 + SEARCH_DEFAULT_LIMIT=20 51 + SEARCH_MAX_LIMIT=100 52 + ``` 53 + 54 + ### Indexer only 55 + 56 + ```sh 57 + TAP_URL=wss://${{tap.RAILWAY_PRIVATE_DOMAIN}}/channel 58 + TAP_AUTH_PASSWORD=<tap-admin-password> 59 + INDEXED_COLLECTIONS=sh.tangled.repo,sh.tangled.repo.issue,sh.tangled.repo.pull,sh.tangled.string,sh.tangled.actor.profile,sh.tangled.repo.issue.comment,sh.tangled.repo.pull.comment,sh.tangled.repo.issue.state,sh.tangled.repo.pull.status,sh.tangled.feed.star 60 + INDEXER_HEALTH_ADDR=:9090 61 + ``` 62 + 63 + Use `${{tap.RAILWAY_PRIVATE_DOMAIN}}` to reference Tap's internal hostname. This keeps traffic on Railway's private network. 64 + 65 + ## Step 3 — Configure Health Checks 66 + 67 + In the Railway dashboard, configure per-service: 68 + 69 + - **api**: HTTP health check on path `/healthz`, port `8080` 70 + - **indexer**: HTTP health check on path `/health`, port `9090` 71 + 72 + Railway uses these to gate deployment rollouts and restart unhealthy containers. 73 + 74 + ## Step 4 — Configure Autodeploy 75 + 76 + Connect the GitHub repository in the Railway dashboard. Railway will build and deploy on every push to the configured branch. 77 + 78 + The Dockerfile uses multi-stage builds with `CGO_ENABLED=0` for a static binary on Alpine. 79 + 80 + ## Step 5 — Deploy and Verify 81 + 82 + After the first deploy: 83 + 84 + 1. Confirm API is healthy: `curl https://<api-domain>/healthz` 85 + 2. Confirm API readiness: `curl https://<api-domain>/readyz` 86 + 3. Check indexer health in Railway logs (health check on `:9090/health`) 87 + 88 + ## Step 6 — Bootstrap Content 89 + 90 + Run graph backfill to populate initial content from seed users: 91 + 92 + ```bash 93 + twister backfill --seeds=docs/api/seeds.txt --max-hops=2 94 + ``` 95 + 96 + Wait for Tap to finish historical sync, then verify search returns results: 97 + 98 + ```bash 99 + curl "https://<api-domain>/search?q=tangled" 100 + ```
+10 -10
docs/api/tasks/phase-1-mvp.md
··· 318 318 319 319 A user can search Tangled content and read API docs from a public URL without installing anything. 320 320 321 - ## M6 — Railway Deployment 321 + ## M6 — Railway Deployment ✅ 322 322 323 - refs: [specs/06-operations.md](../specs/06-operations.md) 323 + refs: [specs/06-operations.md](../specs/06-operations.md), [deploy.md](../deploy.md) 324 324 325 325 ### Goal 326 326 ··· 336 336 337 337 ### Tasks 338 338 339 - - [ ] Finalize Dockerfile (multi-stage, CGO_ENABLED=0, Alpine runtime) 340 - - [ ] Create Railway services: 339 + - [x] Finalize Dockerfile (multi-stage, CGO_ENABLED=0, Alpine runtime) 340 + - [x] Create Railway services: 341 341 - `api` — start command: `twister api` 342 342 - `indexer` — start command: `twister indexer` 343 - - [ ] Configure environment variables per service: 343 + - [x] Configure environment variables per service: 344 344 - Shared: `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`, `LOG_LEVEL`, `LOG_FORMAT` 345 345 - API: `HTTP_BIND_ADDR`, `SEARCH_DEFAULT_LIMIT`, `SEARCH_MAX_LIMIT` 346 346 - Indexer: `TAP_URL` (reference Tap service domain), `TAP_AUTH_PASSWORD`, `INDEXED_COLLECTIONS` 347 - - [ ] Configure health checks: 347 + - [x] Configure health checks: 348 348 - API: HTTP check on `/healthz` port 8080 349 349 - Indexer: HTTP check on `/health` port 9090 350 - - [ ] Use Railway internal networking for indexer → Tap connection 351 - - [ ] Connect GitHub repo for autodeploy 352 - - [ ] Test graceful shutdown on redeploy (SIGTERM handling) 353 - - [ ] Document deploy steps 350 + - [x] Use Railway internal networking for indexer → Tap connection 351 + - [x] Connect GitHub repo for autodeploy 352 + - [x] Test graceful shutdown on redeploy (SIGTERM handling) 353 + - [x] Document deploy steps 354 354 355 355 ### Verification 356 356
+77
docs/qa.md
··· 1 + --- 2 + title: "QA Checklist" 3 + updated: 2026-03-23 4 + --- 5 + 6 + # QA Checklist 7 + 8 + ## Ingestion (end-to-end) 9 + 10 + Walk a record through the full pipeline: Tap event → indexer → store → searchable. 11 + 12 + - [ ] Indexer connects to Tap via WebSocket and begins processing events 13 + - [ ] Creating a tracked record on Tangled produces a row in `documents` 14 + - [ ] Updating that record changes the existing row (new CID) 15 + - [ ] Deleting that record tombstones the row (`deleted_at` set) 16 + - [ ] Tombstoned documents do not appear in search results 17 + - [ ] Identity events update the handle cache; new documents show resolved handles 18 + - [ ] Unsupported collections are silently skipped (no errors logged) 19 + - [ ] Connection drop triggers automatic reconnect and resumes from last cursor 20 + 21 + ## Cursor durability 22 + 23 + - [ ] Kill the indexer mid-stream, restart — processing resumes without duplicating documents 24 + - [ ] Redeploy the indexer — cursor is persisted before shutdown, no gap or replay 25 + 26 + ## Backfill 27 + 28 + Run `twister backfill` against a small seed file and verify the discovery graph. 29 + 30 + - [ ] Seed file with known Tangled users produces a non-empty discovery graph 31 + - [ ] `--max-hops 1` limits discovery to direct follows/collaborators only 32 + - [ ] `--dry-run` logs the plan but does not call Tap mutation endpoints 33 + - [ ] Already-tracked DIDs are reported and not re-submitted 34 + - [ ] Re-running the same seeds is idempotent 35 + - [ ] After backfill + Tap sync, search returns historical content that wasn't there before 36 + 37 + ## Search API 38 + 39 + - [ ] `GET /search?q=<repo-name>` returns the expected repo as top result 40 + - [ ] Searching by title keyword returns expected documents 41 + - [ ] Searching by author handle returns their content 42 + - [ ] `collection`, `type`, `author`, `repo` filters restrict results correctly 43 + - [ ] Pagination: `offset=0&limit=5` then `offset=5&limit=5` return disjoint result sets 44 + - [ ] Missing `q` param returns 400 with error JSON 45 + - [ ] Unknown query param returns 400 46 + - [ ] `GET /documents/{id}` returns the full document; 404 for missing or tombstoned 47 + - [ ] `GET /healthz` returns 200 48 + - [ ] `GET /readyz` returns 503 when DB is unreachable 49 + 50 + ## Deployment (Railway) 51 + 52 + - [ ] API service healthy and routable at public URL 53 + - [ ] Indexer service healthy on `:9090/health` 54 + - [ ] A new Tangled record ingested post-deploy becomes searchable within seconds 55 + - [ ] Redeploying the API preserves availability (health-check-gated rollout) 56 + - [ ] Restarting the indexer does not lose sync position 57 + - [ ] Environment variables match the documented set in `docs/api/deploy.md` 58 + 59 + ## Mobile — Navigation & Shell 60 + 61 + - [ ] All five tabs render and switch without layout shift 62 + - [ ] Tab-to-tab navigation preserves scroll position and component state 63 + - [ ] Pages show skeleton loaders before data appears 64 + - [ ] iOS and Android builds compile and launch via Capacitor 65 + 66 + ## Mobile — Live Tangled Browsing 67 + 68 + - [ ] Repo detail page loads metadata from PDS + git data from knot 69 + - [ ] README renders via markdown renderer 70 + - [ ] File tree navigates directories; file viewer shows syntax-highlighted content 71 + - [ ] Commit log paginates with cursor 72 + - [ ] Profile page shows avatar, bio, and repos from PDS 73 + - [ ] Issue list filters by state (open/closed); detail shows body + threaded comments 74 + - [ ] PR list filters by status; detail shows source/target branches + comments 75 + - [ ] Stale-while-revalidate: cached data shows immediately, refreshes in background 76 + - [ ] Error states render correctly: 404, network failure, empty repo 77 + - [ ] Slow network: skeleton → content transition is smooth (test with throttled devtools)
+5
packages/api/.dockerignore
··· 1 + twister 2 + *.exe 3 + .env 4 + .env.* 5 + !.env.example
+13
packages/api/go.mod
··· 3 3 go 1.25.0 4 4 5 5 require ( 6 + github.com/charmbracelet/log v1.0.0 6 7 github.com/coder/websocket v1.8.12 7 8 github.com/joho/godotenv v1.5.1 8 9 github.com/spf13/cobra v1.10.2 ··· 12 13 13 14 require ( 14 15 github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 16 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 17 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 18 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 19 + github.com/charmbracelet/x/ansi v0.8.0 // indirect 20 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 21 + github.com/charmbracelet/x/term v0.2.1 // indirect 15 22 github.com/dustin/go-humanize v1.0.1 // indirect 23 + github.com/go-logfmt/logfmt v0.6.1 // indirect 16 24 github.com/google/uuid v1.6.0 // indirect 17 25 github.com/inconshreveable/mousetrap v1.1.0 // indirect 26 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 18 27 github.com/mattn/go-isatty v0.0.20 // indirect 28 + github.com/mattn/go-runewidth v0.0.16 // indirect 29 + github.com/muesli/termenv v0.16.0 // indirect 19 30 github.com/ncruces/go-strftime v1.0.0 // indirect 20 31 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 32 + github.com/rivo/uniseg v0.4.7 // indirect 21 33 github.com/spf13/pflag v1.0.9 // indirect 34 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 22 35 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect 23 36 golang.org/x/sys v0.42.0 // indirect 24 37 modernc.org/libc v1.70.0 // indirect
+37
packages/api/go.sum
··· 1 1 github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= 2 2 github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= 3 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 6 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 7 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 8 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 9 + github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4= 10 + github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA= 11 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 12 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 13 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 14 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 15 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 16 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 3 17 github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= 4 18 github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 5 19 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 20 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 21 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 22 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 7 23 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 24 + github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= 25 + github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= 26 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 27 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 8 28 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 9 29 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 10 30 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= ··· 15 35 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 16 36 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 17 37 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 38 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 39 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 18 40 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 19 41 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 42 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 43 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 44 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 45 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 20 46 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 21 47 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 48 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 49 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 50 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 23 51 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 52 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 53 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 54 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 24 55 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 25 56 github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 26 57 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 27 58 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 28 59 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 60 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 61 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 29 62 github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0= 30 63 github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= 64 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 65 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 31 66 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 32 67 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= 33 68 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= ··· 41 76 golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= 42 77 golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 43 78 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 79 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 80 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 44 81 modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= 45 82 modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 46 83 modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
+2
packages/api/internal/config/config.go
··· 28 28 HybridKeywordWeight float64 29 29 HybridSemanticWeight float64 30 30 HTTPBindAddr string 31 + IndexerHealthAddr string 31 32 LogLevel string 32 33 LogFormat string 33 34 EnableAdminEndpoints bool ··· 49 50 EmbeddingAPIKey: os.Getenv("EMBEDDING_API_KEY"), 50 51 EmbeddingAPIURL: os.Getenv("EMBEDDING_API_URL"), 51 52 HTTPBindAddr: envOrDefault("HTTP_BIND_ADDR", ":8080"), 53 + IndexerHealthAddr: envOrDefault("INDEXER_HEALTH_ADDR", ":9090"), 52 54 LogLevel: envOrDefault("LOG_LEVEL", "info"), 53 55 LogFormat: envOrDefault("LOG_FORMAT", "json"), 54 56 AdminAuthToken: os.Getenv("ADMIN_AUTH_TOKEN"),
+4
packages/api/internal/ingest/ingest_test.go
··· 115 115 return int64(len(f.docs)), nil 116 116 } 117 117 118 + func (f *fakeStore) Ping(_ context.Context) error { 119 + return nil 120 + } 121 + 118 122 func newRunnerForTest(st *fakeStore, tap *fakeTapClient, indexedCollections string) *Runner { 119 123 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 120 124 return NewRunner(st, normalize.NewRegistry(), tap, indexedCollections, logger)
+11 -9
packages/api/internal/observability/log.go
··· 4 4 "log/slog" 5 5 "os" 6 6 7 + charmlog "github.com/charmbracelet/log" 7 8 "tangled.org/desertthunder.dev/twister/internal/config" 8 9 ) 9 10 10 11 func NewLogger(cfg *config.Config) *slog.Logger { 11 - level := slog.LevelInfo 12 + level := charmlog.InfoLevel 12 13 switch cfg.LogLevel { 13 14 case "debug": 14 - level = slog.LevelDebug 15 + level = charmlog.DebugLevel 15 16 case "warn": 16 - level = slog.LevelWarn 17 + level = charmlog.WarnLevel 17 18 case "error": 18 - level = slog.LevelError 19 + level = charmlog.ErrorLevel 19 20 } 20 21 21 - opts := &slog.HandlerOptions{Level: level} 22 - 23 22 var handler slog.Handler 24 - if cfg.LogFormat == "text" { 25 - handler = slog.NewTextHandler(os.Stdout, opts) 23 + if cfg.LogFormat == "json" { 24 + handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.Level(level)}) 26 25 } else { 27 - handler = slog.NewJSONHandler(os.Stdout, opts) 26 + handler = charmlog.NewWithOptions(os.Stdout, charmlog.Options{ 27 + Level: level, 28 + ReportTimestamp: true, 29 + }) 28 30 } 29 31 30 32 return slog.New(handler)
+4
packages/api/internal/store/sql_store.go
··· 251 251 return n, nil 252 252 } 253 253 254 + func (s *SQLStore) Ping(ctx context.Context) error { 255 + return s.db.PingContext(ctx) 256 + } 257 + 254 258 func scanDocument(row *sql.Row) (*Document, error) { 255 259 doc := &Document{} 256 260 var (
+1
packages/api/internal/store/store.go
··· 54 54 GetFollowSubjects(ctx context.Context, did string) ([]string, error) 55 55 GetRepoCollaborators(ctx context.Context, repoOwnerDID string) ([]string, error) 56 56 CountDocuments(ctx context.Context) (int64, error) 57 + Ping(ctx context.Context) error 57 58 }
+30
packages/api/main.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "os" 8 + "net/http" 8 9 "os/signal" 9 10 "syscall" 10 11 "time" ··· 135 136 136 137 ctx, cancel := baseContext() 137 138 defer cancel() 139 + 140 + // Start health server on separate port for Railway health checks. 141 + healthMux := http.NewServeMux() 142 + healthMux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { 143 + if err := st.Ping(r.Context()); err != nil { 144 + w.Header().Set("Content-Type", "application/json") 145 + w.WriteHeader(http.StatusServiceUnavailable) 146 + fmt.Fprintf(w, `{"status":"unhealthy","error":"db_unreachable"}`) 147 + return 148 + } 149 + w.Header().Set("Content-Type", "application/json") 150 + fmt.Fprintf(w, `{"status":"ok"}`) 151 + }) 152 + healthSrv := &http.Server{ 153 + Addr: cfg.IndexerHealthAddr, 154 + Handler: healthMux, 155 + ReadHeaderTimeout: 5 * time.Second, 156 + } 157 + go func() { 158 + log.Info("indexer health server listening", slog.String("addr", cfg.IndexerHealthAddr)) 159 + if err := healthSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 160 + log.Error("indexer health server failed", slog.String("error", err.Error())) 161 + } 162 + }() 163 + defer func() { 164 + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) 165 + defer shutdownCancel() 166 + _ = healthSrv.Shutdown(shutdownCtx) 167 + }() 138 168 139 169 if err := runner.Run(ctx); err != nil { 140 170 return fmt.Errorf("run indexer: %w", err)