mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 14:19:13 +02:00
* refactor(server): make ParseUUID error-returning to prevent silent data loss (MUL-1410) util.ParseUUID previously swallowed errors and returned a zero pgtype.UUID on invalid input. When this zero UUID reached a write query (DELETE/UPDATE), the SQL matched zero rows and the handler returned 2xx success — producing silent data corruption. #1661 (DeleteIssue with identifier-style ID) was the visible symptom; PR #1680 patched that one site, this commit closes the class of bug. Changes: - util.ParseUUID now returns (pgtype.UUID, error). Add util.MustParseUUID for trusted round-trips that should panic on invalid input. - handler/handler.go: parseUUID wrapper now calls MustParseUUID — any unguarded user-input string reaching it surfaces as a recovered panic (chi middleware.Recoverer → 500) instead of silently corrupting data. Add parseUUIDOrBadRequest(w, s, fieldName) for handler entry points. - Convert every Queries.Delete*/Update* call site reachable from raw user input (autopilot, comment, project, skill, skill_file, label, pin, attachment, feedback, issue assignee, daemon runtime, workspace) to validate UUIDs explicitly with parseUUIDOrBadRequest, returning 400 on invalid input. Where a resolved entity.ID is already in scope, write queries now use it directly instead of re-parsing the URL string. - Update getWorkspaceMember + loadIssueForUser to handle invalid UUIDs gracefully (404/400 instead of panic). - Update util/middleware/cmd-level callers (subscriber_listeners, notification_listeners, activity_listeners, scope_authorizer, middleware/workspace) to use the error-returning API. - Add server/internal/util/pgx_test.go covering valid/invalid input and the MustParseUUID panic contract. - Add TestDeleteIssueByIdentifier + TestDeleteIssueRejectsInvalidUUID regression tests in handler_test.go (the original #1661 bug + the invalid-input case). - Document the handler UUID parsing convention in CLAUDE.md so the rule is enforceable in future PR review. * fix(server): address GPT-Boy review of #1748 P1 fixes from PR #1748 review: 1. Migrate remaining request-boundary UUIDs to parseUUIDOrBadRequest so malformed input returns 400 instead of panic/500. Was missing on: - issue.go: workspace_id in CreateIssue/ChildIssueProgress/ListIssues/ SearchIssues/BatchUpdateIssues/BatchDeleteIssues; project_id / parent_issue_id / lead_id / assignee_id / assignee_ids / creator_id filters; batch issue_ids and assignee/parent/project fields in BatchUpdateIssues (skip on bad input via util.ParseUUID, matching the existing per-row continue semantics). - project.go: project id + workspace_id in GetProject/UpdateProject/ DeleteProject; lead_id in CreateProject/UpdateProject; workspace_id in ListProjects + SearchProjects. - handler.go: resolveActor now uses util.ParseUUID for X-Agent-ID / X-Task-ID headers; invalid UUID falls back to "member" (matches pre-existing semantics) instead of panicking. - issue.go: validateAssigneePair returns 400 on invalid workspace_id instead of panicking. 2. Fix issue:deleted WS event payloads to emit uuidToString(issue.ID) instead of the raw URL string. After an identifier-path delete ("MUL-7"), the previous payload would have leaked the identifier to subscribers, leaving stale entries in frontend caches that key by UUID. Updated DeleteIssue (issue.go:1341) and BatchDeleteIssues (issue.go:1641). The slog "issue deleted" log line also now records the resolved UUID so logs match the WS payload. 3. Extend TestDeleteIssueByIdentifier to subscribe to the bus and assert issue:deleted.payload.issue_id is the resolved UUID, not the identifier. * fix(server): validate remaining reviewed UUID inputs * fix(server): validate remaining handler UUID inputs * fix(server): finish request boundary UUID audit * fix(server): validate remaining request body UUIDs * fix(server): validate runtime path UUIDs * fix(server): validate remaining audit UUID inputs --------- Co-authored-by: Eve <eve@multica.ai>
699 lines
22 KiB
Go
699 lines
22 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// computeNextRun delegates to the shared cron helper in the service package.
|
|
func computeNextRun(cronExpr, timezone string) (time.Time, error) {
|
|
return service.ComputeNextRun(cronExpr, timezone)
|
|
}
|
|
|
|
// ── Response types ──────────────────────────────────────────────────────────
|
|
|
|
type AutopilotResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Title string `json:"title"`
|
|
Description *string `json:"description"`
|
|
AssigneeID string `json:"assignee_id"`
|
|
Status string `json:"status"`
|
|
ExecutionMode string `json:"execution_mode"`
|
|
IssueTitleTemplate *string `json:"issue_title_template"`
|
|
CreatedByType string `json:"created_by_type"`
|
|
CreatedByID string `json:"created_by_id"`
|
|
LastRunAt *string `json:"last_run_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type AutopilotTriggerResponse struct {
|
|
ID string `json:"id"`
|
|
AutopilotID string `json:"autopilot_id"`
|
|
Kind string `json:"kind"`
|
|
Enabled bool `json:"enabled"`
|
|
CronExpression *string `json:"cron_expression"`
|
|
Timezone *string `json:"timezone"`
|
|
NextRunAt *string `json:"next_run_at"`
|
|
WebhookToken *string `json:"webhook_token"`
|
|
Label *string `json:"label"`
|
|
LastFiredAt *string `json:"last_fired_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type AutopilotRunResponse struct {
|
|
ID string `json:"id"`
|
|
AutopilotID string `json:"autopilot_id"`
|
|
TriggerID *string `json:"trigger_id"`
|
|
Source string `json:"source"`
|
|
Status string `json:"status"`
|
|
IssueID *string `json:"issue_id"`
|
|
TaskID *string `json:"task_id"`
|
|
TriggeredAt string `json:"triggered_at"`
|
|
CompletedAt *string `json:"completed_at"`
|
|
FailureReason *string `json:"failure_reason"`
|
|
TriggerPayload any `json:"trigger_payload"`
|
|
Result any `json:"result"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// ── Converters ──────────────────────────────────────────────────────────────
|
|
|
|
func autopilotToResponse(a db.Autopilot) AutopilotResponse {
|
|
return AutopilotResponse{
|
|
ID: uuidToString(a.ID),
|
|
WorkspaceID: uuidToString(a.WorkspaceID),
|
|
Title: a.Title,
|
|
Description: textToPtr(a.Description),
|
|
AssigneeID: uuidToString(a.AssigneeID),
|
|
Status: a.Status,
|
|
ExecutionMode: a.ExecutionMode,
|
|
IssueTitleTemplate: textToPtr(a.IssueTitleTemplate),
|
|
CreatedByType: a.CreatedByType,
|
|
CreatedByID: uuidToString(a.CreatedByID),
|
|
LastRunAt: timestampToPtr(a.LastRunAt),
|
|
CreatedAt: timestampToString(a.CreatedAt),
|
|
UpdatedAt: timestampToString(a.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
func triggerToResponse(t db.AutopilotTrigger) AutopilotTriggerResponse {
|
|
return AutopilotTriggerResponse{
|
|
ID: uuidToString(t.ID),
|
|
AutopilotID: uuidToString(t.AutopilotID),
|
|
Kind: t.Kind,
|
|
Enabled: t.Enabled,
|
|
CronExpression: textToPtr(t.CronExpression),
|
|
Timezone: textToPtr(t.Timezone),
|
|
NextRunAt: timestampToPtr(t.NextRunAt),
|
|
WebhookToken: textToPtr(t.WebhookToken),
|
|
Label: textToPtr(t.Label),
|
|
LastFiredAt: timestampToPtr(t.LastFiredAt),
|
|
CreatedAt: timestampToString(t.CreatedAt),
|
|
UpdatedAt: timestampToString(t.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
func runToResponse(r db.AutopilotRun) AutopilotRunResponse {
|
|
var payload any
|
|
if r.TriggerPayload != nil {
|
|
json.Unmarshal(r.TriggerPayload, &payload)
|
|
}
|
|
var result any
|
|
if r.Result != nil {
|
|
json.Unmarshal(r.Result, &result)
|
|
}
|
|
return AutopilotRunResponse{
|
|
ID: uuidToString(r.ID),
|
|
AutopilotID: uuidToString(r.AutopilotID),
|
|
TriggerID: uuidToPtr(r.TriggerID),
|
|
Source: r.Source,
|
|
Status: r.Status,
|
|
IssueID: uuidToPtr(r.IssueID),
|
|
TaskID: uuidToPtr(r.TaskID),
|
|
TriggeredAt: timestampToString(r.TriggeredAt),
|
|
CompletedAt: timestampToPtr(r.CompletedAt),
|
|
FailureReason: textToPtr(r.FailureReason),
|
|
TriggerPayload: payload,
|
|
Result: result,
|
|
CreatedAt: timestampToString(r.CreatedAt),
|
|
}
|
|
}
|
|
|
|
// ── Request types ───────────────────────────────────────────────────────────
|
|
|
|
type CreateAutopilotRequest struct {
|
|
Title string `json:"title"`
|
|
Description *string `json:"description"`
|
|
AssigneeID string `json:"assignee_id"`
|
|
ExecutionMode string `json:"execution_mode"`
|
|
IssueTitleTemplate *string `json:"issue_title_template"`
|
|
}
|
|
|
|
type UpdateAutopilotRequest struct {
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
Status *string `json:"status"`
|
|
ExecutionMode *string `json:"execution_mode"`
|
|
IssueTitleTemplate *string `json:"issue_title_template"`
|
|
}
|
|
|
|
type CreateAutopilotTriggerRequest struct {
|
|
Kind string `json:"kind"`
|
|
CronExpression *string `json:"cron_expression"`
|
|
Timezone *string `json:"timezone"`
|
|
Label *string `json:"label"`
|
|
}
|
|
|
|
type UpdateAutopilotTriggerRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
CronExpression *string `json:"cron_expression"`
|
|
Timezone *string `json:"timezone"`
|
|
Label *string `json:"label"`
|
|
}
|
|
|
|
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) ListAutopilots(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
var statusFilter pgtype.Text
|
|
if s := r.URL.Query().Get("status"); s != "" {
|
|
statusFilter = pgtype.Text{String: s, Valid: true}
|
|
}
|
|
|
|
autopilots, err := h.Queries.ListAutopilots(r.Context(), db.ListAutopilotsParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Status: statusFilter,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list autopilots")
|
|
return
|
|
}
|
|
|
|
resp := make([]AutopilotResponse, len(autopilots))
|
|
for i, a := range autopilots {
|
|
resp[i] = autopilotToResponse(a)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"autopilots": resp, "total": len(resp)})
|
|
}
|
|
|
|
func (h *Handler) GetAutopilot(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
autopilot, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
resp := autopilotToResponse(autopilot)
|
|
|
|
// Include triggers.
|
|
triggers, err := h.Queries.ListAutopilotTriggers(r.Context(), autopilot.ID)
|
|
if err != nil {
|
|
triggers = nil
|
|
}
|
|
triggerResp := make([]AutopilotTriggerResponse, len(triggers))
|
|
for i, t := range triggers {
|
|
triggerResp[i] = triggerToResponse(t)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"autopilot": resp,
|
|
"triggers": triggerResp,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) loadAutopilotInWorkspace(w http.ResponseWriter, r *http.Request, autopilotID, workspaceID string) (db.Autopilot, bool) {
|
|
autopilotUUID, ok := parseUUIDOrBadRequest(w, autopilotID, "autopilot id")
|
|
if !ok {
|
|
return db.Autopilot{}, false
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return db.Autopilot{}, false
|
|
}
|
|
|
|
autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: autopilotUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
return db.Autopilot{}, false
|
|
}
|
|
return autopilot, true
|
|
}
|
|
|
|
func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateAutopilotRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Title == "" {
|
|
writeError(w, http.StatusBadRequest, "title is required")
|
|
return
|
|
}
|
|
if req.AssigneeID == "" {
|
|
writeError(w, http.StatusBadRequest, "assignee_id is required")
|
|
return
|
|
}
|
|
if req.ExecutionMode == "" {
|
|
writeError(w, http.StatusBadRequest, "execution_mode is required")
|
|
return
|
|
}
|
|
if req.ExecutionMode != "create_issue" && req.ExecutionMode != "run_only" {
|
|
writeError(w, http.StatusBadRequest, "execution_mode must be create_issue or run_only")
|
|
return
|
|
}
|
|
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
assigneeUUID, ok := parseUUIDOrBadRequest(w, req.AssigneeID, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Validate assignee is an agent in the workspace.
|
|
_, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: assigneeUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
|
|
return
|
|
}
|
|
|
|
autopilot, err := h.Queries.CreateAutopilot(r.Context(), db.CreateAutopilotParams{
|
|
WorkspaceID: wsUUID,
|
|
Title: req.Title,
|
|
AssigneeID: assigneeUUID,
|
|
Status: "active",
|
|
ExecutionMode: req.ExecutionMode,
|
|
CreatedByType: "member",
|
|
CreatedByID: parseUUID(userID),
|
|
Description: ptrToText(req.Description),
|
|
IssueTitleTemplate: ptrToText(req.IssueTitleTemplate),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create autopilot")
|
|
return
|
|
}
|
|
|
|
resp := autopilotToResponse(autopilot)
|
|
h.publish(protocol.EventAutopilotCreated, workspaceID, "member", userID, map[string]any{"autopilot": resp})
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
prev, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
bodyBytes, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to read request body")
|
|
return
|
|
}
|
|
var req UpdateAutopilotRequest
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
var rawFields map[string]json.RawMessage
|
|
json.Unmarshal(bodyBytes, &rawFields)
|
|
|
|
params := db.UpdateAutopilotParams{
|
|
ID: prev.ID,
|
|
Description: prev.Description,
|
|
AssigneeID: prev.AssigneeID,
|
|
IssueTitleTemplate: prev.IssueTitleTemplate,
|
|
}
|
|
if req.Title != nil {
|
|
params.Title = pgtype.Text{String: *req.Title, Valid: true}
|
|
}
|
|
if req.Status != nil {
|
|
params.Status = pgtype.Text{String: *req.Status, Valid: true}
|
|
}
|
|
if req.ExecutionMode != nil {
|
|
params.ExecutionMode = pgtype.Text{String: *req.ExecutionMode, Valid: true}
|
|
}
|
|
if _, ok := rawFields["description"]; ok {
|
|
params.Description = ptrToText(req.Description)
|
|
}
|
|
if _, ok := rawFields["issue_title_template"]; ok {
|
|
params.IssueTitleTemplate = ptrToText(req.IssueTitleTemplate)
|
|
}
|
|
if _, ok := rawFields["assignee_id"]; ok {
|
|
if req.AssigneeID != nil {
|
|
assigneeUUID, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: assigneeUUID,
|
|
WorkspaceID: prev.WorkspaceID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
|
|
return
|
|
}
|
|
params.AssigneeID = assigneeUUID
|
|
}
|
|
}
|
|
|
|
autopilot, err := h.Queries.UpdateAutopilot(r.Context(), params)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update autopilot")
|
|
return
|
|
}
|
|
|
|
resp := autopilotToResponse(autopilot)
|
|
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{"autopilot": resp})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) DeleteAutopilot(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
idUUID, ok := parseUUIDOrBadRequest(w, id, "autopilot id")
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: idUUID,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := h.Queries.DeleteAutopilot(r.Context(), idUUID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete autopilot")
|
|
return
|
|
}
|
|
|
|
h.publish(protocol.EventAutopilotDeleted, workspaceID, "member", userID, map[string]any{"autopilot_id": uuidToString(idUUID)})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ── Trigger management ──────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) CreateAutopilotTrigger(w http.ResponseWriter, r *http.Request) {
|
|
autopilotID := chi.URLParam(r, "id")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateAutopilotTriggerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Kind == "" {
|
|
writeError(w, http.StatusBadRequest, "kind is required")
|
|
return
|
|
}
|
|
if req.Kind != "schedule" && req.Kind != "webhook" && req.Kind != "api" {
|
|
writeError(w, http.StatusBadRequest, "kind must be schedule, webhook, or api")
|
|
return
|
|
}
|
|
if req.Kind == "schedule" && (req.CronExpression == nil || *req.CronExpression == "") {
|
|
writeError(w, http.StatusBadRequest, "cron_expression is required for schedule triggers")
|
|
return
|
|
}
|
|
|
|
if req.Timezone != nil && *req.Timezone != "" {
|
|
if err := service.ValidateTimezone(*req.Timezone); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
var nextRunAt pgtype.Timestamptz
|
|
if req.Kind == "schedule" && req.CronExpression != nil {
|
|
tz := "UTC"
|
|
if req.Timezone != nil && *req.Timezone != "" {
|
|
tz = *req.Timezone
|
|
}
|
|
t, err := computeNextRun(*req.CronExpression, tz)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
nextRunAt = pgtype.Timestamptz{Time: t, Valid: true}
|
|
}
|
|
|
|
trigger, err := h.Queries.CreateAutopilotTrigger(r.Context(), db.CreateAutopilotTriggerParams{
|
|
AutopilotID: ap.ID,
|
|
Kind: req.Kind,
|
|
Enabled: true,
|
|
CronExpression: ptrToText(req.CronExpression),
|
|
Timezone: ptrToText(req.Timezone),
|
|
NextRunAt: nextRunAt,
|
|
Label: ptrToText(req.Label),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create trigger")
|
|
return
|
|
}
|
|
|
|
resp := triggerToResponse(trigger)
|
|
userID, _ := requireUserID(w, r)
|
|
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
|
"autopilot_id": uuidToString(ap.ID),
|
|
"trigger": resp,
|
|
})
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
func (h *Handler) UpdateAutopilotTrigger(w http.ResponseWriter, r *http.Request) {
|
|
autopilotID := chi.URLParam(r, "id")
|
|
triggerID := chi.URLParam(r, "triggerId")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
prev, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID)
|
|
if err != nil || uuidToString(prev.AutopilotID) != uuidToString(ap.ID) {
|
|
writeError(w, http.StatusNotFound, "trigger not found")
|
|
return
|
|
}
|
|
|
|
var req UpdateAutopilotTriggerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
params := db.UpdateAutopilotTriggerParams{
|
|
ID: prev.ID,
|
|
CronExpression: prev.CronExpression,
|
|
Timezone: prev.Timezone,
|
|
NextRunAt: prev.NextRunAt,
|
|
Label: prev.Label,
|
|
}
|
|
if req.Enabled != nil {
|
|
params.Enabled = pgtype.Bool{Bool: *req.Enabled, Valid: true}
|
|
}
|
|
if req.CronExpression != nil {
|
|
params.CronExpression = pgtype.Text{String: *req.CronExpression, Valid: true}
|
|
}
|
|
if req.Timezone != nil {
|
|
if *req.Timezone != "" {
|
|
if err := service.ValidateTimezone(*req.Timezone); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
params.Timezone = pgtype.Text{String: *req.Timezone, Valid: true}
|
|
}
|
|
if req.Label != nil {
|
|
params.Label = pgtype.Text{String: *req.Label, Valid: true}
|
|
}
|
|
|
|
// Recompute next_run_at if cron or timezone changed.
|
|
cronExpr := prev.CronExpression.String
|
|
if req.CronExpression != nil {
|
|
cronExpr = *req.CronExpression
|
|
}
|
|
tz := "UTC"
|
|
if prev.Timezone.Valid {
|
|
tz = prev.Timezone.String
|
|
}
|
|
if req.Timezone != nil {
|
|
tz = *req.Timezone
|
|
}
|
|
if prev.Kind == "schedule" && cronExpr != "" {
|
|
t, err := computeNextRun(cronExpr, tz)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
params.NextRunAt = pgtype.Timestamptz{Time: t, Valid: true}
|
|
}
|
|
|
|
trigger, err := h.Queries.UpdateAutopilotTrigger(r.Context(), params)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update trigger")
|
|
return
|
|
}
|
|
|
|
resp := triggerToResponse(trigger)
|
|
userID, _ := requireUserID(w, r)
|
|
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
|
"autopilot_id": uuidToString(ap.ID),
|
|
"trigger": resp,
|
|
})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) DeleteAutopilotTrigger(w http.ResponseWriter, r *http.Request) {
|
|
autopilotID := chi.URLParam(r, "id")
|
|
triggerID := chi.URLParam(r, "triggerId")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
autopilotUUID, ok := parseUUIDOrBadRequest(w, autopilotID, "autopilot id")
|
|
if !ok {
|
|
return
|
|
}
|
|
triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id")
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: autopilotUUID,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
return
|
|
}
|
|
|
|
trigger, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID)
|
|
if err != nil || uuidToString(trigger.AutopilotID) != uuidToString(autopilotUUID) {
|
|
writeError(w, http.StatusNotFound, "trigger not found")
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := h.Queries.DeleteAutopilotTrigger(r.Context(), triggerUUID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete trigger")
|
|
return
|
|
}
|
|
|
|
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
|
"autopilot_id": uuidToString(autopilotUUID),
|
|
"trigger_id": uuidToString(triggerUUID),
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ── Runs ────────────────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) ListAutopilotRuns(w http.ResponseWriter, r *http.Request) {
|
|
autopilotID := chi.URLParam(r, "id")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
autopilot, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
limit := int32(20)
|
|
offset := int32(0)
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
|
limit = int32(v)
|
|
}
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
|
offset = int32(v)
|
|
}
|
|
}
|
|
|
|
runs, err := h.Queries.ListAutopilotRuns(r.Context(), db.ListAutopilotRunsParams{
|
|
AutopilotID: autopilot.ID,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list runs")
|
|
return
|
|
}
|
|
|
|
resp := make([]AutopilotRunResponse, len(runs))
|
|
for i, run := range runs {
|
|
resp[i] = runToResponse(run)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"runs": resp, "total": len(resp)})
|
|
}
|
|
|
|
// ── Manual trigger ──────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) TriggerAutopilot(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
autopilot, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if autopilot.Status != "active" {
|
|
writeError(w, http.StatusBadRequest, "autopilot is not active")
|
|
return
|
|
}
|
|
|
|
run, err := h.AutopilotService.DispatchAutopilot(r.Context(), autopilot, pgtype.UUID{}, "manual", nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to trigger autopilot: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, runToResponse(*run))
|
|
}
|