mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
* feat(autopilot): add scheduled/triggered automation for AI agents Introduce the Autopilot feature — recurring automations that assign work to AI agents on a schedule or manual trigger. Supports two execution modes: create_issue (creates an issue for the agent to work on) and run_only (directly enqueues an agent task without issue pollution). Backend: migration (3 tables + 2 columns), sqlc queries, AutopilotService with concurrency policies (skip/queue/replace), HTTP CRUD + trigger endpoints, background cron scheduler (30s tick), event listeners for issue→run and task→run status sync. Frontend: types, API client methods, TanStack Query hooks with optimistic mutations, realtime cache invalidation, list page with create dialog, detail page with trigger management and run history, sidebar nav + routes for both web and desktop apps. * feat(autopilot): improve UX — trigger config, edit dialog, template gallery - Replace raw cron input with friendly frequency tabs (Hourly/Daily/Weekdays/Weekly/Custom), time picker, and timezone dropdown defaulting to user's local timezone - Fix Select components showing UUIDs instead of names (Base UI render function pattern) - Add Edit button on detail page opening a unified edit dialog - Remove project/concurrency/issue-title-template from create/edit (simplify for users) - Add trigger configuration inline during autopilot creation - Add template gallery on empty state (6 step-by-step workflow templates) - Rename "Description" to "Prompt" throughout UI - Inject autopilot run timestamp into issue description for agent date awareness - Treat issue status "in_review" as run completion (fixes skip on next trigger) - Make migration idempotent with IF NOT EXISTS clauses
285 lines
9.2 KiB
TypeScript
285 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import {
|
|
Select,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
SelectContent,
|
|
SelectItem,
|
|
} from "@multica/ui/components/ui/select";
|
|
|
|
export type TriggerFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "custom";
|
|
|
|
export interface TriggerConfig {
|
|
frequency: TriggerFrequency;
|
|
time: string; // HH:MM
|
|
dayOfWeek: number; // 0=Sun … 6=Sat
|
|
cronExpression: string; // only used when frequency === "custom"
|
|
timezone: string; // IANA
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FREQUENCIES: { value: TriggerFrequency; label: string }[] = [
|
|
{ value: "hourly", label: "Hourly" },
|
|
{ value: "daily", label: "Daily" },
|
|
{ value: "weekdays", label: "Weekdays" },
|
|
{ value: "weekly", label: "Weekly" },
|
|
{ value: "custom", label: "Custom" },
|
|
];
|
|
|
|
const DAYS_OF_WEEK = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
|
|
const COMMON_TIMEZONES = [
|
|
"UTC",
|
|
"America/New_York",
|
|
"America/Chicago",
|
|
"America/Denver",
|
|
"America/Los_Angeles",
|
|
"America/Sao_Paulo",
|
|
"Europe/London",
|
|
"Europe/Paris",
|
|
"Europe/Berlin",
|
|
"Europe/Moscow",
|
|
"Asia/Dubai",
|
|
"Asia/Kolkata",
|
|
"Asia/Singapore",
|
|
"Asia/Shanghai",
|
|
"Asia/Tokyo",
|
|
"Asia/Seoul",
|
|
"Australia/Sydney",
|
|
"Pacific/Auckland",
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function getLocalTimezone(): string {
|
|
try {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
} catch {
|
|
return "UTC";
|
|
}
|
|
}
|
|
|
|
function getTimezoneOffset(tz: string): string {
|
|
if (tz === "UTC") return "UTC";
|
|
try {
|
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
timeZone: tz,
|
|
timeZoneName: "shortOffset",
|
|
}).formatToParts(new Date());
|
|
return parts.find((p) => p.type === "timeZoneName")?.value ?? tz;
|
|
} catch {
|
|
return tz;
|
|
}
|
|
}
|
|
|
|
function getTimezoneLabel(tz: string): string {
|
|
if (tz === "UTC") return "UTC";
|
|
const city = tz.split("/").pop()?.replace(/_/g, " ") ?? tz;
|
|
return `${city} (${getTimezoneOffset(tz)})`;
|
|
}
|
|
|
|
function formatTime12h(time: string): string {
|
|
const [h, m] = time.split(":");
|
|
const hour = parseInt(h ?? "9", 10);
|
|
const min = parseInt(m ?? "0", 10);
|
|
const ampm = hour >= 12 ? "PM" : "AM";
|
|
return `${hour % 12 || 12}:${min.toString().padStart(2, "0")} ${ampm}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function getDefaultTriggerConfig(): TriggerConfig {
|
|
return {
|
|
frequency: "daily",
|
|
time: "09:00",
|
|
dayOfWeek: 1,
|
|
cronExpression: "0 9 * * 1-5",
|
|
timezone: getLocalTimezone(),
|
|
};
|
|
}
|
|
|
|
export function toCronExpression(cfg: TriggerConfig): string {
|
|
const [h, m] = cfg.time.split(":");
|
|
const hour = parseInt(h ?? "9", 10);
|
|
const min = parseInt(m ?? "0", 10);
|
|
switch (cfg.frequency) {
|
|
case "hourly":
|
|
return `${min} * * * *`;
|
|
case "daily":
|
|
return `${min} ${hour} * * *`;
|
|
case "weekdays":
|
|
return `${min} ${hour} * * 1-5`;
|
|
case "weekly":
|
|
return `${min} ${hour} * * ${cfg.dayOfWeek}`;
|
|
case "custom":
|
|
return cfg.cronExpression;
|
|
}
|
|
}
|
|
|
|
export function describeTrigger(cfg: TriggerConfig): string {
|
|
const offset = getTimezoneOffset(cfg.timezone);
|
|
switch (cfg.frequency) {
|
|
case "hourly": {
|
|
const min = parseInt(cfg.time.split(":")[1] ?? "0", 10);
|
|
return `Runs every hour at :${min.toString().padStart(2, "0")}`;
|
|
}
|
|
case "daily":
|
|
return `Runs daily at ${formatTime12h(cfg.time)} ${offset}`;
|
|
case "weekdays":
|
|
return `Runs weekdays at ${formatTime12h(cfg.time)} ${offset}`;
|
|
case "weekly":
|
|
return `Runs every ${DAYS_OF_WEEK[cfg.dayOfWeek]} at ${formatTime12h(cfg.time)} ${offset}`;
|
|
case "custom":
|
|
return `Custom schedule: ${cfg.cronExpression}`;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function TriggerConfigSection({
|
|
config,
|
|
onChange,
|
|
}: {
|
|
config: TriggerConfig;
|
|
onChange: (config: TriggerConfig) => void;
|
|
}) {
|
|
const timezones = useMemo(() => {
|
|
const local = getLocalTimezone();
|
|
const set = new Set(COMMON_TIMEZONES);
|
|
return set.has(local) ? COMMON_TIMEZONES : [local, ...COMMON_TIMEZONES];
|
|
}, []);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Frequency tabs */}
|
|
<div className="flex flex-wrap gap-1">
|
|
{FREQUENCIES.map((f) => (
|
|
<button
|
|
key={f.value}
|
|
type="button"
|
|
className={cn(
|
|
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
|
config.frequency === f.value
|
|
? "bg-foreground text-background"
|
|
: "bg-muted text-muted-foreground hover:text-foreground",
|
|
)}
|
|
onClick={() => onChange({ ...config, frequency: f.value })}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{config.frequency === "custom" ? (
|
|
/* Custom cron input */
|
|
<div>
|
|
<label className="text-xs text-muted-foreground">Cron Expression</label>
|
|
<input
|
|
type="text"
|
|
value={config.cronExpression}
|
|
onChange={(e) => onChange({ ...config, cronExpression: e.target.value })}
|
|
placeholder="0 9 * * 1-5"
|
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Standard 5-field cron (min hour dom month dow)
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Time + Timezone row */}
|
|
<div className="flex gap-3">
|
|
{config.frequency === "hourly" ? (
|
|
<div className="w-24">
|
|
<label className="text-xs text-muted-foreground">Minute</label>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
max={59}
|
|
value={parseInt(config.time.split(":")[1] ?? "0", 10)}
|
|
onChange={(e) => {
|
|
const min = Math.max(0, Math.min(59, parseInt(e.target.value) || 0));
|
|
onChange({ ...config, time: `00:${min.toString().padStart(2, "0")}` });
|
|
}}
|
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="w-28">
|
|
<label className="text-xs text-muted-foreground">Time</label>
|
|
<input
|
|
type="time"
|
|
value={config.time}
|
|
onChange={(e) => onChange({ ...config, time: e.target.value || config.time })}
|
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<label className="text-xs text-muted-foreground">Timezone</label>
|
|
<Select
|
|
value={config.timezone}
|
|
onValueChange={(v) => v && onChange({ ...config, timezone: v })}
|
|
>
|
|
<SelectTrigger className="mt-1 w-full">
|
|
<SelectValue>
|
|
{() => getTimezoneLabel(config.timezone)}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{timezones.map((tz) => (
|
|
<SelectItem key={tz} value={tz}>
|
|
{getTimezoneLabel(tz)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Day-of-week selector for weekly */}
|
|
{config.frequency === "weekly" && (
|
|
<div>
|
|
<label className="text-xs text-muted-foreground">Day</label>
|
|
<div className="flex gap-1 mt-1">
|
|
{DAYS_OF_WEEK.map((day, i) => (
|
|
<button
|
|
key={day}
|
|
type="button"
|
|
className={cn(
|
|
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
|
config.dayOfWeek === i
|
|
? "bg-foreground text-background"
|
|
: "bg-muted text-muted-foreground hover:text-foreground",
|
|
)}
|
|
onClick={() => onChange({ ...config, dayOfWeek: i })}
|
|
>
|
|
{day}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Human-readable preview */}
|
|
<p className="text-xs text-muted-foreground">{describeTrigger(config)}</p>
|
|
</div>
|
|
);
|
|
}
|