Run Claude sessions on tasks with event streaming and restart recovery

This commit is contained in:
Ian Gulliver
2026-10-13 22:30:39 -07:00
parent 2328904131
commit 09951e64e7
16 changed files with 1630 additions and 170 deletions
+178 -14
View File
@@ -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)