Files
byre/internal/server/server.go
T
2026-10-18 22:17:54 -07:00

925 lines
22 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"g.fc.run/ai/byre/internal/agents"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/clients"
"g.fc.run/ai/byre/internal/git"
"g.fc.run/ai/byre/internal/store"
"g.fc.run/ai/byre/internal/terminal"
"g.fc.run/ai/byre/internal/todo"
"g.fc.run/ai/byre/internal/workflow"
)
type Server struct {
store *store.Store
agents *agents.Manager
hub *hub
clients *clients.Registry
terminals *terminal.Terminals
startedAt time.Time
usersMu sync.Mutex
users map[string]string
}
type ctxKey struct{}
func New(dataDir string, portFirst int) (*Server, error) {
err := os.MkdirAll(dataDir, 0o755)
if err != nil {
return nil, err
}
s := &Server{
hub: newHub(),
terminals: terminal.New(),
startedAt: time.Now().UTC(),
users: map[string]string{},
}
s.clients = clients.New(s.clientsChanged)
s.store, err = store.Open(filepath.Join(dataDir, "byre.db"), portFirst, s.publish)
if err != nil {
return nil, err
}
projects, err := s.store.ListProjects(context.Background())
if err != nil {
s.store.Close()
return nil, err
}
for _, p := range projects {
err = git.ConfigureNotesFetch(p.RepoPath)
if err != nil {
slog.Error("configure notes fetch", "project", p.Id, "repo", p.RepoPath, "err", err)
}
}
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "hands"), filepath.Join(dataDir, "codex"), filepath.Join(dataDir, "antigravity"), s.status, s.createProject, s.clients, s.terminals)
err = s.agents.Recover(context.Background())
if err != nil {
s.store.Close()
return nil, fmt.Errorf("recover sessions: %w", err)
}
go s.agents.Watch()
return s, nil
}
func (s *Server) publish(events []api.Event) {
for _, ev := range events {
s.hub.publish(ev)
}
s.agents.Observe(events)
for _, ev := range events {
if (ev.Kind == "task.merged" || ev.Kind == "task.discarded") && ev.TaskId != nil {
s.terminals.Close(*ev.TaskId)
go s.agents.Unexpose(context.Background(), *ev.TaskId)
}
}
}
func (s *Server) Close() error {
s.terminals.CloseAll()
s.agents.Close()
return s.store.Close()
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
api.HandlerWithOptions(s, api.StdHTTPServerOptions{
BaseRouter: mux,
Middlewares: []api.MiddlewareFunc{s.identify},
ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, http.StatusBadRequest, err)
},
})
return mux
}
func (s *Server) identify(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
writeError(w, http.StatusForbidden, err)
return
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
writeError(w, http.StatusForbidden, fmt.Errorf("request from %s did not arrive via tailscale serve", host))
return
}
login := r.Header.Get("Tailscale-User-Login")
name := r.Header.Get("Tailscale-User-Name")
if login == "" || name == "" {
writeError(w, http.StatusUnauthorized, errors.New("missing Tailscale-User-Login or Tailscale-User-Name"))
return
}
err = s.ensureUser(r.Context(), login, name)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if r.Method != http.MethodGet && !automaticRequests[r.Pattern] {
s.acted(login, r)
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, login)))
})
}
func (s *Server) acted(login string, r *http.Request) {
if s.clients.Acted(r.Header.Get("Byre-Client"), login, r.Header.Get("Byre-Host"), r.Header.Get("Byre-Commit")) {
s.clientsChanged()
}
}
func (s *Server) connectedClients() []api.ConnectedClient {
out := []api.ConnectedClient{}
for _, c := range s.clients.List() {
out = append(out, api.ConnectedClient{
Id: c.ID,
Login: c.Login,
Host: c.Host,
Commit: c.Commit,
ConnectedAt: c.ConnectedAt,
LastActionAt: c.LastActionAt,
})
}
return out
}
func (s *Server) clientsChanged() {
s.store.Broadcast(api.Event{Kind: "admin.clients", Actor: "byre", Payload: map[string]any{}})
}
var automaticRequests = map[string]bool{
"PUT /api/me/pane-focus": true,
"PUT /api/tasks/{taskId}/summary-read": true,
}
func (s *Server) ensureUser(ctx context.Context, login, name string) error {
s.usersMu.Lock()
defer s.usersMu.Unlock()
if s.users[login] == name {
return nil
}
_, err := s.store.EnsureUser(ctx, login, name)
if err != nil {
return err
}
s.users[login] = name
return nil
}
func actor(r *http.Request) string {
return r.Context().Value(ctxKey{}).(string)
}
func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
req := api.UserUpdate{}
if !readJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.GitName) == "" || !strings.Contains(req.GitEmail, "@") {
writeError(w, http.StatusBadRequest, errors.New("git_name and a valid git_email are required"))
return
}
u, err := s.store.UpdateUserGit(r.Context(), actor(r), req.GitName, req.GitEmail)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) ListProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.ListProjects(r.Context())
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) {
req := api.ProjectCreate{}
if !readJSON(w, r, &req) {
return
}
p, err := s.createProject(r.Context(), req.Name, req.RepoPath, actor(r))
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) createProject(ctx context.Context, name, repoPath, createdBy string) (api.Project, error) {
if strings.TrimSpace(name) == "" {
return api.Project{}, fmt.Errorf("%w: name is required", agents.ErrInvalid)
}
info, err := git.Inspect(repoPath)
if err != nil {
return api.Project{}, fmt.Errorf("%w: %w", agents.ErrInvalid, err)
}
err = git.ConfigureNotesFetch(info.Root)
if err != nil {
return api.Project{}, err
}
p := api.Project{
Id: store.NewID(),
Name: name,
RepoPath: info.Root,
DefaultBranch: info.DefaultBranch,
Remote: info.Remote,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
}
err = s.store.CreateProject(ctx, p)
if err != nil {
return api.Project{}, fmt.Errorf("%w: %w", agents.ErrInvalid, err)
}
return p, nil
}
func (s *Server) GetProject(w http.ResponseWriter, r *http.Request, projectID string) {
p, err := s.store.GetProject(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) ListTasks(w http.ResponseWriter, r *http.Request, projectID string) {
_, err := s.store.GetProject(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
tasks, err := s.store.ListTasks(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID string) {
req := api.TaskCreate{}
if !readJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Title) == "" {
writeError(w, http.StatusBadRequest, errors.New("title is required"))
return
}
p, err := s.store.GetProject(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
if req.Workflow != nil {
if req.Prompt != nil {
writeError(w, http.StatusBadRequest, errors.New("prompt can't be given with workflow"))
return
}
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
tasks, err := s.agents.StartWorkflow(r.Context(), p, u, req.Title, p.DefaultBranch, u.Login, *req.Workflow, req.Title, nil)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusCreated, tasks[0])
return
}
if req.Prompt == nil {
t, err := s.agents.CreateTask(r.Context(), p, req.Title, actor(r), p.DefaultBranch, actor(r))
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusCreated, t)
return
}
if strings.TrimSpace(*req.Prompt) == "" {
writeError(w, http.StatusBadRequest, errors.New("prompt is empty"))
return
}
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
model := ""
if req.Model != nil {
model = *req.Model
}
t, err := s.agents.StartTask(r.Context(), p, u, req.Title, *req.Prompt, model)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) GetTodo(w http.ResponseWriter, r *http.Request, projectID string) {
p, err := s.store.GetProject(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
src, found, err := git.ReadFile(p.RepoPath, p.DefaultBranch, todo.File)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, api.Todo{Exists: found, Items: todo.Parse(src)})
}
func (s *Server) StartTodoItem(w http.ResponseWriter, r *http.Request, projectID string) {
req := api.TodoStart{}
if !readJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Title) == "" {
writeError(w, http.StatusBadRequest, errors.New("title is required"))
return
}
p, err := s.store.GetProject(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
model := ""
if req.Model != nil {
model = *req.Model
}
tasks, err := s.agents.StartTodo(r.Context(), p, u, req.Title, model, u.Login, "", req.Discuss != nil && *req.Discuss)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusCreated, tasks[0])
}
func (s *Server) GetTask(w http.ResponseWriter, r *http.Request, taskID string) {
t, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) GetUnmerged(w http.ResponseWriter, r *http.Request, taskID string) {
t, p, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
u, err := git.UnmergedChanges(t.WorktreePath, p.DefaultBranch)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, api.Unmerged{
Commits: u.Commits,
Files: u.Files,
Insertions: u.Insertions,
Deletions: u.Deletions,
Uncommitted: u.Uncommitted,
})
}
func (s *Server) GetCoAuthors(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
coAuthors, err := s.agents.CoAuthors(r.Context(), taskID, actor(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, coAuthors)
}
func (s *Server) MergeTask(w http.ResponseWriter, r *http.Request, taskID string) {
req := api.MergeTaskJSONRequestBody{}
if !readJSON(w, r, &req) {
return
}
t, p, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
if req.Target != nil && !req.Target.Valid() {
writeError(w, http.StatusBadRequest, fmt.Errorf("unknown target %q", *req.Target))
return
}
if req.AfterReview != nil && *req.AfterReview {
target := api.MergeTargetMain
if req.Target != nil {
target = *req.Target
}
title := ""
if target == api.MergeTargetMain {
if req.MergeTitle == nil {
writeError(w, http.StatusBadRequest, errors.New("merge_title is required to merge into the default branch"))
return
}
title = *req.MergeTitle
}
t, err = s.agents.MergeAfterReview(r.Context(), t, u, target, title)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, t)
return
}
if req.Target != nil && *req.Target == api.MergeTargetParent {
t, err = s.agents.MergeIntoParent(r.Context(), t, u)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, t)
return
}
if req.MergeTitle == nil {
writeError(w, http.StatusBadRequest, errors.New("merge_title is required to merge into the default branch"))
return
}
t, err = s.agents.Merge(r.Context(), t, p, u, *req.MergeTitle)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, t)
}
func (s *Server) CancelMergeAfterReview(w http.ResponseWriter, r *http.Request, taskID string) {
err := s.store.CancelMergeAfterReview(r.Context(), taskID, actor(r))
if err != nil {
writeStoreError(w, err)
return
}
t, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) MergeIn(w http.ResponseWriter, r *http.Request, taskID string) {
t, p, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
sess, err := s.agents.MergeIn(r.Context(), t, u, p.DefaultBranch)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, sess)
}
func (s *Server) DiscardTask(w http.ResponseWriter, r *http.Request, taskID string) {
t, _, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
t, err := s.agents.Discard(r.Context(), t, actor(r))
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, t)
}
func (s *Server) PostToBoard(w http.ResponseWriter, r *http.Request, taskID string) {
req := api.BoardPostCreate{}
if !readJSON(w, r, &req) {
return
}
t, _, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
err := s.agents.Post(r.Context(), t, actor(r), req.Text)
if err != nil {
writeAgentError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) activeTask(w http.ResponseWriter, r *http.Request, taskID string) (api.Task, api.Project, bool) {
t, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return t, api.Project{}, false
}
if t.Status != api.TaskStatusActive {
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s", t.Id, t.Status))
return t, api.Project{}, false
}
p, err := s.store.GetProject(r.Context(), t.ProjectId)
if err != nil {
writeStoreError(w, err)
return t, p, false
}
return t, p, true
}
func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
status, err := s.status(r.Context())
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (s *Server) CheckPermissions(w http.ResponseWriter, r *http.Request) {
perms, err := s.agents.CheckPermissions(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, perms)
}
func (s *Server) status(ctx context.Context) (api.Status, error) {
c, err := s.store.Counts(ctx)
if err != nil {
return api.Status{}, err
}
usage, err := s.agents.Usage(ctx)
if err != nil {
return api.Status{}, err
}
health, err := s.agents.Health(ctx)
if err != nil {
return api.Status{}, err
}
dbBytes := int64(0)
for _, suffix := range []string{"", "-wal", "-shm"} {
fi, err := os.Stat(s.store.Path() + suffix)
if err == nil {
dbBytes += fi.Size()
}
}
return api.Status{
StartedAt: s.startedAt,
DbBytes: dbBytes,
LastEventSeq: c.LastEventSeq,
Projects: c.Projects,
ActiveTasks: c.ActiveTasks,
SseClients: s.hub.count(),
Clients: s.connectedClients(),
LiveAgents: s.agents.LiveCount(),
RunningTurns: usage.Running,
RateLimits: usage.RateLimits,
AgentProcesses: health.Processes,
Zombies: health.Zombies,
StuckAfterSeconds: int(agents.StuckAfter / time.Second),
ProtocolErrors: s.agents.ProtocolErrors(),
}, nil
}
func (s *Server) ListTaskEvents(w http.ResponseWriter, r *http.Request, taskID string, params api.ListTaskEventsParams) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
after := int64(0)
if params.After != nil {
after = *params.After
}
limit := 500
if params.Limit != nil {
limit = *params.Limit
}
events, err := s.store.TaskEvents(r.Context(), taskID, after, limit)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, events)
}
func (s *Server) ListSnapshots(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
snaps, err := s.store.ListSnapshots(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, snaps)
}
func (s *Server) ListPreviews(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
previews, err := s.store.ListPreviews(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, previews)
}
func (s *Server) ForkSnapshot(w http.ResponseWriter, r *http.Request, taskID string, n int) {
t, p, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
fork, err := s.agents.Fork(r.Context(), t, p, n, u)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusCreated, fork)
}
func (s *Server) ListSessions(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
sessions, err := s.store.ListSessions(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, sessions)
}
func (s *Server) CreateSession(w http.ResponseWriter, r *http.Request, taskID string) {
req := api.SessionCreate{}
if !readJSON(w, r, &req) {
return
}
images, ok := s.images(w, r, req.Images)
if !ok {
return
}
if strings.TrimSpace(req.Prompt) == "" && len(images) == 0 {
writeError(w, http.StatusBadRequest, errors.New("prompt or images are required"))
return
}
t, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
model := ""
if req.Model != nil {
model = *req.Model
}
sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model, images...)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusCreated, sess)
}
func (s *Server) GetSession(w http.ResponseWriter, r *http.Request, sessionID string) {
sess, err := s.store.GetSession(r.Context(), sessionID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, sess)
}
func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID string) {
req := api.MessageCreate{}
if !readJSON(w, r, &req) {
return
}
images, ok := s.images(w, r, req.Images)
if !ok {
return
}
if strings.TrimSpace(req.Text) == "" && len(images) == 0 {
writeError(w, http.StatusBadRequest, errors.New("text or images are required"))
return
}
model := ""
if req.Model != nil {
model = *req.Model
}
sess, _, err := s.agents.Message(r.Context(), sessionID, actor(r), req.Text, model, images...)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, sess)
}
func (s *Server) WithdrawMessage(w http.ResponseWriter, r *http.Request, taskID string, inputID int64) {
err := s.agents.Withdraw(r.Context(), taskID, inputID, actor(r))
if err != nil {
writeAgentError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) CompactSession(w http.ResponseWriter, r *http.Request, sessionID string) {
req := api.CompactCreate{}
if !readJSON(w, r, &req) {
return
}
model := ""
if req.Model != nil {
model = *req.Model
}
sess, err := s.agents.Compact(r.Context(), sessionID, model)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, sess)
}
func (s *Server) InterruptSession(w http.ResponseWriter, r *http.Request, sessionID string) {
sess, err := s.agents.Interrupt(r.Context(), sessionID)
if err != nil {
writeAgentError(w, err)
return
}
writeJSON(w, http.StatusAccepted, sess)
}
func writeAgentError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, err)
case errors.Is(err, agents.ErrConflict):
writeError(w, http.StatusConflict, err)
case errors.Is(err, agents.ErrInvalid), errors.Is(err, agents.ErrNoGitIdentity), errors.Is(err, workflow.ErrInvalid):
writeError(w, http.StatusBadRequest, err)
default:
writeError(w, http.StatusInternalServerError, err)
}
}
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(v)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("decode request: %w", err))
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
err := json.NewEncoder(w).Encode(v)
if err != nil {
slog.Warn("write response", "err", err)
}
}
func writeError(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, api.Error{Message: err.Error()})
}
func writeStoreError(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, err)
return
}
writeError(w, http.StatusInternalServerError, err)
}