Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLI_AND_DAEMON.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ multica autopilot trigger <id> # Fires the autopilot once, returns th
```bash
multica autopilot runs <id>
multica autopilot runs <id> --limit 50 --output json
multica autopilot runs cancel <run-id>
```

### Schedule Triggers
Expand Down
35 changes: 35 additions & 0 deletions server/cmd/multica/cmd_autopilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ var autopilotRunsCmd = &cobra.Command{
RunE: runAutopilotRuns,
}

var autopilotRunsCancelCmd = &cobra.Command{
Use: "cancel <run-id>",
Short: "Cancel an autopilot run",
Args: exactArgs(1),
RunE: runAutopilotRunCancel,
}

var autopilotTriggerAddCmd = &cobra.Command{
Use: "trigger-add <autopilot-id>",
Short: "Add a schedule or webhook trigger to an autopilot",
Expand Down Expand Up @@ -103,6 +110,7 @@ func init() {
autopilotCmd.AddCommand(autopilotDeleteCmd)
autopilotCmd.AddCommand(autopilotTriggerCmd)
autopilotCmd.AddCommand(autopilotRunsCmd)
autopilotRunsCmd.AddCommand(autopilotRunsCancelCmd)
autopilotCmd.AddCommand(autopilotTriggerAddCmd)
autopilotCmd.AddCommand(autopilotTriggerUpdateCmd)
autopilotCmd.AddCommand(autopilotTriggerDeleteCmd)
Expand Down Expand Up @@ -147,6 +155,7 @@ func init() {
autopilotRunsCmd.Flags().Int("limit", 20, "Max number of runs to return")
autopilotRunsCmd.Flags().Int("offset", 0, "Pagination offset")
autopilotRunsCmd.Flags().String("output", "table", "Output format: table or json")
autopilotRunsCancelCmd.Flags().String("output", "json", "Output format: table or json")

// trigger-add — supports schedule and webhook
autopilotTriggerAddCmd.Flags().String("kind", "schedule", "Trigger kind: schedule or webhook")
Expand Down Expand Up @@ -510,6 +519,32 @@ func runAutopilotRuns(cmd *cobra.Command, args []string) error {
return nil
}

func runAutopilotRunCancel(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
if _, err := requireWorkspaceID(cmd); err != nil {
return err
}

ctx, cancel := cli.APIContext(context.Background())
defer cancel()

var resp map[string]any
if err := client.PostJSON(ctx, "/api/autopilot-runs/"+args[0]+"/cancel", nil, &resp); err != nil {
return fmt.Errorf("cancel autopilot run: %w", err)
}

output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, resp)
}
run, _ := resp["run"].(map[string]any)
fmt.Printf("Autopilot run %s status: %s\n", strVal(run, "id"), strVal(run, "status"))
return nil
}

func runAutopilotTriggerAdd(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
Expand Down
44 changes: 44 additions & 0 deletions server/cmd/multica/cmd_autopilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ func newAutopilotUpdateTestCmd() *cobra.Command {
return cmd
}

func newAutopilotRunCancelTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "cancel"}
cmd.Flags().String("output", "json", "")
return cmd
}

func TestResolveAgent(t *testing.T) {
agentsResp := []map[string]any{
{"id": "11111111-1111-1111-1111-111111111111", "name": "Lambda"},
Expand Down Expand Up @@ -128,6 +134,44 @@ func TestResolveAgent(t *testing.T) {
})
}

func TestRunAutopilotRunCancelPostsRunScopedEndpoint(t *testing.T) {
const runID = "11111111-1111-1111-1111-111111111111"

var called bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/autopilot-runs/"+runID+"/cancel" {
http.NotFound(w, r)
return
}
called = true
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
json.NewEncoder(w).Encode(map[string]any{
"cancelled": true,
"already_terminal": false,
"cancelled_tasks": 1,
"run": map[string]any{
"id": runID,
"status": "cancelled",
},
})
}))
defer srv.Close()

t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")

cmd := newAutopilotRunCancelTestCmd()
if err := runAutopilotRunCancel(cmd, []string{runID}); err != nil {
t.Fatalf("runAutopilotRunCancel: %v", err)
}
if !called {
t.Fatal("cancel endpoint was not called")
}
}

func TestRunAutopilotCreateSendsProjectID(t *testing.T) {
const (
agentID = "11111111-1111-1111-1111-111111111111"
Expand Down
2 changes: 2 additions & 0 deletions server/cmd/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,8 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// Squad leader evaluation (writes to activity_log)
r.Post("/api/issues/{id}/squad-evaluated", h.RecordSquadLeaderEvaluation)

r.Post("/api/autopilot-runs/{runId}/cancel", h.CancelAutopilotRun)

// Autopilots
r.Route("/api/autopilots", func(r chi.Router) {
r.Get("/", h.ListAutopilots)
Expand Down
141 changes: 141 additions & 0 deletions server/internal/handler/autopilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handler

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -10,6 +11,7 @@ import (
"time"

"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
Expand Down Expand Up @@ -123,6 +125,13 @@ type AutopilotRunResponse struct {
CreatedAt string `json:"created_at"`
}

type CancelAutopilotRunResponse struct {
Run AutopilotRunResponse `json:"run"`
Cancelled bool `json:"cancelled"`
AlreadyTerminal bool `json:"already_terminal"`
CancelledTasks int `json:"cancelled_tasks"`
}

// ── Converters ──────────────────────────────────────────────────────────────

func autopilotToResponse(a db.Autopilot, subscribers []db.AutopilotSubscriber) AutopilotResponse {
Expand Down Expand Up @@ -1495,6 +1504,138 @@ func (h *Handler) GetAutopilotRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, runToResponse(run))
}

func (h *Handler) CancelAutopilotRun(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
runID := chi.URLParam(r, "runId")
runUUID, ok := parseUUIDOrBadRequest(w, runID, "run id")
if !ok {
return
}

run, err := h.Queries.GetAutopilotRun(r.Context(), runUUID)
if err != nil {
writeError(w, http.StatusNotFound, "run not found")
return
}
autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
ID: run.AutopilotID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "run not found")
return
}
if !h.canCancelAutopilotRun(r, autopilot, userID, workspaceID) {
writeError(w, http.StatusForbidden, "you do not have access to this autopilot run")
return
}
if autopilotRunStatusTerminal(run.Status) {
writeJSON(w, http.StatusOK, CancelAutopilotRunResponse{
Run: runToResponse(run),
Cancelled: false,
AlreadyTerminal: true,
CancelledTasks: 0,
})
return
}

cancelledTasks := 0
if run.TaskID.Valid {
cancelled, err := h.TaskService.CancelTaskWithResult(r.Context(), run.TaskID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if cancelled.Task.Status == "cancelled" {
cancelledTasks++
}
}
if run.IssueID.Valid {
cancelled, err := h.Queries.CancelAgentTasksByIssue(r.Context(), run.IssueID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
h.TaskService.BroadcastCancelledTasks(r.Context(), cancelled)
cancelledTasks += len(cancelled)
}

updated, err := h.Queries.CancelAutopilotRun(r.Context(), db.CancelAutopilotRunParams{
ID: run.ID,
FailureReason: strToText("cancelled by user"),
})
if errors.Is(err, pgx.ErrNoRows) {
current, getErr := h.Queries.GetAutopilotRun(r.Context(), run.ID)
if getErr == nil && autopilotRunStatusTerminal(current.Status) {
writeJSON(w, http.StatusOK, CancelAutopilotRunResponse{
Run: runToResponse(current),
Cancelled: false,
AlreadyTerminal: true,
CancelledTasks: 0,
})
return
}
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to cancel run")
return
}

h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
"autopilot_id": uuidToString(autopilot.ID),
"run": runToResponse(updated),
})
writeJSON(w, http.StatusOK, CancelAutopilotRunResponse{
Run: runToResponse(updated),
Cancelled: true,
AlreadyTerminal: false,
CancelledTasks: cancelledTasks,
})
}

func (h *Handler) canCancelAutopilotRun(r *http.Request, autopilot db.Autopilot, userID, workspaceID string) bool {
actorType, actorID := h.resolveActor(r, userID, workspaceID)
assigneeType := autopilot.AssigneeType
if assigneeType == "" || assigneeType == "agent" {
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: autopilot.AssigneeID,
WorkspaceID: autopilot.WorkspaceID,
})
if err != nil {
return false
}
return h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID)
}
if assigneeType == "squad" {
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
ID: autopilot.AssigneeID,
WorkspaceID: autopilot.WorkspaceID,
})
if err != nil {
return false
}
return h.canEnqueueSquadLeader(r.Context(), squad.LeaderID, actorType, actorID, workspaceID)
}
return false
}

func autopilotRunStatusTerminal(status string) bool {
switch status {
case "completed", "failed", "skipped", "cancelled":
return true
default:
return false
}
}

// ── Manual trigger ──────────────────────────────────────────────────────────

func (h *Handler) TriggerAutopilot(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading