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:
@@ -16,6 +16,7 @@ steps:
|
||||
artist_name: ${artist_name}
|
||||
- task: fetch-album-info
|
||||
foreach: subdirectory
|
||||
concurrency: 5
|
||||
skip_if: album-info.md
|
||||
inputs:
|
||||
artist_name: ${artist_name}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+91
-8
@@ -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;
|
||||
|
||||
+244
-101
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user