@aion0/forge 0.4.3 → 0.4.4
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 +21 -4
- package/app/api/issue-scanner/route.ts +2 -2
- package/app/api/pipelines/route.ts +14 -0
- package/app/api/project-pipelines/route.ts +68 -0
- package/components/Dashboard.tsx +10 -12
- package/components/PipelineEditor.tsx +3 -1
- package/components/PipelineView.tsx +253 -128
- package/components/ProjectDetail.tsx +163 -230
- package/lib/help-docs/05-pipelines.md +22 -7
- package/lib/help-docs/09-issue-autofix.md +1 -1
- package/lib/init.ts +7 -1
- package/lib/issue-scanner.ts +2 -2
- package/lib/pipeline-scheduler.ts +239 -0
- package/lib/pipeline.ts +43 -87
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/src/core/db/database.ts +24 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
|
-
# Forge v0.4.
|
|
1
|
+
# Forge v0.4.4
|
|
2
2
|
|
|
3
3
|
Released: 2026-03-21
|
|
4
4
|
|
|
5
|
-
## Changes since v0.4.
|
|
5
|
+
## Changes since v0.4.3
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
- feat: add scheduled pipeline execution for project bindings
|
|
9
|
+
- feat: pipeline import/delete + editor cancel confirmation
|
|
6
10
|
|
|
7
11
|
### Bug Fixes
|
|
8
|
-
- fix:
|
|
12
|
+
- fix: embed editor in right panel, button nesting, fetchData error handling
|
|
13
|
+
|
|
14
|
+
### Refactoring
|
|
15
|
+
- refactor: generic project-pipeline bindings replace hardcoded issue scanner
|
|
16
|
+
- refactor: pipeline UI — workflow list in sidebar, project dropdown, per-workflow history
|
|
17
|
+
- refactor: merge issue-auto-fix + pr-review into single issue-fix-and-review pipeline
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
- feat: pipeline import/delete + editor cancel confirmation
|
|
21
|
+
|
|
22
|
+
### Other
|
|
23
|
+
- fix issue
|
|
24
|
+
- fix issue
|
|
25
|
+
- fix issues for no any issue on github
|
|
9
26
|
|
|
10
27
|
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.
|
|
28
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.3...v0.4.4
|
|
@@ -61,7 +61,7 @@ export async function POST(req: Request) {
|
|
|
61
61
|
const config = getConfig(body.projectPath);
|
|
62
62
|
const projectName = config?.projectName || body.projectName;
|
|
63
63
|
try {
|
|
64
|
-
const pipeline = startPipeline('issue-
|
|
64
|
+
const pipeline = startPipeline('issue-fix-and-review', {
|
|
65
65
|
issue_id: String(body.issueId),
|
|
66
66
|
project: projectName,
|
|
67
67
|
base_branch: config?.baseBranch || body.baseBranch || 'auto-detect',
|
|
@@ -93,7 +93,7 @@ export async function POST(req: Request) {
|
|
|
93
93
|
// Reset the processed record first, then re-create with new pipeline
|
|
94
94
|
resetProcessedIssue(body.projectPath, body.issueId);
|
|
95
95
|
try {
|
|
96
|
-
const pipeline = startPipeline('issue-
|
|
96
|
+
const pipeline = startPipeline('issue-fix-and-review', {
|
|
97
97
|
issue_id: String(body.issueId),
|
|
98
98
|
project: projectName,
|
|
99
99
|
base_branch: config?.baseBranch || 'auto-detect',
|
|
@@ -61,6 +61,20 @@ export async function POST(req: Request) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// Delete workflow
|
|
65
|
+
if (body.action === 'delete-workflow' && body.name) {
|
|
66
|
+
const { existsSync: ex, unlinkSync: ul } = await import('node:fs');
|
|
67
|
+
const filePath = join(FLOWS_DIR, `${body.name}.yaml`);
|
|
68
|
+
const altPath = join(FLOWS_DIR, `${body.name}.yml`);
|
|
69
|
+
const path = ex(filePath) ? filePath : ex(altPath) ? altPath : null;
|
|
70
|
+
if (!path) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
71
|
+
// Check if built-in
|
|
72
|
+
const w = listWorkflows().find(w => w.name === body.name);
|
|
73
|
+
if (w?.builtin) return NextResponse.json({ error: 'Cannot delete built-in workflow' }, { status: 400 });
|
|
74
|
+
ul(path);
|
|
75
|
+
return NextResponse.json({ ok: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
// Start pipeline
|
|
65
79
|
const { workflow, input } = body;
|
|
66
80
|
if (!workflow) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
getBindings,
|
|
4
|
+
addBinding,
|
|
5
|
+
removeBinding,
|
|
6
|
+
updateBinding,
|
|
7
|
+
getRuns,
|
|
8
|
+
deleteRun,
|
|
9
|
+
triggerPipeline,
|
|
10
|
+
getNextRunTime,
|
|
11
|
+
} from '@/lib/pipeline-scheduler';
|
|
12
|
+
import { listWorkflows } from '@/lib/pipeline';
|
|
13
|
+
|
|
14
|
+
// GET /api/project-pipelines?project=PATH
|
|
15
|
+
export async function GET(req: Request) {
|
|
16
|
+
const { searchParams } = new URL(req.url);
|
|
17
|
+
const projectPath = searchParams.get('project');
|
|
18
|
+
if (!projectPath) return NextResponse.json({ error: 'project required' }, { status: 400 });
|
|
19
|
+
|
|
20
|
+
const bindings = getBindings(projectPath).map(b => ({
|
|
21
|
+
...b,
|
|
22
|
+
nextRunAt: getNextRunTime(b),
|
|
23
|
+
}));
|
|
24
|
+
const runs = getRuns(projectPath);
|
|
25
|
+
const workflows = listWorkflows().map(w => ({ name: w.name, description: w.description, builtin: w.builtin }));
|
|
26
|
+
|
|
27
|
+
return NextResponse.json({ bindings, runs, workflows });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// POST /api/project-pipelines
|
|
31
|
+
export async function POST(req: Request) {
|
|
32
|
+
const body = await req.json();
|
|
33
|
+
|
|
34
|
+
if (body.action === 'add') {
|
|
35
|
+
const { projectPath, projectName, workflowName, config } = body;
|
|
36
|
+
if (!projectPath || !workflowName) return NextResponse.json({ error: 'projectPath and workflowName required' }, { status: 400 });
|
|
37
|
+
addBinding(projectPath, projectName || projectPath.split('/').pop(), workflowName, config);
|
|
38
|
+
return NextResponse.json({ ok: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (body.action === 'remove') {
|
|
42
|
+
removeBinding(body.projectPath, body.workflowName);
|
|
43
|
+
return NextResponse.json({ ok: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (body.action === 'update') {
|
|
47
|
+
updateBinding(body.projectPath, body.workflowName, { enabled: body.enabled, config: body.config });
|
|
48
|
+
return NextResponse.json({ ok: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (body.action === 'trigger') {
|
|
52
|
+
const { projectPath, projectName, workflowName, input } = body;
|
|
53
|
+
if (!projectPath || !workflowName) return NextResponse.json({ error: 'projectPath and workflowName required' }, { status: 400 });
|
|
54
|
+
try {
|
|
55
|
+
const result = triggerPipeline(projectPath, projectName || projectPath.split('/').pop(), workflowName, input);
|
|
56
|
+
return NextResponse.json({ ok: true, ...result });
|
|
57
|
+
} catch (e: any) {
|
|
58
|
+
return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (body.action === 'delete-run') {
|
|
63
|
+
deleteRun(body.id);
|
|
64
|
+
return NextResponse.json({ ok: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
68
|
+
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -137,18 +137,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
137
137
|
}, []);
|
|
138
138
|
|
|
139
139
|
const fetchData = useCallback(async () => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
setUsage(statusData.usage);
|
|
151
|
-
setProjects(projectsData);
|
|
140
|
+
try {
|
|
141
|
+
const [tasksRes, statusRes, projectsRes] = await Promise.all([
|
|
142
|
+
fetch('/api/tasks'),
|
|
143
|
+
fetch('/api/status'),
|
|
144
|
+
fetch('/api/projects'),
|
|
145
|
+
]);
|
|
146
|
+
if (tasksRes.ok) setTasks(await tasksRes.json());
|
|
147
|
+
if (statusRes.ok) { const s = await statusRes.json(); setProviders(s.providers); setUsage(s.usage); }
|
|
148
|
+
if (projectsRes.ok) setProjects(await projectsRes.json());
|
|
149
|
+
} catch {}
|
|
152
150
|
}, []);
|
|
153
151
|
|
|
154
152
|
useEffect(() => {
|
|
@@ -360,7 +360,9 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
|
360
360
|
Save
|
|
361
361
|
</button>
|
|
362
362
|
<button
|
|
363
|
-
onClick={
|
|
363
|
+
onClick={() => {
|
|
364
|
+
if (confirm('Discard unsaved changes?')) onClose();
|
|
365
|
+
}}
|
|
364
366
|
className="text-xs px-3 py-1 text-gray-400 hover:text-white"
|
|
365
367
|
>
|
|
366
368
|
Cancel
|
|
@@ -8,6 +8,8 @@ interface WorkflowNode {
|
|
|
8
8
|
id: string;
|
|
9
9
|
project: string;
|
|
10
10
|
prompt: string;
|
|
11
|
+
mode?: 'claude' | 'shell';
|
|
12
|
+
branch?: string;
|
|
11
13
|
dependsOn: string[];
|
|
12
14
|
outputs: { name: string; extract: string }[];
|
|
13
15
|
routes: { condition: string; next: string }[];
|
|
@@ -65,22 +67,29 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
65
67
|
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
|
66
68
|
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
|
67
69
|
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
|
|
70
|
+
const [activeWorkflow, setActiveWorkflow] = useState<string | null>(null); // selected workflow in left panel
|
|
68
71
|
const [showCreate, setShowCreate] = useState(false);
|
|
69
72
|
const [selectedWorkflow, setSelectedWorkflow] = useState<string>('');
|
|
73
|
+
const [projects, setProjects] = useState<{ name: string; path: string }[]>([]);
|
|
70
74
|
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
|
71
75
|
const [creating, setCreating] = useState(false);
|
|
72
76
|
const [showEditor, setShowEditor] = useState(false);
|
|
73
77
|
const [editorYaml, setEditorYaml] = useState<string | undefined>(undefined);
|
|
78
|
+
const [showImport, setShowImport] = useState(false);
|
|
79
|
+
const [importYaml, setImportYaml] = useState('');
|
|
74
80
|
|
|
75
81
|
const fetchData = useCallback(async () => {
|
|
76
|
-
const [pRes, wRes] = await Promise.all([
|
|
82
|
+
const [pRes, wRes, projRes] = await Promise.all([
|
|
77
83
|
fetch('/api/pipelines'),
|
|
78
84
|
fetch('/api/pipelines?type=workflows'),
|
|
85
|
+
fetch('/api/projects'),
|
|
79
86
|
]);
|
|
80
87
|
const pData = await pRes.json();
|
|
81
88
|
const wData = await wRes.json();
|
|
89
|
+
const projData = await projRes.json();
|
|
82
90
|
if (Array.isArray(pData)) setPipelines(pData);
|
|
83
91
|
if (Array.isArray(wData)) setWorkflows(wData);
|
|
92
|
+
if (Array.isArray(projData)) setProjects(projData.map((p: any) => ({ name: p.name, path: p.path })));
|
|
84
93
|
}, []);
|
|
85
94
|
|
|
86
95
|
useEffect(() => {
|
|
@@ -148,37 +157,62 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
148
157
|
|
|
149
158
|
return (
|
|
150
159
|
<div className="flex-1 flex min-h-0">
|
|
151
|
-
{/* Left —
|
|
152
|
-
<aside className="w-
|
|
153
|
-
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center
|
|
154
|
-
<span className="text-[11px] font-semibold text-[var(--text-primary)]">
|
|
155
|
-
<select
|
|
156
|
-
onChange={async (e) => {
|
|
157
|
-
const name = e.target.value;
|
|
158
|
-
if (!name) { setEditorYaml(undefined); setShowEditor(true); return; }
|
|
159
|
-
try {
|
|
160
|
-
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(name)}`);
|
|
161
|
-
const data = await res.json();
|
|
162
|
-
setEditorYaml(data.yaml || undefined);
|
|
163
|
-
} catch { setEditorYaml(undefined); }
|
|
164
|
-
setShowEditor(true);
|
|
165
|
-
e.target.value = '';
|
|
166
|
-
}}
|
|
167
|
-
className="text-[10px] px-1 py-0.5 rounded text-green-400 bg-transparent hover:bg-green-400/10 cursor-pointer"
|
|
168
|
-
defaultValue=""
|
|
169
|
-
>
|
|
170
|
-
<option value="">Editor ▾</option>
|
|
171
|
-
<option value="">+ New workflow</option>
|
|
172
|
-
{workflows.map(w => <option key={w.name} value={w.name}>{w.builtin ? '⚙ ' : ''}{w.name}</option>)}
|
|
173
|
-
</select>
|
|
160
|
+
{/* Left — Workflow list */}
|
|
161
|
+
<aside className="w-64 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
162
|
+
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center gap-1.5">
|
|
163
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)] flex-1">Workflows</span>
|
|
174
164
|
<button
|
|
175
|
-
onClick={() =>
|
|
176
|
-
className=
|
|
177
|
-
>
|
|
178
|
-
|
|
179
|
-
|
|
165
|
+
onClick={() => setShowImport(v => !v)}
|
|
166
|
+
className="text-[9px] text-green-400 hover:underline"
|
|
167
|
+
>Import</button>
|
|
168
|
+
<button
|
|
169
|
+
onClick={() => { setEditorYaml(undefined); setShowEditor(true); }}
|
|
170
|
+
className="text-[9px] text-[var(--accent)] hover:underline"
|
|
171
|
+
>+ New</button>
|
|
180
172
|
</div>
|
|
181
173
|
|
|
174
|
+
{/* Import form */}
|
|
175
|
+
{showImport && (
|
|
176
|
+
<div className="p-3 border-b border-[var(--border)] space-y-2">
|
|
177
|
+
<textarea
|
|
178
|
+
value={importYaml}
|
|
179
|
+
onChange={e => setImportYaml(e.target.value)}
|
|
180
|
+
placeholder="Paste YAML workflow here..."
|
|
181
|
+
className="w-full h-40 text-xs font-mono bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-2 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)]"
|
|
182
|
+
spellCheck={false}
|
|
183
|
+
/>
|
|
184
|
+
<div className="flex gap-2">
|
|
185
|
+
<button
|
|
186
|
+
onClick={async () => {
|
|
187
|
+
if (!importYaml.trim()) return;
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch('/api/pipelines', {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'Content-Type': 'application/json' },
|
|
192
|
+
body: JSON.stringify({ action: 'save-workflow', yaml: importYaml }),
|
|
193
|
+
});
|
|
194
|
+
const data = await res.json();
|
|
195
|
+
if (data.ok) {
|
|
196
|
+
setShowImport(false);
|
|
197
|
+
setImportYaml('');
|
|
198
|
+
fetchData();
|
|
199
|
+
alert(`Workflow "${data.name}" imported successfully`);
|
|
200
|
+
} else {
|
|
201
|
+
alert(`Import failed: ${data.error}`);
|
|
202
|
+
}
|
|
203
|
+
} catch { alert('Import failed'); }
|
|
204
|
+
}}
|
|
205
|
+
disabled={!importYaml.trim()}
|
|
206
|
+
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
207
|
+
>Save Workflow</button>
|
|
208
|
+
<button
|
|
209
|
+
onClick={() => { setShowImport(false); setImportYaml(''); }}
|
|
210
|
+
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
211
|
+
>Cancel</button>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
182
216
|
{/* Create form */}
|
|
183
217
|
{showCreate && (
|
|
184
218
|
<div className="p-3 border-b border-[var(--border)] space-y-2">
|
|
@@ -193,17 +227,28 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
193
227
|
))}
|
|
194
228
|
</select>
|
|
195
229
|
|
|
196
|
-
{/* Input fields */}
|
|
230
|
+
{/* Input fields — project fields get a dropdown */}
|
|
197
231
|
{currentWorkflow && Object.keys(currentWorkflow.input).length > 0 && (
|
|
198
232
|
<div className="space-y-1.5">
|
|
199
233
|
{Object.entries(currentWorkflow.input).map(([key, desc]) => (
|
|
200
234
|
<div key={key}>
|
|
201
235
|
<label className="text-[9px] text-[var(--text-secondary)]">{key}: {desc}</label>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
236
|
+
{key.toLowerCase() === 'project' ? (
|
|
237
|
+
<select
|
|
238
|
+
value={inputValues[key] || ''}
|
|
239
|
+
onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
|
|
240
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)]"
|
|
241
|
+
>
|
|
242
|
+
<option value="">Select project...</option>
|
|
243
|
+
{projects.map(p => <option key={p.path} value={p.name}>{p.name}</option>)}
|
|
244
|
+
</select>
|
|
245
|
+
) : (
|
|
246
|
+
<input
|
|
247
|
+
value={inputValues[key] || ''}
|
|
248
|
+
onChange={e => setInputValues(prev => ({ ...prev, [key]: e.target.value }))}
|
|
249
|
+
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)]"
|
|
250
|
+
/>
|
|
251
|
+
)}
|
|
207
252
|
</div>
|
|
208
253
|
))}
|
|
209
254
|
</div>
|
|
@@ -238,44 +283,111 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
238
283
|
</div>
|
|
239
284
|
)}
|
|
240
285
|
|
|
241
|
-
{/*
|
|
286
|
+
{/* Workflow list + execution history */}
|
|
242
287
|
<div className="flex-1 overflow-y-auto">
|
|
243
|
-
{
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
<
|
|
259
|
-
{
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
288
|
+
{workflows.map(w => {
|
|
289
|
+
const isActive = activeWorkflow === w.name;
|
|
290
|
+
const runs = pipelines.filter(p => p.workflowName === w.name);
|
|
291
|
+
return (
|
|
292
|
+
<div key={w.name}>
|
|
293
|
+
<div
|
|
294
|
+
onClick={() => { setActiveWorkflow(isActive ? null : w.name); setSelectedPipeline(null); }}
|
|
295
|
+
className={`w-full text-left px-3 py-2 border-b border-[var(--border)]/30 flex items-center gap-2 cursor-pointer ${
|
|
296
|
+
isActive ? 'bg-[var(--accent)]/10 border-l-2 border-l-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] border-l-2 border-l-transparent'
|
|
297
|
+
}`}
|
|
298
|
+
>
|
|
299
|
+
<span className="text-[8px] text-[var(--text-secondary)]">{isActive ? '▾' : '▸'}</span>
|
|
300
|
+
{w.builtin && <span className="text-[7px] text-[var(--text-secondary)]">⚙</span>}
|
|
301
|
+
<span className="text-[11px] text-[var(--text-primary)] truncate flex-1">{w.name}</span>
|
|
302
|
+
{runs.length > 0 && <span className="text-[8px] text-[var(--text-secondary)]">{runs.length}</span>}
|
|
303
|
+
<button
|
|
304
|
+
onClick={async (e) => {
|
|
305
|
+
e.stopPropagation();
|
|
306
|
+
setSelectedWorkflow(w.name);
|
|
307
|
+
setInputValues({});
|
|
308
|
+
setShowCreate(true);
|
|
309
|
+
setActiveWorkflow(w.name);
|
|
310
|
+
}}
|
|
311
|
+
className="text-[8px] text-[var(--accent)] hover:underline shrink-0"
|
|
312
|
+
title="Run this workflow"
|
|
313
|
+
>Run</button>
|
|
314
|
+
<button
|
|
315
|
+
onClick={async (e) => {
|
|
316
|
+
e.stopPropagation();
|
|
317
|
+
try {
|
|
318
|
+
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
|
|
319
|
+
const data = await res.json();
|
|
320
|
+
setEditorYaml(data.yaml || undefined);
|
|
321
|
+
} catch { setEditorYaml(undefined); }
|
|
322
|
+
setShowEditor(true);
|
|
323
|
+
}}
|
|
324
|
+
className="text-[8px] text-green-400 hover:underline shrink-0"
|
|
325
|
+
title={w.builtin ? 'View YAML' : 'Edit'}
|
|
326
|
+
>{w.builtin ? 'View' : 'Edit'}</button>
|
|
327
|
+
</div>
|
|
328
|
+
{/* Execution history for this workflow */}
|
|
329
|
+
{isActive && (
|
|
330
|
+
<div className="bg-[var(--bg-tertiary)]/50">
|
|
331
|
+
{runs.length === 0 ? (
|
|
332
|
+
<div className="px-4 py-2 text-[9px] text-[var(--text-secondary)]">No runs yet</div>
|
|
333
|
+
) : (
|
|
334
|
+
runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 20).map(p => (
|
|
335
|
+
<button
|
|
336
|
+
key={p.id}
|
|
337
|
+
onClick={() => setSelectedPipeline(p)}
|
|
338
|
+
className={`w-full text-left px-4 py-1.5 border-b border-[var(--border)]/20 hover:bg-[var(--bg-tertiary)] ${
|
|
339
|
+
selectedPipeline?.id === p.id ? 'bg-[var(--accent)]/5' : ''
|
|
340
|
+
}`}
|
|
341
|
+
>
|
|
342
|
+
<div className="flex items-center gap-1.5">
|
|
343
|
+
<span className={`text-[9px] ${STATUS_COLOR[p.status]}`}>●</span>
|
|
344
|
+
<span className="text-[9px] text-[var(--text-secondary)] font-mono">{p.id.slice(0, 8)}</span>
|
|
345
|
+
<div className="flex gap-0.5 ml-1">
|
|
346
|
+
{p.nodeOrder.map(nodeId => (
|
|
347
|
+
<span key={nodeId} className={`text-[8px] ${STATUS_COLOR[p.nodes[nodeId]?.status || 'pending']}`}>
|
|
348
|
+
{STATUS_ICON[p.nodes[nodeId]?.status || 'pending']}
|
|
349
|
+
</span>
|
|
350
|
+
))}
|
|
351
|
+
</div>
|
|
352
|
+
<span className="text-[8px] text-[var(--text-secondary)] ml-auto">
|
|
353
|
+
{new Date(p.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
354
|
+
</span>
|
|
355
|
+
</div>
|
|
356
|
+
</button>
|
|
357
|
+
))
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
265
361
|
</div>
|
|
266
|
-
|
|
267
|
-
)
|
|
268
|
-
{
|
|
362
|
+
);
|
|
363
|
+
})}
|
|
364
|
+
{workflows.length === 0 && (
|
|
269
365
|
<div className="p-4 text-center text-xs text-[var(--text-secondary)]">
|
|
270
|
-
No
|
|
366
|
+
No workflows. Click Import or + New to create one.
|
|
271
367
|
</div>
|
|
272
368
|
)}
|
|
273
369
|
</div>
|
|
274
370
|
</aside>
|
|
275
371
|
|
|
276
|
-
{/* Right — Pipeline detail */}
|
|
277
|
-
<main className="flex-1 flex flex-col min-w-0 overflow-
|
|
278
|
-
{
|
|
372
|
+
{/* Right — Pipeline detail / Editor */}
|
|
373
|
+
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
374
|
+
{showEditor ? (
|
|
375
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading editor...</div>}>
|
|
376
|
+
<PipelineEditor
|
|
377
|
+
initialYaml={editorYaml}
|
|
378
|
+
onSave={async (yaml) => {
|
|
379
|
+
await fetch('/api/pipelines', {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers: { 'Content-Type': 'application/json' },
|
|
382
|
+
body: JSON.stringify({ action: 'save-workflow', yaml }),
|
|
383
|
+
});
|
|
384
|
+
setShowEditor(false);
|
|
385
|
+
fetchData();
|
|
386
|
+
}}
|
|
387
|
+
onClose={() => setShowEditor(false)}
|
|
388
|
+
/>
|
|
389
|
+
</Suspense>
|
|
390
|
+
) : selectedPipeline ? (
|
|
279
391
|
<>
|
|
280
392
|
{/* Header */}
|
|
281
393
|
<div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
|
|
@@ -383,74 +495,87 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
383
495
|
})}
|
|
384
496
|
</div>
|
|
385
497
|
</>
|
|
386
|
-
) : (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
498
|
+
) : activeWorkflow ? (() => {
|
|
499
|
+
const w = workflows.find(wf => wf.name === activeWorkflow);
|
|
500
|
+
if (!w) return null;
|
|
501
|
+
const nodeEntries = Object.entries(w.nodes);
|
|
502
|
+
return (
|
|
503
|
+
<div className="flex-1 flex flex-col overflow-y-auto">
|
|
504
|
+
{/* Workflow header */}
|
|
505
|
+
<div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
|
|
506
|
+
<div className="flex items-center gap-2">
|
|
507
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">{w.name}</span>
|
|
508
|
+
{w.builtin && <span className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">built-in</span>}
|
|
509
|
+
<div className="ml-auto flex gap-2">
|
|
510
|
+
<button
|
|
511
|
+
onClick={() => { setSelectedWorkflow(w.name); setInputValues({}); setShowCreate(true); }}
|
|
512
|
+
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
513
|
+
>Run</button>
|
|
514
|
+
<button
|
|
515
|
+
onClick={async () => {
|
|
516
|
+
try {
|
|
517
|
+
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
|
|
518
|
+
const data = await res.json();
|
|
519
|
+
setEditorYaml(data.yaml || undefined);
|
|
520
|
+
} catch { setEditorYaml(undefined); }
|
|
521
|
+
setShowEditor(true);
|
|
522
|
+
}}
|
|
523
|
+
className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
|
|
524
|
+
>{w.builtin ? 'View YAML' : 'Edit'}</button>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
{w.description && <p className="text-[10px] text-[var(--text-secondary)] mt-1">{w.description}</p>}
|
|
528
|
+
{Object.keys(w.input).length > 0 && (
|
|
529
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
530
|
+
{Object.entries(w.input).map(([k, v]) => (
|
|
531
|
+
<span key={k} className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)]">{k}</span>
|
|
532
|
+
))}
|
|
533
|
+
</div>
|
|
534
|
+
)}
|
|
535
|
+
</div>
|
|
422
536
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
537
|
+
{/* Node flow visualization */}
|
|
538
|
+
<div className="p-4 space-y-2">
|
|
539
|
+
{nodeEntries.map(([nodeId, node], i) => (
|
|
540
|
+
<div key={nodeId}>
|
|
541
|
+
{/* Connection line */}
|
|
542
|
+
{i > 0 && (
|
|
543
|
+
<div className="flex items-center justify-center py-1">
|
|
544
|
+
<div className="w-px h-4 bg-[var(--border)]" />
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
547
|
+
{/* Node card */}
|
|
548
|
+
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--bg-tertiary)]">
|
|
549
|
+
<div className="flex items-center gap-2">
|
|
550
|
+
<span className={`text-[9px] px-1.5 py-0.5 rounded font-medium ${
|
|
551
|
+
node.mode === 'shell' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-purple-500/20 text-purple-400'
|
|
552
|
+
}`}>{node.mode === 'shell' ? 'shell' : 'claude'}</span>
|
|
553
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">{nodeId}</span>
|
|
554
|
+
{node.project && <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.project}</span>}
|
|
555
|
+
</div>
|
|
556
|
+
{node.dependsOn.length > 0 && (
|
|
557
|
+
<div className="text-[8px] text-[var(--text-secondary)] mt-1">depends: {node.dependsOn.join(', ')}</div>
|
|
558
|
+
)}
|
|
559
|
+
<p className="text-[9px] text-[var(--text-secondary)] mt-1 line-clamp-2">{node.prompt.slice(0, 120)}{node.prompt.length > 120 ? '...' : ''}</p>
|
|
560
|
+
{node.outputs.length > 0 && (
|
|
561
|
+
<div className="flex gap-1 mt-1">
|
|
562
|
+
{node.outputs.map(o => (
|
|
563
|
+
<span key={o.name} className="text-[7px] px-1 rounded bg-green-500/10 text-green-400">{o.name}</span>
|
|
564
|
+
))}
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
))}
|
|
570
|
+
</div>
|
|
430
571
|
</div>
|
|
572
|
+
);
|
|
573
|
+
})() : (
|
|
574
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
575
|
+
<p className="text-xs">Select a workflow to view details or run</p>
|
|
431
576
|
</div>
|
|
432
577
|
)}
|
|
433
578
|
</main>
|
|
434
|
-
|
|
435
|
-
{/* Visual Editor */}
|
|
436
|
-
{showEditor && (
|
|
437
|
-
<Suspense fallback={null}>
|
|
438
|
-
<PipelineEditor
|
|
439
|
-
initialYaml={editorYaml}
|
|
440
|
-
onSave={async (yaml) => {
|
|
441
|
-
// Save YAML to ~/.forge/flows/
|
|
442
|
-
await fetch('/api/pipelines', {
|
|
443
|
-
method: 'POST',
|
|
444
|
-
headers: { 'Content-Type': 'application/json' },
|
|
445
|
-
body: JSON.stringify({ action: 'save-workflow', yaml }),
|
|
446
|
-
});
|
|
447
|
-
setShowEditor(false);
|
|
448
|
-
fetchData();
|
|
449
|
-
}}
|
|
450
|
-
onClose={() => setShowEditor(false)}
|
|
451
|
-
/>
|
|
452
|
-
</Suspense>
|
|
453
|
-
)}
|
|
454
579
|
</div>
|
|
455
580
|
);
|
|
456
581
|
}
|