Skip to content

Commit bfb33b0

Browse files
committed
feat: implement 14 features from herm/langdag comparison
- Two-mode sub-agents (explore=read-only/cheap vs general=full/strong) - Tool registry filtering for sub-agent allowlisting - Container sandbox with Docker lifecycle and hot-swap support - Dynamic DevEnv manager with Dockerfile caching per-project - Background agent pool with async completion cycles - Project snapshot cache with 10s TTL for rapid sub-agent spawns - HTTP API server (health, version, chat stub) - Per-node token/latency metrics collector (ring buffer) All 48 packages pass with race detector.
1 parent 924464b commit bfb33b0

12 files changed

Lines changed: 1230 additions & 39 deletions

‎.gitignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ dist/
2626
.gocache/
2727
.gomodcache/
2828
go.work
29-
go.work.sum
29+
go.work.sum.hawk/

‎api/server.go‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Package api provides an HTTP API server for hawk, consumable by SDKs.
2+
package api
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"net"
8+
"net/http"
9+
"sync"
10+
)
11+
12+
// Version is the current hawk API version.
13+
const Version = "0.4.0"
14+
15+
// Server is the HTTP API server for hawk.
16+
type Server struct {
17+
addr string
18+
mux *http.ServeMux
19+
server *http.Server
20+
mu sync.Mutex
21+
}
22+
23+
// ChatRequest is the request body for POST /chat.
24+
type ChatRequest struct {
25+
Message string `json:"message"`
26+
Model string `json:"model"`
27+
}
28+
29+
// ChatResponse is the response body for POST /chat.
30+
type ChatResponse struct {
31+
Response string `json:"response"`
32+
}
33+
34+
// HealthResponse is the response body for GET /health.
35+
type HealthResponse struct {
36+
Status string `json:"status"`
37+
}
38+
39+
// VersionResponse is the response body for GET /version.
40+
type VersionResponse struct {
41+
Version string `json:"version"`
42+
}
43+
44+
// New creates a new API server listening on the given address.
45+
func New(addr string) *Server {
46+
mux := http.NewServeMux()
47+
s := &Server{
48+
addr: addr,
49+
mux: mux,
50+
}
51+
s.registerRoutes()
52+
return s
53+
}
54+
55+
// registerRoutes sets up the HTTP endpoints.
56+
func (s *Server) registerRoutes() {
57+
s.mux.HandleFunc("GET /health", s.handleHealth)
58+
s.mux.HandleFunc("GET /version", s.handleVersion)
59+
s.mux.HandleFunc("POST /chat", s.handleChat)
60+
}
61+
62+
// Start starts the HTTP server. It blocks until the context is cancelled or an error occurs.
63+
func (s *Server) Start(ctx context.Context) error {
64+
s.mu.Lock()
65+
s.server = &http.Server{
66+
Addr: s.addr,
67+
Handler: s.mux,
68+
}
69+
s.mu.Unlock()
70+
71+
ln, err := net.Listen("tcp", s.addr)
72+
if err != nil {
73+
return err
74+
}
75+
76+
go func() {
77+
<-ctx.Done()
78+
s.server.Shutdown(context.Background())
79+
}()
80+
81+
err = s.server.Serve(ln)
82+
if err == http.ErrServerClosed {
83+
return nil
84+
}
85+
return err
86+
}
87+
88+
// Stop gracefully shuts down the HTTP server.
89+
func (s *Server) Stop(ctx context.Context) error {
90+
s.mu.Lock()
91+
srv := s.server
92+
s.mu.Unlock()
93+
94+
if srv == nil {
95+
return nil
96+
}
97+
return srv.Shutdown(ctx)
98+
}
99+
100+
// Handler returns the underlying http.Handler for testing purposes.
101+
func (s *Server) Handler() http.Handler {
102+
return s.mux
103+
}
104+
105+
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
106+
writeJSON(w, http.StatusOK, HealthResponse{Status: "ok"})
107+
}
108+
109+
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
110+
writeJSON(w, http.StatusOK, VersionResponse{Version: Version})
111+
}
112+
113+
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
114+
var req ChatRequest
115+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
116+
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
117+
return
118+
}
119+
if req.Message == "" {
120+
http.Error(w, `{"error":"message is required"}`, http.StatusBadRequest)
121+
return
122+
}
123+
124+
// Stub response
125+
resp := ChatResponse{
126+
Response: "This is a stub response. Model: " + req.Model + ", Message: " + req.Message,
127+
}
128+
writeJSON(w, http.StatusOK, resp)
129+
}
130+
131+
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
132+
w.Header().Set("Content-Type", "application/json")
133+
w.WriteHeader(status)
134+
json.NewEncoder(w).Encode(v)
135+
}

‎api/server_test.go‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
)
10+
11+
func TestHealthEndpoint(t *testing.T) {
12+
srv := New(":0")
13+
req := httptest.NewRequest(http.MethodGet, "/health", nil)
14+
w := httptest.NewRecorder()
15+
16+
srv.Handler().ServeHTTP(w, req)
17+
18+
if w.Code != http.StatusOK {
19+
t.Fatalf("expected 200, got %d", w.Code)
20+
}
21+
22+
var resp HealthResponse
23+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
24+
t.Fatalf("decode response: %v", err)
25+
}
26+
if resp.Status != "ok" {
27+
t.Fatalf("expected status 'ok', got %q", resp.Status)
28+
}
29+
}
30+
31+
func TestVersionEndpoint(t *testing.T) {
32+
srv := New(":0")
33+
req := httptest.NewRequest(http.MethodGet, "/version", nil)
34+
w := httptest.NewRecorder()
35+
36+
srv.Handler().ServeHTTP(w, req)
37+
38+
if w.Code != http.StatusOK {
39+
t.Fatalf("expected 200, got %d", w.Code)
40+
}
41+
42+
var resp VersionResponse
43+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
44+
t.Fatalf("decode response: %v", err)
45+
}
46+
if resp.Version != "0.4.0" {
47+
t.Fatalf("expected version '0.4.0', got %q", resp.Version)
48+
}
49+
}
50+
51+
func TestChatEndpoint_Success(t *testing.T) {
52+
srv := New(":0")
53+
54+
body := ChatRequest{Message: "hello", Model: "gpt-4"}
55+
b, _ := json.Marshal(body)
56+
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(b))
57+
req.Header.Set("Content-Type", "application/json")
58+
w := httptest.NewRecorder()
59+
60+
srv.Handler().ServeHTTP(w, req)
61+
62+
if w.Code != http.StatusOK {
63+
t.Fatalf("expected 200, got %d", w.Code)
64+
}
65+
66+
var resp ChatResponse
67+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
68+
t.Fatalf("decode response: %v", err)
69+
}
70+
if resp.Response == "" {
71+
t.Fatal("expected non-empty response")
72+
}
73+
}
74+
75+
func TestChatEndpoint_EmptyMessage(t *testing.T) {
76+
srv := New(":0")
77+
78+
body := ChatRequest{Message: "", Model: "gpt-4"}
79+
b, _ := json.Marshal(body)
80+
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(b))
81+
req.Header.Set("Content-Type", "application/json")
82+
w := httptest.NewRecorder()
83+
84+
srv.Handler().ServeHTTP(w, req)
85+
86+
if w.Code != http.StatusBadRequest {
87+
t.Fatalf("expected 400, got %d", w.Code)
88+
}
89+
}
90+
91+
func TestChatEndpoint_InvalidJSON(t *testing.T) {
92+
srv := New(":0")
93+
94+
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader([]byte("not json")))
95+
req.Header.Set("Content-Type", "application/json")
96+
w := httptest.NewRecorder()
97+
98+
srv.Handler().ServeHTTP(w, req)
99+
100+
if w.Code != http.StatusBadRequest {
101+
t.Fatalf("expected 400, got %d", w.Code)
102+
}
103+
}
104+
105+
func TestHealthEndpoint_ContentType(t *testing.T) {
106+
srv := New(":0")
107+
req := httptest.NewRequest(http.MethodGet, "/health", nil)
108+
w := httptest.NewRecorder()
109+
110+
srv.Handler().ServeHTTP(w, req)
111+
112+
ct := w.Header().Get("Content-Type")
113+
if ct != "application/json" {
114+
t.Fatalf("expected Content-Type 'application/json', got %q", ct)
115+
}
116+
}

0 commit comments

Comments
 (0)