newpr 0.6.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/history/store.ts +25 -0
  3. package/src/stack/balance.ts +128 -0
  4. package/src/stack/coupling.test.ts +158 -0
  5. package/src/stack/coupling.ts +135 -0
  6. package/src/stack/delta.test.ts +223 -0
  7. package/src/stack/delta.ts +264 -0
  8. package/src/stack/execute.test.ts +176 -0
  9. package/src/stack/execute.ts +194 -0
  10. package/src/stack/feasibility.test.ts +185 -0
  11. package/src/stack/feasibility.ts +286 -0
  12. package/src/stack/integration.test.ts +266 -0
  13. package/src/stack/merge-groups.test.ts +97 -0
  14. package/src/stack/merge-groups.ts +87 -0
  15. package/src/stack/partition.test.ts +233 -0
  16. package/src/stack/partition.ts +273 -0
  17. package/src/stack/plan.test.ts +154 -0
  18. package/src/stack/plan.ts +139 -0
  19. package/src/stack/pr-title.ts +64 -0
  20. package/src/stack/publish.ts +96 -0
  21. package/src/stack/split.ts +173 -0
  22. package/src/stack/types.ts +202 -0
  23. package/src/stack/verify.test.ts +137 -0
  24. package/src/stack/verify.ts +201 -0
  25. package/src/web/client/components/FeasibilityAlert.tsx +64 -0
  26. package/src/web/client/components/InputScreen.tsx +100 -89
  27. package/src/web/client/components/ResultsScreen.tsx +10 -2
  28. package/src/web/client/components/StackGroupCard.tsx +171 -0
  29. package/src/web/client/components/StackWarnings.tsx +135 -0
  30. package/src/web/client/hooks/useStack.ts +301 -0
  31. package/src/web/client/panels/StackPanel.tsx +289 -0
  32. package/src/web/server/routes.ts +114 -0
  33. package/src/web/server/stack-manager.ts +580 -0
  34. package/src/web/server.ts +15 -0
  35. package/src/web/styles/built.css +1 -1
@@ -0,0 +1,171 @@
1
+ import { ChevronRight, GitBranch, ExternalLink, Plus, Minus } from "lucide-react";
2
+ import { useState } from "react";
3
+ import type { StackGroupStats } from "../../../stack/types.ts";
4
+
5
+ const TYPE_COLORS: Record<string, { bg: string; text: string }> = {
6
+ feature: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
7
+ refactor: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
8
+ bugfix: { bg: "bg-red-500/10", text: "text-red-600 dark:text-red-400" },
9
+ chore: { bg: "bg-neutral-500/10", text: "text-neutral-500" },
10
+ docs: { bg: "bg-teal-500/10", text: "text-teal-600 dark:text-teal-400" },
11
+ test: { bg: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400" },
12
+ config: { bg: "bg-orange-500/10", text: "text-orange-600 dark:text-orange-400" },
13
+ };
14
+
15
+ function formatStat(n: number): string {
16
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
17
+ return String(n);
18
+ }
19
+
20
+ function SizeBar({ stats }: { stats: StackGroupStats }) {
21
+ const total = stats.additions + stats.deletions;
22
+ if (total === 0) return null;
23
+ const addPct = Math.round((stats.additions / total) * 100);
24
+ const segments = 5;
25
+ const addSegs = Math.round((addPct / 100) * segments);
26
+ const delSegs = segments - addSegs;
27
+
28
+ return (
29
+ <div className="flex items-center gap-[2px]">
30
+ {Array.from({ length: addSegs }).map((_, i) => (
31
+ <div key={`a${i}`} className="h-[6px] w-[6px] rounded-[1px] bg-green-500/70" />
32
+ ))}
33
+ {Array.from({ length: delSegs }).map((_, i) => (
34
+ <div key={`d${i}`} className="h-[6px] w-[6px] rounded-[1px] bg-red-500/70" />
35
+ ))}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ interface StackGroupCardProps {
41
+ group: {
42
+ id: string;
43
+ name: string;
44
+ type: string;
45
+ description: string;
46
+ files: string[];
47
+ order: number;
48
+ stats?: StackGroupStats;
49
+ pr_title?: string;
50
+ };
51
+ commit?: {
52
+ commit_sha: string;
53
+ branch_name: string;
54
+ };
55
+ pr?: {
56
+ number: number;
57
+ url: string;
58
+ title: string;
59
+ };
60
+ }
61
+
62
+ export function StackGroupCard({ group, commit, pr }: StackGroupCardProps) {
63
+ const [expanded, setExpanded] = useState(false);
64
+ const stats = group.stats;
65
+ const colors = TYPE_COLORS[group.type] ?? TYPE_COLORS.chore!;
66
+
67
+ return (
68
+ <div className="group/card">
69
+ <button
70
+ type="button"
71
+ onClick={() => setExpanded(!expanded)}
72
+ className="w-full flex items-center gap-2.5 px-3 py-2.5 text-left hover:bg-accent/20 transition-colors rounded-md"
73
+ >
74
+ <span className="text-[10px] text-muted-foreground/20 tabular-nums w-4 shrink-0 text-right">
75
+ {group.order + 1}
76
+ </span>
77
+
78
+ <ChevronRight className={`h-3 w-3 text-muted-foreground/25 shrink-0 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
79
+
80
+ <span className={`text-[9px] font-medium px-1.5 py-px rounded ${colors.bg} ${colors.text} shrink-0`}>
81
+ {group.type}
82
+ </span>
83
+
84
+ <span className="text-[12px] font-medium flex-1 min-w-0 truncate text-foreground/90">
85
+ {group.pr_title ?? group.name}
86
+ </span>
87
+
88
+ {stats ? (
89
+ <span className="flex items-center gap-2 shrink-0">
90
+ <span className="text-[10px] text-green-600/70 dark:text-green-400/70 tabular-nums">
91
+ +{formatStat(stats.additions)}
92
+ </span>
93
+ <span className="text-[10px] text-red-500/70 tabular-nums">
94
+ −{formatStat(stats.deletions)}
95
+ </span>
96
+ <SizeBar stats={stats} />
97
+ </span>
98
+ ) : (
99
+ <span className="text-[10px] text-muted-foreground/25 shrink-0 tabular-nums">
100
+ {group.files.length}f
101
+ </span>
102
+ )}
103
+ </button>
104
+
105
+ {expanded && (
106
+ <div className="ml-[26px] pl-5 pb-3 space-y-2.5 border-l border-border/50">
107
+ {group.pr_title && (
108
+ <div className="flex items-center gap-1.5">
109
+ <span className="text-[10px] text-muted-foreground/30">{group.name}</span>
110
+ </div>
111
+ )}
112
+
113
+ <p className="text-[11px] text-muted-foreground/50 leading-[1.6]">
114
+ {group.description}
115
+ </p>
116
+
117
+ {stats && (
118
+ <div className="flex items-center gap-4 text-[10px]">
119
+ <span className="text-muted-foreground/30 tabular-nums">
120
+ {group.files.length} files
121
+ </span>
122
+ <span className="flex items-center gap-1">
123
+ <Plus className="h-2.5 w-2.5 text-green-600/60 dark:text-green-400/60" />
124
+ <span className="tabular-nums text-green-600/60 dark:text-green-400/60">{stats.additions.toLocaleString()}</span>
125
+ </span>
126
+ <span className="flex items-center gap-1">
127
+ <Minus className="h-2.5 w-2.5 text-red-500/60" />
128
+ <span className="tabular-nums text-red-500/60">{stats.deletions.toLocaleString()}</span>
129
+ </span>
130
+ <span className="text-muted-foreground/20 tabular-nums">
131
+ {stats.files_added > 0 && `${stats.files_added}A `}
132
+ {stats.files_modified > 0 && `${stats.files_modified}M `}
133
+ {stats.files_deleted > 0 && `${stats.files_deleted}D`}
134
+ </span>
135
+ </div>
136
+ )}
137
+
138
+ {commit && (
139
+ <div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/30">
140
+ <GitBranch className="h-3 w-3 shrink-0" />
141
+ <span className="font-mono truncate">{commit.branch_name}</span>
142
+ <span className="text-muted-foreground/15">·</span>
143
+ <span className="font-mono shrink-0">{commit.commit_sha.slice(0, 7)}</span>
144
+ </div>
145
+ )}
146
+
147
+ {pr && (
148
+ <a
149
+ href={pr.url}
150
+ target="_blank"
151
+ rel="noopener noreferrer"
152
+ className="inline-flex items-center gap-1.5 text-[10px] text-foreground/50 hover:text-foreground transition-colors"
153
+ >
154
+ <ExternalLink className="h-3 w-3 shrink-0" />
155
+ <span className="tabular-nums">#{pr.number}</span>
156
+ <span className="truncate">{pr.title}</span>
157
+ </a>
158
+ )}
159
+
160
+ <div className="space-y-0">
161
+ {group.files.map((file) => (
162
+ <div key={file} className="text-[10px] font-mono text-muted-foreground/25 py-[2px] truncate">
163
+ {file}
164
+ </div>
165
+ ))}
166
+ </div>
167
+ </div>
168
+ )}
169
+ </div>
170
+ );
171
+ }
@@ -0,0 +1,135 @@
1
+ import { useState } from "react";
2
+ import { ChevronRight, AlertTriangle, Info } from "lucide-react";
3
+ import type { StackWarning, StackWarningCategory } from "../../../stack/types.ts";
4
+
5
+ const CATEGORY_LABELS: Record<StackWarningCategory, string> = {
6
+ assignment: "File Assignment",
7
+ grouping: "Group Changes",
8
+ coupling: "Coupling Rules",
9
+ "verification.scope": "Scope Check",
10
+ "verification.completeness": "Completeness Check",
11
+ system: "System",
12
+ };
13
+
14
+ const CATEGORY_ORDER: StackWarningCategory[] = [
15
+ "system",
16
+ "assignment",
17
+ "coupling",
18
+ "grouping",
19
+ "verification.scope",
20
+ "verification.completeness",
21
+ ];
22
+
23
+ function groupByCategory(warnings: StackWarning[]): Map<StackWarningCategory, StackWarning[]> {
24
+ const map = new Map<StackWarningCategory, StackWarning[]>();
25
+ for (const w of warnings) {
26
+ const existing = map.get(w.category) ?? [];
27
+ existing.push(w);
28
+ map.set(w.category, existing);
29
+ }
30
+ return map;
31
+ }
32
+
33
+ function WarningGroup({ category, items, defaultOpen }: {
34
+ category: StackWarningCategory;
35
+ items: StackWarning[];
36
+ defaultOpen: boolean;
37
+ }) {
38
+ const [expanded, setExpanded] = useState(defaultOpen);
39
+ const hasWarn = items.some((w) => w.severity === "warn");
40
+
41
+ return (
42
+ <div>
43
+ <button
44
+ type="button"
45
+ onClick={() => setExpanded(!expanded)}
46
+ className="w-full flex items-center gap-2 px-2.5 py-2 text-left hover:bg-accent/20 transition-colors rounded-md"
47
+ >
48
+ <ChevronRight className={`h-3 w-3 text-muted-foreground/20 shrink-0 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
49
+ {hasWarn
50
+ ? <AlertTriangle className="h-3 w-3 text-yellow-500/60 shrink-0" />
51
+ : <Info className="h-3 w-3 text-muted-foreground/30 shrink-0" />
52
+ }
53
+ <span className="text-[11px] font-medium text-muted-foreground/50 flex-1">
54
+ {CATEGORY_LABELS[category]}
55
+ </span>
56
+ <span className={`text-[9px] font-medium tabular-nums px-1.5 py-0.5 rounded-full ${
57
+ hasWarn
58
+ ? "bg-yellow-500/8 text-yellow-600/70 dark:text-yellow-400/70"
59
+ : "bg-foreground/[0.04] text-muted-foreground/30"
60
+ }`}>
61
+ {items.length}
62
+ </span>
63
+ </button>
64
+
65
+ {expanded && (
66
+ <div className="ml-5 pl-3 border-l border-border/40 space-y-1.5 pb-2">
67
+ {items.map((w, i) => (
68
+ <WarningItem key={i} warning={w} />
69
+ ))}
70
+ </div>
71
+ )}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ function WarningItem({ warning }: { warning: StackWarning }) {
77
+ const [detailsOpen, setDetailsOpen] = useState(false);
78
+ const hasDetails = warning.details && warning.details.length > 0;
79
+
80
+ return (
81
+ <div className="py-1">
82
+ <div className="flex items-start gap-1.5">
83
+ <span className={`mt-[5px] h-1 w-1 rounded-full shrink-0 ${
84
+ warning.severity === "warn" ? "bg-yellow-500/60" : "bg-muted-foreground/20"
85
+ }`} />
86
+ <div className="flex-1 min-w-0">
87
+ <div className="text-[11px] font-medium text-foreground/60">{warning.title}</div>
88
+ <div className="text-[10px] text-muted-foreground/35 leading-relaxed">{warning.message}</div>
89
+ {hasDetails && (
90
+ <button
91
+ type="button"
92
+ onClick={() => setDetailsOpen(!detailsOpen)}
93
+ className="text-[10px] text-muted-foreground/25 hover:text-muted-foreground/50 mt-0.5 transition-colors"
94
+ >
95
+ {detailsOpen ? "Hide" : `${warning.details!.length} details`}
96
+ </button>
97
+ )}
98
+ {detailsOpen && hasDetails && (
99
+ <div className="mt-1.5 space-y-0">
100
+ {warning.details!.map((d, i) => (
101
+ <div key={i} className="text-[9px] font-mono text-muted-foreground/25 py-[1px] truncate">
102
+ {d}
103
+ </div>
104
+ ))}
105
+ </div>
106
+ )}
107
+ </div>
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ export function StackWarnings({ warnings, defaultCollapsed }: {
114
+ warnings: StackWarning[];
115
+ defaultCollapsed?: boolean;
116
+ }) {
117
+ if (warnings.length === 0) return null;
118
+
119
+ const grouped = groupByCategory(warnings);
120
+ const sortedCategories = CATEGORY_ORDER.filter((c) => grouped.has(c));
121
+ const hasAnyWarn = warnings.some((w) => w.severity === "warn");
122
+
123
+ return (
124
+ <div className="space-y-0">
125
+ {sortedCategories.map((category) => (
126
+ <WarningGroup
127
+ key={category}
128
+ category={category}
129
+ items={grouped.get(category)!}
130
+ defaultOpen={!defaultCollapsed && hasAnyWarn}
131
+ />
132
+ ))}
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,301 @@
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+ import type { FeasibilityResult, StackWarning, StackGroupStats } from "../../../stack/types.ts";
3
+
4
+ type StackPhase = "idle" | "partitioning" | "planning" | "executing" | "publishing" | "done" | "error";
5
+
6
+ interface GroupData {
7
+ name: string;
8
+ type: string;
9
+ description: string;
10
+ files: string[];
11
+ key_changes?: string[];
12
+ }
13
+
14
+ interface PartitionData {
15
+ ownership: Record<string, string>;
16
+ reattributed: Array<{ path: string; from_groups: string[]; to_group: string; reason: string }>;
17
+ warnings: string[];
18
+ structured_warnings: StackWarning[];
19
+ forced_merges: Array<{ path: string; from_group: string; to_group: string }>;
20
+ groups?: GroupData[];
21
+ }
22
+
23
+ interface StackContext {
24
+ repo_path: string;
25
+ base_sha: string;
26
+ head_sha: string;
27
+ base_branch: string;
28
+ head_branch: string;
29
+ pr_number: number;
30
+ owner: string;
31
+ repo: string;
32
+ }
33
+
34
+ interface PlanData {
35
+ base_sha: string;
36
+ head_sha: string;
37
+ groups: Array<{
38
+ id: string;
39
+ name: string;
40
+ type: string;
41
+ description: string;
42
+ files: string[];
43
+ deps: string[];
44
+ order: number;
45
+ stats?: StackGroupStats;
46
+ pr_title?: string;
47
+ }>;
48
+ expected_trees: Record<string, string>;
49
+ }
50
+
51
+ interface ExecResultData {
52
+ run_id: string;
53
+ source_copy_branch: string;
54
+ group_commits: Array<{
55
+ group_id: string;
56
+ commit_sha: string;
57
+ tree_sha: string;
58
+ branch_name: string;
59
+ pr_title?: string;
60
+ }>;
61
+ final_tree_sha: string;
62
+ verified: boolean;
63
+ }
64
+
65
+ interface VerifyResultData {
66
+ verified: boolean;
67
+ errors: string[];
68
+ warnings: string[];
69
+ structured_warnings: StackWarning[];
70
+ }
71
+
72
+ interface PublishResultData {
73
+ branches: Array<{ name: string; pushed: boolean }>;
74
+ prs: Array<{
75
+ group_id: string;
76
+ number: number;
77
+ url: string;
78
+ title: string;
79
+ base_branch: string;
80
+ head_branch: string;
81
+ }>;
82
+ }
83
+
84
+ interface ServerStackState {
85
+ status: string;
86
+ phase: string | null;
87
+ error: string | null;
88
+ maxGroups: number | null;
89
+ context: StackContext | null;
90
+ partition: PartitionData | null;
91
+ feasibility: FeasibilityResult | null;
92
+ plan: PlanData | null;
93
+ execResult: ExecResultData | null;
94
+ verifyResult: VerifyResultData | null;
95
+ startedAt: number;
96
+ finishedAt: number | null;
97
+ }
98
+
99
+ export interface StackState {
100
+ phase: StackPhase;
101
+ error: string | null;
102
+ maxGroups: number | null;
103
+ partition: PartitionData | null;
104
+ feasibility: FeasibilityResult | null;
105
+ context: StackContext | null;
106
+ plan: PlanData | null;
107
+ execResult: ExecResultData | null;
108
+ verifyResult: VerifyResultData | null;
109
+ publishResult: PublishResultData | null;
110
+ progressMessage: string | null;
111
+ }
112
+
113
+ function serverPhaseToClient(status: string, phase: string | null): StackPhase {
114
+ if (status === "error" || status === "canceled") return "error";
115
+ if (status === "done") return "done";
116
+ if (phase === "partitioning") return "partitioning";
117
+ if (phase === "planning") return "planning";
118
+ if (phase === "executing") return "executing";
119
+ if (phase === "done") return "done";
120
+ return "idle";
121
+ }
122
+
123
+ function applyServerState(server: ServerStackState): Partial<StackState> {
124
+ return {
125
+ phase: serverPhaseToClient(server.status, server.phase),
126
+ error: server.error,
127
+ context: server.context,
128
+ partition: server.partition,
129
+ feasibility: server.feasibility,
130
+ plan: server.plan,
131
+ execResult: server.execResult,
132
+ verifyResult: server.verifyResult,
133
+ };
134
+ }
135
+
136
+ export function useStack(sessionId: string | null | undefined) {
137
+ const [state, setState] = useState<StackState>({
138
+ phase: "idle",
139
+ error: null,
140
+ maxGroups: null,
141
+ partition: null,
142
+ feasibility: null,
143
+ context: null,
144
+ plan: null,
145
+ execResult: null,
146
+ verifyResult: null,
147
+ publishResult: null,
148
+ progressMessage: null,
149
+ });
150
+
151
+ const eventSourceRef = useRef<EventSource | null>(null);
152
+
153
+ useEffect(() => {
154
+ if (!sessionId) return;
155
+
156
+ fetch(`/api/stack/${sessionId}`)
157
+ .then((res) => res.json())
158
+ .then((data: { state: ServerStackState | null }) => {
159
+ if (!data.state) return;
160
+ setState((s) => ({ ...s, ...applyServerState(data.state!) }));
161
+ })
162
+ .catch(() => {});
163
+ }, [sessionId]);
164
+
165
+ const setMaxGroups = useCallback((n: number | null) => {
166
+ setState((s) => ({ ...s, maxGroups: n }));
167
+ }, []);
168
+
169
+ const connectSSE = useCallback((id: string) => {
170
+ eventSourceRef.current?.close();
171
+
172
+ const es = new EventSource(`/api/stack/${id}/events`);
173
+ eventSourceRef.current = es;
174
+
175
+ es.addEventListener("progress", (e) => {
176
+ try {
177
+ const data = JSON.parse(e.data) as { phase: string; message: string; state: ServerStackState };
178
+ setState((s) => ({
179
+ ...s,
180
+ ...applyServerState(data.state),
181
+ progressMessage: data.message,
182
+ }));
183
+ } catch {}
184
+ });
185
+
186
+ es.addEventListener("done", (e) => {
187
+ try {
188
+ const data = JSON.parse(e.data) as { state: ServerStackState };
189
+ setState((s) => ({
190
+ ...s,
191
+ ...applyServerState(data.state),
192
+ progressMessage: null,
193
+ }));
194
+ } catch {}
195
+ es.close();
196
+ eventSourceRef.current = null;
197
+ });
198
+
199
+ es.addEventListener("stack_error", (e) => {
200
+ try {
201
+ const data = JSON.parse(e.data) as { message: string; state?: ServerStackState };
202
+ setState((s) => ({
203
+ ...s,
204
+ phase: "error",
205
+ error: data.message,
206
+ ...(data.state ? applyServerState(data.state) : {}),
207
+ progressMessage: null,
208
+ }));
209
+ } catch {}
210
+ es.close();
211
+ eventSourceRef.current = null;
212
+ });
213
+
214
+ es.onerror = () => {
215
+ es.close();
216
+ eventSourceRef.current = null;
217
+ };
218
+ }, []);
219
+
220
+ const runFullPipeline = useCallback(async () => {
221
+ if (!sessionId) return;
222
+
223
+ setState((s) => ({ ...s, phase: "partitioning", error: null, progressMessage: "Starting..." }));
224
+ try {
225
+ const res = await fetch("/api/stack/start", {
226
+ method: "POST",
227
+ headers: { "Content-Type": "application/json" },
228
+ body: JSON.stringify({ sessionId, maxGroups: state.maxGroups }),
229
+ });
230
+ const data = await res.json();
231
+ if (!res.ok) throw new Error(data.error ?? "Failed to start stack pipeline");
232
+
233
+ connectSSE(sessionId);
234
+ } catch (err) {
235
+ setState((s) => ({
236
+ ...s,
237
+ phase: "error",
238
+ error: err instanceof Error ? err.message : String(err),
239
+ progressMessage: null,
240
+ }));
241
+ }
242
+ }, [sessionId, state.maxGroups, connectSSE]);
243
+
244
+ const startPublish = useCallback(async () => {
245
+ if (!sessionId) return;
246
+ setState((s) => ({ ...s, phase: "publishing" }));
247
+ try {
248
+ const res = await fetch("/api/stack/publish", {
249
+ method: "POST",
250
+ headers: { "Content-Type": "application/json" },
251
+ body: JSON.stringify({ sessionId }),
252
+ });
253
+ const data = await res.json();
254
+ if (!res.ok) throw new Error(data.error ?? "Publishing failed");
255
+
256
+ setState((s) => ({
257
+ ...s,
258
+ phase: "done",
259
+ publishResult: data.publish_result,
260
+ }));
261
+ } catch (err) {
262
+ setState((s) => ({
263
+ ...s,
264
+ phase: "error",
265
+ error: err instanceof Error ? err.message : String(err),
266
+ }));
267
+ }
268
+ }, [sessionId]);
269
+
270
+ const reset = useCallback(() => {
271
+ eventSourceRef.current?.close();
272
+ eventSourceRef.current = null;
273
+ setState((s) => ({
274
+ phase: "idle",
275
+ error: null,
276
+ maxGroups: s.maxGroups,
277
+ partition: null,
278
+ feasibility: null,
279
+ context: null,
280
+ plan: null,
281
+ execResult: null,
282
+ verifyResult: null,
283
+ publishResult: null,
284
+ progressMessage: null,
285
+ }));
286
+ }, []);
287
+
288
+ useEffect(() => {
289
+ return () => {
290
+ eventSourceRef.current?.close();
291
+ };
292
+ }, []);
293
+
294
+ return {
295
+ ...state,
296
+ setMaxGroups,
297
+ runFullPipeline,
298
+ startPublish,
299
+ reset,
300
+ };
301
+ }