[eric] events: universal sources (agent-check any condition, custom push via /api/events/ingest)

This commit is contained in:
ciregenz
2026-07-28 13:12:03 -07:00
parent d1ef12b2c7
commit 52056c1911
12 changed files with 644 additions and 144 deletions
@@ -0,0 +1,169 @@
// One trigger row inside the Event Triggers panel: folder watch, page watch,
// agent check (any natural-language condition), or custom push (ingest API).
// Text fields commit onBlur so typing doesn't PATCH per keystroke.
import React from 'react';
import type { CSSProperties } from 'react';
import { API_BASE } from '@/shared/config';
import type { EventTriggerConfig, Workflow } from '@/shared/state/workflowsSlice';
import { useWC, track, knob } from './uiKit';
const WEB_POLL_CHOICES: Array<[number, string]> = [[60, 'every minute'], [300, 'every 5 min'], [900, 'every 15 min'], [3600, 'hourly']];
const AGENT_POLL_CHOICES: Array<[number, string]> = [[300, 'every 5 min'], [900, 'every 15 min'], [3600, 'hourly'], [21600, 'every 6 hours'], [86400, 'daily']];
const KIND_LABELS: Record<string, string> = {
file: 'Folder / file watch',
web: 'Web page watch',
agent: 'Agent check',
custom: 'Custom (push)',
};
interface RowProps {
workflow: Workflow;
trigger: EventTriggerConfig;
onMutate: (mut: (t: EventTriggerConfig) => EventTriggerConfig) => void;
onRemove: () => void;
}
const EventTriggerRow: React.FC<RowProps> = ({ workflow, trigger, onMutate, onRemove }) => {
const WC = useWC();
const t = trigger;
const src = t.source;
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 ghostBtn: CSSProperties = {
height: 26, padding: '0 8px', borderRadius: 7, border: `1px solid rgba(${WC.inkRGB},0.12)`,
cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: WC.raised, color: WC.ink3,
};
const pollSelect = (value: number, choices: Array<[number, string]>, onChange: (v: number) => void) => (
<select
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
style={{ ...fieldStyle, cursor: 'pointer', padding: '0 6px' }}
>
{choices.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
</select>
);
return (
<div style={{ border: `1px solid ${WC.line}`, borderRadius: 10, padding: '10px 11px', marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 9 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: WC.ink3 }}>{KIND_LABELS[src.kind] ?? src.kind}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
onClick={() => onMutate((x) => ({ ...x, enabled: !x.enabled }))}
style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
>
<span style={{ fontSize: 11.5, fontWeight: 600, color: t.enabled ? WC.accent : WC.muted }}>{t.enabled ? 'On' : 'Off'}</span>
<div style={track(t.enabled, WC)}><div style={knob(t.enabled)} /></div>
</div>
<button aria-label="Remove trigger" onClick={onRemove} style={{ ...ghostBtn, padding: '0 8px' }}></button>
</div>
</div>
{src.kind === 'file' && (
<div style={{ marginBottom: 8 }}>
<span style={labelStyle}>Watch this folder or file</span>
<input
style={fieldStyle}
defaultValue={src.path}
placeholder="~/Downloads"
onBlur={(e) => {
const path = e.target.value.trim();
if (path !== src.path) onMutate((x) => ({ ...x, source: { ...src, path } }));
}}
/>
</div>
)}
{src.kind === 'web' && (
<>
<div style={{ marginBottom: 8 }}>
<span style={labelStyle}>Page URL</span>
<input
style={fieldStyle}
defaultValue={src.url}
placeholder="https://example.com/reservations"
onBlur={(e) => {
const url = e.target.value.trim();
if (url !== src.url) onMutate((x) => ({ ...x, source: { ...src, url } }));
}}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<span style={labelStyle}>Watching for</span>
<input
style={fieldStyle}
defaultValue={src.watch_for}
placeholder="a reservation slot opening"
onBlur={(e) => {
const watchFor = e.target.value.trim();
if (watchFor !== src.watch_for) onMutate((x) => ({ ...x, source: { ...src, watch_for: watchFor } }));
}}
/>
</div>
<div style={{ width: 118, flex: 'none' }}>
<span style={labelStyle}>Check</span>
{pollSelect(src.poll_seconds, WEB_POLL_CHOICES, (v) => onMutate((x) => ({ ...x, source: { ...src, poll_seconds: v } })))}
</div>
</div>
</>
)}
{src.kind === 'agent' && (
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<span style={labelStyle}>What counts as the event? An agent checks with its tools.</span>
<input
style={fieldStyle}
defaultValue={src.check}
placeholder="a new episode of my favorite podcast is out"
onBlur={(e) => {
const check = e.target.value.trim();
if (check !== src.check) onMutate((x) => ({ ...x, source: { ...src, check } }));
}}
/>
</div>
<div style={{ width: 130, flex: 'none', alignSelf: 'flex-end' }}>
{pollSelect(src.poll_seconds, AGENT_POLL_CHOICES, (v) => onMutate((x) => ({ ...x, source: { ...src, poll_seconds: v } })))}
</div>
</div>
)}
{src.kind === 'custom' && (
<div style={{ marginBottom: 8 }}>
<span style={labelStyle}>Anything can push events here (scripts, webhooks, Shortcuts):</span>
<pre style={{
margin: 0, padding: '8px 10px', background: WC.inset, border: `1px solid ${WC.line}`,
borderRadius: 8, fontSize: 10.5, color: WC.ink3, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
fontFamily: "'JetBrains Mono',monospace", userSelect: 'text',
}}>
{`POST ${API_BASE}/events/ingest\n{"workflow_id": "${workflow.id}", "trigger_id": "${t.id}", "summary": "what happened", "dedup_key": "optional-id"}`}
</pre>
</div>
)}
<div>
<span style={labelStyle}>Only when (optional, plain English)</span>
<input
style={fieldStyle}
defaultValue={t.predicate}
placeholder={src.kind === 'file' ? 'a new CSV export shows up' : 'it matters enough to act on'}
onBlur={(e) => {
const predicate = e.target.value.trim();
if (predicate !== t.predicate) onMutate((x) => ({ ...x, predicate }));
}}
/>
</div>
</div>
);
};
export default EventTriggerRow;
@@ -1,24 +1,38 @@
// "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.
// "When something happens" panel beside the Schedule card. Four source kinds
// cover the universe: folder/file watch, web page watch, agent check (any
// natural-language condition), and custom push (anything can POST an event).
// The Recent-activity feed makes a quiet trigger debuggable instead of
// mysterious ("saw X, skipped because Y").
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 type { EventSourceConfig, EventTriggerConfig, Workflow, WorkflowEventLogEntry } from '@/shared/state/workflowsSlice';
import EventTriggerRow from './EventTriggerRow';
import { useWC, FONT_SERIF } 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']];
type TriggerKind = 'file' | 'web' | 'agent' | 'custom';
function newTrigger(kind: 'file' | 'web'): EventTriggerConfig {
const ADD_CHOICES: Array<[TriggerKind, string]> = [
['file', '+ Folder'],
['web', '+ Page'],
['agent', '+ Agent check'],
['custom', '+ Custom'],
];
function newSource(kind: TriggerKind): EventSourceConfig {
if (kind === 'file') return { kind: 'file', path: '', poll_seconds: 15 };
if (kind === 'web') return { kind: 'web', url: '', watch_for: '', poll_seconds: 300 };
if (kind === 'agent') return { kind: 'agent', check: '', model: '', poll_seconds: 900 };
return { kind: 'custom' };
}
function newTrigger(kind: TriggerKind): 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 },
source: newSource(kind),
predicate: '',
coalesce_seconds: kind === 'file' ? 30 : 0,
max_fires_per_hour: 6,
@@ -35,10 +49,6 @@ const EventTriggersCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
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;
@@ -56,15 +66,10 @@ const EventTriggersCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
}, [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,
height: 26, padding: '0 8px', borderRadius: 7, border: `1px solid rgba(${WC.inkRGB},0.12)`,
cursor: 'pointer', fontSize: 11, fontWeight: 600, background: WC.raised, color: WC.ink3, whiteSpace: 'nowrap',
};
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 labelStyle: CSSProperties = { fontSize: 11.5, color: WC.muted, marginBottom: 7, display: 'block' };
const dotColor = (kind: WorkflowEventLogEntry['kind']): string => {
if (kind === 'fired') return WC.accent;
@@ -74,122 +79,34 @@ const EventTriggersCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
return (
<div style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.lg, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink }}>Event triggers</span>
<div style={{ display: 'flex', gap: 6 }}>
<button style={ghostBtn} onClick={() => patchTriggers([...triggers, newTrigger('file')])}>+ Folder</button>
<button style={ghostBtn} onClick={() => patchTriggers([...triggers, newTrigger('web')])}>+ Web page</button>
<div style={{ marginBottom: 12 }}>
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink, display: 'block', marginBottom: 8 }}>Event triggers</span>
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
{ADD_CHOICES.map(([kind, label]) => (
<button key={kind} style={ghostBtn} onClick={() => patchTriggers([...triggers, newTrigger(kind)])}>{label}</button>
))}
</div>
</div>
{triggers.length === 0 && (
<span style={{ fontSize: 12.5, color: WC.ink4, lineHeight: 1.5, display: 'block' }}>
Run this workflow when something happens: a file lands in a folder, or a page you care about changes.
Run this workflow when something happens: a file lands, a page changes, an agent spots any condition you describe, or anything pushes an event in.
</span>
)}
{triggers.map((t) => {
const src = t.source;
return (
<div key={t.id} style={{ border: `1px solid ${WC.line}`, borderRadius: 10, padding: '10px 11px', marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 9 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: WC.ink3 }}>
{src.kind === 'file' ? 'Folder / file watch' : 'Web page watch'}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
onClick={() => updateTrigger(t.id, (x) => ({ ...x, enabled: !x.enabled }))}
style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
>
<span style={{ fontSize: 11.5, fontWeight: 600, color: t.enabled ? WC.accent : WC.muted }}>{t.enabled ? 'On' : 'Off'}</span>
<div style={track(t.enabled, WC)}><div style={knob(t.enabled)} /></div>
</div>
<button
aria-label="Remove trigger"
onClick={() => patchTriggers(triggers.filter((x) => x.id !== t.id))}
style={{ ...ghostBtn, padding: '0 8px' }}
>
</button>
</div>
</div>
{src.kind === 'file' ? (
<div style={{ marginBottom: 8 }}>
<span style={labelStyle}>Watch this folder or file</span>
<input
style={fieldStyle}
defaultValue={src.path}
placeholder="~/Downloads"
onBlur={(e) => {
const path = e.target.value.trim();
if (path !== src.path) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, path } }));
}}
/>
</div>
) : (
<>
<div style={{ marginBottom: 8 }}>
<span style={labelStyle}>Page URL</span>
<input
style={fieldStyle}
defaultValue={src.url}
placeholder="https://example.com/reservations"
onBlur={(e) => {
const url = e.target.value.trim();
if (url !== src.url) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, url } }));
}}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<span style={labelStyle}>Watching for</span>
<input
style={fieldStyle}
defaultValue={src.watch_for}
placeholder="a reservation slot opening"
onBlur={(e) => {
const watchFor = e.target.value.trim();
if (watchFor !== src.watch_for) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, watch_for: watchFor } }));
}}
/>
</div>
<div style={{ width: 118, flex: 'none' }}>
<span style={labelStyle}>Check</span>
<select
value={src.poll_seconds}
onChange={(e) => {
const pollSeconds = parseInt(e.target.value, 10);
updateTrigger(t.id, (x) => ({ ...x, source: { ...src, poll_seconds: pollSeconds } }));
}}
style={{ ...fieldStyle, cursor: 'pointer', padding: '0 6px' }}
>
{WEB_POLL_CHOICES.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
</select>
</div>
</div>
</>
)}
<div>
<span style={labelStyle}>Only when (optional, plain English)</span>
<input
style={fieldStyle}
defaultValue={t.predicate}
placeholder={src.kind === 'file' ? 'a new CSV export shows up' : 'the change mentions Friday or Saturday'}
onBlur={(e) => {
const predicate = e.target.value.trim();
if (predicate !== t.predicate) updateTrigger(t.id, (x) => ({ ...x, predicate }));
}}
/>
</div>
</div>
);
})}
{triggers.map((t) => (
<EventTriggerRow
key={t.id}
workflow={workflow}
trigger={t}
onMutate={(mut) => patchTriggers(triggers.map((x) => (x.id === t.id ? mut(x) : x)))}
onRemove={() => patchTriggers(triggers.filter((x) => x.id !== t.id))}
/>
))}
{triggers.length > 0 && log.length > 0 && (
<div style={{ marginTop: 4, paddingTop: 11, borderTop: `1px solid ${WC.line}` }}>
<span style={{ ...labelStyle, marginBottom: 7 }}>Recent activity</span>
<span style={labelStyle}>Recent activity</span>
{log.slice(0, 6).map((e, i) => (
<div key={`${e.ts}-${i}`} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 6 }}>
<div style={{ width: 14, display: 'flex', justifyContent: 'center', flex: 'none', paddingTop: 5 }}>
+15 -1
View File
@@ -42,7 +42,21 @@ export interface WebWatchSource {
poll_seconds: number;
}
export type EventSourceConfig = FileWatchSource | WebWatchSource;
export interface AgentCheckSource {
kind: 'agent';
/** Any natural-language condition; a real agent verifies it each poll with its full tool surface. */
check: string;
/** Empty = the app's default model. */
model: string;
poll_seconds: number;
}
export interface CustomEventSource {
/** Push-only: events arrive via POST /api/events/ingest from any script/webhook/Shortcut. */
kind: 'custom';
}
export type EventSourceConfig = FileWatchSource | WebWatchSource | AgentCheckSource | CustomEventSource;
export interface EventTriggerConfig {
id: string;