feat(autopilots): derive trigger kinds, next run, last run status in list

The list endpoint only selected the autopilot table, so the list UI
could not answer "is this automation working" without N+1 detail
calls. Each list row now carries trigger_kinds + next_run_at (enabled
triggers only — the columns describe how it fires today) and
last_run_status (most recent run). Fields are omitempty and absent
from detail/create/update responses; clients must treat them as
optional per the API compatibility rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-06-12 15:44:34 +08:00
parent 249ee0c260
commit d94ffd7a7a
4 changed files with 220 additions and 28 deletions

View File

@@ -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)})
}

View File

@@ -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])
}
}
}

View File

@@ -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
}

View File

@@ -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