mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 06:39:01 +02:00
Problem ------- The v2 workspace URL refactor (#1141) switched the frontend from sending X-Workspace-ID (UUID) to X-Workspace-Slug. The workspace middleware was updated to accept the slug and translate it via GetWorkspaceBySlug. But the handler package maintained a PARALLEL resolver (`resolveWorkspaceID` in handler.go) used by endpoints that sit outside the workspace middleware — and that resolver was never updated. It only checked context / ?workspace_id / X-Workspace-ID, never the slug. /api/upload-file is the one production route that hit the broken path: it's user-scoped (not behind workspace middleware) because it also serves avatar uploads (no workspace). Post-refactor requests from the frontend arrived with only X-Workspace-Slug; the handler resolver returned "", the code fell into the "no workspace context" branch, and every file upload since v2 landed in S3 with no corresponding DB attachment row — files orphaned, invisible to the UI. Root cause is structural: two resolvers doing the same job, written independently, diverged silently when one was updated. Fix --- Collapse to a single shared helper. middleware.ResolveWorkspaceIDFromRequest is the new canonical resolver; both the middleware's internal `resolveWorkspaceUUID` (for middleware gating) and the handler-side `(h *Handler).resolveWorkspaceID` (promoted from a package function) now delegate to it. Priority order matches what the middleware has had since v2: context > X-Workspace-Slug header > ?workspace_slug query > X-Workspace-ID header > ?workspace_id query. Impact analysis --------------- 47 call sites of the old `resolveWorkspaceID(r)` are renamed to `h.resolveWorkspaceID(r)`. 46 of them sit behind workspace middleware, so they hit the context fast path and see zero behavior change. The one caller that actually gains capability is UploadFile — which now correctly recognizes slug requests and creates DB attachment rows. Tests ----- - New table-driven unit test for ResolveWorkspaceIDFromRequest covers all priority levels and the unknown-slug fallback. - Regression tests for UploadFile: once with X-Workspace-Slug only (the broken path), once with X-Workspace-ID only (legacy CLI/daemon compat path). Both assert that a DB attachment row is created. - Full Go test suite passes; typecheck + pnpm test unaffected. Plan ---- See docs/plans/2026-04-16-unify-workspace-identity-resolver.md for the full first-principles writeup. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
685 lines
22 KiB
Go
685 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"`
|
|
ProjectID *string `json:"project_id"`
|
|
Title string `json:"title"`
|
|
Description *string `json:"description"`
|
|
AssigneeID string `json:"assignee_id"`
|
|
Priority string `json:"priority"`
|
|
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),
|
|
ProjectID: uuidToPtr(a.ProjectID),
|
|
Title: a.Title,
|
|
Description: textToPtr(a.Description),
|
|
AssigneeID: uuidToString(a.AssigneeID),
|
|
Priority: a.Priority,
|
|
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"`
|
|
ProjectID *string `json:"project_id"`
|
|
Priority string `json:"priority"`
|
|
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"`
|
|
ProjectID *string `json:"project_id"`
|
|
Priority *string `json:"priority"`
|
|
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, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: parseUUID(id),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
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) 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
|
|
}
|
|
|
|
// Validate assignee is an agent in the workspace.
|
|
_, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: parseUUID(req.AssigneeID),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
|
|
return
|
|
}
|
|
|
|
priority := req.Priority
|
|
if priority == "" {
|
|
priority = "none"
|
|
}
|
|
|
|
var projectID pgtype.UUID
|
|
if req.ProjectID != nil {
|
|
projectID = parseUUID(*req.ProjectID)
|
|
}
|
|
|
|
autopilot, err := h.Queries.CreateAutopilot(r.Context(), db.CreateAutopilotParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Title: req.Title,
|
|
AssigneeID: parseUUID(req.AssigneeID),
|
|
Priority: priority,
|
|
Status: "active",
|
|
ExecutionMode: req.ExecutionMode,
|
|
CreatedByType: "member",
|
|
CreatedByID: parseUUID(userID),
|
|
ProjectID: projectID,
|
|
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, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: parseUUID(id),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
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,
|
|
ProjectID: prev.ProjectID,
|
|
IssueTitleTemplate: prev.IssueTitleTemplate,
|
|
}
|
|
if req.Title != nil {
|
|
params.Title = pgtype.Text{String: *req.Title, Valid: true}
|
|
}
|
|
if req.Priority != nil {
|
|
params.Priority = pgtype.Text{String: *req.Priority, 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 {
|
|
params.AssigneeID = parseUUID(*req.AssigneeID)
|
|
}
|
|
}
|
|
if _, ok := rawFields["project_id"]; ok {
|
|
if req.ProjectID != nil {
|
|
params.ProjectID = parseUUID(*req.ProjectID)
|
|
} else {
|
|
params.ProjectID = pgtype.UUID{Valid: false}
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: parseUUID(id),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
}); 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(), parseUUID(id)); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete autopilot")
|
|
return
|
|
}
|
|
|
|
h.publish(protocol.EventAutopilotDeleted, workspaceID, "member", userID, map[string]any{"autopilot_id": id})
|
|
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, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: parseUUID(autopilotID),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
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": autopilotID,
|
|
"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)
|
|
|
|
// Verify autopilot belongs to workspace.
|
|
if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: parseUUID(autopilotID),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
}); err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
return
|
|
}
|
|
|
|
prev, err := h.Queries.GetAutopilotTrigger(r.Context(), parseUUID(triggerID))
|
|
if err != nil || uuidToString(prev.AutopilotID) != autopilotID {
|
|
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": autopilotID,
|
|
"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)
|
|
|
|
if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: parseUUID(autopilotID),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
}); err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
return
|
|
}
|
|
|
|
trigger, err := h.Queries.GetAutopilotTrigger(r.Context(), parseUUID(triggerID))
|
|
if err != nil || uuidToString(trigger.AutopilotID) != autopilotID {
|
|
writeError(w, http.StatusNotFound, "trigger not found")
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := h.Queries.DeleteAutopilotTrigger(r.Context(), parseUUID(triggerID)); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete trigger")
|
|
return
|
|
}
|
|
|
|
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
|
|
"autopilot_id": autopilotID,
|
|
"trigger_id": triggerID,
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ── Runs ────────────────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) ListAutopilotRuns(w http.ResponseWriter, r *http.Request) {
|
|
autopilotID := chi.URLParam(r, "id")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: parseUUID(autopilotID),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
}); err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
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: parseUUID(autopilotID),
|
|
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, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
|
|
ID: parseUUID(id),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "autopilot not found")
|
|
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))
|
|
}
|