[eric] events: zero-knob pass (adaptive cadence, MCP inference, self-heal, no test nag, one-URL push)

This commit is contained in:
ciregenz
2026-07-28 17:52:13 -07:00
parent 8281bdebfc
commit a7e5a4dc73
13 changed files with 371 additions and 82 deletions
+53 -8
View File
@@ -314,8 +314,8 @@ TOOLS = [
"contains": {"type": "string", "description": "kind=stream: only messages containing this substring become events."},
"watch_for": {"type": "string", "description": "kind=web: what change matters, in the user's words."},
"check": {"type": "string", "description": "kind=agent: the condition to check, one plain sentence."},
"mcps": {"type": "array", "items": {"type": "string"}, "description": "kind=agent: connected tool names the check may use (e.g. 'google-workspace'). Only what the user's check actually needs."},
"poll_minutes": {"type": "number", "description": "How often to check. Defaults: file continuous (~15s), web 5, agent 15. Agent checks cost a model call each, so don't go below 5 without the user asking."},
"mcps": {"type": "array", "items": {"type": "string"}, "description": "kind=agent: usually OMIT; the system infers connected tools from the check sentence. Pass only to override the inference."},
"poll_minutes": {"type": "number", "description": "Usually OMIT: cadence is automatic (tunes itself from observed event rate). Set only when the user asked for a specific frequency; agent checks cost a model call each."},
"only_when": {"type": "string", "description": "Optional plain-English filter; events not matching it are skipped (logged)."},
"max_fires_per_hour": {"type": "integer", "description": "Safety cap on runs per hour (default 6)."},
},
@@ -668,7 +668,46 @@ def handle_invoke_workflow(args: dict) -> dict:
return _ok(f"Workflow '{match.get('title')}' run {status}.{err_line}\n\n=== RUN TRANSCRIPT ===\n{transcript}\n=== END TRANSCRIPT ===")
TRIGGER_POLL_DEFAULTS = {"file": 15, "web": 300, "agent": 900}
MCP_HINTS = {
"google-workspace": ("email", "inbox", "gmail", "mail", "calendar", "meeting", "drive", "doc", "sheet"),
"notion": ("notion", "page", "database"),
"slack": ("slack", "channel"),
"discord": ("discord",),
"reddit": ("reddit", "subreddit"),
"github": ("github", "pull request", "issue", "repo"),
}
def p_suggest_mcps(check: str, known: set) -> list:
"""Infer connected tools from the check sentence so the user never names them; only suggests tools that actually exist."""
text = check.lower()
out = []
for tool, words in MCP_HINTS.items():
if tool in known and any(w in text for w in words):
out.append(tool)
for tool in known:
if tool not in out and tool in text:
out.append(tool)
return out[:4]
def p_known_tools() -> set:
r = _call("GET", f"http://127.0.0.1:{BACKEND_PORT}/api/tools/list")
if "_error" in r:
return set()
tools = r.get("tools", r) if isinstance(r, dict) else r
known = set()
for t in (tools if isinstance(tools, list) else []):
for key in ("id", "name"):
v = str((t or {}).get(key) or "").strip().lower()
if v:
known.add(v)
return known
def p_steps_signature(steps: list) -> str:
# MUST byte-match the FE stepsSignature (JSON.stringify of [id, text] pairs); pinned by test_watch_for_event_tool.
return json.dumps([[s["id"], s["text"]] for s in steps], separators=(",", ":"), ensure_ascii=False)
def p_find_workflow_any(ident: str):
@@ -712,7 +751,8 @@ def p_build_trigger(args: dict) -> tuple:
if kind not in ("file", "web", "agent", "custom", "stream"):
return None, "kind must be one of: file, web, agent, custom, stream."
poll_minutes = args.get("poll_minutes")
poll_seconds = int(float(poll_minutes) * 60) if poll_minutes else TRIGGER_POLL_DEFAULTS.get(kind, 300)
# 0 = adaptive: the engine tunes cadence from observed event rate; only an explicit poll_minutes pins it.
poll_seconds = int(float(poll_minutes) * 60) if poll_minutes else 0
if kind == "file":
if not (args.get("path") or "").strip():
return None, "kind=file needs path (the file or folder to watch)."
@@ -725,6 +765,8 @@ def p_build_trigger(args: dict) -> tuple:
if not (args.get("check") or "").strip():
return None, "kind=agent needs check (one sentence describing the condition)."
mcps = [str(m) for m in (args.get("mcps") or [])]
if not mcps:
mcps = p_suggest_mcps(args["check"], p_known_tools())
mcp_err = p_validate_mcps(mcps)
if mcp_err:
return None, mcp_err
@@ -734,7 +776,7 @@ def p_build_trigger(args: dict) -> tuple:
return None, "kind=stream needs url (the SSE feed to subscribe to)."
source = {"kind": "stream", "url": args["url"].strip(), "contains": (args.get("contains") or "").strip()}
else:
source = {"kind": "custom"}
source = {"kind": "custom", "secret": uuid.uuid4().hex}
return {
"id": uuid.uuid4().hex,
"enabled": True,
@@ -781,13 +823,15 @@ def handle_watch_for_event(args: dict) -> dict:
steps_in = [s for s in (args.get("steps") or []) if str(s).strip()]
if not steps_in:
return _err("To create a new workflow, pass title and steps (what to do when the event fires), or pass workflow to attach to an existing one.")
steps_payload = [{"id": f"s{i+1}", "text": str(s)} for i, s in enumerate(steps_in)]
body = {
"title": args.get("title") or "Event workflow",
"steps": [{"id": f"s{i+1}", "text": str(s)} for i, s in enumerate(steps_in)],
"steps": steps_payload,
"schedule": {"enabled": False},
"event_triggers": [trigger],
"source_session_id": PARENT_SESSION_ID or None,
"dashboard_id": DASHBOARD_ID or None,
"tested_signature": p_steps_signature(steps_payload),
}
r = _call("POST", "/create", body)
if "_error" in r:
@@ -796,8 +840,9 @@ def handle_watch_for_event(args: dict) -> dict:
extra = ""
if trigger["source"]["kind"] == "custom":
extra = (
f"\nOutside systems push events with: POST http://127.0.0.1:{BACKEND_PORT}/api/events/ingest "
f"(JSON: workflow_id={wid}, trigger_id={trigger['id']}, summary, optional dedup_key; per-install auth token required)."
f"\nOutside systems push events with ONE URL, no token needed: "
f"POST http://127.0.0.1:{BACKEND_PORT}/api/events/ingest/{trigger['source']['secret']} "
f"(JSON body: summary, optional event_type/dedup_key/payload)."
)
return _ok(f"Watching. Workflow \"{title}\" (id: {wid}) now runs on {p_describe_trigger(trigger)}.{extra} The user can edit or disable it in the workflow's Event triggers panel.")
@@ -0,0 +1,84 @@
"""Self-heal before bothering the user: when a URL-bearing watcher (web/stream)
starts failing, one invisible background agent turn investigates and either
fixes the config itself (a moved/redirected URL) or declares it needs a human,
at which point the attention surface takes over. Strict FIX_URL / CANNOT_FIX
contract; only an http(s) URL that actually differs is ever applied, and the
repair is written to the activity log so nothing changes silently."""
import logging
from typing import Optional, Tuple
from typeguard import typechecked
from backend.apps.events.models import EventLogEntry, EventTriggerConfig
logger = logging.getLogger(__name__)
@typechecked
def build_heal_prompt(url: str, last_error: str) -> str:
return (
"You are repairing an automated watcher. It repeatedly fails to read this URL:\n"
f"{url}\n"
f"Most recent error: {last_error or 'unknown'}\n\n"
"Investigate with your tools (fetch the URL, follow redirects, check for an obvious "
"moved/renamed location on the same site). Then END your reply with EXACTLY one of:\n"
"FIX_URL: <a working replacement URL>\n"
"CANNOT_FIX: <one line saying what a human needs to do>"
)
@typechecked
def parse_heal_reply(text: str) -> Tuple[Optional[str], str]:
"""(replacement url or None, reason). Last occurrence wins."""
fix: Optional[str] = None
reason = ""
for line in text.splitlines():
s = line.strip()
if s.upper().startswith("FIX_URL:"):
fix = s[len("FIX_URL:"):].strip()
reason = ""
elif s.upper().startswith("CANNOT_FIX:"):
fix = None
reason = s[len("CANNOT_FIX:"):].strip()
return fix, reason
async def attempt_heal(workflow_id: str, trigger: EventTriggerConfig) -> bool:
"""True when the trigger config was repaired (caller should re-poll now)."""
from backend.apps.events import stores
from backend.apps.events.adapters.agent_check import run_check_turn
from backend.apps.settings.settings import load_settings
from backend.apps.workflows import storage
url = str(getattr(trigger.source, "url", "") or "").strip()
if not url:
return False
health = stores.read_poll_health(trigger.id)
model = getattr(load_settings(), "default_model", None) or "sonnet"
try:
reply = await run_check_turn(model, build_heal_prompt(url, str(health.get("last_error") or "")))
fix, reason = parse_heal_reply(reply)
except Exception as e:
logger.warning("heal turn failed for trigger %s: %s", trigger.id, e)
return False
if fix and fix.startswith("http") and fix != url:
wf = storage.get_workflow(workflow_id)
if wf is None:
return False
live = next((t for t in wf.event_triggers if t.id == trigger.id), None)
if live is None or str(getattr(live.source, "url", "")) != url:
return False # user edited it meanwhile; their change wins
setattr(live.source, "url", fix)
storage.save_workflow(wf)
stores.clear_poll_failures(trigger.id)
stores.append_log(workflow_id, EventLogEntry(
trigger_id=trigger.id, kind="emitted",
summary=f"Self-healed: watcher URL updated to {fix}",
))
return True
stores.append_log(workflow_id, EventLogEntry(
trigger_id=trigger.id, kind="error",
summary=f"Self-heal couldn't fix it: {reason[:160] or 'no working replacement found'}",
))
return False
+42 -10
View File
@@ -38,16 +38,14 @@ class IngestBody(BaseModel):
payload: Dict = Field(default_factory=dict)
@events.router.post("/ingest")
async def ingest_event(body: IngestBody):
from backend.apps.workflows import storage
class IngestPushBody(BaseModel):
summary: str
event_type: str = "custom"
dedup_key: str = ""
payload: Dict = Field(default_factory=dict)
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")
async def p_do_ingest(workflow_id: str, trigger, body: IngestPushBody) -> Dict:
if trigger.source.kind != "custom":
raise HTTPException(status_code=409, detail="Trigger is not a custom (ingest) source")
if not trigger.enabled:
@@ -62,7 +60,7 @@ async def ingest_event(body: IngestBody):
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(
await dispatcher.ingest(workflow_id, trigger, [Event(
source="custom",
event_type=(body.event_type.strip() or "custom")[:60],
summary=summary,
@@ -70,3 +68,37 @@ async def ingest_event(body: IngestBody):
payload=body.payload,
)])
return {"ok": True, "queued": 1, "deduped": False}
@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")
return await p_do_ingest(wf.id, trigger, IngestPushBody(
summary=body.summary, event_type=body.event_type, dedup_key=body.dedup_key, payload=body.payload,
))
@events.router.post("/ingest/{secret}")
async def ingest_event_by_secret(secret: str, body: IngestPushBody):
"""Paste-one-URL push: the per-trigger secret in the path IS the credential
(auth-middleware exempt; same entropy class as the install token, localhost-bound,
revoked by deleting the trigger)."""
import hmac
from backend.apps.workflows import storage
if len(secret.strip()) < 16:
raise HTTPException(status_code=404, detail="Unknown ingest URL")
for wf in storage.list_workflows():
for trigger in wf.event_triggers:
trigger_secret = str(getattr(trigger.source, "secret", "") or "")
if trigger_secret and hmac.compare_digest(trigger_secret, secret):
return await p_do_ingest(wf.id, trigger, body)
raise HTTPException(status_code=404, detail="Unknown ingest URL")
+8 -6
View File
@@ -34,8 +34,8 @@ class FileWatchSource(BaseModel):
@field_validator("poll_seconds")
@classmethod
def p_clamp_poll(cls, v: int) -> int:
# Clamp, don't reject: a stray value from an agent tool or old record shouldn't crash the poll loop.
return max(5, min(v, 3600))
# 0 = adaptive (the engine tunes cadence from observed event rate). Clamp, don't reject.
return 0 if v == 0 else max(5, min(v, 3600))
class WebWatchSource(BaseModel):
@@ -48,8 +48,8 @@ class WebWatchSource(BaseModel):
@field_validator("poll_seconds")
@classmethod
def p_clamp_poll(cls, v: int) -> int:
# 60s floor: polling someone's site faster than that is rude and burns nothing useful.
return max(60, min(v, 86400))
# 0 = adaptive. 60s floor otherwise: polling someone's site faster is rude and buys nothing.
return 0 if v == 0 else max(60, min(v, 86400))
class AgentCheckSource(BaseModel):
@@ -73,8 +73,8 @@ class AgentCheckSource(BaseModel):
@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))
# 0 = adaptive. Each poll costs a real agent turn; 60s floor keeps a typo from burning money.
return 0 if v == 0 else max(60, min(v, 86400))
class CustomEventSource(BaseModel):
@@ -82,6 +82,8 @@ class CustomEventSource(BaseModel):
POST /api/events/ingest, so any script, webhook forwarder, Shortcut, or
MCP can feed this trigger."""
kind: Literal["custom"] = "custom"
# Per-trigger credential baked into the push URL (POST /api/events/ingest/<secret>), so wiring a sender is paste-one-URL instead of token juggling. Same entropy class as the install token; revoked by deleting the trigger.
secret: str = Field(default_factory=lambda: uuid4().hex)
class StreamSource(BaseModel):
+51 -2
View File
@@ -32,6 +32,39 @@ p_loop_task: Optional["asyncio.Task"] = None
p_wake = asyncio.Event()
p_next_poll: Dict[str, float] = {}
p_inflight: Set[str] = set()
# Adaptive cadence (poll_seconds=0 triggers): (default, floor, ceiling) per kind. Events halve the interval toward the floor; 5 straight quiet polls stretch it 1.5x toward the ceiling.
PACE_BOUNDS: Dict[str, Tuple[float, float, float]] = {
"file": (15.0, 5.0, 60.0),
"web": (300.0, 60.0, 1800.0),
"agent": (900.0, 300.0, 21600.0),
}
PACE_QUIET_POLLS = 5
p_pace_interval: Dict[str, float] = {}
p_pace_quiet: Dict[str, int] = {}
def effective_poll_seconds(trigger: EventTriggerConfig) -> float:
fixed = float(getattr(trigger.source, "poll_seconds", 0) or 0)
if fixed > 0:
return fixed
default, lo, hi = PACE_BOUNDS.get(trigger.source.kind, (300.0, 60.0, 3600.0))
return min(max(p_pace_interval.get(trigger.id, default), lo), hi)
def pace_update(trigger: EventTriggerConfig, event_count: int) -> None:
if float(getattr(trigger.source, "poll_seconds", 0) or 0) > 0:
return
default, lo, hi = PACE_BOUNDS.get(trigger.source.kind, (300.0, 60.0, 3600.0))
current = p_pace_interval.get(trigger.id, default)
if event_count > 0:
p_pace_interval[trigger.id] = max(lo, current / 2)
p_pace_quiet[trigger.id] = 0
else:
quiet = p_pace_quiet.get(trigger.id, 0) + 1
if quiet >= PACE_QUIET_POLLS:
p_pace_interval[trigger.id] = min(hi, current * 1.5)
quiet = 0
p_pace_quiet[trigger.id] = quiet
# Held-open live sources (kqueue file signals, SSE streams): trigger_id -> (config signature, stopper).
p_live_handles: Dict[str, Tuple[str, Callable[[], None]]] = {}
@@ -56,6 +89,8 @@ def reset_state() -> None:
p_loop_task = None
p_next_poll.clear()
p_inflight.clear()
p_pace_interval.clear()
p_pace_quiet.clear()
for _, stop in p_live_handles.values():
try:
stop()
@@ -74,7 +109,7 @@ def p_live_triggers() -> List[Tuple[Workflow, EventTriggerConfig, float]]:
source = trig.source
if not trig.enabled or isinstance(source, (CustomEventSource, StreamSource)) or source.kind not in ADAPTERS:
continue
out.append((wf, trig, float(source.poll_seconds)))
out.append((wf, trig, effective_poll_seconds(trig)))
return out
@@ -90,6 +125,7 @@ async def p_poll_one(wf: Workflow, trigger: EventTriggerConfig) -> None:
events, new_cursor = await fetch(trigger.source, cursor)
stores.save_cursor(trigger.id, new_cursor)
stores.clear_poll_failures(trigger.id)
pace_update(trigger, len(events))
if events:
await dispatcher.ingest(workflow_id, trigger, events)
except Exception as e:
@@ -97,7 +133,10 @@ async def p_poll_one(wf: Workflow, trigger: EventTriggerConfig) -> None:
try:
# Exponential backoff on repeated failures: a broken site/model can't burn quota at full cadence, and the log says so instead of dying silently.
failures = stores.record_poll_failure(trigger.id, str(e))
base = float(getattr(trigger.source, "poll_seconds", 300))
# Third straight failure: try to fix it ourselves before the attention surface asks the user.
if failures == 3 and trigger.source.kind in ("web", "stream"):
asyncio.create_task(p_heal_and_wake(wf, trigger))
base = effective_poll_seconds(trigger)
backoff = min(base * (2 ** min(failures, 5)), 21600.0)
p_next_poll[trigger.id] = time.monotonic() + backoff
note = f" (failure {failures} in a row; next try in ~{int(backoff / 60) or 1}m)" if failures >= 2 else ""
@@ -111,6 +150,16 @@ async def p_poll_one(wf: Workflow, trigger: EventTriggerConfig) -> None:
p_inflight.discard(trigger.id)
async def p_heal_and_wake(wf: Workflow, trigger: EventTriggerConfig) -> None:
try:
from backend.apps.events.adapters.heal_trigger import attempt_heal
if await attempt_heal(wf.id, trigger):
mark_due(trigger.id)
kick()
except Exception:
logger.debug("self-heal attempt errored", exc_info=True)
def reconcile_live_sources() -> None:
"""Start/stop held-open sources to match the current trigger set. File signals
make the diff poll instant; stream tasks own an SSE connection outright."""
+5 -1
View File
@@ -4,6 +4,7 @@ the same create path the Workflows UI uses, so the user reviews and owns it
like any other."""
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from typing import Optional
@@ -81,12 +82,15 @@ async def accept_suggestion(suggestion_id: str):
else:
# No clear rhythm in the evidence: create it ready to run, let the user schedule or add a trigger.
schedule = ScheduleConfig(enabled=False)
steps = [WorkflowStep(text=t) for t in suggestion.workflow_steps]
body = WorkflowCreate(
title=suggestion.workflow_title or "Suggested workflow",
description=suggestion.description,
steps=[WorkflowStep(text=t) for t in suggestion.workflow_steps],
steps=steps,
schedule=schedule,
auto_named=False,
# The explicit accept IS the validation moment; byte-matches the FE stepsSignature so the test-first nag never fires.
tested_signature=json.dumps([[s.id, s.text] for s in steps], separators=(",", ":"), ensure_ascii=False),
)
workflow = await create_workflow(body)
suggestion.status = "accepted"
+2 -1
View File
@@ -782,7 +782,8 @@ async def triggers_attention():
continue
health = read_poll_health(t.id)
failures = int(health.get("consecutive_failures") or 0)
if failures >= 3:
# Threshold 5, not 3: the self-heal attempt fires at 3, so the user is only asked once healing has demonstrably failed.
if failures >= 5:
items.append({
"workflow_id": wf.id,
"workflow_title": wf.title,
+2
View File
@@ -190,6 +190,8 @@ P_AUTH_EXEMPT_PREFIX = (
"/api/health",
# 9Router proxies OpenAI requests with the user's sk-... bearer, not our local token; localhost-only is the gate.
"/api/openai-passthrough",
# Per-trigger push URLs: the path secret IS the credential (route 404s on any non-matching secret).
"/api/events/ingest/",
"/docs",
"/openapi",
"/redoc",
+57 -3
View File
@@ -122,17 +122,71 @@ def test_attention_endpoint_surfaces_repeat_failures(make_wf):
wf = make_wf(event_triggers=[trig, healthy])
storage.save_workflow(wf)
for _ in range(2):
for _ in range(4):
stores.record_poll_failure(trig.id, "connect refused")
assert p_run(triggers_attention()) == {"attention": []} # 2 failures = not yet
assert p_run(triggers_attention()) == {"attention": []} # 4 failures = self-heal territory, not the user's yet
stores.record_poll_failure(trig.id, "connect refused")
res = p_run(triggers_attention())
assert len(res["attention"]) == 1
item = res["attention"][0]
assert item["trigger_id"] == trig.id
assert item["consecutive_failures"] == 3
assert item["consecutive_failures"] == 5
assert "connect refused" in item["last_error"]
stores.clear_poll_failures(trig.id)
assert p_run(triggers_attention()) == {"attention": []}
def test_adaptive_pace_tunes_itself():
from backend.apps.events import poll_loop
auto = EventTriggerConfig(source=StreamSource(url="x")) # placeholder; pace keys off trigger id + kind
web_auto = EventTriggerConfig(source={"kind": "web", "url": "https://a.b", "watch_for": "", "poll_seconds": 0})
assert poll_loop.effective_poll_seconds(web_auto) == 300.0 # default until observed
poll_loop.pace_update(web_auto, event_count=3)
assert poll_loop.effective_poll_seconds(web_auto) == 150.0 # events halve toward the floor
for _ in range(5):
poll_loop.pace_update(web_auto, event_count=0)
assert poll_loop.effective_poll_seconds(web_auto) == 225.0 # 5 quiet polls stretch 1.5x
for _ in range(20):
poll_loop.pace_update(web_auto, event_count=9)
assert poll_loop.effective_poll_seconds(web_auto) == 60.0 # floor holds
fixed = EventTriggerConfig(source={"kind": "web", "url": "https://a.b", "watch_for": "", "poll_seconds": 600})
poll_loop.pace_update(fixed, event_count=9)
assert poll_loop.effective_poll_seconds(fixed) == 600.0 # explicit cadence is never second-guessed
assert auto.source.kind == "stream"
def test_self_heal_fixes_url_or_escalates(make_wf, monkeypatch):
from backend.apps.events.adapters import agent_check as ac
from backend.apps.events.adapters import heal_trigger as ht
from backend.apps.events import stores
from backend.apps.workflows import storage
trig = EventTriggerConfig(source=StreamSource(url="https://old.example/feed"))
wf = make_wf(event_triggers=[trig])
storage.save_workflow(wf)
stores.record_poll_failure(trig.id, "410 Gone")
async def p_fix_turn(model, prompt, **kwargs):
assert "https://old.example/feed" in prompt and "410 Gone" in prompt
return "Investigated.\nFIX_URL: https://new.example/feed"
monkeypatch.setattr(ac, "run_check_turn", p_fix_turn)
assert p_run(ht.attempt_heal(wf.id, trig)) is True
healed = storage.get_workflow(wf.id).event_triggers[0]
assert healed.source.url == "https://new.example/feed"
assert stores.read_poll_health(trig.id) == {} # failure streak cleared
assert any("Self-healed" in e.summary for e in stores.read_log(wf.id))
async def p_cannot(model, prompt, **kwargs):
return "CANNOT_FIX: the site now requires a sign-in"
monkeypatch.setattr(ac, "run_check_turn", p_cannot)
assert p_run(ht.attempt_heal(wf.id, healed)) is False
assert storage.get_workflow(wf.id).event_triggers[0].source.url == "https://new.example/feed" # untouched
assert any("requires a sign-in" in e.summary for e in stores.read_log(wf.id))
+36
View File
@@ -184,3 +184,39 @@ def test_custom_triggers_are_never_polled(make_wf):
# 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) == {}
def test_secret_url_ingest(make_wf, monkeypatch):
"""Paste-one-URL push: the path secret is the credential; wrong or short secrets 404."""
from backend.apps.events import dispatcher
from backend.apps.events.events import IngestPushBody, ingest_event_by_secret
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)
secret = trig.source.secret
res = p_run(ingest_event_by_secret(secret, IngestPushBody(summary="Order landed", dedup_key="o1")))
assert res == {"ok": True, "queued": 1, "deduped": False}
assert len(delivered) == 1
with pytest.raises(HTTPException) as e:
p_run(ingest_event_by_secret("f" * 32, IngestPushBody(summary="x")))
assert e.value.status_code == 404
with pytest.raises(HTTPException) as e:
p_run(ingest_event_by_secret("short", IngestPushBody(summary="x")))
assert e.value.status_code == 404
def test_mcp_auto_suggest_and_signature_vector(monkeypatch):
import backend.apps.agents.schedule_mcp_server as srv
known = {"google-workspace", "notion"}
assert srv.p_suggest_mcps("a new email from my landlord arrived", known) == ["google-workspace"]
assert srv.p_suggest_mcps("my notion database gained a row", known) == ["notion"]
assert srv.p_suggest_mcps("the moon is full", known) == []
# Byte-match the FE stepsSignature: JSON.stringify([["s1","a\"b"]]).
assert srv.p_steps_signature([{"id": "s1", "text": 'a"b'}]) == '[["s1","a\\"b"]]'
@@ -8,9 +8,6 @@ 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',
@@ -42,16 +39,6 @@ const EventTriggerRow: React.FC<RowProps> = ({ workflow, trigger, onMutate, onRe
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 }}>
@@ -97,44 +84,33 @@ const EventTriggerRow: React.FC<RowProps> = ({ workflow, trigger, onMutate, onRe
}}
/>
</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 style={{ marginBottom: 8 }}>
<span style={labelStyle}>Watching for (checked automatically; speeds up when things happen)</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>
</>
)}
{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 style={{ marginBottom: 8 }}>
<span style={labelStyle}>What counts as the event? An agent checks automatically 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>
)}
@@ -175,7 +151,9 @@ const EventTriggerRow: React.FC<RowProps> = ({ workflow, trigger, onMutate, onRe
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"}`}
{src.secret
? `POST ${API_BASE}/events/ingest/${src.secret}\n{"summary": "what happened"}`
: `POST ${API_BASE}/events/ingest\n{"workflow_id": "${workflow.id}", "trigger_id": "${t.id}", "summary": "what happened"}`}
</pre>
</div>
)}
@@ -23,11 +23,11 @@ const ADD_CHOICES: Array<[TriggerKind, string]> = [
];
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 };
if (kind === 'file') return { kind: 'file', path: '', poll_seconds: 0 };
if (kind === 'web') return { kind: 'web', url: '', watch_for: '', poll_seconds: 0 };
if (kind === 'agent') return { kind: 'agent', check: '', model: '', poll_seconds: 0 };
if (kind === 'stream') return { kind: 'stream', url: '', contains: '' };
return { kind: 'custom' };
return { kind: 'custom', secret: crypto.randomUUID().replace(/-/g, '') };
}
function newTrigger(kind: TriggerKind): EventTriggerConfig {
@@ -54,6 +54,8 @@ export interface AgentCheckSource {
export interface CustomEventSource {
/** Push-only: events arrive via POST /api/events/ingest from any script/webhook/Shortcut. */
kind: 'custom';
/** Per-trigger push credential; the paste-one-URL form is /api/events/ingest/<secret>. */
secret?: string;
}
export interface StreamSource {