Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"scripts": {
"start": "react-scripts start",
"test": "react-scripts test",
"build": "cross-env CI=false react-scripts build",
"electron": "electron ."
},
Expand Down
13 changes: 8 additions & 5 deletions client/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import "./App.css";
import Views from "./views/Views";
import { AppContext, ContextWrapper } from "./contexts/GlobalContext";
import { YamlContextWrapper } from "./contexts/YamlContext";
import { WorkflowProvider } from "./contexts/WorkflowContext";

function CacheBootstrapper({ children }) {
const { resetFileState } = useContext(AppContext);
Expand Down Expand Up @@ -38,11 +39,13 @@ function App() {
return (
<ContextWrapper>
<YamlContextWrapper>
<CacheBootstrapper>
<div className="App">
<MainContent />
</div>
</CacheBootstrapper>
<WorkflowProvider>
<CacheBootstrapper>
<div className="App">
<MainContent />
</div>
</CacheBootstrapper>
</WorkflowProvider>
</YamlContextWrapper>
</ContextWrapper>
);
Expand Down
78 changes: 78 additions & 0 deletions client/src/__tests__/agentProposalCards.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";

import AgentProposalCard from "../components/chat/AgentProposalCard";

jest.mock("antd", () => ({
Button: ({ children, ...props }) => (
<button type="button" {...props}>
{children}
</button>
),
Space: ({ children }) => <div>{children}</div>,
Tag: ({ children }) => <span>{children}</span>,
Typography: {
Text: ({ children }) => <span>{children}</span>,
},
}));

describe("AgentProposalCard", () => {
it("renders hotspot proposal fields and approve/reject actions", () => {
const onApprove = jest.fn();
const onReject = jest.fn();
const proposal = {
type: "prioritize_failure_hotspots",
rationale: "Focus annotation where the model fails most often.",
target_dataset: "set-a",
hotspots: ["z:11", "z:12"],
priority_metric: "error_rate",
min_failure_rate: 0.2,
};

render(
<AgentProposalCard
proposal={proposal}
onApprove={onApprove}
onReject={onReject}
/>,
);

expect(screen.getByText("Prioritize Failure Hotspots")).toBeTruthy();
expect(screen.getByText("set-a")).toBeTruthy();

fireEvent.click(screen.getByRole("button", { name: "Approve" }));
fireEvent.click(screen.getByRole("button", { name: "Reject" }));

expect(onApprove).toHaveBeenCalledWith(proposal);
expect(onReject).toHaveBeenCalledWith(proposal);
});

it("supports correction-impact cards with compact rationale", () => {
const proposal = {
proposal_type: "preview_correction_impact",
rationale: "A".repeat(200),
target_metric: "f1",
expected_delta: "+0.05",
sample_size: 128,
confidence: "high",
};

render(<AgentProposalCard proposal={proposal} />);

expect(screen.getByText("Preview Correction Impact")).toBeTruthy();
expect(screen.getByText(/A{157}…/)).toBeTruthy();
expect(screen.getByText("+0.05")).toBeTruthy();
});

it("renders fallback proposal content", () => {
render(
<AgentProposalCard
proposal={{ type: "custom_type", rationale: "Keep behavior stable", foo: "bar" }}
/>,
);

expect(screen.getByText("Agent Proposal")).toBeTruthy();
expect(screen.getByText("Keep behavior stable")).toBeTruthy();
expect(screen.getByText("bar")).toBeTruthy();
});
});
34 changes: 34 additions & 0 deletions client/src/__tests__/workflowTimelineFilters.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
DEFAULT_TIMELINE_FILTERS,
filterTimelineEvents,
} from "../contexts/workflow/timelineFilters";

const EVENTS = [
{ id: "1", actor: "user", event_type: "dataset.loaded" },
{ id: "2", actor: "agent", event_type: "agent.proposal_created" },
{ id: "3", actor: "system", event_type: "inference.completed" },
{ id: "4", actor: "agent", event_type: "agent.proposal_approved" },
];

describe("workflow timeline filters", () => {
it("preserves the full timeline by default", () => {
const visible = filterTimelineEvents(EVENTS, DEFAULT_TIMELINE_FILTERS);
expect(visible).toHaveLength(EVENTS.length);
expect(visible).toBe(EVENTS);
});

it("filters by actor and event type combinations", () => {
expect(filterTimelineEvents(EVENTS, { actor: "agent", eventType: "" })).toEqual([
EVENTS[1],
EVENTS[3],
]);

expect(
filterTimelineEvents(EVENTS, { actor: "agent", eventType: "approved" }),
).toEqual([EVENTS[3]]);

expect(
filterTimelineEvents(EVENTS, { actor: "all", eventType: "proposal" }),
).toEqual([EVENTS[1], EVENTS[3]]);
});
});
129 changes: 128 additions & 1 deletion client/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const getErrorDetailMessage = (detail) => {
return String(detail);
};

export async function getNeuroglancerViewer(image, label, scales) {
export async function getNeuroglancerViewer(image, label, scales, workflowId = null) {
try {
const url = `${BASE_URL}/neuroglancer`;
if (hasBrowserFile(image)) {
Expand All @@ -70,6 +70,9 @@ export async function getNeuroglancerViewer(image, label, scales) {
);
}
formData.append("scales", JSON.stringify(scales));
if (workflowId) {
formData.append("workflow_id", String(workflowId));
}
const res = await axios.post(url, formData);
return res.data;
}
Expand All @@ -78,6 +81,7 @@ export async function getNeuroglancerViewer(image, label, scales) {
image: buildFilePath(image),
label: buildFilePath(label),
scales,
workflow_id: workflowId,
});
const res = await axios.post(url, data);
return res.data;
Expand Down Expand Up @@ -141,6 +145,7 @@ export async function startModelTraining(
logPath,
outputPath,
configOriginPath = "",
workflowId = null,
) {
try {
console.log("[API] ===== Starting Training Configuration =====");
Expand Down Expand Up @@ -178,6 +183,7 @@ export async function startModelTraining(
outputPath, // TensorBoard will use this instead
trainingConfig: configToSend,
configOriginPath,
workflow_id: workflowId,
});

console.log("[API] Request payload size:", data.length, "bytes");
Expand Down Expand Up @@ -232,6 +238,7 @@ export async function startModelInference(
outputPath,
checkpointPath,
configOriginPath = "",
workflowId = null,
) {
console.log("\n========== API.JS: START_MODEL_INFERENCE CALLED ==========");
console.log("[API] Function arguments:");
Expand Down Expand Up @@ -293,6 +300,7 @@ export async function startModelInference(
outputPath,
inferenceConfig: configToSend,
configOriginPath,
workflow_id: workflowId,
};

console.log("[API] Payload structure:");
Expand Down Expand Up @@ -462,3 +470,122 @@ export async function getConfigPresetContent(path) {
export async function getModelArchitectures() {
return makeApiRequest("pytc/architectures", "get");
}

// ── Workflow spine ───────────────────────────────────────────────────────────

export async function getCurrentWorkflow() {
try {
const res = await apiClient.get("/api/workflows/current");
return res.data;
} catch (error) {
handleError(error);
}
}

export async function updateWorkflow(workflowId, patch) {
try {
const res = await apiClient.patch(`/api/workflows/${workflowId}`, patch);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function listWorkflowEvents(workflowId) {
try {
const res = await apiClient.get(`/api/workflows/${workflowId}/events`);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function getWorkflowHotspots(workflowId) {
try {
const res = await apiClient.get(`/api/workflows/${workflowId}/hotspots`);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function getWorkflowImpactPreview(workflowId) {
try {
const res = await apiClient.get(`/api/workflows/${workflowId}/impact-preview`);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function getWorkflowMetrics(workflowId) {
try {
const res = await apiClient.get(`/api/workflows/${workflowId}/metrics`);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function exportWorkflowBundle(workflowId) {
try {
const res = await apiClient.post(`/api/workflows/${workflowId}/export-bundle`);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function appendWorkflowEvent(workflowId, event) {
try {
const res = await apiClient.post(`/api/workflows/${workflowId}/events`, event);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function createAgentAction(workflowId, action) {
try {
const res = await apiClient.post(
`/api/workflows/${workflowId}/agent-actions`,
action,
);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function approveAgentAction(workflowId, eventId) {
try {
const res = await apiClient.post(
`/api/workflows/${workflowId}/agent-actions/${eventId}/approve`,
);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function rejectAgentAction(workflowId, eventId) {
try {
const res = await apiClient.post(
`/api/workflows/${workflowId}/agent-actions/${eventId}/reject`,
);
return res.data;
} catch (error) {
handleError(error);
}
}

export async function queryWorkflowAgent(workflowId, query) {
try {
const res = await apiClient.post(`/api/workflows/${workflowId}/agent/query`, {
query,
});
return res.data;
} catch (error) {
handleError(error);
}
}
Loading
Loading