Canvas: Add option to deploy latest draft

Change-Id: Ia74c64354e1f80ec794140ce406fb21c16feb0da
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 9456d17..fb77431 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -16,6 +16,7 @@
 	Env,
 	generateDodoConfig,
 	ConfigSchema,
+	Config,
 	ConfigWithInput,
 	configToGraph,
 	Network,
@@ -24,7 +25,7 @@
 } from "config";
 import { Instant, DateTimeFormatter, ZoneId } from "@js-joda/core";
 import LogStore from "./log.js";
-import { GraphOrConfigSchema, GraphSchema } from "config/dist/graph.js";
+import { GraphOrConfigSchema, GraphSchema, GraphConfigOrDraft } from "config/dist/graph.js";
 
 async function generateKey(root: string): Promise<[string, string]> {
 	const privKeyPath = path.join(root, "key");
@@ -167,10 +168,6 @@
 	};
 }
 
-const projectDeleteReqSchema = z.object({
-	state: z.optional(z.nullable(z.string())),
-});
-
 const handleProjectDelete: express.Handler = async (req, resp) => {
 	try {
 		const projectId = Number(req.params["projectId"]);
@@ -191,20 +188,8 @@
 			resp.status(404);
 			return;
 		}
-		const parseResult = projectDeleteReqSchema.safeParse(req.body);
-		if (!parseResult.success) {
-			resp.status(400);
-			resp.write(JSON.stringify({ error: "Invalid request body", issues: parseResult.error.format() }));
-			return;
-		}
 		if (p.githubToken && p.deployKeyPublic) {
-			const allRepos = [
-				...new Set([
-					...extractGithubRepos(p.state),
-					...extractGithubRepos(p.draft),
-					...extractGithubRepos(parseResult.data.state),
-				]),
-			];
+			const allRepos = [...new Set([...extractGithubRepos(p.state), ...extractGithubRepos(p.draft)])];
 			if (allRepos.length > 0) {
 				const diff: RepoDiff = { toDelete: allRepos, toAdd: [] };
 				const github = new GithubClient(p.githubToken);
@@ -300,6 +285,12 @@
 
 const handleDeploy: express.Handler = async (req, resp) => {
 	try {
+		const reqParsed = GraphConfigOrDraft.safeParse(req.body);
+		if (!reqParsed.success) {
+			resp.status(400);
+			resp.write(JSON.stringify({ error: "Invalid request body", issues: reqParsed.error.format() }));
+			return;
+		}
 		const projectId = Number(req.params["projectId"]);
 		const p = await db.project.findUnique({
 			where: {
@@ -312,6 +303,7 @@
 				deployKey: true,
 				deployKeyPublic: true,
 				state: true,
+				draft: true,
 				geminiApiKey: true,
 				anthropicApiKey: true,
 			},
@@ -320,33 +312,54 @@
 			resp.status(404);
 			return;
 		}
-		const config = ConfigSchema.safeParse(req.body.config);
-		if (!config.success) {
-			resp.status(400);
-			resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
+		let graph: Graph | null = null;
+		let config: Config | null = null;
+		if (reqParsed.data.type === "config") {
+			const parsed = ConfigSchema.safeParse(reqParsed.data.config);
+			if (parsed.success) {
+				config = parsed.data;
+			} else {
+				resp.status(400);
+				resp.write(JSON.stringify({ error: "Invalid configuration", issues: parsed.error.format() }));
+				return;
+			}
+			let oldGraph: Graph | undefined = undefined;
+			if (p.state != null) {
+				oldGraph = parseGraph(p.state)!.data;
+			} else if (p.draft != null) {
+				oldGraph = parseGraph(p.draft)!.data;
+			}
+			let repos: GithubRepository[] = [];
+			if (p.githubToken) {
+				const github = new GithubClient(p.githubToken);
+				repos = await github.getRepositories();
+			}
+			graph = configToGraph(config, getNetworks(resp.locals.username), repos, oldGraph);
+		} else if (reqParsed.data.type === "graph") {
+			graph = reqParsed.data.graph;
+			const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
+			config = generateDodoConfig(projectId.toString(), graph?.nodes || [], env);
+		} else if (reqParsed.data.type === "draft") {
+			if (p.draft == null) {
+				resp.status(400);
+				resp.write(JSON.stringify({ error: "Invalid request body" }));
+				return;
+			}
+			graph = parseGraph(p.draft)!.data!;
+			const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
+			config = generateDodoConfig(projectId.toString(), graph?.nodes || [], env);
+		}
+		if (config == null || graph == null) {
+			resp.status(500);
+			resp.write(JSON.stringify({ error: "Failed to generate configuration" }));
 			return;
 		}
-		let repos: GithubRepository[] = [];
-		if (p.githubToken) {
-			const github = new GithubClient(p.githubToken);
-			repos = await github.getRepositories();
-		}
-		const state = req.body.state
-			? JSON.stringify(req.body.state)
-			: JSON.stringify(
-					configToGraph(
-						config.data,
-						getNetworks(resp.locals.username),
-						repos,
-						p.state ? parseGraph(p.state)!.data! : undefined,
-					),
-				);
 		await db.project.update({
 			where: {
 				id: projectId,
 			},
 			data: {
-				draft: state,
+				draft: JSON.stringify(graph),
 			},
 		});
 		let deployKey: string | null = p.deployKey;
@@ -360,7 +373,7 @@
 		}
 		let diff: RepoDiff | null = null;
 		const cfg: ConfigWithInput = {
-			...config.data,
+			...config,
 			input: {
 				appId: projectId.toString(),
 				managerAddr: env.INTERNAL_API_ADDR!,
@@ -380,22 +393,22 @@
 						id: projectId,
 					},
 					data: {
-						state,
+						state: JSON.stringify(graph),
 						draft: null,
 						instanceId: deployResponse.id,
 						access: JSON.stringify(deployResponse.access),
 					},
 				});
-				diff = { toAdd: extractGithubRepos(state) };
+				diff = { toAdd: extractGithubRepos(JSON.stringify(graph)) };
 			} else {
 				const deployResponse = await appManager.update(p.instanceId, cfg);
-				diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
+				diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(JSON.stringify(graph)));
 				await db.project.update({
 					where: {
 						id: projectId,
 					},
 					data: {
-						state,
+						state: JSON.stringify(graph),
 						draft: null,
 						access: JSON.stringify(deployResponse.access),
 					},
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index c2f8e05..a4634cb 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -609,6 +609,20 @@
 	}),
 ]);
 
+export const GraphConfigOrDraft = z.discriminatedUnion("type", [
+	z.object({
+		type: z.literal("graph"),
+		graph: GraphSchema,
+	}),
+	z.object({
+		type: z.literal("config"),
+		config: ConfigSchema,
+	}),
+	z.object({
+		type: z.literal("draft"),
+	}),
+]);
+
 export type Edge = z.infer<typeof EdgeSchema>;
 export type ViewportTransform = z.infer<typeof ViewportTransformSchema>;
 export type Graph = z.infer<typeof GraphSchema>;
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index c10ce31..f6cc9a5 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -55,6 +55,7 @@
 	Edge,
 	GraphOrConfigSchema,
 	ViewportTransform,
+	GraphConfigOrDraft,
 } from "./graph.js";
 
 export { generateDodoConfig, configToGraph } from "./config.js";
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index 3047fdf..28d43dc 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -1,7 +1,7 @@
-import { nodeLabelFull, useEnv, useMessages, useProjectId, useStateStore } from "@/lib/state";
+import { nodeLabelFull, useMessages, useProjectId, useStateStore } from "@/lib/state";
 import { Button } from "./ui/button";
 import { useCallback, useEffect, useState } from "react";
-import { generateDodoConfig, AppNode } from "config";
+import { AppNode } from "config";
 import { useNodes, useReactFlow } from "@xyflow/react";
 import { useToast } from "@/hooks/use-toast";
 import {
@@ -31,7 +31,6 @@
 	const store = useStateStore();
 	const projectId = useProjectId();
 	const nodes = useNodes<AppNode>();
-	const env = useEnv();
 	const messages = useMessages();
 	const instance = useReactFlow();
 	const [ok, setOk] = useState(false);
@@ -98,18 +97,14 @@
 		}
 		setLoading(true);
 		try {
-			const config = generateDodoConfig(projectId, nodes, env);
-			if (config == null) {
-				throw new Error("MUST NOT REACH!");
-			}
 			const resp = await fetch(`/api/project/${projectId}/deploy`, {
 				method: "POST",
 				headers: {
 					"Content-Type": "application/json",
 				},
 				body: JSON.stringify({
-					state: instance.toObject(),
-					config,
+					type: "graph",
+					graph: instance.toObject(),
 				}),
 			});
 			if (resp.ok) {
@@ -125,7 +120,7 @@
 		} finally {
 			setLoading(false);
 		}
-	}, [projectId, instance, nodes, env, setLoading, info, error, monitor, store]);
+	}, [projectId, instance, setLoading, info, error, monitor, store]);
 	const save = useCallback(async () => {
 		if (projectId == null) {
 			return;
@@ -179,7 +174,6 @@
 			headers: {
 				"Content-Type": "application/json",
 			},
-			body: JSON.stringify({ state: JSON.stringify(instance.toObject()) }),
 		});
 		if (resp.ok) {
 			clear();
@@ -188,7 +182,7 @@
 		} else {
 			error("Failed to delete project", await resp.text());
 		}
-	}, [store, clear, projectId, info, error, instance]);
+	}, [store, clear, projectId, info, error]);
 	const reload = useCallback(async () => {
 		if (projectId == null) {
 			return;