[eric] events: instant file signals (kqueue), SSE stream tier, watcher-attention surface

This commit is contained in:
ciregenz
2026-07-28 15:48:40 -07:00
parent 22d6d088b3
commit 8281bdebfc
16 changed files with 585 additions and 16 deletions
@@ -0,0 +1,68 @@
// Bottom-left nudge when an event watcher keeps failing (site changed, sign-in
// needed, feed dead): names the workflow and jumps to its Event triggers panel,
// where the activity feed says exactly why. A silently dead watcher is the
// trust-killer this exists to prevent.
import React from 'react';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { hideTriggersHealthToast } from '@/shared/state/triggersHealthSlice';
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
export default function TriggerHealthToast() {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const open = useAppSelector((s) => s.triggersHealth.toastOpen);
const items = useAppSelector((s) => s.triggersHealth.items);
const first = items[0];
const onReview = React.useCallback(() => {
if (first) dispatch(openWorkflowsApp({ workflowId: first.workflow_id }));
dispatch(hideTriggersHealthToast());
}, [dispatch, first]);
const extra = items.length > 1 ? ` (and ${items.length - 1} more watcher${items.length > 2 ? 's' : ''})` : '';
return (
<Snackbar
open={open && !!first}
autoHideDuration={null}
onClose={(event, reason) => { if (reason !== 'clickaway') dispatch(hideTriggersHealthToast()); }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
icon={false}
severity="warning"
sx={{
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
maxWidth: 440,
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
}}
action={
<>
<Button size="small" onClick={onReview} sx={{ color: c.accent.primary, fontWeight: 700 }}>
Review
</Button>
<IconButton
size="small"
aria-label="Dismiss"
onClick={() => dispatch(hideTriggersHealthToast())}
sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</>
}
>
{first ? `A watcher on "${first.workflow_title}" keeps failing (${first.consecutive_failures} in a row)${extra}; it may need something from you.` : ''}
</Alert>
</Snackbar>
);
}
@@ -8,6 +8,7 @@ import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast';
import MissedRunsToast from '@/app/pages/Workflows/MissedRunsToast';
import ProviderHealthToast from '@/app/components/overlays/ProviderHealthToast';
import PatternOfferToast from '@/app/components/overlays/PatternOfferToast';
import TriggerHealthToast from '@/app/components/overlays/TriggerHealthToast';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -160,6 +161,9 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
{/* Mined-pattern offer: "you do this a lot, want a workflow?" */}
<PatternOfferToast />
{/* A watcher that keeps failing probably needs something from the user */}
<TriggerHealthToast />
</>
);
};
@@ -27,6 +27,7 @@ import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/wo
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
import { fetchProviderHealth } from '@/shared/state/subscriptionsSlice';
import { fetchPatternSuggestions } from '@/shared/state/patternsSlice';
import { fetchTriggersAttention } from '@/shared/state/triggersHealthSlice';
import { dashboardWs } from '@/shared/ws/WebSocketManager';
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
import { getKeepAliveBrowserIds } from '@/shared/browserFocus';
@@ -92,7 +93,8 @@ export function useDashboardLifecycle({
}, 12_000);
// Mined-pattern offers ride the same once-per-launch gate, staggered after the health pill so nudges don't stack; the ws patterns:suggestions_updated case covers a miner pass finishing later.
const tPatterns = setTimeout(() => { dispatch(fetchPatternSuggestions()); }, 20_000);
return () => { clearTimeout(t); clearTimeout(tPatterns); };
const tTriggers = setTimeout(() => { dispatch(fetchTriggersAttention()); }, 30_000);
return () => { clearTimeout(t); clearTimeout(tPatterns); clearTimeout(tTriggers); };
}, [isActive, dispatch]);
// Track dashboard engagement time
@@ -16,6 +16,7 @@ const KIND_LABELS: Record<string, string> = {
web: 'Web page watch',
agent: 'Agent check',
custom: 'Custom (push)',
stream: 'Live feed (SSE)',
};
interface RowProps {
@@ -137,6 +138,35 @@ const EventTriggerRow: React.FC<RowProps> = ({ workflow, trigger, onMutate, onRe
</div>
)}
{src.kind === 'stream' && (
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<span style={labelStyle}>Feed URL (Server-Sent Events)</span>
<input
style={fieldStyle}
defaultValue={src.url}
placeholder="https://stream.example.com/events"
onBlur={(e) => {
const url = e.target.value.trim();
if (url !== src.url) onMutate((x) => ({ ...x, source: { ...src, url } }));
}}
/>
</div>
<div style={{ width: 150, flex: 'none' }}>
<span style={labelStyle}>Only lines containing</span>
<input
style={fieldStyle}
defaultValue={src.contains}
placeholder="(everything)"
onBlur={(e) => {
const contains = e.target.value.trim();
if (contains !== src.contains) onMutate((x) => ({ ...x, source: { ...src, contains } }));
}}
/>
</div>
</div>
)}
{src.kind === 'custom' && (
<div style={{ marginBottom: 8 }}>
<span style={labelStyle}>Anything can push events here (scripts, webhooks, Shortcuts):</span>
@@ -12,19 +12,21 @@ import EventTriggerRow from './EventTriggerRow';
import { useWC, FONT_SERIF } from './uiKit';
import { useWorkflowPatch } from './useWorkflowPatch';
type TriggerKind = 'file' | 'web' | 'agent' | 'custom';
type TriggerKind = 'file' | 'web' | 'agent' | 'custom' | 'stream';
const ADD_CHOICES: Array<[TriggerKind, string]> = [
['file', '+ Folder'],
['web', '+ Page'],
['agent', '+ Agent check'],
['custom', '+ Custom'],
['stream', '+ Live feed'],
];
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 === 'stream') return { kind: 'stream', url: '', contains: '' };
return { kind: 'custom' };
}
+2
View File
@@ -18,6 +18,7 @@ import subscriptionsReducer from './subscriptionsSlice';
import workflowsReducer from './workflowsSlice';
import missedRunsReducer from './missedRunsSlice';
import patternsReducer from './patternsSlice';
import triggersHealthReducer from './triggersHealthSlice';
import onboardingProgressReducer from '@/shared/state/onboardingProgressSlice';
import onboardingV3Reducer from '@/shared/state/onboardingV3Slice';
@@ -42,6 +43,7 @@ export const store = configureStore({
workflows: workflowsReducer,
missedRuns: missedRunsReducer,
patterns: patternsReducer,
triggersHealth: triggersHealthReducer,
onboardingProgress: onboardingProgressReducer,
onboardingV3: onboardingV3Reducer,
},
@@ -0,0 +1,45 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
export interface TriggerAttentionItem {
workflow_id: string;
workflow_title: string;
trigger_id: string;
kind: string;
consecutive_failures: number;
last_error: string;
}
interface TriggersHealthState {
items: TriggerAttentionItem[];
toastOpen: boolean;
}
const initialState: TriggersHealthState = { items: [], toastOpen: false };
export const fetchTriggersAttention = createAsyncThunk(
'triggersHealth/fetch',
async (): Promise<{ attention: TriggerAttentionItem[] }> => {
const r = await fetch(`${API_BASE}/workflows/triggers/attention`);
return (await r.json()) as { attention: TriggerAttentionItem[] };
},
);
const triggersHealthSlice = createSlice({
name: 'triggersHealth',
initialState,
reducers: {
hideTriggersHealthToast(state) {
state.toastOpen = false;
},
},
extraReducers: (builder) => {
builder.addCase(fetchTriggersAttention.fulfilled, (state, action) => {
state.items = action.payload.attention ?? [];
state.toastOpen = state.items.length > 0;
});
},
});
export const { hideTriggersHealthToast } = triggersHealthSlice.actions;
export default triggersHealthSlice.reducer;
+10 -2
View File
@@ -56,7 +56,15 @@ export interface CustomEventSource {
kind: 'custom';
}
export type EventSourceConfig = FileWatchSource | WebWatchSource | AgentCheckSource | CustomEventSource;
export interface StreamSource {
/** Held-open SSE subscription: the source's own event log, read live. */
kind: 'stream';
url: string;
/** Only messages containing this substring become events; empty = everything. */
contains: string;
}
export type EventSourceConfig = FileWatchSource | WebWatchSource | AgentCheckSource | CustomEventSource | StreamSource;
export interface EventTriggerConfig {
id: string;
@@ -177,7 +185,7 @@ export interface WorkflowRun {
session_id: string | null;
error: string | null;
cost_usd: number;
triggered_by: 'schedule' | 'manual' | 'retry';
triggered_by: 'schedule' | 'manual' | 'retry' | 'event';
/** Live "what's the agent doing" subtitle while status is 'running'. */
last_tool_label?: string | null;
/** Currently-executing 0-based step index while status is 'running';