diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index f5e2e1a4..db530286 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -771,6 +771,16 @@ async def get_workflow_audit(workflow_id: str, limit: int = 50): return {"entries": audit.read_tail(workflow_id, limit=limit)} +@workflows.router.get("/{workflow_id}/events") +async def get_workflow_events(workflow_id: str): + """Event-trigger activity log, newest first ("saw X, skipped because Y, fired run Z").""" + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + from backend.apps.events.stores import read_log + return {"events": [e.model_dump(mode="json") for e in reversed(read_log(workflow_id))]} + + @workflows.router.patch("/{workflow_id}") async def update_workflow( workflow_id: str, diff --git a/frontend/src/app/pages/Workflows/app/DetailView.tsx b/frontend/src/app/pages/Workflows/app/DetailView.tsx index cf0e290b..f102d7f4 100644 --- a/frontend/src/app/pages/Workflows/app/DetailView.tsx +++ b/frontend/src/app/pages/Workflows/app/DetailView.tsx @@ -12,6 +12,7 @@ import { isRunning, runContextChip } from './model'; import { useEditAgentSession } from './useEditAgentSession'; import { useWorkflowPatch } from './useWorkflowPatch'; import ScheduleCard from './ScheduleCard'; +import EventTriggersCard from './EventTriggersCard'; import StepsCard from './StepsCard'; import HistoryCard from './HistoryCard'; import ColorSwatch from './ColorSwatch'; @@ -107,6 +108,7 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
+
diff --git a/frontend/src/app/pages/Workflows/app/EventTriggersCard.tsx b/frontend/src/app/pages/Workflows/app/EventTriggersCard.tsx new file mode 100644 index 00000000..f78a0a12 --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/EventTriggersCard.tsx @@ -0,0 +1,211 @@ +// "When something happens" panel beside the Schedule card: watch a folder/file +// or a web page, optionally filter with a plain-English condition, and see the +// trigger's recent activity ("saw X, skipped because Y") so a quiet trigger is +// debuggable instead of mysterious. + +import React, { useCallback, useEffect, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { API_BASE } from '@/shared/config'; +import type { EventTriggerConfig, Workflow, WorkflowEventLogEntry } from '@/shared/state/workflowsSlice'; +import { useWC, FONT_SERIF, track, knob } from './uiKit'; +import { useWorkflowPatch } from './useWorkflowPatch'; + +const WEB_POLL_CHOICES: Array<[number, string]> = [[60, 'every minute'], [300, 'every 5 min'], [900, 'every 15 min'], [3600, 'hourly']]; + +function newTrigger(kind: 'file' | 'web'): EventTriggerConfig { + return { + id: crypto.randomUUID().replace(/-/g, ''), + enabled: true, + source: kind === 'file' + ? { kind: 'file', path: '', poll_seconds: 15 } + : { kind: 'web', url: '', watch_for: '', poll_seconds: 300 }, + predicate: '', + coalesce_seconds: kind === 'file' ? 30 : 0, + max_fires_per_hour: 6, + }; +} + +const EventTriggersCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { + const WC = useWC(); + const patch = useWorkflowPatch(); + const triggers = workflow.event_triggers ?? []; + const [log, setLog] = useState([]); + + const patchTriggers = useCallback((next: EventTriggerConfig[]) => { + patch(workflow, { event_triggers: next }); + }, [patch, workflow]); + + const updateTrigger = useCallback((id: string, mut: (t: EventTriggerConfig) => EventTriggerConfig) => { + patchTriggers(triggers.map((t) => (t.id === id ? mut(t) : t))); + }, [patchTriggers, triggers]); + + // Activity poll while the panel is mounted; local endpoint, cheap. + useEffect(() => { + if (triggers.length === 0) return; + let alive = true; + const load = async () => { + try { + const r = await fetch(`${API_BASE}/workflows/${workflow.id}/events`); + const data = (await r.json()) as { events: WorkflowEventLogEntry[] }; + if (alive) setLog(data.events ?? []); + } catch { /* activity is best-effort */ } + }; + void load(); + const iv = setInterval(load, 15_000); + return () => { alive = false; clearInterval(iv); }; + }, [workflow.id, triggers.length]); + + const ghostBtn: CSSProperties = { + height: 26, padding: '0 10px', borderRadius: 7, border: `1px solid rgba(${WC.inkRGB},0.12)`, + cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: WC.raised, color: WC.ink3, + }; + const fieldStyle: CSSProperties = { + width: '100%', boxSizing: 'border-box', height: 30, background: WC.raised, + border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '0 9px', + fontSize: 12.5, color: WC.ink, + }; + const labelStyle: CSSProperties = { fontSize: 11.5, color: WC.muted, marginBottom: 4, display: 'block' }; + + const dotColor = (kind: WorkflowEventLogEntry['kind']): string => { + if (kind === 'fired') return WC.accent; + if (kind === 'emitted') return WC.muted; + return WC.warn; + }; + + return ( +
+
+ Event triggers +
+ + +
+
+ + {triggers.length === 0 && ( + + Run this workflow when something happens: a file lands in a folder, or a page you care about changes. + + )} + + {triggers.map((t) => { + const src = t.source; + return ( +
+
+ + {src.kind === 'file' ? 'Folder / file watch' : 'Web page watch'} + +
+
updateTrigger(t.id, (x) => ({ ...x, enabled: !x.enabled }))} + style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }} + > + {t.enabled ? 'On' : 'Off'} +
+
+ +
+
+ + {src.kind === 'file' ? ( +
+ Watch this folder or file + { + const path = e.target.value.trim(); + if (path !== src.path) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, path } })); + }} + /> +
+ ) : ( + <> +
+ Page URL + { + const url = e.target.value.trim(); + if (url !== src.url) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, url } })); + }} + /> +
+
+
+ Watching for + { + const watchFor = e.target.value.trim(); + if (watchFor !== src.watch_for) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, watch_for: watchFor } })); + }} + /> +
+
+ Check + +
+
+ + )} + +
+ Only when (optional, plain English) + { + const predicate = e.target.value.trim(); + if (predicate !== t.predicate) updateTrigger(t.id, (x) => ({ ...x, predicate })); + }} + /> +
+
+ ); + })} + + {triggers.length > 0 && log.length > 0 && ( +
+ Recent activity + {log.slice(0, 6).map((e, i) => ( +
+
+
+
+ + {new Date(e.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} + {' '} + {e.summary} + +
+ ))} +
+ )} +
+ ); +}; + +export default EventTriggersCard; diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 445e0250..20b4aed2 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -28,6 +28,40 @@ export interface ScheduleConfig { runs_count: number; } +export interface FileWatchSource { + kind: 'file'; + path: string; + poll_seconds: number; +} + +export interface WebWatchSource { + kind: 'web'; + url: string; + /** What change actually matters, in the user's words ("a reservation slot opens"). */ + watch_for: string; + poll_seconds: number; +} + +export type EventSourceConfig = FileWatchSource | WebWatchSource; + +export interface EventTriggerConfig { + id: string; + enabled: boolean; + source: EventSourceConfig; + /** Natural-language filter judged per event batch; empty = every batch fires. */ + predicate: string; + coalesce_seconds: number; + max_fires_per_hour: number; +} + +export interface WorkflowEventLogEntry { + ts: string; + trigger_id: string; + kind: 'emitted' | 'fired' | 'skipped' | 'error'; + summary: string; + run_id?: string | null; +} + export interface CostEstimate { monthly_usd: number; last_run_usd: number; @@ -74,6 +108,8 @@ export interface Workflow { steps: WorkflowStep[]; actions: ActionsConfig; schedule: ScheduleConfig; + /** Event triggers live beside the schedule; "every Monday AND when this file changes" is legitimate. */ + event_triggers?: EventTriggerConfig[]; permissions: PermissionTier[]; source_session_id?: string | null; dashboard_id?: string | null;