feat(FN-1852): merge fusion/fn-1852
This commit is contained in:
@@ -368,6 +368,33 @@ export function TaskDetailModal({
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [activeTab, task.id, projectId, addToast]);
|
}, [activeTab, task.id, projectId, addToast]);
|
||||||
|
|
||||||
|
// Subscribe to SSE for real-time workflow result updates while workflow tab is active
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== "workflow") return;
|
||||||
|
|
||||||
|
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||||
|
const es = new EventSource(`/api/events${query}`);
|
||||||
|
|
||||||
|
const handleTaskUpdated = (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const updatedTask = JSON.parse(e.data);
|
||||||
|
// Only update if this is for our task and has workflow step results
|
||||||
|
if (updatedTask.id === task.id && Array.isArray(updatedTask.workflowStepResults)) {
|
||||||
|
setWorkflowResults(updatedTask.workflowStepResults);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed events
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.addEventListener("task:updated", handleTaskUpdated);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.removeEventListener("task:updated", handleTaskUpdated);
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
}, [activeTab, task.id, projectId]);
|
||||||
|
|
||||||
// Reset dependency search when dropdown closes
|
// Reset dependency search when dropdown closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showDepDropdown) {
|
if (!showDepDropdown) {
|
||||||
@@ -1136,6 +1163,7 @@ export function TaskDetailModal({
|
|||||||
enabledWorkflowSteps={workflowEnabledSteps}
|
enabledWorkflowSteps={workflowEnabledSteps}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
isTaskInProgress={task.column === "in-progress" && task.status !== "paused"}
|
||||||
onWorkflowStepsChange={handleWorkflowStepsChange}
|
onWorkflowStepsChange={handleWorkflowStepsChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { Check, ChevronDown, ChevronUp, Maximize2, Pencil, X } from "lucide-react";
|
import { Check, ChevronDown, ChevronUp, Maximize2, Pencil, X } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
||||||
import { fetchWorkflowSteps } from "../api";
|
import { fetchWorkflowSteps } from "../api";
|
||||||
|
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
|
|
||||||
// Markdown rendering components for workflow output
|
// Markdown rendering components for workflow output
|
||||||
@@ -42,6 +43,7 @@ interface WorkflowResultsTabProps {
|
|||||||
enabledWorkflowSteps?: string[];
|
enabledWorkflowSteps?: string[];
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
isTaskInProgress?: boolean;
|
||||||
onWorkflowStepsChange?: (steps: string[]) => void;
|
onWorkflowStepsChange?: (steps: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +107,92 @@ function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders live agent log output for a running (pending) workflow step.
|
||||||
|
* Filters entries to show only those timestamped on or after the step's startedAt.
|
||||||
|
*/
|
||||||
|
function LiveAgentLogOutput({
|
||||||
|
entries,
|
||||||
|
startedAt,
|
||||||
|
stepId,
|
||||||
|
}: {
|
||||||
|
entries: AgentLogEntry[];
|
||||||
|
startedAt: string;
|
||||||
|
stepId: string;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const startedAtMs = new Date(startedAt).getTime();
|
||||||
|
|
||||||
|
// Filter entries to only show those from this step's time window
|
||||||
|
const stepEntries = entries.filter((entry) => {
|
||||||
|
const entryMs = new Date(entry.timestamp).getTime();
|
||||||
|
return entryMs >= startedAtMs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll to bottom as new entries arrive
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}, [stepEntries.length]);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="workflow-live-log" data-testid={`workflow-live-log-${stepId}`}>
|
||||||
|
<div className="workflow-live-log-empty">Waiting for agent output…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="workflow-live-log"
|
||||||
|
data-testid={`workflow-live-log-${stepId}`}
|
||||||
|
>
|
||||||
|
{stepEntries.map((entry, i) => {
|
||||||
|
if (entry.type === "tool") {
|
||||||
|
return (
|
||||||
|
<div key={i} className="workflow-live-log-tool">
|
||||||
|
⚡ {entry.text}
|
||||||
|
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.type === "tool_result") {
|
||||||
|
return (
|
||||||
|
<div key={i} className="workflow-live-log-tool-result">
|
||||||
|
✓ {entry.text}
|
||||||
|
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.type === "tool_error") {
|
||||||
|
return (
|
||||||
|
<div key={i} className="workflow-live-log-tool-error">
|
||||||
|
✗ {entry.text}
|
||||||
|
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.type === "thinking") {
|
||||||
|
return (
|
||||||
|
<div key={i} className="workflow-live-log-thinking">
|
||||||
|
{entry.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Default: text entries
|
||||||
|
return (
|
||||||
|
<span key={i} className="workflow-live-log-text">
|
||||||
|
{entry.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkflowResultsTab({
|
export function WorkflowResultsTab({
|
||||||
taskId,
|
taskId,
|
||||||
results,
|
results,
|
||||||
@@ -112,6 +200,7 @@ export function WorkflowResultsTab({
|
|||||||
enabledWorkflowSteps,
|
enabledWorkflowSteps,
|
||||||
canEdit,
|
canEdit,
|
||||||
projectId,
|
projectId,
|
||||||
|
isTaskInProgress,
|
||||||
onWorkflowStepsChange,
|
onWorkflowStepsChange,
|
||||||
}: WorkflowResultsTabProps) {
|
}: WorkflowResultsTabProps) {
|
||||||
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
|
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
|
||||||
@@ -120,6 +209,16 @@ export function WorkflowResultsTab({
|
|||||||
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
|
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
// Check if any result has pending status
|
||||||
|
const hasPendingStep = results.some((r) => r.status === "pending");
|
||||||
|
|
||||||
|
// Subscribe to live agent logs when task is in progress and has a pending step
|
||||||
|
const { entries: liveLogEntries } = useAgentLogs(
|
||||||
|
taskId,
|
||||||
|
!!isTaskInProgress && hasPendingStep,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchWorkflowSteps(projectId)
|
fetchWorkflowSteps(projectId)
|
||||||
@@ -412,7 +511,14 @@ export function WorkflowResultsTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result.output && (
|
{/* Show live agent logs for pending steps, static output for completed steps */}
|
||||||
|
{result.status === "pending" && result.startedAt ? (
|
||||||
|
<LiveAgentLogOutput
|
||||||
|
entries={liveLogEntries}
|
||||||
|
startedAt={result.startedAt}
|
||||||
|
stepId={result.workflowStepId}
|
||||||
|
/>
|
||||||
|
) : result.output ? (
|
||||||
<div className="workflow-result-output-section">
|
<div className="workflow-result-output-section">
|
||||||
<div className="workflow-result-output-header">
|
<div className="workflow-result-output-header">
|
||||||
<span className="workflow-result-output-label">Output:</span>
|
<span className="workflow-result-output-label">Output:</span>
|
||||||
@@ -471,7 +577,7 @@ export function WorkflowResultsTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -22729,6 +22729,58 @@ html .column.drag-over * {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Workflow Live Log ===== */
|
||||||
|
|
||||||
|
.workflow-live-log {
|
||||||
|
margin-top: var(--space-sm, 8px);
|
||||||
|
padding: var(--space-sm, 8px);
|
||||||
|
background: var(--surface-elevated, #161b22);
|
||||||
|
border: 1px solid var(--border, #30363d);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-live-log-empty {
|
||||||
|
color: var(--text-muted, #8b949e);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-live-log-text {
|
||||||
|
display: block;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--text, #e6edf3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-live-log-tool {
|
||||||
|
color: var(--accent, #58a6ff);
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-live-log-tool-result {
|
||||||
|
color: var(--success, #3fb950);
|
||||||
|
padding: 2px 0 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-live-log-tool-error {
|
||||||
|
color: var(--error, #f85149);
|
||||||
|
padding: 2px 0 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-live-log-thinking {
|
||||||
|
color: var(--text-muted, #8b949e);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-live-log-detail {
|
||||||
|
color: var(--text-muted, #8b949e);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Model Onboarding Modal ===== */
|
/* ===== Model Onboarding Modal ===== */
|
||||||
|
|
||||||
.model-onboarding-modal {
|
.model-onboarding-modal {
|
||||||
|
|||||||
@@ -2896,6 +2896,16 @@ ${failureFeedback}
|
|||||||
|
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Push pending entry BEFORE execution so dashboard can show live status
|
||||||
|
results.push({
|
||||||
|
workflowStepId: ws.id,
|
||||||
|
workflowStepName: ws.name,
|
||||||
|
phase: stepPhase,
|
||||||
|
status: "pending",
|
||||||
|
startedAt,
|
||||||
|
});
|
||||||
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: WorkflowStepOutcome = stepMode === "script"
|
const result: WorkflowStepOutcome = stepMode === "script"
|
||||||
? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings)
|
? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings)
|
||||||
@@ -2905,15 +2915,16 @@ ${failureFeedback}
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
await this.store.logEntry(task.id, `[pre-merge] Workflow step completed: ${ws.name}`);
|
await this.store.logEntry(task.id, `[pre-merge] Workflow step completed: ${ws.name}`);
|
||||||
executorLog.log(`${task.id} — [pre-merge] workflow step passed: ${ws.name}`);
|
executorLog.log(`${task.id} — [pre-merge] workflow step passed: ${ws.name}`);
|
||||||
results.push({
|
// Update existing pending entry in place
|
||||||
workflowStepId: ws.id,
|
const existingIdx = results.findIndex(r => r.workflowStepId === ws.id);
|
||||||
workflowStepName: ws.name,
|
if (existingIdx >= 0) {
|
||||||
phase: stepPhase,
|
results[existingIdx] = {
|
||||||
status: "passed",
|
...results[existingIdx],
|
||||||
output: result.output,
|
status: "passed",
|
||||||
startedAt,
|
output: result.output,
|
||||||
completedAt,
|
completedAt,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
await this.store.updateTask(task.id, { workflowStepResults: results });
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
} else if (result.revisionRequested) {
|
} else if (result.revisionRequested) {
|
||||||
// Revision requested — this is a structured outcome that routes back to executor
|
// Revision requested — this is a structured outcome that routes back to executor
|
||||||
@@ -2923,15 +2934,16 @@ ${failureFeedback}
|
|||||||
result.output,
|
result.output,
|
||||||
);
|
);
|
||||||
executorLog.log(`${task.id} — [pre-merge] workflow step requested revision: ${ws.name}`);
|
executorLog.log(`${task.id} — [pre-merge] workflow step requested revision: ${ws.name}`);
|
||||||
results.push({
|
// Update existing pending entry in place
|
||||||
workflowStepId: ws.id,
|
const existingIdx = results.findIndex(r => r.workflowStepId === ws.id);
|
||||||
workflowStepName: ws.name,
|
if (existingIdx >= 0) {
|
||||||
phase: stepPhase,
|
results[existingIdx] = {
|
||||||
status: "failed",
|
...results[existingIdx],
|
||||||
output: result.output || "Revision requested",
|
status: "failed",
|
||||||
startedAt,
|
output: result.output || "Revision requested",
|
||||||
completedAt,
|
completedAt,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
await this.store.updateTask(task.id, { workflowStepResults: results });
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
return {
|
return {
|
||||||
allPassed: false,
|
allPassed: false,
|
||||||
@@ -2947,15 +2959,16 @@ ${failureFeedback}
|
|||||||
result.error || "Unknown error",
|
result.error || "Unknown error",
|
||||||
);
|
);
|
||||||
executorLog.error(`${task.id} — [pre-merge] workflow step failed: ${ws.name}; output captured in task log`);
|
executorLog.error(`${task.id} — [pre-merge] workflow step failed: ${ws.name}; output captured in task log`);
|
||||||
results.push({
|
// Update existing pending entry in place
|
||||||
workflowStepId: ws.id,
|
const existingIdx = results.findIndex(r => r.workflowStepId === ws.id);
|
||||||
workflowStepName: ws.name,
|
if (existingIdx >= 0) {
|
||||||
phase: stepPhase,
|
results[existingIdx] = {
|
||||||
status: "failed",
|
...results[existingIdx],
|
||||||
output: result.error || "Workflow step failed",
|
status: "failed",
|
||||||
startedAt,
|
output: result.error || "Workflow step failed",
|
||||||
completedAt,
|
completedAt,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
await this.store.updateTask(task.id, { workflowStepResults: results });
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
return {
|
return {
|
||||||
allPassed: false,
|
allPassed: false,
|
||||||
@@ -2972,15 +2985,16 @@ ${failureFeedback}
|
|||||||
err.message || "Unknown error",
|
err.message || "Unknown error",
|
||||||
);
|
);
|
||||||
executorLog.error(`${task.id} — [pre-merge] workflow step error: ${ws.name} — ${err.message}`);
|
executorLog.error(`${task.id} — [pre-merge] workflow step error: ${ws.name} — ${err.message}`);
|
||||||
results.push({
|
// Update existing pending entry in place
|
||||||
workflowStepId: ws.id,
|
const existingIdx = results.findIndex(r => r.workflowStepId === ws.id);
|
||||||
workflowStepName: ws.name,
|
if (existingIdx >= 0) {
|
||||||
phase: stepPhase,
|
results[existingIdx] = {
|
||||||
status: "failed",
|
...results[existingIdx],
|
||||||
output: err.message || "Workflow step error",
|
status: "failed",
|
||||||
startedAt,
|
output: err.message || "Workflow step error",
|
||||||
completedAt,
|
completedAt,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
await this.store.updateTask(task.id, { workflowStepResults: results });
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
return {
|
return {
|
||||||
allPassed: false,
|
allPassed: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user