feat: add sqlite-backed API docs and tests
All checks were successful
ci / test-and-build (push) Successful in 11m21s

This commit is contained in:
Steve W
2026-04-11 18:42:05 +00:00
parent 87efe766a2
commit f0275c41d0
9 changed files with 304 additions and 5 deletions

18
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: ci
on:
push:
branches: [main]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24.2'
- name: Test
run: go test ./...
- name: Build Docker image
run: docker build -t git.danhenry.dev/thelab/work-queue-api:latest .

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM golang:1.24 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o /out/work-queue-api ./cmd/work-queue-api
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/work-queue-api /usr/local/bin/work-queue-api
EXPOSE 8080
ENV PORT=8080
ENV DATABASE_URL=/data/work_queue.db
CMD ["/usr/local/bin/work-queue-api"]

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
# work-queue-api
Lightweight internal work queue API for TheLab agents.
## Features
- Go HTTP API with chi router
- SQLite storage, auto-migrated on startup
- Projects and work items endpoints from `SPEC.md`
- Dispatch history tracking
- Enforces one `in_progress` item per agent
- No auth, binds to port `8080` by default
## Run locally
```bash
export DATABASE_URL=./work_queue.db
export PORT=8080
~/.local/go/bin/go run ./cmd/work-queue-api
```
## Build
```bash
~/.local/go/bin/go build ./cmd/work-queue-api
```
## Endpoints
- `GET /health`
- `POST/GET/PATCH /projects`
- `POST/GET/PATCH/DELETE /work`
## Notes
- `POST /work` accepts `assigned_agent`, but still creates the item in `queued`
- `PATCH /work/:id` enforces status transitions from the spec
- `DELETE /work/:id` cancels queued or dispatched work items

18
ci.yml Normal file
View File

@@ -0,0 +1,18 @@
name: ci
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24.2'
- name: Test
run: go test ./...
- name: Build image
run: docker build -t git.danhenry.dev/thelab/work-queue-api:latest .

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
version: '3.8'
services:
work-queue-api:
build: .
image: git.danhenry.dev/thelab/work-queue-api:latest
ports:
- "8080:8080"
environment:
DATABASE_URL: /data/work_queue.db
PORT: 8080
volumes:
- ./data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3

14
go.mod
View File

@@ -5,5 +5,17 @@ go 1.24.0
require ( require (
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.32 modernc.org/sqlite v1.39.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )

49
go.sum
View File

@@ -1,6 +1,51 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

140
internal/api/server_test.go Normal file
View File

@@ -0,0 +1,140 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"work-queue-api/internal/db"
)
func newTestServer(t *testing.T) (*Server, *sql.DB) {
t.Helper()
sqliteDB, err := db.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("open db: %v", err)
}
server, err := NewServer(sqliteDB)
if err != nil {
t.Fatalf("new server: %v", err)
}
return server, sqliteDB
}
func doJSON(t *testing.T, h http.Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var payload []byte
if body != nil {
var err error
payload, err = json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
}
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
res := httptest.NewRecorder()
h.ServeHTTP(res, req)
return res
}
func TestProjectAndWorkLifecycle(t *testing.T) {
server, sqliteDB := newTestServer(t)
defer sqliteDB.Close()
router := server.Router()
projectRes := doJSON(t, router, http.MethodPost, "/projects", map[string]any{
"name": "Shopping List API",
"external_ref": "todoist:123",
})
if projectRes.Code != http.StatusCreated {
t.Fatalf("create project status = %d body=%s", projectRes.Code, projectRes.Body.String())
}
var project map[string]any
if err := json.Unmarshal(projectRes.Body.Bytes(), &project); err != nil {
t.Fatalf("decode project: %v", err)
}
workRes := doJSON(t, router, http.MethodPost, "/work", map[string]any{
"project_id": project["id"],
"type": "code_review",
"description": "Review PR #3",
"payload": map[string]any{"pr": 3},
"priority": 2,
"assigned_agent": "steve-w",
"created_by": "marcus-a",
})
if workRes.Code != http.StatusCreated {
t.Fatalf("create work status = %d body=%s", workRes.Code, workRes.Body.String())
}
var work map[string]any
if err := json.Unmarshal(workRes.Body.Bytes(), &work); err != nil {
t.Fatalf("decode work: %v", err)
}
for _, step := range []map[string]any{
{"status": "dispatched", "assigned_agent": "steve-w"},
{"status": "in_progress"},
{"status": "completed", "outcome": "success", "notes": "done"},
} {
res := doJSON(t, router, http.MethodPatch, "/work/"+work["id"].(string), step)
if res.Code != http.StatusOK {
t.Fatalf("patch work step=%v status = %d body=%s", step, res.Code, res.Body.String())
}
}
getRes := doJSON(t, router, http.MethodGet, "/work/"+work["id"].(string), nil)
if getRes.Code != http.StatusOK {
t.Fatalf("get work status = %d body=%s", getRes.Code, getRes.Body.String())
}
var fetched map[string]any
if err := json.Unmarshal(getRes.Body.Bytes(), &fetched); err != nil {
t.Fatalf("decode fetched work: %v", err)
}
if fetched["status"] != "completed" {
t.Fatalf("expected completed status, got %v", fetched["status"])
}
logs, ok := fetched["dispatch_log"].([]any)
if !ok || len(logs) != 1 {
t.Fatalf("expected 1 dispatch log, got %v", fetched["dispatch_log"])
}
}
func TestOneInProgressPerAgent(t *testing.T) {
server, sqliteDB := newTestServer(t)
defer sqliteDB.Close()
router := server.Router()
create := func(desc string) string {
res := doJSON(t, router, http.MethodPost, "/work", map[string]any{
"type": "bug_fix",
"description": desc,
"assigned_agent": "steve-w",
})
if res.Code != http.StatusCreated {
t.Fatalf("create work status = %d body=%s", res.Code, res.Body.String())
}
var body map[string]any
_ = json.Unmarshal(res.Body.Bytes(), &body)
return body["id"].(string)
}
first := create("first")
second := create("second")
for _, id := range []string{first, second} {
res := doJSON(t, router, http.MethodPatch, "/work/"+id, map[string]any{"status": "dispatched", "assigned_agent": "steve-w"})
if res.Code != http.StatusOK {
t.Fatalf("dispatch status = %d body=%s", res.Code, res.Body.String())
}
}
res := doJSON(t, router, http.MethodPatch, "/work/"+first, map[string]any{"status": "in_progress"})
if res.Code != http.StatusOK {
t.Fatalf("first in progress status = %d body=%s", res.Code, res.Body.String())
}
res = doJSON(t, router, http.MethodPatch, "/work/"+second, map[string]any{"status": "in_progress"})
if res.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d body=%s", res.Code, res.Body.String())
}
}

View File

@@ -7,7 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
_ "github.com/mattn/go-sqlite3" _ "modernc.org/sqlite"
) )
//go:embed migrations/001_initial.sql //go:embed migrations/001_initial.sql
@@ -18,7 +18,7 @@ func Open(databaseURL string) (*sql.DB, error) {
return nil, fmt.Errorf("mkdir db dir: %w", err) return nil, fmt.Errorf("mkdir db dir: %w", err)
} }
db, err := sql.Open("sqlite3", databaseURL+"?_foreign_keys=on&_busy_timeout=5000") db, err := sql.Open("sqlite", databaseURL+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil { if err != nil {
return nil, err return nil, err
} }