diff --git a/server/internal/handler/autopilot.go b/server/internal/handler/autopilot.go index 59d9d6956..0cde75d46 100644 --- a/server/internal/handler/autopilot.go +++ b/server/internal/handler/autopilot.go @@ -45,6 +45,13 @@ type AutopilotResponse struct { LastRunAt *string `json:"last_run_at"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` + + // List-endpoint-only derived fields (absent on the detail/create/update + // responses and on older servers — clients must treat them as optional). + // Enabled triggers only; last_run_status is the most recent run's status. + TriggerKinds []string `json:"trigger_kinds,omitempty"` + NextRunAt *string `json:"next_run_at,omitempty"` + LastRunStatus *string `json:"last_run_status,omitempty"` } type AutopilotTriggerResponse struct { @@ -326,8 +333,17 @@ func (h *Handler) ListAutopilots(w http.ResponseWriter, r *http.Request) { } resp := make([]AutopilotResponse, len(autopilots)) - for i, a := range autopilots { - resp[i] = autopilotToResponse(a) + for i, row := range autopilots { + r := autopilotToResponse(row.Autopilot) + r.TriggerKinds = row.TriggerKinds + if row.NextRunAt.Valid { + r.NextRunAt = timestampToPtr(row.NextRunAt) + } + if row.LastRunStatus != "" { + s := row.LastRunStatus + r.LastRunStatus = &s + } + resp[i] = r } writeJSON(w, http.StatusOK, map[string]any{"autopilots": resp, "total": len(resp)}) } diff --git a/server/internal/handler/autopilot_list_test.go b/server/internal/handler/autopilot_list_test.go new file mode 100644 index 000000000..b1704c181 --- /dev/null +++ b/server/internal/handler/autopilot_list_test.go @@ -0,0 +1,116 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" +) + +// insertListTestAutopilot creates a bare autopilot row and registers cleanup. +// Triggers/runs cascade on delete. +func insertListTestAutopilot(t *testing.T, agentID, title string) string { + t.Helper() + var id string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO autopilot ( + workspace_id, title, assignee_type, assignee_id, + status, execution_mode, created_by_type, created_by_id + ) + VALUES ($1, $2, 'agent', $3, 'active', 'run_only', 'member', $4) + RETURNING id + `, testWorkspaceID, title, agentID, testUserID).Scan(&id); err != nil { + t.Fatalf("failed to insert test autopilot: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, id) + }) + return id +} + +// TestListAutopilots_DerivedFields guards the three list-only derived +// columns added for the list UI (trigger badges, next run, last-run +// outcome): trigger_kinds/next_run_at must consider ENABLED triggers only, +// last_run_status must be the most recent run's status, and all three must +// be omitted entirely when there is nothing to derive (the optional-field +// contract older clients rely on). +func TestListAutopilots_DerivedFields(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + + agentID := createHandlerTestAgent(t, "autopilot-list-derived-agent", []byte(`[]`)) + withData := insertListTestAutopilot(t, agentID, "list-derived-with-data") + bare := insertListTestAutopilot(t, agentID, "list-derived-bare") + + // Enabled schedule (carries next_run_at), enabled webhook, and a + // DISABLED api trigger that must not leak into trigger_kinds. + for _, q := range []string{ + `INSERT INTO autopilot_trigger (autopilot_id, kind, enabled, cron_expression, timezone, next_run_at) + VALUES ($1, 'schedule', true, '0 9 * * *', 'UTC', now() + interval '1 hour')`, + `INSERT INTO autopilot_trigger (autopilot_id, kind, enabled, webhook_token) + VALUES ($1, 'webhook', true, 'list-derived-tok')`, + `INSERT INTO autopilot_trigger (autopilot_id, kind, enabled) + VALUES ($1, 'api', false)`, + } { + if _, err := testPool.Exec(ctx, q, withData); err != nil { + t.Fatalf("failed to insert trigger: %v", err) + } + } + + // Older completed run, newer failed run — last_run_status must be the + // newest by triggered_at, not insertion order. + for _, q := range []string{ + `INSERT INTO autopilot_run (autopilot_id, source, status, triggered_at) + VALUES ($1, 'schedule', 'failed', now() - interval '1 hour')`, + `INSERT INTO autopilot_run (autopilot_id, source, status, triggered_at) + VALUES ($1, 'schedule', 'completed', now() - interval '2 hour')`, + } { + if _, err := testPool.Exec(ctx, q, withData); err != nil { + t.Fatalf("failed to insert run: %v", err) + } + } + + w := httptest.NewRecorder() + testHandler.ListAutopilots(w, newRequest("GET", "/api/autopilots", nil)) + if w.Code != 200 { + t.Fatalf("ListAutopilots: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body struct { + Autopilots []map[string]any `json:"autopilots"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to decode body: %v", err) + } + rows := make(map[string]map[string]any) + for _, row := range body.Autopilots { + rows[row["id"].(string)] = row + } + + rich, ok := rows[withData] + if !ok { + t.Fatalf("autopilot %s missing from list", withData) + } + kinds, _ := rich["trigger_kinds"].([]any) + if len(kinds) != 2 || kinds[0] != "schedule" || kinds[1] != "webhook" { + t.Errorf("trigger_kinds: expected [schedule webhook] (enabled only, sorted), got %v", rich["trigger_kinds"]) + } + if s, _ := rich["next_run_at"].(string); s == "" { + t.Errorf("next_run_at: expected the enabled schedule trigger's time, got %v", rich["next_run_at"]) + } + if rich["last_run_status"] != "failed" { + t.Errorf("last_run_status: expected most recent run (failed), got %v", rich["last_run_status"]) + } + + plain, ok := rows[bare] + if !ok { + t.Fatalf("autopilot %s missing from list", bare) + } + for _, key := range []string{"trigger_kinds", "next_run_at", "last_run_status"} { + if _, present := plain[key]; present { + t.Errorf("%s: expected field omitted for autopilot with no triggers/runs, got %v", key, plain[key]) + } + } +} diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index b71c287ef..ceb322fcc 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -670,10 +670,29 @@ func (q *Queries) ListAutopilotTriggers(ctx context.Context, autopilotID pgtype. const listAutopilots = `-- name: ListAutopilots :many -SELECT id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type, project_id FROM autopilot -WHERE workspace_id = $1 - AND ($2::text IS NULL OR status = $2) -ORDER BY created_at DESC +SELECT + a.id, a.workspace_id, a.title, a.description, a.assignee_id, a.status, a.execution_mode, a.issue_title_template, a.created_by_type, a.created_by_id, a.last_run_at, a.created_at, a.updated_at, a.assignee_type, a.project_id, + ( + SELECT array_agg(DISTINCT t.kind ORDER BY t.kind) + FROM autopilot_trigger t + WHERE t.autopilot_id = a.id AND t.enabled + )::text[] AS trigger_kinds, + ( + SELECT min(t.next_run_at) + FROM autopilot_trigger t + WHERE t.autopilot_id = a.id AND t.enabled AND t.kind = 'schedule' + )::timestamptz AS next_run_at, + COALESCE(( + SELECT r.status + FROM autopilot_run r + WHERE r.autopilot_id = a.id + ORDER BY r.triggered_at DESC + LIMIT 1 + ), '')::text AS last_run_status +FROM autopilot a +WHERE a.workspace_id = $1 + AND ($2::text IS NULL OR a.status = $2) +ORDER BY a.created_at DESC ` type ListAutopilotsParams struct { @@ -681,34 +700,50 @@ type ListAutopilotsParams struct { Status pgtype.Text `json:"status"` } +type ListAutopilotsRow struct { + Autopilot Autopilot `json:"autopilot"` + TriggerKinds []string `json:"trigger_kinds"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + LastRunStatus string `json:"last_run_status"` +} + // ===================== // Autopilot CRUD // ===================== -func (q *Queries) ListAutopilots(ctx context.Context, arg ListAutopilotsParams) ([]Autopilot, error) { +// List rows carry three derived columns the list UI needs (trigger badges, +// next run, last-run outcome) so the page never has to N+1 into the detail +// endpoint. trigger_kinds/next_run_at only consider ENABLED triggers — the +// columns answer "how does this fire today", not "what is configured". +// last_run_status is COALESCEd to ” (never ran) because sqlc cannot infer +// nullability through a scalar subquery; the handler maps ” back to omitted. +func (q *Queries) ListAutopilots(ctx context.Context, arg ListAutopilotsParams) ([]ListAutopilotsRow, error) { rows, err := q.db.Query(ctx, listAutopilots, arg.WorkspaceID, arg.Status) if err != nil { return nil, err } defer rows.Close() - items := []Autopilot{} + items := []ListAutopilotsRow{} for rows.Next() { - var i Autopilot + var i ListAutopilotsRow if err := rows.Scan( - &i.ID, - &i.WorkspaceID, - &i.Title, - &i.Description, - &i.AssigneeID, - &i.Status, - &i.ExecutionMode, - &i.IssueTitleTemplate, - &i.CreatedByType, - &i.CreatedByID, - &i.LastRunAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.AssigneeType, - &i.ProjectID, + &i.Autopilot.ID, + &i.Autopilot.WorkspaceID, + &i.Autopilot.Title, + &i.Autopilot.Description, + &i.Autopilot.AssigneeID, + &i.Autopilot.Status, + &i.Autopilot.ExecutionMode, + &i.Autopilot.IssueTitleTemplate, + &i.Autopilot.CreatedByType, + &i.Autopilot.CreatedByID, + &i.Autopilot.LastRunAt, + &i.Autopilot.CreatedAt, + &i.Autopilot.UpdatedAt, + &i.Autopilot.AssigneeType, + &i.Autopilot.ProjectID, + &i.TriggerKinds, + &i.NextRunAt, + &i.LastRunStatus, ); err != nil { return nil, err } diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index d35101aa8..c33b5a205 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -3,10 +3,35 @@ -- ===================== -- name: ListAutopilots :many -SELECT * FROM autopilot -WHERE workspace_id = $1 - AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) -ORDER BY created_at DESC; +-- List rows carry three derived columns the list UI needs (trigger badges, +-- next run, last-run outcome) so the page never has to N+1 into the detail +-- endpoint. trigger_kinds/next_run_at only consider ENABLED triggers — the +-- columns answer "how does this fire today", not "what is configured". +-- last_run_status is COALESCEd to '' (never ran) because sqlc cannot infer +-- nullability through a scalar subquery; the handler maps '' back to omitted. +SELECT + sqlc.embed(a), + ( + SELECT array_agg(DISTINCT t.kind ORDER BY t.kind) + FROM autopilot_trigger t + WHERE t.autopilot_id = a.id AND t.enabled + )::text[] AS trigger_kinds, + ( + SELECT min(t.next_run_at) + FROM autopilot_trigger t + WHERE t.autopilot_id = a.id AND t.enabled AND t.kind = 'schedule' + )::timestamptz AS next_run_at, + COALESCE(( + SELECT r.status + FROM autopilot_run r + WHERE r.autopilot_id = a.id + ORDER BY r.triggered_at DESC + LIMIT 1 + ), '')::text AS last_run_status +FROM autopilot a +WHERE a.workspace_id = $1 + AND (sqlc.narg('status')::text IS NULL OR a.status = sqlc.narg('status')) +ORDER BY a.created_at DESC; -- name: GetAutopilot :one SELECT * FROM autopilot