mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
2 Commits
fix/projec
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d47be55372 | ||
|
|
63daa7a112 |
@@ -385,6 +385,62 @@ multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
## Autopilot Commands
|
||||
|
||||
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
|
||||
|
||||
### List Autopilots
|
||||
|
||||
```bash
|
||||
multica autopilot list
|
||||
multica autopilot list --status active --output json
|
||||
```
|
||||
|
||||
### Get Autopilot Details
|
||||
|
||||
```bash
|
||||
multica autopilot get <id>
|
||||
multica autopilot get <id> --output json # includes triggers
|
||||
```
|
||||
|
||||
### Create / Update / Delete
|
||||
|
||||
```bash
|
||||
multica autopilot create \
|
||||
--title "Nightly bug triage" \
|
||||
--description "Scan todo issues and prioritize." \
|
||||
--agent "Lambda" \
|
||||
--mode create_issue
|
||||
|
||||
multica autopilot update <id> --status paused
|
||||
multica autopilot update <id> --description "New prompt"
|
||||
multica autopilot delete <id>
|
||||
```
|
||||
|
||||
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <id> # Fires the autopilot once, returns the run
|
||||
```
|
||||
|
||||
### Run History
|
||||
|
||||
```bash
|
||||
multica autopilot runs <id>
|
||||
multica autopilot runs <id> --limit 50 --output json
|
||||
```
|
||||
|
||||
### Triggers (Schedule / Webhook / API)
|
||||
|
||||
```bash
|
||||
multica autopilot trigger-add <autopilot-id> --kind schedule --cron "0 9 * * 1-5" --timezone "America/New_York"
|
||||
multica autopilot trigger-add <autopilot-id> --kind webhook
|
||||
multica autopilot trigger-update <autopilot-id> <trigger-id> --enabled=false
|
||||
multica autopilot trigger-delete <autopilot-id> <trigger-id>
|
||||
```
|
||||
|
||||
## Other Commands
|
||||
|
||||
```bash
|
||||
|
||||
618
server/cmd/multica/cmd_autopilot.go
Normal file
618
server/cmd/multica/cmd_autopilot.go
Normal file
@@ -0,0 +1,618 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var autopilotCmd = &cobra.Command{
|
||||
Use: "autopilot",
|
||||
Short: "Manage autopilots (scheduled/triggered agent automations)",
|
||||
}
|
||||
|
||||
var autopilotListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List autopilots in the workspace",
|
||||
RunE: runAutopilotList,
|
||||
}
|
||||
|
||||
var autopilotGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Get autopilot details (includes triggers)",
|
||||
Args: exactArgs(1),
|
||||
RunE: runAutopilotGet,
|
||||
}
|
||||
|
||||
var autopilotCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new autopilot",
|
||||
RunE: runAutopilotCreate,
|
||||
}
|
||||
|
||||
var autopilotUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update an autopilot",
|
||||
Args: exactArgs(1),
|
||||
RunE: runAutopilotUpdate,
|
||||
}
|
||||
|
||||
var autopilotDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete an autopilot",
|
||||
Args: exactArgs(1),
|
||||
RunE: runAutopilotDelete,
|
||||
}
|
||||
|
||||
var autopilotTriggerCmd = &cobra.Command{
|
||||
Use: "trigger <id>",
|
||||
Short: "Manually trigger an autopilot to run once",
|
||||
Args: exactArgs(1),
|
||||
RunE: runAutopilotTrigger,
|
||||
}
|
||||
|
||||
var autopilotRunsCmd = &cobra.Command{
|
||||
Use: "runs <id>",
|
||||
Short: "List execution history for an autopilot",
|
||||
Args: exactArgs(1),
|
||||
RunE: runAutopilotRuns,
|
||||
}
|
||||
|
||||
var autopilotTriggerAddCmd = &cobra.Command{
|
||||
Use: "trigger-add <autopilot-id>",
|
||||
Short: "Add a trigger (schedule/webhook/api) to an autopilot",
|
||||
Args: exactArgs(1),
|
||||
RunE: runAutopilotTriggerAdd,
|
||||
}
|
||||
|
||||
var autopilotTriggerUpdateCmd = &cobra.Command{
|
||||
Use: "trigger-update <autopilot-id> <trigger-id>",
|
||||
Short: "Update an existing trigger",
|
||||
Args: exactArgs(2),
|
||||
RunE: runAutopilotTriggerUpdate,
|
||||
}
|
||||
|
||||
var autopilotTriggerDeleteCmd = &cobra.Command{
|
||||
Use: "trigger-delete <autopilot-id> <trigger-id>",
|
||||
Short: "Delete a trigger",
|
||||
Args: exactArgs(2),
|
||||
RunE: runAutopilotTriggerDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
autopilotCmd.AddCommand(autopilotListCmd)
|
||||
autopilotCmd.AddCommand(autopilotGetCmd)
|
||||
autopilotCmd.AddCommand(autopilotCreateCmd)
|
||||
autopilotCmd.AddCommand(autopilotUpdateCmd)
|
||||
autopilotCmd.AddCommand(autopilotDeleteCmd)
|
||||
autopilotCmd.AddCommand(autopilotTriggerCmd)
|
||||
autopilotCmd.AddCommand(autopilotRunsCmd)
|
||||
autopilotCmd.AddCommand(autopilotTriggerAddCmd)
|
||||
autopilotCmd.AddCommand(autopilotTriggerUpdateCmd)
|
||||
autopilotCmd.AddCommand(autopilotTriggerDeleteCmd)
|
||||
|
||||
// list
|
||||
autopilotListCmd.Flags().String("status", "", "Filter by status (active, paused)")
|
||||
autopilotListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// get
|
||||
autopilotGetCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// create
|
||||
autopilotCreateCmd.Flags().String("title", "", "Autopilot title (required)")
|
||||
autopilotCreateCmd.Flags().String("description", "", "Autopilot description (used as task prompt)")
|
||||
autopilotCreateCmd.Flags().String("agent", "", "Assignee agent (name or ID) — required")
|
||||
autopilotCreateCmd.Flags().String("mode", "", "Execution mode: create_issue (required). run_only is not yet supported end-to-end.")
|
||||
autopilotCreateCmd.Flags().String("priority", "none", "Priority for created issues (none, low, medium, high, urgent)")
|
||||
autopilotCreateCmd.Flags().String("project", "", "Project ID (optional)")
|
||||
autopilotCreateCmd.Flags().String("issue-title-template", "", "Template for issue titles (create_issue mode)")
|
||||
autopilotCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// update
|
||||
autopilotUpdateCmd.Flags().String("title", "", "New title")
|
||||
autopilotUpdateCmd.Flags().String("description", "", "New description")
|
||||
autopilotUpdateCmd.Flags().String("agent", "", "New assignee agent (name or ID)")
|
||||
autopilotUpdateCmd.Flags().String("project", "", "New project ID (use empty string to clear)")
|
||||
autopilotUpdateCmd.Flags().String("priority", "", "New priority")
|
||||
autopilotUpdateCmd.Flags().String("status", "", "New status (active, paused)")
|
||||
autopilotUpdateCmd.Flags().String("mode", "", "New execution mode (create_issue)")
|
||||
autopilotUpdateCmd.Flags().String("issue-title-template", "", "New issue title template")
|
||||
autopilotUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// delete
|
||||
// (no flags)
|
||||
|
||||
// trigger (manual run)
|
||||
autopilotTriggerCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// runs
|
||||
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")
|
||||
|
||||
// trigger-add
|
||||
autopilotTriggerAddCmd.Flags().String("kind", "", "Trigger kind: schedule, webhook, or api (required)")
|
||||
autopilotTriggerAddCmd.Flags().String("cron", "", "Cron expression (required for kind=schedule)")
|
||||
autopilotTriggerAddCmd.Flags().String("timezone", "", "IANA timezone for schedule (default UTC)")
|
||||
autopilotTriggerAddCmd.Flags().String("label", "", "Optional human-readable label")
|
||||
autopilotTriggerAddCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// trigger-update
|
||||
autopilotTriggerUpdateCmd.Flags().Bool("enabled", true, "Enable or disable the trigger")
|
||||
autopilotTriggerUpdateCmd.Flags().String("cron", "", "New cron expression")
|
||||
autopilotTriggerUpdateCmd.Flags().String("timezone", "", "New IANA timezone")
|
||||
autopilotTriggerUpdateCmd.Flags().String("label", "", "New label")
|
||||
autopilotTriggerUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Autopilot commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runAutopilotList(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireWorkspaceID(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
path := "/api/autopilots"
|
||||
if status, _ := cmd.Flags().GetString("status"); status != "" {
|
||||
path += "?" + url.Values{"status": {status}}.Encode()
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Autopilots []map[string]any `json:"autopilots"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := client.GetJSON(ctx, path, &resp); err != nil {
|
||||
return fmt.Errorf("list autopilots: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, resp)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "TITLE", "STATUS", "MODE", "ASSIGNEE", "LAST_RUN"}
|
||||
rows := make([][]string, 0, len(resp.Autopilots))
|
||||
for _, a := range resp.Autopilots {
|
||||
rows = append(rows, []string{
|
||||
truncateID(strVal(a, "id")),
|
||||
strVal(a, "title"),
|
||||
strVal(a, "status"),
|
||||
strVal(a, "execution_mode"),
|
||||
truncateID(strVal(a, "assignee_id")),
|
||||
strVal(a, "last_run_at"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAutopilotGet(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var resp map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/autopilots/"+args[0], &resp); err != nil {
|
||||
return fmt.Errorf("get autopilot: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, resp)
|
||||
}
|
||||
|
||||
ap, _ := resp["autopilot"].(map[string]any)
|
||||
headers := []string{"ID", "TITLE", "STATUS", "MODE", "ASSIGNEE", "LAST_RUN"}
|
||||
rows := [][]string{{
|
||||
truncateID(strVal(ap, "id")),
|
||||
strVal(ap, "title"),
|
||||
strVal(ap, "status"),
|
||||
strVal(ap, "execution_mode"),
|
||||
truncateID(strVal(ap, "assignee_id")),
|
||||
strVal(ap, "last_run_at"),
|
||||
}}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAutopilotCreate(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireWorkspaceID(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title, _ := cmd.Flags().GetString("title")
|
||||
if title == "" {
|
||||
return fmt.Errorf("--title is required")
|
||||
}
|
||||
agent, _ := cmd.Flags().GetString("agent")
|
||||
if agent == "" {
|
||||
return fmt.Errorf("--agent is required (agent name or ID)")
|
||||
}
|
||||
mode, _ := cmd.Flags().GetString("mode")
|
||||
if mode == "" {
|
||||
return fmt.Errorf("--mode is required (create_issue)")
|
||||
}
|
||||
// run_only is a valid value server-side but the dispatch path is not wired
|
||||
// end-to-end (daemon /start resolves workspace only via issue/chat, and the
|
||||
// agent prompt expects an issue ID). Keep the CLI to create_issue until the
|
||||
// server path is fixed to avoid shipping a mode that returns 404 on start.
|
||||
if mode != "create_issue" {
|
||||
return fmt.Errorf("--mode must be create_issue (run_only is not yet supported end-to-end)")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
agentID, err := resolveAgent(ctx, client, agent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve agent: %w", err)
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"title": title,
|
||||
"assignee_id": agentID,
|
||||
"execution_mode": mode,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("description"); v != "" {
|
||||
body["description"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("priority") {
|
||||
v, _ := cmd.Flags().GetString("priority")
|
||||
body["priority"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("project"); v != "" {
|
||||
body["project_id"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("issue-title-template"); v != "" {
|
||||
body["issue_title_template"] = v
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/autopilots", body, &result); err != nil {
|
||||
return fmt.Errorf("create autopilot: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
fmt.Printf("Autopilot created: %s (%s)\n", strVal(result, "title"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAutopilotUpdate(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body := map[string]any{}
|
||||
if cmd.Flags().Changed("title") {
|
||||
v, _ := cmd.Flags().GetString("title")
|
||||
body["title"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
v, _ := cmd.Flags().GetString("description")
|
||||
body["description"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("agent") {
|
||||
v, _ := cmd.Flags().GetString("agent")
|
||||
agentID, resolveErr := resolveAgent(ctx, client, v)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve agent: %w", resolveErr)
|
||||
}
|
||||
body["assignee_id"] = agentID
|
||||
}
|
||||
if cmd.Flags().Changed("project") {
|
||||
v, _ := cmd.Flags().GetString("project")
|
||||
if v == "" {
|
||||
body["project_id"] = nil
|
||||
} else {
|
||||
body["project_id"] = v
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("priority") {
|
||||
v, _ := cmd.Flags().GetString("priority")
|
||||
body["priority"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("status") {
|
||||
v, _ := cmd.Flags().GetString("status")
|
||||
body["status"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("mode") {
|
||||
v, _ := cmd.Flags().GetString("mode")
|
||||
if v != "create_issue" {
|
||||
return fmt.Errorf("--mode must be create_issue (run_only is not yet supported end-to-end)")
|
||||
}
|
||||
body["execution_mode"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("issue-title-template") {
|
||||
v, _ := cmd.Flags().GetString("issue-title-template")
|
||||
body["issue_title_template"] = v
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use flags like --title, --description, --agent, --status, --mode, etc.")
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PatchJSON(ctx, "/api/autopilots/"+args[0], body, &result); err != nil {
|
||||
return fmt.Errorf("update autopilot: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
fmt.Printf("Autopilot updated: %s (%s)\n", strVal(result, "title"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAutopilotDelete(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.DeleteJSON(ctx, "/api/autopilots/"+args[0]); err != nil {
|
||||
return fmt.Errorf("delete autopilot: %w", err)
|
||||
}
|
||||
fmt.Printf("Autopilot %s deleted.\n", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAutopilotTrigger(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var run map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/autopilots/"+args[0]+"/trigger", nil, &run); err != nil {
|
||||
return fmt.Errorf("trigger autopilot: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, run)
|
||||
}
|
||||
fmt.Printf("Autopilot triggered: run %s (status: %s)\n", strVal(run, "id"), strVal(run, "status"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAutopilotRuns(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
params := url.Values{}
|
||||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||||
params.Set("limit", fmt.Sprintf("%d", v))
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("offset"); v > 0 {
|
||||
params.Set("offset", fmt.Sprintf("%d", v))
|
||||
}
|
||||
path := "/api/autopilots/" + args[0] + "/runs"
|
||||
if len(params) > 0 {
|
||||
path += "?" + params.Encode()
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Runs []map[string]any `json:"runs"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := client.GetJSON(ctx, path, &resp); err != nil {
|
||||
return fmt.Errorf("list runs: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, resp)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "SOURCE", "STATUS", "ISSUE", "TRIGGERED_AT", "COMPLETED_AT"}
|
||||
rows := make([][]string, 0, len(resp.Runs))
|
||||
for _, r := range resp.Runs {
|
||||
rows = append(rows, []string{
|
||||
truncateID(strVal(r, "id")),
|
||||
strVal(r, "source"),
|
||||
strVal(r, "status"),
|
||||
truncateID(strVal(r, "issue_id")),
|
||||
strVal(r, "triggered_at"),
|
||||
strVal(r, "completed_at"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAutopilotTriggerAdd(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kind, _ := cmd.Flags().GetString("kind")
|
||||
if kind == "" {
|
||||
return fmt.Errorf("--kind is required (schedule, webhook, or api)")
|
||||
}
|
||||
if kind != "schedule" && kind != "webhook" && kind != "api" {
|
||||
return fmt.Errorf("--kind must be schedule, webhook, or api")
|
||||
}
|
||||
cron, _ := cmd.Flags().GetString("cron")
|
||||
if kind == "schedule" && cron == "" {
|
||||
return fmt.Errorf("--cron is required for kind=schedule")
|
||||
}
|
||||
|
||||
body := map[string]any{"kind": kind}
|
||||
if cron != "" {
|
||||
body["cron_expression"] = cron
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("timezone"); v != "" {
|
||||
body["timezone"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("label"); v != "" {
|
||||
body["label"] = v
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/autopilots/"+args[0]+"/triggers", body, &result); err != nil {
|
||||
return fmt.Errorf("create trigger: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
fmt.Printf("Trigger created: %s (kind=%s)\n", strVal(result, "id"), strVal(result, "kind"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAutopilotTriggerUpdate(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]any{}
|
||||
if cmd.Flags().Changed("enabled") {
|
||||
v, _ := cmd.Flags().GetBool("enabled")
|
||||
body["enabled"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("cron") {
|
||||
v, _ := cmd.Flags().GetString("cron")
|
||||
body["cron_expression"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("timezone") {
|
||||
v, _ := cmd.Flags().GetString("timezone")
|
||||
body["timezone"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("label") {
|
||||
v, _ := cmd.Flags().GetString("label")
|
||||
body["label"] = v
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use --enabled, --cron, --timezone, or --label")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
path := "/api/autopilots/" + args[0] + "/triggers/" + args[1]
|
||||
if err := client.PatchJSON(ctx, path, body, &result); err != nil {
|
||||
return fmt.Errorf("update trigger: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
fmt.Printf("Trigger updated: %s\n", strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAutopilotTriggerDelete(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
path := "/api/autopilots/" + args[0] + "/triggers/" + args[1]
|
||||
if err := client.DeleteJSON(ctx, path); err != nil {
|
||||
return fmt.Errorf("delete trigger: %w", err)
|
||||
}
|
||||
fmt.Printf("Trigger %s deleted.\n", args[1])
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// uuidRegexp matches a canonical UUID (8-4-4-4-12 hex).
|
||||
var uuidRegexp = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// resolveAgent accepts either a UUID or an agent name (case-insensitive substring)
|
||||
// and returns the agent's UUID. Errors on no match or ambiguous match.
|
||||
func resolveAgent(ctx context.Context, client *cli.APIClient, nameOrID string) (string, error) {
|
||||
if uuidRegexp.MatchString(nameOrID) {
|
||||
return nameOrID, nil
|
||||
}
|
||||
if client.WorkspaceID == "" {
|
||||
return "", fmt.Errorf("workspace ID is required to resolve agents; use --workspace-id or set MULTICA_WORKSPACE_ID")
|
||||
}
|
||||
|
||||
var agents []map[string]any
|
||||
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
|
||||
if err := client.GetJSON(ctx, agentPath, &agents); err != nil {
|
||||
return "", fmt.Errorf("fetch agents: %w", err)
|
||||
}
|
||||
|
||||
nameLower := strings.ToLower(nameOrID)
|
||||
type match struct{ ID, Name string }
|
||||
var matches []match
|
||||
for _, a := range agents {
|
||||
aName := strVal(a, "name")
|
||||
if strings.Contains(strings.ToLower(aName), nameLower) {
|
||||
matches = append(matches, match{ID: strVal(a, "id"), Name: aName})
|
||||
}
|
||||
}
|
||||
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no agent found matching %q", nameOrID)
|
||||
case 1:
|
||||
return matches[0].ID, nil
|
||||
default:
|
||||
var parts []string
|
||||
for _, m := range matches {
|
||||
parts = append(parts, fmt.Sprintf(" %q (%s)", m.Name, truncateID(m.ID)))
|
||||
}
|
||||
return "", fmt.Errorf("ambiguous agent %q; matches:\n%s", nameOrID, strings.Join(parts, "\n"))
|
||||
}
|
||||
}
|
||||
120
server/cmd/multica/cmd_autopilot_test.go
Normal file
120
server/cmd/multica/cmd_autopilot_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
func TestResolveAgent(t *testing.T) {
|
||||
agentsResp := []map[string]any{
|
||||
{"id": "11111111-1111-1111-1111-111111111111", "name": "Lambda"},
|
||||
{"id": "22222222-2222-2222-2222-222222222222", "name": "Codex Agent"},
|
||||
{"id": "33333333-3333-3333-3333-333333333333", "name": "Claude Reviewer"},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/agents" {
|
||||
json.NewEncoder(w).Encode(agentsResp)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("passes through a UUID without lookup", func(t *testing.T) {
|
||||
id := "44444444-4444-4444-4444-444444444444"
|
||||
got, err := resolveAgent(ctx, client, id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != id {
|
||||
t.Errorf("got %q, want %q", got, id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exact name match", func(t *testing.T) {
|
||||
got, err := resolveAgent(ctx, client, "Lambda")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "11111111-1111-1111-1111-111111111111" {
|
||||
t.Errorf("got %q, want Lambda's UUID", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case-insensitive substring", func(t *testing.T) {
|
||||
got, err := resolveAgent(ctx, client, "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "22222222-2222-2222-2222-222222222222" {
|
||||
t.Errorf("got %q, want Codex Agent's UUID", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match", func(t *testing.T) {
|
||||
_, err := resolveAgent(ctx, client, "nobody")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no match")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ambiguous match", func(t *testing.T) {
|
||||
_, err := resolveAgent(ctx, client, "a") // matches Lambda, Codex Agent, Claude Reviewer
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ambiguous match")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ambiguous") {
|
||||
t.Errorf("expected ambiguous error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing workspace ID for name lookup", func(t *testing.T) {
|
||||
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
|
||||
_, err := resolveAgent(ctx, noWSClient, "Lambda")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when workspace ID is missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UUID works without workspace ID", func(t *testing.T) {
|
||||
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
|
||||
id := "55555555-5555-5555-5555-555555555555"
|
||||
got, err := resolveAgent(ctx, noWSClient, id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != id {
|
||||
t.Errorf("got %q, want %q", got, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUUIDRegexp(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"11111111-1111-1111-1111-111111111111", true},
|
||||
{"A1B2C3D4-1111-1111-1111-111111111111", true},
|
||||
{"not-a-uuid", false},
|
||||
{"11111111-1111-1111-1111-11111111111", false}, // too short
|
||||
{"11111111111111111111111111111111", false}, // missing dashes
|
||||
{"11111111-1111-1111-1111-1111111111111", false}, // too long
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := uuidRegexp.MatchString(tt.in); got != tt.want {
|
||||
t.Errorf("uuidRegexp.MatchString(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ func init() {
|
||||
issueCmd.GroupID = groupCore
|
||||
projectCmd.GroupID = groupCore
|
||||
agentCmd.GroupID = groupCore
|
||||
autopilotCmd.GroupID = groupCore
|
||||
workspaceCmd.GroupID = groupCore
|
||||
repoCmd.GroupID = groupCore
|
||||
skillCmd.GroupID = groupCore
|
||||
@@ -54,6 +55,7 @@ func init() {
|
||||
rootCmd.AddCommand(issueCmd)
|
||||
rootCmd.AddCommand(projectCmd)
|
||||
rootCmd.AddCommand(agentCmd)
|
||||
rootCmd.AddCommand(autopilotCmd)
|
||||
rootCmd.AddCommand(workspaceCmd)
|
||||
rootCmd.AddCommand(repoCmd)
|
||||
rootCmd.AddCommand(skillCmd)
|
||||
|
||||
@@ -183,6 +183,36 @@ func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any)
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// PatchJSON performs a PATCH request with a JSON body.
|
||||
func (c *APIClient) PatchJSON(ctx context.Context, path string, body any, out any) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("PATCH %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// UploadFile uploads a file via multipart form to /api/upload-file.
|
||||
// It returns the attachment ID from the server response.
|
||||
func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) {
|
||||
|
||||
@@ -75,7 +75,10 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("- `multica repo checkout <url>` — Check out a repository into the working directory (creates a git worktree with a dedicated branch)\n")
|
||||
b.WriteString("- `multica issue runs <issue-id> --output json` — List all execution runs for an issue (status, timestamps, errors)\n")
|
||||
b.WriteString("- `multica issue run-messages <task-id> [--since <seq>] --output json` — List messages for a specific execution run (supports incremental fetch)\n")
|
||||
b.WriteString("- `multica attachment download <id> [-o <dir>]` — Download an attachment file locally by ID\n\n")
|
||||
b.WriteString("- `multica attachment download <id> [-o <dir>]` — Download an attachment file locally by ID\n")
|
||||
b.WriteString("- `multica autopilot list [--status X] --output json` — List autopilots (scheduled/triggered agent automations) in the workspace\n")
|
||||
b.WriteString("- `multica autopilot get <id> --output json` — Get autopilot details including triggers\n")
|
||||
b.WriteString("- `multica autopilot runs <id> [--limit N] --output json` — List execution history for an autopilot\n\n")
|
||||
|
||||
b.WriteString("### Write\n")
|
||||
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--assignee X] [--parent <issue-id>] [--status X]` — Create a new issue\n")
|
||||
@@ -84,7 +87,11 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString(" - For content with special characters (backticks, quotes), pipe via stdin: `cat <<'COMMENT' | multica issue comment add <issue-id> --content-stdin`\n")
|
||||
b.WriteString("- `multica issue comment delete <comment-id>` — Delete a comment\n")
|
||||
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
|
||||
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n\n")
|
||||
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n")
|
||||
b.WriteString("- `multica autopilot create --title \"...\" --agent <name> --mode create_issue [--description \"...\"]` — Create an autopilot\n")
|
||||
b.WriteString("- `multica autopilot update <id> [--title X] [--description X] [--status active|paused]` — Update an autopilot\n")
|
||||
b.WriteString("- `multica autopilot trigger <id>` — Manually trigger an autopilot to run once\n")
|
||||
b.WriteString("- `multica autopilot delete <id>` — Delete an autopilot\n\n")
|
||||
|
||||
// Inject available repositories section.
|
||||
if len(ctx.Repos) > 0 {
|
||||
|
||||
@@ -359,6 +359,13 @@ func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if _, ok := rawFields["assignee_id"]; ok {
|
||||
if req.AssigneeID != nil {
|
||||
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
||||
ID: parseUUID(*req.AssigneeID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
|
||||
return
|
||||
}
|
||||
params.AssigneeID = parseUUID(*req.AssigneeID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user