Image input
This commit is contained in:
@@ -45,6 +45,6 @@ func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = m.create(ctx, task, creator, handoffPreamble+handoff, sess.Model, "byre")
|
||||
_, err = m.create(ctx, task, creator, handoffPreamble+handoff, sess.Model, "byre", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending))
|
||||
_, err = m.turn(ctx, sess, task, pending[0].Response.User, deliveryMessage(pending), nil)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
return nil
|
||||
}
|
||||
|
||||
+29
-15
@@ -83,11 +83,11 @@ func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string) (api.AgentSession, error) {
|
||||
return m.create(ctx, task, creator, prompt, model, creator.Login)
|
||||
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string, images ...string) (api.AgentSession, error) {
|
||||
return m.create(ctx, task, creator, prompt, model, creator.Login, images)
|
||||
}
|
||||
|
||||
func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string) (api.AgentSession, error) {
|
||||
func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, prompt, model, actor string, images []string) (api.AgentSession, error) {
|
||||
if creator.GitName == "" || creator.GitEmail == "" {
|
||||
return api.AgentSession{}, ErrNoGitIdentity
|
||||
}
|
||||
@@ -124,10 +124,10 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, p
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.turn(ctx, sess, task, actor, prompt)
|
||||
return m.turn(ctx, sess, task, actor, prompt, images)
|
||||
}
|
||||
|
||||
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string) (api.AgentSession, error) {
|
||||
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string, images ...string) (api.AgentSession, error) {
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
@@ -150,7 +150,7 @@ func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.turn(ctx, sess, task, actor, text)
|
||||
return m.turn(ctx, sess, task, actor, text, images)
|
||||
}
|
||||
|
||||
func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSession, error) {
|
||||
@@ -249,7 +249,7 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
err = m.message(ctx, l, "byre", restartNotice)
|
||||
err = m.message(ctx, l, "byre", restartNotice, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string) (api.AgentSession, error) {
|
||||
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
|
||||
m.capMu.Lock()
|
||||
defer m.capMu.Unlock()
|
||||
|
||||
@@ -320,7 +320,7 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
|
||||
}
|
||||
|
||||
if room {
|
||||
return m.start(ctx, sess, task, actor, text, api.AgentSessionStateIdle)
|
||||
return m.start(ctx, sess, task, actor, text, images, api.AgentSessionStateIdle)
|
||||
}
|
||||
|
||||
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateQueued, actor)
|
||||
@@ -333,6 +333,7 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
|
||||
Vendor: sess.Vendor,
|
||||
Actor: actor,
|
||||
Text: text,
|
||||
Images: images,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -342,7 +343,7 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
|
||||
return m.store.GetSession(ctx, sess.Id)
|
||||
}
|
||||
|
||||
func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, from api.AgentSessionState) (api.AgentSession, error) {
|
||||
func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string, from api.AgentSessionState) (api.AgentSession, error) {
|
||||
err := transition(ctx, m.store, sess.Id, from, api.AgentSessionStateRunning, actor)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
@@ -360,7 +361,7 @@ func (m *Manager) start(ctx context.Context, sess api.AgentSession, task api.Tas
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
err = m.message(ctx, l, actor, text)
|
||||
err = m.message(ctx, l, actor, text, images)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
@@ -430,7 +431,7 @@ func (m *Manager) drain(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = m.start(ctx, sess, task, q.Actor, q.Text, api.AgentSessionStateQueued)
|
||||
_, err = m.start(ctx, sess, task, q.Actor, q.Text, q.Images, api.AgentSessionStateQueued)
|
||||
if err != nil {
|
||||
slog.Error("start queued turn", "session", q.SessionID, "err", err)
|
||||
}
|
||||
@@ -467,12 +468,25 @@ func transition(ctx context.Context, st *store.Store, id string, from, to api.Ag
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) message(ctx context.Context, l *live, actor, text string) error {
|
||||
func (m *Manager) message(ctx context.Context, l *live, actor, text string, images []string) error {
|
||||
imgs := []claude.Image{}
|
||||
for _, id := range images {
|
||||
b, data, err := m.store.ReadBlob(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imgs = append(imgs, claude.Image{MediaType: b.ContentType, Data: data})
|
||||
}
|
||||
|
||||
head, err := git.Head(l.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.emit(ctx, l, actor, "user.message", map[string]any{"text": text, "head": head})
|
||||
payload := map[string]any{"text": text, "head": head}
|
||||
if len(images) > 0 {
|
||||
payload["images"] = images
|
||||
}
|
||||
err = m.emit(ctx, l, actor, "user.message", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -485,7 +499,7 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string) erro
|
||||
l.rateLimited = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
return l.sess.Send(text)
|
||||
return l.sess.Send(text, imgs...)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task api.Task) (*live, error) {
|
||||
|
||||
@@ -119,7 +119,7 @@ func (m *Manager) createTaskFromAgent(ctx context.Context, l *live, raw json.Raw
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = m.create(ctx, task, creator, args.Prompt, model, agentActor)
|
||||
_, err = m.create(ctx, task, creator, args.Prompt, model, agentActor, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
|
||||
}
|
||||
|
||||
+137
-69
@@ -304,6 +304,14 @@ type Attachment struct {
|
||||
// AttachmentKind defines model for Attachment.Kind.
|
||||
type AttachmentKind string
|
||||
|
||||
// Blob defines model for Blob.
|
||||
type Blob struct {
|
||||
ContentType string `json:"content_type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
// Error defines model for Error.
|
||||
type Error struct {
|
||||
Message string `json:"message"`
|
||||
@@ -412,9 +420,14 @@ type InboxState string
|
||||
|
||||
// MessageCreate defines model for MessageCreate.
|
||||
type MessageCreate struct {
|
||||
// Images Blob ids from uploadBlob, sent to the agent as images alongside the text.
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
|
||||
// Model Model for this turn and later ones. When it differs from the session's model, the session switches before the message is sent.
|
||||
Model *string `json:"model,omitempty"`
|
||||
Text string `json:"text"`
|
||||
|
||||
// Text May be empty when images are given.
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Project defines model for Project.
|
||||
@@ -436,8 +449,10 @@ type ProjectCreate struct {
|
||||
|
||||
// SessionCreate defines model for SessionCreate.
|
||||
type SessionCreate struct {
|
||||
Model *string `json:"model,omitempty"`
|
||||
Prompt string `json:"prompt"`
|
||||
// Images Blob ids from uploadBlob, sent to the agent as images alongside the prompt.
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
// Status defines model for Status.
|
||||
@@ -581,6 +596,12 @@ type ServerInterface interface {
|
||||
// (GET /api/admin/status)
|
||||
GetStatus(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (POST /api/blobs)
|
||||
UploadBlob(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// (GET /api/blobs/{blobId})
|
||||
GetBlob(w http.ResponseWriter, r *http.Request, blobId string)
|
||||
|
||||
// (GET /api/events)
|
||||
StreamEvents(w http.ResponseWriter, r *http.Request, params StreamEventsParams)
|
||||
|
||||
@@ -677,6 +698,46 @@ func (siw *ServerInterfaceWrapper) GetStatus(w http.ResponseWriter, r *http.Requ
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// UploadBlob operation middleware
|
||||
func (siw *ServerInterfaceWrapper) UploadBlob(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.UploadBlob(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetBlob operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetBlob(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "blobId" -------------
|
||||
var blobId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "blobId", r.PathValue("blobId"), &blobId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "blobId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetBlob(w, r, blobId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// StreamEvents operation middleware
|
||||
func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1555,6 +1616,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/messages", wrapper.SendMessage)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/compact", wrapper.CompactSession)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/interrupt", wrapper.InterruptSession)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/blobs", wrapper.UploadBlob)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/blobs/{blobId}", wrapper.GetBlob)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox", wrapper.ListInbox)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox/{itemId}", wrapper.GetInboxItem)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/inbox/responses", wrapper.RespondInbox)
|
||||
@@ -1569,72 +1632,77 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
// const string: with thousands of chunks the chained `+` fold is several
|
||||
// times slower for the Go compiler than parsing a slice literal.
|
||||
var swaggerSpec = []string{
|
||||
"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",
|
||||
"sDuc+/v/CwAA//8=",
|
||||
"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",
|
||||
"cpN/2wrn4eFfAQAA//8=",
|
||||
}
|
||||
|
||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -359,10 +360,34 @@ func (s *Session) ID() string {
|
||||
return s.id
|
||||
}
|
||||
|
||||
func (s *Session) Send(text string) error {
|
||||
type Image struct {
|
||||
MediaType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func (s *Session) Send(text string, images ...Image) error {
|
||||
var content any = text
|
||||
if len(images) > 0 {
|
||||
blocks := []map[string]any{}
|
||||
for _, img := range images {
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "image",
|
||||
"source": map[string]any{
|
||||
"type": "base64",
|
||||
"media_type": img.MediaType,
|
||||
"data": base64.StdEncoding.EncodeToString(img.Data),
|
||||
},
|
||||
})
|
||||
}
|
||||
if text != "" {
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": text})
|
||||
}
|
||||
content = blocks
|
||||
}
|
||||
|
||||
return s.write(map[string]any{
|
||||
"type": "user",
|
||||
"message": map[string]any{"role": "user", "content": text},
|
||||
"message": map[string]any{"role": "user", "content": content},
|
||||
"parent_tool_use_id": nil,
|
||||
"session_id": s.ID(),
|
||||
})
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package claude_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -198,6 +203,30 @@ func TestInterrupt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testContext(t)
|
||||
s := start(t, ctx, claude.Options{Cwd: scratchRepo(t)})
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{C: color.RGBA{R: 255, A: 255}}, image.Point{}, draw.Src)
|
||||
buf := bytes.Buffer{}
|
||||
err := png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
|
||||
err = s.Send("Without using any tools, what single colour fills the attached image? Reply with just the colour name.", claude.Image{MediaType: "image/png", Data: buf.Bytes()})
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
text := waitResult(t, ctx, s, nil)
|
||||
|
||||
if !strings.Contains(strings.ToLower(text), "red") {
|
||||
t.Fatalf("model didn't see the image; assistant said %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResume(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testContext(t)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,83 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
const maxBlobBytes = 5 << 20
|
||||
|
||||
var imageTypes = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
}
|
||||
|
||||
func (s *Server) UploadBlob(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBlobBytes))
|
||||
maxErr := &http.MaxBytesError{}
|
||||
if errors.As(err, &maxErr) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, fmt.Errorf("images are limited to %d MiB", maxBlobBytes>>20))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(data)
|
||||
if !imageTypes[contentType] {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("unsupported image type %s; use PNG, JPEG, GIF or WebP", contentType))
|
||||
return
|
||||
}
|
||||
|
||||
b, err := s.store.CreateBlob(r.Context(), contentType, data, actor(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, b)
|
||||
}
|
||||
|
||||
func (s *Server) GetBlob(w http.ResponseWriter, r *http.Request, blobID string) {
|
||||
b, path, err := s.store.GetBlob(r.Context(), blobID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w.Header().Set("Content-Type", b.ContentType)
|
||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
http.ServeContent(w, r, "", b.CreatedAt, f)
|
||||
}
|
||||
|
||||
func (s *Server) images(w http.ResponseWriter, r *http.Request, ids *[]string) ([]string, bool) {
|
||||
if ids == nil {
|
||||
return nil, true
|
||||
}
|
||||
for _, id := range *ids {
|
||||
_, _, err := s.store.GetBlob(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return nil, false
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return *ids, true
|
||||
}
|
||||
@@ -495,7 +495,12 @@ func (s *Server) CreateSession(w http.ResponseWriter, r *http.Request, taskID st
|
||||
model = *req.Model
|
||||
}
|
||||
|
||||
sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model)
|
||||
images, ok := s.images(w, r, req.Images)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := s.agents.Create(r.Context(), t, u, req.Prompt, model, images...)
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
@@ -518,8 +523,13 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("text is required"))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -528,7 +538,7 @@ func (s *Server) SendMessage(w http.ResponseWriter, r *http.Request, sessionID s
|
||||
model = *req.Model
|
||||
}
|
||||
|
||||
sess, err := s.agents.Send(r.Context(), sessionID, actor(r), req.Text, model)
|
||||
sess, err := s.agents.Send(r.Context(), sessionID, actor(r), req.Text, model, images...)
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
func (s *Store) CreateBlob(ctx context.Context, contentType string, data []byte, actor string) (api.Blob, error) {
|
||||
b := api.Blob{
|
||||
Id: NewID(),
|
||||
ContentType: contentType,
|
||||
CreatedBy: actor,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
rel := filepath.Join("blobs", b.Id)
|
||||
|
||||
err := os.MkdirAll(filepath.Join(s.dataDir(), "blobs"), 0o755)
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(s.dataDir(), rel), data, 0o644)
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO blobs (id, content_type, path, created_by, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
b.Id, b.ContentType, rel, b.CreatedBy, formatTime(b.CreatedAt))
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (s *Store) GetBlob(ctx context.Context, id string) (api.Blob, string, error) {
|
||||
b := api.Blob{}
|
||||
rel := ""
|
||||
createdAt := ""
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, content_type, path, created_by, created_at FROM blobs WHERE id = ?`, id).
|
||||
Scan(&b.Id, &b.ContentType, &rel, &b.CreatedBy, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return b, "", errors.Join(ErrNotFound, errors.New("no blob "+id))
|
||||
}
|
||||
if err != nil {
|
||||
return b, "", err
|
||||
}
|
||||
b.CreatedAt, err = parseTime(createdAt)
|
||||
return b, filepath.Join(s.dataDir(), rel), err
|
||||
}
|
||||
|
||||
func (s *Store) ReadBlob(ctx context.Context, id string) (api.Blob, []byte, error) {
|
||||
b, path, err := s.GetBlob(ctx, id)
|
||||
if err != nil {
|
||||
return b, nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
return b, data, err
|
||||
}
|
||||
|
||||
func (s *Store) dataDir() string {
|
||||
return filepath.Dir(s.path)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestQueuedTurnKeepsImages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(api.Event) {})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t1", Branch: "t1", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
b, err := st.CreateBlob(ctx, "image/png", []byte("not really a png"), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create blob: %v", err)
|
||||
}
|
||||
got, data, err := st.ReadBlob(ctx, b.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("read blob: %v", err)
|
||||
}
|
||||
if got.ContentType != "image/png" || got.CreatedBy != "alice" || string(data) != "not really a png" {
|
||||
t.Fatalf("blob round trip: %+v %q", got, data)
|
||||
}
|
||||
|
||||
err = st.EnqueueTurn(ctx, store.QueuedTurn{SessionID: "s1", Actor: "alice", Text: "look", Images: []string{b.Id}, CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
queued, err := st.QueuedTurns(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("queued turns: %v", err)
|
||||
}
|
||||
if len(queued) != 1 || !slices.Equal(queued[0].Images, []string{b.Id}) {
|
||||
t.Fatalf("queued turn lost its images: %+v", queued)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,44 @@ func TestMigrateRunsMigrationAddedBeforeApplied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobsMigrationOverPositionalCopy(t *testing.T) {
|
||||
db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db"))
|
||||
err := migrate(db, migrations[:9])
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
insertReport(t, db, "r1", `{"text":"x"}`)
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE blobs (id TEXT PRIMARY KEY, content_type TEXT NOT NULL, path TEXT NOT NULL, created_by TEXT NOT NULL REFERENCES users(login), created_at TEXT NOT NULL);
|
||||
INSERT INTO blobs VALUES ('b1', 'image/png', 'blobs/b1', 'alice', 'x');
|
||||
ALTER TABLE turn_queue ADD COLUMN images TEXT NOT NULL DEFAULT '[]';
|
||||
INSERT INTO turn_queue (session_id, actor, text, created_at) VALUES ('s1', 'alice', 'queued', 'x');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("positional copy: %v", err)
|
||||
}
|
||||
|
||||
err = migrate(db, migrations)
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
text := ""
|
||||
images := ""
|
||||
err = db.QueryRow(`SELECT text, images FROM turn_queue WHERE session_id = 's1'`).Scan(&text, &images)
|
||||
if err != nil {
|
||||
t.Fatalf("queued turn: %v", err)
|
||||
}
|
||||
if text != "queued" || images != "[]" {
|
||||
t.Fatalf("queued turn %q %q", text, images)
|
||||
}
|
||||
blobs := 0
|
||||
err = db.QueryRow(`SELECT count(*) FROM blobs`).Scan(&blobs)
|
||||
if err != nil || blobs != 1 {
|
||||
t.Fatalf("blobs %d: %v", blobs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenReportPayloadsRunsTwice(t *testing.T) {
|
||||
db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db"))
|
||||
err := migrate(db, migrations)
|
||||
|
||||
+15
-4
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,7 @@ type QueuedTurn struct {
|
||||
Vendor api.AgentSessionVendor
|
||||
Actor string
|
||||
Text string
|
||||
Images []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -44,16 +46,20 @@ func (s *Store) CountRunning(ctx context.Context) (map[api.AgentSessionVendor]in
|
||||
}
|
||||
|
||||
func (s *Store) EnqueueTurn(ctx context.Context, q QueuedTurn) error {
|
||||
images, err := json.Marshal(append([]string{}, q.Images...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO turn_queue (session_id, actor, text, created_at) VALUES (?, ?, ?, ?)`,
|
||||
q.SessionID, q.Actor, q.Text, formatTime(q.CreatedAt))
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO turn_queue (session_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
q.SessionID, q.Actor, q.Text, string(images), formatTime(q.CreatedAt))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) QueuedTurns(ctx context.Context) ([]QueuedTurn, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT q.session_id, s.vendor, q.actor, q.text, q.created_at
|
||||
SELECT q.session_id, s.vendor, q.actor, q.text, q.images, q.created_at
|
||||
FROM turn_queue q JOIN agent_sessions s ON s.id = q.session_id
|
||||
ORDER BY q.created_at
|
||||
`)
|
||||
@@ -66,8 +72,13 @@ func (s *Store) QueuedTurns(ctx context.Context) ([]QueuedTurn, error) {
|
||||
for rows.Next() {
|
||||
q := QueuedTurn{}
|
||||
vendor := ""
|
||||
images := ""
|
||||
createdAt := ""
|
||||
err = rows.Scan(&q.SessionID, &vendor, &q.Actor, &q.Text, &createdAt)
|
||||
err = rows.Scan(&q.SessionID, &vendor, &q.Actor, &q.Text, &images, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(images), &q.Images)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -140,6 +140,25 @@ UPDATE tasks SET status = 'discarded' WHERE status = 'completed';
|
||||
`}, {"dismiss-inbox-of-closed-tasks", `
|
||||
UPDATE inbox_items SET state = 'dismissed', resolved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), snoozed_until = NULL
|
||||
WHERE state = 'open' AND task_id IN (SELECT id FROM tasks WHERE status != 'active');
|
||||
`}, {"add-blobs-and-queued-images", `
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(login),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE turn_queue_next (
|
||||
session_id TEXT PRIMARY KEY REFERENCES agent_sessions(id),
|
||||
actor TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
images TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO turn_queue_next (session_id, actor, text, created_at) SELECT session_id, actor, text, created_at FROM turn_queue;
|
||||
DROP TABLE turn_queue;
|
||||
ALTER TABLE turn_queue_next RENAME TO turn_queue;
|
||||
`}}
|
||||
|
||||
type Store struct {
|
||||
|
||||
Reference in New Issue
Block a user