@aion0/forge 0.9.0 → 0.9.2

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.
Files changed (70) hide show
  1. package/RELEASE_NOTES.md +60 -7
  2. package/app/api/agents/[id]/test/route.ts +150 -0
  3. package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
  4. package/app/api/connectors/tool-test/route.ts +70 -0
  5. package/app/api/jobs/[id]/cancel/route.ts +50 -0
  6. package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
  7. package/app/api/jobs/[id]/run/route.ts +22 -2
  8. package/app/api/jobs/route.ts +11 -1
  9. package/app/api/pipelines/[id]/schema/route.ts +53 -0
  10. package/app/api/pipelines/bulk-delete/route.ts +39 -0
  11. package/app/api/pipelines/gc/route.ts +27 -0
  12. package/app/api/schedules/[id]/cancel/route.ts +27 -0
  13. package/app/api/schedules/[id]/route.ts +173 -0
  14. package/app/api/schedules/[id]/run/route.ts +45 -0
  15. package/app/api/schedules/[id]/runs/route.ts +22 -0
  16. package/app/api/schedules/[id]/stop/route.ts +33 -0
  17. package/app/api/schedules/route.ts +175 -0
  18. package/app/api/tasks/bulk-delete/route.ts +47 -0
  19. package/bin/forge-server.mjs +22 -1
  20. package/cli/mw.mjs +186 -7657
  21. package/cli/mw.ts +26 -0
  22. package/components/ConnectorsPanel.tsx +46 -0
  23. package/components/Dashboard.tsx +23 -10
  24. package/components/JobsView.tsx +245 -6
  25. package/components/PipelineEditor.tsx +38 -1
  26. package/components/PipelineView.tsx +325 -4
  27. package/components/ScheduleCreateModal.tsx +1507 -0
  28. package/components/SchedulesView.tsx +605 -0
  29. package/components/SettingsModal.tsx +116 -7
  30. package/docs/Team-Workflow-Integration.md +487 -0
  31. package/docs/UI-Design-Brief-SidePanel.md +278 -0
  32. package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
  33. package/lib/__tests__/foreach-before.test.ts +201 -0
  34. package/lib/__tests__/foreach-parse.test.ts +114 -0
  35. package/lib/__tests__/foreach-snapshot.test.ts +112 -0
  36. package/lib/__tests__/foreach-source.test.ts +105 -0
  37. package/lib/__tests__/foreach-template.test.ts +112 -0
  38. package/lib/chat/agent-loop.ts +3 -3
  39. package/lib/chat-standalone.ts +26 -1
  40. package/lib/claude-process.ts +8 -5
  41. package/lib/connectors/sync.ts +8 -2
  42. package/lib/crypto.ts +1 -1
  43. package/lib/dirs.ts +22 -7
  44. package/lib/help-docs/05-pipelines.md +171 -0
  45. package/lib/help-docs/13-schedules.md +165 -0
  46. package/lib/help-docs/23-automation-states.md +148 -0
  47. package/lib/help-docs/CLAUDE.md +6 -6
  48. package/lib/init.ts +25 -6
  49. package/lib/jobs/recipes.ts +3 -2
  50. package/lib/jobs/scheduler.ts +215 -11
  51. package/lib/jobs/store.ts +79 -3
  52. package/lib/jobs/types.ts +31 -0
  53. package/lib/logger.ts +1 -1
  54. package/lib/notify.ts +13 -6
  55. package/lib/pipeline-gc.ts +105 -0
  56. package/lib/pipeline-scheduler.ts +29 -0
  57. package/lib/pipeline.ts +811 -330
  58. package/lib/schedules/action-runner.ts +257 -0
  59. package/lib/schedules/scheduler.ts +422 -0
  60. package/lib/schedules/state.ts +41 -0
  61. package/lib/schedules/store.ts +618 -0
  62. package/lib/schedules/types.ts +117 -0
  63. package/lib/settings.ts +35 -0
  64. package/lib/task-manager.ts +56 -13
  65. package/lib/workflow-marketplace.ts +7 -1
  66. package/lib/workspace/skill-installer.ts +7 -6
  67. package/package.json +3 -1
  68. package/lib/help-docs/19-jobs.md +0 -145
  69. package/lib/help-docs/20-mantis-bug-fix.md +0 -115
  70. 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
+ }