Run Claude sessions on tasks with event streaming and restart recovery
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/claude"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/report"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrNoGitIdentity = errors.New("set your git name and email (PUT /api/me) before starting agents")
|
||||
)
|
||||
|
||||
const agentActor = "agent"
|
||||
|
||||
const restartNotice = "byre restarted while you were mid-turn, so your last turn was cut off. Re-check the working tree (git status and the files you were editing) before continuing where you left off."
|
||||
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
|
||||
mu sync.Mutex
|
||||
live map[string]*live
|
||||
closing bool
|
||||
}
|
||||
|
||||
type live struct {
|
||||
sess *claude.Session
|
||||
gate *report.Gate
|
||||
id string
|
||||
taskID string
|
||||
projectID string
|
||||
stopping bool
|
||||
}
|
||||
|
||||
func New(st *store.Store) *Manager {
|
||||
return &Manager{store: st, live: map[string]*live{}}
|
||||
}
|
||||
|
||||
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string) (api.AgentSession, error) {
|
||||
if creator.GitName == "" || creator.GitEmail == "" {
|
||||
return api.AgentSession{}, ErrNoGitIdentity
|
||||
}
|
||||
|
||||
if task.Status != api.TaskStatusActive {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.Status)
|
||||
}
|
||||
|
||||
existing, err := m.store.ListSessions(ctx, task.Id)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
for _, s := range existing {
|
||||
if s.State != api.AgentSessionStateCompleted {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: task %s already has session %s", ErrConflict, task.Id, s.Id)
|
||||
}
|
||||
}
|
||||
|
||||
id := newUUID()
|
||||
sess := api.AgentSession{
|
||||
Id: id,
|
||||
TaskId: task.Id,
|
||||
Vendor: api.Claude,
|
||||
Model: model,
|
||||
VendorSessionId: id,
|
||||
State: api.AgentSessionStateIdle,
|
||||
Role: api.Implementer,
|
||||
CreatedBy: creator.Login,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = m.store.CreateSession(ctx, sess, task.ProjectId)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.turn(ctx, sess, task, creator.Login, prompt, false)
|
||||
}
|
||||
|
||||
func (m *Manager) Send(ctx context.Context, sessionID, actor, text string) (api.AgentSession, error) {
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
if sess.State != api.AgentSessionStateIdle {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: session %s is %s", ErrConflict, sess.Id, sess.State)
|
||||
}
|
||||
|
||||
task, err := m.store.GetTask(ctx, sess.TaskId)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.turn(ctx, sess, task, actor, text, true)
|
||||
}
|
||||
|
||||
func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSession, error) {
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
l := m.live[sessionID]
|
||||
m.mu.Unlock()
|
||||
|
||||
if sess.State != api.AgentSessionStateRunning || l == nil {
|
||||
return api.AgentSession{}, fmt.Errorf("%w: session %s has no turn running", ErrConflict, sess.Id)
|
||||
}
|
||||
|
||||
ictx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = l.sess.Interrupt(ictx)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
|
||||
sessions, err := m.store.ListSessions(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, sess := range sessions {
|
||||
if sess.State == api.AgentSessionStateCompleted {
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
l := m.live[sess.Id]
|
||||
if l != nil {
|
||||
l.stopping = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if l != nil {
|
||||
l.sess.Kill()
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Recover(ctx context.Context) error {
|
||||
sessions, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, sess := range sessions {
|
||||
task, err := m.store.GetTask(ctx, sess.TaskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l, err := m.ensureLive(ctx, sess, task, true)
|
||||
if err != nil {
|
||||
slog.Error("resume session", "session", sess.Id, "err", err)
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": err.Error()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
err = m.message(ctx, l, "byre", restartNotice)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("resumed session", "session", sess.Id, "task", task.Id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
m.mu.Lock()
|
||||
m.closing = true
|
||||
all := []*live{}
|
||||
for _, l := range m.live {
|
||||
all = append(all, l)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, l := range all {
|
||||
l.sess.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) LiveCount() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.live)
|
||||
}
|
||||
|
||||
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, resume bool) (api.AgentSession, error) {
|
||||
l, err := m.ensureLive(ctx, sess, task, resume)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateRunning, actor, nil)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
err = m.message(ctx, l, actor, text)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.store.GetSession(ctx, sess.Id)
|
||||
}
|
||||
|
||||
func (m *Manager) message(ctx context.Context, l *live, actor, text string) error {
|
||||
err := m.emit(ctx, l, actor, "user.message", map[string]any{"text": text})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.gate.Reset()
|
||||
return l.sess.Send(text)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task api.Task, resume bool) (*live, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.closing {
|
||||
return nil, errors.New("shutting down")
|
||||
}
|
||||
|
||||
l := m.live[sess.Id]
|
||||
if l != nil {
|
||||
return l, nil
|
||||
}
|
||||
|
||||
creator, err := m.store.GetUser(ctx, sess.CreatedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l = &live{id: sess.Id, taskID: task.Id, projectID: task.ProjectId}
|
||||
l.gate = &report.Gate{OnReport: func(r report.Report) {
|
||||
err := m.emit(context.Background(), l, agentActor, "agent.report", store.ToPayload(r))
|
||||
if err != nil {
|
||||
slog.Error("record report", "session", l.id, "err", err)
|
||||
}
|
||||
}}
|
||||
|
||||
opts := claude.Options{
|
||||
Cwd: task.WorktreePath,
|
||||
Model: sess.Model,
|
||||
GitName: creator.GitName,
|
||||
GitEmail: creator.GitEmail,
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{l.gate.Tool()}}},
|
||||
Hooks: map[string][]claude.HookMatcher{
|
||||
"Stop": {{Func: l.gate.StopHook}},
|
||||
},
|
||||
}
|
||||
if resume {
|
||||
opts.Resume = sess.VendorSessionId
|
||||
} else {
|
||||
opts.SessionID = sess.VendorSessionId
|
||||
}
|
||||
|
||||
sctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
l.sess, err = claude.Start(sctx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start claude: %w", err)
|
||||
}
|
||||
|
||||
m.live[sess.Id] = l
|
||||
go m.pump(l)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (m *Manager) pump(l *live) {
|
||||
ctx := context.Background()
|
||||
|
||||
for ev := range l.sess.Events() {
|
||||
err := m.handle(ctx, l, ev)
|
||||
if err != nil {
|
||||
slog.Error("record agent event", "session", l.id, "type", ev.Type, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.live, l.id)
|
||||
quiet := m.closing || l.stopping
|
||||
m.mu.Unlock()
|
||||
|
||||
if quiet {
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := m.store.GetSession(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("load exited session", "session", l.id, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if sess.State != api.AgentSessionStateRunning {
|
||||
return
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, map[string]any{"error": "agent process exited mid-turn"})
|
||||
if err != nil {
|
||||
slog.Error("record session exit", "session", l.id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
|
||||
switch ev.Type {
|
||||
case "assistant":
|
||||
blocks, err := ev.Blocks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range blocks {
|
||||
err = m.handleAssistantBlock(ctx, l, b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case "user":
|
||||
blocks, err := ev.Blocks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if b.Type != "tool_result" {
|
||||
continue
|
||||
}
|
||||
content, err := decode(b.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.emit(ctx, l, agentActor, "agent.tool_result", map[string]any{
|
||||
"tool_use_id": b.ToolUseID,
|
||||
"is_error": b.IsError,
|
||||
"content": content,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case "result":
|
||||
res, err := ev.Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.emit(ctx, l, agentActor, "turn.ended", store.ToPayload(res))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, nil)
|
||||
|
||||
case "system":
|
||||
if ev.Subtype == "init" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
raw := map[string]any{}
|
||||
err := json.Unmarshal(ev.Raw, &raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.emit(ctx, l, agentActor, "agent.vendor", raw)
|
||||
}
|
||||
|
||||
func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Block) error {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
return m.emit(ctx, l, agentActor, "agent.message", map[string]any{"text": b.Text})
|
||||
|
||||
case "thinking":
|
||||
if b.Thinking == "" {
|
||||
return nil
|
||||
}
|
||||
return m.emit(ctx, l, agentActor, "agent.thinking", map[string]any{"text": b.Thinking})
|
||||
|
||||
case "tool_use":
|
||||
input, err := decode(b.Input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.emit(ctx, l, agentActor, "agent.tool_call", map[string]any{
|
||||
"id": b.ID,
|
||||
"name": b.Name,
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
|
||||
return m.emit(ctx, l, agentActor, "agent.vendor", store.ToPayload(b))
|
||||
}
|
||||
|
||||
func (m *Manager) emit(ctx context.Context, l *live, actor, kind string, payload map[string]any) error {
|
||||
return m.store.AppendEvent(ctx, api.Event{
|
||||
Kind: kind,
|
||||
ProjectId: &l.projectID,
|
||||
TaskId: &l.taskID,
|
||||
AgentSessionId: &l.id,
|
||||
Actor: actor,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
func decode(raw json.RawMessage) (any, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var v any
|
||||
err := json.Unmarshal(raw, &v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func newUUID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
b[6] = b[6]&0x0f | 0x40
|
||||
b[8] = b[8]&0x3f | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package agents_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/agents"
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
const longCommandPrompt = "Run the bash command `ping -c 120 127.0.0.1` in the foreground (not in the background) and wait for it to finish, then say done."
|
||||
|
||||
const (
|
||||
testModel = "sonnet"
|
||||
login = "alice@example.com"
|
||||
gitName = "Alice Test"
|
||||
gitEmail = "alice@byre.invalid"
|
||||
)
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
store *store.Store
|
||||
events chan api.Event
|
||||
user api.User
|
||||
task api.Task
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
e := &env{t: t, ctx: ctx, events: make(chan api.Event, 4096)}
|
||||
|
||||
var err error
|
||||
e.store, err = store.Open(filepath.Join(t.TempDir(), "byre.db"), func(ev api.Event) { e.events <- ev })
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { e.store.Close() })
|
||||
|
||||
_, err = e.store.EnsureUser(ctx, login, "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
e.user, err = e.store.UpdateUserGit(ctx, login, gitName, gitEmail)
|
||||
if err != nil {
|
||||
t.Fatalf("update user: %v", err)
|
||||
}
|
||||
|
||||
repo := scratchRepo(t)
|
||||
p := api.Project{Id: store.NewID(), Name: "demo", RepoPath: repo, DefaultBranch: "main", Remote: "none", CreatedBy: login, CreatedAt: time.Now().UTC()}
|
||||
err = e.store.CreateProject(ctx, p)
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
id := store.NewID()
|
||||
e.task = api.Task{
|
||||
Id: id,
|
||||
ProjectId: p.Id,
|
||||
Title: "test",
|
||||
Branch: "byre/" + id,
|
||||
WorktreePath: filepath.Join(t.TempDir(), id),
|
||||
Status: api.TaskStatusActive,
|
||||
CreatedBy: login,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
e.task.BaseRef, err = git.AddWorktree(repo, e.task.WorktreePath, e.task.Branch, "main")
|
||||
if err != nil {
|
||||
t.Fatalf("add worktree: %v", err)
|
||||
}
|
||||
err = e.store.CreateTask(ctx, e.task)
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *env) manager() *agents.Manager {
|
||||
m := agents.New(e.store)
|
||||
e.t.Cleanup(m.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *env) waitFor(desc string, match func(api.Event) bool) []api.Event {
|
||||
e.t.Helper()
|
||||
seen := []api.Event{}
|
||||
for {
|
||||
select {
|
||||
case ev := <-e.events:
|
||||
seen = append(seen, ev)
|
||||
e.t.Logf("event %d %s %v", ev.Seq, ev.Kind, ev.Payload)
|
||||
if match(ev) {
|
||||
return seen
|
||||
}
|
||||
case <-e.ctx.Done():
|
||||
e.t.Fatalf("timed out waiting for %s", desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *env) waitIdle() []api.Event {
|
||||
e.t.Helper()
|
||||
return e.waitFor("session idle", func(ev api.Event) bool {
|
||||
return ev.Kind == "session.state" && ev.Payload["state"] == string(api.AgentSessionStateIdle)
|
||||
})
|
||||
}
|
||||
|
||||
func kinds(events []api.Event) []string {
|
||||
out := []string{}
|
||||
for _, ev := range events {
|
||||
out = append(out, ev.Kind)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func has(events []api.Event, kind string) bool {
|
||||
for _, ev := range events {
|
||||
if ev.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestTurnAndFollowUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
sess, err := m.Create(e.ctx, e.task, e.user, `Create a file hi.txt containing exactly the word hi, then git add and git commit it with the message "add hi".`, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
if sess.State != api.AgentSessionStateRunning {
|
||||
t.Fatalf("new session is %s, want running", sess.State)
|
||||
}
|
||||
|
||||
events := e.waitIdle()
|
||||
for _, kind := range []string{"session.created", "user.message", "agent.tool_call", "agent.tool_result", "agent.report", "turn.ended"} {
|
||||
if !has(events, kind) {
|
||||
t.Fatalf("missing %s in %v", kind, kinds(events))
|
||||
}
|
||||
}
|
||||
|
||||
log := gitOut(t, e.task.WorktreePath, "log", "-1", "--format=%an <%ae>")
|
||||
if log != gitName+" <"+gitEmail+">" {
|
||||
t.Fatalf("agent committed as %q", log)
|
||||
}
|
||||
|
||||
_, err = m.Send(e.ctx, sess.Id, login, "Reply with the single word ok.")
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
|
||||
events = e.waitIdle()
|
||||
reply := ""
|
||||
for _, ev := range events {
|
||||
if ev.Kind == "agent.message" {
|
||||
reply += ev.Payload["text"].(string)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(reply), "ok") {
|
||||
t.Fatalf("follow-up reply %q", reply)
|
||||
}
|
||||
|
||||
_, err = m.Send(e.ctx, sess.Id, login, "again")
|
||||
if err != nil {
|
||||
t.Fatalf("send while idle: %v", err)
|
||||
}
|
||||
_, err = m.Send(e.ctx, sess.Id, login, "too soon")
|
||||
if err == nil {
|
||||
t.Fatalf("send while running should conflict")
|
||||
}
|
||||
e.waitIdle()
|
||||
}
|
||||
|
||||
func TestInterrupt(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
sess, err := m.Create(e.ctx, e.task, e.user, longCommandPrompt, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
e.waitFor("tool call", func(ev api.Event) bool { return ev.Kind == "agent.tool_call" })
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
at := time.Now()
|
||||
_, err = m.Interrupt(e.ctx, sess.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("interrupt: %v", err)
|
||||
}
|
||||
|
||||
events := e.waitIdle()
|
||||
if time.Since(at) > 30*time.Second {
|
||||
t.Fatalf("interrupt took %s", time.Since(at))
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ev.Kind != "agent.tool_result" {
|
||||
continue
|
||||
}
|
||||
if ev.Payload["is_error"] != true || strings.Contains(fmt.Sprint(ev.Payload["content"]), "Blocked") {
|
||||
t.Fatalf("command finished or was refused instead of being interrupted: %v", ev.Payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverMidTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
first := agents.New(e.store)
|
||||
|
||||
sess, err := first.Create(e.ctx, e.task, e.user, "Run the bash command `sleep 15` in the foreground (not in the background). After it finishes, create a file done.txt containing exactly the word done.", testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
e.waitFor("tool call", func(ev api.Event) bool { return ev.Kind == "agent.tool_call" })
|
||||
first.Close()
|
||||
|
||||
stored, err := e.store.GetSession(e.ctx, sess.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
if stored.State != api.AgentSessionStateRunning {
|
||||
t.Fatalf("killed mid-turn session is %s, want running", stored.State)
|
||||
}
|
||||
|
||||
second := e.manager()
|
||||
err = second.Recover(e.ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("recover: %v", err)
|
||||
}
|
||||
|
||||
events := e.waitIdle()
|
||||
notified := false
|
||||
for _, ev := range events {
|
||||
if ev.Kind == "user.message" && ev.Actor == "byre" {
|
||||
notified = true
|
||||
}
|
||||
}
|
||||
if !notified {
|
||||
t.Fatalf("no restart notice in %v", kinds(events))
|
||||
}
|
||||
|
||||
out, err := os.ReadFile(filepath.Join(e.task.WorktreePath, "done.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("resumed agent did not finish: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "done" {
|
||||
t.Fatalf("done.txt contains %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
sess, err := m.Create(e.ctx, e.task, e.user, longCommandPrompt, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
e.waitFor("tool call", func(ev api.Event) bool { return ev.Kind == "agent.tool_call" })
|
||||
|
||||
err = m.StopTask(e.ctx, e.task.Id, login)
|
||||
if err != nil {
|
||||
t.Fatalf("stop task: %v", err)
|
||||
}
|
||||
|
||||
stored, err := e.store.GetSession(e.ctx, sess.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
if stored.State != api.AgentSessionStateCompleted || m.LiveCount() != 0 {
|
||||
t.Fatalf("after stop: state %s, live %d", stored.State, m.LiveCount())
|
||||
}
|
||||
|
||||
_, err = m.Send(e.ctx, sess.Id, login, "hello?")
|
||||
if err == nil {
|
||||
t.Fatalf("send to completed session should conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func scratchRepo(t *testing.T) string {
|
||||
dir := t.TempDir()
|
||||
gitOut(t, dir, "init", "--initial-branch=main")
|
||||
gitOut(t, dir, "commit", "--allow-empty", "--message", "root")
|
||||
return dir
|
||||
}
|
||||
|
||||
func gitOut(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=Root",
|
||||
"GIT_AUTHOR_EMAIL=root@byre.invalid",
|
||||
"GIT_COMMITTER_NAME=Root",
|
||||
"GIT_COMMITTER_EMAIL=root@byre.invalid",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
+295
-42
@@ -21,24 +21,99 @@ import (
|
||||
"github.com/oapi-codegen/runtime"
|
||||
)
|
||||
|
||||
// Defines values for TaskStatus.
|
||||
// Defines values for AgentSessionRole.
|
||||
const (
|
||||
Active TaskStatus = "active"
|
||||
Completed TaskStatus = "completed"
|
||||
Implementer AgentSessionRole = "implementer"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the TaskStatus enum.
|
||||
func (e TaskStatus) Valid() bool {
|
||||
// Valid indicates whether the value is a known member of the AgentSessionRole enum.
|
||||
func (e AgentSessionRole) Valid() bool {
|
||||
switch e {
|
||||
case Active:
|
||||
return true
|
||||
case Completed:
|
||||
case Implementer:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for AgentSessionState.
|
||||
const (
|
||||
AgentSessionStateCompleted AgentSessionState = "completed"
|
||||
AgentSessionStateIdle AgentSessionState = "idle"
|
||||
AgentSessionStateRunning AgentSessionState = "running"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the AgentSessionState enum.
|
||||
func (e AgentSessionState) Valid() bool {
|
||||
switch e {
|
||||
case AgentSessionStateCompleted:
|
||||
return true
|
||||
case AgentSessionStateIdle:
|
||||
return true
|
||||
case AgentSessionStateRunning:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for AgentSessionVendor.
|
||||
const (
|
||||
Claude AgentSessionVendor = "claude"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the AgentSessionVendor enum.
|
||||
func (e AgentSessionVendor) Valid() bool {
|
||||
switch e {
|
||||
case Claude:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for TaskStatus.
|
||||
const (
|
||||
TaskStatusActive TaskStatus = "active"
|
||||
TaskStatusCompleted TaskStatus = "completed"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the TaskStatus enum.
|
||||
func (e TaskStatus) Valid() bool {
|
||||
switch e {
|
||||
case TaskStatusActive:
|
||||
return true
|
||||
case TaskStatusCompleted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AgentSession defines model for AgentSession.
|
||||
type AgentSession struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Id string `json:"id"`
|
||||
|
||||
// Model Requested model; empty means the vendor's default.
|
||||
Model string `json:"model"`
|
||||
Role AgentSessionRole `json:"role"`
|
||||
State AgentSessionState `json:"state"`
|
||||
TaskId string `json:"task_id"`
|
||||
Vendor AgentSessionVendor `json:"vendor"`
|
||||
VendorSessionId string `json:"vendor_session_id"`
|
||||
}
|
||||
|
||||
// AgentSessionRole defines model for AgentSession.Role.
|
||||
type AgentSessionRole string
|
||||
|
||||
// AgentSessionState defines model for AgentSession.State.
|
||||
type AgentSessionState string
|
||||
|
||||
// AgentSessionVendor defines model for AgentSession.Vendor.
|
||||
type AgentSessionVendor string
|
||||
|
||||
// Error defines model for Error.
|
||||
type Error struct {
|
||||
Message string `json:"message"`
|
||||
@@ -46,13 +121,19 @@ type Error struct {
|
||||
|
||||
// Event defines model for Event.
|
||||
type Event struct {
|
||||
Actor string `json:"actor"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Kind string `json:"kind"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
ProjectId *string `json:"project_id,omitempty"`
|
||||
Seq int64 `json:"seq"`
|
||||
TaskId *string `json:"task_id,omitempty"`
|
||||
Actor string `json:"actor"`
|
||||
AgentSessionId *string `json:"agent_session_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Kind string `json:"kind"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
ProjectId *string `json:"project_id,omitempty"`
|
||||
Seq int64 `json:"seq"`
|
||||
TaskId *string `json:"task_id,omitempty"`
|
||||
}
|
||||
|
||||
// MessageCreate defines model for MessageCreate.
|
||||
type MessageCreate struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Project defines model for Project.
|
||||
@@ -72,14 +153,23 @@ type ProjectCreate struct {
|
||||
RepoPath string `json:"repo_path"`
|
||||
}
|
||||
|
||||
// SessionCreate defines model for SessionCreate.
|
||||
type SessionCreate struct {
|
||||
Model *string `json:"model,omitempty"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
// Status defines model for Status.
|
||||
type Status struct {
|
||||
ActiveTasks int `json:"active_tasks"`
|
||||
DbBytes int64 `json:"db_bytes"`
|
||||
LastEventSeq int64 `json:"last_event_seq"`
|
||||
Projects int `json:"projects"`
|
||||
SseClients int `json:"sse_clients"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ActiveTasks int `json:"active_tasks"`
|
||||
DbBytes int64 `json:"db_bytes"`
|
||||
LastEventSeq int64 `json:"last_event_seq"`
|
||||
|
||||
// LiveAgents Agent processes currently running.
|
||||
LiveAgents int `json:"live_agents"`
|
||||
Projects int `json:"projects"`
|
||||
SseClients int `json:"sse_clients"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// Task defines model for Task.
|
||||
@@ -133,6 +223,12 @@ type CreateProjectJSONRequestBody = ProjectCreate
|
||||
// CreateTaskJSONRequestBody defines body for CreateTask for application/json ContentType.
|
||||
type CreateTaskJSONRequestBody = TaskCreate
|
||||
|
||||
// SendMessageJSONRequestBody defines body for SendMessage for application/json ContentType.
|
||||
type SendMessageJSONRequestBody = MessageCreate
|
||||
|
||||
// CreateSessionJSONRequestBody defines body for CreateSession for application/json ContentType.
|
||||
type CreateSessionJSONRequestBody = SessionCreate
|
||||
|
||||
// ServerInterface represents all server handlers.
|
||||
type ServerInterface interface {
|
||||
|
||||
@@ -163,11 +259,26 @@ type ServerInterface interface {
|
||||
// (POST /api/projects/{projectId}/tasks)
|
||||
CreateTask(w http.ResponseWriter, r *http.Request, projectId string)
|
||||
|
||||
// (GET /api/sessions/{sessionId})
|
||||
GetSession(w http.ResponseWriter, r *http.Request, sessionId string)
|
||||
|
||||
// (POST /api/sessions/{sessionId}/interrupt)
|
||||
InterruptSession(w http.ResponseWriter, r *http.Request, sessionId string)
|
||||
|
||||
// (POST /api/sessions/{sessionId}/messages)
|
||||
SendMessage(w http.ResponseWriter, r *http.Request, sessionId string)
|
||||
|
||||
// (GET /api/tasks/{taskId})
|
||||
GetTask(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
|
||||
// (POST /api/tasks/{taskId}/complete)
|
||||
CompleteTask(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
|
||||
// (GET /api/tasks/{taskId}/sessions)
|
||||
ListSessions(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
|
||||
// (POST /api/tasks/{taskId}/sessions)
|
||||
CreateSession(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
}
|
||||
|
||||
// ServerInterfaceWrapper converts contexts to parameters.
|
||||
@@ -381,6 +492,84 @@ func (siw *ServerInterfaceWrapper) CreateTask(w http.ResponseWriter, r *http.Req
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetSession operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetSession(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "sessionId" -------------
|
||||
var sessionId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "sessionId", r.PathValue("sessionId"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sessionId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetSession(w, r, sessionId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// InterruptSession operation middleware
|
||||
func (siw *ServerInterfaceWrapper) InterruptSession(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "sessionId" -------------
|
||||
var sessionId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "sessionId", r.PathValue("sessionId"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sessionId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.InterruptSession(w, r, sessionId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// SendMessage operation middleware
|
||||
func (siw *ServerInterfaceWrapper) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "sessionId" -------------
|
||||
var sessionId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "sessionId", r.PathValue("sessionId"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sessionId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.SendMessage(w, r, sessionId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetTask operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetTask(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -433,6 +622,58 @@ func (siw *ServerInterfaceWrapper) CompleteTask(w http.ResponseWriter, r *http.R
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// ListSessions operation middleware
|
||||
func (siw *ServerInterfaceWrapper) ListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "taskId" -------------
|
||||
var taskId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.ListSessions(w, r, taskId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// CreateSession operation middleware
|
||||
func (siw *ServerInterfaceWrapper) CreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "taskId" -------------
|
||||
var taskId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.CreateSession(w, r, taskId)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
type UnescapedCookieParamError struct {
|
||||
ParamName string
|
||||
Err error
|
||||
@@ -562,6 +803,11 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects/{projectId}/tasks", wrapper.CreateTask)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}", wrapper.GetTask)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/complete", wrapper.CompleteTask)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.CreateSession)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/sessions/{sessionId}", wrapper.GetSession)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/messages", wrapper.SendMessage)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/sessions/{sessionId}/interrupt", wrapper.InterruptSession)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/events", wrapper.StreamEvents)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/admin/status", wrapper.GetStatus)
|
||||
|
||||
@@ -573,27 +819,34 @@ 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{
|
||||
"zFhbb+M2E/0rBL8PSAsottsNCtR96iUoUmzbRZN92gYCLY1tbiRSGY69NQL994KkrjblS2p3+xRZJIdn",
|
||||
"Ds8ZDfPCE50XWoEiw6cvHMEUWhlwP24RNdqHRCsCRfZRFEUmE0FSq/FHo5V9Z5Il5MI+/R9hzqf8f+M2",
|
||||
"6tiPmrGPVpZlxFMwCcrCBuFTv82I24Fqbm/zAnUBSNJjysEYsQD7SJsC+JQbQqkWbjnC80oipHz6oZn4",
|
||||
"GNUT9ewjJMTLiN+uq2z6sUVCfsutyBFPEARBGgu3aq4xt088FQTXJHPg0e6aJ6nSYLBCbDItumMtsgK1",
|
||||
"fYxleKmB5x4Aqeibm3ZzqQgWgHYmCfMUjrLFkw1ZgY0qBlqIvcxDTL7zeHe5fA1j9ZrZJph8CnOxyiie",
|
||||
"oVDJMjhlgDYlcggOIOSahoYKHReClocplJYot0d32Q7gZrtepsdS/KObtUv0ntyOTmAHewjHPQlamaBr",
|
||||
"5BpiKzjT2aqjxXQWzzbkZx8h3UwYisFaND5e75VxBhAYA3GSybrKBSaQwNPkum2jNkAn4Z1kOkCjPnN9",
|
||||
"kKEDeBDmaZf+mTAQu7Ib0MAeq9gKncFrLXpGWw949lAlbNQIapXbE/Bs8k5mHRbbhSQpCxvmk8YnQoBT",
|
||||
"XN9BWYduWN+OGLVn1eA/rRRYBQzVgaG8tlD7aaHg7w0EvrepNEUmNvFgmVlIiiEXMhscHVya6YVUhyH7",
|
||||
"aVEfSid0F8NQYu+LNMjaq9FvYTwSjF0m1Vx3DozPNmiXrQGN74cmo8loYrfXBShRSD7lb0aT0Rv3Xaal",
|
||||
"wz0WhRyLNJdq3DphAc6VNj/XoN2lfMp/Brqvtdbr7r6eTM7W21U7BJq7e8A1IFuCyGjJhErZyrZltt2z",
|
||||
"k10erjx2MwiFuDagiPmpI3YrkqX/cWWYTJk0TJJhBp6/Y6kgYV/QEpjr9Jgw7Jf7338b+Z+GiTkBsrfC",
|
||||
"0LV7c333E/tCo1vhx55XgBtWCBQ5EOCXTCAwBKs/SNkM5hqBZXINNaI/rUL7zN8Tgsj9lu7sqmCGTz+8",
|
||||
"cKt8vgSRAtbdw5T3IPGow/7BT2AZVTEd9Daky+fEUI8HpULwF/lzuzYuzb5Wts2y2/O7c/FLe1rwZhtS",
|
||||
"8q9wSRW7ChgA+7AElogsk2rBVgbcXcV+oVYBkL7QVDifV2DoB51uzgqxqmVlvwYRrqD8TOSsHKK0Jifi",
|
||||
"N37jULwGYHshrM++28MFFfBWGnrX9k//KFdJkJtDSdfXm7IxiUAUmxAP32cZqxOoBaJNIAn/Aa8jX0Ym",
|
||||
"/TvDUUr56tybD4lFwaeaqDNoZfxSPd2l5b7K0eX7Qg45kHc/55sTcg5/O6qesirzDQt8+6ijPXX5cR+d",
|
||||
"4+ZKN2jGh+rqcnknusvPETbsMH1lmMsgspIDQ2wu0fzH+G+LRD8N71zfw9gkrgzztwrXQtX3CjZHnbs5",
|
||||
"bcrVPx2q6aOdrsRHdnRepvZ0Lin/cuHxGhmuOpbIk0vOqWKpHeWkN36xfw6UpuYsLlSX9tHSoeTcjvCZ",
|
||||
"v64c9ckb1zd6d3+7yMZDPvwDcr3u+7Axn3Vi5TL20I4jJBpTN+rvBgztdVAFrFil9TkF0Pyz5HVSsLO/",
|
||||
"PcUdZfl3AAAA//8=",
|
||||
"zFlbb9u4Ev4rBM8Bcg6gWDltcYB1n7rdYpFFu1vU6VO3MGhxbLORSIVDJWsE/u8LXnSLKd/WTvsUxaSG",
|
||||
"39y+maEeaaaKUkmQBun4kWrAUkkE9887rZW2D5mSBqSxj6wsc5ExI5RMv6GS9jfMllAw+/RvDXM6pv9K",
|
||||
"W6mpX8XUS1uv1wnlgJkWpRVCx/6YEbULYa8V9WYB0kwAUfhDSq1K0EZ4aJkGZoBPmQM1V7qwT5QzA5dG",
|
||||
"FEATalYl0DFFo4Vc0HXSvDNb2Xc2lgWP/lwoDrld6YP+BHcVoAFO3IbXBIrSrEgBTCIxSyD3ILnSF0g4",
|
||||
"zFmVm1EMklY5WNkgq4KOv1BRlDkUIA1o+jWyHw0z/Rd4bnXVlZR2S+LcmYMBHn3fMLydDijqAXeFZzmr",
|
||||
"OEQF+c1T9P6Ji7T6wV0lNHAPlbYAmuNqA8dE1voGO/U8mHRDoEWoZt8gMxZhE7z9wCkAkS1gN9x6Y1T2",
|
||||
"fciGvmyWGX/khrmYDebt1kqOiulbIePCSrbKFeuutfBLrezjEA6Eux4AIc3/X7WHC2lgAXp7OD0xphUZ",
|
||||
"wCbBTC3Ena784F3x1u3aNLuBv8xuCG5XTPpHb41n4ZjABdOZZjJbHkJDkhUQXdBQKDO0VKppycxyz+R0",
|
||||
"Z3Rf2wDcHHdYLgYTDzlwi257K7CBPYYjlJMhHA3Vb2aTVkW5R5CFfdGzDTMVRklD3MPUphJ2DuhkGZ9N",
|
||||
"Zyvjd++RlDlDM4V7Tzj7ZnJuMTiWws1a50oxKbXKABGQZJXWIE2+IqHujKIyA80MaIUI0ywX9YmRDYbp",
|
||||
"w9LvKem0AjpG3DBQB2jS90YfZN9IMRffMLzddPCMIUxdXxQJrC1E0FTyowjo/I3RrirSxHvdTnjb7u5R",
|
||||
"hMnjdPCg9K3RAIdwWgdlLbqx+lOJSeurBv9hRGcjYLBMDej1tE65bTHhnxEiDQ0XWOZsNR0k0YUwUyiY",
|
||||
"yAdXB1/N1ULI3ZD9tqQPpSO6i2FIsc8lj1rtaPRPMO4Jxr4m5Fx1HEZnKw2uP9V+FqFXo6vRlT1elSBZ",
|
||||
"KeiYvhxdjV66nsYsHe6UlSJlvBAybTNhAS4rrX5ugrrmdEx/BTOpY603fr24ujrZ8BVOiExfE9D3oMkS",
|
||||
"WG6WhElOKtts2XnMbnZ6OLLsahATcYm2SvitI/KOZUv/zwUSwYlAIgwShLvXhDPD7A92THKtNGFIfpv8",
|
||||
"8fvI/4uEzQ1o8p6huXS/XF7/Qv6jtHvDr91VoFekZJoVYED/lzANRIONP+BkBnOlgVjCrhH9aSO0b/mJ",
|
||||
"0cAKf6TzXRCGdPzlkdrIp0tgHHTdG41pDxJNOtbfWWTXSZDpoLcinT4Hivq6M1Rsu+v9dolOzX6sPE2W",
|
||||
"zaHc+cW/2osFn2xDkfwBzhnFjgEjYG+WQDKW50IuSIXgLhNshaoiID3RBJxuhv9Z8dVJIQYuW/c5yOgK",
|
||||
"1t/JOJVDxGvjJPSVPzgmrwHY3tjUvu92dNEIeC/QfGy7qX+kqzBQ4C6l6+Ft3SQJ05qtYnZ4k+ekVqAO",
|
||||
"EIURJXwBryWfJ0z6E9FekfK/Ux8+FCwSHmpDnSBW0sfwdM3X25ija+8zZcgOvfs6vzpA53jtCD1loPnG",
|
||||
"CvSpq5MtvPx1mznTZmgcTMabMMicPxPd8LNHGnYsfYHEaZDYkAM0ZC40/mD2b0mir4bPXN/DWCUukPip",
|
||||
"wrVQ9VxB5loVbk+rcrhSCdtHG12Jl+zMeR7u6Qwpz0w8PkaGWcca8mDKOTRY6owKt7GYPoanHQRVf4s4",
|
||||
"I0H1vnkM2CmAPVOWNKY4jqViNk1t26p15e/PzgZhKFHfSe6zNNxaEVNpuZl11zXIQTe/eFY3N0YjD8xy",
|
||||
"hh1lNPCD3W53/3Si7EjDNxH8Lo6cGKbtaOiZotKSPAizJGYpkARgI9LJEFJUaMgMiOA5bDp8ApKHDwtn",
|
||||
"4tn+Z4u9qPZ5Y8wZccmQhJvSM1PvsaHoeoT00f7ZQdFN0TwTP2+rX53adWpS9pofx8h946X11et+GXzM",
|
||||
"wUPp+wkKdd9vmJouybZMoR0iN+26hkxp7lb9JQ7RUDARYe+3Qa3vGQDNrfZxoXCa7Gh4e+tUMKk3Pcdg",
|
||||
"0Gei/QaEEADuW0vN55gQlfNzjwmnDPm6Ysm+IkTIwSxAsO2KMPXMUJTuclQY9FoPdC++xnRbl9OXs/7H",
|
||||
"02eeHPYpZ7Yv6HfHP0wlW6//DgAA//8=",
|
||||
}
|
||||
|
||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||
|
||||
@@ -29,6 +29,7 @@ type HookMatcher struct {
|
||||
type Options struct {
|
||||
Cwd string
|
||||
Model string
|
||||
SessionID string
|
||||
Resume string
|
||||
ForkSession bool
|
||||
GitName string
|
||||
@@ -45,12 +46,15 @@ type Event struct {
|
||||
}
|
||||
|
||||
type Block struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
IsError bool `json:"is_error"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
ToolUseID string `json:"tool_use_id"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
@@ -138,6 +142,7 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
|
||||
pending: map[string]chan controlResult{},
|
||||
hooks: map[string]HookFunc{},
|
||||
servers: map[string]*mcp.Server{},
|
||||
id: initialID(opts),
|
||||
}
|
||||
|
||||
args, err := s.buildArgs(opts)
|
||||
@@ -179,6 +184,16 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func initialID(opts Options) string {
|
||||
if opts.SessionID != "" {
|
||||
return opts.SessionID
|
||||
}
|
||||
if opts.ForkSession {
|
||||
return ""
|
||||
}
|
||||
return opts.Resume
|
||||
}
|
||||
|
||||
func (s *Session) buildArgs(opts Options) ([]string, error) {
|
||||
settings, err := json.Marshal(map[string]any{
|
||||
"attribution": map[string]any{"commit": "", "pr": ""},
|
||||
@@ -190,7 +205,7 @@ func (s *Session) buildArgs(opts Options) ([]string, error) {
|
||||
servers := map[string]any{}
|
||||
for _, srv := range opts.MCPServers {
|
||||
s.servers[srv.Name] = srv
|
||||
servers[srv.Name] = map[string]any{"type": "sdk", "name": srv.Name}
|
||||
servers[srv.Name] = map[string]any{"type": "sdk", "name": srv.Name, "alwaysLoad": true}
|
||||
}
|
||||
|
||||
mcpConfig, err := json.Marshal(map[string]any{"mcpServers": servers})
|
||||
@@ -212,6 +227,10 @@ func (s *Session) buildArgs(opts Options) ([]string, error) {
|
||||
args = append(args, "--model", opts.Model)
|
||||
}
|
||||
|
||||
if opts.SessionID != "" {
|
||||
args = append(args, "--session-id="+opts.SessionID)
|
||||
}
|
||||
|
||||
if opts.Resume != "" {
|
||||
args = append(args, "--resume="+opts.Resume)
|
||||
}
|
||||
@@ -342,6 +361,12 @@ func (s *Session) Close() error {
|
||||
return s.cmd.Wait()
|
||||
}
|
||||
|
||||
func (s *Session) Kill() {
|
||||
s.cmd.Process.Kill()
|
||||
<-s.done
|
||||
s.cmd.Wait()
|
||||
}
|
||||
|
||||
func (s *Session) write(v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -70,6 +71,9 @@ func TestCommitIdentityHooksAndReport(t *testing.T) {
|
||||
if len(tools) == 0 {
|
||||
t.Fatalf("PostToolUse hook never fired")
|
||||
}
|
||||
if slices.Contains(tools, "ToolSearch") {
|
||||
t.Fatalf("byre MCP tools were deferred behind ToolSearch")
|
||||
}
|
||||
|
||||
log := git(t, repo, "log", "-1", "--format=%an <%ae>%n%cn <%ce>%n%B")
|
||||
want := gitName + " <" + gitEmail + ">\n" + gitName + " <" + gitEmail + ">\nadd hello"
|
||||
@@ -119,13 +123,26 @@ func TestInterrupt(t *testing.T) {
|
||||
ctx := testContext(t)
|
||||
s := start(t, ctx, claude.Options{Cwd: scratchRepo(t)})
|
||||
|
||||
send(t, s, "Run the bash command `sleep 120` in the foreground (not in the background), then say done.")
|
||||
send(t, s, "Run the bash command `ping -c 120 127.0.0.1` in the foreground (not in the background) and wait for it to finish, then say done.")
|
||||
|
||||
interruptedAt := time.Time{}
|
||||
waitResult(t, ctx, s, func(ev claude.Event) {
|
||||
if ev.Type == "user" {
|
||||
blocks, err := ev.Blocks()
|
||||
if err != nil {
|
||||
t.Fatalf("parse user: %v", err)
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if b.Type == "tool_result" && (!b.IsError || strings.Contains(string(b.Content), "Blocked")) {
|
||||
t.Fatalf("command finished or was refused instead of being interrupted: %s", b.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !interruptedAt.IsZero() || !hasToolUse(t, ev) {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
interruptedAt = time.Now()
|
||||
err := s.Interrupt(ctx)
|
||||
if err != nil {
|
||||
|
||||
+39
-49
File diff suppressed because one or more lines are too long
+19
-19
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
-15
File diff suppressed because one or more lines are too long
+120
-2
@@ -15,6 +15,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/agents"
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
agents *agents.Manager
|
||||
hub *hub
|
||||
worktreeRoot string
|
||||
startedAt time.Time
|
||||
@@ -52,10 +54,18 @@ func New(dataDir string, spa fs.FS) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.agents = agents.New(s.store)
|
||||
err = s.agents.Recover(context.Background())
|
||||
if err != nil {
|
||||
s.store.Close()
|
||||
return nil, fmt.Errorf("recover sessions: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
s.agents.Close()
|
||||
return s.store.Close()
|
||||
}
|
||||
|
||||
@@ -242,7 +252,7 @@ func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID st
|
||||
Title: req.Title,
|
||||
Branch: "byre/" + id,
|
||||
WorktreePath: filepath.Join(s.worktreeRoot, p.Id, id),
|
||||
Status: api.Active,
|
||||
Status: api.TaskStatusActive,
|
||||
CreatedBy: actor(r),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
@@ -277,7 +287,7 @@ func (s *Server) CompleteTask(w http.ResponseWriter, r *http.Request, taskID str
|
||||
return
|
||||
}
|
||||
|
||||
if t.Status != api.Active {
|
||||
if t.Status != api.TaskStatusActive {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s", t.Id, t.Status))
|
||||
return
|
||||
}
|
||||
@@ -288,6 +298,12 @@ func (s *Server) CompleteTask(w http.ResponseWriter, r *http.Request, taskID str
|
||||
return
|
||||
}
|
||||
|
||||
err = s.agents.StopTask(r.Context(), t.Id, actor(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = git.RemoveWorktree(p.RepoPath, t.WorktreePath, t.Branch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
@@ -324,9 +340,111 @@ func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
Projects: c.Projects,
|
||||
ActiveTasks: c.ActiveTasks,
|
||||
SseClients: s.hub.count(),
|
||||
LiveAgents: s.agents.LiveCount(),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Prompt) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("prompt is 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)
|
||||
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
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("text is required"))
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := s.agents.Send(r.Context(), sessionID, actor(r), req.Text)
|
||||
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.ErrNoGitIdentity):
|
||||
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()
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestProjectAndTaskLifecycle(t *testing.T) {
|
||||
|
||||
done := api.Task{}
|
||||
code = h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, &done)
|
||||
if code != http.StatusOK || done.Status != api.Completed || done.CompletedAt == nil {
|
||||
if code != http.StatusOK || done.Status != api.TaskStatusCompleted || done.CompletedAt == nil {
|
||||
t.Fatalf("complete: %d %+v", code, done)
|
||||
}
|
||||
|
||||
|
||||
+178
-14
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
const schema = `
|
||||
var migrations = []string{`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
login TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
@@ -61,7 +61,24 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
`, `
|
||||
ALTER TABLE events ADD COLUMN agent_session_id TEXT;
|
||||
|
||||
CREATE TABLE agent_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
vendor TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
vendor_session_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(login),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX agent_sessions_task ON agent_sessions(task_id, created_at);
|
||||
CREATE INDEX agent_sessions_state ON agent_sessions(state);
|
||||
`}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
@@ -77,15 +94,49 @@ func Open(path string, onEvent func(api.Event)) (*Store, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = db.Exec(schema)
|
||||
err = migrate(db)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Store{db: db, path: path, onEvent: onEvent}, nil
|
||||
}
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
version := 0
|
||||
err := db.QueryRow(`PRAGMA user_version`).Scan(&version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := version; i < len(migrations); i++ {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(migrations[i])
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %d: %w", i, err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, i+1))
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %d: %w", i, err)
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
@@ -144,7 +195,7 @@ func (s *Store) CreateProject(ctx context.Context, p api.Project) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{Kind: "project.created", ProjectId: &p.Id, Actor: p.CreatedBy, Payload: toPayload(p)})
|
||||
return emit(api.Event{Kind: "project.created", ProjectId: &p.Id, Actor: p.CreatedBy, Payload: ToPayload(p)})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,7 +234,7 @@ func (s *Store) CreateTask(ctx context.Context, t api.Task) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{Kind: "task.created", ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: t.CreatedBy, Payload: toPayload(t)})
|
||||
return emit(api.Event{Kind: "task.created", ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: t.CreatedBy, Payload: ToPayload(t)})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,7 +267,7 @@ func (s *Store) GetTask(ctx context.Context, id string) (api.Task, error) {
|
||||
func (s *Store) CompleteTask(ctx context.Context, id, actor string) (api.Task, error) {
|
||||
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status = ?, completed_at = ? WHERE id = ? AND status = ?`,
|
||||
string(api.Completed), now(), id, string(api.Active))
|
||||
string(api.TaskStatusCompleted), now(), id, string(api.TaskStatusActive))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -243,7 +294,7 @@ func (s *Store) CompleteTask(ctx context.Context, id, actor string) (api.Task, e
|
||||
|
||||
func (s *Store) EventsAfter(ctx context.Context, after int64, limit int) ([]api.Event, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT seq, kind, project_id, task_id, actor, payload, created_at
|
||||
SELECT seq, kind, project_id, task_id, agent_session_id, actor, payload, created_at
|
||||
FROM events WHERE seq > ? ORDER BY seq LIMIT ?
|
||||
`, after, limit)
|
||||
if err != nil {
|
||||
@@ -256,7 +307,7 @@ func (s *Store) EventsAfter(ctx context.Context, after int64, limit int) ([]api.
|
||||
ev := api.Event{}
|
||||
payload := ""
|
||||
createdAt := ""
|
||||
err := rows.Scan(&ev.Seq, &ev.Kind, &ev.ProjectId, &ev.TaskId, &ev.Actor, &payload, &createdAt)
|
||||
err := rows.Scan(&ev.Seq, &ev.Kind, &ev.ProjectId, &ev.TaskId, &ev.AgentSessionId, &ev.Actor, &payload, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -273,6 +324,100 @@ func (s *Store) EventsAfter(ctx context.Context, after int64, limit int) ([]api.
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AppendEvent(ctx context.Context, ev api.Event) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
return emit(ev)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, sess api.AgentSession, projectID string) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO agent_sessions (id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, sess.Id, sess.TaskId, string(sess.Vendor), sess.Model, sess.VendorSessionId, string(sess.State), string(sess.Role), sess.CreatedBy, formatTime(sess.CreatedAt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emit(api.Event{
|
||||
Kind: "session.created",
|
||||
ProjectId: &projectID,
|
||||
TaskId: &sess.TaskId,
|
||||
AgentSessionId: &sess.Id,
|
||||
Actor: sess.CreatedBy,
|
||||
Payload: ToPayload(sess),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) GetSession(ctx context.Context, id string) (api.AgentSession, error) {
|
||||
sess, err := scanSession(s.db.QueryRowContext(ctx, `SELECT `+sessionColumns+` FROM agent_sessions WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return sess, ErrNotFound
|
||||
}
|
||||
return sess, err
|
||||
}
|
||||
|
||||
func (s *Store) ListSessions(ctx context.Context, taskID string) ([]api.AgentSession, error) {
|
||||
return s.querySessions(ctx, `SELECT `+sessionColumns+` FROM agent_sessions WHERE task_id = ? ORDER BY created_at`, taskID)
|
||||
}
|
||||
|
||||
func (s *Store) SessionsInState(ctx context.Context, state api.AgentSessionState) ([]api.AgentSession, error) {
|
||||
return s.querySessions(ctx, `SELECT `+sessionColumns+` FROM agent_sessions WHERE state = ? ORDER BY created_at`, string(state))
|
||||
}
|
||||
|
||||
func (s *Store) querySessions(ctx context.Context, query string, args ...any) ([]api.AgentSession, error) {
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sessions := []api.AgentSession{}
|
||||
for rows.Next() {
|
||||
sess, err := scanSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetSessionState(ctx context.Context, id string, state api.AgentSessionState, actor string, payload map[string]any) error {
|
||||
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
|
||||
taskID := ""
|
||||
projectID := ""
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT t.id, t.project_id FROM agent_sessions s JOIN tasks t ON t.id = s.task_id WHERE s.id = ?
|
||||
`, id).Scan(&taskID, &projectID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET state = ? WHERE id = ?`, string(state), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if payload == nil {
|
||||
payload = map[string]any{}
|
||||
}
|
||||
payload["state"] = string(state)
|
||||
return emit(api.Event{
|
||||
Kind: "session.state",
|
||||
ProjectId: &projectID,
|
||||
TaskId: &taskID,
|
||||
AgentSessionId: &id,
|
||||
Actor: actor,
|
||||
Payload: payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type Counts struct {
|
||||
LastEventSeq int64
|
||||
Projects int
|
||||
@@ -286,7 +431,7 @@ func (s *Store) Counts(ctx context.Context) (Counts, error) {
|
||||
(SELECT COALESCE(MAX(seq), 0) FROM events),
|
||||
(SELECT COUNT(*) FROM projects),
|
||||
(SELECT COUNT(*) FROM tasks WHERE status = ?)
|
||||
`, string(api.Active)).Scan(&c.LastEventSeq, &c.Projects, &c.ActiveTasks)
|
||||
`, string(api.TaskStatusActive)).Scan(&c.LastEventSeq, &c.Projects, &c.ActiveTasks)
|
||||
return c, err
|
||||
}
|
||||
|
||||
@@ -310,9 +455,9 @@ func (s *Store) write(ctx context.Context, fn func(tx *sql.Tx, emit emitFunc) er
|
||||
return err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO events (kind, project_id, task_id, actor, payload, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING seq
|
||||
`, ev.Kind, ev.ProjectId, ev.TaskId, ev.Actor, string(payload), formatTime(ev.CreatedAt)).Scan(&ev.Seq)
|
||||
INSERT INTO events (kind, project_id, task_id, agent_session_id, actor, payload, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING seq
|
||||
`, ev.Kind, ev.ProjectId, ev.TaskId, ev.AgentSessionId, ev.Actor, string(payload), formatTime(ev.CreatedAt)).Scan(&ev.Seq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -340,6 +485,8 @@ const projectColumns = `id, name, repo_path, default_branch, remote, created_by,
|
||||
|
||||
const taskColumns = `id, project_id, title, branch, worktree_path, base_ref, status, created_by, created_at, completed_at`
|
||||
|
||||
const sessionColumns = `id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at`
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
@@ -379,7 +526,24 @@ func scanTask(row scanner) (api.Task, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func toPayload(v any) map[string]any {
|
||||
func scanSession(row scanner) (api.AgentSession, error) {
|
||||
sess := api.AgentSession{}
|
||||
vendor := ""
|
||||
state := ""
|
||||
role := ""
|
||||
createdAt := ""
|
||||
err := row.Scan(&sess.Id, &sess.TaskId, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.CreatedBy, &createdAt)
|
||||
if err != nil {
|
||||
return sess, err
|
||||
}
|
||||
sess.Vendor = api.AgentSessionVendor(vendor)
|
||||
sess.State = api.AgentSessionState(state)
|
||||
sess.Role = api.AgentSessionRole(role)
|
||||
sess.CreatedAt, err = parseTime(createdAt)
|
||||
return sess, err
|
||||
}
|
||||
|
||||
func ToPayload(v any) map[string]any {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
Reference in New Issue
Block a user