@aion0/forge 0.10.79 → 0.10.81
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 +4 -5
- package/app/api/tasks/[id]/hook/stop/route.ts +15 -0
- package/app/api/tasks/route.ts +2 -1
- package/cli/mw.mjs +7 -5
- package/cli/mw.ts +8 -6
- package/components/Dashboard.tsx +61 -28
- package/components/InlinePipelineView.tsx +22 -5
- package/components/PipelineHistory.tsx +306 -0
- package/components/TaskDetail.tsx +28 -1
- package/components/TmuxTaskTerminal.tsx +105 -0
- package/components/WebTerminal.tsx +7 -0
- package/docs/design_automation_records/Automation Redesign.dc.html +2019 -0
- package/docs/design_automation_records/README.md +232 -0
- package/lib/chat/agent-loop.ts +6 -0
- package/lib/chat/tool-dispatcher.ts +110 -9
- package/lib/help-docs/05-pipelines.md +31 -0
- package/lib/help-docs/25-chat-tools.md +23 -0
- package/lib/pipeline.ts +27 -3
- package/lib/task-manager.ts +73 -3
- package/lib/task-tmux-backend.ts +625 -0
- package/lib/workspace/skill-installer.ts +18 -8
- package/package.json +1 -1
- package/proxy.ts +5 -4
- package/src/core/db/database.ts +1 -0
- package/src/types/index.ts +3 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.81
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-14
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.80
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
- feat(
|
|
9
|
-
- Move file-tree computation server-side (fix browser stack overflow on large repos) (#35)
|
|
8
|
+
- feat(ui): URL deep-linking + Automation/History default + Pipeline History page
|
|
10
9
|
|
|
11
10
|
|
|
12
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.80...v0.10.81
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { fireTmuxHook, completeStaleTmuxTask } from '@/lib/task-tmux-backend';
|
|
3
|
+
|
|
4
|
+
// Called by the Claude Code Stop hook when a tmux-backend task turn completes.
|
|
5
|
+
// The hook script (installed in ~/.claude/settings.json) reads task-context.json
|
|
6
|
+
// from the project dir and POSTs here. We resolve the awaited promise in executeTmuxTask.
|
|
7
|
+
// If no waiter exists (server restart mid-task), fall back to directly completing the task.
|
|
8
|
+
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
9
|
+
const { id } = await params;
|
|
10
|
+
const handled = fireTmuxHook(id);
|
|
11
|
+
if (!handled) {
|
|
12
|
+
completeStaleTmuxTask(id);
|
|
13
|
+
}
|
|
14
|
+
return NextResponse.json({ ok: true });
|
|
15
|
+
}
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -14,7 +14,7 @@ export async function GET(req: Request) {
|
|
|
14
14
|
|
|
15
15
|
// Create a new task
|
|
16
16
|
export async function POST(req: Request) {
|
|
17
|
-
const { projectName, prompt, priority, newSession, conversationId, scheduledAt, mode, watchConfig, agent } = await req.json();
|
|
17
|
+
const { projectName, prompt, priority, newSession, conversationId, scheduledAt, mode, watchConfig, agent, backend } = await req.json();
|
|
18
18
|
|
|
19
19
|
if (!projectName || !prompt) {
|
|
20
20
|
return NextResponse.json({ error: 'projectName and prompt are required' }, { status: 400 });
|
|
@@ -38,6 +38,7 @@ export async function POST(req: Request) {
|
|
|
38
38
|
mode: mode || 'prompt',
|
|
39
39
|
watchConfig: watchConfig || undefined,
|
|
40
40
|
agent: agent || undefined,
|
|
41
|
+
backend: backend === 'tmux' ? 'tmux' : undefined,
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
return NextResponse.json(task);
|
package/cli/mw.mjs
CHANGED
|
@@ -1352,7 +1352,7 @@ var init_clean = __esm({
|
|
|
1352
1352
|
|
|
1353
1353
|
// cli/mw.ts
|
|
1354
1354
|
var _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === "--port");
|
|
1355
|
-
var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "
|
|
1355
|
+
var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "8403"}`;
|
|
1356
1356
|
var [, , cmd, ...args] = process.argv;
|
|
1357
1357
|
async function checkForUpdate() {
|
|
1358
1358
|
try {
|
|
@@ -1463,19 +1463,21 @@ async function main() {
|
|
|
1463
1463
|
case "task":
|
|
1464
1464
|
case "t": {
|
|
1465
1465
|
const newSession = args.includes("--new");
|
|
1466
|
-
const
|
|
1466
|
+
const useTmux = args.includes("--tmux");
|
|
1467
|
+
const filtered = args.filter((a) => a !== "--new" && a !== "--tmux");
|
|
1467
1468
|
const project = filtered[0];
|
|
1468
1469
|
const prompt = filtered.slice(1).join(" ");
|
|
1469
1470
|
if (!project || !prompt) {
|
|
1470
|
-
console.log("Usage: mw task <project> <prompt> [--new]");
|
|
1471
|
-
console.log(" --new
|
|
1471
|
+
console.log("Usage: mw task <project> <prompt> [--new] [--tmux]");
|
|
1472
|
+
console.log(" --new Start a fresh session (ignore previous context)");
|
|
1473
|
+
console.log(" --tmux Run via tmux backend (interactive mode, subscription billing)");
|
|
1472
1474
|
console.log('Example: mw task my-app "Fix the login bug"');
|
|
1473
1475
|
process.exit(1);
|
|
1474
1476
|
}
|
|
1475
1477
|
const task = await api3("/api/tasks", {
|
|
1476
1478
|
method: "POST",
|
|
1477
1479
|
headers: { "Content-Type": "application/json" },
|
|
1478
|
-
body: JSON.stringify({ projectName: project, prompt, newSession })
|
|
1480
|
+
body: JSON.stringify({ projectName: project, prompt, newSession, ...useTmux ? { backend: "tmux" } : {} })
|
|
1479
1481
|
});
|
|
1480
1482
|
const session = task.conversationId ? "(continuing session)" : "(new session)";
|
|
1481
1483
|
console.log(`\u2713 Task ${task.id} created ${session}`);
|
package/cli/mw.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === '--port');
|
|
19
|
-
const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '
|
|
19
|
+
const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '8403'}`;
|
|
20
20
|
|
|
21
21
|
const [, , cmd, ...args] = process.argv;
|
|
22
22
|
|
|
@@ -149,21 +149,23 @@ async function main() {
|
|
|
149
149
|
|
|
150
150
|
case 'task':
|
|
151
151
|
case 't': {
|
|
152
|
-
// Parse --new
|
|
152
|
+
// Parse --new and --tmux flags
|
|
153
153
|
const newSession = args.includes('--new');
|
|
154
|
-
const
|
|
154
|
+
const useTmux = args.includes('--tmux');
|
|
155
|
+
const filtered = args.filter(a => a !== '--new' && a !== '--tmux');
|
|
155
156
|
const project = filtered[0];
|
|
156
157
|
const prompt = filtered.slice(1).join(' ');
|
|
157
158
|
if (!project || !prompt) {
|
|
158
|
-
console.log('Usage: mw task <project> <prompt> [--new]');
|
|
159
|
-
console.log(' --new
|
|
159
|
+
console.log('Usage: mw task <project> <prompt> [--new] [--tmux]');
|
|
160
|
+
console.log(' --new Start a fresh session (ignore previous context)');
|
|
161
|
+
console.log(' --tmux Run via tmux backend (interactive mode, subscription billing)');
|
|
160
162
|
console.log('Example: mw task my-app "Fix the login bug"');
|
|
161
163
|
process.exit(1);
|
|
162
164
|
}
|
|
163
165
|
const task = await api('/api/tasks', {
|
|
164
166
|
method: 'POST',
|
|
165
167
|
headers: { 'Content-Type': 'application/json' },
|
|
166
|
-
body: JSON.stringify({ projectName: project, prompt, newSession }),
|
|
168
|
+
body: JSON.stringify({ projectName: project, prompt, newSession, ...(useTmux ? { backend: 'tmux' } : {}) }),
|
|
167
169
|
});
|
|
168
170
|
const session = task.conversationId ? '(continuing session)' : '(new session)';
|
|
169
171
|
console.log(`✓ Task ${task.id} created ${session}`);
|
package/components/Dashboard.tsx
CHANGED
|
@@ -14,6 +14,7 @@ const CodeViewer = lazy(() => import('./CodeViewer'));
|
|
|
14
14
|
const ProjectManager = lazy(() => import('./ProjectManager'));
|
|
15
15
|
const BrowserPanel = lazy(() => import('./BrowserPanel'));
|
|
16
16
|
const PipelineView = lazy(() => import('./PipelineView'));
|
|
17
|
+
const PipelineHistory = lazy(() => import('./PipelineHistory'));
|
|
17
18
|
const JobsView = lazy(() => import('./JobsView'));
|
|
18
19
|
const SchedulesView = lazy(() => import('./SchedulesView'));
|
|
19
20
|
const HelpDialog = lazy(() => import('./HelpDialog'));
|
|
@@ -103,7 +104,7 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
export default function Dashboard({ user }: { user: any }) {
|
|
106
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('
|
|
107
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'history' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('history');
|
|
107
108
|
|
|
108
109
|
// Honour `?view=<mode>` from the URL so external links (eg the VSCode
|
|
109
110
|
// extension) can deep-link straight into a section. Only views that have a
|
|
@@ -116,7 +117,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
116
117
|
if (raw) {
|
|
117
118
|
const aliases: Record<string, string> = { workspace: 'projects', sessions: 'projects' };
|
|
118
119
|
const v = aliases[raw] || raw;
|
|
119
|
-
const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
|
|
120
|
+
const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'history', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
|
|
120
121
|
if (valid.includes(v)) setViewMode(v as any);
|
|
121
122
|
}
|
|
122
123
|
// Optional deep-link to a specific pipeline run — used by the extension
|
|
@@ -137,6 +138,24 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
137
138
|
const [browserDragging, setBrowserDragging] = useState(false);
|
|
138
139
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
139
140
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
141
|
+
|
|
142
|
+
// Write the current section back into the URL so clicking a menu updates the
|
|
143
|
+
// address bar and links are shareable / bookmarkable. replaceState → the
|
|
144
|
+
// Dashboard never remounts (terminals stay alive) and history isn't spammed.
|
|
145
|
+
// Skip the very first run so the incoming deep-link (read on mount above) is
|
|
146
|
+
// not clobbered before it's consumed.
|
|
147
|
+
const didSyncUrl = useRef(false);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!didSyncUrl.current) { didSyncUrl.current = true; return; }
|
|
150
|
+
const params = new URLSearchParams(window.location.search);
|
|
151
|
+
params.set('view', viewMode);
|
|
152
|
+
if (viewMode === 'tasks' && activeTaskId) params.set('taskId', activeTaskId);
|
|
153
|
+
else params.delete('taskId');
|
|
154
|
+
// pipeline deep-link id only makes sense inside the pipelines view.
|
|
155
|
+
if (viewMode !== 'pipelines') { params.delete('pipeline'); params.delete('pipelineId'); }
|
|
156
|
+
window.history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`);
|
|
157
|
+
}, [viewMode, activeTaskId]);
|
|
158
|
+
|
|
140
159
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
141
160
|
const [showSettings, setShowSettings] = useState(false);
|
|
142
161
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
|
@@ -282,10 +301,14 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
282
301
|
// Listen for open-terminal events from ProjectManager
|
|
283
302
|
useEffect(() => {
|
|
284
303
|
const handler = (e: Event) => {
|
|
285
|
-
const { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv } = (e as CustomEvent).detail;
|
|
304
|
+
const { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv, tmuxSession, tmuxLabel } = (e as CustomEvent).detail;
|
|
286
305
|
setViewMode('terminal');
|
|
287
306
|
setTimeout(() => {
|
|
288
|
-
|
|
307
|
+
if (tmuxSession) {
|
|
308
|
+
terminalRef.current?.openExistingSession?.(tmuxSession, tmuxLabel || tmuxSession);
|
|
309
|
+
} else {
|
|
310
|
+
terminalRef.current?.openProjectTerminal?.(projectPath, projectName, agentId, resumeMode, sessionId, profileEnv);
|
|
311
|
+
}
|
|
289
312
|
}, 300);
|
|
290
313
|
};
|
|
291
314
|
window.addEventListener('forge:open-terminal', handler);
|
|
@@ -484,6 +507,27 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
484
507
|
|
|
485
508
|
{/* View mode toggle */}
|
|
486
509
|
<div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
510
|
+
{/* Automation — first. Sub-tabs: pipelines / tasks / schedules.
|
|
511
|
+
Jobs is deprecated and hidden from the nav (backend still
|
|
512
|
+
present in case of reversion, but no UI entry point). */}
|
|
513
|
+
<button
|
|
514
|
+
onClick={() => {
|
|
515
|
+
if (!['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)) setViewMode('history');
|
|
516
|
+
}}
|
|
517
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
518
|
+
['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)
|
|
519
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
520
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
521
|
+
}`}
|
|
522
|
+
>
|
|
523
|
+
Automation
|
|
524
|
+
</button>
|
|
525
|
+
{/* Activity sub-pill — sits next to Automation since its content
|
|
526
|
+
(running pipelines + upcoming schedules + recent runs) is the
|
|
527
|
+
live read-side of Automation. Click anywhere → dropdown with
|
|
528
|
+
3 sections + a "view" jump to the run. */}
|
|
529
|
+
<Suspense fallback={null}><ActivityPanel /></Suspense>
|
|
530
|
+
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
487
531
|
{/* Workspace */}
|
|
488
532
|
{(['terminal', 'projects'] as const).map(mode => (
|
|
489
533
|
<button
|
|
@@ -511,27 +555,6 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
511
555
|
Docs
|
|
512
556
|
</button>
|
|
513
557
|
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
514
|
-
{/* Automation — sub-tabs: tasks / pipelines / schedules.
|
|
515
|
-
Jobs is deprecated and hidden from the nav (backend still
|
|
516
|
-
present in case of reversion, but no UI entry point). */}
|
|
517
|
-
<button
|
|
518
|
-
onClick={() => {
|
|
519
|
-
if (!['tasks', 'pipelines', 'schedules'].includes(viewMode)) setViewMode('schedules');
|
|
520
|
-
}}
|
|
521
|
-
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
522
|
-
['tasks', 'pipelines', 'schedules'].includes(viewMode)
|
|
523
|
-
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
524
|
-
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
525
|
-
}`}
|
|
526
|
-
>
|
|
527
|
-
Automation
|
|
528
|
-
</button>
|
|
529
|
-
{/* Activity sub-pill — sits next to Automation since its content
|
|
530
|
-
(running pipelines + upcoming schedules + recent runs) is the
|
|
531
|
-
live read-side of Automation. Click anywhere → dropdown with
|
|
532
|
-
3 sections + a "view" jump to the run. */}
|
|
533
|
-
<Suspense fallback={null}><ActivityPanel /></Suspense>
|
|
534
|
-
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
535
558
|
{/* Marketplace */}
|
|
536
559
|
<button
|
|
537
560
|
onClick={() => setViewMode('skills')}
|
|
@@ -907,10 +930,10 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
907
930
|
|
|
908
931
|
{/* Automation secondary toolbar — sub-tabs + context actions.
|
|
909
932
|
Lives below the main header so it never squishes the top nav. */}
|
|
910
|
-
{['tasks', 'pipelines', 'schedules'].includes(viewMode) && (
|
|
933
|
+
{['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode) && (
|
|
911
934
|
<div className="h-8 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-tertiary)]/40">
|
|
912
935
|
<div className="flex items-center gap-1">
|
|
913
|
-
{(['
|
|
936
|
+
{(['history', 'pipelines', 'tasks', 'schedules'] as const).map((m) => (
|
|
914
937
|
<button
|
|
915
938
|
key={m}
|
|
916
939
|
onClick={() => setViewMode(m)}
|
|
@@ -920,7 +943,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
920
943
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
921
944
|
}`}
|
|
922
945
|
>
|
|
923
|
-
{{ tasks: 'Tasks', pipelines: 'Pipelines', schedules: 'Schedules' }[m]}
|
|
946
|
+
{{ tasks: 'Tasks', pipelines: 'Pipelines', schedules: 'Schedules', history: 'History' }[m]}
|
|
924
947
|
</button>
|
|
925
948
|
))}
|
|
926
949
|
{viewMode === 'tasks' && (
|
|
@@ -1054,6 +1077,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
1054
1077
|
</Suspense>
|
|
1055
1078
|
)}
|
|
1056
1079
|
|
|
1080
|
+
{/* History — flat list of all pipeline runs, expandable */}
|
|
1081
|
+
{viewMode === 'history' && (
|
|
1082
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
1083
|
+
<PipelineHistory
|
|
1084
|
+
onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }}
|
|
1085
|
+
onViewPipeline={(pipelineId) => { setPendingPipelineId(pipelineId); setViewMode('pipelines'); }}
|
|
1086
|
+
/>
|
|
1087
|
+
</Suspense>
|
|
1088
|
+
)}
|
|
1089
|
+
|
|
1057
1090
|
{/* Jobs — scheduled connector polls (Forge web read-mostly; create via extension) */}
|
|
1058
1091
|
{viewMode === 'jobs' && (
|
|
1059
1092
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
@@ -52,10 +52,12 @@ function InlineLiveLog({ log }: { log: TaskLogEntry[] }) {
|
|
|
52
52
|
|
|
53
53
|
// ─── DAG node card ────────────────────────────────────────
|
|
54
54
|
|
|
55
|
-
function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
|
|
55
|
+
function InlineDagNode({ nodeId, node, onViewTask }: { nodeId: string; node: any; onViewTask?: (taskId: string) => void }) {
|
|
56
56
|
const isRunning = node.status === 'running';
|
|
57
57
|
const log = useTaskStreamInline(node.taskId, isRunning);
|
|
58
58
|
const statusIcon = node.status === 'done' ? '✅' : node.status === 'failed' ? '❌' : node.status === 'running' ? '🔄' : node.status === 'skipped' ? '⏭' : '⏳';
|
|
59
|
+
const outputs: Record<string, string> = node.outputs || {};
|
|
60
|
+
const outputKeys = Object.keys(outputs).filter(k => (outputs[k] ?? '').length > 0);
|
|
59
61
|
return (
|
|
60
62
|
<div className={`border rounded p-2 ${
|
|
61
63
|
isRunning ? 'border-yellow-500/40 bg-yellow-500/5' :
|
|
@@ -66,18 +68,33 @@ function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
|
|
|
66
68
|
<div className="flex items-center gap-1.5 text-[9px]">
|
|
67
69
|
<span>{statusIcon}</span>
|
|
68
70
|
<span className="font-semibold text-[var(--text-primary)]">{nodeId}</span>
|
|
69
|
-
{node.taskId &&
|
|
71
|
+
{node.taskId && (
|
|
72
|
+
onViewTask
|
|
73
|
+
? <button onClick={() => onViewTask(node.taskId)} className="text-[7px] text-[var(--accent)] font-mono hover:underline">task:{node.taskId} ↗</button>
|
|
74
|
+
: <span className="text-[7px] text-[var(--accent)] font-mono">task:{node.taskId}</span>
|
|
75
|
+
)}
|
|
70
76
|
<span className="text-[var(--text-secondary)] ml-auto">{node.status}</span>
|
|
71
77
|
</div>
|
|
72
78
|
{isRunning && <div className="mt-1.5"><InlineLiveLog log={log} /></div>}
|
|
73
|
-
{node.error && <div className="text-[8px] text-red-400 mt-1">{node.error}</div>}
|
|
79
|
+
{node.error && <div className="text-[8px] text-red-400 mt-1 whitespace-pre-wrap break-words">{node.error}</div>}
|
|
80
|
+
{outputKeys.map((key) => {
|
|
81
|
+
const isDiff = key === 'diff' || /diff/i.test(key);
|
|
82
|
+
return (
|
|
83
|
+
<details key={key} className="mt-1 text-[8px]">
|
|
84
|
+
<summary className="cursor-pointer text-[var(--accent)]">
|
|
85
|
+
{isDiff ? 'diff' : key === 'result' || key === 'report' || key === 'summary' ? `result: ${key}` : `output: ${key}`} ({outputs[key].length} chars)
|
|
86
|
+
</summary>
|
|
87
|
+
<pre className="mt-1 max-h-[200px] overflow-auto bg-[var(--bg-primary)] rounded p-1.5 whitespace-pre-wrap break-words text-[var(--text-secondary)]">{outputs[key].slice(0, 8000)}{outputs[key].length > 8000 ? '\n…(truncated)' : ''}</pre>
|
|
88
|
+
</details>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
74
91
|
</div>
|
|
75
92
|
);
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
// ─── Main component ───────────────────────────────────────
|
|
79
96
|
|
|
80
|
-
export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline: any; onRefresh: () => void }) {
|
|
97
|
+
export default function InlinePipelineView({ pipeline, onRefresh, onViewTask }: { pipeline: any; onRefresh: () => void; onViewTask?: (taskId: string) => void }) {
|
|
81
98
|
useEffect(() => {
|
|
82
99
|
if (pipeline.status !== 'running') return;
|
|
83
100
|
const timer = setInterval(onRefresh, 3000);
|
|
@@ -97,7 +114,7 @@ export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline:
|
|
|
97
114
|
) : (
|
|
98
115
|
<div className="px-3 py-2 space-y-1.5">
|
|
99
116
|
{pipeline.nodeOrder.map((nodeId: string) => (
|
|
100
|
-
<InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} />
|
|
117
|
+
<InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} onViewTask={onViewTask} />
|
|
101
118
|
))}
|
|
102
119
|
</div>
|
|
103
120
|
)}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
|
4
|
+
|
|
5
|
+
const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
|
|
6
|
+
|
|
7
|
+
// Light summary shape returned by GET /api/pipelines (heavy node outputs stripped).
|
|
8
|
+
interface PipelineSummary {
|
|
9
|
+
id: string;
|
|
10
|
+
workflowName: string;
|
|
11
|
+
status: 'running' | 'done' | 'failed' | 'cancelled';
|
|
12
|
+
type?: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
completedAt?: string;
|
|
15
|
+
nodeOrder: string[];
|
|
16
|
+
nodes: Record<string, { status: string; iterations?: number }>;
|
|
17
|
+
input?: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STATUS_META: Record<string, { icon: string; cls: string }> = {
|
|
21
|
+
running: { icon: '🔄', cls: 'text-yellow-400' },
|
|
22
|
+
done: { icon: '✅', cls: 'text-green-400' },
|
|
23
|
+
failed: { icon: '❌', cls: 'text-red-400' },
|
|
24
|
+
cancelled: { icon: '⏹', cls: 'text-[var(--text-secondary)]' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function relTime(iso?: string): string {
|
|
28
|
+
if (!iso) return '';
|
|
29
|
+
const t = new Date(iso).getTime();
|
|
30
|
+
if (Number.isNaN(t)) return '';
|
|
31
|
+
const s = Math.floor((Date.now() - t) / 1000);
|
|
32
|
+
if (s < 60) return `${s}s ago`;
|
|
33
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
34
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
35
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A compact one-liner of the most useful input fields. */
|
|
39
|
+
function inputSummary(input?: Record<string, string>): string {
|
|
40
|
+
if (!input) return '';
|
|
41
|
+
const keys = ['bug_id', 'mr_url', 'mr_iid', 'project', 'issue_id', 'base_branch'];
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
for (const k of keys) {
|
|
44
|
+
if (input[k]) parts.push(`${k}=${String(input[k]).slice(0, 40)}`);
|
|
45
|
+
}
|
|
46
|
+
return parts.join(' · ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function PipelineHistory({ onViewTask, onViewPipeline }: {
|
|
50
|
+
onViewTask: (taskId: string) => void;
|
|
51
|
+
onViewPipeline: (pipelineId: string) => void;
|
|
52
|
+
}) {
|
|
53
|
+
const PAGE = 20;
|
|
54
|
+
const [list, setList] = useState<PipelineSummary[]>([]);
|
|
55
|
+
const [loading, setLoading] = useState(true);
|
|
56
|
+
const [hasMore, setHasMore] = useState(false);
|
|
57
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
58
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
59
|
+
const [fullById, setFullById] = useState<Record<string, any>>({});
|
|
60
|
+
const [statusFilter, setStatusFilter] = useState<'all' | 'running' | 'done' | 'failed'>('all');
|
|
61
|
+
const [search, setSearch] = useState('');
|
|
62
|
+
const [showCleanup, setShowCleanup] = useState(false);
|
|
63
|
+
const expandedRef = useRef(expandedId);
|
|
64
|
+
expandedRef.current = expandedId;
|
|
65
|
+
|
|
66
|
+
// Merge a fetched page into the list by id (fresh copy wins), newest-first.
|
|
67
|
+
// Lets the 4s poll refresh running runs without dropping older loaded pages.
|
|
68
|
+
const mergeIn = useCallback((incoming: PipelineSummary[]) => {
|
|
69
|
+
setList(prev => {
|
|
70
|
+
const byId = new Map(prev.map(p => [p.id, p]));
|
|
71
|
+
for (const p of incoming) byId.set(p.id, p);
|
|
72
|
+
return [...byId.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
73
|
+
});
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
// First page (newest 20). Also the 4s poll target — keeps running runs fresh.
|
|
77
|
+
const fetchFirst = useCallback(async () => {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`/api/pipelines?limit=${PAGE}`);
|
|
80
|
+
if (res.ok) {
|
|
81
|
+
const page: PipelineSummary[] = await res.json();
|
|
82
|
+
mergeIn(page);
|
|
83
|
+
setHasMore(page.length === PAGE);
|
|
84
|
+
}
|
|
85
|
+
} catch { /* keep prior list */ }
|
|
86
|
+
finally { setLoading(false); }
|
|
87
|
+
}, [mergeIn]);
|
|
88
|
+
|
|
89
|
+
// Cursor pagination: fetch the next 20 older than the oldest loaded run.
|
|
90
|
+
const loadMore = useCallback(async () => {
|
|
91
|
+
setLoadingMore(true);
|
|
92
|
+
try {
|
|
93
|
+
const oldest = list.length ? list[list.length - 1].createdAt : undefined;
|
|
94
|
+
const res = await fetch(`/api/pipelines?limit=${PAGE}${oldest ? `&before=${encodeURIComponent(oldest)}` : ''}`);
|
|
95
|
+
if (res.ok) {
|
|
96
|
+
const page: PipelineSummary[] = await res.json();
|
|
97
|
+
mergeIn(page);
|
|
98
|
+
setHasMore(page.length === PAGE);
|
|
99
|
+
}
|
|
100
|
+
} catch { /* ignore */ }
|
|
101
|
+
finally { setLoadingMore(false); }
|
|
102
|
+
}, [list, mergeIn]);
|
|
103
|
+
|
|
104
|
+
const fetchFull = useCallback(async (id: string) => {
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetch(`/api/pipelines/${id}`);
|
|
107
|
+
if (res.ok) {
|
|
108
|
+
const full = await res.json();
|
|
109
|
+
setFullById(prev => ({ ...prev, [id]: full }));
|
|
110
|
+
}
|
|
111
|
+
} catch { /* ignore */ }
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
// Initial load + poll every 4s so running pipelines update live.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
fetchFirst();
|
|
117
|
+
const t = setInterval(() => {
|
|
118
|
+
fetchFirst();
|
|
119
|
+
const id = expandedRef.current;
|
|
120
|
+
if (id) fetchFull(id); // keep the open run fresh while it runs
|
|
121
|
+
}, 4000);
|
|
122
|
+
return () => clearInterval(t);
|
|
123
|
+
}, [fetchFirst, fetchFull]);
|
|
124
|
+
|
|
125
|
+
const toggle = (id: string) => {
|
|
126
|
+
if (expandedId === id) { setExpandedId(null); return; }
|
|
127
|
+
setExpandedId(id);
|
|
128
|
+
if (!fullById[id]) fetchFull(id);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const op = async (id: string, action: 'cancel' | 'delete') => {
|
|
132
|
+
if (action === 'delete' && !confirm('Delete this pipeline run? This cannot be undone.')) return;
|
|
133
|
+
try {
|
|
134
|
+
await fetch(`/api/pipelines/${id}`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ action }),
|
|
138
|
+
});
|
|
139
|
+
if (action === 'delete') { setExpandedId(null); setList(prev => prev.filter(p => p.id !== id)); }
|
|
140
|
+
fetchFirst();
|
|
141
|
+
if (action === 'cancel') fetchFull(id);
|
|
142
|
+
} catch { /* ignore */ }
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Unified cleanup — bulk-delete terminal (done/failed/cancelled) runs.
|
|
146
|
+
// Running/pending runs are always skipped server-side.
|
|
147
|
+
const cleanup = async (olderThanDays: number, label: string) => {
|
|
148
|
+
if (!confirm(`Delete ${label}? Running pipelines are kept. This cannot be undone.`)) return;
|
|
149
|
+
setShowCleanup(false);
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch('/api/pipelines/bulk-delete', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ older_than_days: olderThanDays }),
|
|
155
|
+
});
|
|
156
|
+
const data = res.ok ? await res.json() : null;
|
|
157
|
+
// Many rows gone — rebuild the list from scratch.
|
|
158
|
+
setExpandedId(null);
|
|
159
|
+
setList([]);
|
|
160
|
+
setLoading(true);
|
|
161
|
+
await fetchFirst();
|
|
162
|
+
if (data) alert(`Cleaned up ${data.removed} pipeline run${data.removed === 1 ? '' : 's'}.`);
|
|
163
|
+
} catch { /* ignore */ }
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const filtered = list.filter(p => {
|
|
167
|
+
if (statusFilter !== 'all' && p.status !== statusFilter) return false;
|
|
168
|
+
if (search) {
|
|
169
|
+
const hay = `${p.workflowName} ${inputSummary(p.input)} ${p.id}`.toLowerCase();
|
|
170
|
+
if (!hay.includes(search.toLowerCase())) return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const runningCount = list.filter(p => p.status === 'running').length;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
179
|
+
{/* Filter bar */}
|
|
180
|
+
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
181
|
+
<div className="flex items-center gap-1">
|
|
182
|
+
{(['all', 'running', 'done', 'failed'] as const).map(s => (
|
|
183
|
+
<button
|
|
184
|
+
key={s}
|
|
185
|
+
onClick={() => setStatusFilter(s)}
|
|
186
|
+
className={`text-[11px] px-2 py-0.5 rounded ${
|
|
187
|
+
statusFilter === s
|
|
188
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)]'
|
|
189
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
190
|
+
}`}
|
|
191
|
+
>
|
|
192
|
+
{s === 'all' ? 'All' : s[0].toUpperCase() + s.slice(1)}
|
|
193
|
+
{s === 'running' && runningCount > 0 ? ` (${runningCount})` : ''}
|
|
194
|
+
</button>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
<input
|
|
198
|
+
value={search}
|
|
199
|
+
onChange={e => setSearch(e.target.value)}
|
|
200
|
+
placeholder="Filter by workflow / bug_id / id…"
|
|
201
|
+
className="text-[11px] px-2 py-0.5 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] text-[var(--text-primary)] flex-1 max-w-[320px] outline-none focus:border-[var(--accent)]"
|
|
202
|
+
/>
|
|
203
|
+
<span className="text-[10px] text-[var(--text-secondary)] ml-auto">{filtered.length} run{filtered.length === 1 ? '' : 's'}</span>
|
|
204
|
+
{/* Unified cleanup */}
|
|
205
|
+
<div className="relative">
|
|
206
|
+
<button
|
|
207
|
+
onClick={() => setShowCleanup(v => !v)}
|
|
208
|
+
className="text-[11px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border)]"
|
|
209
|
+
title="Bulk-delete finished pipeline runs"
|
|
210
|
+
>
|
|
211
|
+
Cleanup ▾
|
|
212
|
+
</button>
|
|
213
|
+
{showCleanup && (
|
|
214
|
+
<>
|
|
215
|
+
<div className="fixed inset-0 z-10" onClick={() => setShowCleanup(false)} />
|
|
216
|
+
<div className="absolute right-0 mt-1 z-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg py-1 w-56 text-[11px]">
|
|
217
|
+
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase tracking-wide">Delete finished runs</div>
|
|
218
|
+
<button onClick={() => cleanup(30, 'finished runs older than 30 days')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]">Older than 30 days</button>
|
|
219
|
+
<button onClick={() => cleanup(7, 'finished runs older than 7 days')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]">Older than 7 days</button>
|
|
220
|
+
<button onClick={() => cleanup(0, 'ALL finished runs')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-red-400">All finished (done/failed/cancelled)</button>
|
|
221
|
+
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] border-t border-[var(--border)] mt-1">Running pipelines are always kept.</div>
|
|
222
|
+
</div>
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
<button onClick={fetchFirst} className="text-[11px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]" title="Refresh">↻</button>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* List */}
|
|
230
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
231
|
+
{loading && list.length === 0 ? (
|
|
232
|
+
<div className="flex items-center justify-center h-32 text-[var(--text-secondary)] text-[12px]">Loading…</div>
|
|
233
|
+
) : filtered.length === 0 ? (
|
|
234
|
+
<div className="flex items-center justify-center h-32 text-[var(--text-secondary)] text-[12px]">No pipeline runs.</div>
|
|
235
|
+
) : (
|
|
236
|
+
<div className="divide-y divide-[var(--border)]">
|
|
237
|
+
{filtered.map(p => {
|
|
238
|
+
const meta = STATUS_META[p.status] || STATUS_META.cancelled;
|
|
239
|
+
const total = p.nodeOrder.length;
|
|
240
|
+
const done = Object.values(p.nodes).filter(n => n.status === 'done').length;
|
|
241
|
+
const isOpen = expandedId === p.id;
|
|
242
|
+
const full = fullById[p.id];
|
|
243
|
+
return (
|
|
244
|
+
<div key={p.id}>
|
|
245
|
+
{/* Row header */}
|
|
246
|
+
<div
|
|
247
|
+
onClick={() => toggle(p.id)}
|
|
248
|
+
className="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-[var(--bg-tertiary)]/40"
|
|
249
|
+
>
|
|
250
|
+
<span className={`text-[11px] w-4 text-center transition-transform ${isOpen ? 'rotate-90' : ''}`}>▸</span>
|
|
251
|
+
<span className="text-[12px]">{meta.icon}</span>
|
|
252
|
+
<span className="text-[12px] font-medium text-[var(--text-primary)] truncate max-w-[220px]">{p.workflowName}</span>
|
|
253
|
+
<span className={`text-[10px] ${meta.cls}`}>{p.status}</span>
|
|
254
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{done}/{total} steps</span>
|
|
255
|
+
{inputSummary(p.input) && (
|
|
256
|
+
<span className="text-[10px] text-[var(--text-secondary)] truncate max-w-[280px] font-mono">{inputSummary(p.input)}</span>
|
|
257
|
+
)}
|
|
258
|
+
<span className="text-[10px] text-[var(--text-secondary)] ml-auto whitespace-nowrap">{relTime(p.createdAt)}</span>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{/* Expanded detail */}
|
|
262
|
+
{isOpen && (
|
|
263
|
+
<div className="bg-[var(--bg-tertiary)]/30 border-t border-[var(--border)]">
|
|
264
|
+
{/* Action bar */}
|
|
265
|
+
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px]">
|
|
266
|
+
<button
|
|
267
|
+
onClick={() => onViewPipeline(p.id)}
|
|
268
|
+
className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25"
|
|
269
|
+
>
|
|
270
|
+
Open in Pipelines ↗
|
|
271
|
+
</button>
|
|
272
|
+
{p.status === 'running' && (
|
|
273
|
+
<button onClick={() => op(p.id, 'cancel')} className="px-2 py-0.5 rounded text-yellow-400 hover:bg-yellow-400/10">Cancel</button>
|
|
274
|
+
)}
|
|
275
|
+
<button onClick={() => op(p.id, 'delete')} className="px-2 py-0.5 rounded text-red-400 hover:bg-red-400/10">Delete</button>
|
|
276
|
+
<span className="text-[10px] text-[var(--text-secondary)] ml-auto font-mono">id:{p.id}</span>
|
|
277
|
+
</div>
|
|
278
|
+
{full ? (
|
|
279
|
+
<Suspense fallback={<div className="px-4 py-2 text-[10px] text-[var(--text-secondary)]">Loading detail…</div>}>
|
|
280
|
+
<InlinePipelineView pipeline={full} onRefresh={() => fetchFull(p.id)} onViewTask={onViewTask} />
|
|
281
|
+
</Suspense>
|
|
282
|
+
) : (
|
|
283
|
+
<div className="px-4 py-3 text-[10px] text-[var(--text-secondary)]">Loading detail…</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
})}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
{hasMore && !(loading && list.length === 0) && (
|
|
293
|
+
<div className="flex justify-center py-3">
|
|
294
|
+
<button
|
|
295
|
+
onClick={loadMore}
|
|
296
|
+
disabled={loadingMore}
|
|
297
|
+
className="text-[11px] px-3 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
298
|
+
>
|
|
299
|
+
{loadingMore ? 'Loading…' : 'Load more'}
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|