mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cli): Comprehensive CLI Enhancement Suite - 20+ new commands (#2074)
Integrated into release/v3.8.0
This commit is contained in:
2822
bin/cli-commands.mjs
Normal file
2822
bin/cli-commands.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,67 @@ if (args.includes("--help") || args.includes("-h")) {
|
||||
omniroute --no-open Don't open browser automatically
|
||||
omniroute --mcp Start MCP server (stdio transport for IDEs)
|
||||
omniroute reset-encrypted-columns Reset encrypted credentials (recovery)
|
||||
|
||||
\x1b[1mServer Management:\x1b[0m
|
||||
omniroute serve Start the OmniRoute server
|
||||
omniroute stop Stop the running server
|
||||
omniroute restart Restart the server
|
||||
omniroute dashboard Open dashboard in browser
|
||||
omniroute open Alias for dashboard (same as dashboard)
|
||||
|
||||
\x1b[1mCLI Integration Suite:\x1b[0m
|
||||
omniroute setup Interactive wizard to configure CLI tools
|
||||
omniroute doctor Run health diagnostics
|
||||
omniroute status Show comprehensive status
|
||||
omniroute logs Stream request logs (--json, --search, --follow)
|
||||
omniroute config show Display current configuration
|
||||
|
||||
\x1b[1mProvider & Keys:\x1b[0m
|
||||
omniroute provider list List available providers
|
||||
omniroute provider add Add OmniRoute as provider
|
||||
omniroute keys add Add API key for provider
|
||||
omniroute keys list List configured API keys
|
||||
omniroute keys remove Remove API key
|
||||
|
||||
\x1b[1mModels & Combos:\x1b[0m
|
||||
omniroute models List available models (--json, --search)
|
||||
omniroute models <prov> Filter models by provider
|
||||
omniroute combo list List routing combos
|
||||
omniroute combo switch Switch active combo
|
||||
omniroute combo create Create new combo
|
||||
omniroute combo delete Delete a combo
|
||||
|
||||
\x1b[1mBackup & Restore:\x1b[0m
|
||||
omniroute backup Create backup of config & DB
|
||||
omniroute restore Restore from backup (list or specify timestamp)
|
||||
|
||||
\x1b[1mMonitoring:\x1b[0m
|
||||
omniroute health Detailed health (breakers, cache, memory)
|
||||
omniroute quota Show provider quota usage
|
||||
omniroute cache Show cache status
|
||||
omniroute cache clear Clear semantic/signature cache
|
||||
|
||||
\x1b[1mProtocols:\x1b[0m
|
||||
omniroute mcp status MCP server status
|
||||
omniroute mcp restart Restart MCP server
|
||||
omniroute a2a status A2A server status
|
||||
omniroute a2a card Show A2A agent card
|
||||
|
||||
\x1b[1mTunnels & Network:\x1b[0m
|
||||
omniroute tunnel list List active tunnels
|
||||
omniroute tunnel create Create tunnel (cloudflare/tailscale/ngrok)
|
||||
omniroute tunnel stop Stop a tunnel
|
||||
|
||||
\x1b[1mEnvironment:\x1b[0m
|
||||
omniroute env show Show environment variables
|
||||
omniroute env get <key> Get specific env var
|
||||
omniroute env set <k> <v> Set env var (temporary)
|
||||
|
||||
\x1b[1mTools & Utils:\x1b[0m
|
||||
omniroute test Test provider connectivity
|
||||
omniroute update Check for updates
|
||||
omniroute completion Generate shell completion
|
||||
|
||||
omniroute --help Show this help
|
||||
omniroute --version Show version
|
||||
|
||||
@@ -160,6 +221,24 @@ if (args.includes("--version") || args.includes("-v")) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── CLI Integration Suite subcommands ───────────────────────────────────────
|
||||
const subcommands = [
|
||||
"setup", "doctor", "status", "logs", "provider", "config", "test", "update",
|
||||
"serve", "stop", "restart",
|
||||
"keys", "models", "combo",
|
||||
"completion", "dashboard",
|
||||
"backup", "restore", "quota", "health",
|
||||
"cache", "mcp", "a2a", "tunnel",
|
||||
"env", "open"
|
||||
];
|
||||
const subcommand = args[0];
|
||||
|
||||
if (subcommands.includes(subcommand)) {
|
||||
const { runSubcommand } = await import("./cli-commands.mjs");
|
||||
await runSubcommand(subcommand, args.slice(1));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── reset-encrypted-columns subcommand ──────────────────────────────────────
|
||||
// Recovery tool for users who lost STORAGE_ENCRYPTION_KEY after upgrade (#1622)
|
||||
if (args.includes("reset-encrypted-columns")) {
|
||||
|
||||
478
src/app/(dashboard)/dashboard/cloud-agents/page.tsx
Normal file
478
src/app/(dashboard)/dashboard/cloud-agents/page.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, Input, Badge } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface CloudAgentTask {
|
||||
id: string;
|
||||
provider: string;
|
||||
status: "pending" | "running" | "waiting_approval" | "completed" | "failed" | "cancelled";
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
plan?: string;
|
||||
messages: Array<{ role: string; content: string; timestamp: string }>;
|
||||
}
|
||||
|
||||
const CLOUD_AGENTS = [
|
||||
{
|
||||
id: "jules",
|
||||
name: "Jules",
|
||||
provider: "Google",
|
||||
description: "Google's autonomous coding agent",
|
||||
icon: "🟡",
|
||||
color: "bg-yellow-500/10 text-yellow-600",
|
||||
},
|
||||
{
|
||||
id: "devin",
|
||||
name: "Devin",
|
||||
provider: "Cognition",
|
||||
description: "Cognition's AI software engineer",
|
||||
icon: "🔵",
|
||||
color: "bg-blue-500/10 text-blue-600",
|
||||
},
|
||||
{
|
||||
id: "codex-cloud",
|
||||
name: "Codex Cloud",
|
||||
provider: "OpenAI",
|
||||
description: "OpenAI's cloud-based coding agent",
|
||||
icon: "⚡",
|
||||
color: "bg-emerald-500/10 text-emerald-600",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CloudAgentsPage() {
|
||||
const [tasks, setTasks] = useState<CloudAgentTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<CloudAgentTask | null>(null);
|
||||
const [newTask, setNewTask] = useState({
|
||||
provider: "jules",
|
||||
description: "",
|
||||
});
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
const t = useTranslations("cloudAgents");
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/agents/tasks");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTasks(data.tasks || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch tasks:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
|
||||
const handleCreateTask = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/agents/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: newTask.provider,
|
||||
description: newTask.description,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTasks((prev) => [data.task, ...prev]);
|
||||
setNewTask({ provider: "jules", description: "" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create task:", err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!selectedTask || !messageInput.trim()) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "message",
|
||||
message: messageInput,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSelectedTask(data.task);
|
||||
setTasks((prev) => prev.map((task) => (task.id === selectedTask.id ? data.task : task)));
|
||||
setMessageInput("");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to send message:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprovePlan = async () => {
|
||||
if (!selectedTask) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "approve" }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSelectedTask(data.task);
|
||||
setTasks((prev) => prev.map((t) => (t.id === selectedTask.id ? data.task : t)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to approve plan:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelTask = async (taskId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/agents/tasks/${taskId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "cancel" }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTasks((prev) => prev.map((t) => (t.id === taskId ? data.task : t)));
|
||||
if (selectedTask?.id === taskId) {
|
||||
setSelectedTask(data.task);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to cancel task:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/agents/tasks/${taskId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
setTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||||
if (selectedTask?.id === taskId) {
|
||||
setSelectedTask(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete task:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; label: string }> = {
|
||||
pending: { color: "bg-zinc-500/10 text-zinc-500", label: t("statusPending") },
|
||||
running: { color: "bg-blue-500/10 text-blue-500", label: t("statusRunning") },
|
||||
waiting_approval: {
|
||||
color: "bg-amber-500/10 text-amber-600",
|
||||
label: t("statusWaitingApproval"),
|
||||
},
|
||||
completed: { color: "bg-emerald-500/10 text-emerald-600", label: t("statusCompleted") },
|
||||
failed: { color: "bg-red-500/10 text-red-500", label: t("statusFailed") },
|
||||
cancelled: { color: "bg-zinc-500/10 text-zinc-400", label: t("statusCancelled") },
|
||||
};
|
||||
const s = statusMap[status] || statusMap.pending;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${s.color}`}
|
||||
>
|
||||
{status === "running" && <span className="animate-pulse">●</span>}
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getAgentInfo = (providerId: string) => {
|
||||
return CLOUD_AGENTS.find((a) => a.id === providerId) || CLOUD_AGENTS[0];
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-text-muted">{t("loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-text-muted mt-1">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-purple-500/20 bg-purple-500/5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-main">{t("aboutTitle")}</h2>
|
||||
<p className="text-sm text-text-muted mt-1">{t("aboutDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{CLOUD_AGENTS.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="rounded-lg border border-purple-500/15 bg-purple-500/5 p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{agent.icon}</span>
|
||||
<p className="text-sm font-medium text-text-main">{agent.name}</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{agent.description}</p>
|
||||
<p className="text-[10px] text-purple-500 mt-1">{agent.provider}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border border-purple-500/15 bg-surface/40 p-3 text-sm text-text-muted">
|
||||
<span className="font-medium text-text-main">{t("howItWorksTitle")}</span>
|
||||
<span className="ml-1">{t("howItWorksDesc")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
|
||||
<span className="material-symbols-outlined text-[20px]">add_task</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("newTaskTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("newTaskDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleCreateTask} className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-muted mb-1.5 block">
|
||||
{t("selectAgent")}
|
||||
</label>
|
||||
<select
|
||||
value={newTask.provider}
|
||||
onChange={(e) => setNewTask({ ...newTask, provider: e.target.value })}
|
||||
className="w-full rounded-lg border border-border/50 bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
{CLOUD_AGENTS.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name} ({agent.provider})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label={t("taskDescription")}
|
||||
placeholder={t("taskDescriptionPlaceholder")}
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary" loading={creating}>
|
||||
<span className="material-symbols-outlined text-[16px] mr-1">rocket_launch</span>
|
||||
{t("startTask")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold">{t("tasks")}</h2>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[40px] mb-2">assignment</span>
|
||||
<p>{t("noTasks")}</p>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => {
|
||||
const agent = getAgentInfo(task.provider);
|
||||
return (
|
||||
<Card
|
||||
key={task.id}
|
||||
className={`cursor-pointer transition-all hover:border-primary/30 ${
|
||||
selectedTask?.id === task.id ? "border-primary ring-1 ring-primary/20" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedTask(task)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{agent.icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main line-clamp-1">
|
||||
{task.description || t("untitledTask")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{agent.name} • {new Date(task.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(task.status)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold">{t("taskDetail")}</h2>
|
||||
{selectedTask ? (
|
||||
<Card className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{getAgentInfo(selectedTask.provider).icon}</span>
|
||||
<div>
|
||||
<p className="font-medium">{getAgentInfo(selectedTask.provider).name}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("created")}: {new Date(selectedTask.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(selectedTask.status)}
|
||||
</div>
|
||||
|
||||
{selectedTask.status === "waiting_approval" && selectedTask.plan && (
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-amber-600">
|
||||
description
|
||||
</span>
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
{t("planReady")}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-text-muted whitespace-pre-wrap bg-black/5 dark:bg-white/5 rounded p-2 max-h-32 overflow-auto">
|
||||
{selectedTask.plan}
|
||||
</pre>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button variant="primary" size="sm" onClick={handleApprovePlan}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">check</span>
|
||||
{t("approvePlan")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleCancelTask(selectedTask.id)}
|
||||
>
|
||||
{t("rejectPlan")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTask.messages.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">{t("conversation")}</p>
|
||||
<div className="flex flex-col gap-2 max-h-64 overflow-auto">
|
||||
{selectedTask.messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`p-2 rounded-lg text-xs ${
|
||||
msg.role === "assistant"
|
||||
? "bg-purple-500/10 text-text-main"
|
||||
: "bg-surface/40 text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium capitalize">{msg.role}: </span>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTask.result && (
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-emerald-600">
|
||||
check_circle
|
||||
</span>
|
||||
<span className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
|
||||
{t("result")}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-text-muted whitespace-pre-wrap">
|
||||
{selectedTask.result}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTask.error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/5 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-red-500">
|
||||
error
|
||||
</span>
|
||||
<span className="text-sm font-medium text-red-600">{t("error")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{selectedTask.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTask.status === "running" && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t("sendMessagePlaceholder")}
|
||||
value={messageInput}
|
||||
onChange={(e) => setMessageInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSendMessage()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="primary" onClick={handleSendMessage}>
|
||||
<span className="material-symbols-outlined text-[16px]">send</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-3 border-t border-border/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCancelTask(selectedTask.id)}
|
||||
disabled={["completed", "failed", "cancelled"].includes(selectedTask.status)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">cancel</span>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTask(selectedTask.id)}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">delete</span>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted border border-dashed border-border/50 rounded-lg">
|
||||
<span className="material-symbols-outlined text-[40px] mb-2">touch_app</span>
|
||||
<p>{t("selectTaskPrompt")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IMAGE_ONLY_PROVIDER_IDS,
|
||||
VIDEO_PROVIDER_IDS,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
CLOUD_AGENT_PROVIDERS,
|
||||
} from "@/shared/constants/providers";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
@@ -488,6 +489,13 @@ export default function ProvidersPage() {
|
||||
searchQuery
|
||||
);
|
||||
|
||||
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
|
||||
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
|
||||
cloudAgentProviderEntriesAll,
|
||||
showConfiguredOnly,
|
||||
searchQuery
|
||||
);
|
||||
|
||||
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
|
||||
const upstreamProxyEntries = filterConfiguredProviderEntries(
|
||||
upstreamProxyEntriesAll,
|
||||
@@ -970,6 +978,51 @@ export default function ProvidersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cloud Agent Providers */}
|
||||
{cloudAgentProviderEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("cloudAgentProviders")}{" "}
|
||||
<span
|
||||
className="size-2.5 rounded-full bg-violet-500"
|
||||
title={t("cloudAgentProviders")}
|
||||
/>
|
||||
<ProviderCountBadge {...countConfigured(cloudAgentProviderEntriesAll)} />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("cloud-agent")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
testingMode === "cloud-agent"
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title={t("testAll")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "cloud-agent" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "cloud-agent" ? t("testing") : t("testAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{cloudAgentProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<ProviderCard
|
||||
key={providerId}
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
stats={stats}
|
||||
authType={displayAuthType}
|
||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local / Self-Hosted Providers */}
|
||||
{localProviderEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function ProxyTab() {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
async function init() {
|
||||
|
||||
186
src/app/api/v1/agents/tasks/[id]/route.ts
Normal file
186
src/app/api/v1/agents/tasks/[id]/route.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
import { getAgent } from "@/lib/cloudAgent/registry";
|
||||
import { getCloudAgentTaskById, updateCloudAgentTask } from "@/lib/cloudAgent/db";
|
||||
import { z } from "zod";
|
||||
import pino from "pino";
|
||||
|
||||
const logger = pino({ name: "cloud-agents-api" });
|
||||
|
||||
function getCorsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, { headers: getCorsHeaders() });
|
||||
}
|
||||
|
||||
const ApproveSchema = z.object({
|
||||
action: z.literal("approve"),
|
||||
});
|
||||
|
||||
const MessageSchema = z.object({
|
||||
action: z.literal("message"),
|
||||
message: z.string().min(1),
|
||||
});
|
||||
|
||||
const CancelSchema = z.object({
|
||||
action: z.literal("cancel"),
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const task = getCloudAgentTaskById(id);
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task not found" },
|
||||
{ status: 404, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key required" },
|
||||
{ status: 401, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
const agent = getAgent(task.provider_id);
|
||||
if (agent && task.external_id) {
|
||||
try {
|
||||
const statusResult = await agent.getStatus(task.external_id, { apiKey });
|
||||
|
||||
updateCloudAgentTask(id, {
|
||||
status: statusResult.status,
|
||||
result: statusResult.result ? JSON.stringify(statusResult.result) : null,
|
||||
activities: JSON.stringify(statusResult.activities),
|
||||
error: statusResult.error || null,
|
||||
completed_at:
|
||||
statusResult.status === "completed" || statusResult.status === "failed"
|
||||
? new Date().toISOString()
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to sync task status:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTask = getCloudAgentTaskById(id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
id: updatedTask!.id,
|
||||
providerId: updatedTask!.provider_id,
|
||||
externalId: updatedTask!.external_id,
|
||||
status: updatedTask!.status,
|
||||
prompt: updatedTask!.prompt,
|
||||
source: JSON.parse(updatedTask!.source),
|
||||
options: JSON.parse(updatedTask!.options),
|
||||
result: updatedTask!.result ? JSON.parse(updatedTask!.result) : null,
|
||||
activities: JSON.parse(updatedTask!.activities),
|
||||
error: updatedTask!.error,
|
||||
createdAt: updatedTask!.created_at,
|
||||
updatedAt: updatedTask!.updated_at,
|
||||
completedAt: updatedTask!.completed_at,
|
||||
},
|
||||
},
|
||||
{ headers: getCorsHeaders() }
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const task = getCloudAgentTaskById(id);
|
||||
if (!task) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task not found" },
|
||||
{ status: 404, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key required" },
|
||||
{ status: 401, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
let validated;
|
||||
if (body.action === "approve") {
|
||||
validated = ApproveSchema.parse(body);
|
||||
} else if (body.action === "message") {
|
||||
validated = MessageSchema.parse(body);
|
||||
} else if (body.action === "cancel") {
|
||||
validated = CancelSchema.parse(body);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid action" },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
const agent = getAgent(task.provider_id);
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
{ error: "Agent not found" },
|
||||
{ status: 500, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
if (validated.action === "approve") {
|
||||
if (!task.external_id) {
|
||||
return NextResponse.json(
|
||||
{ error: "No external task to approve" },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
await agent.approvePlan(task.external_id, { apiKey });
|
||||
updateCloudAgentTask(id, { status: "running" });
|
||||
} else if (validated.action === "message") {
|
||||
if (!task.external_id) {
|
||||
return NextResponse.json(
|
||||
{ error: "No external task to message" },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
const activity = await agent.sendMessage(task.external_id, validated.message, { apiKey });
|
||||
const activities = JSON.parse(task.activities);
|
||||
activities.push(activity);
|
||||
updateCloudAgentTask(id, { activities: JSON.stringify(activities) });
|
||||
} else if (validated.action === "cancel") {
|
||||
updateCloudAgentTask(id, { status: "cancelled" });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { headers: getCorsHeaders() });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: error.errors },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
logger.error({ err: error }, "Failed to process task action");
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
}
|
||||
173
src/app/api/v1/agents/tasks/route.ts
Normal file
173
src/app/api/v1/agents/tasks/route.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
import { getAgent } from "@/lib/cloudAgent/registry";
|
||||
import {
|
||||
insertCloudAgentTask,
|
||||
getCloudAgentTaskById,
|
||||
getAllCloudAgentTasks,
|
||||
getCloudAgentTasksByProvider,
|
||||
getCloudAgentTasksByStatus,
|
||||
updateCloudAgentTask,
|
||||
deleteCloudAgentTask,
|
||||
} from "@/lib/cloudAgent/db";
|
||||
import { CreateCloudAgentTaskSchema } from "@/lib/cloudAgent/types";
|
||||
import { CLOUD_AGENT_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { z } from "zod";
|
||||
import pino from "pino";
|
||||
|
||||
const logger = pino({ name: "cloud-agents-api" });
|
||||
|
||||
function getCorsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, { headers: getCorsHeaders() });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const providerId = searchParams.get("provider");
|
||||
const status = searchParams.get("status");
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10);
|
||||
|
||||
let tasks;
|
||||
if (providerId) {
|
||||
tasks = getCloudAgentTasksByProvider(providerId, limit);
|
||||
} else if (status) {
|
||||
tasks = getCloudAgentTasksByStatus(status, limit);
|
||||
} else {
|
||||
tasks = getAllCloudAgentTasks(limit);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: tasks.map((t) => ({
|
||||
id: t.id,
|
||||
providerId: t.provider_id,
|
||||
externalId: t.external_id,
|
||||
status: t.status,
|
||||
prompt: t.prompt,
|
||||
source: JSON.parse(t.source),
|
||||
options: JSON.parse(t.options),
|
||||
result: t.result ? JSON.parse(t.result) : null,
|
||||
activities: JSON.parse(t.activities),
|
||||
error: t.error,
|
||||
createdAt: t.created_at,
|
||||
updatedAt: t.updated_at,
|
||||
completedAt: t.completed_at,
|
||||
})),
|
||||
},
|
||||
{ headers: getCorsHeaders() }
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validated = CreateCloudAgentTaskSchema.parse(body);
|
||||
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key required" },
|
||||
{ status: 401, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
const agent = getAgent(validated.providerId);
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown provider: ${validated.providerId}` },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
const task = await agent.createTask(
|
||||
{
|
||||
prompt: validated.prompt,
|
||||
source: validated.source,
|
||||
options: validated.options || {},
|
||||
},
|
||||
{ apiKey }
|
||||
);
|
||||
|
||||
insertCloudAgentTask({
|
||||
id: task.id,
|
||||
provider_id: task.providerId,
|
||||
external_id: task.externalId || null,
|
||||
status: task.status,
|
||||
prompt: task.prompt,
|
||||
source: JSON.stringify(task.source),
|
||||
options: JSON.stringify(task.options),
|
||||
result: null,
|
||||
activities: JSON.stringify(task.activities),
|
||||
error: null,
|
||||
created_at: task.createdAt,
|
||||
updated_at: task.updatedAt,
|
||||
completed_at: null,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
id: task.id,
|
||||
providerId: task.providerId,
|
||||
externalId: task.externalId,
|
||||
status: task.status,
|
||||
prompt: task.prompt,
|
||||
source: task.source,
|
||||
options: task.options,
|
||||
createdAt: task.createdAt,
|
||||
},
|
||||
},
|
||||
{ status: 201, headers: getCorsHeaders() }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: error.errors },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
logger.error({ err: error }, "Failed to create cloud agent task");
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = searchParams.get("id");
|
||||
|
||||
if (!taskId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task ID required" },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
deleteCloudAgentTask(taskId);
|
||||
|
||||
return NextResponse.json({ success: true }, { headers: getCorsHeaders() });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,7 @@
|
||||
"noLockouts": "No Lockouts",
|
||||
"webSearchDesc": "Web Search Desc",
|
||||
"audioProvidersHeading": "Audio Providers Heading",
|
||||
"cloudAgentProviders": "Cloud Agent Providers",
|
||||
"minutesAgo": "Minutes Ago",
|
||||
"a": "A",
|
||||
"liveAutoRefreshing": "Live Auto Refreshing",
|
||||
@@ -668,6 +669,7 @@
|
||||
"playground": "Playground",
|
||||
"searchTools": "Search Tools",
|
||||
"agents": "Agents",
|
||||
"cloudAgents": "Cloud Agents",
|
||||
"memory": "Memory",
|
||||
"skills": "Skills",
|
||||
"docs": "Docs",
|
||||
@@ -4851,6 +4853,42 @@
|
||||
"settingsRoutingLink": "Settings/Routing",
|
||||
"openSettings": "Settings"
|
||||
},
|
||||
"cloudAgents": {
|
||||
"title": "Cloud Agents",
|
||||
"description": "Manage autonomous coding agents (Jules, Devin, Codex Cloud)",
|
||||
"loading": "Loading tasks...",
|
||||
"aboutTitle": "About Cloud Agents",
|
||||
"aboutDescription": "Cloud agents are remote AI coding assistants that can execute tasks autonomously. They work differently from local CLI agents - you interact with them through OmniRoute's API.",
|
||||
"howItWorksTitle": "How it works:",
|
||||
"howItWorksDesc": "Create a task → Agent analyzes and proposes a plan → You approve → Agent executes → Results returned",
|
||||
"newTaskTitle": "Create New Task",
|
||||
"newTaskDescription": "Start a new task with a cloud agent",
|
||||
"selectAgent": "Select Agent",
|
||||
"taskDescription": "Task Description",
|
||||
"taskDescriptionPlaceholder": "Describe what you want the agent to do...",
|
||||
"startTask": "Start Task",
|
||||
"tasks": "Tasks",
|
||||
"taskDetail": "Task Detail",
|
||||
"noTasks": "No tasks yet. Create one to get started.",
|
||||
"untitledTask": "Untitled Task",
|
||||
"created": "Created",
|
||||
"conversation": "Conversation",
|
||||
"result": "Result",
|
||||
"error": "Error",
|
||||
"planReady": "Plan Ready for Approval",
|
||||
"approvePlan": "Approve Plan",
|
||||
"rejectPlan": "Reject & Cancel",
|
||||
"sendMessagePlaceholder": "Send a message to the agent...",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"selectTaskPrompt": "Select a task to view details",
|
||||
"statusPending": "Pending",
|
||||
"statusRunning": "Running",
|
||||
"statusWaitingApproval": "Waiting Approval",
|
||||
"statusCompleted": "Completed",
|
||||
"statusFailed": "Failed",
|
||||
"statusCancelled": "Cancelled"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
148
src/lib/cloudAgent/agents/codex.ts
Normal file
148
src/lib/cloudAgent/agents/codex.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
CloudAgentBase,
|
||||
type AgentCredentials,
|
||||
type CreateTaskParams,
|
||||
type GetStatusResult,
|
||||
} from "../baseAgent.ts";
|
||||
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
|
||||
import { CLOUD_AGENT_STATUS } from "../types.ts";
|
||||
|
||||
export class CodexCloudAgent extends CloudAgentBase {
|
||||
readonly providerId = "codex-cloud";
|
||||
readonly baseUrl = "https://api.openai.com/v1";
|
||||
|
||||
async createTask(
|
||||
params: CreateTaskParams,
|
||||
credentials: AgentCredentials
|
||||
): Promise<CloudAgentTask> {
|
||||
const taskId = this.generateTaskId();
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
prompt: params.prompt,
|
||||
repository_context: params.source.repoUrl,
|
||||
};
|
||||
|
||||
if (params.source.branch) {
|
||||
body.branch = params.source.branch;
|
||||
}
|
||||
|
||||
if (params.options.environment) {
|
||||
body.environment = {
|
||||
setup: params.options.environment,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${credentials.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Codex Cloud create task failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
id: taskId,
|
||||
providerId: this.providerId,
|
||||
externalId: data.id,
|
||||
status: this.mapStatus(data.status || "pending"),
|
||||
prompt: params.prompt,
|
||||
source: params.source,
|
||||
options: params.options,
|
||||
activities: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
|
||||
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${credentials.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Codex Cloud get status failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const status = this.mapStatus(data.status || "pending");
|
||||
|
||||
const activities: CloudAgentActivity[] = [];
|
||||
|
||||
if (data.subagents) {
|
||||
for (const subagent of data.subagents) {
|
||||
activities.push({
|
||||
id: this.generateActivityId(),
|
||||
type: "command",
|
||||
content: `Subagent: ${subagent.name} - ${subagent.status}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result;
|
||||
if (status === CLOUD_AGENT_STATUS.COMPLETED && (data.result || data.pr_url)) {
|
||||
result = {
|
||||
prUrl: data.pr_url || data.result?.pr_url,
|
||||
commitMessage: data.result?.commit_message,
|
||||
summary: data.result?.summary,
|
||||
duration: data.elapsed_time,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
externalId,
|
||||
result,
|
||||
activities,
|
||||
error: data.error || data.error_message,
|
||||
};
|
||||
}
|
||||
|
||||
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
|
||||
throw new Error("Codex Cloud does not support plan approval - it auto-plans");
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
externalId: string,
|
||||
message: string,
|
||||
credentials: AgentCredentials
|
||||
): Promise<CloudAgentActivity> {
|
||||
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}/followup`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${credentials.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Codex Cloud send message failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.generateActivityId(),
|
||||
type: "message",
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async listSources(
|
||||
_credentials: AgentCredentials
|
||||
): Promise<{ name: string; url: string; branch?: string }[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
137
src/lib/cloudAgent/agents/devin.ts
Normal file
137
src/lib/cloudAgent/agents/devin.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
CloudAgentBase,
|
||||
type AgentCredentials,
|
||||
type CreateTaskParams,
|
||||
type GetStatusResult,
|
||||
} from "../baseAgent.ts";
|
||||
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
|
||||
import { CLOUD_AGENT_STATUS } from "../types.ts";
|
||||
|
||||
export class DevinAgent extends CloudAgentBase {
|
||||
readonly providerId = "devin";
|
||||
readonly baseUrl = "https://api.devin.ai/v1";
|
||||
|
||||
async createTask(
|
||||
params: CreateTaskParams,
|
||||
credentials: AgentCredentials
|
||||
): Promise<CloudAgentTask> {
|
||||
const taskId = this.generateTaskId();
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
prompt: params.prompt,
|
||||
repo_url: params.source.repoUrl,
|
||||
};
|
||||
|
||||
if (params.source.branch) {
|
||||
body.branch = params.source.branch;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/sessions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${credentials.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Devin create task failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
id: taskId,
|
||||
providerId: this.providerId,
|
||||
externalId: data.id,
|
||||
status: this.mapStatus(data.status || "created"),
|
||||
prompt: params.prompt,
|
||||
source: params.source,
|
||||
options: params.options,
|
||||
activities: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
|
||||
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${credentials.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Devin get status failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const status = this.mapStatus(data.status || "created");
|
||||
|
||||
const activities: CloudAgentActivity[] = (data.messages || []).map(
|
||||
(msg: Record<string, unknown>) => ({
|
||||
id: this.generateActivityId(),
|
||||
type: "message" as const,
|
||||
content: (msg.content as string) || "",
|
||||
timestamp: (msg.created_at as string) || new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
let result;
|
||||
if (status === CLOUD_AGENT_STATUS.COMPLETED && data.output) {
|
||||
result = {
|
||||
prUrl: data.pr_url,
|
||||
summary: data.output,
|
||||
duration: data.duration,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
externalId,
|
||||
result,
|
||||
activities,
|
||||
error: data.error,
|
||||
};
|
||||
}
|
||||
|
||||
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
|
||||
throw new Error("Devin does not support plan approval - it auto-plans");
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
externalId: string,
|
||||
message: string,
|
||||
credentials: AgentCredentials
|
||||
): Promise<CloudAgentActivity> {
|
||||
const response = await fetch(`${this.baseUrl}/sessions/${externalId}/message`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${credentials.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ content: message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Devin send message failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.generateActivityId(),
|
||||
type: "message",
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async listSources(
|
||||
_credentials: AgentCredentials
|
||||
): Promise<{ name: string; url: string; branch?: string }[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
170
src/lib/cloudAgent/agents/jules.ts
Normal file
170
src/lib/cloudAgent/agents/jules.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
CloudAgentBase,
|
||||
type AgentCredentials,
|
||||
type CreateTaskParams,
|
||||
type GetStatusResult,
|
||||
} from "../baseAgent.ts";
|
||||
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
|
||||
import { CLOUD_AGENT_STATUS } from "../types.ts";
|
||||
|
||||
export class JulesAgent extends CloudAgentBase {
|
||||
readonly providerId = "jules";
|
||||
readonly baseUrl = "https://jules.googleapis.com/v1alpha";
|
||||
|
||||
async createTask(
|
||||
params: CreateTaskParams,
|
||||
credentials: AgentCredentials
|
||||
): Promise<CloudAgentTask> {
|
||||
const taskId = this.generateTaskId();
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
prompt: params.prompt,
|
||||
source: {
|
||||
repository: {
|
||||
owner: params.source.repoUrl.split("/").filter(Boolean).slice(-2, -1)[0] || "",
|
||||
name: params.source.repoName,
|
||||
},
|
||||
branch: params.source.branch || "main",
|
||||
},
|
||||
};
|
||||
|
||||
if (params.options.autoCreatePr) {
|
||||
body.automationMode = "AUTO_CREATE_PR";
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/sessions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": credentials.apiKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Jules create task failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
id: taskId,
|
||||
providerId: this.providerId,
|
||||
externalId: data.name?.split("/").pop() || taskId,
|
||||
status: this.mapStatus(data.state || "pending"),
|
||||
prompt: params.prompt,
|
||||
source: params.source,
|
||||
options: params.options,
|
||||
activities: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(externalId: string, _credentials: AgentCredentials): Promise<GetStatusResult> {
|
||||
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
|
||||
headers: {
|
||||
"X-Goog-Api-Key": _credentials.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Jules get status failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const status = this.mapStatus(data.state || "pending");
|
||||
|
||||
const activities: CloudAgentActivity[] = (data.activities || []).map(
|
||||
(act: Record<string, unknown>) => ({
|
||||
id: this.generateActivityId(),
|
||||
type: act.type as CloudAgentActivity["type"],
|
||||
content: (act.description as string) || "",
|
||||
timestamp: (act.timestamp as string) || new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
let result;
|
||||
if (status === CLOUD_AGENT_STATUS.COMPLETED && data.outputs) {
|
||||
result = {
|
||||
prUrl: data.outputs.prUrl,
|
||||
commitMessage: data.outputs.commitMessage,
|
||||
summary: data.outputs.summary,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
externalId,
|
||||
result,
|
||||
activities,
|
||||
error: data.error,
|
||||
};
|
||||
}
|
||||
|
||||
async approvePlan(externalId: string, credentials: AgentCredentials): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/sessions/${externalId}:approvePlan`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": credentials.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Jules approve plan failed: ${response.status} ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
externalId: string,
|
||||
message: string,
|
||||
credentials: AgentCredentials
|
||||
): Promise<CloudAgentActivity> {
|
||||
const response = await fetch(`${this.baseUrl}/sessions/${externalId}:sendMessage`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": credentials.apiKey,
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Jules send message failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.generateActivityId(),
|
||||
type: "message",
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async listSources(
|
||||
credentials: AgentCredentials
|
||||
): Promise<{ name: string; url: string; branch?: string }[]> {
|
||||
const response = await fetch(`${this.baseUrl}/sources`, {
|
||||
headers: {
|
||||
"X-Goog-Api-Key": credentials.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Jules list sources failed: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return (data.sources || []).map((source: Record<string, unknown>) => ({
|
||||
name: source.name as string,
|
||||
url: `https://github.com/${source.repoOwner}/${source.repoName}`,
|
||||
branch: source.defaultBranch as string | undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
95
src/lib/cloudAgent/baseAgent.ts
Normal file
95
src/lib/cloudAgent/baseAgent.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
CloudAgentTask,
|
||||
CloudAgentStatus,
|
||||
CloudAgentSource,
|
||||
CloudAgentResult,
|
||||
CloudAgentActivity,
|
||||
} from "./types.ts";
|
||||
|
||||
export interface AgentCredentials {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface CreateTaskParams {
|
||||
prompt: string;
|
||||
source: CloudAgentSource;
|
||||
options: {
|
||||
autoCreatePr?: boolean;
|
||||
planApprovalRequired?: boolean;
|
||||
environment?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetStatusResult {
|
||||
status: CloudAgentStatus;
|
||||
externalId?: string;
|
||||
result?: CloudAgentResult;
|
||||
activities: CloudAgentActivity[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export abstract class CloudAgentBase {
|
||||
abstract readonly providerId: string;
|
||||
abstract readonly baseUrl: string;
|
||||
|
||||
abstract createTask(
|
||||
params: CreateTaskParams,
|
||||
credentials: AgentCredentials
|
||||
): Promise<CloudAgentTask>;
|
||||
|
||||
abstract getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult>;
|
||||
|
||||
abstract approvePlan(externalId: string, credentials: AgentCredentials): Promise<void>;
|
||||
|
||||
abstract sendMessage(
|
||||
externalId: string,
|
||||
message: string,
|
||||
credentials: AgentCredentials
|
||||
): Promise<CloudAgentActivity>;
|
||||
|
||||
abstract listSources(
|
||||
credentials: AgentCredentials
|
||||
): Promise<{ name: string; url: string; branch?: string }[]>;
|
||||
|
||||
protected mapStatus(status: string): CloudAgentStatus {
|
||||
const statusLower = status.toLowerCase();
|
||||
|
||||
if (statusLower.includes("completed") || statusLower.includes("done")) {
|
||||
return "completed";
|
||||
}
|
||||
if (statusLower.includes("failed") || statusLower.includes("error")) {
|
||||
return "failed";
|
||||
}
|
||||
if (statusLower.includes("cancelled") || statusLower.includes("canceled")) {
|
||||
return "cancelled";
|
||||
}
|
||||
if (
|
||||
statusLower.includes("running") ||
|
||||
statusLower.includes("active") ||
|
||||
statusLower.includes("executing")
|
||||
) {
|
||||
return "running";
|
||||
}
|
||||
if (
|
||||
statusLower.includes("pending") ||
|
||||
statusLower.includes("queued") ||
|
||||
statusLower.includes("waiting")
|
||||
) {
|
||||
return "queued";
|
||||
}
|
||||
if (statusLower.includes("approval") || statusLower.includes("plan")) {
|
||||
return "awaiting_approval";
|
||||
}
|
||||
|
||||
return "queued";
|
||||
}
|
||||
|
||||
protected generateTaskId(): string {
|
||||
return `task_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
protected generateActivityId(): string {
|
||||
return `act_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
}
|
||||
145
src/lib/cloudAgent/db.ts
Normal file
145
src/lib/cloudAgent/db.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { getDbInstance } from "@/lib/db/core.ts";
|
||||
|
||||
export interface CloudAgentTaskRow {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
external_id: string | null;
|
||||
status: string;
|
||||
prompt: string;
|
||||
source: string;
|
||||
options: string;
|
||||
result: string | null;
|
||||
activities: string;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export function createCloudAgentTaskTable(): void {
|
||||
const db = getDbInstance();
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cloud_agent_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
external_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
prompt TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
options TEXT DEFAULT '{}',
|
||||
result TEXT,
|
||||
activities TEXT DEFAULT '[]',
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_provider
|
||||
ON cloud_agent_tasks(provider_id)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_status
|
||||
ON cloud_agent_tasks(status)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_created
|
||||
ON cloud_agent_tasks(created_at DESC)
|
||||
`);
|
||||
}
|
||||
|
||||
export function insertCloudAgentTask(task: CloudAgentTaskRow): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO cloud_agent_tasks (
|
||||
id, provider_id, external_id, status, prompt, source,
|
||||
options, result, activities, error, created_at, updated_at, completed_at
|
||||
) VALUES (
|
||||
@id, @provider_id, @external_id, @status, @prompt, @source,
|
||||
@options, @result, @activities, @error, @created_at, @updated_at, @completed_at
|
||||
)
|
||||
`
|
||||
).run(task);
|
||||
}
|
||||
|
||||
// Whitelist of allowed columns for update operations
|
||||
const ALLOWED_UPDATE_COLUMNS = new Set([
|
||||
"status",
|
||||
"prompt",
|
||||
"source",
|
||||
"options",
|
||||
"result",
|
||||
"activities",
|
||||
"error",
|
||||
"completed_at",
|
||||
]);
|
||||
|
||||
export function updateCloudAgentTask(
|
||||
id: string,
|
||||
updates: Partial<Omit<CloudAgentTaskRow, "id">>
|
||||
): void {
|
||||
const db = getDbInstance();
|
||||
|
||||
// Validate keys against whitelist to prevent SQL injection
|
||||
const validUpdates: Partial<Omit<CloudAgentTaskRow, "id">> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (ALLOWED_UPDATE_COLUMNS.has(key)) {
|
||||
(validUpdates as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const fields = Object.keys(validUpdates)
|
||||
.map((key) => `${key} = @${key}`)
|
||||
.join(", ");
|
||||
|
||||
if (!fields) return; // No valid updates
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE cloud_agent_tasks
|
||||
SET ${fields}, updated_at = datetime('now')
|
||||
WHERE id = @id
|
||||
`
|
||||
).run({ id, ...validUpdates });
|
||||
}
|
||||
|
||||
export function getCloudAgentTaskById(id: string): CloudAgentTaskRow | null {
|
||||
const db = getDbInstance();
|
||||
return db
|
||||
.prepare("SELECT * FROM cloud_agent_tasks WHERE id = ?")
|
||||
.get(id) as CloudAgentTaskRow | null;
|
||||
}
|
||||
|
||||
export function getCloudAgentTasksByProvider(providerId: string, limit = 50): CloudAgentTaskRow[] {
|
||||
const db = getDbInstance();
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT * FROM cloud_agent_tasks WHERE provider_id = ? ORDER BY created_at DESC LIMIT ?"
|
||||
)
|
||||
.all(providerId, limit) as CloudAgentTaskRow[];
|
||||
}
|
||||
|
||||
export function getCloudAgentTasksByStatus(status: string, limit = 50): CloudAgentTaskRow[] {
|
||||
const db = getDbInstance();
|
||||
return db
|
||||
.prepare("SELECT * FROM cloud_agent_tasks WHERE status = ? ORDER BY created_at DESC LIMIT ?")
|
||||
.all(status, limit) as CloudAgentTaskRow[];
|
||||
}
|
||||
|
||||
export function getAllCloudAgentTasks(limit = 100): CloudAgentTaskRow[] {
|
||||
const db = getDbInstance();
|
||||
return db
|
||||
.prepare("SELECT * FROM cloud_agent_tasks ORDER BY created_at DESC LIMIT ?")
|
||||
.all(limit) as CloudAgentTaskRow[];
|
||||
}
|
||||
|
||||
export function deleteCloudAgentTask(id: string): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM cloud_agent_tasks WHERE id = ?").run(id);
|
||||
}
|
||||
8
src/lib/cloudAgent/index.ts
Normal file
8
src/lib/cloudAgent/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./types.ts";
|
||||
export * from "./baseAgent.ts";
|
||||
export * from "./registry.ts";
|
||||
export * from "./db.ts";
|
||||
|
||||
import { createCloudAgentTaskTable } from "./db.ts";
|
||||
|
||||
createCloudAgentTaskTable();
|
||||
25
src/lib/cloudAgent/registry.ts
Normal file
25
src/lib/cloudAgent/registry.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { CloudAgentBase } from "./baseAgent.ts";
|
||||
import { JulesAgent } from "./agents/jules.ts";
|
||||
import { DevinAgent } from "./agents/devin.ts";
|
||||
import { CodexCloudAgent } from "./agents/codex.ts";
|
||||
|
||||
const AGENTS: Record<string, CloudAgentBase> = {
|
||||
jules: new JulesAgent(),
|
||||
devin: new DevinAgent(),
|
||||
"codex-cloud": new CodexCloudAgent(),
|
||||
};
|
||||
|
||||
export function getAgent(providerId: string): CloudAgentBase | null {
|
||||
return AGENTS[providerId] || null;
|
||||
}
|
||||
|
||||
export function getAvailableAgents(): string[] {
|
||||
return Object.keys(AGENTS);
|
||||
}
|
||||
|
||||
export function isCloudAgentProvider(providerId: string): boolean {
|
||||
return providerId in AGENTS;
|
||||
}
|
||||
|
||||
export { JulesAgent, DevinAgent, CodexCloudAgent };
|
||||
export type { CloudAgentBase } from "./baseAgent.ts";
|
||||
111
src/lib/cloudAgent/types.ts
Normal file
111
src/lib/cloudAgent/types.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CLOUD_AGENT_STATUS = {
|
||||
QUEUED: "queued",
|
||||
RUNNING: "running",
|
||||
AWAITING_APPROVAL: "awaiting_approval",
|
||||
COMPLETED: "completed",
|
||||
FAILED: "failed",
|
||||
CANCELLED: "cancelled",
|
||||
} as const;
|
||||
|
||||
export type CloudAgentStatus = (typeof CLOUD_AGENT_STATUS)[keyof typeof CLOUD_AGENT_STATUS];
|
||||
|
||||
export const CloudAgentStatusSchema = z.enum([
|
||||
"queued",
|
||||
"running",
|
||||
"awaiting_approval",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export interface CloudAgentSource {
|
||||
repoName: string;
|
||||
repoUrl: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface CloudAgentResult {
|
||||
prUrl?: string;
|
||||
prNumber?: number;
|
||||
commitMessage?: string;
|
||||
diffUrl?: string;
|
||||
summary?: string;
|
||||
duration?: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export interface CloudAgentActivity {
|
||||
id: string;
|
||||
type: "plan" | "command" | "code_change" | "message" | "error" | "completion";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CloudAgentTask {
|
||||
id: string;
|
||||
providerId: "jules" | "devin" | "codex-cloud";
|
||||
externalId?: string;
|
||||
status: CloudAgentStatus;
|
||||
prompt: string;
|
||||
source: CloudAgentSource;
|
||||
options: {
|
||||
autoCreatePr?: boolean;
|
||||
planApprovalRequired?: boolean;
|
||||
environment?: Record<string, string>;
|
||||
};
|
||||
result?: CloudAgentResult;
|
||||
activities: CloudAgentActivity[];
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export const CloudAgentSourceSchema = z.object({
|
||||
repoName: z.string().min(1),
|
||||
repoUrl: z.string().url(),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CloudAgentResultSchema = z.object({
|
||||
prUrl: z.string().url().optional(),
|
||||
prNumber: z.number().int().positive().optional(),
|
||||
commitMessage: z.string().optional(),
|
||||
diffUrl: z.string().url().optional(),
|
||||
summary: z.string().optional(),
|
||||
duration: z.number().int().positive().optional(),
|
||||
cost: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export const CloudAgentActivitySchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["plan", "command", "code_change", "message", "error", "completion"]),
|
||||
content: z.string(),
|
||||
timestamp: z.string().datetime(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const CloudAgentTaskOptionsSchema = z.object({
|
||||
autoCreatePr: z.boolean().optional(),
|
||||
planApprovalRequired: z.boolean().optional(),
|
||||
environment: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const CreateCloudAgentTaskSchema = z.object({
|
||||
providerId: z.enum(["jules", "devin", "codex-cloud"]),
|
||||
prompt: z.string().min(1).max(10000),
|
||||
source: CloudAgentSourceSchema,
|
||||
options: CloudAgentTaskOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export const UpdateCloudAgentTaskSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
action: z.enum(["approve", "reject", "cancel", "message"]),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateCloudAgentTaskInput = z.infer<typeof CreateCloudAgentTaskSchema>;
|
||||
export type UpdateCloudAgentTaskInput = z.infer<typeof UpdateCloudAgentTaskSchema>;
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
APIKEY_PROVIDERS,
|
||||
AUDIO_ONLY_PROVIDERS,
|
||||
CLOUD_AGENT_PROVIDERS,
|
||||
FREE_PROVIDERS,
|
||||
LOCAL_PROVIDERS,
|
||||
OAUTH_PROVIDERS,
|
||||
@@ -21,7 +22,8 @@ export type StaticProviderCatalogCategory =
|
||||
| "search"
|
||||
| "audio"
|
||||
| "upstream-proxy"
|
||||
| "apikey";
|
||||
| "apikey"
|
||||
| "cloud-agent";
|
||||
|
||||
export interface ProviderCatalogMetadata {
|
||||
id: string;
|
||||
@@ -133,6 +135,12 @@ export const STATIC_PROVIDER_CATALOG_GROUPS: Record<
|
||||
displayAuthType: "apikey",
|
||||
toggleAuthType: "apikey",
|
||||
},
|
||||
"cloud-agent": {
|
||||
category: "cloud-agent",
|
||||
providers: CLOUD_AGENT_PROVIDERS as ProviderRecord,
|
||||
displayAuthType: "apikey",
|
||||
toggleAuthType: "apikey",
|
||||
},
|
||||
};
|
||||
|
||||
export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCategory[] = [
|
||||
@@ -143,6 +151,7 @@ export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCate
|
||||
"search",
|
||||
"audio",
|
||||
"upstream-proxy",
|
||||
"cloud-agent",
|
||||
"apikey",
|
||||
];
|
||||
|
||||
|
||||
@@ -1880,6 +1880,38 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean {
|
||||
return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId);
|
||||
}
|
||||
|
||||
// Cloud Agent Providers
|
||||
export const CLOUD_AGENT_PROVIDERS = {
|
||||
jules: {
|
||||
id: "jules",
|
||||
alias: "jl",
|
||||
name: "Jules (Google)",
|
||||
icon: "smart_toy",
|
||||
color: "#4285F4",
|
||||
website: "https://jules.google",
|
||||
authHint: "Get your API key from Jules Settings → API. Free during alpha.",
|
||||
},
|
||||
devin: {
|
||||
id: "devin",
|
||||
alias: "dv",
|
||||
name: "Devin (Cognition)",
|
||||
icon: "precision_manufacturing",
|
||||
color: "#8B5CF6",
|
||||
website: "https://devin.ai",
|
||||
authHint: "Get your API token from Devin Settings → API.",
|
||||
},
|
||||
"codex-cloud": {
|
||||
id: "codex-cloud",
|
||||
alias: "cx-cloud",
|
||||
name: "Codex Cloud (OpenAI)",
|
||||
icon: "cloud",
|
||||
color: "#10A37F",
|
||||
website: "https://platform.openai.com/docs/codex",
|
||||
authHint:
|
||||
"Use your OpenAI API key or ChatGPT subscription. Codex Cloud included with Plus/Pro/Business.",
|
||||
},
|
||||
};
|
||||
|
||||
// All providers (combined)
|
||||
export const AI_PROVIDERS = {
|
||||
...FREE_PROVIDERS,
|
||||
@@ -1890,6 +1922,7 @@ export const AI_PROVIDERS = {
|
||||
...SEARCH_PROVIDERS,
|
||||
...AUDIO_ONLY_PROVIDERS,
|
||||
...UPSTREAM_PROXY_PROVIDERS,
|
||||
...CLOUD_AGENT_PROVIDERS,
|
||||
};
|
||||
|
||||
export type AiProviderId = keyof typeof AI_PROVIDERS;
|
||||
@@ -1968,3 +2001,4 @@ validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS");
|
||||
validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS");
|
||||
validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS");
|
||||
validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS");
|
||||
validateProviders(CLOUD_AGENT_PROVIDERS, "CLOUD_AGENT_PROVIDERS");
|
||||
|
||||
@@ -14,6 +14,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"limits",
|
||||
"cli-tools",
|
||||
"agents",
|
||||
"cloud-agents",
|
||||
"memory",
|
||||
"skills",
|
||||
"translator",
|
||||
@@ -69,6 +70,7 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
const CLI_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
{ id: "cli-tools", href: "/dashboard/cli-tools", i18nKey: "cliToolsShort", icon: "terminal" },
|
||||
{ id: "agents", href: "/dashboard/agents", i18nKey: "agents", icon: "smart_toy" },
|
||||
{ id: "cloud-agents", href: "/dashboard/cloud-agents", i18nKey: "cloudAgents", icon: "cloud" },
|
||||
{ id: "memory", href: "/dashboard/memory", i18nKey: "memory", icon: "psychology" },
|
||||
{ id: "skills", href: "/dashboard/skills", i18nKey: "skills", icon: "auto_fix_high" },
|
||||
];
|
||||
|
||||
166
tests/unit/cloudAgent-types.test.ts
Normal file
166
tests/unit/cloudAgent-types.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { CLOUD_AGENT_STATUS, CloudAgentStatusSchema, CloudAgentTask } =
|
||||
await import("../../src/lib/cloudAgent/types.ts");
|
||||
const { CreateCloudAgentTaskSchema, UpdateCloudAgentTaskSchema } =
|
||||
await import("../../src/lib/cloudAgent/types.ts");
|
||||
const { isCloudAgentProvider, getAgent, getAvailableAgents } =
|
||||
await import("../../src/lib/cloudAgent/registry.ts");
|
||||
|
||||
test("CLOUD_AGENT_STATUS has correct values", () => {
|
||||
assert.strictEqual(CLOUD_AGENT_STATUS.QUEUED, "queued");
|
||||
assert.strictEqual(CLOUD_AGENT_STATUS.RUNNING, "running");
|
||||
assert.strictEqual(CLOUD_AGENT_STATUS.AWAITING_APPROVAL, "awaiting_approval");
|
||||
assert.strictEqual(CLOUD_AGENT_STATUS.COMPLETED, "completed");
|
||||
assert.strictEqual(CLOUD_AGENT_STATUS.FAILED, "failed");
|
||||
assert.strictEqual(CLOUD_AGENT_STATUS.CANCELLED, "cancelled");
|
||||
});
|
||||
|
||||
test("CloudAgentStatusSchema validates valid statuses", () => {
|
||||
const validStatuses = [
|
||||
"queued",
|
||||
"running",
|
||||
"awaiting_approval",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
];
|
||||
for (const status of validStatuses) {
|
||||
const result = CloudAgentStatusSchema.safeParse(status);
|
||||
assert.strictEqual(result.success, true, `Status ${status} should be valid`);
|
||||
}
|
||||
});
|
||||
|
||||
test("CloudAgentStatusSchema rejects invalid status", () => {
|
||||
const result = CloudAgentStatusSchema.safeParse("invalid");
|
||||
assert.strictEqual(result.success, false);
|
||||
});
|
||||
|
||||
test("CreateCloudAgentTaskSchema validates valid input", () => {
|
||||
const validInput = {
|
||||
providerId: "jules",
|
||||
prompt: "Fix the bug in auth.ts",
|
||||
source: {
|
||||
repoName: "my-repo",
|
||||
repoUrl: "https://github.com/user/my-repo",
|
||||
branch: "main",
|
||||
},
|
||||
options: {
|
||||
autoCreatePr: true,
|
||||
planApprovalRequired: true,
|
||||
},
|
||||
};
|
||||
const result = CreateCloudAgentTaskSchema.safeParse(validInput);
|
||||
assert.strictEqual(result.success, true);
|
||||
});
|
||||
|
||||
test("CreateCloudAgentTaskSchema rejects missing required fields", () => {
|
||||
const invalidInput = {
|
||||
providerId: "jules",
|
||||
};
|
||||
const result = CreateCloudAgentTaskSchema.safeParse(invalidInput);
|
||||
assert.strictEqual(result.success, false);
|
||||
});
|
||||
|
||||
test("CreateCloudAgentTaskSchema rejects invalid providerId", () => {
|
||||
const invalidInput = {
|
||||
providerId: "invalid-provider",
|
||||
prompt: "Test",
|
||||
source: {
|
||||
repoName: "test",
|
||||
repoUrl: "https://github.com/test/test",
|
||||
},
|
||||
};
|
||||
const result = CreateCloudAgentTaskSchema.safeParse(invalidInput);
|
||||
assert.strictEqual(result.success, false);
|
||||
});
|
||||
|
||||
test("CreateCloudAgentTaskSchema rejects invalid repoUrl", () => {
|
||||
const invalidInput = {
|
||||
providerId: "jules",
|
||||
prompt: "Test",
|
||||
source: {
|
||||
repoName: "test",
|
||||
repoUrl: "not-a-url",
|
||||
},
|
||||
};
|
||||
const result = CreateCloudAgentTaskSchema.safeParse(invalidInput);
|
||||
assert.strictEqual(result.success, false);
|
||||
});
|
||||
|
||||
test("UpdateCloudAgentTaskSchema validates approve action", () => {
|
||||
const validInput = {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
action: "approve",
|
||||
};
|
||||
const result = UpdateCloudAgentTaskSchema.safeParse(validInput);
|
||||
assert.strictEqual(result.success, true);
|
||||
});
|
||||
|
||||
test("UpdateCloudAgentTaskSchema validates cancel action", () => {
|
||||
const validInput = {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
action: "cancel",
|
||||
};
|
||||
const result = UpdateCloudAgentTaskSchema.safeParse(validInput);
|
||||
assert.strictEqual(result.success, true);
|
||||
});
|
||||
|
||||
test("UpdateCloudAgentTaskSchema validates message action with content", () => {
|
||||
const validInput = {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
action: "message",
|
||||
message: "Please continue with the next step",
|
||||
};
|
||||
const result = UpdateCloudAgentTaskSchema.safeParse(validInput);
|
||||
assert.strictEqual(result.success, true);
|
||||
});
|
||||
|
||||
test("UpdateCloudAgentTaskSchema allows message without content (optional)", () => {
|
||||
const validInput = {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
action: "message",
|
||||
};
|
||||
const result = UpdateCloudAgentTaskSchema.safeParse(validInput);
|
||||
assert.strictEqual(result.success, true);
|
||||
});
|
||||
|
||||
test("getAvailableAgents returns all registered agents", () => {
|
||||
const agents = getAvailableAgents();
|
||||
assert.strictEqual(agents.includes("jules"), true);
|
||||
assert.strictEqual(agents.includes("devin"), true);
|
||||
assert.strictEqual(agents.includes("codex-cloud"), true);
|
||||
assert.strictEqual(agents.length, 3);
|
||||
});
|
||||
|
||||
test("getAgent returns correct agent for valid providerId", () => {
|
||||
const jules = getAgent("jules");
|
||||
assert.notStrictEqual(jules, null);
|
||||
assert.strictEqual(jules?.providerId, "jules");
|
||||
|
||||
const devin = getAgent("devin");
|
||||
assert.notStrictEqual(devin, null);
|
||||
assert.strictEqual(devin?.providerId, "devin");
|
||||
|
||||
const codex = getAgent("codex-cloud");
|
||||
assert.notStrictEqual(codex, null);
|
||||
assert.strictEqual(codex?.providerId, "codex-cloud");
|
||||
});
|
||||
|
||||
test("getAgent returns null for invalid providerId", () => {
|
||||
const result = getAgent("invalid-provider");
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test("isCloudAgentProvider returns true for valid providers", () => {
|
||||
assert.strictEqual(isCloudAgentProvider("jules"), true);
|
||||
assert.strictEqual(isCloudAgentProvider("devin"), true);
|
||||
assert.strictEqual(isCloudAgentProvider("codex-cloud"), true);
|
||||
});
|
||||
|
||||
test("isCloudAgentProvider returns false for invalid providers", () => {
|
||||
assert.strictEqual(isCloudAgentProvider("openai"), false);
|
||||
assert.strictEqual(isCloudAgentProvider("anthropic"), false);
|
||||
assert.strictEqual(isCloudAgentProvider("invalid"), false);
|
||||
});
|
||||
Reference in New Issue
Block a user