diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..85d4cef --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 . diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..06316ac --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..463b55f --- /dev/null +++ b/README.md @@ -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 diff --git a/ci.yml b/ci.yml new file mode 100644 index 0000000..6360da4 --- /dev/null +++ b/ci.yml @@ -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 . diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1165ae0 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod index 0d3d140..d50d382 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,17 @@ go 1.24.0 require ( github.com/go-chi/chi/v5 v5.2.3 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 ) diff --git a/go.sum b/go.sum index df4eb1f..ece5c57 100644 --- a/go.sum +++ b/go.sum @@ -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/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +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= diff --git a/internal/api/server_test.go b/internal/api/server_test.go new file mode 100644 index 0000000..1373da4 --- /dev/null +++ b/internal/api/server_test.go @@ -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()) + } +} diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 98d4d6b..c63261f 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -7,7 +7,7 @@ import ( "os" "path/filepath" - _ "github.com/mattn/go-sqlite3" + _ "modernc.org/sqlite" ) //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) } - 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 { return nil, err }