Add a per-user lead agent and make the right pane task-focused
This commit is contained in:
+164
-43
@@ -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, ¤t)
|
||||
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, ¤t)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user