Skip to content

Commit e0e15f9

Browse files
authored
Merge pull request #167 from PytorchConnectomics/feat/workflow-wave-forward-port
Forward-port workflow wave onto current main
2 parents 5aa4eaa + 8c31e19 commit e0e15f9

44 files changed

Lines changed: 4148 additions & 73 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"scripts": {
3030
"start": "react-scripts start",
31+
"test": "react-scripts test",
3132
"build": "cross-env CI=false react-scripts build",
3233
"electron": "electron ."
3334
},

client/src/App.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import "./App.css";
33
import Views from "./views/Views";
44
import { AppContext, ContextWrapper } from "./contexts/GlobalContext";
55
import { YamlContextWrapper } from "./contexts/YamlContext";
6+
import { WorkflowProvider } from "./contexts/WorkflowContext";
67

78
function CacheBootstrapper({ children }) {
89
const { resetFileState } = useContext(AppContext);
@@ -38,11 +39,13 @@ function App() {
3839
return (
3940
<ContextWrapper>
4041
<YamlContextWrapper>
41-
<CacheBootstrapper>
42-
<div className="App">
43-
<MainContent />
44-
</div>
45-
</CacheBootstrapper>
42+
<WorkflowProvider>
43+
<CacheBootstrapper>
44+
<div className="App">
45+
<MainContent />
46+
</div>
47+
</CacheBootstrapper>
48+
</WorkflowProvider>
4649
</YamlContextWrapper>
4750
</ContextWrapper>
4851
);
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from "react";
2+
import { fireEvent, render, screen } from "@testing-library/react";
3+
4+
import AgentProposalCard from "../components/chat/AgentProposalCard";
5+
6+
jest.mock("antd", () => ({
7+
Button: ({ children, ...props }) => (
8+
<button type="button" {...props}>
9+
{children}
10+
</button>
11+
),
12+
Space: ({ children }) => <div>{children}</div>,
13+
Tag: ({ children }) => <span>{children}</span>,
14+
Typography: {
15+
Text: ({ children }) => <span>{children}</span>,
16+
},
17+
}));
18+
19+
describe("AgentProposalCard", () => {
20+
it("renders hotspot proposal fields and approve/reject actions", () => {
21+
const onApprove = jest.fn();
22+
const onReject = jest.fn();
23+
const proposal = {
24+
type: "prioritize_failure_hotspots",
25+
rationale: "Focus annotation where the model fails most often.",
26+
target_dataset: "set-a",
27+
hotspots: ["z:11", "z:12"],
28+
priority_metric: "error_rate",
29+
min_failure_rate: 0.2,
30+
};
31+
32+
render(
33+
<AgentProposalCard
34+
proposal={proposal}
35+
onApprove={onApprove}
36+
onReject={onReject}
37+
/>,
38+
);
39+
40+
expect(screen.getByText("Prioritize Failure Hotspots")).toBeTruthy();
41+
expect(screen.getByText("set-a")).toBeTruthy();
42+
43+
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
44+
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
45+
46+
expect(onApprove).toHaveBeenCalledWith(proposal);
47+
expect(onReject).toHaveBeenCalledWith(proposal);
48+
});
49+
50+
it("supports correction-impact cards with compact rationale", () => {
51+
const proposal = {
52+
proposal_type: "preview_correction_impact",
53+
rationale: "A".repeat(200),
54+
target_metric: "f1",
55+
expected_delta: "+0.05",
56+
sample_size: 128,
57+
confidence: "high",
58+
};
59+
60+
render(<AgentProposalCard proposal={proposal} />);
61+
62+
expect(screen.getByText("Preview Correction Impact")).toBeTruthy();
63+
expect(screen.getByText(/A{157}/)).toBeTruthy();
64+
expect(screen.getByText("+0.05")).toBeTruthy();
65+
});
66+
67+
it("renders fallback proposal content", () => {
68+
render(
69+
<AgentProposalCard
70+
proposal={{ type: "custom_type", rationale: "Keep behavior stable", foo: "bar" }}
71+
/>,
72+
);
73+
74+
expect(screen.getByText("Agent Proposal")).toBeTruthy();
75+
expect(screen.getByText("Keep behavior stable")).toBeTruthy();
76+
expect(screen.getByText("bar")).toBeTruthy();
77+
});
78+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {
2+
DEFAULT_TIMELINE_FILTERS,
3+
filterTimelineEvents,
4+
} from "../contexts/workflow/timelineFilters";
5+
6+
const EVENTS = [
7+
{ id: "1", actor: "user", event_type: "dataset.loaded" },
8+
{ id: "2", actor: "agent", event_type: "agent.proposal_created" },
9+
{ id: "3", actor: "system", event_type: "inference.completed" },
10+
{ id: "4", actor: "agent", event_type: "agent.proposal_approved" },
11+
];
12+
13+
describe("workflow timeline filters", () => {
14+
it("preserves the full timeline by default", () => {
15+
const visible = filterTimelineEvents(EVENTS, DEFAULT_TIMELINE_FILTERS);
16+
expect(visible).toHaveLength(EVENTS.length);
17+
expect(visible).toBe(EVENTS);
18+
});
19+
20+
it("filters by actor and event type combinations", () => {
21+
expect(filterTimelineEvents(EVENTS, { actor: "agent", eventType: "" })).toEqual([
22+
EVENTS[1],
23+
EVENTS[3],
24+
]);
25+
26+
expect(
27+
filterTimelineEvents(EVENTS, { actor: "agent", eventType: "approved" }),
28+
).toEqual([EVENTS[3]]);
29+
30+
expect(
31+
filterTimelineEvents(EVENTS, { actor: "all", eventType: "proposal" }),
32+
).toEqual([EVENTS[1], EVENTS[3]]);
33+
});
34+
});

client/src/api.js

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const getErrorDetailMessage = (detail) => {
5252
return String(detail);
5353
};
5454

55-
export async function getNeuroglancerViewer(image, label, scales) {
55+
export async function getNeuroglancerViewer(image, label, scales, workflowId = null) {
5656
try {
5757
const url = `${BASE_URL}/neuroglancer`;
5858
if (hasBrowserFile(image)) {
@@ -70,6 +70,9 @@ export async function getNeuroglancerViewer(image, label, scales) {
7070
);
7171
}
7272
formData.append("scales", JSON.stringify(scales));
73+
if (workflowId) {
74+
formData.append("workflow_id", String(workflowId));
75+
}
7376
const res = await axios.post(url, formData);
7477
return res.data;
7578
}
@@ -78,6 +81,7 @@ export async function getNeuroglancerViewer(image, label, scales) {
7881
image: buildFilePath(image),
7982
label: buildFilePath(label),
8083
scales,
84+
workflow_id: workflowId,
8185
});
8286
const res = await axios.post(url, data);
8387
return res.data;
@@ -141,6 +145,7 @@ export async function startModelTraining(
141145
logPath,
142146
outputPath,
143147
configOriginPath = "",
148+
workflowId = null,
144149
) {
145150
try {
146151
console.log("[API] ===== Starting Training Configuration =====");
@@ -178,6 +183,7 @@ export async function startModelTraining(
178183
outputPath, // TensorBoard will use this instead
179184
trainingConfig: configToSend,
180185
configOriginPath,
186+
workflow_id: workflowId,
181187
});
182188

183189
console.log("[API] Request payload size:", data.length, "bytes");
@@ -232,6 +238,7 @@ export async function startModelInference(
232238
outputPath,
233239
checkpointPath,
234240
configOriginPath = "",
241+
workflowId = null,
235242
) {
236243
console.log("\n========== API.JS: START_MODEL_INFERENCE CALLED ==========");
237244
console.log("[API] Function arguments:");
@@ -293,6 +300,7 @@ export async function startModelInference(
293300
outputPath,
294301
inferenceConfig: configToSend,
295302
configOriginPath,
303+
workflow_id: workflowId,
296304
};
297305

298306
console.log("[API] Payload structure:");
@@ -462,3 +470,122 @@ export async function getConfigPresetContent(path) {
462470
export async function getModelArchitectures() {
463471
return makeApiRequest("pytc/architectures", "get");
464472
}
473+
474+
// ── Workflow spine ───────────────────────────────────────────────────────────
475+
476+
export async function getCurrentWorkflow() {
477+
try {
478+
const res = await apiClient.get("/api/workflows/current");
479+
return res.data;
480+
} catch (error) {
481+
handleError(error);
482+
}
483+
}
484+
485+
export async function updateWorkflow(workflowId, patch) {
486+
try {
487+
const res = await apiClient.patch(`/api/workflows/${workflowId}`, patch);
488+
return res.data;
489+
} catch (error) {
490+
handleError(error);
491+
}
492+
}
493+
494+
export async function listWorkflowEvents(workflowId) {
495+
try {
496+
const res = await apiClient.get(`/api/workflows/${workflowId}/events`);
497+
return res.data;
498+
} catch (error) {
499+
handleError(error);
500+
}
501+
}
502+
503+
export async function getWorkflowHotspots(workflowId) {
504+
try {
505+
const res = await apiClient.get(`/api/workflows/${workflowId}/hotspots`);
506+
return res.data;
507+
} catch (error) {
508+
handleError(error);
509+
}
510+
}
511+
512+
export async function getWorkflowImpactPreview(workflowId) {
513+
try {
514+
const res = await apiClient.get(`/api/workflows/${workflowId}/impact-preview`);
515+
return res.data;
516+
} catch (error) {
517+
handleError(error);
518+
}
519+
}
520+
521+
export async function getWorkflowMetrics(workflowId) {
522+
try {
523+
const res = await apiClient.get(`/api/workflows/${workflowId}/metrics`);
524+
return res.data;
525+
} catch (error) {
526+
handleError(error);
527+
}
528+
}
529+
530+
export async function exportWorkflowBundle(workflowId) {
531+
try {
532+
const res = await apiClient.post(`/api/workflows/${workflowId}/export-bundle`);
533+
return res.data;
534+
} catch (error) {
535+
handleError(error);
536+
}
537+
}
538+
539+
export async function appendWorkflowEvent(workflowId, event) {
540+
try {
541+
const res = await apiClient.post(`/api/workflows/${workflowId}/events`, event);
542+
return res.data;
543+
} catch (error) {
544+
handleError(error);
545+
}
546+
}
547+
548+
export async function createAgentAction(workflowId, action) {
549+
try {
550+
const res = await apiClient.post(
551+
`/api/workflows/${workflowId}/agent-actions`,
552+
action,
553+
);
554+
return res.data;
555+
} catch (error) {
556+
handleError(error);
557+
}
558+
}
559+
560+
export async function approveAgentAction(workflowId, eventId) {
561+
try {
562+
const res = await apiClient.post(
563+
`/api/workflows/${workflowId}/agent-actions/${eventId}/approve`,
564+
);
565+
return res.data;
566+
} catch (error) {
567+
handleError(error);
568+
}
569+
}
570+
571+
export async function rejectAgentAction(workflowId, eventId) {
572+
try {
573+
const res = await apiClient.post(
574+
`/api/workflows/${workflowId}/agent-actions/${eventId}/reject`,
575+
);
576+
return res.data;
577+
} catch (error) {
578+
handleError(error);
579+
}
580+
}
581+
582+
export async function queryWorkflowAgent(workflowId, query) {
583+
try {
584+
const res = await apiClient.post(`/api/workflows/${workflowId}/agent/query`, {
585+
query,
586+
});
587+
return res.data;
588+
} catch (error) {
589+
handleError(error);
590+
}
591+
}

0 commit comments

Comments
 (0)