Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
d47be55372 fix(autopilot): address code review — restrict run_only, validate workspace on update
Code review caught two issues with the initial CLI PR:

1. run_only mode is broken end-to-end. The daemon-side
   resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves
   workspace from issue/chat, so run_only tasks (which have neither)
   return 404 from /start. BuildPrompt() would also emit an empty issue
   ID. The service-level resolver in internal/service/task.go already
   handles AutopilotRunID, but the daemon endpoint uses the handler
   copy. Fixing that path is out of scope for the CLI PR; drop
   run_only from the CLI and docs so we don't recommend a mode that
   cannot complete. Server continues to accept it for the existing UI.

2. UpdateAutopilot did not verify that a new assignee_id belongs to
   the workspace, unlike CreateAutopilot. This let a PATCH swap in an
   agent from a different workspace. Mirror the same
   GetAgentInWorkspace check.
2026-04-17 10:36:09 +08:00
Jiayuan Zhang
63daa7a112 feat(cli): add autopilot commands
Expose the existing autopilot REST API through the multica CLI so
users and agents can list, get, create, update, delete, trigger, and
inspect autopilots, plus manage their triggers (schedule/webhook/api).

Also surface the read + core write commands in the agent meta skill
prompt so agents discover them without needing --help.

- new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints
- add APIClient.PatchJSON (autopilot update uses PATCH)
- expose autopilot in CORE COMMANDS group
- extend runtime_config.go meta skill with autopilot entries
- document autopilot command group in CLI_AND_DAEMON.md
2026-04-17 10:21:41 +08:00
7 changed files with 842 additions and 2 deletions

View File

@@ -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

View 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"))
}
}

View 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)
}
}
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)
}
}