@aion0/forge 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +5 -5
- package/app/api/agents/[id]/test/route.ts +150 -0
- package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
- package/app/api/connectors/tool-test/route.ts +70 -0
- package/app/api/jobs/[id]/cancel/route.ts +50 -0
- package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
- package/app/api/jobs/[id]/run/route.ts +22 -2
- package/app/api/jobs/route.ts +11 -1
- package/app/api/pipelines/[id]/schema/route.ts +53 -0
- package/app/api/pipelines/bulk-delete/route.ts +39 -0
- package/app/api/pipelines/gc/route.ts +27 -0
- package/app/api/schedules/[id]/cancel/route.ts +27 -0
- package/app/api/schedules/[id]/route.ts +173 -0
- package/app/api/schedules/[id]/run/route.ts +45 -0
- package/app/api/schedules/[id]/runs/route.ts +22 -0
- package/app/api/schedules/[id]/stop/route.ts +33 -0
- package/app/api/schedules/route.ts +175 -0
- package/app/api/tasks/bulk-delete/route.ts +47 -0
- package/bin/forge-server.mjs +22 -1
- package/cli/mw.mjs +186 -7657
- package/cli/mw.ts +26 -0
- package/components/ConnectorsPanel.tsx +46 -0
- package/components/Dashboard.tsx +23 -10
- package/components/JobsView.tsx +245 -6
- package/components/PipelineEditor.tsx +38 -1
- package/components/PipelineView.tsx +325 -4
- package/components/ScheduleCreateModal.tsx +1507 -0
- package/components/SchedulesView.tsx +605 -0
- package/components/SettingsModal.tsx +106 -0
- package/docs/Team-Workflow-Integration.md +487 -0
- package/docs/UI-Design-Brief-SidePanel.md +278 -0
- package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
- package/lib/__tests__/foreach-before.test.ts +201 -0
- package/lib/__tests__/foreach-parse.test.ts +114 -0
- package/lib/__tests__/foreach-snapshot.test.ts +112 -0
- package/lib/__tests__/foreach-source.test.ts +105 -0
- package/lib/__tests__/foreach-template.test.ts +112 -0
- package/lib/chat/agent-loop.ts +3 -3
- package/lib/chat-standalone.ts +26 -1
- package/lib/claude-process.ts +8 -5
- package/lib/connectors/sync.ts +8 -2
- package/lib/crypto.ts +1 -1
- package/lib/dirs.ts +22 -7
- package/lib/help-docs/05-pipelines.md +171 -0
- package/lib/help-docs/13-schedules.md +165 -0
- package/lib/help-docs/23-automation-states.md +148 -0
- package/lib/help-docs/CLAUDE.md +6 -6
- package/lib/init.ts +25 -6
- package/lib/jobs/recipes.ts +3 -2
- package/lib/jobs/scheduler.ts +215 -11
- package/lib/jobs/store.ts +79 -3
- package/lib/jobs/types.ts +31 -0
- package/lib/logger.ts +1 -1
- package/lib/notify.ts +13 -6
- package/lib/pipeline-gc.ts +105 -0
- package/lib/pipeline-scheduler.ts +29 -0
- package/lib/pipeline.ts +811 -330
- package/lib/schedules/action-runner.ts +257 -0
- package/lib/schedules/scheduler.ts +422 -0
- package/lib/schedules/state.ts +41 -0
- package/lib/schedules/store.ts +618 -0
- package/lib/schedules/types.ts +117 -0
- package/lib/settings.ts +35 -0
- package/lib/task-manager.ts +56 -13
- package/lib/telegram-bot.ts +9 -3
- package/lib/workflow-marketplace.ts +7 -1
- package/lib/workspace/skill-installer.ts +7 -6
- package/package.json +3 -1
- package/lib/help-docs/19-jobs.md +0 -145
- package/lib/help-docs/20-mantis-bug-fix.md +0 -115
- package/lib/help-docs/22-recipes.md +0 -124
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SchedulesView — Forge web Schedules tab.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the JobsView shape (list + expand + action bar) but radically
|
|
7
|
+
* simpler: Schedule = pipeline + input + trigger, no concurrency/dedup
|
|
8
|
+
* surface. The 4-state model (idle / running / last_failed / paused)
|
|
9
|
+
* drives all visible state.
|
|
10
|
+
*
|
|
11
|
+
* Design reference:
|
|
12
|
+
* forge-browser-extension/docs/design/design_handoff_forge_schedules/
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
16
|
+
import ScheduleCreateModal from './ScheduleCreateModal';
|
|
17
|
+
|
|
18
|
+
interface Schedule {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
body_kind: 'pipeline' | 'skill' | 'connector_tool';
|
|
23
|
+
body_ref: string;
|
|
24
|
+
input: Record<string, unknown>;
|
|
25
|
+
skills: string[];
|
|
26
|
+
action_kind: 'none' | 'chat' | 'email' | 'telegram';
|
|
27
|
+
action_config: Record<string, unknown>;
|
|
28
|
+
action_skip_on_empty: boolean;
|
|
29
|
+
schedule_kind: 'period' | 'once' | 'cron' | 'manual';
|
|
30
|
+
schedule_interval_minutes: number;
|
|
31
|
+
schedule_at: string | null;
|
|
32
|
+
schedule_cron: string | null;
|
|
33
|
+
next_run_at: string | null;
|
|
34
|
+
last_run_at: string | null;
|
|
35
|
+
created_at: string;
|
|
36
|
+
updated_at: string;
|
|
37
|
+
inflight_count: number;
|
|
38
|
+
last_status: 'done' | 'failed' | 'cancelled' | null;
|
|
39
|
+
active_state: 'idle' | 'running' | 'last_failed' | 'paused';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ScheduleRun {
|
|
43
|
+
id: string;
|
|
44
|
+
schedule_id: string;
|
|
45
|
+
target_id: string;
|
|
46
|
+
trigger: 'schedule' | 'manual';
|
|
47
|
+
status: 'started' | 'done' | 'failed' | 'cancelled';
|
|
48
|
+
body_output: string | null;
|
|
49
|
+
action_status: 'pending' | 'done' | 'failed' | 'skipped' | null;
|
|
50
|
+
action_error: string | null;
|
|
51
|
+
started_at: string;
|
|
52
|
+
finished_at: string | null;
|
|
53
|
+
error: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Filter = 'all' | 'running' | 'last_failed' | 'idle' | 'paused';
|
|
57
|
+
|
|
58
|
+
interface Props {
|
|
59
|
+
onViewPipeline?: (pipelineId: string) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default function SchedulesView({ onViewPipeline }: Props) {
|
|
63
|
+
const [schedules, setSchedules] = useState<Schedule[] | null>(null);
|
|
64
|
+
const [err, setErr] = useState('');
|
|
65
|
+
const [expandedId, setExpandedId] = useState<string>('');
|
|
66
|
+
const [runsBySchedule, setRunsBySchedule] = useState<Record<string, ScheduleRun[]>>({});
|
|
67
|
+
const [filter, setFilter] = useState<Filter>('all');
|
|
68
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
69
|
+
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
|
70
|
+
|
|
71
|
+
const refresh = useCallback(async () => {
|
|
72
|
+
try {
|
|
73
|
+
const r = await fetch('/api/schedules');
|
|
74
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
75
|
+
const j = await r.json();
|
|
76
|
+
setSchedules(j.schedules || []);
|
|
77
|
+
setErr('');
|
|
78
|
+
} catch (e) {
|
|
79
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
80
|
+
setSchedules([]);
|
|
81
|
+
}
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
85
|
+
|
|
86
|
+
// Poll while one is expanded so state stays live.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!expandedId) return;
|
|
89
|
+
const id = expandedId;
|
|
90
|
+
const t = setInterval(() => {
|
|
91
|
+
void refresh();
|
|
92
|
+
void loadRuns(id);
|
|
93
|
+
}, 3000);
|
|
94
|
+
return () => clearInterval(t);
|
|
95
|
+
}, [expandedId, refresh]);
|
|
96
|
+
|
|
97
|
+
async function loadRuns(id: string) {
|
|
98
|
+
try {
|
|
99
|
+
const r = await fetch(`/api/schedules/${encodeURIComponent(id)}/runs?limit=20`);
|
|
100
|
+
const j = await r.json();
|
|
101
|
+
setRunsBySchedule((prev) => ({ ...prev, [id]: j.runs || [] }));
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function expand(id: string) {
|
|
106
|
+
if (expandedId === id) { setExpandedId(''); return; }
|
|
107
|
+
setExpandedId(id);
|
|
108
|
+
if (!runsBySchedule[id]) void loadRuns(id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function fireNow(id: string, opts?: { cancelInflight?: boolean }) {
|
|
112
|
+
try {
|
|
113
|
+
const qs = opts?.cancelInflight ? '?cancel_inflight=1' : '';
|
|
114
|
+
const r = await fetch(`/api/schedules/${encodeURIComponent(id)}/run${qs}`, { method: 'POST' });
|
|
115
|
+
if (!r.ok) {
|
|
116
|
+
const j = await r.json().catch(() => ({}));
|
|
117
|
+
setErr(j.error || `Fire failed (${r.status})`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setTimeout(() => { void refresh(); void loadRuns(id); }, 300);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
setErr(`Fire failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function cancelInflight(id: string) {
|
|
127
|
+
if (!confirm('Cancel all in-flight pipelines for this schedule?')) return;
|
|
128
|
+
try {
|
|
129
|
+
await fetch(`/api/schedules/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
|
|
130
|
+
void refresh();
|
|
131
|
+
void loadRuns(id);
|
|
132
|
+
} catch (e) { setErr(`Cancel failed: ${e instanceof Error ? e.message : String(e)}`); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function stopSchedule(id: string) {
|
|
136
|
+
if (!confirm('Pause AND cancel all running pipelines?')) return;
|
|
137
|
+
try {
|
|
138
|
+
await fetch(`/api/schedules/${encodeURIComponent(id)}/stop`, { method: 'POST' });
|
|
139
|
+
void refresh();
|
|
140
|
+
void loadRuns(id);
|
|
141
|
+
} catch (e) { setErr(`Stop failed: ${e instanceof Error ? e.message : String(e)}`); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function togglePaused(s: Schedule) {
|
|
145
|
+
try {
|
|
146
|
+
await fetch(`/api/schedules/${encodeURIComponent(s.id)}`, {
|
|
147
|
+
method: 'PATCH',
|
|
148
|
+
headers: { 'Content-Type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ enabled: !s.enabled }),
|
|
150
|
+
});
|
|
151
|
+
void refresh();
|
|
152
|
+
} catch (e) { setErr(`Toggle failed: ${e instanceof Error ? e.message : String(e)}`); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function deleteSchedule(s: Schedule) {
|
|
156
|
+
const cancelInflightPipes = s.inflight_count > 0
|
|
157
|
+
&& confirm(`This schedule has ${s.inflight_count} running pipeline${s.inflight_count === 1 ? '' : 's'}. Cancel them too?`);
|
|
158
|
+
if (!confirm(`Delete schedule "${s.name}"?`)) return;
|
|
159
|
+
try {
|
|
160
|
+
const qs = cancelInflightPipes ? '?cancel_inflight=1' : '';
|
|
161
|
+
await fetch(`/api/schedules/${encodeURIComponent(s.id)}${qs}`, { method: 'DELETE' });
|
|
162
|
+
setExpandedId('');
|
|
163
|
+
void refresh();
|
|
164
|
+
} catch (e) { setErr(`Delete failed: ${e instanceof Error ? e.message : String(e)}`); }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Render ─────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
const counts = {
|
|
170
|
+
all: schedules?.length ?? 0,
|
|
171
|
+
running: (schedules ?? []).filter((s) => s.active_state === 'running').length,
|
|
172
|
+
last_failed: (schedules ?? []).filter((s) => s.active_state === 'last_failed').length,
|
|
173
|
+
idle: (schedules ?? []).filter((s) => s.active_state === 'idle').length,
|
|
174
|
+
paused: (schedules ?? []).filter((s) => s.active_state === 'paused').length,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const visible = (schedules ?? []).filter((s) => filter === 'all' || s.active_state === filter);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className="flex-1 flex flex-col min-h-0 overflow-auto p-4">
|
|
181
|
+
<div className="flex items-center justify-between mb-3">
|
|
182
|
+
<div>
|
|
183
|
+
<div className="flex items-baseline gap-2">
|
|
184
|
+
<h2 className="text-[14px] font-semibold text-[var(--text-primary)]">Schedules</h2>
|
|
185
|
+
<span className="text-[10px] text-[var(--text-secondary)]">tick every 60s</span>
|
|
186
|
+
</div>
|
|
187
|
+
<p className="text-[10px] text-[var(--text-secondary)] mt-0.5 max-w-2xl">
|
|
188
|
+
Schedule a pipeline with input + trigger. Pipeline runs everything; Schedule just decides when to fire.
|
|
189
|
+
Jobs (old) still work in parallel; new automations should use Schedules.
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
<div className="flex items-center gap-2">
|
|
193
|
+
<button
|
|
194
|
+
onClick={() => setShowCreate(true)}
|
|
195
|
+
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold hover:opacity-90"
|
|
196
|
+
>+ New schedule</button>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Filter tabs */}
|
|
201
|
+
<div className="flex items-baseline border-b border-[var(--border)] mb-3">
|
|
202
|
+
{(['all', 'running', 'last_failed', 'idle', 'paused'] as const).map((f) => (
|
|
203
|
+
<button
|
|
204
|
+
key={f}
|
|
205
|
+
onClick={() => setFilter(f)}
|
|
206
|
+
className={`text-[11px] px-2.5 py-1.5 -mb-px border-b-2 ${
|
|
207
|
+
filter === f
|
|
208
|
+
? 'border-[var(--text-primary)] text-[var(--text-primary)] font-semibold'
|
|
209
|
+
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
210
|
+
}`}
|
|
211
|
+
>
|
|
212
|
+
{f.replace('_', ' ')}
|
|
213
|
+
<span className={`ml-1.5 text-[9px] font-mono px-1 rounded ${
|
|
214
|
+
filter === f ? 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]' : 'text-[var(--text-secondary)]/60'
|
|
215
|
+
}`}>{counts[f]}</span>
|
|
216
|
+
</button>
|
|
217
|
+
))}
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2 mb-3">{err}</div>}
|
|
221
|
+
|
|
222
|
+
{schedules == null ? (
|
|
223
|
+
<div className="text-[11px] text-[var(--text-secondary)] text-center py-8">Loading…</div>
|
|
224
|
+
) : visible.length === 0 ? (
|
|
225
|
+
<div className="text-[11px] text-[var(--text-secondary)] text-center py-8">
|
|
226
|
+
{filter === 'all' ? '(no schedules — click "+ New schedule" to create one)' : `(no ${filter.replace('_', ' ')} schedules)`}
|
|
227
|
+
</div>
|
|
228
|
+
) : (
|
|
229
|
+
<div className="space-y-2">
|
|
230
|
+
{visible.map((s) => (
|
|
231
|
+
<ScheduleRow
|
|
232
|
+
key={s.id}
|
|
233
|
+
schedule={s}
|
|
234
|
+
expanded={expandedId === s.id}
|
|
235
|
+
runs={runsBySchedule[s.id]}
|
|
236
|
+
onClick={() => expand(s.id)}
|
|
237
|
+
onFire={(cancel) => fireNow(s.id, { cancelInflight: cancel })}
|
|
238
|
+
onCancel={() => cancelInflight(s.id)}
|
|
239
|
+
onStop={() => stopSchedule(s.id)}
|
|
240
|
+
onTogglePaused={() => togglePaused(s)}
|
|
241
|
+
onEdit={() => setEditingSchedule(s)}
|
|
242
|
+
onDelete={() => deleteSchedule(s)}
|
|
243
|
+
onViewPipeline={onViewPipeline}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{showCreate && (
|
|
250
|
+
<ScheduleCreateModal
|
|
251
|
+
onClose={() => setShowCreate(false)}
|
|
252
|
+
onCreated={() => { setShowCreate(false); void refresh(); }}
|
|
253
|
+
/>
|
|
254
|
+
)}
|
|
255
|
+
{editingSchedule && (
|
|
256
|
+
<ScheduleCreateModal
|
|
257
|
+
existing={editingSchedule}
|
|
258
|
+
onClose={() => setEditingSchedule(null)}
|
|
259
|
+
onCreated={() => { setEditingSchedule(null); void refresh(); }}
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Row ──────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
const STATE_META: Record<Schedule['active_state'], { label: string; color: string; bg: string }> = {
|
|
269
|
+
running: { label: 'running', color: 'var(--yellow)', bg: 'rgba(212, 160, 23, 0.15)' },
|
|
270
|
+
last_failed: { label: 'last failed', color: 'var(--red)', bg: 'rgba(168, 51, 31, 0.15)' },
|
|
271
|
+
idle: { label: 'idle', color: 'var(--green)', bg: 'rgba(46, 107, 77, 0.15)' },
|
|
272
|
+
paused: { label: 'paused', color: 'var(--text-secondary)', bg: 'rgba(128, 126, 118, 0.15)' },
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
function ScheduleRow({
|
|
276
|
+
schedule: s,
|
|
277
|
+
expanded,
|
|
278
|
+
runs,
|
|
279
|
+
onClick,
|
|
280
|
+
onFire,
|
|
281
|
+
onCancel,
|
|
282
|
+
onStop,
|
|
283
|
+
onTogglePaused,
|
|
284
|
+
onEdit,
|
|
285
|
+
onDelete,
|
|
286
|
+
onViewPipeline,
|
|
287
|
+
}: {
|
|
288
|
+
schedule: Schedule;
|
|
289
|
+
expanded: boolean;
|
|
290
|
+
runs?: ScheduleRun[];
|
|
291
|
+
onClick: () => void;
|
|
292
|
+
onFire: (cancelInflight: boolean) => void;
|
|
293
|
+
onCancel: () => void;
|
|
294
|
+
onStop: () => void;
|
|
295
|
+
onTogglePaused: () => void;
|
|
296
|
+
onEdit: () => void;
|
|
297
|
+
onDelete: () => void;
|
|
298
|
+
onViewPipeline?: (pipelineId: string) => void;
|
|
299
|
+
}) {
|
|
300
|
+
const meta = STATE_META[s.active_state];
|
|
301
|
+
const busy = s.active_state === 'running';
|
|
302
|
+
const inflight = s.inflight_count;
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<div className={`rounded border bg-[var(--bg-secondary)] ${expanded ? 'border-[var(--text-secondary)]' : 'border-[var(--border)]'}`}>
|
|
306
|
+
<div onClick={onClick} className="px-3 py-2 cursor-pointer">
|
|
307
|
+
<div className="flex items-baseline gap-2">
|
|
308
|
+
<span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
|
|
309
|
+
<span
|
|
310
|
+
className="text-[9px] px-1.5 py-0.5 rounded uppercase font-semibold tracking-wide"
|
|
311
|
+
style={{ background: meta.bg, color: meta.color }}
|
|
312
|
+
>
|
|
313
|
+
{meta.label}
|
|
314
|
+
</span>
|
|
315
|
+
<span className={`text-[12px] font-semibold text-[var(--text-primary)] truncate flex-1 ${s.active_state === 'paused' && inflight === 0 ? 'line-through opacity-60' : ''}`}>
|
|
316
|
+
{s.name}
|
|
317
|
+
</span>
|
|
318
|
+
{s.active_state === 'paused' && inflight > 0 && (
|
|
319
|
+
<span className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--yellow)]/20 text-[var(--yellow)]">
|
|
320
|
+
{inflight} pipeline{inflight === 1 ? '' : 's'} still running
|
|
321
|
+
</span>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
<div className="text-[10px] text-[var(--text-secondary)] font-mono mt-1 truncate">
|
|
325
|
+
⚡ {s.body_ref} · {describeSchedule(s)}
|
|
326
|
+
<span className="ml-2 text-[var(--text-secondary)]/60">{s.id}</span>
|
|
327
|
+
</div>
|
|
328
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
|
329
|
+
{s.next_run_at ? `next ${formatTime(s.next_run_at)}` : (s.schedule_kind === 'manual' ? 'manual only' : 'paused')}
|
|
330
|
+
{' · '}
|
|
331
|
+
{s.last_run_at ? `last ${formatTime(s.last_run_at)}` : 'never run'}
|
|
332
|
+
{s.last_status && (
|
|
333
|
+
<span className={`ml-1.5 text-[9px] px-1 py-0 rounded ${
|
|
334
|
+
s.last_status === 'done' ? 'bg-[var(--green)]/15 text-[var(--green)]' :
|
|
335
|
+
s.last_status === 'failed' ? 'bg-[var(--red)]/15 text-[var(--red)]' :
|
|
336
|
+
'bg-[var(--text-secondary)]/15 text-[var(--text-secondary)]'
|
|
337
|
+
}`}>{s.last_status}</span>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
{expanded && (
|
|
343
|
+
<div className="border-t border-[var(--border)] px-3 py-2 bg-[var(--bg-tertiary)]">
|
|
344
|
+
{/* Action bar — conditional per state spec */}
|
|
345
|
+
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
|
346
|
+
{/* Run now: always shown; disabled if running (unless cancel_inflight) */}
|
|
347
|
+
<button
|
|
348
|
+
onClick={(e) => { e.stopPropagation(); onFire(busy); }}
|
|
349
|
+
disabled={busy && inflight > 0 && !confirm}
|
|
350
|
+
className={`text-[10px] px-2 py-0.5 border rounded ${
|
|
351
|
+
s.active_state === 'last_failed' || s.active_state === 'idle'
|
|
352
|
+
? 'border-[var(--text-primary)] bg-[var(--text-primary)] text-[var(--bg-primary)]'
|
|
353
|
+
: 'border-[var(--border)] hover:bg-[var(--bg-secondary)]'
|
|
354
|
+
}`}
|
|
355
|
+
title={busy ? `Cancel current pipeline${inflight === 1 ? '' : 's'} and run anyway?` : 'Trigger this schedule now'}
|
|
356
|
+
>
|
|
357
|
+
{busy ? `Run now (cancel ${inflight})` : 'Run now'}
|
|
358
|
+
</button>
|
|
359
|
+
|
|
360
|
+
{/* Cancel (only if has inflight) */}
|
|
361
|
+
{inflight > 0 && (
|
|
362
|
+
<button
|
|
363
|
+
onClick={(e) => { e.stopPropagation(); onCancel(); }}
|
|
364
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--yellow)]/50 text-[var(--yellow)] rounded hover:bg-[var(--yellow)]/10"
|
|
365
|
+
title="Kill the in-flight pipelines but keep schedule enabled"
|
|
366
|
+
>Cancel ({inflight})</button>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{/* Pause: hidden if already paused */}
|
|
370
|
+
{s.enabled && (
|
|
371
|
+
<button
|
|
372
|
+
onClick={(e) => { e.stopPropagation(); onTogglePaused(); }}
|
|
373
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]"
|
|
374
|
+
title="Stop auto-triggering. Running pipelines keep going."
|
|
375
|
+
>Pause</button>
|
|
376
|
+
)}
|
|
377
|
+
|
|
378
|
+
{/* Resume: only when paused and no inflight (avoid confusion) */}
|
|
379
|
+
{!s.enabled && inflight === 0 && (
|
|
380
|
+
<button
|
|
381
|
+
onClick={(e) => { e.stopPropagation(); onTogglePaused(); }}
|
|
382
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--text-primary)] bg-[var(--text-primary)] text-[var(--bg-primary)] rounded"
|
|
383
|
+
title="Re-enable scheduled triggers"
|
|
384
|
+
>Resume</button>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
{/* Stop: only when there's inflight (running OR paused-with-inflight) */}
|
|
388
|
+
{inflight > 0 && (
|
|
389
|
+
<button
|
|
390
|
+
onClick={(e) => { e.stopPropagation(); onStop(); }}
|
|
391
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--red)]/50 text-[var(--red)] rounded hover:bg-[var(--red)]/10"
|
|
392
|
+
title="Pause AND cancel all running pipelines"
|
|
393
|
+
>Stop</button>
|
|
394
|
+
)}
|
|
395
|
+
|
|
396
|
+
<button
|
|
397
|
+
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
|
398
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]"
|
|
399
|
+
title="Edit input / trigger / action (body type & ref are immutable)"
|
|
400
|
+
>Edit</button>
|
|
401
|
+
|
|
402
|
+
<span className="flex-1" />
|
|
403
|
+
|
|
404
|
+
<button
|
|
405
|
+
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
|
406
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--red)]/50 text-[var(--red)] rounded hover:bg-[var(--red)]/10"
|
|
407
|
+
>Delete</button>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
{/* Configuration */}
|
|
411
|
+
<div className="mb-3">
|
|
412
|
+
<div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1">Configuration</div>
|
|
413
|
+
<div className="text-[10px] grid grid-cols-[80px_1fr] gap-x-3 gap-y-1 font-mono">
|
|
414
|
+
<span className="text-[var(--text-secondary)]">Body</span>
|
|
415
|
+
<span>{s.body_kind} · {s.body_ref}</span>
|
|
416
|
+
<span className="text-[var(--text-secondary)]">Action</span>
|
|
417
|
+
<span>{describeAction(s)}</span>
|
|
418
|
+
<span className="text-[var(--text-secondary)]">Trigger</span>
|
|
419
|
+
<span>{describeSchedule(s)}</span>
|
|
420
|
+
{Object.entries(s.input).map(([k, v]) => (
|
|
421
|
+
<>
|
|
422
|
+
<span key={`k-${k}`} className="text-[var(--text-secondary)]">{k}</span>
|
|
423
|
+
<span key={`v-${k}`} className="whitespace-pre-wrap break-all">{typeof v === 'string' ? v : JSON.stringify(v)}</span>
|
|
424
|
+
</>
|
|
425
|
+
))}
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{/* Tracked pipelines */}
|
|
430
|
+
<div className="mb-1">
|
|
431
|
+
<div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1 flex items-baseline gap-2 flex-wrap">
|
|
432
|
+
<span>Tracked pipelines</span>
|
|
433
|
+
{runs && runs.length > 0 && (
|
|
434
|
+
<span className="text-[10px] font-normal normal-case text-[var(--text-primary)]">
|
|
435
|
+
{runs.length} total
|
|
436
|
+
{runs.filter(r => r.status === 'started').length ? <> · <span className="text-[var(--accent)]">{runs.filter(r => r.status === 'started').length} running</span></> : null}
|
|
437
|
+
{runs.filter(r => r.status === 'done').length ? <> · <span className="text-[var(--green)]">{runs.filter(r => r.status === 'done').length} done</span></> : null}
|
|
438
|
+
{runs.filter(r => r.status === 'failed').length ? <> · <span className="text-[var(--red)]">{runs.filter(r => r.status === 'failed').length} failed</span></> : null}
|
|
439
|
+
{runs.filter(r => r.status === 'cancelled').length ? <> · <span className="text-[var(--text-secondary)]">{runs.filter(r => r.status === 'cancelled').length} cancelled</span></> : null}
|
|
440
|
+
</span>
|
|
441
|
+
)}
|
|
442
|
+
</div>
|
|
443
|
+
{runs == null ? (
|
|
444
|
+
<div className="text-[10px] text-[var(--text-secondary)]">Loading…</div>
|
|
445
|
+
) : runs.length === 0 ? (
|
|
446
|
+
<div className="text-[10px] text-[var(--text-secondary)]">(no runs yet)</div>
|
|
447
|
+
) : (
|
|
448
|
+
<div className="space-y-0.5">
|
|
449
|
+
{/* Show running + last terminal */}
|
|
450
|
+
{(() => {
|
|
451
|
+
const active = runs.filter((r) => r.status === 'started');
|
|
452
|
+
const lastTerminal = runs.find((r) => r.status !== 'started');
|
|
453
|
+
return lastTerminal ? [...active, lastTerminal] : active;
|
|
454
|
+
})().map((r) => (
|
|
455
|
+
<RunRow
|
|
456
|
+
key={r.id}
|
|
457
|
+
run={r}
|
|
458
|
+
isPipelineBody={s.body_kind === 'pipeline'}
|
|
459
|
+
onViewPipeline={onViewPipeline}
|
|
460
|
+
/>
|
|
461
|
+
))}
|
|
462
|
+
</div>
|
|
463
|
+
)}
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function describeSchedule(s: Schedule): string {
|
|
472
|
+
switch (s.schedule_kind) {
|
|
473
|
+
case 'period': return `every ${s.schedule_interval_minutes} min`;
|
|
474
|
+
case 'cron': return `cron "${s.schedule_cron}"`;
|
|
475
|
+
case 'once': return `once at ${s.schedule_at}`;
|
|
476
|
+
case 'manual': return 'manual only';
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function describeAction(s: Schedule): string {
|
|
481
|
+
const cfg = s.action_config || {};
|
|
482
|
+
switch (s.action_kind) {
|
|
483
|
+
case 'none': return 'none';
|
|
484
|
+
case 'chat': {
|
|
485
|
+
const id = typeof cfg.session_id === 'string' ? cfg.session_id : '';
|
|
486
|
+
return id ? `chat · ${id.slice(0, 8)}…` : 'chat';
|
|
487
|
+
}
|
|
488
|
+
case 'email': {
|
|
489
|
+
const to = cfg.to;
|
|
490
|
+
const list = Array.isArray(to) ? to.join(', ') : (typeof to === 'string' ? to : '');
|
|
491
|
+
return list ? `email · ${list}` : 'email';
|
|
492
|
+
}
|
|
493
|
+
case 'telegram': {
|
|
494
|
+
const id = typeof cfg.chat_id === 'string' && cfg.chat_id ? cfg.chat_id : '(default chat)';
|
|
495
|
+
return `telegram · ${id}`;
|
|
496
|
+
}
|
|
497
|
+
default: return s.action_kind;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function RunRow({ run: r, isPipelineBody, onViewPipeline }: {
|
|
502
|
+
run: ScheduleRun;
|
|
503
|
+
isPipelineBody: boolean;
|
|
504
|
+
onViewPipeline?: (pipelineId: string) => void;
|
|
505
|
+
}) {
|
|
506
|
+
// body_output/error captured for connector_tool runs (target_id starts
|
|
507
|
+
// ct_*) and skill runs aren't navigable to a pipeline detail page; the
|
|
508
|
+
// only place to surface them is right here, inline. Failed runs default
|
|
509
|
+
// to expanded so the user doesn't have to hunt.
|
|
510
|
+
// Surface ALL three stages — trigger / body / action — instead of
|
|
511
|
+
// just body status. Skipped or failed action was previously
|
|
512
|
+
// invisible (e.g. "body status was started" race where action got
|
|
513
|
+
// permanently skipped while body settled later — user had no way
|
|
514
|
+
// to see WHY chat/email/telegram never fired).
|
|
515
|
+
const actionFailedOrSkipped = r.action_status === 'failed' || r.action_status === 'skipped';
|
|
516
|
+
const [expanded, setExpanded] = useState(r.status === 'failed' || actionFailedOrSkipped);
|
|
517
|
+
const canOpenPipeline = isPipelineBody && !!onViewPipeline && !r.target_id.startsWith('ct_');
|
|
518
|
+
const hasDetails = !!(r.body_output || r.error || r.action_error);
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<div className="text-[10px] font-mono">
|
|
522
|
+
<div
|
|
523
|
+
className="flex items-baseline gap-2 py-0.5 px-1 rounded hover:bg-[var(--bg-secondary)]/50 cursor-pointer"
|
|
524
|
+
onClick={(e) => {
|
|
525
|
+
e.stopPropagation();
|
|
526
|
+
if (canOpenPipeline) onViewPipeline!(r.target_id);
|
|
527
|
+
else if (hasDetails) setExpanded((x) => !x);
|
|
528
|
+
}}
|
|
529
|
+
title={canOpenPipeline ? 'Open pipeline run · click status badges to expand' : hasDetails ? 'Click to expand details' : ''}
|
|
530
|
+
>
|
|
531
|
+
<span className={
|
|
532
|
+
r.status === 'started' ? 'text-[var(--accent)]' :
|
|
533
|
+
r.status === 'done' ? 'text-[var(--green)]' :
|
|
534
|
+
r.status === 'failed' ? 'text-[var(--red)]' :
|
|
535
|
+
r.status === 'cancelled' ? 'text-[var(--text-secondary)]' : ''
|
|
536
|
+
}>
|
|
537
|
+
{r.status === 'started' ? '🔄' :
|
|
538
|
+
r.status === 'done' ? '✅' :
|
|
539
|
+
r.status === 'failed' ? '❌' :
|
|
540
|
+
r.status === 'cancelled' ? '⏹' : '·'}
|
|
541
|
+
</span>
|
|
542
|
+
<span className="text-[var(--text-secondary)] w-20 truncate">{r.target_id.slice(0, 8)}</span>
|
|
543
|
+
<span className="flex-1 truncate">
|
|
544
|
+
{formatTime(r.started_at)}{r.finished_at && ' → ' + formatTime(r.finished_at)}
|
|
545
|
+
</span>
|
|
546
|
+
<span className={`px-1 py-0 text-[9px] rounded ${
|
|
547
|
+
r.trigger === 'manual' ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'bg-[var(--text-secondary)]/15 text-[var(--text-secondary)]'
|
|
548
|
+
}`}>{r.trigger}</span>
|
|
549
|
+
{/* Body status badge */}
|
|
550
|
+
<span className={`px-1 py-0 text-[9px] rounded ${
|
|
551
|
+
r.status === 'done' ? 'bg-[var(--green)]/15 text-[var(--green)]' :
|
|
552
|
+
r.status === 'failed' ? 'bg-[var(--red)]/15 text-[var(--red)]' :
|
|
553
|
+
r.status === 'cancelled' ? 'bg-[var(--text-secondary)]/15 text-[var(--text-secondary)]' :
|
|
554
|
+
'bg-[var(--accent)]/15 text-[var(--accent)]'
|
|
555
|
+
}`} title="body status">body:{r.status}</span>
|
|
556
|
+
{/* Action status badge — visible whenever action_status is set
|
|
557
|
+
(i.e. action was attempted). null = action_kind=none, hide. */}
|
|
558
|
+
{r.action_status !== null && (
|
|
559
|
+
<span className={`px-1 py-0 text-[9px] rounded ${
|
|
560
|
+
r.action_status === 'done' ? 'bg-[var(--green)]/15 text-[var(--green)]' :
|
|
561
|
+
r.action_status === 'failed' ? 'bg-[var(--red)]/15 text-[var(--red)]' :
|
|
562
|
+
r.action_status === 'skipped' ? 'bg-[var(--yellow)]/15 text-[var(--yellow)]' :
|
|
563
|
+
'bg-[var(--text-secondary)]/15 text-[var(--text-secondary)]'
|
|
564
|
+
}`} title={`action status${r.action_error ? ' — ' + r.action_error : ''}`}>
|
|
565
|
+
action:{r.action_status}
|
|
566
|
+
</span>
|
|
567
|
+
)}
|
|
568
|
+
</div>
|
|
569
|
+
{expanded && hasDetails && (
|
|
570
|
+
<div className="ml-7 my-1 px-2 py-1.5 bg-[var(--bg-secondary)] rounded text-[10px] whitespace-pre-wrap break-words border border-[var(--border)]">
|
|
571
|
+
{r.error && (
|
|
572
|
+
<div className="text-[var(--red)] mb-1">
|
|
573
|
+
<span className="font-semibold">body error:</span> {r.error}
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
{r.action_error && (
|
|
577
|
+
<div className={`mb-1 ${r.action_status === 'failed' ? 'text-[var(--red)]' : 'text-[var(--yellow)]'}`}>
|
|
578
|
+
<span className="font-semibold">action {r.action_status || 'error'}:</span> {r.action_error}
|
|
579
|
+
</div>
|
|
580
|
+
)}
|
|
581
|
+
{r.body_output && (
|
|
582
|
+
<div className="text-[var(--text-secondary)]">
|
|
583
|
+
<div className="font-semibold mb-0.5">body_output (fed into action):</div>
|
|
584
|
+
<div className="max-h-60 overflow-auto">{r.body_output.slice(0, 4000)}{r.body_output.length > 4000 ? '…' : ''}</div>
|
|
585
|
+
</div>
|
|
586
|
+
)}
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
589
|
+
</div>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function formatTime(iso: string): string {
|
|
594
|
+
if (!iso) return '';
|
|
595
|
+
const d = new Date(iso);
|
|
596
|
+
const now = Date.now();
|
|
597
|
+
const diff = d.getTime() - now;
|
|
598
|
+
const absMin = Math.abs(diff) / 60_000;
|
|
599
|
+
if (absMin < 1) return diff > 0 ? '<1m' : 'just now';
|
|
600
|
+
if (absMin < 60) {
|
|
601
|
+
const m = Math.round(absMin);
|
|
602
|
+
return diff > 0 ? `in ${m}m` : `${m}m ago`;
|
|
603
|
+
}
|
|
604
|
+
return d.toLocaleString();
|
|
605
|
+
}
|