Run Claude sessions on tasks with event streaming and restart recovery
This commit is contained in:
+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