fix(frontend): Fix return type and usage of api.listLibraryAgents() (#9498)

- Follow-up to #9258

The front end is fetching `/library/agents` -> `LibraryAgent[]` but
using the result as `GraphMeta[]`. This breaks a bunch of things.

### Changes 🏗️

Frontend:
- Add `LibraryAgent` type for `api.listLibraryAgents()`
- Amend all broken usages of `LibraryAgent` objects
- Introduce branded typing for `LibraryAgent.id` and `GraphMeta.id` to
disallow mixing them. This prevents incorrect use in the future, and
reduces the chance of this frontend issue accumulating interest on
existing open PRs.

Backend:
- Add a migration to create `LibraryAgent` objects for all existing
`AgentGraphs`


### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
- [x] Check that all existing agents are listed in the agents list on
`/monitoring` (check against DB or `GET /api/graphs`)
  - [x] Check that all views of `/monitoring` work
  - [x] Try to run an agent and check its status
This commit is contained in:
Reinier van der Leer
2025-02-18 18:39:44 +01:00
committed by GitHub
parent dcbbe11c53
commit d1832ce10b
14 changed files with 148 additions and 73 deletions

View File

@@ -0,0 +1,33 @@
-- Create LibraryAgents for all AgentGraphs in their owners' library, skipping existing entries
INSERT INTO "LibraryAgent" (
"id",
"createdAt",
"updatedAt",
"userId",
"agentId",
"agentVersion",
"useGraphIsActiveVersion",
"isFavorite",
"isCreatedByUser",
"isArchived",
"isDeleted")
SELECT
gen_random_uuid(), --> id
ag."createdAt", --> createdAt
ag."createdAt", --> updatedAt
ag."userId", --> userId
ag."id", --> agentId
ag."version", --> agentVersion
true, --> useGraphIsActiveVersion
false, --> isFavorite
true, --> isCreatedByUser
false, --> isArchived
false --> isDeleted
FROM "AgentGraph" AS ag
WHERE ag."isActive" = true
AND NOT EXISTS (
SELECT 1
FROM "LibraryAgent" AS la
WHERE la."userId" = ag."userId"
AND la."agentId" = ag."id"
);

View File

@@ -1,6 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { GraphID } from "@/lib/autogpt-server-api/types";
import FlowEditor from "@/components/Flow";
export default function Home() {
@@ -9,7 +10,7 @@ export default function Home() {
return (
<FlowEditor
className="flow-container"
flowID={query.get("flowID") ?? undefined}
flowID={query.get("flowID") as GraphID | null ?? undefined}
flowVersion={query.get("flowVersion") ?? undefined}
/>
);

View File

@@ -1,7 +1,11 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { GraphExecution, Schedule, GraphMeta } from "@/lib/autogpt-server-api";
import {
GraphExecution,
Schedule,
LibraryAgent,
} from "@/lib/autogpt-server-api";
import { Card } from "@/components/ui/card";
import {
@@ -15,10 +19,10 @@ import { SchedulesTable } from "@/components/monitor/scheduleTable";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
const Monitor = () => {
const [flows, setFlows] = useState<GraphMeta[]>([]);
const [flows, setFlows] = useState<LibraryAgent[]>([]);
const [executions, setExecutions] = useState<GraphExecution[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [selectedFlow, setSelectedFlow] = useState<GraphMeta | null>(null);
const [selectedFlow, setSelectedFlow] = useState<LibraryAgent | null>(null);
const [selectedRun, setSelectedRun] = useState<GraphExecution | null>(null);
const [sortColumn, setSortColumn] = useState<keyof Schedule>("id");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
@@ -83,7 +87,7 @@ const Monitor = () => {
selectedFlow={selectedFlow}
onSelectFlow={(f) => {
setSelectedRun(null);
setSelectedFlow(f.id == selectedFlow?.id ? null : (f as GraphMeta));
setSelectedFlow(f.id == selectedFlow?.id ? null : f);
}}
/>
<FlowRunsList
@@ -91,7 +95,7 @@ const Monitor = () => {
flows={flows}
executions={[
...(selectedFlow
? executions.filter((v) => v.graph_id == selectedFlow.id)
? executions.filter((v) => v.graph_id == selectedFlow.agent_id)
: executions),
].sort((a, b) => Number(b.started_at) - Number(a.started_at))}
selectedRun={selectedRun}
@@ -102,7 +106,8 @@ const Monitor = () => {
{(selectedRun && (
<FlowRunInfo
flow={
selectedFlow || flows.find((f) => f.id == selectedRun.graph_id)!
selectedFlow ||
flows.find((f) => f.agent_id == selectedRun.graph_id)!
}
execution={selectedRun}
className={column3}
@@ -111,7 +116,9 @@ const Monitor = () => {
(selectedFlow && (
<FlowInfo
flow={selectedFlow}
executions={executions.filter((e) => e.graph_id == selectedFlow.id)}
executions={executions.filter(
(e) => e.graph_id == selectedFlow.agent_id,
)}
className={column3}
refresh={() => {
fetchAgents();

View File

@@ -26,7 +26,7 @@ import {
import "@xyflow/react/dist/style.css";
import { CustomNode } from "./CustomNode";
import "./flow.css";
import { BlockUIType, formatEdgeID } from "@/lib/autogpt-server-api";
import { BlockUIType, formatEdgeID, GraphID } from "@/lib/autogpt-server-api";
import { getTypeColor, findNewlyAddedBlockCoordinates } from "@/lib/utils";
import { history } from "./history";
import { CustomEdge } from "./CustomEdge";
@@ -69,7 +69,7 @@ export type NodeDimension = {
export const FlowContext = createContext<FlowContextType | null>(null);
const FlowEditor: React.FC<{
flowID?: string;
flowID?: GraphID;
flowVersion?: string;
className?: string;
}> = ({ flowID, flowVersion, className }) => {

View File

@@ -1,8 +1,5 @@
import BackendAPI, {
GraphExecution,
GraphMeta,
} from "@/lib/autogpt-server-api";
import React, { useMemo } from "react";
import { GraphExecution, LibraryAgent } from "@/lib/autogpt-server-api";
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { TextRenderer } from "@/components/ui/render";
@@ -39,10 +36,10 @@ export const AgentFlowList = ({
onSelectFlow,
className,
}: {
flows: GraphMeta[];
flows: LibraryAgent[];
executions?: GraphExecution[];
selectedFlow: GraphMeta | null;
onSelectFlow: (f: GraphMeta) => void;
selectedFlow: LibraryAgent | null;
onSelectFlow: (f: LibraryAgent) => void;
className?: string;
}) => {
return (
@@ -112,7 +109,7 @@ export const AgentFlowList = ({
lastRun: GraphExecution | null = null;
if (executions) {
const _flowRuns = executions.filter(
(r) => r.graph_id == flow.id,
(r) => r.graph_id == flow.agent_id,
);
runCount = _flowRuns.length;
lastRun =

View File

@@ -2,10 +2,10 @@ import React, { useEffect, useState, useCallback } from "react";
import {
GraphExecution,
Graph,
GraphMeta,
safeCopyGraph,
BlockUIType,
BlockIORootSchema,
LibraryAgent,
} from "@/lib/autogpt-server-api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -39,7 +39,7 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context";
export const FlowInfo: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
flow: GraphMeta;
flow: LibraryAgent;
executions: GraphExecution[];
flowVersion?: number | "all";
refresh: () => void;
@@ -65,7 +65,7 @@ export const FlowInfo: React.FC<
setNodes,
edges,
setEdges,
} = useAgentGraph(flow.id, flow.version, undefined, false);
} = useAgentGraph(flow.agent_id, flow.agent_version, undefined, false);
const api = useBackendAPI();
const { toast } = useToast();
@@ -76,7 +76,8 @@ export const FlowInfo: React.FC<
);
const selectedFlowVersion: Graph | undefined = flowVersions?.find(
(v) =>
v.version == (selectedVersion == "all" ? flow.version : selectedVersion),
v.version ==
(selectedVersion == "all" ? flow.agent_version : selectedVersion),
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -139,8 +140,10 @@ export const FlowInfo: React.FC<
};
useEffect(() => {
api.getGraphAllVersions(flow.id).then((result) => setFlowVersions(result));
}, [flow.id, api]);
api
.getGraphAllVersions(flow.agent_id)
.then((result) => setFlowVersions(result));
}, [flow.agent_id, api]);
const openRunnerInput = () => setIsRunnerInputOpen(true);
@@ -181,7 +184,8 @@ export const FlowInfo: React.FC<
<CardHeader className="flex-row justify-between space-x-3 space-y-0">
<div>
<CardTitle>
{flow.name} <span className="font-light">v{flow.version}</span>
{flow.name}{" "}
<span className="font-light">v{flow.agent_version}</span>
</CardTitle>
</div>
<div className="flex items-start space-x-2">
@@ -224,7 +228,7 @@ export const FlowInfo: React.FC<
)}
<Link
className={buttonVariants({ variant: "default" })}
href={`/build?flowID=${flow.id}&flowVersion=${flow.version}`}
href={`/build?flowID=${flow.agent_id}&flowVersion=${flow.agent_version}`}
>
<Pencil2Icon className="mr-2" />
Open in Builder
@@ -268,10 +272,10 @@ export const FlowInfo: React.FC<
</CardHeader>
<CardContent>
<FlowRunsStats
flows={[selectedFlowVersion ?? flow]}
flows={[flow]}
executions={executions.filter(
(execution) =>
execution.graph_id == flow.id &&
execution.graph_id == flow.agent_id &&
(selectedVersion == "all" ||
execution.graph_version == selectedVersion),
)}
@@ -296,7 +300,7 @@ export const FlowInfo: React.FC<
<Button
variant="destructive"
onClick={() => {
api.deleteGraph(flow.id).then(() => {
api.deleteGraph(flow.agent_id).then(() => {
setIsDeleteModalOpen(false);
refresh();
});

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import {
GraphExecution,
GraphMeta,
LibraryAgent,
NodeExecutionResult,
SpecialBlockID,
} from "@/lib/autogpt-server-api";
@@ -17,7 +17,7 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context";
export const FlowRunInfo: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
flow: GraphMeta;
flow: LibraryAgent;
execution: GraphExecution;
}
> = ({ flow, execution, ...props }) => {
@@ -27,7 +27,7 @@ export const FlowRunInfo: React.FC<
const fetchBlockResults = useCallback(async () => {
const executionResults = await api.getGraphExecutionInfo(
flow.id,
flow.agent_id,
execution.execution_id,
);
@@ -70,7 +70,7 @@ export const FlowRunInfo: React.FC<
result: result.output_data?.output || undefined,
})),
);
}, [api, flow.id, execution.execution_id]);
}, [api, flow.agent_id, execution.execution_id]);
// Fetch graph and execution data
useEffect(() => {
@@ -78,15 +78,15 @@ export const FlowRunInfo: React.FC<
fetchBlockResults();
}, [isOutputOpen, fetchBlockResults]);
if (execution.graph_id != flow.id) {
if (execution.graph_id != flow.agent_id) {
throw new Error(
`FlowRunInfo can't be used with non-matching execution.graph_id and flow.id`,
);
}
const handleStopRun = useCallback(() => {
api.stopGraphExecution(flow.id, execution.execution_id);
}, [api, flow.id, execution.execution_id]);
api.stopGraphExecution(flow.agent_id, execution.execution_id);
}, [api, flow.agent_id, execution.execution_id]);
return (
<>
@@ -107,17 +107,19 @@ export const FlowRunInfo: React.FC<
<Button onClick={() => setIsOutputOpen(true)} variant="outline">
<ExitIcon className="mr-2" /> View Outputs
</Button>
<Link
className={buttonVariants({ variant: "default" })}
href={`/build?flowID=${flow.id}&flowVersion=${execution.graph_version}&flowExecutionID=${execution.execution_id}`}
>
<Pencil2Icon className="mr-2" /> Open in Builder
</Link>
{flow.is_created_by_user && (
<Link
className={buttonVariants({ variant: "default" })}
href={`/build?flowID=${execution.graph_id}&flowVersion=${execution.graph_version}&flowExecutionID=${execution.execution_id}`}
>
<Pencil2Icon className="mr-2" /> Open in Builder
</Link>
)}
</div>
</CardHeader>
<CardContent>
<p className="hidden">
<strong>Agent ID:</strong> <code>{flow.id}</code>
<strong>Agent ID:</strong> <code>{flow.agent_id}</code>
</p>
<p className="hidden">
<strong>Run ID:</strong> <code>{execution.execution_id}</code>

View File

@@ -1,5 +1,5 @@
import React from "react";
import { GraphExecution, GraphMeta } from "@/lib/autogpt-server-api";
import { GraphExecution, LibraryAgent } from "@/lib/autogpt-server-api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
@@ -14,7 +14,7 @@ import { FlowRunStatusBadge } from "@/components/monitor/FlowRunStatusBadge";
import { TextRenderer } from "../ui/render";
export const FlowRunsList: React.FC<{
flows: GraphMeta[];
flows: LibraryAgent[];
executions: GraphExecution[];
className?: string;
selectedRun?: GraphExecution | null;
@@ -51,7 +51,9 @@ export const FlowRunsList: React.FC<{
>
<TableCell>
<TextRenderer
value={flows.find((f) => f.id == execution.graph_id)?.name}
value={
flows.find((f) => f.agent_id == execution.graph_id)?.name
}
truncateLengthLimit={30}
/>
</TableCell>

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { GraphExecution, GraphMeta } from "@/lib/autogpt-server-api";
import { GraphExecution, LibraryAgent } from "@/lib/autogpt-server-api";
import { CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
@@ -11,7 +11,7 @@ import { Calendar } from "@/components/ui/calendar";
import { FlowRunsTimeline } from "@/components/monitor/FlowRunsTimeline";
export const FlowRunsStatus: React.FC<{
flows: GraphMeta[];
flows: LibraryAgent[];
executions: GraphExecution[];
title?: string;
className?: string;

View File

@@ -1,4 +1,4 @@
import { GraphExecution, GraphMeta } from "@/lib/autogpt-server-api";
import { GraphExecution, LibraryAgent } from "@/lib/autogpt-server-api";
import {
ComposedChart,
DefaultLegendContentProps,
@@ -22,7 +22,7 @@ export const FlowRunsTimeline = ({
dataMin,
className,
}: {
flows: GraphMeta[];
flows: LibraryAgent[];
executions: GraphExecution[];
dataMin: "dataMin" | number;
className?: string;
@@ -62,7 +62,7 @@ export const FlowRunsTimeline = ({
if (payload && payload.length) {
const data: GraphExecution & { time: number; _duration: number } =
payload[0].payload;
const flow = flows.find((f) => f.id === data.graph_id);
const flow = flows.find((f) => f.agent_id === data.graph_id);
return (
<Card className="p-2 text-xs leading-normal">
<p>
@@ -94,7 +94,7 @@ export const FlowRunsTimeline = ({
<Scatter
key={flow.id}
data={executions
.filter((e) => e.graph_id == flow.id)
.filter((e) => e.graph_id == flow.agent_id)
.map((e) => ({
...e,
time: e.started_at + e.total_run_time * 1000,

View File

@@ -1,4 +1,4 @@
import { Schedule } from "@/lib/autogpt-server-api";
import { LibraryAgent, Schedule } from "@/lib/autogpt-server-api";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
@@ -21,7 +21,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GraphMeta } from "@/lib/autogpt-server-api";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
@@ -36,7 +35,7 @@ import { Label } from "../ui/label";
interface SchedulesTableProps {
schedules: Schedule[];
agents: GraphMeta[];
agents: LibraryAgent[];
onRemoveSchedule: (scheduleId: string, enabled: boolean) => void;
sortColumn: keyof Schedule;
sortDirection: "asc" | "desc";
@@ -54,12 +53,12 @@ export const SchedulesTable = ({
const { toast } = useToast();
const router = useRouter();
const cron_manager = new CronExpressionManager();
const [selectedAgent, setSelectedAgent] = useState<string>("");
const [selectedVersion, setSelectedVersion] = useState<number>(0);
const [selectedAgent, setSelectedAgent] = useState<string>(""); // Library Agent ID
const [selectedVersion, setSelectedVersion] = useState<number>(0); // Graph version
const [maxVersion, setMaxVersion] = useState<number>(0);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<string>("");
const [selectedFilter, setSelectedFilter] = useState<string>(""); // Graph ID
const filteredAndSortedSchedules = [...schedules]
.filter(
@@ -91,8 +90,8 @@ export const SchedulesTable = ({
const handleAgentSelect = (agentId: string) => {
setSelectedAgent(agentId);
const agent = agents.find((a) => a.id === agentId);
setMaxVersion(agent!.version);
setSelectedVersion(agent!.version);
setMaxVersion(agent!.agent_version);
setSelectedVersion(agent!.agent_version);
};
const handleVersionSelect = (version: string) => {
@@ -117,10 +116,11 @@ export const SchedulesTable = ({
return;
}
setIsLoading(true);
const agent = agents.find((a) => a.id == selectedAgent)!;
try {
await new Promise((resolve) => setTimeout(resolve, 100));
router.push(
`/build?flowID=${selectedAgent}&flowVersion=${selectedVersion}&open_scheduling=true`,
`/build?flowID=${agent.agent_id}&flowVersion=${agent.agent_version}&open_scheduling=true`,
);
} catch (error) {
console.error("Navigation error:", error);
@@ -184,7 +184,7 @@ export const SchedulesTable = ({
</SelectTrigger>
<SelectContent className="text-xs">
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<SelectItem key={agent.id} value={agent.agent_id}>
{agent.name}
</SelectItem>
))}
@@ -237,8 +237,8 @@ export const SchedulesTable = ({
filteredAndSortedSchedules.map((schedule) => (
<TableRow key={schedule.id}>
<TableCell className="font-medium">
{agents.find((a) => a.id === schedule.graph_id)?.name ||
schedule.graph_id}
{agents.find((a) => a.agent_id === schedule.graph_id)
?.name || schedule.graph_id}
</TableCell>
<TableCell>{schedule.graph_version}</TableCell>
<TableCell>

View File

@@ -6,6 +6,7 @@ import BackendAPI, {
BlockUIType,
formatEdgeID,
Graph,
GraphID,
NodeExecutionResult,
} from "@/lib/autogpt-server-api";
import {
@@ -26,7 +27,7 @@ import { default as NextLink } from "next/link";
const ajv = new Ajv({ strict: false, allErrors: true });
export default function useAgentGraph(
flowID?: string,
flowID?: GraphID,
flowVersion?: number,
flowExecutionID?: string,
passDataToBeads?: boolean,

View File

@@ -18,6 +18,7 @@ import {
GraphCreatable,
GraphMeta,
GraphUpdateable,
LibraryAgent,
MyAgentsResponse,
NodeExecutionResult,
ProfileDetails,
@@ -450,7 +451,7 @@ export default class BackendAPI {
/////////// V2 LIBRARY API //////////////
/////////////////////////////////////////
async listLibraryAgents(): Promise<GraphMeta[]> {
async listLibraryAgents(): Promise<LibraryAgent[]> {
return this._get("/library/agents");
}

View File

@@ -224,12 +224,12 @@ export type GraphExecution = {
duration: number;
total_run_time: number;
status: "QUEUED" | "RUNNING" | "COMPLETED" | "TERMINATED" | "FAILED";
graph_id: string;
graph_id: GraphID;
graph_version: number;
};
export type GraphMeta = {
id: string;
id: GraphID;
version: number;
is_active: boolean;
name: string;
@@ -238,6 +238,8 @@ export type GraphMeta = {
output_schema: BlockIOObjectSubSchema;
};
export type GraphID = Brand<string, "GraphID">;
/* Mirror of backend/data/graph.py:Graph */
export type Graph = GraphMeta & {
nodes: Array<Node>;
@@ -259,7 +261,7 @@ export type GraphCreatable = Omit<GraphUpdateable, "id"> & { id?: string };
/* Mirror of backend/data/execution.py:ExecutionResult */
export type NodeExecutionResult = {
graph_id: string;
graph_id: GraphID;
graph_version: number;
graph_exec_id: string;
node_exec_id: string;
@@ -280,6 +282,24 @@ export type NodeExecutionResult = {
end_time?: Date;
};
/* Mirror of backend/server/v2/library/model.py:LibraryAgent */
export type LibraryAgent = {
id: LibraryAgentID;
agent_id: GraphID;
agent_version: number;
preset_id: string | null;
updated_at: Date;
name: string;
description: string;
input_schema: BlockIOObjectSubSchema;
output_schema: BlockIOObjectSubSchema;
is_favorite: boolean;
is_created_by_user: boolean;
is_latest_version: boolean;
};
export type LibraryAgentID = Brand<string, "LibraryAgentID">;
/* Mirror of backend/server/integrations/router.py:CredentialsMetaResponse */
export type CredentialsMetaResponse = {
id: string;
@@ -503,7 +523,7 @@ export type Schedule = {
name: string;
cron: string;
user_id: string;
graph_id: string;
graph_id: GraphID;
graph_version: number;
input_data: { [key: string]: any };
next_run_time: string;
@@ -511,7 +531,7 @@ export type Schedule = {
export type ScheduleCreatable = {
cron: string;
graph_id: string;
graph_id: GraphID;
graph_version: number;
input_data: { [key: string]: any };
};
@@ -580,7 +600,7 @@ export interface CreditTransaction {
amount: number;
balance: number;
description: string;
usage_graph_id: string;
usage_graph_id: GraphID;
usage_execution_id: string;
usage_node_count: number;
usage_starting_time: Date;
@@ -602,3 +622,10 @@ export interface RefundRequest {
created_at: Date;
updated_at: Date;
}
/* *** UTILITIES *** */
/** Use branded types for IDs -> deny mixing IDs between different object classes */
export type Brand<T, Brand extends string> = T & {
readonly [B in Brand as `__${B}_brand`]: never;
};