Add web product Phase 1 skeleton
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"ai-workflow-skill/internal/store"
|
||||
)
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error errorPayload `json:"error"`
|
||||
}
|
||||
|
||||
type errorPayload struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
status, code := classifyError(err)
|
||||
writeJSON(w, status, errorEnvelope{
|
||||
Error: errorPayload{
|
||||
Code: code,
|
||||
Message: errorMessage(err),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func classifyError(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrInvalidInput):
|
||||
return http.StatusBadRequest, "invalid_input"
|
||||
case errors.Is(err, store.ErrRunNotFound), errors.Is(err, store.ErrTaskNotFound), errors.Is(err, store.ErrThreadNotFound):
|
||||
return http.StatusNotFound, "not_found"
|
||||
case errors.Is(err, store.ErrInvalidState):
|
||||
return http.StatusConflict, "invalid_state"
|
||||
default:
|
||||
return http.StatusInternalServerError, "internal_error"
|
||||
}
|
||||
}
|
||||
|
||||
func errorMessage(err error) string {
|
||||
if err == nil {
|
||||
return "unknown error"
|
||||
}
|
||||
return fmt.Sprintf("%v", err)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"ai-workflow-skill/internal/query"
|
||||
"ai-workflow-skill/internal/store"
|
||||
)
|
||||
|
||||
type readService interface {
|
||||
ListRuns(ctx context.Context) ([]query.RunListItem, error)
|
||||
GetRunDetail(ctx context.Context, runID string) (query.RunDetail, error)
|
||||
ListRunTasks(ctx context.Context, runID string) ([]store.Task, error)
|
||||
ListBlockedTasks(ctx context.Context, runID string) ([]store.BlockedTask, error)
|
||||
GetThreadDetail(ctx context.Context, threadID string) (store.ThreadDetail, error)
|
||||
}
|
||||
|
||||
func NewRouter(service readService) http.Handler {
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestID)
|
||||
router.Use(middleware.Recoverer)
|
||||
router.Use(middleware.Timeout(30 * time.Second))
|
||||
|
||||
router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
})
|
||||
})
|
||||
|
||||
router.Route("/api", func(r chi.Router) {
|
||||
r.Get("/runs", func(w http.ResponseWriter, r *http.Request) {
|
||||
runs, err := service.ListRuns(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
|
||||
})
|
||||
|
||||
r.Get("/runs/{runID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
runID := chi.URLParam(r, "runID")
|
||||
run, err := service.GetRunDetail(r.Context(), runID)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"run": run})
|
||||
})
|
||||
|
||||
r.Get("/runs/{runID}/tasks", func(w http.ResponseWriter, r *http.Request) {
|
||||
runID := chi.URLParam(r, "runID")
|
||||
tasks, err := service.ListRunTasks(r.Context(), runID)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
|
||||
})
|
||||
|
||||
r.Get("/runs/{runID}/blocked", func(w http.ResponseWriter, r *http.Request) {
|
||||
runID := chi.URLParam(r, "runID")
|
||||
blocked, err := service.ListBlockedTasks(r.Context(), runID)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"blocked": blocked})
|
||||
})
|
||||
|
||||
r.Get("/threads/{threadID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
threadID := chi.URLParam(r, "threadID")
|
||||
thread, err := service.GetThreadDetail(r.Context(), threadID)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"thread": thread})
|
||||
})
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ai-workflow-skill/internal/app"
|
||||
dbpkg "ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/store"
|
||||
)
|
||||
|
||||
func TestRouterExposesReadOnlyWebEndpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
sqlDB, err := dbpkg.Open(ctx, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := dbpkg.ApplyMigrations(ctx, sqlDB); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
orchStore := store.NewOrchStore(sqlDB)
|
||||
inboxStore := store.NewInboxStore(sqlDB)
|
||||
|
||||
_, err = orchStore.CreateRun(ctx, store.CreateRunInput{
|
||||
RunID: "run_web_001",
|
||||
Goal: "Build the web control plane",
|
||||
Summary: "Initial HTTP slice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
|
||||
_, err = orchStore.AddTask(ctx, store.AddTaskInput{
|
||||
RunID: "run_web_001",
|
||||
TaskID: "T1",
|
||||
Title: "Implement read API",
|
||||
Summary: "Expose run state over HTTP",
|
||||
DefaultTo: "worker-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("add task T1: %v", err)
|
||||
}
|
||||
|
||||
_, err = orchStore.AddTask(ctx, store.AddTaskInput{
|
||||
RunID: "run_web_001",
|
||||
TaskID: "T2",
|
||||
Title: "Build React shell",
|
||||
Summary: "Scaffold the frontend workspace",
|
||||
DefaultTo: "worker-b",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("add task T2: %v", err)
|
||||
}
|
||||
|
||||
dispatch, err := orchStore.DispatchTask(ctx, store.DispatchInput{
|
||||
RunID: "run_web_001",
|
||||
TaskID: "T1",
|
||||
ToAgent: "worker-a",
|
||||
Body: "Expose the initial HTTP API.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch task: %v", err)
|
||||
}
|
||||
|
||||
if _, err := inboxStore.ClaimThread(ctx, store.ClaimInput{
|
||||
ThreadID: dispatch.Attempt.ThreadID,
|
||||
Agent: "worker-a",
|
||||
LeaseSeconds: 300,
|
||||
}); err != nil {
|
||||
t.Fatalf("claim thread: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := inboxStore.UpdateThreadStatus(ctx, store.UpdateInput{
|
||||
ThreadID: dispatch.Attempt.ThreadID,
|
||||
Agent: "worker-a",
|
||||
Status: "blocked",
|
||||
Summary: "Need the API shape",
|
||||
Body: "Confirm whether run detail should include blocked tasks.",
|
||||
}); err != nil {
|
||||
t.Fatalf("mark thread blocked: %v", err)
|
||||
}
|
||||
|
||||
if _, err := orchStore.ReconcileRun(ctx, "run_web_001"); err != nil {
|
||||
t.Fatalf("reconcile run: %v", err)
|
||||
}
|
||||
|
||||
handler := NewRouter(app.NewWebService(sqlDB))
|
||||
|
||||
assertStatusAndJSONField(t, handler, "/health", http.StatusOK, []string{"status"}, "ok")
|
||||
assertStatusAndJSONField(t, handler, "/api/runs", http.StatusOK, []string{"runs", "0", "run", "run_id"}, "run_web_001")
|
||||
assertStatusAndJSONField(t, handler, "/api/runs/run_web_001", http.StatusOK, []string{"run", "run", "run_id"}, "run_web_001")
|
||||
assertStatusAndJSONField(t, handler, "/api/runs/run_web_001/tasks", http.StatusOK, []string{"tasks", "0", "task_id"}, "T1")
|
||||
assertStatusAndJSONField(t, handler, "/api/runs/run_web_001/blocked", http.StatusOK, []string{"blocked", "0", "task", "task_id"}, "T1")
|
||||
assertStatusAndJSONField(t, handler, "/api/threads/"+dispatch.Attempt.ThreadID, http.StatusOK, []string{"thread", "thread", "thread_id"}, dispatch.Attempt.ThreadID)
|
||||
}
|
||||
|
||||
func TestRouterMapsNotFoundErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
sqlDB, err := dbpkg.Open(ctx, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := dbpkg.ApplyMigrations(ctx, sqlDB); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
handler := NewRouter(app.NewWebService(sqlDB))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/runs/missing-run", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
code := nestedString(t, payload, "error", "code")
|
||||
if code != "not_found" {
|
||||
t.Fatalf("expected not_found error code, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatusAndJSONField(t *testing.T, handler http.Handler, path string, wantStatus int, fieldPath []string, want string) {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != wantStatus {
|
||||
t.Fatalf("GET %s: expected status %d, got %d", path, wantStatus, rec.Code)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("GET %s: decode response: %v", path, err)
|
||||
}
|
||||
|
||||
got := nestedString(t, payload, fieldPath...)
|
||||
if got != want {
|
||||
t.Fatalf("GET %s: expected %q at %v, got %q", path, want, fieldPath, got)
|
||||
}
|
||||
}
|
||||
|
||||
func nestedString(t *testing.T, value any, path ...string) string {
|
||||
t.Helper()
|
||||
|
||||
current := value
|
||||
for _, part := range path {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
current = typed[part]
|
||||
case []any:
|
||||
if len(part) != 1 || part[0] < '0' || part[0] > '9' {
|
||||
t.Fatalf("path segment %q is not a numeric index", part)
|
||||
}
|
||||
index := int(part[0] - '0')
|
||||
if index >= len(typed) {
|
||||
t.Fatalf("index %d out of range for path %v", index, path)
|
||||
}
|
||||
current = typed[index]
|
||||
default:
|
||||
t.Fatalf("unsupported type %T at path %v", current, path)
|
||||
}
|
||||
}
|
||||
|
||||
got, ok := current.(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected string at path %v, got %T", path, current)
|
||||
}
|
||||
return got
|
||||
}
|
||||
Reference in New Issue
Block a user