mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] events: universal sources (agent-check any condition, custom push via /api/events/ingest)
This commit is contained in:
@@ -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
|
||||
@@ -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}
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user