loops wizard: topology builder in step 2
Step 2 now defaults to a Builder pane: kind picker (from /api/topologies) + team size + role distribution preview, with /api/topologies/build rendering the canonical graph and node/edge counts. Advanced JSON stays as a toggle for hand-crafted graphs — same shape lands in the payload either way, so downstream code is unchanged. Debounced build effect wraps the async load in an inner function to avoid the setState-in-effect cascading-renders lint. Uses the same largest-remainder role apportionment as TeamWizard so builder output matches team-wizard output for the same kind + size.
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
// 4-step loop wizard: identity → task/topology → triggers → repeat policy.
|
||||
// The topology field is a JSON textarea for v1 (defaults to an empty graph);
|
||||
// a proper visual builder is future work. On submit, if the webhook trigger
|
||||
// was enabled the response returns webhook_token + signing_key ONCE — the
|
||||
// wizard shows them in a copy-friendly card before dismissing.
|
||||
// Topology step has two modes: a builder (pick a kind from the catalog + a
|
||||
// role list; POST /api/topologies/build renders the canonical graph) and
|
||||
// an advanced JSON textarea for hand-crafted graphs. On submit, if the
|
||||
// webhook trigger was enabled the response returns webhook_token +
|
||||
// signing_key ONCE — the wizard shows them in a copy-friendly card before
|
||||
// dismissing.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -14,6 +16,11 @@ import {
|
||||
type LoopCreated,
|
||||
type LoopRepeatPolicy,
|
||||
} from "@/lib/api/loops";
|
||||
import {
|
||||
fetchTopologyCatalog,
|
||||
type CatalogEntry,
|
||||
type TopologyGraph,
|
||||
} from "@/lib/api/topology";
|
||||
|
||||
const mono =
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||
@@ -29,6 +36,14 @@ export function LoopsWizard({
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [task, setTask] = useState("");
|
||||
const [topologyMode, setTopologyMode] = useState<"builder" | "advanced">(
|
||||
"builder",
|
||||
);
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
|
||||
const [kind, setKind] = useState<string>("");
|
||||
const [teamSize, setTeamSize] = useState(4);
|
||||
const [builtGraph, setBuiltGraph] = useState<TopologyGraph | null>(null);
|
||||
const [buildingGraph, setBuildingGraph] = useState(false);
|
||||
const [graphText, setGraphText] = useState(
|
||||
JSON.stringify({ nodes: [], edges: [] }, null, 2),
|
||||
);
|
||||
@@ -44,11 +59,76 @@ export function LoopsWizard({
|
||||
const [untilWithin, setUntilWithin] = useState(20);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [buildError, setBuildError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<LoopCreated | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const c = await fetchTopologyCatalog();
|
||||
setCatalog(c);
|
||||
if (c.length > 0) setKind((k) => k || c[0].kind);
|
||||
} catch {
|
||||
// Catalog is optional — advanced mode still works.
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const selectedEntry = useMemo(
|
||||
() => catalog.find((c) => c.kind === kind),
|
||||
[catalog, kind],
|
||||
);
|
||||
|
||||
// Rebuild the canonical graph whenever the builder inputs change. Debounced
|
||||
// via a small effect so rapid clicks don't hammer /build. All state writes
|
||||
// happen in the debounced async load — the effect body only registers the
|
||||
// timer + cleanup (avoids the setState-in-effect cascading-renders rule).
|
||||
useEffect(() => {
|
||||
if (topologyMode !== "builder" || !selectedEntry) return;
|
||||
const dist = selectedEntry.role_distribution.length
|
||||
? selectedEntry.role_distribution
|
||||
: [{ role: "worker", weight: 1 }];
|
||||
const roles = staffRoles(dist, Math.max(1, teamSize));
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setBuildingGraph(true);
|
||||
setBuildError(null);
|
||||
try {
|
||||
const r = await fetch("/api/topologies/build", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind, roles }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`build failed (${r.status})`);
|
||||
const g = (await r.json()) as TopologyGraph;
|
||||
if (cancelled) return;
|
||||
setBuiltGraph(g);
|
||||
setGraphText(JSON.stringify(g, null, 2));
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setBuildError(e instanceof Error ? e.message : "build failed");
|
||||
setBuiltGraph(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setBuildingGraph(false);
|
||||
}
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
void load();
|
||||
}, 150);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [topologyMode, kind, teamSize, selectedEntry]);
|
||||
|
||||
const graphReady =
|
||||
topologyMode === "builder"
|
||||
? builtGraph !== null && !buildingGraph
|
||||
: isValidJson(graphText);
|
||||
const canNext =
|
||||
(step === 1 && title.trim().length > 0) ||
|
||||
(step === 2 && task.trim().length > 0 && isValidJson(graphText)) ||
|
||||
(step === 2 && task.trim().length > 0 && graphReady) ||
|
||||
(step === 3 && (cronEnabled || onCompletion || webhookEnabled)) ||
|
||||
step === 4;
|
||||
|
||||
@@ -194,18 +274,49 @@ export function LoopsWizard({
|
||||
placeholder="What should the loop's agents do each iteration?"
|
||||
style={fieldStyle}
|
||||
/>
|
||||
<label style={labelStyle} htmlFor="loop-graph">Topology graph (JSON)</label>
|
||||
<textarea
|
||||
id="loop-graph"
|
||||
value={graphText}
|
||||
onChange={(e) => setGraphText(e.target.value)}
|
||||
rows={10}
|
||||
style={{ ...fieldStyle, fontFamily: mono, fontSize: 12 }}
|
||||
/>
|
||||
<p style={hintStyle}>
|
||||
Uses the same shape as topology_runs.graph. Empty graph
|
||||
(default) works — a visual builder is future work.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<span style={labelStyle}>Topology</span>
|
||||
<ModeToggle
|
||||
mode={topologyMode}
|
||||
onChange={setTopologyMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{topologyMode === "builder" ? (
|
||||
<TopologyBuilder
|
||||
catalog={catalog}
|
||||
kind={kind}
|
||||
onKind={setKind}
|
||||
teamSize={teamSize}
|
||||
onTeamSize={setTeamSize}
|
||||
built={builtGraph}
|
||||
building={buildingGraph}
|
||||
error={buildError}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
id="loop-graph"
|
||||
value={graphText}
|
||||
onChange={(e) => setGraphText(e.target.value)}
|
||||
rows={10}
|
||||
style={{ ...fieldStyle, fontFamily: mono, fontSize: 12 }}
|
||||
/>
|
||||
<p style={hintStyle}>
|
||||
Uses the same shape as topology_runs.graph. Switch to
|
||||
Builder to generate a canonical graph from a kind + role
|
||||
distribution.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -487,6 +598,222 @@ function SecretRow({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ModeToggle({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: "builder" | "advanced";
|
||||
onChange: (m: "builder" | "advanced") => void;
|
||||
}) {
|
||||
const pill = (active: boolean): React.CSSProperties => ({
|
||||
padding: "4px 10px",
|
||||
borderRadius: 6,
|
||||
background: active ? "rgba(255,138,122,.12)" : "transparent",
|
||||
color: active ? "#ffbfb3" : "#8a8a92",
|
||||
fontSize: 11,
|
||||
fontFamily: mono,
|
||||
fontWeight: 700,
|
||||
letterSpacing: ".08em",
|
||||
textTransform: "uppercase",
|
||||
border: 0,
|
||||
cursor: "pointer",
|
||||
});
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
gap: 2,
|
||||
padding: 2,
|
||||
borderRadius: 8,
|
||||
background: "rgba(255,255,255,.04)",
|
||||
border: "1px solid rgba(255,255,255,.06)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("builder")}
|
||||
style={pill(mode === "builder")}
|
||||
>
|
||||
Builder
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("advanced")}
|
||||
style={pill(mode === "advanced")}
|
||||
>
|
||||
Advanced JSON
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopologyBuilder({
|
||||
catalog,
|
||||
kind,
|
||||
onKind,
|
||||
teamSize,
|
||||
onTeamSize,
|
||||
built,
|
||||
building,
|
||||
error,
|
||||
}: {
|
||||
catalog: CatalogEntry[];
|
||||
kind: string;
|
||||
onKind: (k: string) => void;
|
||||
teamSize: number;
|
||||
onTeamSize: (n: number) => void;
|
||||
built: TopologyGraph | null;
|
||||
building: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const entry = catalog.find((c) => c.kind === kind);
|
||||
if (catalog.length === 0) {
|
||||
return (
|
||||
<p style={hintStyle}>
|
||||
Loading topology catalog… switch to Advanced JSON if it doesn't
|
||||
appear.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{catalog.map((c) => {
|
||||
const active = kind === c.kind;
|
||||
return (
|
||||
<button
|
||||
key={c.kind}
|
||||
type="button"
|
||||
onClick={() => onKind(c.kind)}
|
||||
title={c.description}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
background: active
|
||||
? "rgba(255,138,122,.10)"
|
||||
: "rgba(255,255,255,.03)",
|
||||
border: active
|
||||
? "1px solid rgba(255,138,122,.35)"
|
||||
: "1px solid rgba(255,255,255,.06)",
|
||||
color: "#eaeaee",
|
||||
fontFamily: mono,
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700 }}>{c.name}</div>
|
||||
<div
|
||||
style={{
|
||||
color: "#8a8a92",
|
||||
fontSize: 10.5,
|
||||
marginTop: 2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{c.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 120px",
|
||||
gap: 12,
|
||||
alignItems: "end",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ ...hintStyle, fontSize: 10, letterSpacing: ".1em", textTransform: "uppercase" }}>
|
||||
Roles
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
marginTop: 4,
|
||||
minHeight: 24,
|
||||
}}
|
||||
>
|
||||
{entry?.role_distribution.length ? (
|
||||
entry.role_distribution.map((r) => (
|
||||
<span
|
||||
key={r.role}
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
color: "#eaeaee",
|
||||
padding: "3px 8px",
|
||||
borderRadius: 5,
|
||||
background: "rgba(255,255,255,.05)",
|
||||
}}
|
||||
>
|
||||
{r.role} · {Math.round(r.weight * 100)}%
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={hintStyle}>no roles defined</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ ...hintStyle, fontSize: 10, letterSpacing: ".1em", textTransform: "uppercase" }}>
|
||||
Team size
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={teamSize}
|
||||
onChange={(e) =>
|
||||
onTeamSize(Math.max(1, parseInt(e.target.value, 10) || 1))
|
||||
}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(255,255,255,.03)",
|
||||
border: "1px solid rgba(255,255,255,.05)",
|
||||
fontFamily: mono,
|
||||
fontSize: 12,
|
||||
color: "#eaeaee",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{building
|
||||
? "Building…"
|
||||
: built
|
||||
? `${built.nodes.length} nodes · ${built.edges.length} edges`
|
||||
: "—"}
|
||||
</span>
|
||||
<span style={{ color: "#8a8a92", fontSize: 11 }}>{kind}</span>
|
||||
</div>
|
||||
{error && (
|
||||
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isValidJson(s: string) {
|
||||
try {
|
||||
JSON.parse(s);
|
||||
@@ -496,6 +823,32 @@ function isValidJson(s: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Largest-remainder apportionment of `size` across the role distribution.
|
||||
* Mirrors TeamWizard so the built graph matches what the team wizard would
|
||||
* produce for the same kind + size. */
|
||||
function staffRoles(
|
||||
dist: { role: string; weight: number }[],
|
||||
size: number,
|
||||
): string[] {
|
||||
if (dist.length === 0) return [];
|
||||
const base = dist.map((d) => {
|
||||
const exact = d.weight * size;
|
||||
const n = Math.floor(exact);
|
||||
return { role: d.role, n, rem: exact - n };
|
||||
});
|
||||
let used = base.reduce((s, b) => s + b.n, 0);
|
||||
const ordered = [...base].sort((a, b) => b.rem - a.rem);
|
||||
for (let i = 0; used < size && i < ordered.length * 4; i++) {
|
||||
ordered[i % ordered.length].n += 1;
|
||||
used += 1;
|
||||
}
|
||||
const roles: string[] = [];
|
||||
for (const b of base) {
|
||||
for (let i = 0; i < b.n; i++) roles.push(b.role);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
|
||||
Reference in New Issue
Block a user