From 0b832c7e65fade63415047c05bd89ce5185f9177 Mon Sep 17 00:00:00 2001 From: Damian Loch Date: Mon, 4 May 2026 16:43:58 -0400 Subject: [PATCH 01/45] feat: add Actions system for user-defined background agent tasks Adds a general-purpose Actions feature where users define natural language instructions that Aurora executes as background agent tasks using connected tools (IaC, GitHub, Datadog, etc.). Replaces the need for hardcoded automation pipelines. Backend: - actions + action_runs DB tables with RLS - CRUD + trigger routes at /api/actions - Executor service (dispatch_action, build_action_prompt) - Completion hooks in run_background_chat (all exit paths + finally) - Dedicated RBAC resource for actions (viewer read, editor write) Frontend: - Full actions page with list/detail/create views - API proxy routes - Run Action dropdown on IncidentCard (post-RCA trigger) - Sidebar nav entry --- client/src/app/actions/page.tsx | 545 ++++++++++++++++++ client/src/app/api/actions/[id]/route.ts | 17 + client/src/app/api/actions/[id]/run/route.ts | 7 + client/src/app/api/actions/[id]/runs/route.ts | 7 + client/src/app/api/actions/route.ts | 10 + .../app/incidents/components/IncidentCard.tsx | 76 ++- client/src/components/navigation.tsx | 20 +- server/chat/background/task.py | 46 +- server/main_compute.py | 3 + server/routes/actions/__init__.py | 6 + server/routes/actions/actions_routes.py | 261 +++++++++ server/services/actions/__init__.py | 0 server/services/actions/executor.py | 266 +++++++++ server/utils/auth/enforcer.py | 21 +- server/utils/db/db_utils.py | 37 ++ 15 files changed, 1317 insertions(+), 5 deletions(-) create mode 100644 client/src/app/actions/page.tsx create mode 100644 client/src/app/api/actions/[id]/route.ts create mode 100644 client/src/app/api/actions/[id]/run/route.ts create mode 100644 client/src/app/api/actions/[id]/runs/route.ts create mode 100644 client/src/app/api/actions/route.ts create mode 100644 server/routes/actions/__init__.py create mode 100644 server/routes/actions/actions_routes.py create mode 100644 server/services/actions/__init__.py create mode 100644 server/services/actions/executor.py diff --git a/client/src/app/actions/page.tsx b/client/src/app/actions/page.tsx new file mode 100644 index 000000000..d362cac50 --- /dev/null +++ b/client/src/app/actions/page.tsx @@ -0,0 +1,545 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { + Play, + Plus, + ChevronRight, + Clock, + CheckCircle2, + XCircle, + ArrowLeft, + Loader2, + Workflow, + AlertTriangle, + Hash, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useQuery, fetchR } from '@/lib/query'; +import { useToast } from '@/hooks/use-toast'; + +interface Action { + id: string; + name: string; + description: string; + instructions: string; + trigger_type: 'on_incident' | 'manual'; + mode: 'agent' | 'ask'; + enabled: boolean; + run_count: number; + last_run_at: string | null; + last_run_status: 'success' | 'error' | 'running' | null; +} + +interface ActionRun { + id: string; + status: 'success' | 'error' | 'running' | 'pending'; + trigger_context: Record; + started_at: string; + completed_at: string | null; + duration_ms?: number; + chat_session_id: string | null; + incident_id: string | null; + error: string | null; +} + +interface ActionDetail extends Action { + trigger_config: Record; + created_at: string; + updated_at: string; +} + +const actionsFetcher = async (key: string, signal: AbortSignal) => { + const res = await fetch(key, { credentials: 'include', signal }); + if (!res.ok) throw new Error(`Failed to load actions: ${res.status}`); + const data = await res.json(); + return data.actions || []; +}; + +const actionDetailFetcher = async (key: string, signal: AbortSignal) => { + const res = await fetch(key, { credentials: 'include', signal }); + if (!res.ok) throw new Error(`Failed to load action: ${res.status}`); + return res.json(); +}; + +// -- Shared style primitives (matching monitor page) -- + +function StatCard({ label, value, sub, icon: Icon }: { + label: string; + value: string; + sub?: string; + icon?: React.ComponentType<{ className?: string }>; +}) { + return ( +
+
+ {Icon && } + {label} +
+

+ {value} +

+ {sub &&

{sub}

} +
+ ); +} + +function Panel({ title, subtitle, children }: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} +
+ ); +} + +function TriggerBadge({ type }: { type: Action['trigger_type'] }) { + const styles: Record = { + on_incident: 'bg-blue-500/10 text-blue-400', + manual: 'bg-zinc-500/10 text-zinc-400', + }; + const labels: Record = { + on_incident: 'On Incident', + manual: 'Manual', + }; + return ( + + {labels[type] || type} + + ); +} + +function ModeBadge({ mode }: { mode: Action['mode'] }) { + return mode === 'agent' + ? Read-Write + : Read-Only; +} + +function StatusDot({ status }: { status: ActionRun['status'] }) { + switch (status) { + case 'success': return ; + case 'error': return ; + case 'running': return ; + default: return ; + } +} + +// -- List view -- + +function ActionsListView({ actions, onSelect, onCreate }: { + actions: Action[]; + onSelect: (a: Action) => void; + onCreate: () => void; +}) { + const active = actions.filter(a => a.enabled).length; + const totalRuns = actions.reduce((s, a) => s + (a.run_count || 0), 0); + + return ( +
+
+ + + +
+ + + {actions.length === 0 ? ( +
+ +

No actions yet

+

Create your first action to automate SRE workflows

+ +
+ ) : ( +
+ + + + + + + + + + + + + {actions.map((action) => ( + onSelect(action)} + className="border-b border-zinc-800/40 hover:bg-zinc-800/20 transition-colors duration-150 cursor-pointer" + > + + + + + + + + ))} + +
NameTriggerModeRunsLast Run
+
+ {action.name} + {!action.enabled && ( + OFF + )} +
+ {action.description && ( +

{action.description}

+ )} +
+ {action.run_count || 0} + + {action.last_run_at ? ( +
+ + {new Date(action.last_run_at).toLocaleDateString()} +
+ ) : ( + Never + )} +
+ +
+
+ )} +
+
+ ); +} + +// -- Detail view -- + +function ActionDetailView({ actionId, onBack }: { actionId: string; onBack: () => void }) { + const { toast } = useToast(); + const { data, mutate } = useQuery<{ action: ActionDetail; recent_runs: ActionRun[] }>( + `/api/actions/${actionId}`, actionDetailFetcher, { staleTime: 5_000 } + ); + + const action = data?.action; + const runs = data?.recent_runs || []; + + const handleToggle = useCallback(async (enabled: boolean) => { + await fetchR(`/api/actions/${actionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + mutate(); + }, [actionId, mutate]); + + const handleRunNow = useCallback(async () => { + try { + await fetchR(`/api/actions/${actionId}/run`, { method: 'POST' }); + toast({ title: 'Action triggered', description: 'Background task started.' }); + mutate(); + } catch { + toast({ title: 'Failed to trigger action', variant: 'destructive' }); + } + }, [actionId, mutate, toast]); + + const handleDelete = useCallback(async () => { + await fetchR(`/api/actions/${actionId}`, { method: 'DELETE' }); + onBack(); + }, [actionId, onBack]); + + if (!action) { + return
Loading...
; + } + + const succeeded = runs.filter(r => r.status === 'success').length; + const failed = runs.filter(r => r.status === 'error').length; + + return ( +
+
+ +
+
+

{action.name}

+ {action.description &&

{action.description}

} +
+
+
+ Enabled + +
+ + +
+
+
+ +
+ + + +
+ + +
+
+ + +
+
+

Instructions

+
+ {action.instructions} +
+
+ {action.mode === 'agent' && ( +
+ + Agent mode: Aurora can execute commands, modify Terraform, and open PRs. All actions are logged. +
+ )} +
+
+ + + {runs.length === 0 ? ( +

No runs yet

+ ) : ( +
+ + + + + + + + + + + {runs.map((run) => ( + + + + + + + ))} + +
StatusStartedDuration
+
+ + {run.status} +
+ {run.error &&

{run.error}

} +
+ {run.started_at ? new Date(run.started_at).toLocaleString() : '-'} + + {run.duration_ms != null ? `${(run.duration_ms / 1000).toFixed(1)}s` : '-'} + + {run.chat_session_id && (run.status === 'success' || run.status === 'error') && ( + + View Chat + + )} +
+
+ )} +
+
+ ); +} + +// -- Create view -- + +function CreateActionView({ onBack, onCreated }: { onBack: () => void; onCreated: () => void }) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [instructions, setInstructions] = useState(''); + const [triggerType, setTriggerType] = useState('manual'); + const [mode, setMode] = useState('agent'); + const [submitting, setSubmitting] = useState(false); + const { toast } = useToast(); + + const handleCreate = async () => { + setSubmitting(true); + try { + const res = await fetchR('/api/actions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description: description || undefined, instructions, trigger_type: triggerType, mode }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: 'Failed to create action', description: err.error || 'Unknown error', variant: 'destructive' }); + return; + } + toast({ title: 'Action created' }); + onCreated(); + } catch { + toast({ title: 'Failed to create action', variant: 'destructive' }); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ +

Create Action

+

Define natural language instructions that Aurora executes as a background agent task.

+
+ +
+
+ +
+
+ + setName(e.target.value)} placeholder="e.g. Mute noisy Datadog alerts via Terraform" /> +
+
+ + setDescription(e.target.value)} placeholder="Short summary of what this action does" /> +
+
+
+ + +
+