Add a per-user lead agent and make the right pane task-focused

This commit is contained in:
Ian Gulliver
2026-10-15 09:40:30 -07:00
parent d212590cdc
commit bdd1ae48f0
68 changed files with 3332 additions and 1364 deletions
+164 -43
View File
@@ -208,6 +208,42 @@ WHERE kind = 'question' AND json_type(payload, '$.recommended_option') IS NOT NU
UPDATE inbox_items SET snoozed_until = NULL WHERE snoozed_until IS NOT NULL;
`}, {"add-task-todo-item", `
ALTER TABLE tasks ADD COLUMN todo_item TEXT NOT NULL DEFAULT '';
`}, {"add-lead-agents", `
CREATE TABLE agent_sessions_next (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks(id),
owner TEXT NOT NULL DEFAULT '',
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
);
INSERT INTO agent_sessions_next (id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at)
SELECT id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at FROM agent_sessions;
DROP TABLE agent_sessions;
ALTER TABLE agent_sessions_next RENAME TO agent_sessions;
CREATE INDEX agent_sessions_task ON agent_sessions(task_id, created_at);
CREATE INDEX agent_sessions_state ON agent_sessions(state);
CREATE INDEX agent_sessions_owner ON agent_sessions(owner, created_at);
ALTER TABLE events ADD COLUMN owner TEXT;
CREATE INDEX events_owner ON events(owner, seq) WHERE owner IS NOT NULL;
ALTER TABLE users ADD COLUMN focus TEXT NOT NULL DEFAULT '{"tabs":["transcript"],"tab":"transcript"}';
CREATE TABLE lead_inputs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL REFERENCES users(login),
kind TEXT NOT NULL,
actor TEXT NOT NULL,
text TEXT NOT NULL,
images TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
CREATE INDEX lead_inputs_owner ON lead_inputs(owner, id);
`}}
type Store struct {
@@ -315,7 +351,20 @@ func appliedMigrations(db *sql.DB) (map[string]bool, error) {
}
func applyMigration(db *sql.DB, m migration) error {
tx, err := db.Begin()
ctx := context.Background()
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`)
if err != nil {
return err
}
defer conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return err
}
@@ -326,6 +375,11 @@ func applyMigration(db *sql.DB, m migration) error {
return err
}
err = checkForeignKeys(tx)
if err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO migrations (name, applied_at) VALUES (?, ?)`, m.name, now())
if err != nil {
return err
@@ -334,6 +388,27 @@ func applyMigration(db *sql.DB, m migration) error {
return tx.Commit()
}
func checkForeignKeys(tx *sql.Tx) error {
rows, err := tx.Query(`PRAGMA foreign_key_check`)
if err != nil {
return err
}
defer rows.Close()
if rows.Next() {
table := ""
rowid := sql.NullInt64{}
parent := ""
fkid := 0
err = rows.Scan(&table, &rowid, &parent, &fkid)
if err != nil {
return err
}
return fmt.Errorf("foreign key violation: %s row %d references a missing %s", table, rowid.Int64, parent)
}
return rows.Err()
}
func (s *Store) Close() error {
return s.db.Close()
}
@@ -549,11 +624,13 @@ func (s *Store) Participants(ctx context.Context, taskID string) ([]api.Particip
return participants, rows.Err()
}
func (s *Store) EventsAfter(ctx context.Context, after int64, limit int) ([]api.Event, error) {
const eventColumns = `seq, kind, project_id, task_id, agent_session_id, owner, actor, payload, created_at`
func (s *Store) EventsAfter(ctx context.Context, viewer string, after int64, limit int) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT seq, kind, project_id, task_id, agent_session_id, actor, payload, created_at
FROM events WHERE seq > ? ORDER BY seq LIMIT ?
`, after, limit)
SELECT `+eventColumns+`
FROM events WHERE seq > ? AND (owner IS NULL OR owner = ?) ORDER BY seq LIMIT ?
`, after, viewer, limit)
if err != nil {
return nil, err
}
@@ -562,7 +639,7 @@ func (s *Store) EventsAfter(ctx context.Context, after int64, limit int) ([]api.
func (s *Store) TaskEvents(ctx context.Context, taskID string, after int64, limit int) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT seq, kind, project_id, task_id, agent_session_id, actor, payload, created_at
SELECT `+eventColumns+`
FROM events WHERE task_id = ? AND seq > ? ORDER BY seq LIMIT ?
`, taskID, after, limit)
if err != nil {
@@ -571,6 +648,27 @@ func (s *Store) TaskEvents(ctx context.Context, taskID string, after int64, limi
return scanEvents(rows)
}
func (s *Store) TaskEventsTail(ctx context.Context, taskID string, limit int) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT * FROM (SELECT `+eventColumns+` FROM events WHERE task_id = ? ORDER BY seq DESC LIMIT ?) ORDER BY seq
`, taskID, limit)
if err != nil {
return nil, err
}
return scanEvents(rows)
}
func (s *Store) LeadEvents(ctx context.Context, owner string, after int64, limit int) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT `+eventColumns+`
FROM events WHERE owner = ? AND agent_session_id IS NOT NULL AND seq > ? ORDER BY seq LIMIT ?
`, owner, after, limit)
if err != nil {
return nil, err
}
return scanEvents(rows)
}
func scanEvents(rows *sql.Rows) ([]api.Event, error) {
defer rows.Close()
@@ -579,7 +677,7 @@ func scanEvents(rows *sql.Rows) ([]api.Event, error) {
ev := api.Event{}
payload := ""
createdAt := ""
err := rows.Scan(&ev.Seq, &ev.Kind, &ev.ProjectId, &ev.TaskId, &ev.AgentSessionId, &ev.Actor, &payload, &createdAt)
err := rows.Scan(&ev.Seq, &ev.Kind, &ev.ProjectId, &ev.TaskId, &ev.AgentSessionId, &ev.Owner, &ev.Actor, &payload, &createdAt)
if err != nil {
return nil, err
}
@@ -612,23 +710,58 @@ func (s *Store) Broadcast(ev api.Event) {
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))
INSERT INTO agent_sessions (id, task_id, owner, vendor, model, vendor_session_id, state, role, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, sess.Id, nullable(sess.TaskId), sess.Owner, 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),
})
return emit(sessionEvent("session.created", projectID, sess.TaskId, sess.Owner, sess.Id, sess.CreatedBy, ToPayload(sess)))
})
}
func sessionEvent(kind, projectID, taskID, owner, sessionID, actor string, payload map[string]any) api.Event {
return api.Event{
Kind: kind,
ProjectId: nullable(projectID),
TaskId: nullable(taskID),
AgentSessionId: &sessionID,
Owner: nullable(owner),
Actor: actor,
Payload: payload,
}
}
func nullable(s string) *string {
if s == "" {
return nil
}
return &s
}
func (s *Store) LeadSessions(ctx context.Context, owner string) ([]api.AgentSession, error) {
return s.querySessions(ctx, `SELECT `+sessionColumns+` FROM agent_sessions WHERE owner = ? ORDER BY created_at`, owner)
}
func (s *Store) LeadOwners(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT owner FROM agent_sessions WHERE owner != '' ORDER BY owner`)
if err != nil {
return nil, err
}
defer rows.Close()
owners := []string{}
for rows.Next() {
owner := ""
err = rows.Scan(&owner)
if err != nil {
return nil, err
}
owners = append(owners, owner)
}
return owners, rows.Err()
}
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) {
@@ -677,10 +810,11 @@ func (s *Store) transitionSession(ctx context.Context, id string, from *api.Agen
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
taskID := ""
projectID := ""
owner := ""
current := ""
err := tx.QueryRowContext(ctx, `
SELECT t.id, t.project_id, s.state FROM agent_sessions s JOIN tasks t ON t.id = s.task_id WHERE s.id = ?
`, id).Scan(&taskID, &projectID, &current)
SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner, s.state FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ?
`, id).Scan(&taskID, &projectID, &owner, &current)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -701,14 +835,7 @@ func (s *Store) transitionSession(ctx context.Context, id string, from *api.Agen
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,
})
return emit(sessionEvent("session.state", projectID, taskID, owner, id, actor, payload))
})
}
@@ -716,9 +843,10 @@ func (s *Store) SetSessionModel(ctx context.Context, id, model, actor string) er
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
taskID := ""
projectID := ""
owner := ""
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)
SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ?
`, id).Scan(&taskID, &projectID, &owner)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -731,14 +859,7 @@ func (s *Store) SetSessionModel(ctx context.Context, id, model, actor string) er
return err
}
return emit(api.Event{
Kind: "session.model",
ProjectId: &projectID,
TaskId: &taskID,
AgentSessionId: &id,
Actor: actor,
Payload: map[string]any{"model": model},
})
return emit(sessionEvent("session.model", projectID, taskID, owner, id, actor, map[string]any{"model": model}))
})
}
@@ -779,9 +900,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, 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)
INSERT INTO events (kind, project_id, task_id, agent_session_id, owner, actor, payload, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING seq
`, ev.Kind, ev.ProjectId, ev.TaskId, ev.AgentSessionId, ev.Owner, ev.Actor, string(payload), formatTime(ev.CreatedAt)).Scan(&ev.Seq)
if err != nil {
return err
}
@@ -809,7 +930,7 @@ const projectColumns = `id, name, repo_path, default_branch, remote, created_by,
const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, status, created_by, created_at, closed_at, merge_commit, todo_item`
const sessionColumns = `id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at`
const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, created_by, created_at`
type scanner interface {
Scan(dest ...any) error
@@ -867,7 +988,7 @@ func scanSession(row scanner) (api.AgentSession, error) {
state := ""
role := ""
createdAt := ""
err := row.Scan(&sess.Id, &sess.TaskId, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.CreatedBy, &createdAt)
err := row.Scan(&sess.Id, &sess.TaskId, &sess.Owner, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.CreatedBy, &createdAt)
if err != nil {
return sess, err
}