Add routines automation workflows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-19 08:39:24 -05:00
parent 7a652b8998
commit 8f5196f7d6
35 changed files with 25977 additions and 5 deletions

View File

@@ -13,6 +13,8 @@ import { Projects } from "./pages/Projects";
import { ProjectDetail } from "./pages/ProjectDetail";
import { Issues } from "./pages/Issues";
import { IssueDetail } from "./pages/IssueDetail";
import { Routines } from "./pages/Routines";
import { RoutineDetail } from "./pages/RoutineDetail";
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
import { Goals } from "./pages/Goals";
import { GoalDetail } from "./pages/GoalDetail";
@@ -149,6 +151,8 @@ function boardRoutes() {
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
@@ -313,6 +317,8 @@ export function App() {
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />

View File

@@ -22,7 +22,14 @@ export interface IssueForRun {
}
export const activityApi = {
list: (companyId: string) => api.get<ActivityEvent[]>(`/companies/${companyId}/activity`),
list: (companyId: string, filters?: { entityType?: string; entityId?: string; agentId?: string }) => {
const params = new URLSearchParams();
if (filters?.entityType) params.set("entityType", filters.entityType);
if (filters?.entityId) params.set("entityId", filters.entityId);
if (filters?.agentId) params.set("agentId", filters.agentId);
const qs = params.toString();
return api.get<ActivityEvent[]>(`/companies/${companyId}/activity${qs ? `?${qs}` : ""}`);
},
forIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity`),
runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs`),
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),

View File

@@ -6,6 +6,7 @@ export { companiesApi } from "./companies";
export { agentsApi } from "./agents";
export { projectsApi } from "./projects";
export { issuesApi } from "./issues";
export { routinesApi } from "./routines";
export { goalsApi } from "./goals";
export { approvalsApi } from "./approvals";
export { costsApi } from "./costs";

View File

@@ -22,6 +22,9 @@ export const issuesApi = {
touchedByUserId?: string;
unreadForUserId?: string;
labelId?: string;
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
q?: string;
},
) => {
@@ -33,6 +36,9 @@ export const issuesApi = {
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
if (filters?.labelId) params.set("labelId", filters.labelId);
if (filters?.originKind) params.set("originKind", filters.originKind);
if (filters?.originId) params.set("originId", filters.originId);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.q) params.set("q", filters.q);
const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);

57
ui/src/api/routines.ts Normal file
View File

@@ -0,0 +1,57 @@
import type {
ActivityEvent,
Routine,
RoutineDetail,
RoutineListItem,
RoutineRun,
RoutineRunSummary,
RoutineTrigger,
RoutineTriggerSecretMaterial,
} from "@paperclipai/shared";
import { activityApi } from "./activity";
import { api } from "./client";
export interface RoutineTriggerResponse {
trigger: RoutineTrigger;
secretMaterial: RoutineTriggerSecretMaterial | null;
}
export interface RotateRoutineTriggerResponse {
trigger: RoutineTrigger;
secretMaterial: RoutineTriggerSecretMaterial;
}
export const routinesApi = {
list: (companyId: string) => api.get<RoutineListItem[]>(`/companies/${companyId}/routines`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Routine>(`/companies/${companyId}/routines`, data),
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
update: (id: string, data: Record<string, unknown>) => api.patch<Routine>(`/routines/${id}`, data),
listRuns: (id: string, limit: number = 50) => api.get<RoutineRunSummary[]>(`/routines/${id}/runs?limit=${limit}`),
createTrigger: (id: string, data: Record<string, unknown>) =>
api.post<RoutineTriggerResponse>(`/routines/${id}/triggers`, data),
updateTrigger: (id: string, data: Record<string, unknown>) =>
api.patch<RoutineTrigger>(`/routine-triggers/${id}`, data),
rotateTriggerSecret: (id: string) =>
api.post<RotateRoutineTriggerResponse>(`/routine-triggers/${id}/rotate-secret`, {}),
run: (id: string, data?: Record<string, unknown>) =>
api.post<RoutineRun>(`/routines/${id}/run`, data ?? {}),
activity: async (
companyId: string,
routineId: string,
related?: { triggerIds?: string[]; runIds?: string[] },
) => {
const requests = [
activityApi.list(companyId, { entityType: "routine", entityId: routineId }),
...(related?.triggerIds ?? []).map((triggerId) =>
activityApi.list(companyId, { entityType: "routine_trigger", entityId: triggerId })),
...(related?.runIds ?? []).map((runId) =>
activityApi.list(companyId, { entityType: "routine_run", entityId: runId })),
];
const events = (await Promise.all(requests)).flat();
const deduped = new Map(events.map((event) => [event.id, event]));
return [...deduped.values()].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
},
};

View File

@@ -9,6 +9,7 @@ import {
SquarePen,
Network,
Boxes,
Repeat,
Settings,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
@@ -98,6 +99,7 @@ export function Sidebar() {
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection>

View File

@@ -7,6 +7,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"agents",
"projects",
"issues",
"routines",
"goals",
"approvals",
"costs",

View File

@@ -48,6 +48,12 @@ export const queryKeys = {
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
},
routines: {
list: (companyId: string) => ["routines", companyId] as const,
detail: (id: string) => ["routines", "detail", id] as const,
runs: (id: string) => ["routines", "runs", id] as const,
activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const,
},
executionWorkspaces: {
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
["execution-workspaces", companyId, filters ?? {}] as const,

View File

@@ -0,0 +1,914 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Clock3,
Copy,
Play,
RefreshCw,
Repeat,
Save,
Webhook,
Zap,
} from "lucide-react";
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { IssueRow } from "../components/IssueRow";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { timeAgo } from "../lib/timeAgo";
import type { RoutineTrigger } from "@paperclipai/shared";
const priorities = ["critical", "high", "medium", "low"];
const routineStatuses = ["active", "paused", "archived"];
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const triggerKinds = ["schedule", "webhook", "api"];
const signingModes = ["bearer", "hmac_sha256"];
const routineTabs = ["triggers", "runs", "issues", "activity"] as const;
const concurrencyPolicyDescriptions: Record<string, string> = {
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
always_enqueue: "Queue every trigger occurrence, even if several runs stack up.",
skip_if_active: "Drop overlapping trigger occurrences while the routine is already active.",
};
const catchUpPolicyDescriptions: Record<string, string> = {
skip_missed: "Ignore schedule windows that were missed while the routine or scheduler was paused.",
enqueue_missed_with_cap: "Catch up missed schedule windows with a capped backlog after recovery.",
};
const signingModeDescriptions: Record<string, string> = {
bearer: "Expect a shared bearer token in the Authorization header.",
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
};
type RoutineTab = (typeof routineTabs)[number];
type SecretMessage = {
title: string;
webhookUrl: string;
webhookSecret: string;
};
function isRoutineTab(value: string | null): value is RoutineTab {
return value !== null && routineTabs.includes(value as RoutineTab);
}
function getRoutineTabFromSearch(search: string): RoutineTab {
const tab = new URLSearchParams(search).get("tab");
return isRoutineTab(tab) ? tab : "triggers";
}
function formatActivityDetailValue(value: unknown): string {
if (value === null) return "null";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return value.length === 0 ? "[]" : value.map((item) => formatActivityDetailValue(item)).join(", ");
try {
return JSON.stringify(value);
} catch {
return "[unserializable]";
}
}
function TriggerEditor({
trigger,
onSave,
onRotate,
}: {
trigger: RoutineTrigger;
onSave: (id: string, patch: Record<string, unknown>) => void;
onRotate: (id: string) => void;
}) {
const [draft, setDraft] = useState({
label: trigger.label ?? "",
enabled: trigger.enabled ? "true" : "false",
cronExpression: trigger.cronExpression ?? "",
timezone: trigger.timezone ?? "UTC",
signingMode: trigger.signingMode ?? "bearer",
replayWindowSec: String(trigger.replayWindowSec ?? 300),
});
useEffect(() => {
setDraft({
label: trigger.label ?? "",
enabled: trigger.enabled ? "true" : "false",
cronExpression: trigger.cronExpression ?? "",
timezone: trigger.timezone ?? "UTC",
signingMode: trigger.signingMode ?? "bearer",
replayWindowSec: String(trigger.replayWindowSec ?? 300),
});
}, [trigger]);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
{trigger.kind === "schedule" ? <Clock3 className="h-4 w-4" /> : trigger.kind === "webhook" ? <Webhook className="h-4 w-4" /> : <Zap className="h-4 w-4" />}
{trigger.label ?? trigger.kind}
</CardTitle>
<CardDescription>
{trigger.kind === "schedule" && trigger.nextRunAt
? `Next run ${new Date(trigger.nextRunAt).toLocaleString()}`
: trigger.kind === "webhook"
? "Public webhook trigger"
: "Authenticated API/manual trigger"}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Label</Label>
<Input
value={draft.label}
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Enabled</Label>
<Select value={draft.enabled} onValueChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Enabled</SelectItem>
<SelectItem value="false">Paused</SelectItem>
</SelectContent>
</Select>
</div>
{trigger.kind === "schedule" && (
<>
<div className="space-y-2">
<Label>Cron</Label>
<Input
value={draft.cronExpression}
onChange={(event) => setDraft((current) => ({ ...current, cronExpression: event.target.value }))}
placeholder="0 10 * * *"
/>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Input
value={draft.timezone}
onChange={(event) => setDraft((current) => ({ ...current, timezone: event.target.value }))}
placeholder="America/Chicago"
/>
</div>
</>
)}
{trigger.kind === "webhook" && (
<>
<div className="space-y-2">
<Label>Signing mode</Label>
<Select
value={draft.signingMode}
onValueChange={(signingMode) => setDraft((current) => ({ ...current, signingMode }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Replay window seconds</Label>
<Input
value={draft.replayWindowSec}
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
/>
</div>
</>
)}
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={() =>
onSave(trigger.id, {
label: draft.label.trim() || null,
enabled: draft.enabled === "true",
...(trigger.kind === "schedule"
? { cronExpression: draft.cronExpression.trim(), timezone: draft.timezone.trim() }
: {}),
...(trigger.kind === "webhook"
? {
signingMode: draft.signingMode,
replayWindowSec: Number(draft.replayWindowSec || "300"),
}
: {}),
})
}
>
<Save className="mr-2 h-4 w-4" />
Save trigger
</Button>
{trigger.kind === "webhook" && (
<Button variant="outline" onClick={() => onRotate(trigger.id)}>
<RefreshCw className="mr-2 h-4 w-4" />
Rotate secret
</Button>
)}
{trigger.lastResult && <span className="text-sm text-muted-foreground">Last result: {trigger.lastResult}</span>}
</div>
</CardContent>
</Card>
);
}
export function RoutineDetail() {
const { routineId } = useParams<{ routineId: string }>();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { pushToast } = useToast();
const hydratedRoutineIdRef = useRef<string | null>(null);
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
const [newTrigger, setNewTrigger] = useState({
kind: "schedule",
label: "",
cronExpression: "0 10 * * *",
timezone: "UTC",
signingMode: "bearer",
replayWindowSec: "300",
});
const [editDraft, setEditDraft] = useState({
title: "",
description: "",
projectId: "",
assigneeAgentId: "",
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
});
const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]);
const { data: routine, isLoading, error } = useQuery({
queryKey: queryKeys.routines.detail(routineId!),
queryFn: () => routinesApi.get(routineId!),
enabled: !!routineId,
});
const { data: routineRuns } = useQuery({
queryKey: queryKeys.routines.runs(routineId!),
queryFn: () => routinesApi.listRuns(routineId!),
enabled: !!routineId,
});
const relatedActivityIds = useMemo(
() => ({
triggerIds: routine?.triggers.map((trigger) => trigger.id) ?? [],
runIds: routineRuns?.map((run) => run.id) ?? [],
}),
[routine?.triggers, routineRuns],
);
const { data: executionIssues } = useQuery({
queryKey: ["routine-execution-issues", selectedCompanyId, routineId],
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
originKind: "routine_execution",
originId: routineId!,
includeRoutineExecutions: true,
}),
enabled: !!selectedCompanyId && !!routineId,
});
const { data: activity } = useQuery({
queryKey: [
...queryKeys.routines.activity(selectedCompanyId!, routineId!),
relatedActivityIds.triggerIds.join(","),
relatedActivityIds.runIds.join(","),
],
queryFn: () => routinesApi.activity(selectedCompanyId!, routineId!, relatedActivityIds),
enabled: !!selectedCompanyId && !!routineId && !!routine,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const routineDefaults = useMemo(
() =>
routine
? {
title: routine.title,
description: routine.description ?? "",
projectId: routine.projectId,
assigneeAgentId: routine.assigneeAgentId,
priority: routine.priority,
status: routine.status,
concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy,
}
: null,
[routine],
);
const isEditDirty = useMemo(() => {
if (!routineDefaults) return false;
return (
editDraft.title !== routineDefaults.title ||
editDraft.description !== routineDefaults.description ||
editDraft.projectId !== routineDefaults.projectId ||
editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId ||
editDraft.priority !== routineDefaults.priority ||
editDraft.status !== routineDefaults.status ||
editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy ||
editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy
);
}, [editDraft, routineDefaults]);
useEffect(() => {
if (!routine) return;
setBreadcrumbs([{ label: "Routines", href: "/routines" }, { label: routine.title }]);
if (!routineDefaults) return;
const changedRoutine = hydratedRoutineIdRef.current !== routine.id;
if (changedRoutine || !isEditDirty) {
setEditDraft(routineDefaults);
hydratedRoutineIdRef.current = routine.id;
}
}, [routine, routineDefaults, isEditDirty, setBreadcrumbs]);
const copySecretValue = async (label: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
pushToast({ title: `${label} copied`, tone: "success" });
} catch (error) {
pushToast({
title: `Failed to copy ${label.toLowerCase()}`,
body: error instanceof Error ? error.message : "Clipboard access was denied.",
tone: "error",
});
}
};
const setActiveTab = (value: string) => {
if (!routineId || !isRoutineTab(value)) return;
const params = new URLSearchParams(location.search);
if (value === "triggers") {
params.delete("tab");
} else {
params.set("tab", value);
}
const search = params.toString();
navigate(
{
pathname: location.pathname,
search: search ? `?${search}` : "",
},
{ replace: true },
);
};
const saveRoutine = useMutation({
mutationFn: () =>
routinesApi.update(routineId!, {
...editDraft,
description: editDraft.description.trim() || null,
}),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Failed to save routine",
body: error instanceof Error ? error.message : "Paperclip could not save the routine.",
tone: "error",
});
},
});
const runRoutine = useMutation({
mutationFn: () => routinesApi.run(routineId!),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.runs(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Routine run failed",
body: error instanceof Error ? error.message : "Paperclip could not start the routine run.",
tone: "error",
});
},
});
const createTrigger = useMutation({
mutationFn: async (): Promise<RoutineTriggerResponse> =>
routinesApi.createTrigger(routineId!, {
kind: newTrigger.kind,
label: newTrigger.label.trim() || null,
...(newTrigger.kind === "schedule"
? { cronExpression: newTrigger.cronExpression.trim(), timezone: newTrigger.timezone.trim() }
: {}),
...(newTrigger.kind === "webhook"
? {
signingMode: newTrigger.signingMode,
replayWindowSec: Number(newTrigger.replayWindowSec || "300"),
}
: {}),
}),
onSuccess: async (result) => {
if (result.secretMaterial) {
setSecretMessage({
title: "Webhook trigger created",
webhookUrl: result.secretMaterial.webhookUrl,
webhookSecret: result.secretMaterial.webhookSecret,
});
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Failed to add trigger",
body: error instanceof Error ? error.message : "Paperclip could not create the trigger.",
tone: "error",
});
},
});
const updateTrigger = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Record<string, unknown> }) => routinesApi.updateTrigger(id, patch),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Failed to update trigger",
body: error instanceof Error ? error.message : "Paperclip could not update the trigger.",
tone: "error",
});
},
});
const rotateTrigger = useMutation({
mutationFn: (id: string): Promise<RotateRoutineTriggerResponse> => routinesApi.rotateTriggerSecret(id),
onSuccess: async (result) => {
setSecretMessage({
title: "Webhook secret rotated",
webhookUrl: result.secretMaterial.webhookUrl,
webhookSecret: result.secretMaterial.webhookSecret,
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Failed to rotate webhook secret",
body: error instanceof Error ? error.message : "Paperclip could not rotate the webhook secret.",
tone: "error",
});
},
});
const agentName = useMemo(
() => new Map((agents ?? []).map((agent) => [agent.id, agent.name])),
[agents],
);
const projectName = useMemo(
() => new Map((projects ?? []).map((project) => [project.id, project.name])),
[projects],
);
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
if (error || !routine) {
return (
<Card>
<CardContent className="pt-6 text-sm text-destructive">
{error instanceof Error ? error.message : "Routine not found"}
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{secretMessage && (
<Card className="border-blue-500/30 bg-blue-500/5">
<CardHeader>
<CardTitle className="text-base">{secretMessage.title}</CardTitle>
<CardDescription>
Save this now. Paperclip will not show the secret value again.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="space-y-1">
<Label>Webhook URL</Label>
<div className="flex items-center gap-2">
<Input value={secretMessage.webhookUrl} readOnly />
<Button variant="outline" onClick={() => copySecretValue("Webhook URL", secretMessage.webhookUrl)}>
<Copy className="h-4 w-4" />
Copy URL
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Secret</Label>
<div className="flex items-center gap-2">
<Input value={secretMessage.webhookSecret} readOnly />
<Button variant="outline" onClick={() => copySecretValue("Webhook secret", secretMessage.webhookSecret)}>
<Copy className="h-4 w-4" />
Copy secret
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>{routine.title}</CardTitle>
<CardDescription>
Project {projectName.get(routine.projectId) ?? routine.projectId.slice(0, 8)} · Assignee {agentName.get(routine.assigneeAgentId) ?? routine.assigneeAgentId.slice(0, 8)}
</CardDescription>
</div>
<div className="flex gap-2">
<Badge variant={routine.status === "active" ? "default" : "secondary"}>
{routine.status.replaceAll("_", " ")}
</Badge>
<Button onClick={() => runRoutine.mutate()} disabled={runRoutine.isPending}>
<Play className="mr-2 h-4 w-4" />
Run now
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label>Title</Label>
<Input value={editDraft.title} onChange={(event) => setEditDraft((current) => ({ ...current, title: event.target.value }))} />
</div>
<div className="space-y-2 md:col-span-2">
<Label>Instructions</Label>
<Textarea
rows={4}
value={editDraft.description}
onChange={(event) => setEditDraft((current) => ({ ...current, description: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Project</Label>
<Select value={editDraft.projectId} onValueChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(projects ?? []).map((project) => (
<SelectItem key={project.id} value={project.id}>{project.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Assignee</Label>
<Select value={editDraft.assigneeAgentId} onValueChange={(assigneeAgentId) => setEditDraft((current) => ({ ...current, assigneeAgentId }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(agents ?? []).map((agent) => (
<SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={editDraft.status} onValueChange={(status) => setEditDraft((current) => ({ ...current, status }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{routineStatuses.map((status) => (
<SelectItem key={status} value={status}>{status.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={editDraft.priority} onValueChange={(priority) => setEditDraft((current) => ({ ...current, priority }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{priorities.map((priority) => (
<SelectItem key={priority} value={priority}>{priority}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Concurrency</Label>
<Select value={editDraft.concurrencyPolicy} onValueChange={(concurrencyPolicy) => setEditDraft((current) => ({ ...current, concurrencyPolicy }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{concurrencyPolicyDescriptions[editDraft.concurrencyPolicy]}
</p>
</div>
<div className="space-y-2">
<Label>Catch-up</Label>
<Select value={editDraft.catchUpPolicy} onValueChange={(catchUpPolicy) => setEditDraft((current) => ({ ...current, catchUpPolicy }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{catchUpPolicyDescriptions[editDraft.catchUpPolicy]}
</p>
</div>
<div className="md:col-span-2 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{routine.activeIssue ? (
<span>
Active issue:{" "}
<Link to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`} className="hover:underline">
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)}
</Link>
</span>
) : (
"No active execution issue."
)}
</div>
<div className="flex items-center gap-3">
{isEditDirty && (
<span className="text-xs text-amber-600">
Unsaved routine edits stay local until you save.
</span>
)}
<Button onClick={() => saveRoutine.mutate()} disabled={saveRoutine.isPending}>
<Save className="mr-2 h-4 w-4" />
Save routine
</Button>
</div>
</div>
</CardContent>
</Card>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList variant="line" className="w-full justify-start gap-1">
<TabsTrigger value="triggers">Triggers</TabsTrigger>
<TabsTrigger value="runs">Runs</TabsTrigger>
<TabsTrigger value="issues">Execution Issues</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="triggers" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Add Trigger</CardTitle>
<CardDescription>
Schedules, public webhooks, or authenticated internal runs all flow into the same routine run log.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Trigger kind</Label>
<Select value={newTrigger.kind} onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triggerKinds.map((kind) => (
<SelectItem key={kind} value={kind}>{kind}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Label</Label>
<Input value={newTrigger.label} onChange={(event) => setNewTrigger((current) => ({ ...current, label: event.target.value }))} />
</div>
{newTrigger.kind === "schedule" && (
<>
<div className="space-y-2">
<Label>Cron</Label>
<Input value={newTrigger.cronExpression} onChange={(event) => setNewTrigger((current) => ({ ...current, cronExpression: event.target.value }))} />
<p className="text-xs text-muted-foreground">
Five fields, minute first. Example: <code>0 10 * * 1-5</code> runs at 10:00 on weekdays.
</p>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Input value={newTrigger.timezone} onChange={(event) => setNewTrigger((current) => ({ ...current, timezone: event.target.value }))} />
<p className="text-xs text-muted-foreground">
Use an IANA timezone such as <code>America/Chicago</code> so schedules follow local time.
</p>
</div>
</>
)}
{newTrigger.kind === "webhook" && (
<>
<div className="space-y-2">
<Label>Signing mode</Label>
<Select value={newTrigger.signingMode} onValueChange={(signingMode) => setNewTrigger((current) => ({ ...current, signingMode }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{signingModeDescriptions[newTrigger.signingMode]}
</p>
</div>
<div className="space-y-2">
<Label>Replay window seconds</Label>
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
<p className="text-xs text-muted-foreground">
Reject webhook requests that arrive too late. A common starting point is 300 seconds.
</p>
</div>
</>
)}
<div className="md:col-span-2 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Webhook triggers return a one-time URL and secret. Copy them immediately.
</p>
<Button onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
{createTrigger.isPending ? "Adding..." : "Add trigger"}
</Button>
</div>
</CardContent>
</Card>
<div className="grid gap-4">
{routine.triggers.length === 0 ? (
<EmptyState icon={Repeat} message="No triggers configured yet. Add the first trigger above to make this routine run." />
) : (
routine.triggers.map((trigger) => (
<TriggerEditor
key={trigger.id}
trigger={trigger}
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
onRotate={(id) => rotateTrigger.mutate(id)}
/>
))
)}
</div>
</TabsContent>
<TabsContent value="runs">
<Card>
<CardHeader>
<CardTitle>Run History</CardTitle>
<CardDescription>Every trigger occurrence is captured here, whether it created work or was coalesced.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(routineRuns ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No runs yet.</p>
) : (
(routineRuns ?? []).map((run) => (
<div key={run.id} className="rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{run.source}</Badge>
<Badge variant={run.status === "failed" ? "destructive" : "secondary"}>
{run.status.replaceAll("_", " ")}
</Badge>
<span className="text-sm text-muted-foreground">{timeAgo(run.triggeredAt)}</span>
</div>
{run.trigger && (
<p className="mt-2 text-sm text-muted-foreground">
Trigger: {run.trigger.label ?? run.trigger.kind}
</p>
)}
{run.linkedIssue && (
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="mt-2 block text-sm hover:underline">
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)} · {run.linkedIssue.title}
</Link>
)}
{run.failureReason && (
<p className="mt-2 text-sm text-destructive">{run.failureReason}</p>
)}
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="issues">
<Card>
<CardHeader>
<CardTitle>Execution Issues</CardTitle>
<CardDescription>These are the actual issue records created from the routine.</CardDescription>
</CardHeader>
<CardContent>
{(executionIssues ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No execution issues yet.</p>
) : (
<div>
{(executionIssues ?? []).map((issue) => (
<IssueRow key={issue.id} issue={issue} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="activity">
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>Routine-level mutations and operator actions.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(activity ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No activity yet.</p>
) : (
(activity ?? []).map((event) => (
<div key={event.id} className="rounded-lg border border-border p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{event.action.replaceAll(".", " ")}</span>
<span className="text-muted-foreground">{timeAgo(event.createdAt)}</span>
</div>
{event.details && Object.keys(event.details).length > 0 && (
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{Object.entries(event.details).map(([key, value]) => (
<span key={key} className="rounded-full border border-border bg-muted/40 px-2 py-1">
<span className="font-medium text-foreground/80">{key.replaceAll("_", " ")}:</span>{" "}
{formatActivityDetailValue(value)}
</span>
))}
</div>
)}
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

372
ui/src/pages/Routines.tsx Normal file
View File

@@ -0,0 +1,372 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "@/lib/router";
import { Repeat, Plus, Play, Clock3, Webhook } from "lucide-react";
import { routinesApi } from "../api/routines";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { timeAgo } from "../lib/timeAgo";
const priorities = ["critical", "high", "medium", "low"];
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const concurrencyPolicyDescriptions: Record<string, string> = {
coalesce_if_active: "If a run is already active, keep just one follow-up run queued.",
always_enqueue: "Queue every trigger occurrence, even if the routine is already running.",
skip_if_active: "Drop new trigger occurrences while a run is still active.",
};
const catchUpPolicyDescriptions: Record<string, string> = {
skip_missed: "Ignore windows that were missed while the scheduler or routine was paused.",
enqueue_missed_with_cap: "Catch up missed schedule windows with a capped backlog after recovery.",
};
function triggerIcon(kind: string) {
if (kind === "schedule") return <Clock3 className="h-3.5 w-3.5" />;
if (kind === "webhook") return <Webhook className="h-3.5 w-3.5" />;
return <Play className="h-3.5 w-3.5" />;
}
export function Routines() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pushToast } = useToast();
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
const [draft, setDraft] = useState({
title: "",
description: "",
projectId: "",
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
});
useEffect(() => {
setBreadcrumbs([{ label: "Routines" }]);
}, [setBreadcrumbs]);
const { data: routines, isLoading, error } = useQuery({
queryKey: queryKeys.routines.list(selectedCompanyId!),
queryFn: () => routinesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const createRoutine = useMutation({
mutationFn: () =>
routinesApi.create(selectedCompanyId!, {
...draft,
description: draft.description.trim() || null,
}),
onSuccess: async (routine) => {
setDraft({
title: "",
description: "",
projectId: "",
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
});
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
pushToast({
title: "Routine created",
body: "Add the first trigger to turn it into a live workflow.",
tone: "success",
});
navigate(`/routines/${routine.id}?tab=triggers`);
},
});
const runRoutine = useMutation({
mutationFn: (id: string) => routinesApi.run(id),
onMutate: (id) => {
setRunningRoutineId(id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
},
onSettled: () => {
setRunningRoutineId(null);
},
onError: (error) => {
pushToast({
title: "Routine run failed",
body: error instanceof Error ? error.message : "Paperclip could not start the routine run.",
tone: "error",
});
},
});
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
const agentName = new Map((agents ?? []).map((agent) => [agent.id, agent.name]));
const projectName = new Map((projects ?? []).map((project) => [project.id, project.name]));
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Create Routine</CardTitle>
<CardDescription>
Define recurring work once, then add the first trigger on the next screen to make it live.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="routine-title">Title</Label>
<Input
id="routine-title"
value={draft.title}
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
placeholder="Review the last 24 hours of merged code"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="routine-description">Instructions</Label>
<Textarea
id="routine-description"
value={draft.description}
onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value }))}
rows={4}
placeholder="Summarize noteworthy changes, update docs if needed, and leave a concise report."
/>
</div>
<div className="space-y-2">
<Label>Project</Label>
<Select value={draft.projectId} onValueChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}>
<SelectTrigger>
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent>
{(projects ?? []).map((project) => (
<SelectItem key={project.id} value={project.id}>{project.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Assignee</Label>
<Select
value={draft.assigneeAgentId}
onValueChange={(assigneeAgentId) => setDraft((current) => ({ ...current, assigneeAgentId }))}
>
<SelectTrigger>
<SelectValue placeholder="Choose assignee" />
</SelectTrigger>
<SelectContent>
{(agents ?? []).map((agent) => (
<SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={draft.priority} onValueChange={(priority) => setDraft((current) => ({ ...current, priority }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{priorities.map((priority) => (
<SelectItem key={priority} value={priority}>{priority.replace("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Concurrency</Label>
<Select
value={draft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}
</p>
</div>
<div className="space-y-2">
<Label>Catch-up</Label>
<Select
value={draft.catchUpPolicy}
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{catchUpPolicyDescriptions[draft.catchUpPolicy]}
</p>
</div>
<div className="md:col-span-2 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
After creation, Paperclip takes you straight to trigger setup for schedule, webhook, or API entrypoints.
</p>
<Button
onClick={() => createRoutine.mutate()}
disabled={
createRoutine.isPending ||
!draft.title.trim() ||
!draft.projectId ||
!draft.assigneeAgentId
}
>
<Plus className="mr-2 h-4 w-4" />
{createRoutine.isPending ? "Creating..." : "Create Routine"}
</Button>
</div>
{createRoutine.isError && (
<p className="md:col-span-2 text-sm text-destructive">
{createRoutine.error instanceof Error ? createRoutine.error.message : "Failed to create routine"}
</p>
)}
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="pt-6 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load routines"}
</CardContent>
</Card>
)}
<div className="grid gap-4">
{(routines ?? []).length === 0 ? (
<EmptyState
icon={Repeat}
message="No routines yet. Create the first recurring workflow above."
/>
) : (
(routines ?? []).map((routine) => (
<Card key={routine.id}>
<CardContent className="flex flex-col gap-4 pt-6 md:flex-row md:items-start md:justify-between">
<div className="space-y-3 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Link to={`/routines/${routine.id}`} className="text-base font-medium hover:underline">
{routine.title}
</Link>
<Badge variant={routine.status === "active" ? "default" : "secondary"}>
{routine.status.replaceAll("_", " ")}
</Badge>
<Badge variant="outline">{routine.priority}</Badge>
</div>
{routine.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{routine.description}
</p>
)}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>Project: {projectName.get(routine.projectId) ?? routine.projectId.slice(0, 8)}</span>
<span>Assignee: {agentName.get(routine.assigneeAgentId) ?? routine.assigneeAgentId.slice(0, 8)}</span>
<span>Concurrency: {routine.concurrencyPolicy.replaceAll("_", " ")}</span>
</div>
<div className="flex flex-wrap gap-2">
{routine.triggers.length === 0 ? (
<Badge variant="outline">No triggers</Badge>
) : (
routine.triggers.map((trigger) => (
<Badge key={trigger.id} variant="outline" className="gap-1">
{triggerIcon(trigger.kind)}
{trigger.label ?? trigger.kind}
{!trigger.enabled && " paused"}
</Badge>
))
)}
</div>
</div>
<div className="flex shrink-0 flex-col gap-3 md:min-w-[250px]">
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
<p className="font-medium">Last run</p>
{routine.lastRun ? (
<div className="mt-1 space-y-1 text-muted-foreground">
<p>{routine.lastRun.status.replaceAll("_", " ")}</p>
<p>{timeAgo(routine.lastRun.triggeredAt)}</p>
</div>
) : (
<p className="mt-1 text-muted-foreground">No executions yet.</p>
)}
</div>
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
<p className="font-medium">Active execution issue</p>
{routine.activeIssue ? (
<Link to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`} className="mt-1 block text-muted-foreground hover:underline">
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)} · {routine.activeIssue.title}
</Link>
) : (
<p className="mt-1 text-muted-foreground">Nothing open.</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => runRoutine.mutate(routine.id)}
disabled={runningRoutineId === routine.id}
>
<Play className="mr-2 h-4 w-4" />
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</Button>
<Button asChild className="flex-1">
<Link to={`/routines/${routine.id}`}>Open</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}