Image input
This commit is contained in:
@@ -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