fix: remove deprecated graphs
This commit is contained in:
@@ -36,7 +36,10 @@
|
||||
"Bash(pkill -f \"pytest.*test_event_loop_node\")",
|
||||
"Bash(pkill -f \"pytest.*TestToolConcurrency\")",
|
||||
"Bash(grep -n \"def.*discover\\\\|/api/agents\\\\|agents_discover\" /home/timothy/aden/hive/core/framework/server/*.py)",
|
||||
"Bash(bun run:*)"
|
||||
"Bash(bun run:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npm test:*)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/home/timothy/.hive/skills/writing-hive-skills",
|
||||
|
||||
@@ -176,56 +176,6 @@ export interface GraphTopology {
|
||||
entry_points?: EntryPoint[];
|
||||
}
|
||||
|
||||
// --- Draft graph types (planning phase) ---
|
||||
|
||||
export interface DraftNode {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
node_type: string;
|
||||
tools: string[];
|
||||
input_keys: string[];
|
||||
output_keys: string[];
|
||||
success_criteria: string;
|
||||
sub_agents: string[];
|
||||
/** For decision nodes: the yes/no question evaluated during dissolution. */
|
||||
decision_clause?: string;
|
||||
flowchart_type: string;
|
||||
flowchart_shape: string;
|
||||
flowchart_color: string;
|
||||
}
|
||||
|
||||
export interface DraftEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
condition: string;
|
||||
description: string;
|
||||
/** Short label shown on the flowchart edge (e.g. "Yes", "No"). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface DraftGraph {
|
||||
agent_name: string;
|
||||
goal: string;
|
||||
description: string;
|
||||
success_criteria: string[];
|
||||
constraints: string[];
|
||||
nodes: DraftNode[];
|
||||
edges: DraftEdge[];
|
||||
entry_node: string;
|
||||
terminal_nodes: string[];
|
||||
flowchart_legend: Record<string, { shape: string; color: string }>;
|
||||
}
|
||||
|
||||
/** Mapping from runtime graph nodes → original flowchart draft nodes. */
|
||||
export interface FlowchartMap {
|
||||
/** runtime_node_id → list of original draft node IDs it absorbed. */
|
||||
map: Record<string, string[]> | null;
|
||||
/** Original draft graph preserved before planning-node dissolution (decision + subagent). */
|
||||
original_draft: DraftGraph | null;
|
||||
}
|
||||
|
||||
export interface NodeCriteria {
|
||||
node_id: string;
|
||||
success_criteria: string | null;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
import { Clock, Webhook, Zap, ArrowRight, Activity } from "lucide-react";
|
||||
import type { GraphNode } from "./graph-types";
|
||||
import { cronToLabel } from "@/lib/graphUtils";
|
||||
|
||||
interface TriggersPanelProps {
|
||||
triggers: GraphNode[];
|
||||
selectedId?: string | null;
|
||||
onSelect?: (trigger: GraphNode) => void;
|
||||
}
|
||||
|
||||
function TriggerIcon({ type }: { type?: string }) {
|
||||
const cls = "w-3.5 h-3.5";
|
||||
switch (type) {
|
||||
case "webhook":
|
||||
return <Webhook className={cls} />;
|
||||
case "timer":
|
||||
return <Clock className={cls} />;
|
||||
case "api":
|
||||
return <ArrowRight className={cls} />;
|
||||
case "event":
|
||||
return <Activity className={cls} />;
|
||||
default:
|
||||
return <Zap className={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLabel(config: Record<string, unknown> | undefined): string | null {
|
||||
if (!config) return null;
|
||||
const cron = config.cron as string | undefined;
|
||||
if (cron) return cronToLabel(cron);
|
||||
const interval = config.interval_minutes as number | undefined;
|
||||
if (interval != null) {
|
||||
if (interval >= 60) return `Every ${interval / 60}h`;
|
||||
return `Every ${interval}m`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function countdownLabel(nextFireIn: number | undefined): string | null {
|
||||
if (nextFireIn == null || nextFireIn <= 0) return null;
|
||||
const h = Math.floor(nextFireIn / 3600);
|
||||
const m = Math.floor((nextFireIn % 3600) / 60);
|
||||
const s = Math.floor(nextFireIn % 60);
|
||||
return h > 0
|
||||
? `next in ${h}h ${String(m).padStart(2, "0")}m`
|
||||
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function TriggerCard({
|
||||
trigger,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
trigger: GraphNode;
|
||||
selected: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const isActive = trigger.status === "running" || trigger.status === "complete";
|
||||
const schedule = scheduleLabel(trigger.triggerConfig);
|
||||
const nextFireIn = trigger.triggerConfig?.next_fire_in as number | undefined;
|
||||
const countdown = isActive ? countdownLabel(nextFireIn) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"w-full text-left rounded-lg border px-3 py-2.5 transition-colors",
|
||||
selected
|
||||
? "bg-primary/10 border-primary/30"
|
||||
: "bg-background/60 border-border/30 hover:bg-muted/40 hover:border-border/50",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={[
|
||||
"flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center",
|
||||
isActive ? "bg-primary/15 text-primary" : "bg-muted/60 text-muted-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<TriggerIcon type={trigger.triggerType} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate">{trigger.label}</p>
|
||||
{schedule && schedule !== trigger.label && (
|
||||
<p className="text-[10.5px] text-muted-foreground truncate mt-0.5">{schedule}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={[
|
||||
"flex-shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full",
|
||||
isActive
|
||||
? "bg-emerald-500/15 text-emerald-400"
|
||||
: "bg-muted/60 text-muted-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
{isActive ? "active" : "inactive"}
|
||||
</span>
|
||||
</div>
|
||||
{countdown && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1.5 italic pl-8">{countdown}</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TriggersPanel({ triggers, selectedId, onSelect }: TriggersPanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-card/30 border-l border-border/30">
|
||||
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-semibold text-foreground uppercase tracking-wide">
|
||||
Triggers
|
||||
</h3>
|
||||
{triggers.length > 0 && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{triggers.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
||||
{triggers.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Clock className="w-6 h-6 mx-auto text-muted-foreground/40 mb-2" />
|
||||
<p className="text-[11px] text-muted-foreground">No triggers configured</p>
|
||||
<p className="text-[10px] text-muted-foreground/70 mt-1 px-2">
|
||||
Ask the queen to set a schedule or webhook
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
triggers.map((t) => (
|
||||
<TriggerCard
|
||||
key={t.id}
|
||||
trigger={t}
|
||||
selected={selectedId === t.id}
|
||||
onClick={onSelect ? () => onSelect(t) : undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { topologyToGraphNodes } from "./graph-converter";
|
||||
import type { GraphTopology, NodeSpec } from "@/api/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeNode(id: string, overrides: Partial<NodeSpec> = {}): NodeSpec {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: "",
|
||||
node_type: "event_loop",
|
||||
input_keys: [],
|
||||
output_keys: [],
|
||||
nullable_output_keys: [],
|
||||
tools: [],
|
||||
routes: {},
|
||||
max_retries: 3,
|
||||
max_node_visits: 0,
|
||||
client_facing: false,
|
||||
success_criteria: null,
|
||||
system_prompt: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("edge classification", () => {
|
||||
it("linear chain: all edges in next[], no backEdges", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A"), makeNode("B"), makeNode("C")],
|
||||
edges: [
|
||||
{ source: "A", target: "B", condition: "on_success", priority: 0 },
|
||||
{ source: "B", target: "C", condition: "on_success", priority: 0 },
|
||||
],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const a = result.find((n) => n.id === "A")!;
|
||||
const b = result.find((n) => n.id === "B")!;
|
||||
const c = result.find((n) => n.id === "C")!;
|
||||
|
||||
expect(a.next).toEqual(["B"]);
|
||||
expect(a.backEdges).toBeUndefined();
|
||||
expect(b.next).toEqual(["C"]);
|
||||
expect(b.backEdges).toBeUndefined();
|
||||
expect(c.next).toBeUndefined();
|
||||
expect(c.backEdges).toBeUndefined();
|
||||
});
|
||||
|
||||
it("loop edge: classified as backEdge", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A"), makeNode("B"), makeNode("C")],
|
||||
edges: [
|
||||
{ source: "A", target: "B", condition: "on_success", priority: 0 },
|
||||
{ source: "B", target: "C", condition: "on_success", priority: 0 },
|
||||
{ source: "C", target: "A", condition: "on_success", priority: 0 },
|
||||
],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
const c = result.find((n) => n.id === "C")!;
|
||||
|
||||
expect(c.next).toBeUndefined();
|
||||
expect(c.backEdges).toEqual(["A"]);
|
||||
});
|
||||
|
||||
it("diamond/fan-out: multiple next targets", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A"), makeNode("B"), makeNode("C"), makeNode("D")],
|
||||
edges: [
|
||||
{ source: "A", target: "B", condition: "on_success", priority: 0 },
|
||||
{ source: "A", target: "C", condition: "on_failure", priority: 1 },
|
||||
{ source: "B", target: "D", condition: "on_success", priority: 0 },
|
||||
{ source: "C", target: "D", condition: "on_success", priority: 0 },
|
||||
],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
const a = result.find((n) => n.id === "A")!;
|
||||
|
||||
expect(a.next).toEqual(expect.arrayContaining(["B", "C"]));
|
||||
expect(a.next).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("status mapping", () => {
|
||||
it("no enrichment: all nodes pending", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A"), makeNode("B")],
|
||||
edges: [
|
||||
{ source: "A", target: "B", condition: "on_success", priority: 0 },
|
||||
],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result.every((n) => n.status === "pending")).toBe(true);
|
||||
});
|
||||
|
||||
it("is_current: running", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A", { is_current: true, visit_count: 1, in_path: true })],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result[0].status).toBe("running");
|
||||
});
|
||||
|
||||
it("is_current + visit_count > 1: looping", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A", { is_current: true, visit_count: 3, in_path: true })],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result[0].status).toBe("looping");
|
||||
});
|
||||
|
||||
it("in_path + visited + not current: complete", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A", { in_path: true, visit_count: 1, is_current: false })],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result[0].status).toBe("complete");
|
||||
});
|
||||
|
||||
it("has_failures: error", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A", { has_failures: true, in_path: true, visit_count: 1 })],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result[0].status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Iteration tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("iteration tracking", () => {
|
||||
it("visit_count maps to iterations", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A", { visit_count: 3, in_path: true })],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result[0].iterations).toBe(3);
|
||||
});
|
||||
|
||||
it("max_node_visits maps to maxIterations", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A", { max_node_visits: 5, visit_count: 1, in_path: true })],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result[0].maxIterations).toBe(5);
|
||||
});
|
||||
|
||||
it("max_node_visits == 0 (unlimited): maxIterations omitted", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A", { max_node_visits: 0, visit_count: 1, in_path: true })],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result[0].maxIterations).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge labels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("edge labels", () => {
|
||||
it("conditional edges produce edgeLabels, on_success/always do not", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A"), makeNode("B"), makeNode("C"), makeNode("D")],
|
||||
edges: [
|
||||
{ source: "A", target: "B", condition: "conditional", priority: 0 },
|
||||
{ source: "A", target: "C", condition: "on_failure", priority: 1 },
|
||||
{ source: "B", target: "D", condition: "on_success", priority: 0 },
|
||||
{ source: "C", target: "D", condition: "always", priority: 0 },
|
||||
],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
const a = result.find((n) => n.id === "A")!;
|
||||
const b = result.find((n) => n.id === "B")!;
|
||||
const c = result.find((n) => n.id === "C")!;
|
||||
|
||||
// A has conditional + on_failure edges → both get labels
|
||||
expect(a.edgeLabels).toEqual({ B: "conditional", C: "on_failure" });
|
||||
// B has on_success → no label
|
||||
expect(b.edgeLabels).toBeUndefined();
|
||||
// C has always → no label
|
||||
expect(c.edgeLabels).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node ordering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("node ordering", () => {
|
||||
it("nodes returned in BFS walk order from entry_node, not input order", () => {
|
||||
const topology: GraphTopology = {
|
||||
// Input order: C, A, B — but BFS from A should yield A, B, C
|
||||
nodes: [makeNode("C"), makeNode("A"), makeNode("B")],
|
||||
edges: [
|
||||
{ source: "A", target: "B", condition: "on_success", priority: 0 },
|
||||
{ source: "B", target: "C", condition: "on_success", priority: 0 },
|
||||
],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result.map((n) => n.id)).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("empty topology returns empty array", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
entry_node: "",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger node synthesis from entry_points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("trigger node synthesis", () => {
|
||||
it("single non-manual entry point: trigger node prepended before entry_node", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A"), makeNode("B")],
|
||||
edges: [
|
||||
{ source: "A", target: "B", condition: "on_success", priority: 0 },
|
||||
],
|
||||
entry_node: "A",
|
||||
entry_points: [
|
||||
{ id: "webhook", name: "Webhook Handler", entry_node: "A", trigger_type: "webhook", trigger_config: { url: "/hook" } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const trigger = result[0];
|
||||
expect(trigger.id).toBe("__trigger_webhook");
|
||||
expect(trigger.nodeType).toBe("trigger");
|
||||
expect(trigger.triggerType).toBe("webhook");
|
||||
expect(trigger.triggerConfig).toEqual({ url: "/hook" });
|
||||
expect(trigger.label).toBe("Webhook Handler");
|
||||
expect(trigger.status).toBe("pending");
|
||||
expect(trigger.next).toEqual(["A"]);
|
||||
});
|
||||
|
||||
it("trigger_config is threaded through for timer triggers", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A")],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
entry_points: [
|
||||
{ id: "timer", name: "Daily Check", entry_node: "A", trigger_type: "timer", trigger_config: { cron: "0 9 * * *" } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
const trigger = result[0];
|
||||
expect(trigger.triggerConfig).toEqual({ cron: "0 9 * * *" });
|
||||
});
|
||||
|
||||
it("no entry_points: no trigger nodes added", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A")],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].nodeType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("only manual entry points: no trigger nodes added", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A")],
|
||||
edges: [],
|
||||
entry_node: "A",
|
||||
entry_points: [
|
||||
{ id: "main", name: "Main", entry_node: "A", trigger_type: "manual" },
|
||||
],
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("A");
|
||||
});
|
||||
|
||||
it("multiple non-manual entry points: multiple trigger nodes", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A"), makeNode("B"), makeNode("C")],
|
||||
edges: [
|
||||
{ source: "A", target: "C", condition: "on_success", priority: 0 },
|
||||
{ source: "B", target: "C", condition: "on_success", priority: 0 },
|
||||
],
|
||||
entry_node: "A",
|
||||
entry_points: [
|
||||
{ id: "webhook", name: "Webhook", entry_node: "A", trigger_type: "webhook" },
|
||||
{ id: "timer", name: "Daily Timer", entry_node: "B", trigger_type: "timer" },
|
||||
],
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result).toHaveLength(5); // 2 triggers + 3 nodes
|
||||
const triggers = result.filter((n) => n.nodeType === "trigger");
|
||||
expect(triggers).toHaveLength(2);
|
||||
expect(triggers[0].next).toEqual(["A"]);
|
||||
expect(triggers[1].next).toEqual(["B"]);
|
||||
});
|
||||
|
||||
it("mix of manual and non-manual: only non-manual become trigger nodes", () => {
|
||||
const topology: GraphTopology = {
|
||||
nodes: [makeNode("A"), makeNode("B")],
|
||||
edges: [
|
||||
{ source: "A", target: "B", condition: "on_success", priority: 0 },
|
||||
],
|
||||
entry_node: "A",
|
||||
entry_points: [
|
||||
{ id: "main", name: "Main", entry_node: "A", trigger_type: "manual" },
|
||||
{ id: "webhook", name: "Webhook", entry_node: "A", trigger_type: "webhook" },
|
||||
],
|
||||
};
|
||||
|
||||
const result = topologyToGraphNodes(topology);
|
||||
expect(result).toHaveLength(3); // 1 trigger + 2 nodes
|
||||
const triggers = result.filter((n) => n.nodeType === "trigger");
|
||||
expect(triggers).toHaveLength(1);
|
||||
expect(triggers[0].triggerType).toBe("webhook");
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { GraphTopology, NodeSpec } from "@/api/types";
|
||||
import type { GraphNode, NodeStatus } from "@/components/graph-types";
|
||||
|
||||
/**
|
||||
* Convert a backend GraphTopology (nodes + edges + entry_node) into
|
||||
* the GraphNode[] shape that DraftGraph renders.
|
||||
*
|
||||
* Four jobs:
|
||||
* 1. Synthesize trigger nodes from non-manual entry_points
|
||||
* 2. Order nodes via BFS from trigger/entry_node
|
||||
* 3. Classify edges as forward (next) or backward (backEdges)
|
||||
* 4. Map session enrichment fields to NodeStatus
|
||||
*/
|
||||
export function topologyToGraphNodes(topology: GraphTopology): GraphNode[] {
|
||||
const { nodes: allNodes, edges, entry_node, entry_points } = topology;
|
||||
if (allNodes.length === 0) return [];
|
||||
|
||||
// Filter out subagent-only nodes (referenced in sub_agents but not in any edge)
|
||||
const subagentIds = new Set<string>();
|
||||
for (const n of allNodes) {
|
||||
for (const sa of n.sub_agents ?? []) {
|
||||
subagentIds.add(sa);
|
||||
}
|
||||
}
|
||||
const edgeParticipants = new Set<string>();
|
||||
for (const e of edges) {
|
||||
edgeParticipants.add(e.source);
|
||||
edgeParticipants.add(e.target);
|
||||
}
|
||||
const nodes = allNodes.filter(
|
||||
(n) =>
|
||||
!subagentIds.has(n.id) ||
|
||||
edgeParticipants.has(n.id) ||
|
||||
n.id === entry_node,
|
||||
);
|
||||
|
||||
// --- Synthesize trigger nodes for non-manual entry points ---
|
||||
const schedulerEntryPoints = (entry_points || []).filter(
|
||||
(ep) => ep.trigger_type !== "manual",
|
||||
);
|
||||
const triggerMap = new Map<string, GraphNode>();
|
||||
|
||||
for (const ep of schedulerEntryPoints) {
|
||||
const triggerId = `__trigger_${ep.id}`;
|
||||
triggerMap.set(triggerId, {
|
||||
id: triggerId,
|
||||
label: ep.name,
|
||||
status: "pending",
|
||||
nodeType: "trigger",
|
||||
triggerType: ep.trigger_type,
|
||||
triggerConfig: {
|
||||
...ep.trigger_config,
|
||||
...(ep.next_fire_in != null ? { next_fire_in: ep.next_fire_in } : {}),
|
||||
...(ep.task ? { task: ep.task } : {}),
|
||||
},
|
||||
next: [ep.entry_node],
|
||||
});
|
||||
}
|
||||
|
||||
// Build adjacency list: source → [target, ...] (includes trigger edges)
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
const list = adj.get(e.source) || [];
|
||||
list.push(e.target);
|
||||
adj.set(e.source, list);
|
||||
}
|
||||
for (const [triggerId, triggerNode] of triggerMap) {
|
||||
adj.set(triggerId, triggerNode.next!);
|
||||
}
|
||||
|
||||
// BFS — start from trigger nodes (if any), then entry_node.
|
||||
// Always include entry_node so the DAG ordering stays correct
|
||||
// even when triggers target a node other than entry.
|
||||
const order: string[] = [];
|
||||
const position = new Map<string, number>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
const entryStart = entry_node || nodes[0].id;
|
||||
const starts =
|
||||
triggerMap.size > 0
|
||||
? [...triggerMap.keys(), entryStart]
|
||||
: [entryStart];
|
||||
const queue = [...starts];
|
||||
for (const s of starts) visited.add(s);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!;
|
||||
position.set(id, order.length);
|
||||
order.push(id);
|
||||
|
||||
for (const target of adj.get(id) || []) {
|
||||
if (!visited.has(target)) {
|
||||
visited.add(target);
|
||||
queue.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any nodes not reachable from entry (shouldn't happen in valid graphs)
|
||||
for (const n of nodes) {
|
||||
if (!visited.has(n.id)) {
|
||||
position.set(n.id, order.length);
|
||||
order.push(n.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a node lookup
|
||||
const nodeMap = new Map<string, NodeSpec>();
|
||||
for (const n of nodes) {
|
||||
nodeMap.set(n.id, n);
|
||||
}
|
||||
|
||||
// Classify edges per source node
|
||||
const nextMap = new Map<string, string[]>();
|
||||
const backMap = new Map<string, string[]>();
|
||||
|
||||
for (const e of edges) {
|
||||
const srcPos = position.get(e.source) ?? 0;
|
||||
const tgtPos = position.get(e.target) ?? 0;
|
||||
|
||||
if (tgtPos <= srcPos) {
|
||||
// Back edge (target is at same or earlier position in BFS)
|
||||
const list = backMap.get(e.source) || [];
|
||||
list.push(e.target);
|
||||
backMap.set(e.source, list);
|
||||
} else {
|
||||
// Forward edge
|
||||
const list = nextMap.get(e.source) || [];
|
||||
list.push(e.target);
|
||||
nextMap.set(e.source, list);
|
||||
}
|
||||
}
|
||||
|
||||
// Build edge condition labels (only for non-trivial conditions)
|
||||
const edgeLabelMap = new Map<string, Record<string, string>>();
|
||||
for (const e of edges) {
|
||||
if (e.condition !== "always" && e.condition !== "on_success") {
|
||||
const labels = edgeLabelMap.get(e.source) || {};
|
||||
labels[e.target] = e.condition;
|
||||
edgeLabelMap.set(e.source, labels);
|
||||
}
|
||||
}
|
||||
|
||||
// Build GraphNode[] in BFS order
|
||||
return order.map((id) => {
|
||||
// Synthetic trigger nodes are returned directly
|
||||
const trigger = triggerMap.get(id);
|
||||
if (trigger) return trigger;
|
||||
|
||||
const spec = nodeMap.get(id);
|
||||
const next = nextMap.get(id);
|
||||
const back = backMap.get(id);
|
||||
const labels = edgeLabelMap.get(id);
|
||||
|
||||
const result: GraphNode = {
|
||||
id,
|
||||
label: spec?.name || id,
|
||||
status: mapStatus(spec),
|
||||
...(next && next.length > 0 ? { next } : {}),
|
||||
...(back && back.length > 0 ? { backEdges: back } : {}),
|
||||
...(labels ? { edgeLabels: labels } : {}),
|
||||
};
|
||||
|
||||
// Iteration tracking from session enrichment
|
||||
if (spec?.visit_count !== undefined && spec.visit_count > 0) {
|
||||
result.iterations = spec.visit_count;
|
||||
}
|
||||
if (spec?.max_node_visits !== undefined && spec.max_node_visits > 0) {
|
||||
result.maxIterations = spec.max_node_visits;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function mapStatus(spec: NodeSpec | undefined): NodeStatus {
|
||||
if (!spec) return "pending";
|
||||
|
||||
if (spec.has_failures) return "error";
|
||||
if (spec.is_current) {
|
||||
return (spec.visit_count ?? 0) > 1 ? "looping" : "running";
|
||||
}
|
||||
if (spec.in_path && (spec.visit_count ?? 0) > 0) return "complete";
|
||||
|
||||
return "pending";
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// ── Shared graph utilities ──
|
||||
// Common helpers used by both AgentGraph and DraftGraph.
|
||||
// AgentGraph still has its own copies for now (separate cleanup PR).
|
||||
// Shared helpers for graph-like components (TriggersPanel, etc.).
|
||||
|
||||
/** Read a CSS custom property value (space-separated HSL components). */
|
||||
export function cssVar(name: string): string {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { useParams, useLocation } from "react-router-dom";
|
||||
import { Loader2, WifiOff, KeyRound, FolderOpen, X } from "lucide-react";
|
||||
import type { GraphNode, NodeStatus } from "@/components/graph-types";
|
||||
import DraftGraph from "@/components/DraftGraph";
|
||||
import TriggersPanel from "@/components/TriggersPanel";
|
||||
import ChatPanel, { type ChatMessage, type ImageContent } from "@/components/ChatPanel";
|
||||
import NodeDetailPanel from "@/components/NodeDetailPanel";
|
||||
import CredentialsModal, {
|
||||
@@ -10,17 +10,10 @@ import CredentialsModal, {
|
||||
clearCredentialCache,
|
||||
} from "@/components/CredentialsModal";
|
||||
import { executionApi } from "@/api/execution";
|
||||
import { workersApi } from "@/api/workers";
|
||||
import { sessionsApi } from "@/api/sessions";
|
||||
import { useMultiSSE } from "@/hooks/use-sse";
|
||||
import type {
|
||||
LiveSession,
|
||||
AgentEvent,
|
||||
NodeSpec,
|
||||
DraftGraph as DraftGraphData,
|
||||
} from "@/api/types";
|
||||
import type { LiveSession, AgentEvent } from "@/api/types";
|
||||
import { sseEventToChatMessage, formatAgentDisplayName } from "@/lib/chat-helpers";
|
||||
import { topologyToGraphNodes } from "@/lib/graph-converter";
|
||||
import { cronToLabel } from "@/lib/graphUtils";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
@@ -48,8 +41,6 @@ function truncate(s: string, max: number): string {
|
||||
type SessionRestoreResult = {
|
||||
messages: ChatMessage[];
|
||||
restoredPhase: "planning" | "building" | "staging" | "running" | "independent" | null;
|
||||
flowchartMap: Record<string, string[]> | null;
|
||||
originalDraft: DraftGraphData | null;
|
||||
};
|
||||
|
||||
async function restoreSessionMessages(
|
||||
@@ -62,8 +53,6 @@ async function restoreSessionMessages(
|
||||
if (events.length > 0) {
|
||||
const messages: ChatMessage[] = [];
|
||||
let runningPhase: ChatMessage["phase"] = undefined;
|
||||
let flowchartMap: Record<string, string[]> | null = null;
|
||||
let originalDraft: DraftGraphData | null = null;
|
||||
for (const evt of events) {
|
||||
const p =
|
||||
evt.type === "queen_phase_changed"
|
||||
@@ -74,14 +63,6 @@ async function restoreSessionMessages(
|
||||
if (p && ["planning", "building", "staging", "running"].includes(p)) {
|
||||
runningPhase = p as ChatMessage["phase"];
|
||||
}
|
||||
if (evt.type === "custom" && (evt.data as Record<string, unknown>)?.event === "flowchart_updated" && evt.data) {
|
||||
const mapData = evt.data as {
|
||||
map?: Record<string, string[]>;
|
||||
original_draft?: DraftGraphData;
|
||||
};
|
||||
flowchartMap = mapData.map ?? null;
|
||||
originalDraft = mapData.original_draft ?? null;
|
||||
}
|
||||
const msg = sseEventToChatMessage(evt, thread, agentDisplayName);
|
||||
if (!msg) continue;
|
||||
if (evt.stream_id === "queen") {
|
||||
@@ -90,12 +71,12 @@ async function restoreSessionMessages(
|
||||
}
|
||||
messages.push(msg);
|
||||
}
|
||||
return { messages, restoredPhase: runningPhase ?? null, flowchartMap, originalDraft };
|
||||
return { messages, restoredPhase: runningPhase ?? null };
|
||||
}
|
||||
} catch {
|
||||
// Event log not available
|
||||
}
|
||||
return { messages: [], restoredPhase: null, flowchartMap: null, originalDraft: null };
|
||||
return { messages: [], restoredPhase: null };
|
||||
}
|
||||
|
||||
// ── Agent backend state ──────────────────────────────────────────────────────
|
||||
@@ -107,18 +88,10 @@ interface AgentState {
|
||||
queenReady: boolean;
|
||||
error: string | null;
|
||||
displayName: string | null;
|
||||
colonyId: string | null; nodeSpecs: NodeSpec[];
|
||||
awaitingInput: boolean;
|
||||
workerInputMessageId: string | null;
|
||||
queenBuilding: boolean;
|
||||
queenPhase: "planning" | "building" | "staging" | "running" | "independent";
|
||||
designingDraft: boolean;
|
||||
draftGraph: DraftGraphData | null;
|
||||
originalDraft: DraftGraphData | null;
|
||||
flowchartMap: Record<string, string[]> | null;
|
||||
agentPath: string | null;
|
||||
workerRunState: "idle" | "deploying" | "running";
|
||||
currentExecutionId: string | null;
|
||||
currentRunId: string | null;
|
||||
nodeLogs: Record<string, string[]>;
|
||||
nodeActionPlans: Record<string, string>;
|
||||
@@ -153,19 +126,10 @@ function defaultAgentState(): AgentState {
|
||||
queenReady: false,
|
||||
error: null,
|
||||
displayName: null,
|
||||
colonyId: null,
|
||||
nodeSpecs: [],
|
||||
awaitingInput: false,
|
||||
workerInputMessageId: null,
|
||||
queenBuilding: false,
|
||||
queenPhase: "planning",
|
||||
designingDraft: false,
|
||||
draftGraph: null,
|
||||
originalDraft: null,
|
||||
flowchartMap: null,
|
||||
agentPath: null,
|
||||
workerRunState: "idle",
|
||||
currentExecutionId: null,
|
||||
currentRunId: null,
|
||||
nodeLogs: {},
|
||||
nodeActionPlans: {},
|
||||
@@ -239,9 +203,6 @@ export default function ColonyChat() {
|
||||
const [credentialAgentPath, setCredentialAgentPath] = useState<string | null>(null);
|
||||
const [dismissedBanner, setDismissedBanner] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [graphPanelPct, setGraphPanelPct] = useState(30);
|
||||
const savedGraphPanelPct = useRef(30);
|
||||
const resizing = useRef(false);
|
||||
|
||||
// ── Header actions (Credentials, Data, Browser) ─────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -281,8 +242,6 @@ export default function ColonyChat() {
|
||||
const queenIterTextRef = useRef<Record<string, Record<number, string>>>({});
|
||||
const suppressIntroRef = useRef(false);
|
||||
const loadingRef = useRef(false);
|
||||
const designingDraftSinceRef = useRef(0);
|
||||
const designingDraftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -346,71 +305,11 @@ export default function ColonyChat() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ── Drag-to-resize graph panel ──────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!resizing.current) return;
|
||||
const sidebarWidth = 240;
|
||||
const pct = 100 - ((e.clientX - sidebarWidth) / (window.innerWidth - sidebarWidth)) * 100;
|
||||
setGraphPanelPct(Math.max(15, Math.min(50, pct)));
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
resizing.current = false;
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const nodeIsSelected = selectedNode !== null;
|
||||
useEffect(() => {
|
||||
if (nodeIsSelected) {
|
||||
savedGraphPanelPct.current = graphPanelPct;
|
||||
setGraphPanelPct((prev) => Math.min(prev, 30));
|
||||
} else {
|
||||
setGraphPanelPct(savedGraphPanelPct.current);
|
||||
}
|
||||
}, [nodeIsSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reset dismissed banner when the error clears
|
||||
useEffect(() => {
|
||||
if (!agentState.error) setDismissedBanner(null);
|
||||
}, [agentState.error]);
|
||||
|
||||
// ── Graph fetching ─────────────────────────────────────────────────────
|
||||
|
||||
const fetchGraph = useCallback(
|
||||
async (sessionId: string, knownGraphId?: string) => {
|
||||
try {
|
||||
let colonyId = knownGraphId;
|
||||
if (!colonyId) {
|
||||
// Try session detail first (colony_id is always set when worker is loaded)
|
||||
try {
|
||||
const detail = await sessionsApi.get(sessionId);
|
||||
colonyId = detail.colony_id ?? undefined;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
if (!colonyId) {
|
||||
const { colonies } = await sessionsApi.colonies(sessionId);
|
||||
if (!colonies.length) return;
|
||||
colonyId = colonies[0];
|
||||
}
|
||||
const topology = await workersApi.nodes(sessionId, colonyId);
|
||||
updateState({ colonyId, nodeSpecs: topology.nodes });
|
||||
const nodes = topologyToGraphNodes(topology);
|
||||
if (nodes.length > 0) setGraphNodes(nodes);
|
||||
} catch {
|
||||
// Graph fetch failed
|
||||
}
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
// ── Session loading ────────────────────────────────────────────────────
|
||||
|
||||
const loadSession = useCallback(async () => {
|
||||
@@ -473,8 +372,6 @@ export default function ColonyChat() {
|
||||
}
|
||||
|
||||
let restoredPhase: "planning" | "building" | "staging" | "running" | "independent" | null = null;
|
||||
let restoredFlowchartMap: Record<string, string[]> | null = null;
|
||||
let restoredOriginalDraft: DraftGraphData | null = null;
|
||||
|
||||
if (!liveSession) {
|
||||
// Pre-fetch messages from cold session
|
||||
@@ -484,8 +381,6 @@ export default function ColonyChat() {
|
||||
const restored = await restoreSessionMessages(coldRestoreId, agentPath, displayName);
|
||||
preRestoredMsgs = restored.messages;
|
||||
restoredPhase = restored.restoredPhase;
|
||||
restoredFlowchartMap = restored.flowchartMap;
|
||||
restoredOriginalDraft = restored.originalDraft;
|
||||
}
|
||||
|
||||
if (coldRestoreId || preRestoredMsgs.length > 0) {
|
||||
@@ -511,10 +406,7 @@ export default function ColonyChat() {
|
||||
sessionId: session.session_id,
|
||||
displayName,
|
||||
queenPhase: initialPhase,
|
||||
queenBuilding: initialPhase === "building",
|
||||
queenSupportsImages: session.queen_supports_images !== false,
|
||||
...(restoredFlowchartMap ? { flowchartMap: restoredFlowchartMap } : {}),
|
||||
...(restoredOriginalDraft ? { originalDraft: restoredOriginalDraft, draftGraph: null } : {}),
|
||||
});
|
||||
|
||||
// Restore messages for live resume
|
||||
@@ -528,10 +420,6 @@ export default function ColonyChat() {
|
||||
restored.messages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
||||
setMessages(restored.messages);
|
||||
}
|
||||
if (restored.flowchartMap && !restoredFlowchartMap) {
|
||||
restoredFlowchartMap = restored.flowchartMap;
|
||||
restoredOriginalDraft = restored.originalDraft;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRestoredContent = isResumedSession || !!coldRestoreId;
|
||||
@@ -543,8 +431,6 @@ export default function ColonyChat() {
|
||||
ready: true,
|
||||
loading: false,
|
||||
queenReady: hasRestoredContent,
|
||||
...(restoredFlowchartMap ? { flowchartMap: restoredFlowchartMap } : {}),
|
||||
...(restoredOriginalDraft ? { originalDraft: restoredOriginalDraft, draftGraph: null } : {}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError && err.status === 424) {
|
||||
@@ -578,13 +464,6 @@ export default function ColonyChat() {
|
||||
}
|
||||
}, [agentPath, isNewChat]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch graph when session becomes ready
|
||||
useEffect(() => {
|
||||
if (agentState.sessionId && agentState.ready && !agentState.colonyId) {
|
||||
fetchGraph(agentState.sessionId);
|
||||
}
|
||||
}, [agentState.sessionId, agentState.ready, agentState.colonyId, fetchGraph]);
|
||||
|
||||
// ── SSE event handler ──────────────────────────────────────────────────
|
||||
|
||||
const handleSSEEvent = useCallback(
|
||||
@@ -635,8 +514,6 @@ export default function ColonyChat() {
|
||||
isStreaming: false,
|
||||
workerIsTyping: true,
|
||||
awaitingInput: false,
|
||||
workerRunState: "running",
|
||||
currentExecutionId: event.execution_id || state.currentExecutionId || null,
|
||||
currentRunId: incomingRunId,
|
||||
nodeLogs: {},
|
||||
subagentReports: [],
|
||||
@@ -662,8 +539,6 @@ export default function ColonyChat() {
|
||||
workerIsTyping: false,
|
||||
awaitingInput: false,
|
||||
workerInputMessageId: null,
|
||||
workerRunState: "idle",
|
||||
currentExecutionId: null,
|
||||
llmSnapshots: {},
|
||||
pendingQuestion: null,
|
||||
pendingOptions: null,
|
||||
@@ -671,7 +546,6 @@ export default function ColonyChat() {
|
||||
pendingQuestionSource: null,
|
||||
});
|
||||
markAllNodesAs(["running", "looping"], "complete");
|
||||
if (state.sessionId) fetchGraph(state.sessionId, state.colonyId || undefined);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -745,7 +619,6 @@ export default function ColonyChat() {
|
||||
isTyping: false,
|
||||
isStreaming: false,
|
||||
queenIsTyping: false,
|
||||
queenBuilding: false,
|
||||
pendingQuestion: prompt || null,
|
||||
pendingOptions: options,
|
||||
pendingQuestions: questions,
|
||||
@@ -767,7 +640,6 @@ export default function ColonyChat() {
|
||||
pendingQuestionSource: null,
|
||||
});
|
||||
if (!isQueen) {
|
||||
updateState({ workerRunState: "idle", currentExecutionId: null });
|
||||
markAllNodesAs(["running", "looping"], "pending");
|
||||
}
|
||||
}
|
||||
@@ -785,7 +657,6 @@ export default function ColonyChat() {
|
||||
pendingQuestionSource: null,
|
||||
});
|
||||
if (!isQueen) {
|
||||
updateState({ workerRunState: "idle", currentExecutionId: null });
|
||||
if (event.node_id) {
|
||||
updateGraphNodeStatus(event.node_id, "error");
|
||||
const errMsg = (event.data?.error as string) || "unknown error";
|
||||
@@ -898,12 +769,6 @@ export default function ColonyChat() {
|
||||
const toolName = (event.data?.tool_name as string) || "unknown";
|
||||
const toolUseId = (event.data?.tool_use_id as string) || "";
|
||||
|
||||
if (isQueen && toolName === "save_agent_draft") {
|
||||
designingDraftSinceRef.current = Date.now();
|
||||
if (designingDraftTimerRef.current) clearTimeout(designingDraftTimerRef.current);
|
||||
updateState({ designingDraft: true });
|
||||
}
|
||||
|
||||
const sid = event.stream_id;
|
||||
setAgentState((prev) => {
|
||||
const newActive = {
|
||||
@@ -1002,7 +867,7 @@ export default function ColonyChat() {
|
||||
}
|
||||
|
||||
case "credentials_required": {
|
||||
updateState({ workerRunState: "idle", error: "credentials_required" });
|
||||
updateState({ error: "credentials_required" });
|
||||
const credAgentPath = event.data?.agent_path as string | undefined;
|
||||
if (credAgentPath) setCredentialAgentPath(credAgentPath);
|
||||
setCredentialsOpen(true);
|
||||
@@ -1025,64 +890,20 @@ export default function ColonyChat() {
|
||||
queenPhaseRef.current = newPhase;
|
||||
updateState({
|
||||
queenPhase: newPhase,
|
||||
queenBuilding: newPhase === "building",
|
||||
workerRunState: newPhase === "running" ? "running" : "idle",
|
||||
...(newPhase === "planning" ? { originalDraft: null, flowchartMap: null } : {}),
|
||||
...(eventAgentPath ? { agentPath: eventAgentPath } : {}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "custom": {
|
||||
const customEvent = event.data as Record<string, unknown>;
|
||||
if (customEvent?.event === "draft_updated") {
|
||||
const draft = customEvent as unknown as DraftGraphData | undefined;
|
||||
if (draft?.nodes) {
|
||||
const MIN_SPINNER_MS = 600;
|
||||
const since = designingDraftSinceRef.current;
|
||||
const elapsed = Date.now() - since;
|
||||
const remaining = Math.max(0, MIN_SPINNER_MS - elapsed);
|
||||
if (remaining > 0 && since > 0) {
|
||||
updateState({ draftGraph: draft });
|
||||
designingDraftTimerRef.current = setTimeout(() => {
|
||||
updateState({ designingDraft: false });
|
||||
}, remaining);
|
||||
} else {
|
||||
updateState({ draftGraph: draft, designingDraft: false });
|
||||
}
|
||||
}
|
||||
} else if (customEvent?.event === "flowchart_updated") {
|
||||
const mapData = customEvent as {
|
||||
map?: Record<string, string[]>;
|
||||
original_draft?: DraftGraphData;
|
||||
};
|
||||
if (mapData) {
|
||||
updateState({
|
||||
flowchartMap: mapData.map ?? null,
|
||||
originalDraft: mapData.original_draft ?? null,
|
||||
draftGraph: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "worker_colony_loaded": {
|
||||
const graphName = event.data?.colony_name as string | undefined;
|
||||
const agentPathFromEvent = event.data?.agent_path as string | undefined;
|
||||
const dn = formatAgentDisplayName(graphName || agentSlug(agentPath));
|
||||
clearCredentialCache(agentPathFromEvent);
|
||||
updateState({
|
||||
displayName: dn,
|
||||
queenBuilding: false,
|
||||
workerRunState: "idle",
|
||||
colonyId: null,
|
||||
nodeSpecs: [],
|
||||
});
|
||||
updateState({ displayName: dn });
|
||||
setGraphNodes([]);
|
||||
// Remove old worker messages
|
||||
setMessages((prev) => prev.filter((m) => m.role !== "worker"));
|
||||
if (state.sessionId) fetchGraph(state.sessionId);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1165,7 +986,7 @@ export default function ColonyChat() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[agentPath, queenInfo.name, updateState, upsertMessage, updateGraphNodeStatus, markAllNodesAs, appendNodeLog, fetchGraph, graphNodes],
|
||||
[agentPath, queenInfo.name, updateState, upsertMessage, updateGraphNodeStatus, markAllNodesAs, appendNodeLog, graphNodes],
|
||||
);
|
||||
|
||||
// ── SSE subscription ───────────────────────────────────────────────────
|
||||
@@ -1181,46 +1002,6 @@ export default function ColonyChat() {
|
||||
|
||||
// ── Action handlers ────────────────────────────────────────────────────
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!agentState.sessionId || !agentState.ready) return;
|
||||
setDismissedBanner(null);
|
||||
try {
|
||||
updateState({ workerRunState: "deploying" });
|
||||
const result = await executionApi.trigger(agentState.sessionId, "default", {});
|
||||
updateState({ currentExecutionId: result.execution_id });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 424) {
|
||||
const errBody = (err as ApiError).body as Record<string, unknown>;
|
||||
const credPath = (errBody?.agent_path as string) || null;
|
||||
if (credPath) setCredentialAgentPath(credPath);
|
||||
updateState({ workerRunState: "idle", error: "credentials_required" });
|
||||
setCredentialsOpen(true);
|
||||
return;
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
upsertMessage({
|
||||
id: makeId(),
|
||||
agent: "System",
|
||||
agentColor: "",
|
||||
content: `Failed to trigger run: ${errMsg}`,
|
||||
timestamp: "",
|
||||
type: "system",
|
||||
thread: agentPath,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
updateState({ workerRunState: "idle" });
|
||||
}
|
||||
}, [agentState.sessionId, agentState.ready, agentPath, updateState, upsertMessage]);
|
||||
|
||||
const handlePause = useCallback(async () => {
|
||||
if (!agentState.sessionId || !agentState.currentExecutionId) return;
|
||||
try {
|
||||
await executionApi.pause(agentState.sessionId, agentState.currentExecutionId);
|
||||
} catch {
|
||||
// fire-and-forget
|
||||
}
|
||||
}, [agentState.sessionId, agentState.currentExecutionId]);
|
||||
|
||||
const handleCancelQueen = useCallback(async () => {
|
||||
if (!agentState.sessionId) return;
|
||||
try {
|
||||
@@ -1416,44 +1197,14 @@ export default function ColonyChat() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pipeline graph panel */}
|
||||
<div
|
||||
className="bg-card/30 flex flex-col border-l border-border/30 relative"
|
||||
style={{ width: `${graphPanelPct}%`, minWidth: 240, flexShrink: 0 }}
|
||||
>
|
||||
<div className="flex-1 min-h-0">
|
||||
<DraftGraph
|
||||
key={colonyId}
|
||||
draft={agentState.originalDraft ?? agentState.draftGraph ?? null}
|
||||
originalDraft={agentState.originalDraft ?? null}
|
||||
loadingMessage={
|
||||
agentState.designingDraft
|
||||
? "Designing flowchart..."
|
||||
: !agentState.originalDraft &&
|
||||
!agentState.draftGraph &&
|
||||
agentState.queenPhase !== "planning"
|
||||
? "Loading flowchart..."
|
||||
: null
|
||||
}
|
||||
building={agentState.queenBuilding}
|
||||
onRun={handleRun}
|
||||
onPause={handlePause}
|
||||
runState={agentState.workerRunState}
|
||||
flowchartMap={agentState.flowchartMap ?? undefined}
|
||||
runtimeNodes={graphNodes}
|
||||
onRuntimeNodeClick={(runtimeNodeId) => {
|
||||
const node = graphNodes.find((n) => n.id === runtimeNodeId);
|
||||
if (node) setSelectedNode((prev) => (prev?.id === node.id ? null : node));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-primary/30 active:bg-primary/40 transition-colors z-10"
|
||||
onMouseDown={() => {
|
||||
resizing.current = true;
|
||||
document.body.style.cursor = "col-resize";
|
||||
}}
|
||||
{/* Triggers sidebar */}
|
||||
<div className="w-[260px] flex-shrink-0">
|
||||
<TriggersPanel
|
||||
triggers={graphNodes.filter((n) => n.nodeType === "trigger")}
|
||||
selectedId={resolvedSelectedNode?.id ?? null}
|
||||
onSelect={(trigger) =>
|
||||
setSelectedNode((prev) => (prev?.id === trigger.id ? null : trigger))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1463,7 +1214,6 @@ export default function ColonyChat() {
|
||||
<NodeDetailPanel
|
||||
node={resolvedSelectedNode}
|
||||
sessionId={agentState.sessionId || ""}
|
||||
colonyId={agentState.colonyId || ""}
|
||||
nodeLogs={agentState.nodeLogs[resolvedSelectedNode.id] || []}
|
||||
actionPlan={agentState.nodeActionPlans[resolvedSelectedNode.id]}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
|
||||
Reference in New Issue
Block a user