Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
a2dbf03785 fix(desktop): center dock icon within canvas
The bundled dock icon used by `pnpm dev:desktop` had its squircle
touching the top of the 1024×1024 canvas (T=0, B=11), making the
shape look shifted up in the macOS dock. Shift content down 6px
to balance margins (T=6, B=5).
2026-04-17 09:25:47 +08:00
12 changed files with 98 additions and 928 deletions

View File

@@ -385,62 +385,6 @@ 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 KiB

After

Width:  |  Height:  |  Size: 534 KiB

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -10,6 +11,8 @@ import {
GeminiCliLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
githubUrl,
heroButtonClassName,
} from "./shared";
@@ -63,14 +66,25 @@ export function LandingHero() {
</svg>
{t.hero.downloadDesktop}
</Link>
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
>
<GitHubMark className="size-4" />
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<div className="mt-10 flex items-center justify-center gap-8">
<span className="text-[15px] text-white/50">
{t.hero.worksWith}
</span>
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2.5 text-white/80">
<ClaudeCodeLogo className="size-5" />
<span className="text-[15px] font-medium">Claude Code</span>
@@ -103,6 +117,64 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">
@@ -110,6 +182,7 @@ function LandingBackdrop() {
src="/images/landing-bg.jpg"
alt=""
fill
priority
className="object-cover object-center"
/>
</div>
@@ -125,7 +198,6 @@ function ProductImage({ alt }: { alt: string }) {
alt={alt}
width={3532}
height={2382}
priority
className="block h-auto w-full"
sizes="(max-width: 1320px) 100vw, 1320px"
quality={85}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { Zap, Play, Pause, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
import {
@@ -20,7 +20,6 @@ import { PageHeader } from "../../layout/page-header";
import { ActorAvatar } from "../../common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import {
@@ -421,8 +420,9 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
}
};
const handleToggleStatus = (checked: boolean) => {
updateAutopilot.mutate({ id: autopilotId, status: checked ? "active" : "paused" });
const handleToggleStatus = () => {
const newStatus = autopilot.status === "active" ? "paused" : "active";
updateAutopilot.mutate({ id: autopilotId, status: newStatus });
};
return (
@@ -435,29 +435,27 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
</AppLink>
<span className="text-muted-foreground">/</span>
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
<div className="ml-1 flex items-center gap-1.5">
<Switch
size="sm"
checked={autopilot.status === "active"}
onCheckedChange={handleToggleStatus}
disabled={autopilot.status === "archived"}
aria-label={autopilot.status === "active" ? "Pause autopilot" : "Activate autopilot"}
/>
<span className={cn(
"text-xs font-medium capitalize",
autopilot.status === "active" ? "text-emerald-500" :
autopilot.status === "paused" ? "text-amber-500" :
"text-muted-foreground",
)}>
{autopilot.status}
</span>
</div>
<span className={cn(
"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
autopilot.status === "active" ? "bg-emerald-500/10 text-emerald-500" :
autopilot.status === "paused" ? "bg-amber-500/10 text-amber-500" :
"bg-muted text-muted-foreground",
)}>
{autopilot.status}
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
<Pencil className="h-3.5 w-3.5 mr-1" />
Edit
</Button>
<Button size="sm" variant="outline" onClick={handleToggleStatus}>
{autopilot.status === "active" ? (
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
) : (
<><Play className="h-3.5 w-3.5 mr-1" /> Activate</>
)}
</Button>
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
<Play className="h-3.5 w-3.5 mr-1" />
{triggerAutopilot.isPending ? "Running..." : "Run now"}

View File

@@ -1,618 +0,0 @@
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

@@ -1,120 +0,0 @@
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,7 +34,6 @@ func init() {
issueCmd.GroupID = groupCore
projectCmd.GroupID = groupCore
agentCmd.GroupID = groupCore
autopilotCmd.GroupID = groupCore
workspaceCmd.GroupID = groupCore
repoCmd.GroupID = groupCore
skillCmd.GroupID = groupCore
@@ -55,7 +54,6 @@ 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

@@ -20,9 +20,7 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
if !ok {
return
}
// Issues created via handler use IssueResponse; autopilot-created issues
// use map[string]any (see service/autopilot.go → issueToMap).
issue, ok := extractIssueFields(payload["issue"])
issue, ok := payload["issue"].(handler.IssueResponse)
if !ok {
return
}
@@ -50,7 +48,7 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
if !ok {
return
}
issue, ok := extractIssueFields(payload["issue"])
issue, ok := payload["issue"].(handler.IssueResponse)
if !ok {
return
}
@@ -109,31 +107,6 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
})
}
// extractIssueFields normalizes an issue payload that may be either a
// handler.IssueResponse struct (HTTP handler path) or a map[string]any
// (autopilot service path) into a common shape.
func extractIssueFields(v any) (handler.IssueResponse, bool) {
if issue, ok := v.(handler.IssueResponse); ok {
return issue, true
}
m, ok := v.(map[string]any)
if !ok {
return handler.IssueResponse{}, false
}
issue := handler.IssueResponse{}
issue.ID, _ = m["id"].(string)
issue.WorkspaceID, _ = m["workspace_id"].(string)
issue.CreatorType, _ = m["creator_type"].(string)
issue.CreatorID, _ = m["creator_id"].(string)
issue.AssigneeType, _ = m["assignee_type"].(*string)
issue.AssigneeID, _ = m["assignee_id"].(*string)
issue.Description, _ = m["description"].(*string)
if issue.ID == "" || issue.CreatorID == "" {
return handler.IssueResponse{}, false
}
return issue, true
}
// addSubscriber adds a user as an issue subscriber and publishes a
// subscriber:added event for real-time frontend sync.
func addSubscriber(bus *events.Bus, queries *db.Queries, workspaceID, issueID, userType, userID, reason string) {

View File

@@ -357,39 +357,6 @@ func TestSubscriberAddedEventPublished(t *testing.T) {
}
}
// Autopilot publishes EventIssueCreated with a map[string]any payload (not handler.IssueResponse).
// The listener must still subscribe the creator.
func TestSubscriberIssueCreated_AutopilotMapPayload(t *testing.T) {
queries := db.New(testPool)
bus := events.New()
registerSubscriberListeners(bus, queries)
issueID := createTestIssue(t, testWorkspaceID, testUserID)
t.Cleanup(func() { cleanupTestIssue(t, issueID) })
bus.Publish(events.Event{
Type: protocol.EventIssueCreated,
WorkspaceID: testWorkspaceID,
ActorType: "member",
ActorID: testUserID,
Payload: map[string]any{
"issue": map[string]any{
"id": issueID,
"workspace_id": testWorkspaceID,
"title": "autopilot test issue",
"status": "todo",
"priority": "medium",
"creator_type": "member",
"creator_id": testUserID,
},
},
})
if !isSubscribed(t, queries, issueID, "member", testUserID) {
t.Fatal("expected creator to be subscribed when autopilot publishes map payload")
}
}
// Verify parseUUID is consistent — pgtype.UUID from our local helper should match util.ParseUUID
func TestParseUUIDConsistency(t *testing.T) {
uuid := "550e8400-e29b-41d4-a716-446655440000"

View File

@@ -183,36 +183,6 @@ 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,10 +75,7 @@ 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")
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("- `multica attachment download <id> [-o <dir>]` — Download an attachment file locally by ID\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")
@@ -87,11 +84,7 @@ 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")
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")
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n\n")
// Inject available repositories section.
if len(ctx.Repos) > 0 {

View File

@@ -359,13 +359,6 @@ 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)
}
}