[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
+136
View File
@@ -0,0 +1,136 @@
"""The universal poll adapter: a real agent session verifies ANY
natural-language condition on an interval, with the same tools, MCP gate, and
admission bounds as a normal chat turn. Contract: the agent ends its reply
with EVENT:/NO_EVENT + STATE: lines; state round-trips through the cursor so
"since the last check" means something. Check sessions are plumbing, not chat
history: they're deleted after each check so they can't pollute history or
the pattern miner."""
import asyncio
import hashlib
import time
from typing import Dict, List, Optional, Tuple
from typeguard import typechecked
from backend.apps.events.models import AgentCheckSource, Event
CHECK_TIMEOUT_S = 240.0
@typechecked
def build_check_prompt(check: str, state: str) -> str:
return (
"You are an unattended event checker. Determine whether this event has occurred "
f"since the last check: {check.strip()}\n\n"
f"Previous check state: {state or 'none; this is the baseline check'}.\n\n"
"Use your tools as needed, then END your reply in EXACTLY this format (as the final lines):\n"
"EVENT: <one factual line describing what happened>\n"
"STATE: <one line capturing what you observed, to compare against next time>\n"
"If nothing new happened, instead end with:\n"
"NO_EVENT\n"
"STATE: <one line>\n"
"On the baseline check (no previous state) always reply NO_EVENT and just record STATE."
)
@typechecked
def parse_check_reply(text: str) -> Tuple[Optional[str], str]:
"""(event line or None, state). Last occurrence wins so earlier prose echoing
the format can't fake a verdict. Raises when the contract is missing entirely."""
event_line: Optional[str] = None
state = ""
saw_verdict = False
for line in text.splitlines():
s = line.strip()
if s.upper().startswith("EVENT:"):
event_line = s[len("EVENT:"):].strip()
saw_verdict = True
elif s.upper() == "NO_EVENT":
event_line = None
saw_verdict = True
elif s.upper().startswith("STATE:"):
state = s[len("STATE:"):].strip()
if not saw_verdict:
raise ValueError("check agent returned no EVENT/NO_EVENT verdict")
return event_line, state
async def p_await_reply(session_id: str) -> str:
from backend.apps.agents.agent_manager import agent_manager
deadline = time.monotonic() + CHECK_TIMEOUT_S
while time.monotonic() < deadline:
sess = agent_manager.sessions.get(session_id)
if sess is None or getattr(sess, "status", None) in ("completed", "error", "stopped"):
break
await asyncio.sleep(0.5)
sess = agent_manager.sessions.get(session_id)
if sess is None:
raise RuntimeError("check session vanished")
status = getattr(sess, "status", None)
if status == "running":
try:
await agent_manager.stop_agent(session_id)
except Exception:
pass
raise RuntimeError(f"check agent timed out after {int(CHECK_TIMEOUT_S)}s")
if status != "completed":
raise RuntimeError(f"check agent ended with status {status}")
for m in reversed(getattr(sess, "messages", []) or []):
if getattr(m, "role", None) == "assistant" and isinstance(getattr(m, "content", None), str) and m.content.strip():
return m.content
raise RuntimeError("check agent produced no reply")
async def run_check_turn(model: str, prompt: str) -> str:
"""One ephemeral agent turn; the session file is deleted afterward."""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.core.models import AgentConfig
from backend.apps.agents.manager.session.session_store import delete_session_file
session = await agent_manager.launch_agent(AgentConfig(name="Event check", model=model, mode="agent"))
try:
await agent_manager.send_message(session.id, prompt)
return await p_await_reply(session.id)
finally:
try:
await agent_manager.close_session(session.id)
except Exception:
pass
try:
delete_session_file(session.id)
except Exception:
pass
@typechecked
async def agent_check(source: AgentCheckSource, cursor: Dict) -> Tuple[List[Event], Dict]:
check = source.check.strip()
if not check:
return [], cursor
from backend.apps.settings.settings import load_settings
baselined = bool(cursor.get("baselined")) and cursor.get("check") == check
prev_state = str(cursor.get("state") or "") if baselined else ""
model = source.model.strip() or (getattr(load_settings(), "default_model", None) or "sonnet")
reply = await run_check_turn(model, build_check_prompt(check, prev_state))
event_line, state = parse_check_reply(reply)
new_cursor: Dict = {"baselined": True, "check": check, "state": state or prev_state}
if cursor.get("last_event_digest"):
new_cursor["last_event_digest"] = cursor["last_event_digest"]
# Baseline never fires, even if the model ignores the instruction.
if event_line is None or not event_line.strip() or not baselined:
return [], new_cursor
digest = hashlib.sha256(event_line.strip().encode()).hexdigest()[:16]
if cursor.get("last_event_digest") == digest:
# The agent re-reported the identical event (state parroting); once is enough.
return [], new_cursor
new_cursor["last_event_digest"] = digest
return [Event(
source="agent",
event_type="check_event",
summary=event_line.strip()[:300],
dedup_key=digest,
payload={"check": check, "state": state},
)], new_cursor
+72
View File
@@ -0,0 +1,72 @@
"""Routes for the event engine. POST /api/events/ingest is the universal push
entry: any script, webhook forwarder, macOS Shortcut, or MCP can feed a
workflow's custom trigger. Ingested events ride the SAME dispatcher path as
polled ones, so coalescing, the predicate, and the rate cap all still apply.
The engine's lifecycle itself rides workflows_lifespan; this SubApp is
routes-only. Auth: the per-install bearer token gates this like every other
localhost API route."""
from contextlib import asynccontextmanager
from typing import Dict
from uuid import uuid4
from fastapi import HTTPException
from pydantic import BaseModel, Field
from backend.apps.events import dispatcher, stores
from backend.apps.events.models import Event
from backend.config.Apps import SubApp
@asynccontextmanager
async def events_lifespan():
yield
events = SubApp("events", events_lifespan)
MAX_SEEN_KEYS = 300
class IngestBody(BaseModel):
workflow_id: str
trigger_id: str
summary: str
event_type: str = "custom"
# Same key twice = delivered once; lets webhook retries stay idempotent.
dedup_key: str = ""
payload: Dict = Field(default_factory=dict)
@events.router.post("/ingest")
async def ingest_event(body: IngestBody):
from backend.apps.workflows import storage
wf = storage.get_workflow(body.workflow_id)
if wf is None or wf.deleted_at is not None:
raise HTTPException(status_code=404, detail="Workflow not found")
trigger = next((t for t in wf.event_triggers if t.id == body.trigger_id), None)
if trigger is None:
raise HTTPException(status_code=404, detail="Trigger not found")
if trigger.source.kind != "custom":
raise HTTPException(status_code=409, detail="Trigger is not a custom (ingest) source")
if not trigger.enabled:
raise HTTPException(status_code=409, detail="Trigger is disabled")
summary = body.summary.strip()[:300]
if not summary:
raise HTTPException(status_code=400, detail="summary is required")
dedup_key = body.dedup_key.strip() or uuid4().hex
cursor = stores.load_cursor(trigger.id)
seen = [str(k) for k in (cursor.get("seen") or [])]
if dedup_key in seen:
return {"ok": True, "queued": 0, "deduped": True}
seen.append(dedup_key)
stores.save_cursor(trigger.id, {"seen": seen[-MAX_SEEN_KEYS:]})
await dispatcher.ingest(wf.id, trigger, [Event(
source="custom",
event_type=(body.event_type.strip() or "custom")[:60],
summary=summary,
dedup_key=dedup_key,
payload=body.payload,
)])
return {"ok": True, "queued": 1, "deduped": False}
+26 -1
View File
@@ -52,8 +52,33 @@ class WebWatchSource(BaseModel):
return max(60, min(v, 86400))
class AgentCheckSource(BaseModel):
"""The universal poll source: a real agent verifies any natural-language
condition on an interval, so anything an agent can observe (with tools,
MCPs, the web, the filesystem) becomes a trigger."""
kind: Literal["agent"] = "agent"
# What event to look for, in the user's words ("a new episode of X dropped").
check: str = ""
# Empty = the app's default model; each poll is a real (short) agent turn.
model: str = ""
poll_seconds: int = 900
@field_validator("poll_seconds")
@classmethod
def p_clamp_poll(cls, v: int) -> int:
# Each poll costs a real agent turn; 60s floor keeps a typo from burning money.
return max(60, min(v, 86400))
class CustomEventSource(BaseModel):
"""The universal push source: never polled; events arrive only via
POST /api/events/ingest, so any script, webhook forwarder, Shortcut, or
MCP can feed this trigger."""
kind: Literal["custom"] = "custom"
EventSourceConfig = Annotated[
Union[FileWatchSource, WebWatchSource],
Union[FileWatchSource, WebWatchSource, AgentCheckSource, CustomEventSource],
Field(discriminator="kind"),
]
+16 -10
View File
@@ -10,16 +10,19 @@ import time
from typing import Awaitable, Callable, Dict, List, Optional, Set, Tuple
from backend.apps.events import dispatcher, stores
from backend.apps.events.file_watch import file_watch
from backend.apps.events.models import Event, EventLogEntry, EventTriggerConfig
from backend.apps.events.web_watch import web_watch
from backend.apps.events.adapters.agent_check import agent_check
from backend.apps.events.adapters.file_watch import file_watch
from backend.apps.events.adapters.web_watch import web_watch
from backend.apps.events.models import CustomEventSource, Event, EventLogEntry, EventTriggerConfig
from backend.apps.workflows.models import Workflow
logger = logging.getLogger(__name__)
# "custom" is deliberately absent: those triggers are push-only via /api/events/ingest.
ADAPTERS: Dict[str, Callable[..., Awaitable[Tuple[List[Event], Dict]]]] = {
"file": file_watch,
"web": web_watch,
"agent": agent_check,
}
p_loop_task: Optional["asyncio.Task"] = None
@@ -45,14 +48,17 @@ def reset_state() -> None:
p_inflight.clear()
def p_live_triggers() -> List[Tuple[Workflow, EventTriggerConfig]]:
def p_live_triggers() -> List[Tuple[Workflow, EventTriggerConfig, float]]:
"""(workflow, trigger, poll_seconds) for every pollable trigger; push-only sources are excluded here."""
from backend.apps.workflows import storage
out: List[Tuple[Workflow, EventTriggerConfig]] = []
out: List[Tuple[Workflow, EventTriggerConfig, float]] = []
for wf in storage.list_workflows():
for trig in wf.event_triggers:
if trig.enabled and trig.source.kind in ADAPTERS:
out.append((wf, trig))
source = trig.source
if not trig.enabled or isinstance(source, CustomEventSource) or source.kind not in ADAPTERS:
continue
out.append((wf, trig, float(source.poll_seconds)))
return out
@@ -84,9 +90,9 @@ def tick() -> None:
if storage.get_paused():
return
now = time.monotonic()
for wf, trig in p_live_triggers():
for wf, trig, poll_seconds in p_live_triggers():
if p_next_poll.get(trig.id, 0.0) <= now and trig.id not in p_inflight:
p_next_poll[trig.id] = now + float(trig.source.poll_seconds)
p_next_poll[trig.id] = now + poll_seconds
p_inflight.add(trig.id)
asyncio.create_task(p_poll_one(wf.id, trig))
@@ -94,7 +100,7 @@ def tick() -> None:
def p_seconds_until_next() -> float:
now = time.monotonic()
soonest: Optional[float] = None
for _, trig in p_live_triggers():
for _, trig, _ in p_live_triggers():
nxt = p_next_poll.get(trig.id, now)
if soonest is None or nxt < soonest:
soonest = nxt
+2 -1
View File
@@ -47,11 +47,12 @@ from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy
from backend.apps.agents.core.openai_passthrough import openai_passthrough
from backend.apps.workflows.workflows import workflows
from backend.apps.patterns.patterns import patterns
from backend.apps.events.events import events
from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, anthropic_proxy, workflows, patterns, openai_passthrough])
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, anthropic_proxy, workflows, patterns, events, openai_passthrough])
app = main_app.app
# Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py.
+3 -3
View File
@@ -50,7 +50,7 @@ def test_trigger_config_clamps_and_round_trips(make_wf):
def test_file_watch_baselines_then_diffs(tmp_path):
from backend.apps.events.file_watch import file_watch
from backend.apps.events.adapters.file_watch import file_watch
watch_dir = tmp_path / "watched"
watch_dir.mkdir()
@@ -73,7 +73,7 @@ def test_file_watch_baselines_then_diffs(tmp_path):
def test_file_watch_caps_burst(tmp_path):
from backend.apps.events import file_watch as fw
from backend.apps.events.adapters import file_watch as fw
watch_dir = tmp_path / "burst"
watch_dir.mkdir()
@@ -99,7 +99,7 @@ def p_fake_pages(monkeypatch, pages: list[str]):
def test_web_watch_baseline_dedup_change_error(monkeypatch):
from backend.apps.events.web_watch import web_watch
from backend.apps.events.adapters.web_watch import web_watch
source = WebWatchSource(url="https://example.com/reserve", watch_for="a reservation opening")
p_fake_pages(monkeypatch, [
+160
View File
@@ -0,0 +1,160 @@
"""The universal event sources: the agent-check adapter's contract (verdict
parsing, baseline suppression, identical-event dedup) and the /api/events
ingest route (validation, idempotent dedup keys, dispatcher hand-off), plus
the guarantee that custom triggers are never polled.
Run:
cd backend && .venv/bin/python -m pytest tests/test_event_universal.py -v
"""
from __future__ import annotations
import asyncio
import pytest
from fastapi import HTTPException
from backend.apps.events.models import AgentCheckSource, CustomEventSource, Event, EventTriggerConfig
def p_run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
@pytest.fixture(autouse=True)
def p_events_env(isolated_workflows_data, reset_scheduler_state, monkeypatch, tmp_path):
from backend.apps.events import dispatcher, poll_loop, stores
monkeypatch.setattr(stores, "EVENTS_DIR", str(tmp_path / "events"))
monkeypatch.setattr(stores, "CURSORS_DIR", str(tmp_path / "events" / "cursors"))
monkeypatch.setattr(stores, "PENDING_DIR", str(tmp_path / "events" / "pending"))
monkeypatch.setattr(stores, "LOGS_DIR", str(tmp_path / "events" / "logs"))
dispatcher.stop()
poll_loop.reset_state()
yield
dispatcher.stop()
poll_loop.reset_state()
def test_parse_check_reply_contract():
from backend.apps.events.adapters.agent_check import parse_check_reply
event, state = parse_check_reply("I looked around.\nEVENT: A new episode dropped\nSTATE: latest is ep 42")
assert event == "A new episode dropped"
assert state == "latest is ep 42"
event, state = parse_check_reply("Nothing changed.\nNO_EVENT\nSTATE: still ep 41")
assert event is None
assert state == "still ep 41"
# Prose that echoes the format earlier can't fake a verdict; the LAST verdict wins.
event, state = parse_check_reply("The format is EVENT: like this.\nNO_EVENT\nSTATE: s")
assert event is None
with pytest.raises(ValueError):
parse_check_reply("I could not complete the check.")
def test_agent_check_baseline_then_event_then_dedup(monkeypatch):
from backend.apps.events.adapters import agent_check as ac
replies = iter([
"EVENT: ignores the baseline rule\nSTATE: seen ep 41", # model misbehaves on baseline
"EVENT: Episode 42 is out\nSTATE: seen ep 42",
"EVENT: Episode 42 is out\nSTATE: seen ep 42", # same event re-reported
])
prompts: list[str] = []
async def p_fake_turn(model, prompt):
prompts.append(prompt)
return next(replies)
monkeypatch.setattr(ac, "run_check_turn", p_fake_turn)
source = AgentCheckSource(check="a new episode of the show dropped", poll_seconds=60)
events, cursor = p_run(ac.agent_check(source, {}))
assert events == [] # baseline never fires, even when the model reports EVENT
events, cursor = p_run(ac.agent_check(source, cursor))
assert len(events) == 1
assert events[0].summary == "Episode 42 is out"
assert "seen ep 41" in prompts[1] # previous state round-tripped into the prompt
events, cursor = p_run(ac.agent_check(source, cursor))
assert events == [] # identical event line reported again fires once, not forever
def p_custom_wf(make_wf):
from backend.apps.workflows import storage
trig = EventTriggerConfig(source=CustomEventSource(), coalesce_seconds=0)
wf = make_wf(event_triggers=[trig])
storage.save_workflow(wf)
return wf, trig
def test_ingest_validates_and_dispatches(make_wf, monkeypatch):
from backend.apps.events import dispatcher
from backend.apps.events.events import IngestBody, ingest_event
wf, trig = p_custom_wf(make_wf)
delivered: list[Event] = []
async def p_fake_ingest(workflow_id, trigger, events, persist=True):
delivered.extend(events)
monkeypatch.setattr(dispatcher, "ingest", p_fake_ingest)
body = IngestBody(workflow_id=wf.id, trigger_id=trig.id, summary="Order #123 landed", dedup_key="order-123")
res = p_run(ingest_event(body))
assert res == {"ok": True, "queued": 1, "deduped": False}
assert len(delivered) == 1
assert delivered[0].source == "custom"
# Same dedup_key again = idempotent, not a second run.
res = p_run(ingest_event(body))
assert res["deduped"] is True
assert len(delivered) == 1
with pytest.raises(HTTPException) as e:
p_run(ingest_event(IngestBody(workflow_id="nope", trigger_id=trig.id, summary="x")))
assert e.value.status_code == 404
with pytest.raises(HTTPException) as e:
p_run(ingest_event(IngestBody(workflow_id=wf.id, trigger_id="nope", summary="x")))
assert e.value.status_code == 404
with pytest.raises(HTTPException) as e:
p_run(ingest_event(IngestBody(workflow_id=wf.id, trigger_id=trig.id, summary=" ")))
assert e.value.status_code == 400
def test_ingest_refuses_non_custom_and_disabled(make_wf):
from backend.apps.events.events import IngestBody, ingest_event
from backend.apps.events.models import FileWatchSource
from backend.apps.workflows import storage
file_trig = EventTriggerConfig(source=FileWatchSource(path="/tmp/x"))
off_trig = EventTriggerConfig(source=CustomEventSource(), enabled=False)
wf = make_wf(event_triggers=[file_trig, off_trig])
storage.save_workflow(wf)
with pytest.raises(HTTPException) as e:
p_run(ingest_event(IngestBody(workflow_id=wf.id, trigger_id=file_trig.id, summary="x")))
assert e.value.status_code == 409
with pytest.raises(HTTPException) as e:
p_run(ingest_event(IngestBody(workflow_id=wf.id, trigger_id=off_trig.id, summary="x")))
assert e.value.status_code == 409
def test_custom_triggers_are_never_polled(make_wf):
from backend.apps.events import poll_loop, stores
wf, trig = p_custom_wf(make_wf)
async def scenario():
poll_loop.tick()
await asyncio.sleep(0.1)
p_run(scenario())
# No poll ran: no cursor written (beyond none), no log entries, no errors.
assert stores.read_log(wf.id) == []
assert stores.load_cursor(trig.id) == {}
@@ -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;