@aion0/forge 0.2.2 โ†’ 0.2.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.
@@ -0,0 +1,435 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
4
+
5
+ const PipelineEditor = lazy(() => import('./PipelineEditor'));
6
+
7
+ interface WorkflowNode {
8
+ id: string;
9
+ project: string;
10
+ prompt: string;
11
+ dependsOn: string[];
12
+ outputs: { name: string; extract: string }[];
13
+ routes: { condition: string; next: string }[];
14
+ maxIterations: number;
15
+ }
16
+
17
+ interface Workflow {
18
+ name: string;
19
+ description?: string;
20
+ vars: Record<string, string>;
21
+ input: Record<string, string>;
22
+ nodes: Record<string, WorkflowNode>;
23
+ }
24
+
25
+ interface PipelineNodeState {
26
+ status: 'pending' | 'running' | 'done' | 'failed' | 'skipped';
27
+ taskId?: string;
28
+ outputs: Record<string, string>;
29
+ iterations: number;
30
+ startedAt?: string;
31
+ completedAt?: string;
32
+ error?: string;
33
+ }
34
+
35
+ interface Pipeline {
36
+ id: string;
37
+ workflowName: string;
38
+ status: 'running' | 'done' | 'failed' | 'cancelled';
39
+ input: Record<string, string>;
40
+ vars: Record<string, string>;
41
+ nodes: Record<string, PipelineNodeState>;
42
+ nodeOrder: string[];
43
+ createdAt: string;
44
+ completedAt?: string;
45
+ }
46
+
47
+ const STATUS_ICON: Record<string, string> = {
48
+ pending: 'โณ',
49
+ running: '๐Ÿ”„',
50
+ done: 'โœ…',
51
+ failed: 'โŒ',
52
+ skipped: 'โญ',
53
+ };
54
+
55
+ const STATUS_COLOR: Record<string, string> = {
56
+ pending: 'text-gray-400',
57
+ running: 'text-yellow-400',
58
+ done: 'text-green-400',
59
+ failed: 'text-red-400',
60
+ skipped: 'text-gray-500',
61
+ };
62
+
63
+ export default function PipelineView() {
64
+ const [pipelines, setPipelines] = useState<Pipeline[]>([]);
65
+ const [workflows, setWorkflows] = useState<Workflow[]>([]);
66
+ const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
67
+ const [showCreate, setShowCreate] = useState(false);
68
+ const [selectedWorkflow, setSelectedWorkflow] = useState<string>('');
69
+ const [inputValues, setInputValues] = useState<Record<string, string>>({});
70
+ const [creating, setCreating] = useState(false);
71
+ const [showEditor, setShowEditor] = useState(false);
72
+
73
+ const fetchData = useCallback(async () => {
74
+ const [pRes, wRes] = await Promise.all([
75
+ fetch('/api/pipelines'),
76
+ fetch('/api/pipelines?type=workflows'),
77
+ ]);
78
+ const pData = await pRes.json();
79
+ const wData = await wRes.json();
80
+ if (Array.isArray(pData)) setPipelines(pData);
81
+ if (Array.isArray(wData)) setWorkflows(wData);
82
+ }, []);
83
+
84
+ useEffect(() => {
85
+ fetchData();
86
+ const timer = setInterval(fetchData, 5000);
87
+ return () => clearInterval(timer);
88
+ }, [fetchData]);
89
+
90
+ // Refresh selected pipeline
91
+ useEffect(() => {
92
+ if (!selectedPipeline || selectedPipeline.status !== 'running') return;
93
+ const timer = setInterval(async () => {
94
+ const res = await fetch(`/api/pipelines/${selectedPipeline.id}`);
95
+ const data = await res.json();
96
+ if (data.id) setSelectedPipeline(data);
97
+ }, 3000);
98
+ return () => clearInterval(timer);
99
+ }, [selectedPipeline?.id, selectedPipeline?.status]);
100
+
101
+ const handleCreate = async () => {
102
+ if (!selectedWorkflow) return;
103
+ setCreating(true);
104
+ try {
105
+ const res = await fetch('/api/pipelines', {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ workflow: selectedWorkflow, input: inputValues }),
109
+ });
110
+ const data = await res.json();
111
+ if (data.id) {
112
+ setSelectedPipeline(data);
113
+ setShowCreate(false);
114
+ setInputValues({});
115
+ fetchData();
116
+ }
117
+ } catch {}
118
+ setCreating(false);
119
+ };
120
+
121
+ const handleCancel = async (id: string) => {
122
+ await fetch(`/api/pipelines/${id}`, {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ action: 'cancel' }),
126
+ });
127
+ fetchData();
128
+ if (selectedPipeline?.id === id) {
129
+ const res = await fetch(`/api/pipelines/${id}`);
130
+ setSelectedPipeline(await res.json());
131
+ }
132
+ };
133
+
134
+ const handleDelete = async (id: string) => {
135
+ if (!confirm('Delete this pipeline?')) return;
136
+ await fetch(`/api/pipelines/${id}`, {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ action: 'delete' }),
140
+ });
141
+ if (selectedPipeline?.id === id) setSelectedPipeline(null);
142
+ fetchData();
143
+ };
144
+
145
+ const currentWorkflow = workflows.find(w => w.name === selectedWorkflow);
146
+
147
+ return (
148
+ <div className="flex-1 flex min-h-0">
149
+ {/* Left โ€” Pipeline list */}
150
+ <aside className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
151
+ <div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
152
+ <span className="text-[11px] font-semibold text-[var(--text-primary)]">Pipelines</span>
153
+ <button
154
+ onClick={() => setShowEditor(true)}
155
+ className="text-[10px] px-2 py-0.5 rounded text-green-400 hover:bg-green-400/10"
156
+ >
157
+ Editor
158
+ </button>
159
+ <button
160
+ onClick={() => setShowCreate(v => !v)}
161
+ className={`text-[10px] px-2 py-0.5 rounded ${showCreate ? 'text-white bg-[var(--accent)]' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
162
+ >
163
+ + Run
164
+ </button>
165
+ </div>
166
+
167
+ {/* Create form */}
168
+ {showCreate && (
169
+ <div className="p-3 border-b border-[var(--border)] space-y-2">
170
+ <select
171
+ value={selectedWorkflow}
172
+ onChange={e => { setSelectedWorkflow(e.target.value); setInputValues({}); }}
173
+ className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)]"
174
+ >
175
+ <option value="">Select workflow...</option>
176
+ {workflows.map(w => (
177
+ <option key={w.name} value={w.name}>{w.name}{w.description ? ` โ€” ${w.description}` : ''}</option>
178
+ ))}
179
+ </select>
180
+
181
+ {/* Input fields */}
182
+ {currentWorkflow && Object.keys(currentWorkflow.input).length > 0 && (
183
+ <div className="space-y-1.5">
184
+ {Object.entries(currentWorkflow.input).map(([key, desc]) => (
185
+ <div key={key}>
186
+ <label className="text-[9px] text-[var(--text-secondary)]">{key}: {desc}</label>
187
+ <input
188
+ value={inputValues[key] || ''}
189
+ onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
190
+ className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
191
+ />
192
+ </div>
193
+ ))}
194
+ </div>
195
+ )}
196
+
197
+ {/* Workflow preview */}
198
+ {currentWorkflow && (
199
+ <div className="text-[9px] text-[var(--text-secondary)] space-y-0.5">
200
+ {Object.entries(currentWorkflow.nodes).map(([id, node]) => (
201
+ <div key={id} className="flex items-center gap-1">
202
+ <span className="text-[var(--accent)]">{id}</span>
203
+ {node.dependsOn.length > 0 && <span>โ† {node.dependsOn.join(', ')}</span>}
204
+ <span className="text-[var(--text-secondary)] truncate ml-auto">{node.project}</span>
205
+ </div>
206
+ ))}
207
+ </div>
208
+ )}
209
+
210
+ <button
211
+ onClick={handleCreate}
212
+ disabled={!selectedWorkflow || creating}
213
+ className="w-full text-[10px] px-2 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
214
+ >
215
+ {creating ? 'Starting...' : 'Start Pipeline'}
216
+ </button>
217
+
218
+ {workflows.length === 0 && (
219
+ <p className="text-[9px] text-[var(--text-secondary)]">
220
+ No workflows found. Create YAML files in ~/.forge/flows/
221
+ </p>
222
+ )}
223
+ </div>
224
+ )}
225
+
226
+ {/* Pipeline list */}
227
+ <div className="flex-1 overflow-y-auto">
228
+ {pipelines.map(p => (
229
+ <button
230
+ key={p.id}
231
+ onClick={() => setSelectedPipeline(p)}
232
+ className={`w-full text-left px-3 py-2 border-b border-[var(--border)]/30 hover:bg-[var(--bg-tertiary)] ${
233
+ selectedPipeline?.id === p.id ? 'bg-[var(--bg-tertiary)]' : ''
234
+ }`}
235
+ >
236
+ <div className="flex items-center gap-2">
237
+ <span className={`text-[10px] ${STATUS_COLOR[p.status]}`}>โ—</span>
238
+ <span className="text-xs font-medium truncate">{p.workflowName}</span>
239
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{p.id}</span>
240
+ </div>
241
+ <div className="flex items-center gap-1 mt-0.5 pl-4">
242
+ {p.nodeOrder.map(nodeId => (
243
+ <span key={nodeId} className={`text-[9px] ${STATUS_COLOR[p.nodes[nodeId]?.status || 'pending']}`}>
244
+ {STATUS_ICON[p.nodes[nodeId]?.status || 'pending']}
245
+ </span>
246
+ ))}
247
+ <span className="text-[8px] text-[var(--text-secondary)] ml-auto">
248
+ {new Date(p.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
249
+ </span>
250
+ </div>
251
+ </button>
252
+ ))}
253
+ {pipelines.length === 0 && (
254
+ <div className="p-4 text-center text-xs text-[var(--text-secondary)]">
255
+ No pipelines yet
256
+ </div>
257
+ )}
258
+ </div>
259
+ </aside>
260
+
261
+ {/* Right โ€” Pipeline detail */}
262
+ <main className="flex-1 flex flex-col min-w-0 overflow-y-auto">
263
+ {selectedPipeline ? (
264
+ <>
265
+ {/* Header */}
266
+ <div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
267
+ <div className="flex items-center gap-2">
268
+ <span className={`text-sm ${STATUS_COLOR[selectedPipeline.status]}`}>
269
+ {STATUS_ICON[selectedPipeline.status]}
270
+ </span>
271
+ <span className="text-sm font-semibold text-[var(--text-primary)]">{selectedPipeline.workflowName}</span>
272
+ <span className="text-[10px] text-[var(--text-secondary)] font-mono">{selectedPipeline.id}</span>
273
+ <div className="flex items-center gap-2 ml-auto">
274
+ {selectedPipeline.status === 'running' && (
275
+ <button
276
+ onClick={() => handleCancel(selectedPipeline.id)}
277
+ className="text-[10px] px-2 py-0.5 text-red-400 border border-red-400/30 rounded hover:bg-red-400 hover:text-white"
278
+ >
279
+ Cancel
280
+ </button>
281
+ )}
282
+ <button
283
+ onClick={() => handleDelete(selectedPipeline.id)}
284
+ className="text-[10px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-red-400"
285
+ >
286
+ Delete
287
+ </button>
288
+ </div>
289
+ </div>
290
+ <div className="text-[9px] text-[var(--text-secondary)] mt-1">
291
+ Started: {new Date(selectedPipeline.createdAt).toLocaleString()}
292
+ {selectedPipeline.completedAt && ` ยท Completed: ${new Date(selectedPipeline.completedAt).toLocaleString()}`}
293
+ </div>
294
+ {Object.keys(selectedPipeline.input).length > 0 && (
295
+ <div className="text-[9px] text-[var(--text-secondary)] mt-1">
296
+ Input: {Object.entries(selectedPipeline.input).map(([k, v]) => `${k}="${v}"`).join(', ')}
297
+ </div>
298
+ )}
299
+ </div>
300
+
301
+ {/* DAG visualization */}
302
+ <div className="p-4 space-y-2">
303
+ {selectedPipeline.nodeOrder.map((nodeId, idx) => {
304
+ const node = selectedPipeline.nodes[nodeId];
305
+ return (
306
+ <div key={nodeId}>
307
+ {/* Connection line */}
308
+ {idx > 0 && (
309
+ <div className="flex items-center pl-5 py-1">
310
+ <div className="w-px h-4 bg-[var(--border)]" />
311
+ </div>
312
+ )}
313
+
314
+ {/* Node card */}
315
+ <div className={`border rounded-lg p-3 ${
316
+ node.status === 'running' ? 'border-yellow-500/50 bg-yellow-500/5' :
317
+ node.status === 'done' ? 'border-green-500/30 bg-green-500/5' :
318
+ node.status === 'failed' ? 'border-red-500/30 bg-red-500/5' :
319
+ 'border-[var(--border)]'
320
+ }`}>
321
+ <div className="flex items-center gap-2">
322
+ <span className={STATUS_COLOR[node.status]}>{STATUS_ICON[node.status]}</span>
323
+ <span className="text-xs font-semibold text-[var(--text-primary)]">{nodeId}</span>
324
+ {node.taskId && (
325
+ <span className="text-[9px] text-[var(--text-secondary)] font-mono">task:{node.taskId}</span>
326
+ )}
327
+ {node.iterations > 1 && (
328
+ <span className="text-[9px] text-yellow-400">iter {node.iterations}</span>
329
+ )}
330
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.status}</span>
331
+ </div>
332
+
333
+ {node.error && (
334
+ <div className="text-[10px] text-red-400 mt-1">{node.error}</div>
335
+ )}
336
+
337
+ {/* Outputs */}
338
+ {Object.keys(node.outputs).length > 0 && (
339
+ <div className="mt-2 space-y-1">
340
+ {Object.entries(node.outputs).map(([key, val]) => (
341
+ <details key={key} className="text-[10px]">
342
+ <summary className="cursor-pointer text-[var(--accent)]">
343
+ output: {key} ({val.length} chars)
344
+ </summary>
345
+ <pre className="mt-1 p-2 bg-[var(--bg-tertiary)] rounded text-[9px] text-[var(--text-secondary)] max-h-32 overflow-auto whitespace-pre-wrap">
346
+ {val.slice(0, 1000)}{val.length > 1000 ? '...' : ''}
347
+ </pre>
348
+ </details>
349
+ ))}
350
+ </div>
351
+ )}
352
+
353
+ {/* Timing */}
354
+ {node.startedAt && (
355
+ <div className="text-[8px] text-[var(--text-secondary)] mt-1">
356
+ {node.startedAt && `Started: ${new Date(node.startedAt).toLocaleTimeString()}`}
357
+ {node.completedAt && ` ยท Done: ${new Date(node.completedAt).toLocaleTimeString()}`}
358
+ </div>
359
+ )}
360
+ </div>
361
+ </div>
362
+ );
363
+ })}
364
+ </div>
365
+ </>
366
+ ) : (
367
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
368
+ <div className="text-center space-y-2">
369
+ <p className="text-sm">Select a pipeline or create a new one</p>
370
+ <p className="text-xs">Define workflows in <code className="text-[var(--accent)]">~/.forge/flows/*.yaml</code></p>
371
+ <details className="text-left text-[10px] mt-4 max-w-md">
372
+ <summary className="cursor-pointer text-[var(--accent)]">Example workflow YAML</summary>
373
+ <pre className="mt-2 p-3 bg-[var(--bg-tertiary)] rounded overflow-auto whitespace-pre text-[var(--text-secondary)]">{`name: feature-build
374
+ description: "Design โ†’ Implement โ†’ Review"
375
+
376
+ input:
377
+ requirement: "Feature description"
378
+
379
+ vars:
380
+ project: my-app
381
+
382
+ nodes:
383
+ architect:
384
+ project: "{{vars.project}}"
385
+ prompt: |
386
+ Analyze this requirement and create
387
+ a technical design document:
388
+ {{input.requirement}}
389
+ outputs:
390
+ - name: design_doc
391
+ extract: result
392
+
393
+ implement:
394
+ project: "{{vars.project}}"
395
+ depends_on: [architect]
396
+ prompt: |
397
+ Implement this design:
398
+ {{nodes.architect.outputs.design_doc}}
399
+ outputs:
400
+ - name: diff
401
+ extract: git_diff
402
+
403
+ review:
404
+ project: "{{vars.project}}"
405
+ depends_on: [implement]
406
+ prompt: |
407
+ Review this code change:
408
+ {{nodes.implement.outputs.diff}}`}</pre>
409
+ </details>
410
+ </div>
411
+ </div>
412
+ )}
413
+ </main>
414
+
415
+ {/* Visual Editor */}
416
+ {showEditor && (
417
+ <Suspense fallback={null}>
418
+ <PipelineEditor
419
+ onSave={async (yaml) => {
420
+ // Save YAML to ~/.forge/flows/
421
+ await fetch('/api/pipelines', {
422
+ method: 'POST',
423
+ headers: { 'Content-Type': 'application/json' },
424
+ body: JSON.stringify({ action: 'save-workflow', yaml }),
425
+ });
426
+ setShowEditor(false);
427
+ fetchData();
428
+ }}
429
+ onClose={() => setShowEditor(false)}
430
+ />
431
+ </Suspense>
432
+ )}
433
+ </div>
434
+ );
435
+ }