add pipeline job management with /jobs pages and per-step output viewer

- Pipeline jobs now persist to DB with progress tracking and cost accumulation
- Jobs survive WebSocket disconnects with in-memory event buffer replay
- New /jobs list page with search, status badges, and cost display
- New /jobs/:id detail page with live WebSocket attachment and REST fallback
- Two-column layout using WorkspaceLayout for resizable steps/output panels
- Streaming messages tagged with stepIndex/iterationLabel for per-step output grouping
- TaskRunnerModal links to job detail page once job is created
- Dock entry added for Jobs page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 08:48:50 +00:00
co-authored by Claude Opus 4.6
parent 22c60d4a57
commit 5e38343f44
22 changed files with 4506 additions and 252 deletions
+2
View File
@@ -64,6 +64,8 @@ export function App() {
<Route path="/tasks" element={<Dashboard.Tasks />} />
<Route path="/processes" element={<Dashboard.Processes />} />
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
<Route path="/jobs" element={<Dashboard.JobsScreen />} />
<Route path="/jobs/:id" element={<Dashboard.JobDetail />} />
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
@@ -0,0 +1,774 @@
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import {
ArrowLeft, CheckCircle2, AlertCircle, Loader2, StopCircle, AlertTriangle, Clock, Square,
ChevronRight, Wrench,
} from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
import { Button } from '@/components/ui/button';
import { WorkspaceLayout } from 'officerdev';
import type { LayoutNode, PanelComponents } from 'officerdev';
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
type StepDef = { task: string; foreach?: string; concurrency?: number };
type IterationStatus = {
label: string;
status: 'pending' | 'running' | 'complete' | 'error';
error?: string;
cost?: Cost;
};
type ParallelStep = {
stepIndex: number;
taskName: string;
concurrency: number;
iterations: IterationStatus[];
};
type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
type ProgressData = {
steps?: StepDef[];
currentStepIndex?: number;
parallel?: { taskName: string; concurrency: number; iterations: Array<{ label: string; status: string }> };
};
type JobData = {
id: string;
taskDirName: string;
taskName: string;
status: JobStatus;
totalCost: Cost | null;
progress: ProgressData | null;
error: string | null;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
};
// Output entries for the right panel
type OutputEntry =
| { id: string; type: 'text'; text: string }
| { id: string; type: 'tool'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; output?: string; isError?: boolean };
type ServerMessage =
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
| { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: Cost }
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: Cost }
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
| { jobId: string; type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'pipeline:complete'; totalCost: Cost }
| { jobId: string; type: 'error'; message: string }
| { jobId: string; type: 'stopped' }
| { type: 'job:state'; jobId: string; status: string; progress: unknown; cost: unknown };
const formatDate = (iso: string) => {
const d = new Date(iso);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const formatElapsed = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
};
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
const formatTokens = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
/** Build a unique key for grouping output by step/iteration */
const outputKey = (stepIndex: number, iterationLabel?: string) =>
iterationLabel ? `${stepIndex}:${iterationLabel}` : `${stepIndex}`;
const StatusIcon = ({ status, className }: { status: string; className?: string }) => {
const base = className ?? 'h-4 w-4 shrink-0';
switch (status) {
case 'completed':
case 'complete':
return <CheckCircle2 className={`${base} text-green-500`} />;
case 'failed':
case 'error':
return <AlertCircle className={`${base} text-red-500`} />;
case 'running':
return <Loader2 className={`${base} text-blue-500 animate-spin`} />;
case 'stopped':
return <StopCircle className={`${base} text-amber-500`} />;
case 'interrupted':
return <AlertTriangle className={`${base} text-amber-500`} />;
default:
return <Clock className={`${base} text-duck-dark/30`} />;
}
};
type ToolCallEntryProps = {
entry: Extract<OutputEntry, { type: 'tool' }>;
};
const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
const [expanded, setExpanded] = useState(false);
return (
<div className="border border-duck-dark/10 rounded-md overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-2.5 py-1.5 bg-duck-dark/[0.03] hover:bg-duck-dark/[0.06] transition-colors cursor-pointer"
>
<Wrench className="h-3 w-3 text-duck-dark/40 shrink-0" />
<span className="text-duck-dark/60 font-medium">{entry.toolName}</span>
{entry.output !== undefined && (
<StatusIcon
status={entry.isError ? 'error' : 'complete'}
className="h-3 w-3 shrink-0 ml-auto"
/>
)}
{entry.output === undefined && (
<Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />
)}
<ChevronRight className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
{expanded && (
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
<div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Input</div>
<pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto">
{JSON.stringify(entry.toolInput, null, 2)}
</pre>
</div>
{entry.output !== undefined && (
<div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
<pre className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}>
{entry.output}
</pre>
</div>
)}
</div>
)}
</div>
);
};
// Context to share state between workspace panels
type JobPanelContext = {
displaySteps: StepDef[];
isLive: boolean;
isRunning: boolean;
completedSteps: Set<number>;
activeStepIndex: number;
progressStepIndex: number;
jobStatus: JobStatus;
selectedKey: string | null;
selectOutput: (key: string) => void;
displayParallel: ParallelStep | null;
skippedItems: Array<{ label: string; reason: string }>;
outputMap: Map<string, OutputEntry[]>;
streamingMap: Map<string, string>;
outputPanelRef: React.RefObject<HTMLDivElement | null>;
};
const JobPanelCtx = createContext<JobPanelContext | null>(null);
const useJobPanel = () => useContext(JobPanelCtx)!;
const DEFAULT_LAYOUT: LayoutNode = {
type: 'group',
id: 'root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'steps', appType: null }, size: 30 },
{ node: { type: 'panel', id: 'output', appType: null }, size: 70 },
],
};
const StepsPanel = () => {
const ctx = useJobPanel();
const {
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, jobStatus, selectedKey, selectOutput, displayParallel,
skippedItems, outputMap,
} = ctx;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="px-3 py-2 border-b border-duck-dark/10">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Steps</h2>
</div>
<div className="flex-1 overflow-y-auto">
{displaySteps.map((step, i) => {
let status: string = 'pending';
if (isLive || isRunning) {
if (completedSteps.has(i)) {
status = 'complete';
} else if (activeStepIndex === i) {
status = 'running';
} else if (i < activeStepIndex) {
status = 'complete';
}
} else {
if (jobStatus === 'completed') {
status = 'complete';
} else if (progressStepIndex >= 0 && i < progressStepIndex) {
status = 'complete';
} else if (i === progressStepIndex) {
status = jobStatus === 'failed' || jobStatus === 'interrupted' ? 'error' : 'complete';
}
}
const stepKey = outputKey(i);
const isSelected = selectedKey === stepKey && !displayParallel?.stepIndex;
const hasOutput = outputMap.has(stepKey);
return (
<div key={i}>
<button
onClick={() => selectOutput(stepKey)}
className={`w-full text-left flex items-center gap-2 px-3 py-2 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${isSelected ? 'bg-duck-teal/10' : ''}`}
>
<div className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-medium bg-duck-dark/10 text-duck-dark/50 shrink-0">
{i + 1}
</div>
<StatusIcon status={status} className="h-3 w-3 shrink-0" />
<span className="text-xs text-duck-dark flex-1 truncate">{step.task}</span>
{hasOutput && <ChevronRight className="h-3 w-3 text-duck-dark/20 shrink-0" />}
</button>
{/* Parallel iterations nested under their step */}
{displayParallel && displayParallel.stepIndex === i && (
<div className="bg-duck-dark/[0.02]">
{displayParallel.iterations.map((it) => {
const itKey = outputKey(i, it.label);
const itSelected = selectedKey === itKey;
const itHasOutput = outputMap.has(itKey);
return (
<button
key={it.label}
onClick={() => selectOutput(itKey)}
className={`w-full text-left flex items-center gap-2 pl-9 pr-3 py-1.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${itSelected ? 'bg-duck-teal/10' : ''}`}
>
<StatusIcon status={it.status} className="h-3 w-3 shrink-0" />
<span className="text-xs text-duck-dark/80 flex-1 truncate">{it.label}</span>
{it.cost && (
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">{formatCost(it.cost.totalUSD)}</span>
)}
{itHasOutput && <ChevronRight className="h-3 w-3 text-duck-dark/20 shrink-0" />}
</button>
);
})}
{skippedItems.length > 0 && (
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">
{skippedItems.length} skipped
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
const OutputPanel = () => {
const ctx = useJobPanel();
const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx;
const selectedOutputs = selectedKey ? (outputMap.get(selectedKey) ?? []) : [];
const selectedStreaming = selectedKey ? (streamingMap.get(selectedKey) ?? '') : '';
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
{selectedKey && (
<span className="text-xs text-duck-dark/40 truncate">{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}</span>
)}
</div>
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
{!selectedKey && (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
Select a step or sub-task to view output
</div>
)}
{selectedKey && selectedOutputs.length === 0 && !selectedStreaming && (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
No output yet
</div>
)}
{selectedOutputs.map((entry) => {
if (entry.type === 'text') {
return (
<div key={entry.id} className="text-duck-dark/80 whitespace-pre-wrap break-words leading-relaxed">
{entry.text}
</div>
);
}
return (
<ToolCallEntry key={entry.id} entry={entry} />
);
})}
{selectedStreaming && (
<div className="text-duck-dark/60 whitespace-pre-wrap break-words leading-relaxed">
{selectedStreaming}
<span className="animate-pulse">|</span>
</div>
)}
</div>
</div>
);
};
export const JobDetail = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const client = useClient();
const [job, setJob] = useState<JobData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLive, setIsLive] = useState(false);
// Live state — track by step index
const [steps, setSteps] = useState<StepDef[]>([]);
const [activeStepIndex, setActiveStepIndex] = useState<number>(-1);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [parallelStep, setParallelStep] = useState<ParallelStep | null>(null);
const [runningCost, setRunningCost] = useState<Cost>({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
const [totalCost, setTotalCost] = useState<Cost | null>(null);
const [hasError, setHasError] = useState(false);
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
const [elapsed, setElapsed] = useState(0);
const [liveStatus, setLiveStatus] = useState<'running' | 'done'>('running');
// Per-step/iteration output tracking
const [outputMap, setOutputMap] = useState<Map<string, OutputEntry[]>>(new Map());
const [streamingMap, setStreamingMap] = useState<Map<string, string>>(new Map());
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const streamBuffers = useRef<Map<string, string>>(new Map());
const outputPanelRef = useRef<HTMLDivElement | null>(null);
const autoFollowRef = useRef(true);
const [layout, setLayout] = useState<LayoutNode>(DEFAULT_LAYOUT);
const wsRef = useRef<WebSocket | null>(null);
const startTimeRef = useRef(Date.now());
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopTimer = useCallback(() => {
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
}, []);
const addCost = useCallback((cost: Cost) => {
setRunningCost((prev) => ({
inputTokens: prev.inputTokens + cost.inputTokens,
outputTokens: prev.outputTokens + cost.outputTokens,
totalUSD: prev.totalUSD + cost.totalUSD,
}));
}, []);
const appendOutput = useCallback((key: string, entry: OutputEntry) => {
setOutputMap((prev) => {
const next = new Map(prev);
const arr = next.get(key) ?? [];
next.set(key, [...arr, entry]);
return next;
});
}, []);
const updateToolOutput = useCallback((key: string, toolCallId: string, output: string, isError: boolean) => {
setOutputMap((prev) => {
const arr = prev.get(key);
if (!arr) return prev;
const next = new Map(prev);
next.set(key, arr.map((e) =>
e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e,
));
return next;
});
}, []);
// Auto-scroll output panel
useEffect(() => {
if (outputPanelRef.current) {
outputPanelRef.current.scrollTop = outputPanelRef.current.scrollHeight;
}
}, [outputMap, streamingMap, selectedKey]);
// Fetch job from REST API
useEffect(() => {
if (!id) return;
client
.get<JobData>(`/pipeline-jobs/${id}`)
.then((data) => {
setJob(data);
setIsLoading(false);
})
.catch(() => {
setIsLoading(false);
});
}, [id]);
// Connect WebSocket for live attachment
useEffect(() => {
if (!id || !job) return;
if (job.status !== 'running' && job.status !== 'pending') return;
const token = localStorage.getItem('BEARER_TOKEN');
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/api/tasks/pipeline/ws?token=${token}`;
const ws = new WebSocket(url);
wsRef.current = ws;
startTimeRef.current = job.startedAt ? new Date(job.startedAt).getTime() : Date.now();
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'attach', jobId: id }));
});
ws.addEventListener('message', (ev) => {
try {
const msg = JSON.parse(ev.data) as ServerMessage;
handleEvent(msg);
} catch {
// ignore
}
});
ws.addEventListener('close', () => {
setIsLive(false);
});
return () => {
ws.close();
wsRef.current = null;
stopTimer();
};
}, [id, job?.status]);
const handleEvent = useCallback((msg: ServerMessage) => {
switch (msg.type) {
case 'job:state':
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
setLiveStatus('done');
if (msg.cost) setTotalCost(msg.cost as Cost);
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
stopTimer();
if (id) {
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
}
}
if (msg.progress) {
const p = msg.progress as ProgressData;
if (p?.steps) setSteps(p.steps);
}
break;
case 'pipeline:init':
setIsLive(true);
setSteps(msg.steps);
break;
case 'step:start': {
setParallelStep(null);
setActiveStepIndex(msg.stepIndex);
const key = msg.iteration
? outputKey(msg.stepIndex, msg.iteration.label)
: outputKey(msg.stepIndex);
if (autoFollowRef.current) setSelectedKey(key);
break;
}
case 'step:complete':
setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex));
setParallelStep(null);
setActiveStepIndex(-1);
if (msg.cost) addCost(msg.cost);
// Flush any remaining stream buffer for this step
flushStreamBuffer(outputKey(msg.stepIndex));
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:parallel':
setActiveStepIndex(msg.stepIndex);
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
// Auto-select first iteration
if (autoFollowRef.current && msg.iterations.length > 0) {
setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0]));
}
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'running' } : it,
),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'assistant:delta': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const buf = streamBuffers.current;
buf.set(key, (buf.get(key) ?? '') + msg.text);
setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!));
break;
}
case 'assistant:text': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const text = msg.text || streamBuffers.current.get(key) || '';
if (text) {
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
}
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
break;
}
case 'tool:start': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
// Flush any streaming text before the tool call
flushStreamBuffer(key);
appendOutput(key, {
id: crypto.randomUUID(),
type: 'tool',
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
});
break;
}
case 'tool:result': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
updateToolOutput(key, msg.toolCallId, msg.output, msg.isError);
break;
}
case 'pipeline:complete':
setTotalCost(msg.totalCost);
setLiveStatus('done');
setCompletedSteps((prev) => {
const next = new Set(prev);
setSteps((s) => { s.forEach((_, i) => next.add(i)); return s; });
return next;
});
setActiveStepIndex(-1);
setParallelStep(null);
stopTimer();
if (id) {
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
}
break;
case 'error':
setHasError(true);
setLiveStatus('done');
stopTimer();
break;
case 'stopped':
setLiveStatus('done');
stopTimer();
break;
}
}, [id, stopTimer, addCost, appendOutput, updateToolOutput]);
const flushStreamBuffer = useCallback((key: string) => {
const text = streamBuffers.current.get(key);
if (text) {
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
}
}, [appendOutput]);
const handleStop = useCallback(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) {
wsRef.current.send(JSON.stringify({ type: 'stop', jobId: id }));
}
}, [id]);
const selectOutput = useCallback((key: string) => {
autoFollowRef.current = false;
setSelectedKey(key);
}, []);
const panelComponents: PanelComponents = useMemo(() => ({
steps: StepsPanel,
output: OutputPanel,
}), []);
const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending';
const isRunning = displayStatus === 'running';
const displaySteps = steps.length > 0 ? steps : (job?.progress?.steps ?? []);
const jobDone = !isRunning && !isLive;
const progressStepIndex = job?.progress?.currentStepIndex ?? -1;
const displayParallel: ParallelStep | null = parallelStep ?? (jobDone && job?.progress?.parallel ? {
stepIndex: progressStepIndex,
taskName: job.progress.parallel.taskName,
concurrency: job.progress.parallel.concurrency,
iterations: job.progress.parallel.iterations.map((it) => ({
label: it.label,
status: it.status as IterationStatus['status'],
})),
} : null);
const panelCtx = useMemo<JobPanelContext>(() => ({
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, jobStatus: job?.status ?? 'pending', selectedKey, selectOutput,
displayParallel, skippedItems, outputMap, streamingMap, outputPanelRef,
}), [
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, job?.status, selectedKey, selectOutput,
displayParallel, skippedItems, outputMap, streamingMap,
]);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>
);
}
if (!job) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
<span>Job not found</span>
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">Back to jobs</Link>
</div>
);
}
const displayCost = totalCost ?? (runningCost.totalUSD > 0 ? runningCost : null) ?? job.totalCost;
const displayElapsed = isRunning
? elapsed
: job.startedAt && job.completedAt
? Math.floor((new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime()) / 1000)
: elapsed > 0 ? elapsed : null;
return (
<div className="flex h-full flex-col p-3 md:p-6 gap-4">
{/* Header + stats bar */}
<Card className="px-4 py-3 shrink-0">
<div className="flex items-center gap-3">
<button onClick={() => navigate('/jobs')} className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<StatusIcon status={displayStatus} className="h-5 w-5" />
<h1 className="text-lg font-semibold text-duck-dark truncate">{job.taskName}</h1>
</div>
<div className="text-xs text-duck-dark/50 mt-0.5 ml-7">
{formatDate(job.createdAt)}
{job.completedAt && `${formatDate(job.completedAt)}`}
</div>
</div>
{isRunning && (
<Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5">
<Square className="h-3 w-3" />
Stop
</Button>
)}
</div>
{(displayCost || displayElapsed !== null) && (
<div className="flex items-center gap-6 text-xs mt-3 pt-3 border-t border-duck-dark/10 ml-9">
{displayElapsed !== null && (
<div className="flex items-center gap-1.5 text-duck-dark/60">
<Clock className="h-3.5 w-3.5" />
<span className="font-mono tabular-nums">{formatElapsed(displayElapsed)}</span>
</div>
)}
{displayCost && (
<>
<div className="flex items-center gap-1.5 text-duck-dark/60">
<span className="text-duck-dark/40">Tokens:</span>
<span className="font-mono tabular-nums">
{formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out
</span>
</div>
<div className="flex items-center gap-1.5 text-duck-dark/60">
<span className="text-duck-dark/40">Cost:</span>
<span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span>
</div>
</>
)}
</div>
)}
</Card>
{/* Error message */}
{(hasError || job.error) && (
<Card className="px-4 py-3 shrink-0 border-red-200 dark:border-red-800/50 bg-red-50/50 dark:bg-red-950/20">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<span className="text-sm text-red-700 dark:text-red-300">{job.error ?? 'An error occurred during execution'}</span>
</div>
</Card>
)}
{/* Two-column: steps list + output viewer */}
<div className="flex-1 min-h-0">
<JobPanelCtx.Provider value={panelCtx}>
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} components={panelComponents} noHeader />
</JobPanelCtx.Provider>
</div>
</div>
);
};
@@ -0,0 +1,138 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { Search, CheckCircle2, AlertCircle, Clock, Loader2, StopCircle, AlertTriangle } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
type JobSummary = {
id: string;
taskDirName: string;
taskName: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } | null;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
error: string | null;
};
const formatDate = (iso: string) => {
const d = new Date(iso);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
const StatusIcon = ({ status }: { status: JobSummary['status'] }) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />;
case 'failed':
return <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />;
case 'running':
return <Loader2 className="h-4 w-4 text-blue-500 shrink-0 animate-spin" />;
case 'stopped':
return <StopCircle className="h-4 w-4 text-amber-500 shrink-0" />;
case 'interrupted':
return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />;
case 'pending':
return <Clock className="h-4 w-4 text-duck-dark/40 shrink-0" />;
}
};
const StatusBadge = ({ status }: { status: JobSummary['status'] }) => {
const colors: Record<string, string> = {
completed: 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300',
failed: 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300',
running: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
stopped: 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
interrupted: 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
pending: 'bg-gray-100 dark:bg-gray-800/40 text-gray-600 dark:text-gray-400',
};
return (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${colors[status] ?? ''}`}>
{status}
</span>
);
};
export const JobsScreen = () => {
const client = useClient();
const navigate = useNavigate();
const [jobs, setJobs] = useState<JobSummary[]>([]);
const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
client
.get<JobSummary[]>('/pipeline-jobs')
.then((data) => {
setJobs(data);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}, []);
const filtered = search
? jobs.filter((j) => {
const q = search.toLowerCase();
return j.taskName.toLowerCase().includes(q) || j.taskDirName.toLowerCase().includes(q) || j.status.includes(q);
})
: jobs;
return (
<div className="flex h-full p-3 md:p-6">
<Card className="flex-1 flex flex-col overflow-hidden">
<div className="p-3 border-b border-duck-dark/10 flex items-center gap-3">
<h2 className="text-sm font-semibold text-duck-dark">Pipeline Jobs</h2>
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
<input
type="text"
placeholder="Search jobs..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading && (
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">Loading...</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">
{search ? 'No jobs match your search' : 'No pipeline jobs yet'}
</div>
)}
{filtered.map((job) => (
<button
key={job.id}
onClick={() => navigate(`/jobs/${job.id}`)}
className="w-full text-left px-4 py-3 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer flex items-center gap-3"
>
<StatusIcon status={job.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-duck-dark truncate">{job.taskName}</span>
<StatusBadge status={job.status} />
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-duck-dark/50">{formatDate(job.createdAt)}</span>
{job.totalCost && (
<span className="text-xs text-duck-dark/40 font-mono">{formatCost(job.totalCost.totalUSD)}</span>
)}
{job.error && (
<span className="text-xs text-red-500 truncate max-w-[200px]">{job.error}</span>
)}
</div>
</div>
</button>
))}
</div>
</Card>
</div>
);
};
@@ -0,0 +1,2 @@
export { JobsScreen } from './JobsScreen';
export { JobDetail } from './JobDetail';
@@ -110,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
};
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe, MonitorSmartphone } from 'lucide-react';
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe, MonitorSmartphone, Workflow } from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
@@ -120,6 +120,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
@@ -20,3 +20,4 @@ export * from './Terminal';
export * from './Email';
export * from './Browser';
export * from './Desktop';
export * from './Jobs';
@@ -0,0 +1,20 @@
CREATE TABLE "pipeline_jobs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"task_dir_name" text NOT NULL,
"task_name" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"inputs" jsonb DEFAULT '{}'::jsonb NOT NULL,
"cwd" text,
"config" jsonb NOT NULL,
"progress" jsonb,
"total_cost" jsonb,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "pipeline_jobs" ADD CONSTRAINT "pipeline_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_pipeline_jobs_user_created" ON "pipeline_jobs" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_pipeline_jobs_status" ON "pipeline_jobs" USING btree ("status");
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,13 @@
"when": 1772985087180,
"tag": "0002_cute_doorman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1773040399200,
"tag": "0003_perpetual_james_howlett",
"breakpoints": true
}
]
}
+8
View File
@@ -81,5 +81,13 @@ export {
deleteTask,
} from './queries/tasks';
export {
createPipelineJob,
getPipelineJob,
updatePipelineJob,
getPipelineJobsForUser,
markInterruptedJobs,
} from './queries/pipeline-jobs';
export { db } from './db';
export * as schema from './schema';
@@ -0,0 +1,36 @@
import { eq, and, inArray, desc } from 'drizzle-orm';
import { db } from '../db';
import { pipelineJobs } from '../schema/pipeline-jobs';
import type { PipelineJobInsert } from '../types';
export async function createPipelineJob(data: PipelineJobInsert) {
const rows = await db.insert(pipelineJobs).values(data).returning();
return rows[0]!;
}
export async function getPipelineJob(id: string) {
const rows = await db.select().from(pipelineJobs).where(eq(pipelineJobs.id, id)).limit(1);
return rows[0] ?? null;
}
export async function updatePipelineJob(id: string, data: Partial<PipelineJobInsert>) {
await db.update(pipelineJobs).set(data).where(eq(pipelineJobs.id, id));
}
export async function getPipelineJobsForUser(userId: number, limit = 50) {
return db
.select()
.from(pipelineJobs)
.where(eq(pipelineJobs.userId, userId))
.orderBy(desc(pipelineJobs.createdAt))
.limit(limit);
}
export async function markInterruptedJobs() {
const result = await db
.update(pipelineJobs)
.set({ status: 'interrupted', completedAt: new Date() })
.where(inArray(pipelineJobs.status, ['pending', 'running']))
.returning({ id: pipelineJobs.id });
return result.length;
}
@@ -6,3 +6,4 @@ export * from './agent-items';
export * from './operations';
export * from './server';
export * from './email';
export * from './pipeline-jobs';
@@ -0,0 +1,22 @@
import { pgTable, text, integer, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const pipelineJobs = pgTable('pipeline_jobs', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
taskDirName: text('task_dir_name').notNull(),
taskName: text('task_name').notNull(),
status: text('status', { enum: ['pending', 'running', 'completed', 'failed', 'stopped', 'interrupted'] }).notNull().default('pending'),
inputs: jsonb('inputs').notNull().default({}),
cwd: text('cwd'),
config: jsonb('config').notNull(),
progress: jsonb('progress'),
totalCost: jsonb('total_cost'),
error: text('error'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => [
index('idx_pipeline_jobs_user_created').on(table.userId, table.createdAt),
index('idx_pipeline_jobs_status').on(table.status),
]);
+5
View File
@@ -103,3 +103,8 @@ export type ServerConfigInsert = typeof Schema.serverConfig.$inferInsert;
export type ServerIntegrationSelect = typeof Schema.serverIntegrations.$inferSelect;
export type ServerIntegrationInsert = typeof Schema.serverIntegrations.$inferInsert;
// ── Pipeline Jobs ──
export type PipelineJobSelect = typeof Schema.pipelineJobs.$inferSelect;
export type PipelineJobInsert = typeof Schema.pipelineJobs.$inferInsert;
+4
View File
@@ -323,6 +323,10 @@ try {
import { initQueue, enqueueJob as queueEnqueue, cancelJob as queueCancel, listAllJobs as queueList, readJob as queueGet } from './servers/queue/init';
initQueue().catch((err) => console.error('[queue] failed to initialize:', err));
// Mark any orphaned pipeline jobs from previous server run
import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager';
cleanupOnStartup().catch((err) => console.error('[pipeline-jobs] startup cleanup failed:', err));
// Ensure PulseAudio is running with virtual sink for cliamp audio streaming
(async () => {
const pulseaudio = Bun.which('pulseaudio');
+324 -142
View File
@@ -3,11 +3,13 @@ import { randomUUID } from 'crypto';
import { readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { getTaskByDirName, getUserSettings } from 'officerdb';
import { getHomeDirForRole } from '../../data-path';
import { getHomeDirForRole, getHomeDir } from '../../data-path';
import { resolveBaseCwd } from '../pi/websocket';
import { SANDBOX_HOME } from '../../sidecar/sandbox';
import * as sidecar from '../../sidecar-registry';
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
import type { PiEvent, MessageCost } from '../pi/types';
import * as jobManager from './pipeline-job-manager';
const DEFAULT_MODEL = 'claude-code';
@@ -21,72 +23,64 @@ async function resolveModel(userId: number): Promise<string> {
}
}
type WSData = {
userId: number;
email: string;
username: string;
role: string;
sandboxed: boolean;
};
// ── Types ──
type PipelineStep = {
task: string;
inputs?: Record<string, string>;
foreach?: 'subdirectory';
skip_if?: string;
concurrency?: string;
};
type PipelineConfig = {
steps: PipelineStep[];
};
type RunMessage = {
type: 'run';
taskDirName: string;
inputs: Record<string, string>;
cwd?: string;
};
type ClientMessage = RunMessage | { type: 'stop' };
// Messages sent to client
type OutMessage =
| { type: 'pipeline:init'; steps: Array<{ task: string; foreach?: string }> }
export type OutMessage =
| { type: 'pipeline:init'; steps: Array<{ task: string; foreach?: string; concurrency?: number }> }
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
| { type: 'step:complete'; stepIndex: number; cost?: MessageCost }
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { type: 'assistant:delta'; text: string }
| { type: 'assistant:text'; text: string }
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
| { type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
| { type: 'iteration:start'; stepIndex: number; label: string }
| { type: 'iteration:complete'; stepIndex: number; label: string; cost?: MessageCost }
| { type: 'iteration:error'; stepIndex: number; label: string; error: string }
| { type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
| { type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; stepIndex: number; iterationLabel?: string }
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string }
| { type: 'pipeline:complete'; totalCost: MessageCost }
| { type: 'error'; message: string }
| { type: 'stopped' };
// Active pipelines per WebSocket
const activePipelines = new WeakMap<ServerWebSocket<WSData>, { abort: () => void }>();
function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
}
type EmitEvent = (msg: OutMessage) => void;
type AbortSignal = { aborted: boolean };
// ── Step execution ──
type RunStepParams = {
ws: ServerWebSocket<WSData>;
userId: number;
email: string;
username: string;
role: string;
taskDirName: string;
prompt: string;
cwd: string;
abortSignal: AbortSignal;
emit: EmitEvent;
stepIndex: number;
iterationLabel?: string;
};
async function runAgenticStep({ ws, taskDirName, prompt, cwd, abortSignal }: RunStepParams): Promise<MessageCost> {
const { email, username, userId, role } = ws.data;
async function runAgenticStep({ userId, email, username, role, taskDirName, prompt, cwd, abortSignal, emit, stepIndex, iterationLabel }: RunStepParams): Promise<MessageCost> {
const sessionId = randomUUID();
const model = await resolveModel(userId);
const isClaudeCode = model.startsWith('claude-code');
console.log(`[pipeline] starting step for session ${sessionId} (model=${model})`);
console.log(`[pipeline] starting step ${stepIndex} for session ${sessionId} (model=${model})${iterationLabel ? ` [${iterationLabel}]` : ''}`);
return new Promise<MessageCost>(async (resolve, reject) => {
if (abortSignal.aborted) return reject(new Error('Pipeline aborted'));
@@ -98,16 +92,16 @@ async function runAgenticStep({ ws, taskDirName, prompt, cwd, abortSignal }: Run
switch (event.type) {
case 'delta':
send(ws, { type: 'assistant:delta', text: event.text });
emit({ type: 'assistant:delta', text: event.text, stepIndex, iterationLabel });
break;
case 'text':
send(ws, { type: 'assistant:text', text: event.text });
emit({ type: 'assistant:text', text: event.text, stepIndex, iterationLabel });
break;
case 'tool:start':
send(ws, { type: 'tool:start', toolCallId: event.toolCallId, toolName: event.toolName, toolInput: event.toolInput });
emit({ type: 'tool:start', toolCallId: event.toolCallId, toolName: event.toolName, toolInput: event.toolInput, stepIndex, iterationLabel });
break;
case 'tool:result':
send(ws, { type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError });
emit({ type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError, stepIndex, iterationLabel });
break;
case 'result':
cleanup?.();
@@ -157,10 +151,22 @@ async function runAgenticStep({ ws, taskDirName, prompt, cwd, abortSignal }: Run
});
}
// ── Helpers ──
function resolveInputTemplate(template: string, variables: Record<string, string>): string {
return template.replace(/\$\{(\w+)\}/g, (_, key) => variables[key] ?? '');
}
/** Convert a host-side absolute path to the path the agent sees inside the sandbox. */
function toAgentPath(hostPath: string, email: string, role: string): string {
if (role === 'Super Admin') return hostPath;
const hostHome = getHomeDir(email);
if (hostPath.startsWith(hostHome)) {
return SANDBOX_HOME + hostPath.slice(hostHome.length);
}
return hostPath;
}
function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targetDir?: string): string {
const inputLines = Object.entries(inputs)
.filter(([, v]) => v.trim())
@@ -174,93 +180,214 @@ function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targe
return `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
}
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
const { email, role, userId } = ws.data;
// ── Parallel foreach ──
const pipelineTask = await getTaskByDirName(msg.taskDirName, userId);
type ParallelForeachParams = {
userId: number;
email: string;
username: string;
role: string;
stepIdx: number;
step: PipelineStep;
stepTask: { name: string; body: string };
subdirs: string[];
baseCwd: string;
inputs: Record<string, string>;
cwd?: string;
abortSignal: AbortSignal;
totalCost: MessageCost;
emit: EmitEvent;
};
async function runParallelForeach({
userId, email, username, role, stepIdx, step, stepTask, subdirs, baseCwd,
inputs, cwd, abortSignal, totalCost, emit,
}: ParallelForeachParams) {
const concurrency = Math.max(1, parseInt(step.concurrency ?? '1', 10));
// Determine skip vs run
const toSkip: string[] = [];
const toRun: string[] = [];
for (const subdir of subdirs) {
const subdirPath = join(baseCwd, subdir);
if (step.skip_if && existsSync(join(subdirPath, step.skip_if))) {
toSkip.push(subdir);
} else {
toRun.push(subdir);
}
}
// Send skips
for (const label of toSkip) {
emit({ type: 'step:skip', stepIndex: stepIdx, label, reason: `${step.skip_if} already exists` });
}
// Tell UI about parallel step
emit({
type: 'step:parallel',
stepIndex: stepIdx,
taskName: stepTask.name,
iterations: toRun,
concurrency,
});
// Pool-based execution
const executing = new Set<Promise<void>>();
for (const subdir of toRun) {
if (abortSignal.aborted) break;
const run = async () => {
emit({ type: 'iteration:start', stepIndex: stepIdx, label: subdir });
const iterVars = { ...inputs, folder_name: subdir };
const iterInputs: Record<string, string> = {};
if (step.inputs) {
for (const [key, template] of Object.entries(step.inputs)) {
iterInputs[key] = resolveInputTemplate(template, iterVars);
}
}
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
const targetDir = toAgentPath(resolvedCwd, email, role);
const prompt = buildStepPrompt(stepTask.body, iterInputs, targetDir);
try {
const cost = await runAgenticStep({
userId, email, username, role,
taskDirName: step.task,
prompt,
cwd: resolvedCwd,
abortSignal,
emit,
stepIndex: stepIdx,
iterationLabel: subdir,
});
totalCost.inputTokens += cost.inputTokens;
totalCost.outputTokens += cost.outputTokens;
totalCost.totalUSD += cost.totalUSD;
emit({ type: 'iteration:complete', stepIndex: stepIdx, label: subdir, cost });
} catch (err) {
if (!abortSignal.aborted) {
emit({ type: 'iteration:error', stepIndex: stepIdx, label: subdir, error: err instanceof Error ? err.message : String(err) });
}
}
};
const p = run().then(() => { executing.delete(p); });
executing.add(p);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
await Promise.all(executing);
}
// ── Pipeline orchestrator ──
export type ExecutePipelineParams = {
userId: number;
email: string;
username: string;
role: string;
taskDirName: string;
inputs: Record<string, string>;
cwd?: string;
abortSignal: AbortSignal;
emit: EmitEvent;
};
export async function executePipeline({ userId, email, username, role, taskDirName, inputs, cwd, abortSignal, emit }: ExecutePipelineParams): Promise<void> {
const pipelineTask = await getTaskByDirName(taskDirName, userId);
if (!pipelineTask) {
send(ws, { type: 'error', message: `Task not found: ${msg.taskDirName}` });
emit({ type: 'error', message: `Task not found: ${taskDirName}` });
return;
}
if (pipelineTask.mode !== 'pipeline') {
send(ws, { type: 'error', message: 'Task is not a pipeline-mode task' });
emit({ type: 'error', message: 'Task is not a pipeline-mode task' });
return;
}
const config = pipelineTask.config as PipelineConfig | null;
if (!config?.steps?.length) {
send(ws, { type: 'error', message: 'Pipeline has no steps defined' });
emit({ type: 'error', message: 'Pipeline has no steps defined' });
return;
}
const abortSignal = { aborted: false };
activePipelines.set(ws, {
abort: () => { abortSignal.aborted = true; },
});
const baseCwd = resolveBaseCwd(email, role, msg.cwd);
const baseCwd = resolveBaseCwd(email, role, cwd);
const totalCost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
// Send pipeline init
send(ws, {
emit({
type: 'pipeline:init',
steps: config.steps.map((s) => ({ task: s.task, foreach: s.foreach })),
steps: config.steps.map((s) => ({
task: s.task,
foreach: s.foreach,
concurrency: s.concurrency ? parseInt(s.concurrency, 10) : undefined,
})),
});
try {
for (let stepIdx = 0; stepIdx < config.steps.length; stepIdx++) {
if (abortSignal.aborted) break;
for (let stepIdx = 0; stepIdx < config.steps.length; stepIdx++) {
if (abortSignal.aborted) break;
const step = config.steps[stepIdx]!;
const step = config.steps[stepIdx]!;
// Resolve the referenced task
const stepTask = await getTaskByDirName(step.task, userId);
if (!stepTask) {
send(ws, { type: 'error', message: `Step task not found: ${step.task}` });
const stepTask = await getTaskByDirName(step.task, userId);
if (!stepTask) {
emit({ type: 'error', message: `Step task not found: ${step.task}` });
return;
}
if (!stepTask.body) {
emit({ type: 'error', message: `Step task "${step.task}" has no body` });
return;
}
const resolvedInputs: Record<string, string> = {};
if (step.inputs) {
for (const [key, template] of Object.entries(step.inputs)) {
resolvedInputs[key] = resolveInputTemplate(template, inputs);
}
}
if (step.foreach === 'subdirectory') {
let subdirs: string[];
try {
subdirs = readdirSync(baseCwd, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.filter((name) => !name.startsWith('.'))
.sort();
} catch {
emit({ type: 'error', message: `Cannot read directory: ${baseCwd}` });
return;
}
if (!stepTask.body) {
send(ws, { type: 'error', message: `Step task "${step.task}" has no body` });
return;
}
// Resolve input templates using pipeline inputs
const resolvedInputs: Record<string, string> = {};
if (step.inputs) {
for (const [key, template] of Object.entries(step.inputs)) {
resolvedInputs[key] = resolveInputTemplate(template, msg.inputs);
}
}
if (step.foreach === 'subdirectory') {
// Iterate over subdirectories
let subdirs: string[];
try {
subdirs = readdirSync(baseCwd, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.filter((name) => !name.startsWith('.'))
.sort();
} catch {
send(ws, { type: 'error', message: `Cannot read directory: ${baseCwd}` });
return;
}
const concurrency = parseInt(step.concurrency ?? '1', 10);
if (concurrency > 1) {
await runParallelForeach({
userId, email, username, role,
stepIdx, step, stepTask: { name: stepTask.name, body: stepTask.body },
subdirs, baseCwd, inputs, cwd, abortSignal, totalCost, emit,
});
} else {
// Sequential
for (let i = 0; i < subdirs.length; i++) {
if (abortSignal.aborted) break;
const subdir = subdirs[i]!;
const subdirPath = join(baseCwd, subdir);
// Check skip condition
if (step.skip_if && existsSync(join(subdirPath, step.skip_if))) {
send(ws, { type: 'step:skip', stepIndex: stepIdx, label: subdir, reason: `${step.skip_if} already exists` });
emit({ type: 'step:skip', stepIndex: stepIdx, label: subdir, reason: `${step.skip_if} already exists` });
continue;
}
// Resolve folder-specific variables
const iterVars = { ...msg.inputs, folder_name: subdir };
const iterVars = { ...inputs, folder_name: subdir };
const iterInputs: Record<string, string> = {};
if (step.inputs) {
for (const [key, template] of Object.entries(step.inputs)) {
@@ -268,87 +395,145 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
}
}
send(ws, {
emit({
type: 'step:start',
stepIndex: stepIdx,
taskName: stepTask.name,
iteration: { current: i + 1, total: subdirs.length, label: subdir },
});
// Build relative path for cwd (sandbox-safe)
const stepCwd = subdirPath;
const cwdRelative = msg.cwd ? `${msg.cwd}/${subdir}` : subdir;
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
// Build ~/relative path for context
const targetDir = msg.cwd ? `~/${msg.cwd}/${subdir}` : `~/${subdir}`;
const targetDir = toAgentPath(resolvedCwd, email, role);
const prompt = buildStepPrompt(stepTask.body, iterInputs, targetDir);
const cost = await runAgenticStep({
ws,
userId, email, username, role,
taskDirName: step.task,
prompt,
cwd: resolvedCwd,
abortSignal,
emit,
stepIndex: stepIdx,
iterationLabel: subdir,
});
totalCost.inputTokens += cost.inputTokens;
totalCost.outputTokens += cost.outputTokens;
totalCost.totalUSD += cost.totalUSD;
send(ws, { type: 'step:complete', stepIndex: stepIdx, cost });
emit({ type: 'step:complete', stepIndex: stepIdx, cost });
}
} else {
// Single execution step
send(ws, { type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name });
const targetDir = msg.cwd ? `~/${msg.cwd}` : '~';
const prompt = buildStepPrompt(stepTask.body, resolvedInputs, targetDir);
const cost = await runAgenticStep({
ws,
taskDirName: step.task,
prompt,
cwd: baseCwd,
abortSignal,
});
totalCost.inputTokens += cost.inputTokens;
totalCost.outputTokens += cost.outputTokens;
totalCost.totalUSD += cost.totalUSD;
send(ws, { type: 'step:complete', stepIndex: stepIdx, cost });
}
}
} else {
// Single execution step
emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name });
if (!abortSignal.aborted) {
send(ws, { type: 'pipeline:complete', totalCost });
const targetDir = toAgentPath(baseCwd, email, role);
const prompt = buildStepPrompt(stepTask.body, resolvedInputs, targetDir);
const cost = await runAgenticStep({
userId, email, username, role,
taskDirName: step.task,
prompt,
cwd: baseCwd,
abortSignal,
emit,
stepIndex: stepIdx,
});
totalCost.inputTokens += cost.inputTokens;
totalCost.outputTokens += cost.outputTokens;
totalCost.totalUSD += cost.totalUSD;
emit({ type: 'step:complete', stepIndex: stepIdx, cost });
}
} catch (err) {
if (!abortSignal.aborted) {
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) });
}
} finally {
activePipelines.delete(ws);
}
if (!abortSignal.aborted) {
emit({ type: 'pipeline:complete', totalCost });
}
}
// ── WebSocket handler (thin layer) ──
type WSData = {
userId: number;
email: string;
username: string;
role: string;
sandboxed: boolean;
};
type ClientMessage =
| { type: 'run'; taskDirName: string; inputs: Record<string, string>; cwd?: string }
| { type: 'stop'; jobId: string }
| { type: 'attach'; jobId: string }
| { type: 'list' };
function send(ws: ServerWebSocket<WSData>, msg: Record<string, unknown>) {
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
}
export function open(_ws: ServerWebSocket<WSData>) {}
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
const data = typeof raw === 'string' ? raw : raw.toString();
try {
const msg = JSON.parse(data) as ClientMessage;
if (msg.type === 'run') {
handleRun(ws, msg);
} else if (msg.type === 'stop') {
const active = activePipelines.get(ws);
if (active) {
active.abort();
activePipelines.delete(ws);
send(ws, { type: 'stopped' });
switch (msg.type) {
case 'run': {
const { userId, email, username, role } = ws.data;
// Resolve task name for the DB record
const task = await getTaskByDirName(msg.taskDirName, userId);
if (!task) {
send(ws, { type: 'error', message: `Task not found: ${msg.taskDirName}` });
return;
}
const jobId = await jobManager.startJob({
userId,
email,
username,
role,
taskDirName: msg.taskDirName,
taskName: task.name,
inputs: msg.inputs,
cwd: msg.cwd,
config: task.config,
});
send(ws, { type: 'job:created', jobId });
jobManager.attachViewer(jobId, ws);
break;
}
case 'stop': {
jobManager.stopJob(msg.jobId);
break;
}
case 'attach': {
const attached = jobManager.attachViewer(msg.jobId, ws);
if (!attached) {
// Job not live — send the DB state
const job = await jobManager.getJob(msg.jobId);
if (job) {
send(ws, { type: 'job:state', jobId: msg.jobId, status: job.status, progress: job.progress, cost: job.totalCost });
} else {
send(ws, { type: 'error', message: `Job not found: ${msg.jobId}` });
}
}
break;
}
case 'list': {
const jobs = await jobManager.getJobsForUser(ws.data.userId);
send(ws, { type: 'job:list', jobs: jobs.map((j) => ({ id: j.id, taskDirName: j.taskDirName, taskName: j.taskName, status: j.status, isLive: j.isLive, totalCost: j.totalCost, createdAt: j.createdAt, completedAt: j.completedAt })) });
break;
}
}
} catch {
@@ -357,11 +542,8 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
}
export function close(ws: ServerWebSocket<WSData>) {
const active = activePipelines.get(ws);
if (active) {
active.abort();
activePipelines.delete(ws);
}
// Detach from all jobs but don't stop them
jobManager.detachAllForViewer(ws);
}
export const pipelineWebsocket = {
@@ -0,0 +1,293 @@
import type { ServerWebSocket } from 'bun';
import { randomUUID } from 'crypto';
import { EventEmitter } from 'events';
import {
createPipelineJob,
getPipelineJob,
updatePipelineJob,
getPipelineJobsForUser,
markInterruptedJobs,
} from 'officerdb';
import { executePipeline } from './pipeline-executor';
import type { OutMessage } from './pipeline-executor';
type WSData = {
userId: number;
email: string;
username: string;
role: string;
sandboxed: boolean;
};
type LiveJob = {
jobId: string;
userId: number;
abortSignal: { aborted: boolean };
emitter: EventEmitter;
eventBuffer: OutMessage[];
viewers: Set<ServerWebSocket<WSData>>;
progressDirty: boolean;
lastProgress: unknown;
lastCost: unknown;
};
const EVENT_BUFFER_SIZE = 500;
const PROGRESS_FLUSH_MS = 3000;
const liveJobs = new Map<string, LiveJob>();
function sendToViewer(ws: ServerWebSocket<WSData>, jobId: string, event: OutMessage) {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ jobId, ...event }));
}
}
function broadcast(job: LiveJob, event: OutMessage) {
// Buffer for replay — skip high-frequency deltas (only useful for live streaming)
if (event.type !== 'assistant:delta') {
job.eventBuffer.push(event);
if (job.eventBuffer.length > EVENT_BUFFER_SIZE) {
job.eventBuffer.shift();
}
}
// Forward to all viewers
for (const ws of job.viewers) {
sendToViewer(ws, job.jobId, event);
}
}
type StartJobParams = {
userId: number;
email: string;
username: string;
role: string;
taskDirName: string;
taskName: string;
inputs: Record<string, string>;
cwd?: string;
config: unknown;
};
export async function startJob(params: StartJobParams): Promise<string> {
const jobId = randomUUID();
// Create DB record
await createPipelineJob({
id: jobId,
userId: params.userId,
taskDirName: params.taskDirName,
taskName: params.taskName,
status: 'running',
inputs: params.inputs,
cwd: params.cwd ?? null,
config: params.config,
startedAt: new Date(),
});
// Create live state
const job: LiveJob = {
jobId,
userId: params.userId,
abortSignal: { aborted: false },
emitter: new EventEmitter(),
eventBuffer: [],
viewers: new Set(),
progressDirty: false,
lastProgress: null,
lastCost: null,
};
liveJobs.set(jobId, job);
// Emit callback — broadcasts to viewers and tracks progress
const emit = (event: OutMessage) => {
broadcast(job, event);
// Track progress for DB persistence (skip streaming messages — only structural events)
if (event.type === 'pipeline:init' || event.type.startsWith('step:') || event.type.startsWith('iteration:')) {
job.progressDirty = true;
job.lastProgress = extractProgress(event, job.lastProgress);
}
if (event.type === 'step:complete' || event.type === 'iteration:complete') {
const cost = 'cost' in event ? event.cost : undefined;
if (cost) {
const prev = (job.lastCost as { inputTokens: number; outputTokens: number; totalUSD: number } | null) ?? { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
job.lastCost = {
inputTokens: prev.inputTokens + cost.inputTokens,
outputTokens: prev.outputTokens + cost.outputTokens,
totalUSD: prev.totalUSD + cost.totalUSD,
};
job.progressDirty = true;
}
}
};
// Periodic progress flush to DB
const flushInterval = setInterval(async () => {
if (job.progressDirty) {
job.progressDirty = false;
try {
await updatePipelineJob(jobId, {
progress: job.lastProgress as Record<string, unknown>,
totalCost: job.lastCost as Record<string, unknown>,
});
} catch (err) {
console.error(`[pipeline-jobs] failed to flush progress for ${jobId}:`, err);
}
}
}, PROGRESS_FLUSH_MS);
// Run the pipeline in the background
executePipeline({
userId: params.userId,
email: params.email,
username: params.username,
role: params.role,
taskDirName: params.taskDirName,
inputs: params.inputs,
cwd: params.cwd,
abortSignal: job.abortSignal,
emit,
}).then(async () => {
clearInterval(flushInterval);
await updatePipelineJob(jobId, {
status: 'completed',
progress: job.lastProgress as Record<string, unknown>,
totalCost: job.lastCost as Record<string, unknown>,
completedAt: new Date(),
}).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err));
liveJobs.delete(jobId);
}).catch(async (err) => {
clearInterval(flushInterval);
const message = err instanceof Error ? err.message : String(err);
const isStopped = job.abortSignal.aborted;
await updatePipelineJob(jobId, {
status: isStopped ? 'stopped' : 'failed',
progress: job.lastProgress as Record<string, unknown>,
totalCost: job.lastCost as Record<string, unknown>,
error: isStopped ? undefined : message,
completedAt: new Date(),
}).catch((e) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, e));
liveJobs.delete(jobId);
});
return jobId;
}
export function attachViewer(jobId: string, ws: ServerWebSocket<WSData>) {
const job = liveJobs.get(jobId);
if (!job) return false;
job.viewers.add(ws);
// Replay buffered events so the viewer catches up
for (const event of job.eventBuffer) {
sendToViewer(ws, jobId, event);
}
return true;
}
export function detachViewer(jobId: string, ws: ServerWebSocket<WSData>) {
const job = liveJobs.get(jobId);
if (job) job.viewers.delete(ws);
}
export function detachAllForViewer(ws: ServerWebSocket<WSData>) {
for (const job of liveJobs.values()) {
job.viewers.delete(ws);
}
}
export function stopJob(jobId: string): boolean {
const job = liveJobs.get(jobId);
if (!job) return false;
job.abortSignal.aborted = true;
return true;
}
export function isJobLive(jobId: string): boolean {
return liveJobs.has(jobId);
}
export function getLiveJobForUser(userId: number): string | null {
for (const [jobId, job] of liveJobs) {
if (job.userId === userId) return jobId;
}
return null;
}
export async function getJobsForUser(userId: number) {
const jobs = await getPipelineJobsForUser(userId);
return jobs.map((j) => ({
...j,
isLive: liveJobs.has(j.id),
}));
}
export async function getJob(jobId: string) {
const job = await getPipelineJob(jobId);
if (!job) return null;
return { ...job, isLive: liveJobs.has(job.id) };
}
export async function cleanupOnStartup() {
const count = await markInterruptedJobs();
if (count > 0) {
console.log(`[pipeline-jobs] marked ${count} interrupted jobs on startup`);
}
}
/** Extract minimal progress info from events for DB storage. */
function extractProgress(event: OutMessage, prev: unknown): unknown {
const p = (prev as Record<string, unknown>) ?? {};
switch (event.type) {
case 'pipeline:init':
return { ...p, steps: event.steps, currentStepIndex: -1 };
case 'step:start':
return { ...p, currentStepIndex: event.stepIndex, currentIteration: event.iteration ?? null };
case 'step:complete':
return { ...p, currentStepIndex: event.stepIndex };
case 'step:parallel':
return {
...p,
currentStepIndex: event.stepIndex,
parallel: { taskName: event.taskName, concurrency: event.concurrency, iterations: event.iterations.map((l) => ({ label: l, status: 'pending' })) },
};
case 'iteration:start':
if (p.parallel && Array.isArray((p.parallel as Record<string, unknown>).iterations)) {
const parallel = p.parallel as { iterations: Array<{ label: string; status: string }> };
return {
...p,
parallel: {
...parallel,
iterations: parallel.iterations.map((it) => it.label === event.label ? { ...it, status: 'running' } : it),
},
};
}
return p;
case 'iteration:complete':
case 'iteration:error':
if (p.parallel && Array.isArray((p.parallel as Record<string, unknown>).iterations)) {
const parallel = p.parallel as { iterations: Array<{ label: string; status: string }> };
const status = event.type === 'iteration:complete' ? 'complete' : 'error';
return {
...p,
parallel: {
...parallel,
iterations: parallel.iterations.map((it) => it.label === event.label ? { ...it, status } : it),
},
};
}
return p;
default:
return p;
}
}
@@ -0,0 +1,29 @@
import { createRouter } from '../../create-router';
import { getPipelineJobsForUser, getPipelineJob } from 'officerdb';
export const pipelineJobsRouter = createRouter();
// GET /pipeline-jobs — list user's pipeline jobs
pipelineJobsRouter.get('/', async (c) => {
const user = c.get('user');
const jobs = await getPipelineJobsForUser(user.id);
return c.json(jobs.map((j) => ({
id: j.id,
taskDirName: j.taskDirName,
taskName: j.taskName,
status: j.status,
totalCost: j.totalCost,
createdAt: j.createdAt,
startedAt: j.startedAt,
completedAt: j.completedAt,
error: j.error,
})));
});
// GET /pipeline-jobs/:id — single job detail
pipelineJobsRouter.get('/:id', async (c) => {
const user = c.get('user');
const job = await getPipelineJob(c.req.param('id'));
if (!job || job.userId !== user.id) return c.json({ error: 'Not found' }, 404);
return c.json(job);
});
+2
View File
@@ -30,6 +30,7 @@ import { desktopRouter } from './api/desktop/rest';
import { appsRouter, appServeRouter } from './api/apps';
import { bugReportRouter } from './api/bug-report/bug-report';
import { savedSessionsRouter } from './api/saved-sessions/saved-sessions';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { broadcastPanelRefresh } from './api/terminal/websocket';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
@@ -100,6 +101,7 @@ protectedRouter.route('/browser', browserRouter);
protectedRouter.route('/apps', appsRouter);
protectedRouter.route('/bug-report', bugReportRouter);
protectedRouter.route('/saved-sessions', savedSessionsRouter);
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
desktopRouter.use(superAdminMiddleware);
protectedRouter.route('/desktop', desktopRouter);
protectedRouter.route('/', piRestRouter);
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { X, Play, Square, CircleCheck, CircleX, Copy, Check } from 'lucide-react';
import { X, Play, Square, CircleCheck, CircleX, Copy, Check, Loader2, AlertCircle, ExternalLink } from 'lucide-react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
@@ -512,7 +512,7 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
// Auto-scroll
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [pipeline.messages, pipeline.streamingText, pipeline.currentStep]);
}, [pipeline.messages, pipeline.streamingText, pipeline.currentStep, pipeline.parallelStep]);
// Ding on completion
const prevPhaseRef = useRef(pipeline.phase);
@@ -554,9 +554,25 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
);
}
const formatElapsed = (s: number) => {
const m = Math.floor(s / 60);
const sec = s % 60;
return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
};
const rc = pipeline.runningCost;
const totalTokens = rc.inputTokens + rc.outputTokens;
const ps = pipeline.parallelStep;
// Parallel progress counts
const pDone = ps?.iterations.filter((it) => it.status === 'complete').length ?? 0;
const pRunning = ps?.iterations.filter((it) => it.status === 'running').length ?? 0;
const pError = ps?.iterations.filter((it) => it.status === 'error').length ?? 0;
const pTotal = ps?.iterations.length ?? 0;
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Step progress header */}
{/* Step progress header — sequential */}
{pipeline.currentStep && (
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-duck-dark/3">
<div className="flex items-center gap-2 text-sm">
@@ -577,8 +593,65 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
</div>
)}
{/* Messages */}
{/* Step progress header — parallel */}
{ps && (
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-duck-dark/3">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-duck-dark">{ps.taskName}</span>
<span className="text-duck-dark/50 text-xs">
{pDone}/{pTotal} done
{pRunning > 0 && <span className="ml-1">· {pRunning} running</span>}
{pError > 0 && <span className="ml-1 text-red-500">· {pError} failed</span>}
</span>
{pDone + pError < pTotal && (
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
)}
{pDone + pError === pTotal && pTotal > 0 && (
<span className="ml-auto text-xs text-green-600">done</span>
)}
</div>
</div>
)}
{/* Running stats */}
{pipeline.phase === 'running' && (
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/5 flex items-center gap-4 text-xs text-duck-dark/40 font-mono tabular-nums">
<span>{formatElapsed(pipeline.elapsed)}</span>
{totalTokens > 0 && <span>{totalTokens.toLocaleString()} tok</span>}
{rc.totalUSD > 0 && <span>${rc.totalUSD.toFixed(3)}</span>}
</div>
)}
{/* Content area */}
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Parallel iteration grid */}
{ps && (
<div className="px-4 py-3 space-y-1">
{ps.iterations.map((it) => (
<div key={it.label} className="flex items-center gap-2 py-1 px-2 rounded text-sm">
{it.status === 'pending' && <span className="h-4 w-4 rounded-full border border-duck-dark/20 shrink-0" />}
{it.status === 'running' && <Loader2 className="h-4 w-4 text-amber-500 animate-spin shrink-0" />}
{it.status === 'complete' && <CircleCheck className="h-4 w-4 text-green-500 shrink-0" />}
{it.status === 'error' && <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />}
<span className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-red-500' : 'text-duck-dark/60'}`}>
{it.label}
</span>
{it.cost && (
<span className="ml-auto text-xs text-duck-dark/30 font-mono tabular-nums shrink-0">
${it.cost.totalUSD.toFixed(3)}
</span>
)}
{it.error && (
<span className="ml-auto text-xs text-red-400 truncate max-w-[200px]" title={it.error}>
{it.error}
</span>
)}
</div>
))}
</div>
)}
{/* Sequential messages */}
{pipeline.messages.map((msg, i) => (
<div key={i} className="px-4 py-1.5">
<MessageBubble message={msg} onAnswer={() => {}} />
@@ -614,8 +687,8 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
</span>
)}
{pipeline.totalCost && (
<span className="text-xs text-duck-dark/40">
${pipeline.totalCost.totalUSD.toFixed(3)} · {pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens} tokens
<span className="text-xs text-duck-dark/40 font-mono tabular-nums">
{formatElapsed(pipeline.elapsed)} · {(pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens).toLocaleString()} tokens · ${pipeline.totalCost.totalUSD.toFixed(3)}
</span>
)}
{pipeline.skippedItems.length > 0 && (
@@ -623,6 +696,15 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
{pipeline.skippedItems.length} skipped ({pipeline.skippedItems.map((s) => s.label).join(', ')})
</span>
)}
{pipeline.jobId && (
<a
href={`/jobs/${pipeline.jobId}`}
className="flex items-center gap-1.5 text-xs text-duck-teal hover:text-duck-teal/80 transition-colors mt-1"
>
<ExternalLink className="h-3 w-3" />
View in Jobs
</a>
)}
</div>
</div>
);
@@ -656,8 +738,9 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
// Context values for autofill
// Build a ~/relative path for the agent (works inside bwrap sandbox)
const entryRelPath = entryName && cwd.path ? `~/${cwd.path}/${entryName}` : entryName ? `~/${entryName}` : undefined;
// Build absolute path the agent sees (sandboxed: /data/home/..., non-sandboxed: ~/...)
const homePrefix = sandboxed ? '/data/home' : '~';
const entryRelPath = entryName && cwd.path ? `${homePrefix}/${cwd.path}/${entryName}` : entryName ? `${homePrefix}/${entryName}` : undefined;
const autofillContext: Record<string, string> = {};
if (entryName) autofillContext.entry_name = entryName;
if (entryRelPath) autofillContext.entry_path = entryRelPath;
@@ -6,6 +6,7 @@ type Phase = 'ready' | 'running' | 'done';
type StepDef = {
task: string;
foreach?: string;
concurrency?: number;
};
type StepStatus = {
@@ -15,31 +16,60 @@ type StepStatus = {
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
};
export type IterationStatus = {
label: string;
status: 'pending' | 'running' | 'complete' | 'error';
error?: string;
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
};
type ParallelStep = {
stepIndex: number;
taskName: string;
concurrency: number;
iterations: IterationStatus[];
};
type ServerMessage =
| { type: 'pipeline:init'; steps: StepDef[] }
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
| { type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { type: 'assistant:delta'; text: string }
| { type: 'assistant:text'; text: string }
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
| { type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } }
| { type: 'error'; message: string }
| { type: 'stopped' };
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
| { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
| { jobId: string; type: 'assistant:delta'; text: string; iterationLabel?: string }
| { jobId: string; type: 'assistant:text'; text: string; iterationLabel?: string }
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; iterationLabel?: string }
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; iterationLabel?: string }
| { jobId: string; type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } }
| { jobId: string; type: 'error'; message: string }
| { jobId: string; type: 'stopped' }
| { type: 'job:created'; jobId: string }
| { type: 'job:state'; jobId: string; status: string; progress: unknown; cost: unknown }
| { type: 'error'; message: string };
export function usePipelineRunner() {
const [phase, setPhase] = useState<Phase>('ready');
const [isConnected, setIsConnected] = useState(false);
const [jobId, setJobId] = useState<string | null>(null);
const [steps, setSteps] = useState<StepDef[]>([]);
const [currentStep, setCurrentStep] = useState<StepStatus | null>(null);
const [parallelStep, setParallelStep] = useState<ParallelStep | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(null);
const [runningCost, setRunningCost] = useState({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
const [hasError, setHasError] = useState(false);
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
const [elapsed, setElapsed] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const streamBufferRef = useRef('');
const startTimeRef = useRef<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const jobIdRef = useRef<string | null>(null);
const inParallelRef = useRef(false);
const flushStream = useCallback(() => {
const text = streamBufferRef.current;
@@ -50,6 +80,183 @@ export function usePipelineRunner() {
}
}, []);
const stopTimer = useCallback(() => {
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
}, []);
const addCost = useCallback((cost: { inputTokens: number; outputTokens: number; totalUSD: number }) => {
setRunningCost((prev) => ({
inputTokens: prev.inputTokens + cost.inputTokens,
outputTokens: prev.outputTokens + cost.outputTokens,
totalUSD: prev.totalUSD + cost.totalUSD,
}));
}, []);
const handleEvent = useCallback((msg: ServerMessage) => {
// Filter events by jobId (ignore events from other jobs)
if ('jobId' in msg && msg.jobId && jobIdRef.current && msg.jobId !== jobIdRef.current) return;
switch (msg.type) {
case 'job:created':
jobIdRef.current = msg.jobId;
setJobId(msg.jobId);
break;
case 'job:state':
// Reconnection to a completed/failed job
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
setPhase('done');
if (msg.cost) setTotalCost(msg.cost as { inputTokens: number; outputTokens: number; totalUSD: number });
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
stopTimer();
}
break;
case 'pipeline:init':
setSteps(msg.steps);
break;
case 'step:start':
flushStream();
setMessages([]);
setParallelStep(null);
inParallelRef.current = false;
setCurrentStep({
taskName: msg.taskName,
iteration: msg.iteration,
status: 'running',
});
break;
case 'step:complete':
flushStream();
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
if (msg.cost) addCost(msg.cost);
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:parallel':
flushStream();
setMessages([]);
setCurrentStep(null);
inParallelRef.current = true;
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'running' } : it,
),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
break;
case 'assistant:delta':
// Skip messages from parallel sub-agents (shown in iteration grid instead)
if (inParallelRef.current && msg.iterationLabel) break;
streamBufferRef.current += msg.text;
setStreamingText(streamBufferRef.current);
break;
case 'assistant:text': {
if (inParallelRef.current && msg.iterationLabel) break;
const text = msg.text || streamBufferRef.current;
if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
}
streamBufferRef.current = '';
setStreamingText('');
break;
}
case 'tool:start':
if (inParallelRef.current && msg.iterationLabel) break;
flushStream();
setMessages((prev) => [
...prev,
{
role: 'tool' as const,
id: crypto.randomUUID(),
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
output: undefined,
isError: false,
},
]);
break;
case 'tool:result':
if (inParallelRef.current && msg.iterationLabel) break;
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'pipeline:complete':
flushStream();
setTotalCost(msg.totalCost);
setPhase('done');
stopTimer();
break;
case 'error':
flushStream();
setMessages((prev) => [...prev, { role: 'error' as const, id: crypto.randomUUID(), text: msg.message }]);
setHasError(true);
setPhase('done');
stopTimer();
break;
case 'stopped':
flushStream();
setPhase('done');
stopTimer();
break;
}
}, [flushStream, stopTimer, addCost]);
useEffect(() => {
const token = localStorage.getItem('BEARER_TOKEN');
if (!token) return;
@@ -59,98 +266,18 @@ export function usePipelineRunner() {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.addEventListener('open', () => setIsConnected(true));
ws.addEventListener('open', () => {
setIsConnected(true);
if (jobIdRef.current) {
ws.send(JSON.stringify({ type: 'attach', jobId: jobIdRef.current }));
}
});
ws.addEventListener('close', () => setIsConnected(false));
ws.addEventListener('message', (ev) => {
try {
const msg = JSON.parse(ev.data) as ServerMessage;
switch (msg.type) {
case 'pipeline:init':
setSteps(msg.steps);
break;
case 'step:start':
// Flush any streaming text from the previous step
flushStream();
// Clear messages for the new step iteration
setMessages([]);
setCurrentStep({
taskName: msg.taskName,
iteration: msg.iteration,
status: 'running',
});
break;
case 'step:complete':
flushStream();
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'assistant:delta':
streamBufferRef.current += msg.text;
setStreamingText(streamBufferRef.current);
break;
case 'assistant:text': {
const text = msg.text || streamBufferRef.current;
if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
}
streamBufferRef.current = '';
setStreamingText('');
break;
}
case 'tool:start':
flushStream();
setMessages((prev) => [
...prev,
{
role: 'tool' as const,
id: crypto.randomUUID(),
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
output: undefined,
isError: false,
},
]);
break;
case 'tool:result':
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'pipeline:complete':
flushStream();
setTotalCost(msg.totalCost);
setPhase('done');
break;
case 'error':
flushStream();
setMessages((prev) => [...prev, { role: 'error' as const, id: crypto.randomUUID(), text: msg.message }]);
setHasError(true);
setPhase('done');
break;
case 'stopped':
flushStream();
setPhase('done');
break;
}
handleEvent(msg);
} catch {
// ignore
}
@@ -159,6 +286,7 @@ export function usePipelineRunner() {
return () => {
ws.close();
wsRef.current = null;
stopTimer();
};
}, []);
@@ -169,17 +297,32 @@ export function usePipelineRunner() {
setMessages([]);
setStreamingText('');
setTotalCost(null);
setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
setHasError(false);
setSkippedItems([]);
setCurrentStep(null);
setParallelStep(null);
setElapsed(0);
setJobId(null);
jobIdRef.current = null;
streamBufferRef.current = '';
startTimeRef.current = Date.now();
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd }));
}, []);
const stop = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify({ type: 'stop' }));
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !jobIdRef.current) return;
wsRef.current.send(JSON.stringify({ type: 'stop', jobId: jobIdRef.current }));
}, []);
return { phase, isConnected, steps, currentStep, messages, streamingText, totalCost, hasError, skippedItems, run, stop };
return {
phase, isConnected, jobId, steps, currentStep, parallelStep, messages, streamingText,
totalCost, runningCost, hasError, skippedItems, elapsed, run, stop,
};
}