Canvas: Implement Agent Sketch node, update dodo-app.jsonschema

- Add Gemini API key to the project
- Update dodo schema to support Gemini API key
- Update dodo schema to support Agent Sketch node

Change-Id: I6a96186f86ad169152ca0021b38130e485ebbf14
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
index dc3b122..6793bf7 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -15,14 +15,11 @@
 } from "./graph.js";
 import { Edge } from "@xyflow/react";
 import { v4 as uuidv4 } from "uuid";
-import { ConfigWithInput, Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain } from "./types.js";
+import { Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain, isAgent } from "./types.js";
 import { GithubRepository } from "./github.js";
 
-export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): ConfigWithInput | null {
+export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
 	try {
-		if (appId == null || env.managerAddr == null) {
-			return null;
-		}
 		const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
 		const ingressNodes = nodes
 			.filter((n) => n.type === "gateway-https")
@@ -46,90 +43,96 @@
 					);
 				});
 		};
+		const services = nodes
+			.filter((n) => n.type === "app")
+			.map((n): Service => {
+				return {
+					nodeId: n.id,
+					type: n.data.type,
+					name: n.data.label,
+					source:
+						n.data.repository != undefined
+							? {
+									repository: nodes
+										.filter((i) => i.type === "github")
+										.find((i) => i.id === n.data.repository?.repoNodeId)!.data.repository!.sshURL,
+									branch:
+										n.data.repository != undefined && "branch" in n.data.repository
+											? n.data.repository.branch
+											: "main",
+									rootDir:
+										n.data.repository != undefined && "rootDir" in n.data.repository
+											? n.data.repository.rootDir
+											: "/",
+								}
+							: undefined,
+					ports: (n.data.ports || [])
+						.filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
+						.map((p) => ({
+							name: p.name.toLowerCase(),
+							value: p.value,
+							protocol: "TCP", // TODO(gio)
+						})),
+					env: (n.data.envVars || [])
+						.filter((e) => "name" in e)
+						.map((e) => ({
+							name: e.name,
+							alias: "alias" in e ? e.alias : undefined,
+						})),
+					ingress: ingressNodes
+						.filter((i) => i.data.https!.serviceId === n.id)
+						.map(
+							(i): Ingress => ({
+								nodeId: i.id,
+								network: networkMap.get(i.data.network!)!,
+								subdomain: i.data.subdomain!,
+								port: {
+									name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
+								},
+								auth:
+									i.data.auth?.enabled || false
+										? {
+												enabled: true,
+												groups: i.data.auth!.groups,
+												noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
+											}
+										: {
+												enabled: false,
+											},
+							}),
+						),
+					expose: findExpose(n),
+					preBuildCommands: n.data.preBuildCommands
+						? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
+						: [],
+					dev: {
+						enabled: n.data.dev ? n.data.dev.enabled : false,
+						username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
+						codeServer:
+							n.data.dev?.enabled && n.data.dev.expose != null
+								? {
+										network: networkMap.get(n.data.dev.expose.network)!,
+										subdomain: n.data.dev.expose.subdomain,
+									}
+								: undefined,
+						ssh:
+							n.data.dev?.enabled && n.data.dev.expose != null
+								? {
+										network: networkMap.get(n.data.dev.expose.network)!,
+										subdomain: n.data.dev.expose.subdomain,
+									}
+								: undefined,
+					},
+					agent: n.data.agent
+						? {
+								geminiApiKey: n.data.agent.geminiApiKey,
+							}
+						: undefined,
+				};
+			});
 		return {
-			input: {
-				appId: appId,
-				managerAddr: env.managerAddr,
-			},
-			service: nodes
-				.filter((n) => n.type === "app")
-				.map((n): Service => {
-					return {
-						nodeId: n.id,
-						type: n.data.type,
-						name: n.data.label,
-						source: {
-							repository: nodes
-								.filter((i) => i.type === "github")
-								.find((i) => i.id === n.data.repository?.repoNodeId)!.data.repository!.sshURL,
-							branch:
-								n.data.repository != undefined && "branch" in n.data.repository
-									? n.data.repository.branch
-									: "main",
-							rootDir:
-								n.data.repository != undefined && "rootDir" in n.data.repository
-									? n.data.repository.rootDir
-									: "/",
-						},
-						ports: (n.data.ports || [])
-							.filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
-							.map((p) => ({
-								name: p.name.toLowerCase(),
-								value: p.value,
-								protocol: "TCP", // TODO(gio)
-							})),
-						env: (n.data.envVars || [])
-							.filter((e) => "name" in e)
-							.map((e) => ({
-								name: e.name,
-								alias: "alias" in e ? e.alias : undefined,
-							})),
-						ingress: ingressNodes
-							.filter((i) => i.data.https!.serviceId === n.id)
-							.map(
-								(i): Ingress => ({
-									nodeId: i.id,
-									network: networkMap.get(i.data.network!)!,
-									subdomain: i.data.subdomain!,
-									port: {
-										name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
-									},
-									auth:
-										i.data.auth?.enabled || false
-											? {
-													enabled: true,
-													groups: i.data.auth!.groups,
-													noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
-												}
-											: {
-													enabled: false,
-												},
-								}),
-							),
-						expose: findExpose(n),
-						preBuildCommands: n.data.preBuildCommands
-							? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
-							: [],
-						dev: {
-							enabled: n.data.dev ? n.data.dev.enabled : false,
-							username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
-							codeServer:
-								n.data.dev?.enabled && n.data.dev.expose != null
-									? {
-											network: networkMap.get(n.data.dev.expose.network)!,
-											subdomain: n.data.dev.expose.subdomain,
-										}
-									: undefined,
-							ssh:
-								n.data.dev?.enabled && n.data.dev.expose != null
-									? {
-											network: networkMap.get(n.data.dev.expose.network)!,
-											subdomain: n.data.dev.expose.subdomain,
-										}
-									: undefined,
-						},
-					};
-				}),
+			service: services.filter((s) => !isAgent(s)),
+			agent: services.filter(isAgent),
 			volume: nodes
 				.filter((n) => n.type === "volume")
 				.map(
@@ -163,7 +166,7 @@
 		};
 	} catch (e) {
 		console.log(e);
-		return { input: { appId: "qweqwe", managerAddr: "" } };
+		return null;
 	}
 }
 
@@ -184,12 +187,12 @@
 		return ret;
 	}
 	const repoNodes = (config.service || [])
-		.filter((s) => s.source.repository != null)
+		.filter((s) => s.source?.repository != null)
 		.map((s): GithubNode | null => {
 			const existing = current.nodes.find(
-				(n) => n.type === "github" && n.data.repository?.sshURL === s.source.repository,
+				(n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
 			);
-			const repo = repos.find((r) => r.ssh_url === s.source.repository);
+			const repo = repos.find((r) => r.ssh_url === s.source!.repository);
 			if (repo == null) {
 				return null;
 			}
@@ -233,7 +236,7 @@
 			position: existing != null ? existing.position : { x: 0, y: 0 },
 		};
 	});
-	const services = config.service?.map((s): ServiceNode => {
+	const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
 		let existing: ServiceNode | null = null;
 		if (s.nodeId !== undefined) {
 			existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
@@ -245,12 +248,17 @@
 				label: s.name,
 				type: s.type,
 				env: [],
-				repository: {
-					id: repoNodes.find((r) => r.data.repository?.sshURL === s.source.repository)!.data.repository!.id,
-					repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source.repository)!.id,
-					branch: s.source.branch,
-					rootDir: s.source.rootDir,
-				},
+				repository:
+					s.source != null
+						? {
+								id: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!.data
+									.repository!.id,
+								repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!
+									.id,
+								branch: s.source!.branch,
+								rootDir: s.source!.rootDir,
+							}
+						: undefined,
 				ports: (s.ports || []).map(
 					(p): Port => ({
 						id: uuidv4(),
@@ -278,6 +286,7 @@
 				}),
 				volume: s.volume || [],
 				preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
+				agent: s.agent,
 				// TODO(gio): dev
 				isChoosingPortToConnect: false,
 			},
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index e8741f9..c259d8f 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -163,6 +163,9 @@
 				codeServerNodeId: string;
 				sshNodeId: string;
 		  };
+	agent?: {
+		geminiApiKey?: string;
+	};
 	info?: z.infer<typeof serviceAnalyzisSchema>;
 };
 
@@ -301,12 +304,12 @@
 });
 
 export const envSchema = z.object({
-	managerAddr: z.optional(z.string().min(1)),
 	instanceId: z.optional(z.string().min(1)),
 	deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
 	networks: z.array(networkSchema).default([]),
 	integrations: z.object({
 		github: z.boolean(),
+		gemini: z.boolean(),
 	}),
 	services: z.array(serviceInfoSchema),
 	user: z.object({
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index f668b5b..a8f21de 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -46,6 +46,7 @@
 	MongoDBData,
 	GithubData,
 	envSchema,
+	accessSchema,
 } from "./graph.js";
 
 export { generateDodoConfig, configToGraph } from "./config.js";
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
index fe417b8..de0725a 100644
--- a/apps/canvas/config/src/types.ts
+++ b/apps/canvas/config/src/types.ts
@@ -49,6 +49,7 @@
 	"nextjs:deno-2.0.0",
 	"nodejs:23.1.0",
 	"nodejs:24.0.2",
+	"sketch:latest",
 ] as const;
 
 const ServiceTypeSchema = z.enum(ServiceTypes);
@@ -57,11 +58,13 @@
 	nodeId: z.string().optional(),
 	type: ServiceTypeSchema,
 	name: z.string(),
-	source: z.object({
-		repository: z.string(),
-		branch: z.string(),
-		rootDir: z.string(),
-	}),
+	source: z
+		.object({
+			repository: z.string(),
+			branch: z.string(),
+			rootDir: z.string(),
+		})
+		.optional(),
 	ports: z
 		.array(
 			z.object({
@@ -91,6 +94,15 @@
 			codeServer: DomainSchema.optional(),
 		})
 		.optional(),
+	agent: z
+		.object({
+			geminiApiKey: z.string().optional(),
+		})
+		.optional(),
+});
+
+const AgentSchema = ServiceSchema.extend({
+	type: z.literal("sketch:latest"),
 });
 
 const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
@@ -118,6 +130,7 @@
 
 export const ConfigSchema = z.object({
 	service: z.array(ServiceSchema).optional(),
+	agent: z.array(AgentSchema).optional(),
 	volume: z.array(VolumeSchema).optional(),
 	postgresql: z.array(PostgreSQLSchema).optional(),
 	mongodb: z.array(MongoDBSchema).optional(),
@@ -132,6 +145,7 @@
 			private: z.string(),
 		})
 		.optional(),
+	geminiApiKey: z.string().optional(),
 });
 
 export const ConfigWithInputSchema = ConfigSchema.extend({
@@ -146,6 +160,7 @@
 export type PortValue = z.infer<typeof PortValueSchema>;
 export type PortDomain = z.infer<typeof PortDomainSchema>;
 export type ServiceType = z.infer<typeof ServiceTypeSchema>;
+export type Agent = z.infer<typeof AgentSchema>;
 export type Service = z.infer<typeof ServiceSchema>;
 export type VolumeType = z.infer<typeof VolumeTypeSchema>;
 export type Volume = z.infer<typeof VolumeSchema>;
@@ -153,3 +168,5 @@
 export type MongoDB = z.infer<typeof MongoDBSchema>;
 export type Config = z.infer<typeof ConfigSchema>;
 export type ConfigWithInput = z.infer<typeof ConfigWithInputSchema>;
+
+export const isAgent = (s: Service): s is Agent => s.type === "sketch:latest";