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}
|
artist_name: ${artist_name}
|
||||||
- task: fetch-album-info
|
- task: fetch-album-info
|
||||||
foreach: subdirectory
|
foreach: subdirectory
|
||||||
|
concurrency: 5
|
||||||
skip_if: album-info.md
|
skip_if: album-info.md
|
||||||
inputs:
|
inputs:
|
||||||
artist_name: ${artist_name}
|
artist_name: ${artist_name}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export function App() {
|
|||||||
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
||||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
<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" element={<Dashboard.DashboardsScreen />} />
|
||||||
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
|
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
|
||||||
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
<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[] = [
|
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
{ 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: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||||
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
|
{ 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: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ export * from './Terminal';
|
|||||||
export * from './Email';
|
export * from './Email';
|
||||||
export * from './Browser';
|
export * from './Browser';
|
||||||
export * from './Desktop';
|
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,
|
"when": 1772985087180,
|
||||||
"tag": "0002_cute_doorman",
|
"tag": "0002_cute_doorman",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773040399200,
|
||||||
|
"tag": "0003_perpetual_james_howlett",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -81,5 +81,13 @@ export {
|
|||||||
deleteTask,
|
deleteTask,
|
||||||
} from './queries/tasks';
|
} from './queries/tasks';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPipelineJob,
|
||||||
|
getPipelineJob,
|
||||||
|
updatePipelineJob,
|
||||||
|
getPipelineJobsForUser,
|
||||||
|
markInterruptedJobs,
|
||||||
|
} from './queries/pipeline-jobs';
|
||||||
|
|
||||||
export { db } from './db';
|
export { db } from './db';
|
||||||
export * as schema from './schema';
|
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 './operations';
|
||||||
export * from './server';
|
export * from './server';
|
||||||
export * from './email';
|
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 ServerIntegrationSelect = typeof Schema.serverIntegrations.$inferSelect;
|
||||||
export type ServerIntegrationInsert = typeof Schema.serverIntegrations.$inferInsert;
|
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';
|
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));
|
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
|
// Ensure PulseAudio is running with virtual sink for cliamp audio streaming
|
||||||
(async () => {
|
(async () => {
|
||||||
const pulseaudio = Bun.which('pulseaudio');
|
const pulseaudio = Bun.which('pulseaudio');
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { randomUUID } from 'crypto';
|
|||||||
import { readdirSync, existsSync } from 'node:fs';
|
import { readdirSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { getTaskByDirName, getUserSettings } from 'officerdb';
|
import { getTaskByDirName, getUserSettings } from 'officerdb';
|
||||||
import { getHomeDirForRole } from '../../data-path';
|
import { getHomeDirForRole, getHomeDir } from '../../data-path';
|
||||||
import { resolveBaseCwd } from '../pi/websocket';
|
import { resolveBaseCwd } from '../pi/websocket';
|
||||||
|
import { SANDBOX_HOME } from '../../sidecar/sandbox';
|
||||||
import * as sidecar from '../../sidecar-registry';
|
import * as sidecar from '../../sidecar-registry';
|
||||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||||
import type { PiEvent, MessageCost } from '../pi/types';
|
import type { PiEvent, MessageCost } from '../pi/types';
|
||||||
|
import * as jobManager from './pipeline-job-manager';
|
||||||
|
|
||||||
const DEFAULT_MODEL = 'claude-code';
|
const DEFAULT_MODEL = 'claude-code';
|
||||||
|
|
||||||
@@ -21,72 +23,64 @@ async function resolveModel(userId: number): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type WSData = {
|
// ── Types ──
|
||||||
userId: number;
|
|
||||||
email: string;
|
|
||||||
username: string;
|
|
||||||
role: string;
|
|
||||||
sandboxed: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PipelineStep = {
|
type PipelineStep = {
|
||||||
task: string;
|
task: string;
|
||||||
inputs?: Record<string, string>;
|
inputs?: Record<string, string>;
|
||||||
foreach?: 'subdirectory';
|
foreach?: 'subdirectory';
|
||||||
skip_if?: string;
|
skip_if?: string;
|
||||||
|
concurrency?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PipelineConfig = {
|
type PipelineConfig = {
|
||||||
steps: PipelineStep[];
|
steps: PipelineStep[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type RunMessage = {
|
|
||||||
type: 'run';
|
|
||||||
taskDirName: string;
|
|
||||||
inputs: Record<string, string>;
|
|
||||||
cwd?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ClientMessage = RunMessage | { type: 'stop' };
|
|
||||||
|
|
||||||
// Messages sent to client
|
// Messages sent to client
|
||||||
type OutMessage =
|
export type OutMessage =
|
||||||
| { type: 'pipeline:init'; steps: Array<{ task: string; foreach?: string }> }
|
| { 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:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
||||||
| { type: 'step:complete'; stepIndex: number; cost?: MessageCost }
|
| { type: 'step:complete'; stepIndex: number; cost?: MessageCost }
|
||||||
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||||
| { type: 'assistant:delta'; text: string }
|
| { type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
|
||||||
| { type: 'assistant:text'; text: string }
|
| { type: 'iteration:start'; stepIndex: number; label: string }
|
||||||
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
|
| { type: 'iteration:complete'; stepIndex: number; label: string; cost?: MessageCost }
|
||||||
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
|
| { 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: 'pipeline:complete'; totalCost: MessageCost }
|
||||||
| { type: 'error'; message: string }
|
| { type: 'error'; message: string }
|
||||||
| { type: 'stopped' };
|
| { type: 'stopped' };
|
||||||
|
|
||||||
// Active pipelines per WebSocket
|
type EmitEvent = (msg: OutMessage) => void;
|
||||||
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 AbortSignal = { aborted: boolean };
|
type AbortSignal = { aborted: boolean };
|
||||||
|
|
||||||
|
// ── Step execution ──
|
||||||
|
|
||||||
type RunStepParams = {
|
type RunStepParams = {
|
||||||
ws: ServerWebSocket<WSData>;
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
taskDirName: string;
|
taskDirName: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
|
emit: EmitEvent;
|
||||||
|
stepIndex: number;
|
||||||
|
iterationLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function runAgenticStep({ ws, taskDirName, prompt, cwd, abortSignal }: RunStepParams): Promise<MessageCost> {
|
async function runAgenticStep({ userId, email, username, role, taskDirName, prompt, cwd, abortSignal, emit, stepIndex, iterationLabel }: RunStepParams): Promise<MessageCost> {
|
||||||
const { email, username, userId, role } = ws.data;
|
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const model = await resolveModel(userId);
|
const model = await resolveModel(userId);
|
||||||
const isClaudeCode = model.startsWith('claude-code');
|
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) => {
|
return new Promise<MessageCost>(async (resolve, reject) => {
|
||||||
if (abortSignal.aborted) return reject(new Error('Pipeline aborted'));
|
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) {
|
switch (event.type) {
|
||||||
case 'delta':
|
case 'delta':
|
||||||
send(ws, { type: 'assistant:delta', text: event.text });
|
emit({ type: 'assistant:delta', text: event.text, stepIndex, iterationLabel });
|
||||||
break;
|
break;
|
||||||
case 'text':
|
case 'text':
|
||||||
send(ws, { type: 'assistant:text', text: event.text });
|
emit({ type: 'assistant:text', text: event.text, stepIndex, iterationLabel });
|
||||||
break;
|
break;
|
||||||
case 'tool:start':
|
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;
|
break;
|
||||||
case 'tool:result':
|
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;
|
break;
|
||||||
case 'result':
|
case 'result':
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
@@ -157,10 +151,22 @@ async function runAgenticStep({ ws, taskDirName, prompt, cwd, abortSignal }: Run
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
function resolveInputTemplate(template: string, variables: Record<string, string>): string {
|
function resolveInputTemplate(template: string, variables: Record<string, string>): string {
|
||||||
return template.replace(/\$\{(\w+)\}/g, (_, key) => variables[key] ?? '');
|
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 {
|
function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targetDir?: string): string {
|
||||||
const inputLines = Object.entries(inputs)
|
const inputLines = Object.entries(inputs)
|
||||||
.filter(([, v]) => v.trim())
|
.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}`;
|
return `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
// ── Parallel foreach ──
|
||||||
const { email, role, userId } = ws.data;
|
|
||||||
|
|
||||||
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) {
|
if (!pipelineTask) {
|
||||||
send(ws, { type: 'error', message: `Task not found: ${msg.taskDirName}` });
|
emit({ type: 'error', message: `Task not found: ${taskDirName}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pipelineTask.mode !== 'pipeline') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = pipelineTask.config as PipelineConfig | null;
|
const config = pipelineTask.config as PipelineConfig | null;
|
||||||
if (!config?.steps?.length) {
|
if (!config?.steps?.length) {
|
||||||
send(ws, { type: 'error', message: 'Pipeline has no steps defined' });
|
emit({ type: 'error', message: 'Pipeline has no steps defined' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortSignal = { aborted: false };
|
const baseCwd = resolveBaseCwd(email, role, cwd);
|
||||||
activePipelines.set(ws, {
|
|
||||||
abort: () => { abortSignal.aborted = true; },
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseCwd = resolveBaseCwd(email, role, msg.cwd);
|
|
||||||
const totalCost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
const totalCost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||||
|
|
||||||
// Send pipeline init
|
emit({
|
||||||
send(ws, {
|
|
||||||
type: 'pipeline:init',
|
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++) {
|
||||||
for (let stepIdx = 0; stepIdx < config.steps.length; stepIdx++) {
|
if (abortSignal.aborted) break;
|
||||||
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);
|
||||||
const stepTask = await getTaskByDirName(step.task, userId);
|
if (!stepTask) {
|
||||||
if (!stepTask) {
|
emit({ type: 'error', message: `Step task not found: ${step.task}` });
|
||||||
send(ws, { 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stepTask.body) {
|
const concurrency = parseInt(step.concurrency ?? '1', 10);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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++) {
|
for (let i = 0; i < subdirs.length; i++) {
|
||||||
if (abortSignal.aborted) break;
|
if (abortSignal.aborted) break;
|
||||||
|
|
||||||
const subdir = subdirs[i]!;
|
const subdir = subdirs[i]!;
|
||||||
const subdirPath = join(baseCwd, subdir);
|
const subdirPath = join(baseCwd, subdir);
|
||||||
|
|
||||||
// Check skip condition
|
|
||||||
if (step.skip_if && existsSync(join(subdirPath, step.skip_if))) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve folder-specific variables
|
const iterVars = { ...inputs, folder_name: subdir };
|
||||||
const iterVars = { ...msg.inputs, folder_name: subdir };
|
|
||||||
const iterInputs: Record<string, string> = {};
|
const iterInputs: Record<string, string> = {};
|
||||||
if (step.inputs) {
|
if (step.inputs) {
|
||||||
for (const [key, template] of Object.entries(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',
|
type: 'step:start',
|
||||||
stepIndex: stepIdx,
|
stepIndex: stepIdx,
|
||||||
taskName: stepTask.name,
|
taskName: stepTask.name,
|
||||||
iteration: { current: i + 1, total: subdirs.length, label: subdir },
|
iteration: { current: i + 1, total: subdirs.length, label: subdir },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build relative path for cwd (sandbox-safe)
|
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
||||||
const stepCwd = subdirPath;
|
|
||||||
const cwdRelative = msg.cwd ? `${msg.cwd}/${subdir}` : subdir;
|
|
||||||
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
||||||
|
const targetDir = toAgentPath(resolvedCwd, email, role);
|
||||||
// Build ~/relative path for context
|
|
||||||
const targetDir = msg.cwd ? `~/${msg.cwd}/${subdir}` : `~/${subdir}`;
|
|
||||||
const prompt = buildStepPrompt(stepTask.body, iterInputs, targetDir);
|
const prompt = buildStepPrompt(stepTask.body, iterInputs, targetDir);
|
||||||
|
|
||||||
const cost = await runAgenticStep({
|
const cost = await runAgenticStep({
|
||||||
ws,
|
userId, email, username, role,
|
||||||
taskDirName: step.task,
|
taskDirName: step.task,
|
||||||
prompt,
|
prompt,
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
emit,
|
||||||
|
stepIndex: stepIdx,
|
||||||
|
iterationLabel: subdir,
|
||||||
});
|
});
|
||||||
|
|
||||||
totalCost.inputTokens += cost.inputTokens;
|
totalCost.inputTokens += cost.inputTokens;
|
||||||
totalCost.outputTokens += cost.outputTokens;
|
totalCost.outputTokens += cost.outputTokens;
|
||||||
totalCost.totalUSD += cost.totalUSD;
|
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) {
|
const targetDir = toAgentPath(baseCwd, email, role);
|
||||||
send(ws, { type: 'pipeline:complete', totalCost });
|
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 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();
|
const data = typeof raw === 'string' ? raw : raw.toString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data) as ClientMessage;
|
const msg = JSON.parse(data) as ClientMessage;
|
||||||
|
|
||||||
if (msg.type === 'run') {
|
switch (msg.type) {
|
||||||
handleRun(ws, msg);
|
case 'run': {
|
||||||
} else if (msg.type === 'stop') {
|
const { userId, email, username, role } = ws.data;
|
||||||
const active = activePipelines.get(ws);
|
|
||||||
if (active) {
|
// Resolve task name for the DB record
|
||||||
active.abort();
|
const task = await getTaskByDirName(msg.taskDirName, userId);
|
||||||
activePipelines.delete(ws);
|
if (!task) {
|
||||||
send(ws, { type: 'stopped' });
|
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 {
|
} catch {
|
||||||
@@ -357,11 +542,8 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function close(ws: ServerWebSocket<WSData>) {
|
export function close(ws: ServerWebSocket<WSData>) {
|
||||||
const active = activePipelines.get(ws);
|
// Detach from all jobs but don't stop them
|
||||||
if (active) {
|
jobManager.detachAllForViewer(ws);
|
||||||
active.abort();
|
|
||||||
activePipelines.delete(ws);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pipelineWebsocket = {
|
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 { appsRouter, appServeRouter } from './api/apps';
|
||||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||||
import { savedSessionsRouter } from './api/saved-sessions/saved-sessions';
|
import { savedSessionsRouter } from './api/saved-sessions/saved-sessions';
|
||||||
|
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
||||||
@@ -100,6 +101,7 @@ protectedRouter.route('/browser', browserRouter);
|
|||||||
protectedRouter.route('/apps', appsRouter);
|
protectedRouter.route('/apps', appsRouter);
|
||||||
protectedRouter.route('/bug-report', bugReportRouter);
|
protectedRouter.route('/bug-report', bugReportRouter);
|
||||||
protectedRouter.route('/saved-sessions', savedSessionsRouter);
|
protectedRouter.route('/saved-sessions', savedSessionsRouter);
|
||||||
|
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
||||||
desktopRouter.use(superAdminMiddleware);
|
desktopRouter.use(superAdminMiddleware);
|
||||||
protectedRouter.route('/desktop', desktopRouter);
|
protectedRouter.route('/desktop', desktopRouter);
|
||||||
protectedRouter.route('/', piRestRouter);
|
protectedRouter.route('/', piRestRouter);
|
||||||
|
|||||||
+91
-8
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
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 { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { cardStyle } from '@/components/Card';
|
import { cardStyle } from '@/components/Card';
|
||||||
@@ -512,7 +512,7 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
|||||||
// Auto-scroll
|
// Auto-scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [pipeline.messages, pipeline.streamingText, pipeline.currentStep]);
|
}, [pipeline.messages, pipeline.streamingText, pipeline.currentStep, pipeline.parallelStep]);
|
||||||
|
|
||||||
// Ding on completion
|
// Ding on completion
|
||||||
const prevPhaseRef = useRef(pipeline.phase);
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{/* Step progress header */}
|
{/* Step progress header — sequential */}
|
||||||
{pipeline.currentStep && (
|
{pipeline.currentStep && (
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-duck-dark/3">
|
<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">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
@@ -577,8 +593,65 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
|||||||
</div>
|
</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">
|
<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) => (
|
{pipeline.messages.map((msg, i) => (
|
||||||
<div key={i} className="px-4 py-1.5">
|
<div key={i} className="px-4 py-1.5">
|
||||||
<MessageBubble message={msg} onAnswer={() => {}} />
|
<MessageBubble message={msg} onAnswer={() => {}} />
|
||||||
@@ -614,8 +687,8 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pipeline.totalCost && (
|
{pipeline.totalCost && (
|
||||||
<span className="text-xs text-duck-dark/40">
|
<span className="text-xs text-duck-dark/40 font-mono tabular-nums">
|
||||||
${pipeline.totalCost.totalUSD.toFixed(3)} · {pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens} tokens
|
{formatElapsed(pipeline.elapsed)} · {(pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens).toLocaleString()} tokens · ${pipeline.totalCost.totalUSD.toFixed(3)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pipeline.skippedItems.length > 0 && (
|
{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(', ')})
|
{pipeline.skippedItems.length} skipped ({pipeline.skippedItems.map((s) => s.label).join(', ')})
|
||||||
</span>
|
</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>
|
||||||
</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' };
|
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
|
||||||
|
|
||||||
// Context values for autofill
|
// Context values for autofill
|
||||||
// Build a ~/relative path for the agent (works inside bwrap sandbox)
|
// Build absolute path the agent sees (sandboxed: /data/home/..., non-sandboxed: ~/...)
|
||||||
const entryRelPath = entryName && cwd.path ? `~/${cwd.path}/${entryName}` : entryName ? `~/${entryName}` : undefined;
|
const homePrefix = sandboxed ? '/data/home' : '~';
|
||||||
|
const entryRelPath = entryName && cwd.path ? `${homePrefix}/${cwd.path}/${entryName}` : entryName ? `${homePrefix}/${entryName}` : undefined;
|
||||||
const autofillContext: Record<string, string> = {};
|
const autofillContext: Record<string, string> = {};
|
||||||
if (entryName) autofillContext.entry_name = entryName;
|
if (entryName) autofillContext.entry_name = entryName;
|
||||||
if (entryRelPath) autofillContext.entry_path = entryRelPath;
|
if (entryRelPath) autofillContext.entry_path = entryRelPath;
|
||||||
|
|||||||
+244
-101
@@ -6,6 +6,7 @@ type Phase = 'ready' | 'running' | 'done';
|
|||||||
type StepDef = {
|
type StepDef = {
|
||||||
task: string;
|
task: string;
|
||||||
foreach?: string;
|
foreach?: string;
|
||||||
|
concurrency?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StepStatus = {
|
type StepStatus = {
|
||||||
@@ -15,31 +16,60 @@ type StepStatus = {
|
|||||||
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
|
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 ServerMessage =
|
||||||
| { type: 'pipeline:init'; steps: StepDef[] }
|
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
|
||||||
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
| { jobId: string; 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 } }
|
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
||||||
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||||
| { type: 'assistant:delta'; text: string }
|
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
|
||||||
| { type: 'assistant:text'; text: string }
|
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
|
||||||
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
|
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
||||||
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
|
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
|
||||||
| { type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
| { jobId: string; type: 'assistant:delta'; text: string; iterationLabel?: string }
|
||||||
| { type: 'error'; message: string }
|
| { jobId: string; type: 'assistant:text'; text: string; iterationLabel?: string }
|
||||||
| { type: 'stopped' };
|
| { 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() {
|
export function usePipelineRunner() {
|
||||||
const [phase, setPhase] = useState<Phase>('ready');
|
const [phase, setPhase] = useState<Phase>('ready');
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [steps, setSteps] = useState<StepDef[]>([]);
|
const [steps, setSteps] = useState<StepDef[]>([]);
|
||||||
const [currentStep, setCurrentStep] = useState<StepStatus | null>(null);
|
const [currentStep, setCurrentStep] = useState<StepStatus | null>(null);
|
||||||
|
const [parallelStep, setParallelStep] = useState<ParallelStep | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [streamingText, setStreamingText] = useState('');
|
const [streamingText, setStreamingText] = useState('');
|
||||||
const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(null);
|
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 [hasError, setHasError] = useState(false);
|
||||||
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
|
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const streamBufferRef = useRef('');
|
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 flushStream = useCallback(() => {
|
||||||
const text = streamBufferRef.current;
|
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(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('BEARER_TOKEN');
|
const token = localStorage.getItem('BEARER_TOKEN');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -59,98 +266,18 @@ export function usePipelineRunner() {
|
|||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
wsRef.current = ws;
|
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('close', () => setIsConnected(false));
|
||||||
|
|
||||||
ws.addEventListener('message', (ev) => {
|
ws.addEventListener('message', (ev) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data) as ServerMessage;
|
const msg = JSON.parse(ev.data) as ServerMessage;
|
||||||
|
handleEvent(msg);
|
||||||
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;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -159,6 +286,7 @@ export function usePipelineRunner() {
|
|||||||
return () => {
|
return () => {
|
||||||
ws.close();
|
ws.close();
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
|
stopTimer();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -169,17 +297,32 @@ export function usePipelineRunner() {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
setTotalCost(null);
|
setTotalCost(null);
|
||||||
|
setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
||||||
setHasError(false);
|
setHasError(false);
|
||||||
setSkippedItems([]);
|
setSkippedItems([]);
|
||||||
|
setCurrentStep(null);
|
||||||
|
setParallelStep(null);
|
||||||
|
setElapsed(0);
|
||||||
|
setJobId(null);
|
||||||
|
jobIdRef.current = null;
|
||||||
streamBufferRef.current = '';
|
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 }));
|
wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !jobIdRef.current) return;
|
||||||
wsRef.current.send(JSON.stringify({ type: 'stop' }));
|
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