@aion0/forge 0.3.2 → 0.3.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/CLAUDE.md +2 -1
- package/app/api/issue-scanner/route.ts +116 -0
- package/app/api/pipelines/route.ts +11 -3
- package/app/api/skills/route.ts +12 -0
- package/app/login/page.tsx +1 -1
- package/bin/forge-server.mjs +80 -21
- package/cli/mw.ts +2 -1
- package/components/DocTerminal.tsx +3 -2
- package/components/PipelineView.tsx +3 -2
- package/components/ProjectManager.tsx +273 -2
- package/components/SkillsPanel.tsx +28 -6
- package/components/WebTerminal.tsx +4 -3
- package/lib/cloudflared.ts +1 -1
- package/lib/init.ts +6 -0
- package/lib/issue-scanner.ts +298 -0
- package/lib/pipeline.ts +296 -28
- package/lib/skills.ts +30 -2
- package/lib/task-manager.ts +39 -0
- package/lib/terminal-standalone.ts +5 -1
- package/next-env.d.ts +1 -1
- package/next.config.ts +3 -1
- package/package.json +1 -1
- package/src/core/db/database.ts +1 -0
- package/src/types/index.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -9,7 +9,8 @@ forge server start # production via npm link/install
|
|
|
9
9
|
forge server start --dev # dev mode
|
|
10
10
|
forge server start # background by default, logs to ~/.forge/forge.log
|
|
11
11
|
forge server start --foreground # foreground mode
|
|
12
|
-
forge server stop # stop
|
|
12
|
+
forge server stop # stop default instance (port 3000)
|
|
13
|
+
forge server stop --port 4000 --dir ~/.forge-staging # stop specific instance
|
|
13
14
|
forge server restart # stop + start (safe for remote)
|
|
14
15
|
forge server rebuild # force rebuild
|
|
15
16
|
forge server start --port 4000 --terminal-port 4001 --dir ~/.forge-staging
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
getConfig,
|
|
4
|
+
saveConfig,
|
|
5
|
+
listConfigs,
|
|
6
|
+
scanAndTrigger,
|
|
7
|
+
restartScanner,
|
|
8
|
+
getProcessedIssues,
|
|
9
|
+
resetProcessedIssue,
|
|
10
|
+
getNextScanTime,
|
|
11
|
+
type IssueAutofixConfig,
|
|
12
|
+
} from '@/lib/issue-scanner';
|
|
13
|
+
|
|
14
|
+
// GET /api/issue-scanner?project=PATH — get config + processed issues
|
|
15
|
+
export async function GET(req: Request) {
|
|
16
|
+
const { searchParams } = new URL(req.url);
|
|
17
|
+
const projectPath = searchParams.get('project');
|
|
18
|
+
|
|
19
|
+
if (projectPath) {
|
|
20
|
+
const config = getConfig(projectPath);
|
|
21
|
+
const processed = getProcessedIssues(projectPath);
|
|
22
|
+
const scanTime = getNextScanTime(projectPath);
|
|
23
|
+
return NextResponse.json({ config, processed, ...scanTime });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// List all enabled configs
|
|
27
|
+
const configs = listConfigs();
|
|
28
|
+
return NextResponse.json({ configs });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// POST /api/issue-scanner
|
|
32
|
+
export async function POST(req: Request) {
|
|
33
|
+
const body = await req.json();
|
|
34
|
+
|
|
35
|
+
// Save config
|
|
36
|
+
if (body.action === 'save-config') {
|
|
37
|
+
const config: IssueAutofixConfig = {
|
|
38
|
+
projectPath: body.projectPath,
|
|
39
|
+
projectName: body.projectName,
|
|
40
|
+
enabled: !!body.enabled,
|
|
41
|
+
interval: body.interval || 30,
|
|
42
|
+
labels: body.labels || [],
|
|
43
|
+
baseBranch: body.baseBranch || '',
|
|
44
|
+
};
|
|
45
|
+
saveConfig(config);
|
|
46
|
+
restartScanner();
|
|
47
|
+
return NextResponse.json({ ok: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Manual scan & trigger
|
|
51
|
+
if (body.action === 'scan') {
|
|
52
|
+
const config = getConfig(body.projectPath);
|
|
53
|
+
if (!config) return NextResponse.json({ error: 'Not configured' }, { status: 400 });
|
|
54
|
+
const result = scanAndTrigger(config);
|
|
55
|
+
return NextResponse.json(result);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Manual trigger for a specific issue
|
|
59
|
+
if (body.action === 'trigger') {
|
|
60
|
+
const { startPipeline } = require('@/lib/pipeline');
|
|
61
|
+
const config = getConfig(body.projectPath);
|
|
62
|
+
const projectName = config?.projectName || body.projectName;
|
|
63
|
+
try {
|
|
64
|
+
const pipeline = startPipeline('issue-auto-fix', {
|
|
65
|
+
issue_id: String(body.issueId),
|
|
66
|
+
project: projectName,
|
|
67
|
+
base_branch: config?.baseBranch || body.baseBranch || 'auto-detect',
|
|
68
|
+
});
|
|
69
|
+
// Track in processed issues
|
|
70
|
+
const { getDb } = require('@/src/core/db/database');
|
|
71
|
+
const { getDbPath } = require('@/src/config');
|
|
72
|
+
getDb(getDbPath()).prepare(`
|
|
73
|
+
INSERT OR REPLACE INTO issue_autofix_processed (project_path, issue_number, pipeline_id, status)
|
|
74
|
+
VALUES (?, ?, ?, 'processing')
|
|
75
|
+
`).run(body.projectPath, body.issueId, pipeline.id);
|
|
76
|
+
return NextResponse.json({ ok: true, pipelineId: pipeline.id });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Reset a processed issue (allow re-scan)
|
|
83
|
+
if (body.action === 'reset') {
|
|
84
|
+
resetProcessedIssue(body.projectPath, body.issueId);
|
|
85
|
+
return NextResponse.json({ ok: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Retry with additional context/instructions
|
|
89
|
+
if (body.action === 'retry') {
|
|
90
|
+
const { startPipeline } = require('@/lib/pipeline');
|
|
91
|
+
const config = getConfig(body.projectPath);
|
|
92
|
+
const projectName = config?.projectName || body.projectName;
|
|
93
|
+
// Reset the processed record first, then re-create with new pipeline
|
|
94
|
+
resetProcessedIssue(body.projectPath, body.issueId);
|
|
95
|
+
try {
|
|
96
|
+
const pipeline = startPipeline('issue-auto-fix', {
|
|
97
|
+
issue_id: String(body.issueId),
|
|
98
|
+
project: projectName,
|
|
99
|
+
base_branch: config?.baseBranch || 'auto-detect',
|
|
100
|
+
extra_context: body.context || '',
|
|
101
|
+
});
|
|
102
|
+
// Re-mark as processed with new pipeline ID
|
|
103
|
+
const { getDb } = require('@/src/core/db/database');
|
|
104
|
+
const { getDbPath } = require('@/src/config');
|
|
105
|
+
getDb(getDbPath()).prepare(`
|
|
106
|
+
INSERT OR REPLACE INTO issue_autofix_processed (project_path, issue_number, pipeline_id, status)
|
|
107
|
+
VALUES (?, ?, ?, 'processing')
|
|
108
|
+
`).run(body.projectPath, body.issueId, pipeline.id);
|
|
109
|
+
return NextResponse.json({ ok: true, pipelineId: pipeline.id });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
116
|
+
}
|
|
@@ -24,9 +24,17 @@ export async function GET(req: Request) {
|
|
|
24
24
|
const filePath = join(FLOWS_DIR, `${name}.yaml`);
|
|
25
25
|
const altPath = join(FLOWS_DIR, `${name}.yml`);
|
|
26
26
|
const path = existsSync(filePath) ? filePath : existsSync(altPath) ? altPath : null;
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
if (path) {
|
|
28
|
+
return NextResponse.json({ yaml: readFileSync(path, 'utf-8') });
|
|
29
|
+
}
|
|
30
|
+
// Check built-in workflows
|
|
31
|
+
const workflow = listWorkflows().find(w => w.name === name);
|
|
32
|
+
if (workflow?.builtin) {
|
|
33
|
+
const { BUILTIN_WORKFLOWS } = await import('@/lib/pipeline');
|
|
34
|
+
const yaml = BUILTIN_WORKFLOWS[name];
|
|
35
|
+
if (yaml) return NextResponse.json({ yaml: yaml.trim(), builtin: true });
|
|
36
|
+
}
|
|
37
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
30
38
|
} catch {
|
|
31
39
|
return NextResponse.json({ error: 'Failed to read' }, { status: 500 });
|
|
32
40
|
}
|
package/app/api/skills/route.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
uninstallProject,
|
|
9
9
|
refreshInstallState,
|
|
10
10
|
checkLocalModified,
|
|
11
|
+
purgeDeletedSkill,
|
|
11
12
|
} from '@/lib/skills';
|
|
12
13
|
import { loadSettings } from '@/lib/settings';
|
|
13
14
|
import { homedir } from 'node:os';
|
|
@@ -156,5 +157,16 @@ export async function POST(req: Request) {
|
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
if (body.action === 'purge-deleted') {
|
|
161
|
+
const { name } = body;
|
|
162
|
+
if (!name) return NextResponse.json({ ok: false, error: 'name required' }, { status: 400 });
|
|
163
|
+
try {
|
|
164
|
+
purgeDeletedSkill(name);
|
|
165
|
+
return NextResponse.json({ ok: true });
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
159
171
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
160
172
|
}
|
package/app/login/page.tsx
CHANGED
|
@@ -12,7 +12,7 @@ export default function LoginPage() {
|
|
|
12
12
|
|
|
13
13
|
useEffect(() => {
|
|
14
14
|
const host = window.location.hostname;
|
|
15
|
-
setIsRemote(
|
|
15
|
+
setIsRemote(host.endsWith('.trycloudflare.com'));
|
|
16
16
|
// Restore theme
|
|
17
17
|
const saved = localStorage.getItem('forge-theme');
|
|
18
18
|
if (saved === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
package/bin/forge-server.mjs
CHANGED
|
@@ -67,6 +67,7 @@ const terminalPort = parseInt(getArg('--terminal-port')) || (webPort + 1);
|
|
|
67
67
|
const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge', 'data');
|
|
68
68
|
|
|
69
69
|
const PID_FILE = join(DATA_DIR, 'forge.pid');
|
|
70
|
+
const PIDS_FILE = join(DATA_DIR, 'forge.pids'); // all child process PIDs
|
|
70
71
|
const LOG_FILE = join(DATA_DIR, 'forge.log');
|
|
71
72
|
|
|
72
73
|
process.chdir(ROOT);
|
|
@@ -208,16 +209,43 @@ if (resetTerminal) {
|
|
|
208
209
|
}
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
// ── PID tracking for clean shutdown ──
|
|
213
|
+
|
|
214
|
+
function savePids(pids) {
|
|
215
|
+
writeFileSync(PIDS_FILE, JSON.stringify(pids), 'utf-8');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function loadPids() {
|
|
219
|
+
try { return JSON.parse(readFileSync(PIDS_FILE, 'utf-8')); } catch { return []; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function killTrackedPids() {
|
|
223
|
+
const pids = loadPids();
|
|
224
|
+
for (const pid of pids) {
|
|
225
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
226
|
+
}
|
|
227
|
+
// Give them a moment, then force kill
|
|
228
|
+
if (pids.length > 0) {
|
|
229
|
+
setTimeout(() => {
|
|
230
|
+
for (const pid of pids) {
|
|
231
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
232
|
+
}
|
|
233
|
+
}, 2000);
|
|
234
|
+
}
|
|
235
|
+
try { unlinkSync(PIDS_FILE); } catch {}
|
|
236
|
+
}
|
|
237
|
+
|
|
211
238
|
// ── Kill orphan standalone processes ──
|
|
212
239
|
const protectedPids = new Set();
|
|
213
240
|
|
|
214
241
|
function cleanupOrphans() {
|
|
242
|
+
const myPid = String(process.pid);
|
|
243
|
+
const instanceTag = `--forge-port=${webPort}`;
|
|
215
244
|
try {
|
|
216
|
-
//
|
|
245
|
+
// Kill processes on our ports
|
|
217
246
|
for (const port of [webPort, terminalPort]) {
|
|
218
247
|
try {
|
|
219
248
|
const pids = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
220
|
-
const myPid = String(process.pid);
|
|
221
249
|
for (const pid of pids.split('\n').filter(Boolean)) {
|
|
222
250
|
const p = pid.trim();
|
|
223
251
|
if (p === myPid || protectedPids.has(p)) continue;
|
|
@@ -225,20 +253,18 @@ function cleanupOrphans() {
|
|
|
225
253
|
}
|
|
226
254
|
} catch {}
|
|
227
255
|
}
|
|
228
|
-
// Kill standalone processes
|
|
256
|
+
// Kill standalone processes: our instance's + orphans without any tag
|
|
229
257
|
try {
|
|
230
258
|
const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone' | grep -v grep`, {
|
|
231
259
|
encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
232
260
|
}).trim();
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
|
|
241
|
-
}
|
|
261
|
+
for (const line of out.split('\n').filter(Boolean)) {
|
|
262
|
+
const isOurs = line.includes(instanceTag);
|
|
263
|
+
const isOrphan = !line.includes('--forge-port='); // no tag = legacy orphan
|
|
264
|
+
if (!isOurs && !isOrphan) continue; // belongs to another instance, skip
|
|
265
|
+
const pid = line.trim().split(/\s+/)[1];
|
|
266
|
+
if (pid === myPid || protectedPids.has(pid)) continue;
|
|
267
|
+
try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
|
|
242
268
|
}
|
|
243
269
|
} catch {}
|
|
244
270
|
} catch {}
|
|
@@ -250,9 +276,11 @@ const services = [];
|
|
|
250
276
|
function startServices() {
|
|
251
277
|
cleanupOrphans();
|
|
252
278
|
|
|
279
|
+
const instanceTag = `--forge-port=${webPort}`;
|
|
280
|
+
|
|
253
281
|
// Terminal server
|
|
254
282
|
const termScript = join(ROOT, 'lib', 'terminal-standalone.ts');
|
|
255
|
-
const termChild = spawn('npx', ['tsx', termScript], {
|
|
283
|
+
const termChild = spawn('npx', ['tsx', termScript, instanceTag], {
|
|
256
284
|
cwd: ROOT,
|
|
257
285
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
258
286
|
env: { ...process.env },
|
|
@@ -262,13 +290,17 @@ function startServices() {
|
|
|
262
290
|
|
|
263
291
|
// Telegram bot
|
|
264
292
|
const telegramScript = join(ROOT, 'lib', 'telegram-standalone.ts');
|
|
265
|
-
const telegramChild = spawn('npx', ['tsx', telegramScript], {
|
|
293
|
+
const telegramChild = spawn('npx', ['tsx', telegramScript, instanceTag], {
|
|
266
294
|
cwd: ROOT,
|
|
267
295
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
268
296
|
env: { ...process.env },
|
|
269
297
|
});
|
|
270
298
|
services.push(telegramChild);
|
|
271
299
|
console.log(`[forge] Telegram bot started (pid: ${telegramChild.pid})`);
|
|
300
|
+
|
|
301
|
+
// Track all child PIDs for clean shutdown
|
|
302
|
+
const childPids = services.map(c => c.pid).filter(Boolean);
|
|
303
|
+
savePids(childPids);
|
|
272
304
|
}
|
|
273
305
|
|
|
274
306
|
function stopServices() {
|
|
@@ -280,20 +312,47 @@ function stopServices() {
|
|
|
280
312
|
}
|
|
281
313
|
|
|
282
314
|
// ── Helper: stop running instance ──
|
|
283
|
-
function stopServer() {
|
|
315
|
+
async function stopServer() {
|
|
284
316
|
stopServices();
|
|
285
317
|
try { unlinkSync(join(DATA_DIR, 'tunnel-state.json')); } catch {}
|
|
286
318
|
|
|
319
|
+
// Kill all tracked child PIDs first
|
|
320
|
+
killTrackedPids();
|
|
321
|
+
|
|
322
|
+
let stopped = false;
|
|
323
|
+
|
|
324
|
+
// Try PID file first
|
|
287
325
|
try {
|
|
288
326
|
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim());
|
|
289
327
|
process.kill(pid, 'SIGTERM');
|
|
290
|
-
unlinkSync(PID_FILE);
|
|
291
328
|
console.log(`[forge] Stopped (pid ${pid})`);
|
|
292
|
-
|
|
293
|
-
} catch {
|
|
329
|
+
stopped = true;
|
|
330
|
+
} catch {}
|
|
331
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
332
|
+
|
|
333
|
+
// Also kill by port (in case PID file is stale)
|
|
334
|
+
const portPids = [];
|
|
335
|
+
try {
|
|
336
|
+
const pids = execSync(`lsof -ti:${webPort}`, { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
337
|
+
for (const p of pids.split('\n').filter(Boolean)) {
|
|
338
|
+
const pid = parseInt(p.trim());
|
|
339
|
+
try { process.kill(pid, 'SIGTERM'); stopped = true; portPids.push(pid); } catch {}
|
|
340
|
+
}
|
|
341
|
+
if (pids) console.log(`[forge] Killed processes on port ${webPort}`);
|
|
342
|
+
} catch {}
|
|
343
|
+
|
|
344
|
+
// Force kill after 2 seconds if SIGTERM didn't work
|
|
345
|
+
if (portPids.length > 0) {
|
|
346
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
347
|
+
for (const pid of portPids) {
|
|
348
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!stopped) {
|
|
294
353
|
console.log('[forge] No running server found');
|
|
295
|
-
return false;
|
|
296
354
|
}
|
|
355
|
+
return stopped;
|
|
297
356
|
}
|
|
298
357
|
|
|
299
358
|
// ── Helper: start background server ──
|
|
@@ -329,13 +388,13 @@ function startBackground() {
|
|
|
329
388
|
|
|
330
389
|
// ── Stop ──
|
|
331
390
|
if (isStop) {
|
|
332
|
-
stopServer();
|
|
391
|
+
await stopServer();
|
|
333
392
|
process.exit(0);
|
|
334
393
|
}
|
|
335
394
|
|
|
336
395
|
// ── Restart ──
|
|
337
396
|
if (isRestart) {
|
|
338
|
-
stopServer();
|
|
397
|
+
await stopServer();
|
|
339
398
|
// Wait for port to fully release
|
|
340
399
|
const net = await import('node:net');
|
|
341
400
|
for (let i = 0; i < 20; i++) {
|
package/cli/mw.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* mw watch <id> — live stream task output
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
const
|
|
19
|
+
const _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === '--port');
|
|
20
|
+
const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '3000'}`;
|
|
20
21
|
|
|
21
22
|
const [, , cmd, ...args] = process.argv;
|
|
22
23
|
|
|
@@ -8,13 +8,14 @@ import '@xterm/xterm/css/xterm.css';
|
|
|
8
8
|
const SESSION_NAME = 'mw-docs-claude';
|
|
9
9
|
|
|
10
10
|
function getWsUrl() {
|
|
11
|
-
if (typeof window === 'undefined') return
|
|
11
|
+
if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
|
|
12
12
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
13
13
|
const wsHost = window.location.hostname;
|
|
14
14
|
if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
|
|
15
15
|
return `${wsProtocol}//${window.location.host}/terminal-ws`;
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
const webPort = parseInt(window.location.port) || 3000;
|
|
18
|
+
return `${wsProtocol}//${wsHost}:${webPort + 1}`;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export default function DocTerminal({ docRoot }: { docRoot: string }) {
|
|
@@ -17,6 +17,7 @@ interface WorkflowNode {
|
|
|
17
17
|
interface Workflow {
|
|
18
18
|
name: string;
|
|
19
19
|
description?: string;
|
|
20
|
+
builtin?: boolean;
|
|
20
21
|
vars: Record<string, string>;
|
|
21
22
|
input: Record<string, string>;
|
|
22
23
|
nodes: Record<string, WorkflowNode>;
|
|
@@ -168,7 +169,7 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
168
169
|
>
|
|
169
170
|
<option value="">Editor ▾</option>
|
|
170
171
|
<option value="">+ New workflow</option>
|
|
171
|
-
{workflows.map(w => <option key={w.name} value={w.name}>{w.name}</option>)}
|
|
172
|
+
{workflows.map(w => <option key={w.name} value={w.name}>{w.builtin ? '⚙ ' : ''}{w.name}</option>)}
|
|
172
173
|
</select>
|
|
173
174
|
<button
|
|
174
175
|
onClick={() => setShowCreate(v => !v)}
|
|
@@ -188,7 +189,7 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
188
189
|
>
|
|
189
190
|
<option value="">Select workflow...</option>
|
|
190
191
|
{workflows.map(w => (
|
|
191
|
-
<option key={w.name} value={w.name}>{w.name}{w.description ? ` — ${w.description}` : ''}</option>
|
|
192
|
+
<option key={w.name} value={w.name}>{w.builtin ? '[Built-in] ' : ''}{w.name}{w.description ? ` — ${w.description}` : ''}</option>
|
|
192
193
|
))}
|
|
193
194
|
</select>
|
|
194
195
|
|