mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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)})
|
||||
}
|
||||
|
||||
116
server/internal/handler/autopilot_list_test.go
Normal file
116
server/internal/handler/autopilot_list_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user