mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-20 13:18:56 +02:00
Compare commits
6 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d47be55372 | ||
|
|
63daa7a112 | ||
|
|
7dc37e87df | ||
|
|
cf8a9647bb | ||
|
|
d7a8e9041e | ||
|
|
3b7abae5b4 |
@@ -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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -11,8 +10,6 @@ import {
|
||||
GeminiCliLogo,
|
||||
OpenClawLogo,
|
||||
OpenCodeLogo,
|
||||
GitHubMark,
|
||||
githubUrl,
|
||||
heroButtonClassName,
|
||||
} from "./shared";
|
||||
|
||||
@@ -66,25 +63,14 @@ 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 items-center justify-center gap-8">
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
|
||||
<span className="text-[15px] text-white/50">
|
||||
{t.hero.worksWith}
|
||||
</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3">
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<ClaudeCodeLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">Claude Code</span>
|
||||
@@ -117,64 +103,6 @@ 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">
|
||||
@@ -182,7 +110,6 @@ function LandingBackdrop() {
|
||||
src="/images/landing-bg.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
@@ -198,6 +125,7 @@ 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}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Zap, Play, Pause, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
|
||||
import { Zap, Play, 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,6 +20,7 @@ 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 {
|
||||
@@ -420,9 +421,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = () => {
|
||||
const newStatus = autopilot.status === "active" ? "paused" : "active";
|
||||
updateAutopilot.mutate({ id: autopilotId, status: newStatus });
|
||||
const handleToggleStatus = (checked: boolean) => {
|
||||
updateAutopilot.mutate({ id: autopilotId, status: checked ? "active" : "paused" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -435,27 +435,29 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
</AppLink>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
|
||||
<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 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>
|
||||
</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"}
|
||||
|
||||
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)
|
||||
|
||||
@@ -20,7 +20,9 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
issue, ok := payload["issue"].(handler.IssueResponse)
|
||||
// Issues created via handler use IssueResponse; autopilot-created issues
|
||||
// use map[string]any (see service/autopilot.go → issueToMap).
|
||||
issue, ok := extractIssueFields(payload["issue"])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -48,7 +50,7 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
issue, ok := payload["issue"].(handler.IssueResponse)
|
||||
issue, ok := extractIssueFields(payload["issue"])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -107,6 +109,31 @@ 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) {
|
||||
|
||||
@@ -357,6 +357,39 @@ 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"
|
||||
|
||||
@@ -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