@aion0/forge 0.10.80 → 0.10.82

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 CHANGED
@@ -1,15 +1,8 @@
1
- # Forge v0.10.80
1
+ # Forge v0.10.82
2
2
 
3
- Released: 2026-06-14
3
+ Released: 2026-06-15
4
4
 
5
- ## Changes since v0.10.79
5
+ ## Changes since v0.10.81
6
6
 
7
- ### Other
8
- - feat(chat): read_project_file + full task output + anti-fabrication guidance
9
- - feat(pipeline): per-run tmux/headless backend selection
10
- - feat(tasks): tmux backend — reliable completion detection + interactive session view
11
- - feat(tasks): show backend + agent badges in TaskDetail header
12
- - feat(tasks): tmux backend — interactive claude via Stop hook completion detection
13
7
 
14
-
15
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.79...v0.10.80
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.81...v0.10.82
@@ -14,6 +14,7 @@ const CodeViewer = lazy(() => import('./CodeViewer'));
14
14
  const ProjectManager = lazy(() => import('./ProjectManager'));
15
15
  const BrowserPanel = lazy(() => import('./BrowserPanel'));
16
16
  const PipelineView = lazy(() => import('./PipelineView'));
17
+ const PipelineHistory = lazy(() => import('./PipelineHistory'));
17
18
  const JobsView = lazy(() => import('./JobsView'));
18
19
  const SchedulesView = lazy(() => import('./SchedulesView'));
19
20
  const HelpDialog = lazy(() => import('./HelpDialog'));
@@ -103,7 +104,7 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
103
104
  }
104
105
 
105
106
  export default function Dashboard({ user }: { user: any }) {
106
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('terminal');
107
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'history' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('history');
107
108
 
108
109
  // Honour `?view=<mode>` from the URL so external links (eg the VSCode
109
110
  // extension) can deep-link straight into a section. Only views that have a
@@ -116,7 +117,7 @@ export default function Dashboard({ user }: { user: any }) {
116
117
  if (raw) {
117
118
  const aliases: Record<string, string> = { workspace: 'projects', sessions: 'projects' };
118
119
  const v = aliases[raw] || raw;
119
- const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
120
+ const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'history', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
120
121
  if (valid.includes(v)) setViewMode(v as any);
121
122
  }
122
123
  // Optional deep-link to a specific pipeline run — used by the extension
@@ -137,6 +138,24 @@ export default function Dashboard({ user }: { user: any }) {
137
138
  const [browserDragging, setBrowserDragging] = useState(false);
138
139
  const [tasks, setTasks] = useState<Task[]>([]);
139
140
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
141
+
142
+ // Write the current section back into the URL so clicking a menu updates the
143
+ // address bar and links are shareable / bookmarkable. replaceState → the
144
+ // Dashboard never remounts (terminals stay alive) and history isn't spammed.
145
+ // Skip the very first run so the incoming deep-link (read on mount above) is
146
+ // not clobbered before it's consumed.
147
+ const didSyncUrl = useRef(false);
148
+ useEffect(() => {
149
+ if (!didSyncUrl.current) { didSyncUrl.current = true; return; }
150
+ const params = new URLSearchParams(window.location.search);
151
+ params.set('view', viewMode);
152
+ if (viewMode === 'tasks' && activeTaskId) params.set('taskId', activeTaskId);
153
+ else params.delete('taskId');
154
+ // pipeline deep-link id only makes sense inside the pipelines view.
155
+ if (viewMode !== 'pipelines') { params.delete('pipeline'); params.delete('pipelineId'); }
156
+ window.history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`);
157
+ }, [viewMode, activeTaskId]);
158
+
140
159
  const [showNewTask, setShowNewTask] = useState(false);
141
160
  const [showSettings, setShowSettings] = useState(false);
142
161
  const [needsOnboarding, setNeedsOnboarding] = useState(false);
@@ -488,6 +507,27 @@ export default function Dashboard({ user }: { user: any }) {
488
507
 
489
508
  {/* View mode toggle */}
490
509
  <div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
510
+ {/* Automation — first. Sub-tabs: pipelines / tasks / schedules.
511
+ Jobs is deprecated and hidden from the nav (backend still
512
+ present in case of reversion, but no UI entry point). */}
513
+ <button
514
+ onClick={() => {
515
+ if (!['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)) setViewMode('history');
516
+ }}
517
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
518
+ ['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)
519
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
520
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
521
+ }`}
522
+ >
523
+ Automation
524
+ </button>
525
+ {/* Activity sub-pill — sits next to Automation since its content
526
+ (running pipelines + upcoming schedules + recent runs) is the
527
+ live read-side of Automation. Click anywhere → dropdown with
528
+ 3 sections + a "view" jump to the run. */}
529
+ <Suspense fallback={null}><ActivityPanel /></Suspense>
530
+ <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
491
531
  {/* Workspace */}
492
532
  {(['terminal', 'projects'] as const).map(mode => (
493
533
  <button
@@ -515,27 +555,6 @@ export default function Dashboard({ user }: { user: any }) {
515
555
  Docs
516
556
  </button>
517
557
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
518
- {/* Automation — sub-tabs: tasks / pipelines / schedules.
519
- Jobs is deprecated and hidden from the nav (backend still
520
- present in case of reversion, but no UI entry point). */}
521
- <button
522
- onClick={() => {
523
- if (!['tasks', 'pipelines', 'schedules'].includes(viewMode)) setViewMode('schedules');
524
- }}
525
- className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
526
- ['tasks', 'pipelines', 'schedules'].includes(viewMode)
527
- ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
528
- : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
529
- }`}
530
- >
531
- Automation
532
- </button>
533
- {/* Activity sub-pill — sits next to Automation since its content
534
- (running pipelines + upcoming schedules + recent runs) is the
535
- live read-side of Automation. Click anywhere → dropdown with
536
- 3 sections + a "view" jump to the run. */}
537
- <Suspense fallback={null}><ActivityPanel /></Suspense>
538
- <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
539
558
  {/* Marketplace */}
540
559
  <button
541
560
  onClick={() => setViewMode('skills')}
@@ -911,10 +930,10 @@ export default function Dashboard({ user }: { user: any }) {
911
930
 
912
931
  {/* Automation secondary toolbar — sub-tabs + context actions.
913
932
  Lives below the main header so it never squishes the top nav. */}
914
- {['tasks', 'pipelines', 'schedules'].includes(viewMode) && (
933
+ {['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode) && (
915
934
  <div className="h-8 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-tertiary)]/40">
916
935
  <div className="flex items-center gap-1">
917
- {(['schedules', 'pipelines', 'tasks'] as const).map((m) => (
936
+ {(['history', 'pipelines', 'tasks', 'schedules'] as const).map((m) => (
918
937
  <button
919
938
  key={m}
920
939
  onClick={() => setViewMode(m)}
@@ -924,7 +943,7 @@ export default function Dashboard({ user }: { user: any }) {
924
943
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
925
944
  }`}
926
945
  >
927
- {{ tasks: 'Tasks', pipelines: 'Pipelines', schedules: 'Schedules' }[m]}
946
+ {{ tasks: 'Tasks', pipelines: 'Pipelines', schedules: 'Schedules', history: 'History' }[m]}
928
947
  </button>
929
948
  ))}
930
949
  {viewMode === 'tasks' && (
@@ -1058,6 +1077,16 @@ export default function Dashboard({ user }: { user: any }) {
1058
1077
  </Suspense>
1059
1078
  )}
1060
1079
 
1080
+ {/* History — flat list of all pipeline runs, expandable */}
1081
+ {viewMode === 'history' && (
1082
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
1083
+ <PipelineHistory
1084
+ onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }}
1085
+ onViewPipeline={(pipelineId) => { setPendingPipelineId(pipelineId); setViewMode('pipelines'); }}
1086
+ />
1087
+ </Suspense>
1088
+ )}
1089
+
1061
1090
  {/* Jobs — scheduled connector polls (Forge web read-mostly; create via extension) */}
1062
1091
  {viewMode === 'jobs' && (
1063
1092
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -52,10 +52,12 @@ function InlineLiveLog({ log }: { log: TaskLogEntry[] }) {
52
52
 
53
53
  // ─── DAG node card ────────────────────────────────────────
54
54
 
55
- function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
55
+ function InlineDagNode({ nodeId, node, onViewTask }: { nodeId: string; node: any; onViewTask?: (taskId: string) => void }) {
56
56
  const isRunning = node.status === 'running';
57
57
  const log = useTaskStreamInline(node.taskId, isRunning);
58
58
  const statusIcon = node.status === 'done' ? '✅' : node.status === 'failed' ? '❌' : node.status === 'running' ? '🔄' : node.status === 'skipped' ? '⏭' : '⏳';
59
+ const outputs: Record<string, string> = node.outputs || {};
60
+ const outputKeys = Object.keys(outputs).filter(k => (outputs[k] ?? '').length > 0);
59
61
  return (
60
62
  <div className={`border rounded p-2 ${
61
63
  isRunning ? 'border-yellow-500/40 bg-yellow-500/5' :
@@ -66,18 +68,33 @@ function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
66
68
  <div className="flex items-center gap-1.5 text-[9px]">
67
69
  <span>{statusIcon}</span>
68
70
  <span className="font-semibold text-[var(--text-primary)]">{nodeId}</span>
69
- {node.taskId && <span className="text-[7px] text-[var(--accent)] font-mono">task:{node.taskId}</span>}
71
+ {node.taskId && (
72
+ onViewTask
73
+ ? <button onClick={() => onViewTask(node.taskId)} className="text-[7px] text-[var(--accent)] font-mono hover:underline">task:{node.taskId} ↗</button>
74
+ : <span className="text-[7px] text-[var(--accent)] font-mono">task:{node.taskId}</span>
75
+ )}
70
76
  <span className="text-[var(--text-secondary)] ml-auto">{node.status}</span>
71
77
  </div>
72
78
  {isRunning && <div className="mt-1.5"><InlineLiveLog log={log} /></div>}
73
- {node.error && <div className="text-[8px] text-red-400 mt-1">{node.error}</div>}
79
+ {node.error && <div className="text-[8px] text-red-400 mt-1 whitespace-pre-wrap break-words">{node.error}</div>}
80
+ {outputKeys.map((key) => {
81
+ const isDiff = key === 'diff' || /diff/i.test(key);
82
+ return (
83
+ <details key={key} className="mt-1 text-[8px]">
84
+ <summary className="cursor-pointer text-[var(--accent)]">
85
+ {isDiff ? 'diff' : key === 'result' || key === 'report' || key === 'summary' ? `result: ${key}` : `output: ${key}`} ({outputs[key].length} chars)
86
+ </summary>
87
+ <pre className="mt-1 max-h-[200px] overflow-auto bg-[var(--bg-primary)] rounded p-1.5 whitespace-pre-wrap break-words text-[var(--text-secondary)]">{outputs[key].slice(0, 8000)}{outputs[key].length > 8000 ? '\n…(truncated)' : ''}</pre>
88
+ </details>
89
+ );
90
+ })}
74
91
  </div>
75
92
  );
76
93
  }
77
94
 
78
95
  // ─── Main component ───────────────────────────────────────
79
96
 
80
- export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline: any; onRefresh: () => void }) {
97
+ export default function InlinePipelineView({ pipeline, onRefresh, onViewTask }: { pipeline: any; onRefresh: () => void; onViewTask?: (taskId: string) => void }) {
81
98
  useEffect(() => {
82
99
  if (pipeline.status !== 'running') return;
83
100
  const timer = setInterval(onRefresh, 3000);
@@ -97,7 +114,7 @@ export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline:
97
114
  ) : (
98
115
  <div className="px-3 py-2 space-y-1.5">
99
116
  {pipeline.nodeOrder.map((nodeId: string) => (
100
- <InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} />
117
+ <InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} onViewTask={onViewTask} />
101
118
  ))}
102
119
  </div>
103
120
  )}
@@ -0,0 +1,306 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
+
5
+ const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
6
+
7
+ // Light summary shape returned by GET /api/pipelines (heavy node outputs stripped).
8
+ interface PipelineSummary {
9
+ id: string;
10
+ workflowName: string;
11
+ status: 'running' | 'done' | 'failed' | 'cancelled';
12
+ type?: string;
13
+ createdAt: string;
14
+ completedAt?: string;
15
+ nodeOrder: string[];
16
+ nodes: Record<string, { status: string; iterations?: number }>;
17
+ input?: Record<string, string>;
18
+ }
19
+
20
+ const STATUS_META: Record<string, { icon: string; cls: string }> = {
21
+ running: { icon: '🔄', cls: 'text-yellow-400' },
22
+ done: { icon: '✅', cls: 'text-green-400' },
23
+ failed: { icon: '❌', cls: 'text-red-400' },
24
+ cancelled: { icon: '⏹', cls: 'text-[var(--text-secondary)]' },
25
+ };
26
+
27
+ function relTime(iso?: string): string {
28
+ if (!iso) return '';
29
+ const t = new Date(iso).getTime();
30
+ if (Number.isNaN(t)) return '';
31
+ const s = Math.floor((Date.now() - t) / 1000);
32
+ if (s < 60) return `${s}s ago`;
33
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
34
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
35
+ return `${Math.floor(s / 86400)}d ago`;
36
+ }
37
+
38
+ /** A compact one-liner of the most useful input fields. */
39
+ function inputSummary(input?: Record<string, string>): string {
40
+ if (!input) return '';
41
+ const keys = ['bug_id', 'mr_url', 'mr_iid', 'project', 'issue_id', 'base_branch'];
42
+ const parts: string[] = [];
43
+ for (const k of keys) {
44
+ if (input[k]) parts.push(`${k}=${String(input[k]).slice(0, 40)}`);
45
+ }
46
+ return parts.join(' · ');
47
+ }
48
+
49
+ export default function PipelineHistory({ onViewTask, onViewPipeline }: {
50
+ onViewTask: (taskId: string) => void;
51
+ onViewPipeline: (pipelineId: string) => void;
52
+ }) {
53
+ const PAGE = 20;
54
+ const [list, setList] = useState<PipelineSummary[]>([]);
55
+ const [loading, setLoading] = useState(true);
56
+ const [hasMore, setHasMore] = useState(false);
57
+ const [loadingMore, setLoadingMore] = useState(false);
58
+ const [expandedId, setExpandedId] = useState<string | null>(null);
59
+ const [fullById, setFullById] = useState<Record<string, any>>({});
60
+ const [statusFilter, setStatusFilter] = useState<'all' | 'running' | 'done' | 'failed'>('all');
61
+ const [search, setSearch] = useState('');
62
+ const [showCleanup, setShowCleanup] = useState(false);
63
+ const expandedRef = useRef(expandedId);
64
+ expandedRef.current = expandedId;
65
+
66
+ // Merge a fetched page into the list by id (fresh copy wins), newest-first.
67
+ // Lets the 4s poll refresh running runs without dropping older loaded pages.
68
+ const mergeIn = useCallback((incoming: PipelineSummary[]) => {
69
+ setList(prev => {
70
+ const byId = new Map(prev.map(p => [p.id, p]));
71
+ for (const p of incoming) byId.set(p.id, p);
72
+ return [...byId.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
73
+ });
74
+ }, []);
75
+
76
+ // First page (newest 20). Also the 4s poll target — keeps running runs fresh.
77
+ const fetchFirst = useCallback(async () => {
78
+ try {
79
+ const res = await fetch(`/api/pipelines?limit=${PAGE}`);
80
+ if (res.ok) {
81
+ const page: PipelineSummary[] = await res.json();
82
+ mergeIn(page);
83
+ setHasMore(page.length === PAGE);
84
+ }
85
+ } catch { /* keep prior list */ }
86
+ finally { setLoading(false); }
87
+ }, [mergeIn]);
88
+
89
+ // Cursor pagination: fetch the next 20 older than the oldest loaded run.
90
+ const loadMore = useCallback(async () => {
91
+ setLoadingMore(true);
92
+ try {
93
+ const oldest = list.length ? list[list.length - 1].createdAt : undefined;
94
+ const res = await fetch(`/api/pipelines?limit=${PAGE}${oldest ? `&before=${encodeURIComponent(oldest)}` : ''}`);
95
+ if (res.ok) {
96
+ const page: PipelineSummary[] = await res.json();
97
+ mergeIn(page);
98
+ setHasMore(page.length === PAGE);
99
+ }
100
+ } catch { /* ignore */ }
101
+ finally { setLoadingMore(false); }
102
+ }, [list, mergeIn]);
103
+
104
+ const fetchFull = useCallback(async (id: string) => {
105
+ try {
106
+ const res = await fetch(`/api/pipelines/${id}`);
107
+ if (res.ok) {
108
+ const full = await res.json();
109
+ setFullById(prev => ({ ...prev, [id]: full }));
110
+ }
111
+ } catch { /* ignore */ }
112
+ }, []);
113
+
114
+ // Initial load + poll every 4s so running pipelines update live.
115
+ useEffect(() => {
116
+ fetchFirst();
117
+ const t = setInterval(() => {
118
+ fetchFirst();
119
+ const id = expandedRef.current;
120
+ if (id) fetchFull(id); // keep the open run fresh while it runs
121
+ }, 4000);
122
+ return () => clearInterval(t);
123
+ }, [fetchFirst, fetchFull]);
124
+
125
+ const toggle = (id: string) => {
126
+ if (expandedId === id) { setExpandedId(null); return; }
127
+ setExpandedId(id);
128
+ if (!fullById[id]) fetchFull(id);
129
+ };
130
+
131
+ const op = async (id: string, action: 'cancel' | 'delete') => {
132
+ if (action === 'delete' && !confirm('Delete this pipeline run? This cannot be undone.')) return;
133
+ try {
134
+ await fetch(`/api/pipelines/${id}`, {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body: JSON.stringify({ action }),
138
+ });
139
+ if (action === 'delete') { setExpandedId(null); setList(prev => prev.filter(p => p.id !== id)); }
140
+ fetchFirst();
141
+ if (action === 'cancel') fetchFull(id);
142
+ } catch { /* ignore */ }
143
+ };
144
+
145
+ // Unified cleanup — bulk-delete terminal (done/failed/cancelled) runs.
146
+ // Running/pending runs are always skipped server-side.
147
+ const cleanup = async (olderThanDays: number, label: string) => {
148
+ if (!confirm(`Delete ${label}? Running pipelines are kept. This cannot be undone.`)) return;
149
+ setShowCleanup(false);
150
+ try {
151
+ const res = await fetch('/api/pipelines/bulk-delete', {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({ older_than_days: olderThanDays }),
155
+ });
156
+ const data = res.ok ? await res.json() : null;
157
+ // Many rows gone — rebuild the list from scratch.
158
+ setExpandedId(null);
159
+ setList([]);
160
+ setLoading(true);
161
+ await fetchFirst();
162
+ if (data) alert(`Cleaned up ${data.removed} pipeline run${data.removed === 1 ? '' : 's'}.`);
163
+ } catch { /* ignore */ }
164
+ };
165
+
166
+ const filtered = list.filter(p => {
167
+ if (statusFilter !== 'all' && p.status !== statusFilter) return false;
168
+ if (search) {
169
+ const hay = `${p.workflowName} ${inputSummary(p.input)} ${p.id}`.toLowerCase();
170
+ if (!hay.includes(search.toLowerCase())) return false;
171
+ }
172
+ return true;
173
+ });
174
+
175
+ const runningCount = list.filter(p => p.status === 'running').length;
176
+
177
+ return (
178
+ <div className="flex-1 min-h-0 flex flex-col">
179
+ {/* Filter bar */}
180
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border)] shrink-0">
181
+ <div className="flex items-center gap-1">
182
+ {(['all', 'running', 'done', 'failed'] as const).map(s => (
183
+ <button
184
+ key={s}
185
+ onClick={() => setStatusFilter(s)}
186
+ className={`text-[11px] px-2 py-0.5 rounded ${
187
+ statusFilter === s
188
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)]'
189
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
190
+ }`}
191
+ >
192
+ {s === 'all' ? 'All' : s[0].toUpperCase() + s.slice(1)}
193
+ {s === 'running' && runningCount > 0 ? ` (${runningCount})` : ''}
194
+ </button>
195
+ ))}
196
+ </div>
197
+ <input
198
+ value={search}
199
+ onChange={e => setSearch(e.target.value)}
200
+ placeholder="Filter by workflow / bug_id / id…"
201
+ className="text-[11px] px-2 py-0.5 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] text-[var(--text-primary)] flex-1 max-w-[320px] outline-none focus:border-[var(--accent)]"
202
+ />
203
+ <span className="text-[10px] text-[var(--text-secondary)] ml-auto">{filtered.length} run{filtered.length === 1 ? '' : 's'}</span>
204
+ {/* Unified cleanup */}
205
+ <div className="relative">
206
+ <button
207
+ onClick={() => setShowCleanup(v => !v)}
208
+ className="text-[11px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border)]"
209
+ title="Bulk-delete finished pipeline runs"
210
+ >
211
+ Cleanup ▾
212
+ </button>
213
+ {showCleanup && (
214
+ <>
215
+ <div className="fixed inset-0 z-10" onClick={() => setShowCleanup(false)} />
216
+ <div className="absolute right-0 mt-1 z-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg py-1 w-56 text-[11px]">
217
+ <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase tracking-wide">Delete finished runs</div>
218
+ <button onClick={() => cleanup(30, 'finished runs older than 30 days')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]">Older than 30 days</button>
219
+ <button onClick={() => cleanup(7, 'finished runs older than 7 days')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]">Older than 7 days</button>
220
+ <button onClick={() => cleanup(0, 'ALL finished runs')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-red-400">All finished (done/failed/cancelled)</button>
221
+ <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] border-t border-[var(--border)] mt-1">Running pipelines are always kept.</div>
222
+ </div>
223
+ </>
224
+ )}
225
+ </div>
226
+ <button onClick={fetchFirst} className="text-[11px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]" title="Refresh">↻</button>
227
+ </div>
228
+
229
+ {/* List */}
230
+ <div className="flex-1 min-h-0 overflow-y-auto">
231
+ {loading && list.length === 0 ? (
232
+ <div className="flex items-center justify-center h-32 text-[var(--text-secondary)] text-[12px]">Loading…</div>
233
+ ) : filtered.length === 0 ? (
234
+ <div className="flex items-center justify-center h-32 text-[var(--text-secondary)] text-[12px]">No pipeline runs.</div>
235
+ ) : (
236
+ <div className="divide-y divide-[var(--border)]">
237
+ {filtered.map(p => {
238
+ const meta = STATUS_META[p.status] || STATUS_META.cancelled;
239
+ const total = p.nodeOrder.length;
240
+ const done = Object.values(p.nodes).filter(n => n.status === 'done').length;
241
+ const isOpen = expandedId === p.id;
242
+ const full = fullById[p.id];
243
+ return (
244
+ <div key={p.id}>
245
+ {/* Row header */}
246
+ <div
247
+ onClick={() => toggle(p.id)}
248
+ className="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-[var(--bg-tertiary)]/40"
249
+ >
250
+ <span className={`text-[11px] w-4 text-center transition-transform ${isOpen ? 'rotate-90' : ''}`}>▸</span>
251
+ <span className="text-[12px]">{meta.icon}</span>
252
+ <span className="text-[12px] font-medium text-[var(--text-primary)] truncate max-w-[220px]">{p.workflowName}</span>
253
+ <span className={`text-[10px] ${meta.cls}`}>{p.status}</span>
254
+ <span className="text-[10px] text-[var(--text-secondary)]">{done}/{total} steps</span>
255
+ {inputSummary(p.input) && (
256
+ <span className="text-[10px] text-[var(--text-secondary)] truncate max-w-[280px] font-mono">{inputSummary(p.input)}</span>
257
+ )}
258
+ <span className="text-[10px] text-[var(--text-secondary)] ml-auto whitespace-nowrap">{relTime(p.createdAt)}</span>
259
+ </div>
260
+
261
+ {/* Expanded detail */}
262
+ {isOpen && (
263
+ <div className="bg-[var(--bg-tertiary)]/30 border-t border-[var(--border)]">
264
+ {/* Action bar */}
265
+ <div className="flex items-center gap-2 px-4 py-1.5 text-[11px]">
266
+ <button
267
+ onClick={() => onViewPipeline(p.id)}
268
+ className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25"
269
+ >
270
+ Open in Pipelines ↗
271
+ </button>
272
+ {p.status === 'running' && (
273
+ <button onClick={() => op(p.id, 'cancel')} className="px-2 py-0.5 rounded text-yellow-400 hover:bg-yellow-400/10">Cancel</button>
274
+ )}
275
+ <button onClick={() => op(p.id, 'delete')} className="px-2 py-0.5 rounded text-red-400 hover:bg-red-400/10">Delete</button>
276
+ <span className="text-[10px] text-[var(--text-secondary)] ml-auto font-mono">id:{p.id}</span>
277
+ </div>
278
+ {full ? (
279
+ <Suspense fallback={<div className="px-4 py-2 text-[10px] text-[var(--text-secondary)]">Loading detail…</div>}>
280
+ <InlinePipelineView pipeline={full} onRefresh={() => fetchFull(p.id)} onViewTask={onViewTask} />
281
+ </Suspense>
282
+ ) : (
283
+ <div className="px-4 py-3 text-[10px] text-[var(--text-secondary)]">Loading detail…</div>
284
+ )}
285
+ </div>
286
+ )}
287
+ </div>
288
+ );
289
+ })}
290
+ </div>
291
+ )}
292
+ {hasMore && !(loading && list.length === 0) && (
293
+ <div className="flex justify-center py-3">
294
+ <button
295
+ onClick={loadMore}
296
+ disabled={loadingMore}
297
+ className="text-[11px] px-3 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
298
+ >
299
+ {loadingMore ? 'Loading…' : 'Load more'}
300
+ </button>
301
+ </div>
302
+ )}
303
+ </div>
304
+ </div>
305
+ );
306
+ }
@@ -130,7 +130,16 @@ async function runHttpProbe(
130
130
  const t0 = Date.now();
131
131
  let res: Response;
132
132
  try {
133
- res = await fetch(url, { method, headers, body, signal: ctrl.signal });
133
+ // Honour connector-level http.verify_tls self-signed appliances (Jenkins,
134
+ // NAC, ESXi …) need undici with rejectUnauthorized:false, same as http.ts.
135
+ const fetchInit = { method, headers, body, signal: ctrl.signal };
136
+ if (def.http?.verify_tls === false) {
137
+ const { fetch: undiciFetch, Agent } = await import('undici');
138
+ const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
139
+ res = await undiciFetch(url, { ...fetchInit, dispatcher } as any) as unknown as Response;
140
+ } else {
141
+ res = await fetch(url, fetchInit);
142
+ }
134
143
  } catch (e) {
135
144
  clearTimeout(timer);
136
145
  const err = e as Error & { cause?: unknown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.80",
3
+ "version": "0.10.82",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {