@aion0/forge 0.4.6 → 0.4.8

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,12 +1,11 @@
1
- # Forge v0.4.6
1
+ # Forge v0.4.8
2
2
 
3
- Released: 2026-03-21
3
+ Released: 2026-03-22
4
4
 
5
- ## Changes since v0.4.5
5
+ ## Changes since v0.4.7
6
6
 
7
- ### Other
8
- - improve help features
9
- - imporve help features
7
+ ### Features
8
+ - feat: image preview, all file types support, docs tab limit
10
9
 
11
10
 
12
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.5...v0.4.6
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.7...v0.4.8
@@ -26,11 +26,13 @@ const CODE_EXTS = new Set([
26
26
  '.xml', '.csv', '.lock',
27
27
  ]);
28
28
 
29
+ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', '.avif']);
30
+
29
31
  function isCodeFile(name: string): boolean {
30
32
  if (name.startsWith('.') && !name.startsWith('.env') && !name.startsWith('.git')) return false;
31
33
  const ext = extname(name);
32
34
  if (!ext) return !name.includes('.'); // files like Makefile, Dockerfile
33
- return CODE_EXTS.has(ext);
35
+ return CODE_EXTS.has(ext) || IMAGE_EXTS.has(ext);
34
36
  }
35
37
 
36
38
  function scanDir(dir: string, base: string, depth: number = 0): FileNode[] {
@@ -140,6 +142,17 @@ export async function GET(req: Request) {
140
142
  'class', 'jar', 'war',
141
143
  'pyc', 'pyo', 'wasm',
142
144
  ]);
145
+ // Image files — return base64 for preview
146
+ const IMAGE_PREVIEW = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
147
+ if (IMAGE_PREVIEW.has(ext)) {
148
+ if (size > 5_000_000) {
149
+ return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: `${sizeMB} MB`, message: 'Image too large to preview (> 5 MB)' });
150
+ }
151
+ const data = readFileSync(fullPath);
152
+ const mime = ext === 'svg' ? 'image/svg+xml' : `image/${ext === 'jpg' ? 'jpeg' : ext}`;
153
+ const base64 = `data:${mime};base64,${data.toString('base64')}`;
154
+ return NextResponse.json({ image: true, dataUrl: base64, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
155
+ }
143
156
  if (BINARY_EXTS.has(ext)) {
144
157
  return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
145
158
  }
@@ -105,9 +105,24 @@ export async function GET(req: Request) {
105
105
  try {
106
106
  const stat = statSync(fullPath);
107
107
  const size = stat.size;
108
+ const ext = extname(fullPath).replace('.', '').toLowerCase();
108
109
  const sizeKB = Math.round(size / 1024);
109
110
  const sizeMB = (size / (1024 * 1024)).toFixed(1);
110
111
 
112
+ // Binary file types
113
+ const BINARY_EXTS = new Set([
114
+ 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'avif',
115
+ 'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi',
116
+ 'zip', 'gz', 'tar', 'bz2', 'xz', '7z', 'rar',
117
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
118
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a',
119
+ 'woff', 'woff2', 'ttf', 'eot', 'otf',
120
+ 'sqlite', 'db', 'sqlite3', 'class', 'jar', 'pyc', 'wasm',
121
+ ]);
122
+ if (BINARY_EXTS.has(ext)) {
123
+ return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
124
+ }
125
+
111
126
  if (size > 2_000_000) {
112
127
  return NextResponse.json({ tooLarge: true, size, sizeLabel: `${sizeMB} MB`, message: 'File exceeds 2 MB limit' });
113
128
  }
@@ -115,7 +130,7 @@ export async function GET(req: Request) {
115
130
  return NextResponse.json({ large: true, size, sizeLabel: `${sizeKB} KB` });
116
131
  }
117
132
  const content = readFileSync(fullPath, 'utf-8');
118
- return NextResponse.json({ content });
133
+ return NextResponse.json({ content, language: ext });
119
134
  } catch {
120
135
  return NextResponse.json({ error: 'File not found' }, { status: 404 });
121
136
  }
@@ -8,6 +8,8 @@ import {
8
8
  deleteRun,
9
9
  triggerPipeline,
10
10
  getNextRunTime,
11
+ scanAndTriggerIssues,
12
+ resetDedup,
11
13
  } from '@/lib/pipeline-scheduler';
12
14
  import { listWorkflows } from '@/lib/pipeline';
13
15
 
@@ -64,5 +66,26 @@ export async function POST(req: Request) {
64
66
  return NextResponse.json({ ok: true });
65
67
  }
66
68
 
69
+ if (body.action === 'scan-now') {
70
+ const { projectPath, projectName, workflowName } = body;
71
+ if (!projectPath || !workflowName) return NextResponse.json({ error: 'projectPath and workflowName required' }, { status: 400 });
72
+ const bindings = getBindings(projectPath);
73
+ const binding = bindings.find(b => b.workflowName === workflowName);
74
+ if (!binding) return NextResponse.json({ error: 'Binding not found' }, { status: 404 });
75
+ try {
76
+ const result = scanAndTriggerIssues(binding);
77
+ return NextResponse.json({ ok: true, ...result });
78
+ } catch (e: any) {
79
+ return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
80
+ }
81
+ }
82
+
83
+ if (body.action === 'reset-dedup') {
84
+ const { projectPath, workflowName, dedupKey } = body;
85
+ if (!projectPath || !workflowName || !dedupKey) return NextResponse.json({ error: 'projectPath, workflowName, dedupKey required' }, { status: 400 });
86
+ resetDedup(projectPath, workflowName, dedupKey);
87
+ return NextResponse.json({ ok: true });
88
+ }
89
+
67
90
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
68
91
  }
@@ -101,6 +101,18 @@ export default function Dashboard({ user }: { user: any }) {
101
101
  return () => window.removeEventListener('forge:open-terminal', handler);
102
102
  }, []);
103
103
 
104
+ // Listen for navigation events (e.g. from ProjectDetail → Pipelines)
105
+ const [pendingPipelineId, setPendingPipelineId] = useState<string | null>(null);
106
+ useEffect(() => {
107
+ const handler = (e: Event) => {
108
+ const { view, pipelineId } = (e as CustomEvent).detail;
109
+ if (view) setViewMode(view);
110
+ if (pipelineId) setPendingPipelineId(pipelineId);
111
+ };
112
+ window.addEventListener('forge:navigate', handler);
113
+ return () => window.removeEventListener('forge:navigate', handler);
114
+ }, []);
115
+
104
116
  // Version check (on mount + every 10 min)
105
117
  useEffect(() => {
106
118
  const check = () => fetch('/api/version').then(r => r.json()).then(setVersionInfo).catch(() => {});
@@ -561,7 +573,11 @@ export default function Dashboard({ user }: { user: any }) {
561
573
  {/* Pipelines */}
562
574
  {viewMode === 'pipelines' && (
563
575
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
564
- <PipelineView onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }} />
576
+ <PipelineView
577
+ onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }}
578
+ focusPipelineId={pendingPipelineId}
579
+ onFocusHandled={() => setPendingPipelineId(null)}
580
+ />
565
581
  </Suspense>
566
582
  )}
567
583
 
@@ -55,20 +55,18 @@ function TreeNode({ node, depth, selected, onSelect }: {
55
55
  }
56
56
 
57
57
  const isSelected = selected === node.path;
58
- const canOpen = node.fileType === 'md' || node.fileType === 'image';
59
58
 
60
59
  return (
61
60
  <button
62
- onClick={() => canOpen && onSelect(node.path)}
61
+ onClick={() => onSelect(node.path)}
63
62
  className={`w-full text-left flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate ${
64
- !canOpen ? 'text-[var(--text-secondary)]/40 cursor-default'
65
- : isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
63
+ isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
66
64
  : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
67
65
  }`}
68
66
  style={{ paddingLeft: depth * 12 + 16 }}
69
67
  title={node.path}
70
68
  >
71
- {node.fileType === 'image' ? '🖼 ' : ''}{node.name.replace(/\.md$/, '')}
69
+ {node.fileType === 'image' ? '🖼 ' : ''}{node.name}
72
70
  </button>
73
71
  );
74
72
  }
@@ -84,6 +82,20 @@ function flattenTree(nodes: FileNode[]): FileNode[] {
84
82
  return result;
85
83
  }
86
84
 
85
+ const BINARY_EXTS = /\.(png|jpg|jpeg|gif|bmp|ico|webp|avif|mp3|mp4|wav|ogg|webm|mov|avi|zip|gz|tar|bz2|xz|7z|rar|pdf|doc|docx|xls|xlsx|ppt|pptx|exe|dll|so|dylib|bin|woff|woff2|ttf|eot|otf|sqlite|db|class|jar|pyc|wasm|o|a)$/i;
86
+
87
+ function filterTree(nodes: FileNode[]): FileNode[] {
88
+ return nodes.reduce<FileNode[]>((acc, node) => {
89
+ if (node.type === 'dir') {
90
+ const children = filterTree(node.children || []);
91
+ if (children.length > 0) acc.push({ ...node, children });
92
+ } else if (!BINARY_EXTS.test(node.name)) {
93
+ acc.push(node);
94
+ }
95
+ return acc;
96
+ }, []);
97
+ }
98
+
87
99
  // ─── Main Component ──────────────────────────────────────
88
100
 
89
101
  export default function DocsViewer() {
@@ -176,7 +188,9 @@ export default function DocsViewer() {
176
188
  setLoading(true);
177
189
  const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
178
190
  const data = await res.json();
179
- if (data.tooLarge) {
191
+ if (data.binary) {
192
+ setFileWarning(`${data.fileType?.toUpperCase() || 'Binary'} file — ${data.sizeLabel} — cannot be displayed`);
193
+ } else if (data.tooLarge) {
180
194
  setFileWarning(`File too large (${data.sizeLabel})`);
181
195
  } else {
182
196
  fileContent = data.content || null;
@@ -185,11 +199,15 @@ export default function DocsViewer() {
185
199
  }
186
200
  setContent(fileContent);
187
201
 
202
+ const MAX_TABS = 8;
188
203
  const newTab: DocTab = { id: genTabId(), filePath: path, fileName, rootIdx: activeRoot, isImage: isImg, content: fileContent };
189
204
  setDocTabs(prev => {
190
- // Double-check no duplicate
191
205
  if (prev.find(t => t.filePath === path)) return prev;
192
- const updated = [...prev, newTab];
206
+ let updated = [...prev, newTab];
207
+ // Auto-close oldest tabs if over limit
208
+ while (updated.length > MAX_TABS) {
209
+ updated = updated.slice(1);
210
+ }
193
211
  setActiveDocTabId(newTab.id);
194
212
  persistDocTabs(updated, newTab.id);
195
213
  return updated;
@@ -259,6 +277,7 @@ export default function DocsViewer() {
259
277
  }, [activeRoot, fetchTree]);
260
278
 
261
279
  const [fileWarning, setFileWarning] = useState<string | null>(null);
280
+ const [hideUnsupported, setHideUnsupported] = useState(true);
262
281
 
263
282
  // Fetch file content
264
283
  const isImageFile = (path: string) => /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|avif)$/i.test(path);
@@ -321,9 +340,9 @@ export default function DocsViewer() {
321
340
  }
322
341
 
323
342
  return (
324
- <div className="flex-1 flex flex-col min-h-0">
343
+ <div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
325
344
  {/* Doc content area */}
326
- <div className="flex-1 flex min-h-0">
345
+ <div className="flex-1 flex min-h-0 min-w-0 overflow-hidden">
327
346
  {/* Collapsible sidebar — file tree */}
328
347
  {sidebarOpen && (
329
348
  <aside style={{ width: sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
@@ -343,9 +362,16 @@ export default function DocsViewer() {
343
362
  {/* Header with refresh */}
344
363
  <div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center">
345
364
  <span className="text-[10px] text-[var(--text-secondary)] truncate">{roots[activeRoot] || 'Docs'}</span>
365
+ <button
366
+ onClick={() => setHideUnsupported(v => !v)}
367
+ className={`text-[9px] ml-auto shrink-0 px-1 rounded ${hideUnsupported ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)]'} hover:text-[var(--text-primary)]`}
368
+ title={hideUnsupported ? 'Show all files' : 'Hide binary files'}
369
+ >
370
+ {hideUnsupported ? 'Docs' : 'All'}
371
+ </button>
346
372
  <button
347
373
  onClick={() => { fetchTree(activeRoot); if (selectedFile) openFile(selectedFile); }}
348
- className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto shrink-0"
374
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
349
375
  title="Refresh files"
350
376
  >
351
377
 
@@ -384,7 +410,7 @@ export default function DocsViewer() {
384
410
  ))
385
411
  )
386
412
  ) : (
387
- tree.map(node => (
413
+ (hideUnsupported ? filterTree(tree) : tree).map(node => (
388
414
  <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} />
389
415
  ))
390
416
  )}
@@ -493,7 +519,7 @@ export default function DocsViewer() {
493
519
  spellCheck={false}
494
520
  />
495
521
  </div>
496
- ) : (
522
+ ) : selectedFile.endsWith('.md') ? (
497
523
  <div className="flex-1 overflow-y-auto px-8 py-6">
498
524
  {loading ? (
499
525
  <div className="text-xs text-[var(--text-secondary)]">Loading...</div>
@@ -503,6 +529,17 @@ export default function DocsViewer() {
503
529
  </div>
504
530
  )}
505
531
  </div>
532
+ ) : (
533
+ <div className="flex-1 overflow-y-auto">
534
+ <pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
535
+ {content.split('\n').map((line, i) => (
536
+ <div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
537
+ <span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
538
+ <span className="flex-1">{line || ' '}</span>
539
+ </div>
540
+ ))}
541
+ </pre>
542
+ </div>
506
543
  )
507
544
  ) : (
508
545
  <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
@@ -64,7 +64,7 @@ const STATUS_COLOR: Record<string, string> = {
64
64
  skipped: 'text-gray-500',
65
65
  };
66
66
 
67
- export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: string) => void }) {
67
+ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandled }: { onViewTask?: (taskId: string) => void; focusPipelineId?: string | null; onFocusHandled?: () => void }) {
68
68
  const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 256, minWidth: 140, maxWidth: 480 });
69
69
  const [pipelines, setPipelines] = useState<Pipeline[]>([]);
70
70
  const [workflows, setWorkflows] = useState<Workflow[]>([]);
@@ -100,6 +100,17 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
100
100
  return () => clearInterval(timer);
101
101
  }, [fetchData]);
102
102
 
103
+ // Focus on a specific pipeline (from external navigation)
104
+ useEffect(() => {
105
+ if (!focusPipelineId || pipelines.length === 0) return;
106
+ const target = pipelines.find(p => p.id === focusPipelineId);
107
+ if (target) {
108
+ setSelectedPipeline(target);
109
+ setShowEditor(false);
110
+ onFocusHandled?.();
111
+ }
112
+ }, [focusPipelineId, pipelines, onFocusHandled]);
113
+
103
114
  // Refresh selected pipeline
104
115
  useEffect(() => {
105
116
  if (!selectedPipeline || selectedPipeline.status !== 'running') return;
@@ -59,6 +59,8 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
59
59
  const [fileTree, setFileTree] = useState<any[]>([]);
60
60
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
61
61
  const [fileContent, setFileContent] = useState<string | null>(null);
62
+ const [fileImageUrl, setFileImageUrl] = useState<string | null>(null);
63
+ const [fileBinaryInfo, setFileBinaryInfo] = useState<{ fileType: string; sizeLabel: string; message?: string } | null>(null);
62
64
  const [fileLanguage, setFileLanguage] = useState('');
63
65
  const [fileLoading, setFileLoading] = useState(false);
64
66
  const [showLog, setShowLog] = useState(false);
@@ -69,10 +71,12 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
69
71
  const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
70
72
  // Pipeline bindings state
71
73
  const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
72
- const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; createdAt: string }[]>([]);
74
+ const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; dedupKey: string | null; createdAt: string }[]>([]);
73
75
  const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean }[]>([]);
74
76
  const [showAddPipeline, setShowAddPipeline] = useState(false);
75
77
  const [triggerInput, setTriggerInput] = useState<Record<string, string>>({});
78
+ const [runMenu, setRunMenu] = useState<string | null>(null); // workflowName of open run menu
79
+ const [issueInput, setIssueInput] = useState('');
76
80
  const [claudeMdContent, setClaudeMdContent] = useState('');
77
81
  const [claudeMdExists, setClaudeMdExists] = useState(false);
78
82
  const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
@@ -115,12 +119,23 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
115
119
  setSelectedFile(path);
116
120
  setDiffContent(null);
117
121
  setDiffFile(null);
122
+ setFileContent(null);
123
+ setFileImageUrl(null);
124
+ setFileBinaryInfo(null);
118
125
  setFileLoading(true);
119
126
  try {
120
127
  const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&file=${encodeURIComponent(path)}`);
121
128
  const data = await res.json();
122
- setFileContent(data.content || null);
123
- setFileLanguage(data.language || '');
129
+ if (data.image) {
130
+ setFileImageUrl(data.dataUrl);
131
+ } else if (data.binary) {
132
+ setFileBinaryInfo({ fileType: data.fileType, sizeLabel: data.sizeLabel, message: data.message });
133
+ } else if (data.tooLarge) {
134
+ setFileBinaryInfo({ fileType: '', sizeLabel: data.sizeLabel, message: data.message });
135
+ } else {
136
+ setFileContent(data.content || null);
137
+ setFileLanguage(data.language || '');
138
+ }
124
139
  } catch { setFileContent(null); }
125
140
  setFileLoading(false);
126
141
  }, [projectPath]);
@@ -527,6 +542,23 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
527
542
  </>
528
543
  ) : fileLoading ? (
529
544
  <div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
545
+ ) : selectedFile && fileImageUrl ? (
546
+ <>
547
+ <div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
548
+ <div className="flex-1 flex items-center justify-center p-4 overflow-auto">
549
+ <img src={fileImageUrl} alt={selectedFile} className="max-w-full max-h-full object-contain rounded" />
550
+ </div>
551
+ </>
552
+ ) : selectedFile && fileBinaryInfo ? (
553
+ <>
554
+ <div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
555
+ <div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
556
+ <span className="text-2xl">📄</span>
557
+ <span className="text-xs">{fileBinaryInfo.fileType ? fileBinaryInfo.fileType.toUpperCase() + ' file' : 'File'} — {fileBinaryInfo.sizeLabel}</span>
558
+ {fileBinaryInfo.message && <span className="text-[10px]">{fileBinaryInfo.message}</span>}
559
+ <span className="text-[10px]">Binary file cannot be displayed</span>
560
+ </div>
561
+ </>
530
562
  ) : selectedFile && fileContent !== null ? (
531
563
  <>
532
564
  <div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
@@ -846,10 +878,74 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
846
878
  }} className="accent-[var(--accent)]" />
847
879
  Enabled
848
880
  </label>
849
- <button
850
- onClick={() => triggerProjectPipeline(b.workflowName, triggerInput)}
851
- className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
852
- >Run</button>
881
+ <div className="relative">
882
+ <button
883
+ onClick={() => {
884
+ const isIssueWf = b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review';
885
+ if (!isIssueWf) {
886
+ triggerProjectPipeline(b.workflowName, triggerInput);
887
+ } else {
888
+ setRunMenu(runMenu === b.workflowName ? null : b.workflowName);
889
+ setIssueInput('');
890
+ }
891
+ }}
892
+ className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
893
+ >Run</button>
894
+ {runMenu === b.workflowName && (
895
+ <div className="absolute top-full right-0 mt-1 z-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg p-2 space-y-2 w-[200px]">
896
+ <button
897
+ onClick={async () => {
898
+ setRunMenu(null);
899
+ try {
900
+ const res = await fetch('/api/project-pipelines', {
901
+ method: 'POST',
902
+ headers: { 'Content-Type': 'application/json' },
903
+ body: JSON.stringify({ action: 'scan-now', projectPath, projectName, workflowName: b.workflowName }),
904
+ });
905
+ const data = await res.json();
906
+ if (data.error) alert(`Scan error: ${data.error}`);
907
+ else alert(`Scanned ${data.total} issues, triggered ${data.triggered} fix${data.pending > 0 ? ` (${data.pending} more pending)` : ''}`);
908
+ fetchPipelineBindings();
909
+ } catch { alert('Scan failed'); }
910
+ }}
911
+ className="w-full text-[9px] px-2 py-1.5 rounded border border-green-500/50 text-green-400 hover:bg-green-500/10 font-medium"
912
+ >Auto Scan — fix all new issues</button>
913
+ <div className="border-t border-[var(--border)]/50 my-1" />
914
+ <div className="flex items-center gap-1">
915
+ <input
916
+ type="text"
917
+ value={issueInput}
918
+ onChange={e => setIssueInput(e.target.value)}
919
+ placeholder="Issue #"
920
+ className="flex-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[9px] text-[var(--text-primary)]"
921
+ onKeyDown={e => {
922
+ if (e.key === 'Enter' && issueInput.trim()) {
923
+ setRunMenu(null);
924
+ triggerProjectPipeline(b.workflowName, {
925
+ ...triggerInput,
926
+ issue_id: issueInput.trim(),
927
+ base_branch: b.config.baseBranch || 'auto-detect',
928
+ });
929
+ }
930
+ }}
931
+ autoFocus
932
+ />
933
+ <button
934
+ onClick={() => {
935
+ if (!issueInput.trim()) return;
936
+ setRunMenu(null);
937
+ triggerProjectPipeline(b.workflowName, {
938
+ ...triggerInput,
939
+ issue_id: issueInput.trim(),
940
+ base_branch: b.config.baseBranch || 'auto-detect',
941
+ });
942
+ }}
943
+ className="text-[9px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-80"
944
+ >Fix</button>
945
+ </div>
946
+ </div>
947
+ )}
948
+ </div>
853
949
  <button
854
950
  onClick={async () => {
855
951
  if (!confirm(`Remove "${b.workflowName}" from this project?`)) return;
@@ -900,6 +996,51 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
900
996
  </span>
901
997
  )}
902
998
  </div>
999
+ {/* Issue scan config (for issue-fix-and-review workflow) */}
1000
+ {(b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review') && (
1001
+ <div className="space-y-1.5 pt-1 border-t border-[var(--border)]/30">
1002
+ {b.config.interval > 0 && (
1003
+ <div className="text-[8px] text-[var(--text-secondary)]">
1004
+ Scheduled mode: auto-scans GitHub issues and fixes new ones
1005
+ </div>
1006
+ )}
1007
+ <div className="flex items-center gap-2 text-[9px]">
1008
+ <label className="text-[var(--text-secondary)]">Labels:</label>
1009
+ <input
1010
+ type="text"
1011
+ defaultValue={(b.config.labels || []).join(', ')}
1012
+ placeholder="bug, autofix (empty = all)"
1013
+ onBlur={async (e) => {
1014
+ const labels = e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean);
1015
+ const newConfig = { ...b.config, labels };
1016
+ await fetch('/api/project-pipelines', {
1017
+ method: 'POST',
1018
+ headers: { 'Content-Type': 'application/json' },
1019
+ body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
1020
+ });
1021
+ fetchPipelineBindings();
1022
+ }}
1023
+ className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
1024
+ />
1025
+ <label className="text-[var(--text-secondary)]">Base:</label>
1026
+ <input
1027
+ type="text"
1028
+ defaultValue={b.config.baseBranch || ''}
1029
+ placeholder="auto-detect"
1030
+ onBlur={async (e) => {
1031
+ const newConfig = { ...b.config, baseBranch: e.target.value.trim() || undefined };
1032
+ await fetch('/api/project-pipelines', {
1033
+ method: 'POST',
1034
+ headers: { 'Content-Type': 'application/json' },
1035
+ body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
1036
+ });
1037
+ fetchPipelineBindings();
1038
+ }}
1039
+ className="w-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
1040
+ />
1041
+ </div>
1042
+ </div>
1043
+ )}
903
1044
  </div>
904
1045
  ))
905
1046
  )}
@@ -913,30 +1054,58 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
913
1054
  {pipelineRuns.map(run => (
914
1055
  <div key={run.id} className="flex items-start gap-2 px-3 py-2 border-b border-[var(--border)]/30 last:border-b-0 text-[10px]">
915
1056
  <span className={`shrink-0 ${
916
- run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : 'text-yellow-400'
1057
+ run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : run.status === 'skipped' ? 'text-gray-400' : 'text-yellow-400'
917
1058
  }`}>●</span>
918
1059
  <div className="flex-1 min-w-0">
919
1060
  <div className="flex items-center gap-2">
920
1061
  <span className="text-[var(--text-primary)] font-medium">{run.workflowName}</span>
921
- <span className="text-[8px] text-[var(--text-secondary)] font-mono">{run.pipelineId.slice(0, 8)}</span>
1062
+ {run.dedupKey && (
1063
+ <span className="text-[8px] text-[var(--accent)] font-mono">{run.dedupKey.replace('issue:', '#')}</span>
1064
+ )}
1065
+ <button
1066
+ onClick={() => window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: run.pipelineId } }))}
1067
+ className="text-[8px] text-[var(--accent)] font-mono hover:underline"
1068
+ title="View in Pipelines"
1069
+ >{run.pipelineId.slice(0, 8)}</button>
922
1070
  <span className="text-[8px] text-[var(--text-secondary)] ml-auto">{new Date(run.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
923
1071
  </div>
924
1072
  {run.summary && (
925
1073
  <pre className="text-[9px] text-[var(--text-secondary)] mt-1 whitespace-pre-wrap break-words line-clamp-3">{run.summary}</pre>
926
1074
  )}
927
1075
  </div>
928
- <button
929
- onClick={async () => {
930
- if (!confirm('Delete this run?')) return;
931
- await fetch('/api/project-pipelines', {
932
- method: 'POST',
933
- headers: { 'Content-Type': 'application/json' },
934
- body: JSON.stringify({ action: 'delete-run', id: run.id }),
935
- });
936
- fetchPipelineBindings();
937
- }}
938
- className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] shrink-0"
939
- >×</button>
1076
+ <div className="flex items-center gap-1 shrink-0">
1077
+ {run.status === 'failed' && run.dedupKey && (
1078
+ <button
1079
+ onClick={async () => {
1080
+ await fetch('/api/project-pipelines', {
1081
+ method: 'POST',
1082
+ headers: { 'Content-Type': 'application/json' },
1083
+ body: JSON.stringify({ action: 'reset-dedup', projectPath, workflowName: run.workflowName, dedupKey: run.dedupKey }),
1084
+ });
1085
+ // Delete the failed run then re-scan
1086
+ await fetch('/api/project-pipelines', {
1087
+ method: 'POST',
1088
+ headers: { 'Content-Type': 'application/json' },
1089
+ body: JSON.stringify({ action: 'delete-run', id: run.id }),
1090
+ });
1091
+ fetchPipelineBindings();
1092
+ }}
1093
+ className="text-[8px] text-[var(--accent)] hover:underline"
1094
+ >Retry</button>
1095
+ )}
1096
+ <button
1097
+ onClick={async () => {
1098
+ if (!confirm('Delete this run?')) return;
1099
+ await fetch('/api/project-pipelines', {
1100
+ method: 'POST',
1101
+ headers: { 'Content-Type': 'application/json' },
1102
+ body: JSON.stringify({ action: 'delete-run', id: run.id }),
1103
+ });
1104
+ fetchPipelineBindings();
1105
+ }}
1106
+ className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
1107
+ >×</button>
1108
+ </div>
940
1109
  </div>
941
1110
  ))}
942
1111
  </div>
@@ -18,7 +18,7 @@ export default function TabBar({ tabs, activeId, onActivate, onClose }: TabBarPr
18
18
  if (tabs.length === 0) return null;
19
19
 
20
20
  return (
21
- <div className="flex items-center border-b border-[var(--border)] bg-[var(--bg-tertiary)] overflow-x-auto shrink-0">
21
+ <div className="flex items-center border-b border-[var(--border)] bg-[var(--bg-tertiary)] overflow-x-auto shrink-0 min-w-0 max-w-full">
22
22
  {tabs.map(tab => (
23
23
  <div
24
24
  key={tab.id}
@@ -171,8 +171,9 @@ function pushLog(line: string) {
171
171
 
172
172
  export async function startTunnel(localPort: number = parseInt(process.env.PORT || '3000')): Promise<{ url?: string; error?: string }> {
173
173
  console.log(`[tunnel] Starting tunnel on port ${localPort}...`);
174
- // Check if this worker already has a process
175
- if (state.process) {
174
+ // Prevent concurrent starts: state.process is already spawned, or another call is
175
+ // mid-flight between the guard and spawn (the async download window).
176
+ if (state.process || state.status === 'starting') {
176
177
  return state.url ? { url: state.url } : { error: 'Tunnel is starting...' };
177
178
  }
178
179
 
@@ -182,6 +183,13 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
182
183
  try { process.kill(saved.pid, 0); return { url: saved.url }; } catch {}
183
184
  }
184
185
 
186
+ // Claim 'starting' before any async work so concurrent callers are blocked
187
+ // from this point onward (pgrep kill + download can take seconds).
188
+ state.status = 'starting';
189
+ state.url = null;
190
+ state.error = null;
191
+ state.log = [];
192
+
185
193
  // Kill ALL existing cloudflared processes to prevent duplicates
186
194
  try {
187
195
  const { execSync } = require('node:child_process');
@@ -191,11 +199,6 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
191
199
  }
192
200
  } catch {}
193
201
 
194
- state.status = 'starting';
195
- state.url = null;
196
- state.error = null;
197
- state.log = [];
198
-
199
202
  // Generate new session code for remote login 2FA
200
203
  try {
201
204
  const { rotateSessionCode } = require('./password');
@@ -217,7 +217,7 @@ nodes:
217
217
 
218
218
  ## Built-in Workflows
219
219
 
220
- ### issue-auto-fix
220
+ ### issue-fix-and-review
221
221
  Complete issue resolution: fetch GitHub issue → fix code on new branch → create PR.
222
222
 
223
223
  **Input**: `issue_id`, `project`, `base_branch` (optional), `extra_context` (optional)
@@ -265,22 +265,22 @@ curl "http://localhost:3000/api/project-pipelines?project=/path/to/project"
265
265
  # Add binding
266
266
  curl -X POST http://localhost:3000/api/project-pipelines \
267
267
  -H 'Content-Type: application/json' \
268
- -d '{"action":"add","projectPath":"/path","projectName":"my-app","workflowName":"issue-auto-fix"}'
268
+ -d '{"action":"add","projectPath":"/path","projectName":"my-app","workflowName":"issue-fix-and-review"}'
269
269
 
270
270
  # Update binding (enable/disable, change config/schedule)
271
271
  curl -X POST http://localhost:3000/api/project-pipelines \
272
272
  -H 'Content-Type: application/json' \
273
- -d '{"action":"update","projectPath":"/path","workflowName":"issue-auto-fix","config":{"interval":30}}'
273
+ -d '{"action":"update","projectPath":"/path","workflowName":"issue-fix-and-review","config":{"interval":30}}'
274
274
 
275
275
  # Trigger pipeline manually
276
276
  curl -X POST http://localhost:3000/api/project-pipelines \
277
277
  -H 'Content-Type: application/json' \
278
- -d '{"action":"trigger","projectPath":"/path","projectName":"my-app","workflowName":"issue-auto-fix","input":{"issue_id":"42"}}'
278
+ -d '{"action":"trigger","projectPath":"/path","projectName":"my-app","workflowName":"issue-fix-and-review","input":{"issue_id":"42"}}'
279
279
 
280
280
  # Remove binding
281
281
  curl -X POST http://localhost:3000/api/project-pipelines \
282
282
  -H 'Content-Type: application/json' \
283
- -d '{"action":"remove","projectPath":"/path","workflowName":"issue-auto-fix"}'
283
+ -d '{"action":"remove","projectPath":"/path","workflowName":"issue-fix-and-review"}'
284
284
  ```
285
285
 
286
286
  ## CLI
@@ -2,41 +2,48 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- Automatically scan GitHub Issues, fix code, create PRs — all hands-free. Uses the built-in `issue-auto-fix` pipeline workflow.
5
+ Automatically scan GitHub Issues, fix code, create PRs — all hands-free. Uses the built-in `issue-fix-and-review` pipeline workflow with integrated issue scanning.
6
6
 
7
7
  ## Prerequisites
8
8
 
9
9
  - `gh` CLI installed and authenticated: `gh auth login`
10
10
  - Project has a GitHub remote
11
11
 
12
- ## Setup (via Project Pipeline Binding)
12
+ ## Setup
13
13
 
14
14
  1. Go to **Projects → select project → Pipelines tab**
15
- 2. Click **+ Add** and select `issue-auto-fix`
15
+ 2. Click **+ Add** and select `issue-fix-and-review`
16
16
  3. Enable the binding
17
- 4. Set a **Schedule** (e.g., Every 30 min) for automatic scanning, or leave as "Manual only"
18
- 5. Click **Run** to manually trigger with an `issue_id`
17
+ 4. Check **Auto-scan GitHub Issues** to enable automatic scanning
18
+ 5. Configure:
19
+ - **Schedule**: How often to scan (e.g., Every 30 min)
20
+ - **Labels**: Filter issues by label (comma-separated, empty = all)
21
+ - **Base Branch**: Leave empty for auto-detect (main/master)
22
+ 6. Click **Scan** to manually trigger a scan
19
23
 
20
24
  ## Flow
21
25
 
22
26
  ```
23
- Setup Fetch Issue Fix Code (new branch) → Push & Create PR → Notify
27
+ Scan IssuesFor each new issue:
28
+ Setup → Fetch Issue → Fix Code (new branch) → Push & Create PR → Notify
24
29
  ```
25
30
 
26
- 1. **Setup**: Checks for clean working directory, detects repo and base branch
27
- 2. **Fetch Issue**: `gh issue view` fetches issue data (skips if no issue_id)
28
- 3. **Fix Code**: Claude analyzes issue and fixes code on `fix/<id>-<description>` branch
29
- 4. **Push & PR**: Pushes branch and creates Pull Request via `gh pr create`
30
- 5. **Notify**: Switches back to original branch, reports PR URL
31
+ 1. **Scan**: `gh issue list` finds open issues matching labels
32
+ 2. **Dedup**: Already-processed issues are skipped (tracked in `pipeline_runs`)
33
+ 3. **Setup**: Checks for clean working directory, detects repo and base branch
34
+ 4. **Fetch Issue**: `gh issue view` fetches issue data
35
+ 5. **Fix Code**: Claude analyzes issue and fixes code on `fix/<id>-<description>` branch
36
+ 6. **Push & PR**: Pushes branch and creates Pull Request via `gh pr create`
37
+ 7. **Notify**: Switches back to original branch, reports PR URL
31
38
 
32
- ## Input Fields
39
+ ## Manual Trigger
33
40
 
34
- | Input | Description | Required |
35
- |-------|-------------|----------|
36
- | `issue_id` | GitHub issue number | Yes (skips if empty) |
37
- | `project` | Project name | Yes |
38
- | `base_branch` | Base branch for fix | No (auto-detect) |
39
- | `extra_context` | Additional instructions | No |
41
+ - **Run** button: Triggers the workflow with custom input (requires `issue_id`)
42
+ - **Scan** button: Scans for all open issues and triggers fixes for new ones
43
+
44
+ ## Dedup
45
+
46
+ Each processed issue is tracked with a `dedup_key` (e.g., `issue:42`) in the pipeline runs table. Once an issue has been processed, it won't be triggered again even if it's still open. To re-process an issue, delete its run from the execution history.
40
47
 
41
48
  ## Safety
42
49
 
@@ -45,7 +52,4 @@ Setup → Fetch Issue → Fix Code (new branch) → Push & Create PR → Notify
45
52
  - Cleans up old fix branches for the same issue
46
53
  - Switches back to original branch after completion
47
54
  - Uses `--force-with-lease` for safe push
48
-
49
- ## Legacy Issue Scanner
50
-
51
- The old issue scanner (`Projects → Issues tab`) is still functional for existing configurations. It uses `issue_autofix_config` DB table for per-project scan settings. New projects should use the pipeline binding approach above.
55
+ - Running pipelines are not re-triggered (one fix per issue at a time)
package/lib/init.ts CHANGED
@@ -95,18 +95,12 @@ export function ensureInitialized() {
95
95
  // Session watcher is safe (file-based, idempotent)
96
96
  startWatcherLoop();
97
97
 
98
- // Pipeline scheduler — periodic execution for project-bound workflows
98
+ // Pipeline scheduler — periodic execution + issue scanning for project-bound workflows
99
99
  try {
100
100
  const { startScheduler } = require('./pipeline-scheduler');
101
101
  startScheduler();
102
102
  } catch {}
103
103
 
104
- // Legacy issue scanner (still used if issue_autofix_config has entries)
105
- try {
106
- const { startScanner } = require('./issue-scanner');
107
- startScanner();
108
- } catch {}
109
-
110
104
  // If services are managed externally (forge-server), skip
111
105
  if (process.env.FORGE_EXTERNAL_SERVICES === '1') {
112
106
  // Password display
@@ -1,27 +1,36 @@
1
1
  /**
2
- * Pipeline Scheduler — manages project-pipeline bindings and scheduled execution.
3
- * Replaces issue-scanner with a generic approach.
2
+ * Pipeline Scheduler — manages project-pipeline bindings, scheduled execution,
3
+ * and issue scanning (replaces issue-scanner.ts).
4
4
  *
5
5
  * Each project can bind multiple workflows. Each binding has:
6
- * - config: JSON with workflow-specific settings (e.g. interval, labels for issue pipelines)
6
+ * - config: JSON with workflow-specific settings (interval, scanType, labels, baseBranch)
7
7
  * - enabled: on/off toggle
8
8
  * - scheduled execution via config.interval (minutes, 0 = manual only)
9
+ * - config.scanType: 'github-issues' enables automatic issue scanning + dedup
9
10
  */
10
11
 
11
12
  import { getDb } from '@/src/core/db/database';
12
13
  import { getDbPath } from '@/src/config';
13
14
  import { startPipeline, getPipeline } from './pipeline';
14
15
  import { randomUUID } from 'node:crypto';
16
+ import { execSync } from 'node:child_process';
15
17
 
16
18
  function db() { return getDb(getDbPath()); }
17
19
 
20
+ /** Normalize SQLite datetime('now') → ISO 8601 UTC string. */
21
+ function toIsoUTC(s: string | null): string | null {
22
+ if (!s) return null;
23
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s)) return s.replace(' ', 'T') + 'Z';
24
+ return s;
25
+ }
26
+
18
27
  export interface ProjectPipelineBinding {
19
28
  id: number;
20
29
  projectPath: string;
21
30
  projectName: string;
22
31
  workflowName: string;
23
32
  enabled: boolean;
24
- config: Record<string, any>; // interval (minutes), labels, baseBranch, etc.
33
+ config: Record<string, any>; // interval, scanType, labels, baseBranch, etc.
25
34
  lastRunAt: string | null;
26
35
  createdAt: string;
27
36
  }
@@ -33,6 +42,7 @@ export interface PipelineRun {
33
42
  pipelineId: string;
34
43
  status: string;
35
44
  summary: string;
45
+ dedupKey: string | null;
36
46
  createdAt: string;
37
47
  }
38
48
 
@@ -47,8 +57,8 @@ export function getBindings(projectPath: string): ProjectPipelineBinding[] {
47
57
  workflowName: r.workflow_name,
48
58
  enabled: !!r.enabled,
49
59
  config: JSON.parse(r.config || '{}'),
50
- lastRunAt: r.last_run_at || null,
51
- createdAt: r.created_at,
60
+ lastRunAt: toIsoUTC(r.last_run_at),
61
+ createdAt: toIsoUTC(r.created_at) ?? r.created_at,
52
62
  }));
53
63
  }
54
64
 
@@ -61,8 +71,8 @@ export function getAllScheduledBindings(): ProjectPipelineBinding[] {
61
71
  workflowName: r.workflow_name,
62
72
  enabled: true,
63
73
  config: JSON.parse(r.config || '{}'),
64
- lastRunAt: r.last_run_at || null,
65
- createdAt: r.created_at,
74
+ lastRunAt: toIsoUTC(r.last_run_at),
75
+ createdAt: toIsoUTC(r.created_at) ?? r.created_at,
66
76
  })).filter(b => b.config.interval && b.config.interval > 0);
67
77
  }
68
78
 
@@ -96,12 +106,12 @@ function updateLastRunAt(projectPath: string, workflowName: string): void {
96
106
 
97
107
  // ─── Runs ────────────────────────────────────────────────
98
108
 
99
- export function recordRun(projectPath: string, workflowName: string, pipelineId: string): string {
109
+ export function recordRun(projectPath: string, workflowName: string, pipelineId: string, dedupKey?: string): string {
100
110
  const id = randomUUID().slice(0, 8);
101
111
  db().prepare(`
102
- INSERT INTO pipeline_runs (id, project_path, workflow_name, pipeline_id, status)
103
- VALUES (?, ?, ?, ?, 'running')
104
- `).run(id, projectPath, workflowName, pipelineId);
112
+ INSERT INTO pipeline_runs (id, project_path, workflow_name, pipeline_id, status, dedup_key)
113
+ VALUES (?, ?, ?, ?, 'running', ?)
114
+ `).run(id, projectPath, workflowName, pipelineId, dedupKey || null);
105
115
  return id;
106
116
  }
107
117
 
@@ -127,7 +137,8 @@ export function getRuns(projectPath: string, workflowName?: string, limit = 20):
127
137
  pipelineId: r.pipeline_id,
128
138
  status: r.status,
129
139
  summary: r.summary || '',
130
- createdAt: r.created_at,
140
+ dedupKey: r.dedup_key || null,
141
+ createdAt: toIsoUTC(r.created_at) ?? r.created_at,
131
142
  }));
132
143
  }
133
144
 
@@ -135,18 +146,36 @@ export function deleteRun(id: string): void {
135
146
  db().prepare('DELETE FROM pipeline_runs WHERE id = ?').run(id);
136
147
  }
137
148
 
149
+ // ─── Dedup ──────────────────────────────────────────────
150
+
151
+ function isDuplicate(projectPath: string, workflowName: string, dedupKey: string): boolean {
152
+ const row = db().prepare(
153
+ 'SELECT 1 FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ?'
154
+ ).get(projectPath, workflowName, dedupKey);
155
+ return !!row;
156
+ }
157
+
158
+ export function resetDedup(projectPath: string, workflowName: string, dedupKey: string): void {
159
+ db().prepare(
160
+ 'DELETE FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ?'
161
+ ).run(projectPath, workflowName, dedupKey);
162
+ }
163
+
138
164
  // ─── Trigger ─────────────────────────────────────────────
139
165
 
140
- export function triggerPipeline(projectPath: string, projectName: string, workflowName: string, extraInput?: Record<string, any>): { pipelineId: string; runId: string } {
166
+ export function triggerPipeline(
167
+ projectPath: string, projectName: string, workflowName: string,
168
+ extraInput?: Record<string, any>, dedupKey?: string
169
+ ): { pipelineId: string; runId: string } {
141
170
  const input: Record<string, string> = {
142
171
  project: projectName,
143
172
  ...extraInput,
144
173
  };
145
174
 
146
175
  const pipeline = startPipeline(workflowName, input);
147
- const runId = recordRun(projectPath, workflowName, pipeline.id);
176
+ const runId = recordRun(projectPath, workflowName, pipeline.id, dedupKey);
148
177
  updateLastRunAt(projectPath, workflowName);
149
- console.log(`[pipeline-scheduler] Triggered ${workflowName} for ${projectName} (pipeline: ${pipeline.id})`);
178
+ console.log(`[pipeline-scheduler] Triggered ${workflowName} for ${projectName} (pipeline: ${pipeline.id}${dedupKey ? ', dedup: ' + dedupKey : ''})`);
150
179
  return { pipelineId: pipeline.id, runId };
151
180
  }
152
181
 
@@ -171,6 +200,94 @@ export function syncRunStatus(pipelineId: string): void {
171
200
  updateRun(pipelineId, pipeline.status, summary.trim());
172
201
  }
173
202
 
203
+ // ─── GitHub Issue Scanning ──────────────────────────────
204
+
205
+ function getRepoFromProject(projectPath: string): string | null {
206
+ try {
207
+ return execSync('gh repo view --json nameWithOwner -q .nameWithOwner', {
208
+ cwd: projectPath, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
209
+ }).trim() || null;
210
+ } catch {
211
+ try {
212
+ const url = execSync('git remote get-url origin', {
213
+ cwd: projectPath, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
214
+ }).trim();
215
+ return url.replace(/.*github\.com[:/]/, '').replace(/\.git$/, '') || null;
216
+ } catch { return null; }
217
+ }
218
+ }
219
+
220
+ function fetchOpenIssues(projectPath: string, labels: string[]): { number: number; title: string; error?: string }[] {
221
+ const repo = getRepoFromProject(projectPath);
222
+ if (!repo) return [{ number: -1, title: '', error: 'Could not detect GitHub repo. Run: gh auth login' }];
223
+ try {
224
+ const labelFilter = labels.length > 0 ? ` --label "${labels.join(',')}"` : '';
225
+ const out = execSync(`gh issue list --state open --json number,title${labelFilter} -R ${repo}`, {
226
+ cwd: projectPath, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
227
+ });
228
+ return JSON.parse(out) || [];
229
+ } catch (e: any) {
230
+ const msg = e.stderr?.toString() || e.message || 'gh CLI failed';
231
+ return [{ number: -1, title: '', error: msg.includes('auth') ? 'GitHub CLI not authenticated. Run: gh auth login' : msg }];
232
+ }
233
+ }
234
+
235
+ export function scanAndTriggerIssues(binding: ProjectPipelineBinding): { triggered: number; issues: number[]; total: number; pending: number; error?: string } {
236
+ const labels: string[] = binding.config.labels || [];
237
+ const issues = fetchOpenIssues(binding.projectPath, labels);
238
+
239
+ // Check for errors
240
+ if (issues.length === 1 && (issues[0] as any).error) {
241
+ return { triggered: 0, issues: [], total: 0, pending: 0, error: (issues[0] as any).error };
242
+ }
243
+
244
+ // Check if there's already a running pipeline for this project+workflow — only one at a time
245
+ // to prevent concurrent git operations on the same repo
246
+ const recentRuns = getRuns(binding.projectPath, binding.workflowName, 5);
247
+ const hasRunning = recentRuns.some(r => r.status === 'running');
248
+
249
+ const newIssues: { number: number; title: string }[] = [];
250
+ for (const issue of issues) {
251
+ if (issue.number < 0) continue;
252
+ const dedupKey = `issue:${issue.number}`;
253
+ if (!isDuplicate(binding.projectPath, binding.workflowName, dedupKey)) {
254
+ newIssues.push(issue);
255
+ }
256
+ }
257
+
258
+ if (newIssues.length === 0) {
259
+ updateLastRunAt(binding.projectPath, binding.workflowName);
260
+ return { triggered: 0, issues: [], total: issues.length, pending: 0 };
261
+ }
262
+
263
+ // Only trigger ONE issue at a time to avoid concurrent git conflicts
264
+ // Next issue will be triggered on the next scan cycle
265
+ if (hasRunning) {
266
+ console.log(`[pipeline-scheduler] Issue scan: ${newIssues.length} new issues for ${binding.projectName}, waiting for current pipeline to finish`);
267
+ return { triggered: 0, issues: [], total: issues.length, pending: newIssues.length };
268
+ }
269
+
270
+ const issue = newIssues[0];
271
+ const dedupKey = `issue:${issue.number}`;
272
+ try {
273
+ triggerPipeline(
274
+ binding.projectPath, binding.projectName, binding.workflowName,
275
+ {
276
+ issue_id: String(issue.number),
277
+ base_branch: binding.config.baseBranch || 'auto-detect',
278
+ },
279
+ dedupKey
280
+ );
281
+ console.log(`[pipeline-scheduler] Issue scan: triggered #${issue.number} "${issue.title}" for ${binding.projectName} (${newIssues.length - 1} more pending)`);
282
+ } catch (e: any) {
283
+ console.error(`[pipeline-scheduler] Issue scan: failed to trigger #${issue.number}:`, e.message);
284
+ return { triggered: 0, issues: [], total: issues.length, pending: newIssues.length, error: e.message };
285
+ }
286
+
287
+ updateLastRunAt(binding.projectPath, binding.workflowName);
288
+ return { triggered: 1, issues: [issue.number], total: issues.length, pending: newIssues.length - 1 };
289
+ }
290
+
174
291
  // ─── Periodic Scheduler ─────────────────────────────────
175
292
 
176
293
  const schedulerKey = Symbol.for('forge-pipeline-scheduler');
@@ -210,19 +327,24 @@ function tickScheduler(): void {
210
327
  const lastRun = binding.lastRunAt ? new Date(binding.lastRunAt).getTime() : 0;
211
328
  const elapsed = now - lastRun;
212
329
 
213
- if (elapsed >= intervalMs) {
214
- // Check if there's already a running pipeline for this binding
215
- const recentRuns = getRuns(binding.projectPath, binding.workflowName, 1);
216
- if (recentRuns.length > 0 && recentRuns[0].status === 'running') {
217
- continue; // skip if still running
218
- }
330
+ if (elapsed < intervalMs) continue;
331
+
332
+ try {
333
+ const isIssueWorkflow = binding.workflowName === 'issue-fix-and-review' || binding.workflowName === 'issue-auto-fix' || binding.config.scanType === 'github-issues';
334
+ if (isIssueWorkflow) {
335
+ // Issue scan mode: fetch issues → dedup → trigger per issue
336
+ console.log(`[pipeline-scheduler] Scheduled issue scan: ${binding.workflowName} for ${binding.projectName}`);
337
+ scanAndTriggerIssues(binding);
338
+ } else {
339
+ // Normal mode: single trigger (skip if still running)
340
+ const recentRuns = getRuns(binding.projectPath, binding.workflowName, 1);
341
+ if (recentRuns.length > 0 && recentRuns[0].status === 'running') continue;
219
342
 
220
- try {
221
343
  console.log(`[pipeline-scheduler] Scheduled trigger: ${binding.workflowName} for ${binding.projectName}`);
222
344
  triggerPipeline(binding.projectPath, binding.projectName, binding.workflowName, binding.config.input);
223
- } catch (e: any) {
224
- console.error(`[pipeline-scheduler] Scheduled trigger failed for ${binding.workflowName}:`, e.message);
225
345
  }
346
+ } catch (e: any) {
347
+ console.error(`[pipeline-scheduler] Scheduled trigger failed for ${binding.workflowName}:`, e.message);
226
348
  }
227
349
  }
228
350
  } catch (e: any) {
package/lib/pipeline.ts CHANGED
@@ -92,7 +92,7 @@ nodes:
92
92
  if [ -n "$(git status --porcelain)" ]; then echo "ERROR: Working directory has uncommitted changes. Please commit or stash first." && exit 1; fi && \
93
93
  ORIG_BRANCH=$(git branch --show-current || git rev-parse --short HEAD) && \
94
94
  REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git remote get-url origin | sed 's/.*github.com[:/]//;s/.git$//') && \
95
- BASE={{input.base_branch}} && \
95
+ BASE="{{input.base_branch}}" && \
96
96
  if [ -z "$BASE" ] || [ "$BASE" = "auto-detect" ]; then BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo main); fi && \
97
97
  git checkout "$BASE" 2>/dev/null || true && \
98
98
  git pull origin "$BASE" 2>/dev/null || true && \
@@ -109,7 +109,8 @@ nodes:
109
109
  prompt: |
110
110
  ISSUE_ID="{{input.issue_id}}" && \
111
111
  if [ -z "$ISSUE_ID" ]; then echo "__SKIP__ No issue_id provided" && exit 0; fi && \
112
- REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
112
+ SETUP_INFO=$'{{nodes.setup.outputs.info}}' && \
113
+ REPO=$(echo "$SETUP_INFO" | grep REPO= | cut -d= -f2) && \
113
114
  gh issue view "$ISSUE_ID" --json title,body,labels,number -R "$REPO"
114
115
  outputs:
115
116
  - name: issue_json
@@ -140,11 +141,12 @@ nodes:
140
141
  project: "{{input.project}}"
141
142
  depends_on: [fix-code]
142
143
  prompt: |
143
- REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
144
+ SETUP_INFO=$'{{nodes.setup.outputs.info}}' && \
145
+ REPO=$(echo "$SETUP_INFO" | grep REPO= | cut -d= -f2) && \
144
146
  BRANCH=$(git branch --show-current) && \
145
147
  git push -u origin "$BRANCH" --force-with-lease 2>&1 && \
146
- PR_URL=$(gh pr create --title 'Fix #{{input.issue_id}}' \
147
- --body 'Auto-fix by Forge Pipeline for issue #{{input.issue_id}}.' -R "$REPO" 2>/dev/null || \
148
+ PR_URL=$(gh pr create --title "Fix #{{input.issue_id}}" \
149
+ --body "Auto-fix by Forge Pipeline for issue #{{input.issue_id}}." -R "$REPO" 2>/dev/null || \
148
150
  gh pr view "$BRANCH" --json url -q .url -R "$REPO" 2>/dev/null) && \
149
151
  echo "$PR_URL"
150
152
  outputs:
@@ -178,12 +180,14 @@ nodes:
178
180
  project: "{{input.project}}"
179
181
  depends_on: [review]
180
182
  prompt: |
181
- ORIG=$(echo '{{nodes.setup.outputs.info}}' | grep ORIG_BRANCH= | cut -d= -f2) && \
183
+ SETUP_INFO=$'{{nodes.setup.outputs.info}}' && \
184
+ ORIG=$(echo "$SETUP_INFO" | grep ORIG_BRANCH= | cut -d= -f2) && \
185
+ PR_URL=$'{{nodes.push-and-pr.outputs.pr_url}}' && \
182
186
  if [ -n "$(git status --porcelain)" ]; then
183
- echo "Issue #{{input.issue_id}} — PR: {{nodes.push-and-pr.outputs.pr_url}} | Review: {{nodes.review.outputs.review_result}} (staying on $(git branch --show-current))"
187
+ echo "Issue #{{input.issue_id}} — PR: $PR_URL (staying on $(git branch --show-current))"
184
188
  else
185
189
  git checkout "$ORIG" 2>/dev/null || true
186
- echo "Issue #{{input.issue_id}} — PR: {{nodes.push-and-pr.outputs.pr_url}} | Review: {{nodes.review.outputs.review_result}} (switched back to $ORIG)"
190
+ echo "Issue #{{input.issue_id}} — PR: $PR_URL (switched back to $ORIG)"
187
191
  fi
188
192
  outputs:
189
193
  - name: result
@@ -306,12 +310,22 @@ export function listPipelines(): Pipeline[] {
306
310
 
307
311
  // ─── Template Resolution ──────────────────────────────────
308
312
 
309
- /** Escape a string for safe embedding in shell commands (single-quote wrapping) */
313
+ /** Escape a string for safe embedding in single-quoted shell strings */
310
314
  function shellEscape(s: string): string {
311
315
  // Replace single quotes with '\'' (end quote, escaped quote, start quote)
312
316
  return s.replace(/'/g, "'\\''");
313
317
  }
314
318
 
319
+ /** Escape a string for safe embedding in $'...' shell strings (ANSI-C quoting) */
320
+ function shellEscapeAnsiC(s: string): string {
321
+ return s
322
+ .replace(/\\/g, '\\\\')
323
+ .replace(/'/g, "\\'")
324
+ .replace(/\n/g, '\\n')
325
+ .replace(/\r/g, '\\r')
326
+ .replace(/\t/g, '\\t');
327
+ }
328
+
315
329
  function resolveTemplate(template: string, ctx: {
316
330
  input: Record<string, string>;
317
331
  vars: Record<string, string>;
@@ -336,7 +350,7 @@ function resolveTemplate(template: string, ctx: {
336
350
  }
337
351
  }
338
352
 
339
- return shellMode ? shellEscape(value) : value;
353
+ return shellMode ? shellEscapeAnsiC(value) : value;
340
354
  });
341
355
  }
342
356
 
@@ -12,6 +12,13 @@ import { loadSettings } from './settings';
12
12
  import { notifyTaskComplete, notifyTaskFailed } from './notify';
13
13
  import type { Task, TaskLogEntry, TaskStatus, TaskMode, WatchConfig } from '@/src/types';
14
14
 
15
+ /** Normalize SQLite datetime('now') → ISO 8601 UTC string. */
16
+ function toIsoUTC(s: string | null | undefined): string | null {
17
+ if (!s) return null;
18
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s)) return s.replace(' ', 'T') + 'Z';
19
+ return s;
20
+ }
21
+
15
22
  const runnerKey = Symbol.for('mw-task-runner');
16
23
  const gRunner = globalThis as any;
17
24
  if (!gRunner[runnerKey]) gRunner[runnerKey] = { runner: null, currentTaskId: null };
@@ -601,10 +608,10 @@ function rowToTask(row: any): Task {
601
608
  gitBranch: row.git_branch || undefined,
602
609
  costUSD: row.cost_usd || undefined,
603
610
  error: row.error || undefined,
604
- createdAt: row.created_at,
605
- startedAt: row.started_at || undefined,
606
- completedAt: row.completed_at || undefined,
607
- scheduledAt: row.scheduled_at || undefined,
611
+ createdAt: toIsoUTC(row.created_at) ?? row.created_at,
612
+ startedAt: toIsoUTC(row.started_at) ?? undefined,
613
+ completedAt: toIsoUTC(row.completed_at) ?? undefined,
614
+ scheduledAt: toIsoUTC(row.scheduled_at) ?? undefined,
608
615
  };
609
616
  }
610
617
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -35,6 +35,25 @@ function initSchema(db: Database.Database) {
35
35
  migrate('ALTER TABLE skills ADD COLUMN rating REAL DEFAULT 0');
36
36
  migrate('ALTER TABLE skills ADD COLUMN deleted_remotely INTEGER NOT NULL DEFAULT 0');
37
37
  migrate('ALTER TABLE project_pipelines ADD COLUMN last_run_at TEXT');
38
+ migrate('ALTER TABLE pipeline_runs ADD COLUMN dedup_key TEXT');
39
+ // Unique index for dedup (only applies when dedup_key is NOT NULL)
40
+ try { db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_pipeline_runs_dedup ON pipeline_runs(project_path, workflow_name, dedup_key)'); } catch {}
41
+ // Migrate old issue_autofix_processed → pipeline_runs
42
+ try {
43
+ const old = db.prepare('SELECT * FROM issue_autofix_processed').all() as any[];
44
+ if (old.length > 0) {
45
+ const ins = db.prepare('INSERT OR IGNORE INTO pipeline_runs (id, project_path, workflow_name, pipeline_id, status, dedup_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
46
+ for (const r of old) {
47
+ ins.run(
48
+ r.pipeline_id?.slice(0, 8) || ('mig-' + r.issue_number),
49
+ r.project_path, 'issue-fix-and-review', r.pipeline_id || '',
50
+ r.status === 'processing' ? 'running' : (r.status || 'done'),
51
+ `issue:${r.issue_number}`, r.created_at || new Date().toISOString()
52
+ );
53
+ }
54
+ console.log(`[db] Migrated ${old.length} issue_autofix_processed records to pipeline_runs`);
55
+ }
56
+ } catch {}
38
57
 
39
58
  db.exec(`
40
59
  CREATE TABLE IF NOT EXISTS sessions (