mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[eric] events: instant file signals (kqueue), SSE stream tier, watcher-attention surface
This commit is contained in:
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user