From 2aa2ff923e695f2fa70c0fe11473b9439a9a961d Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Tue, 13 Oct 2026 11:12:57 +0530 Subject: [PATCH 1/2] feat: implement Nova automation features and enhance API integrations: - Introduced new API routes for managing automations, including creation, listing, updating, and deletion. - Added support for webhook triggers and automation event processing, allowing for dynamic responses to external events. - Implemented automation context retrieval and user authorization checks to ensure secure access to automation functionalities. - Enhanced automation input validation and error handling for improved reliability and user experience. - Created utility functions for managing automation templates and triggers, streamlining the automation setup process. - Integrated automation event normalization for Slack, GitHub, and Linear, enabling seamless interaction with external services. --- apps/supercode-cli/server/src/lib/composio.ts | 11 +- .../automations/[[...segments]]/page.tsx | 23 ++ apps/web/app/api/inngest/route.ts | 3 + .../nova/automations/[id]/credential/route.ts | 10 + .../app/api/nova/automations/[id]/route.ts | 24 ++ .../api/nova/automations/[id]/test/route.ts | 15 + .../nova/automations/[id]/webhook/route.ts | 25 ++ apps/web/app/api/nova/automations/route.ts | 19 ++ .../web/app/api/nova/webhooks/github/route.ts | 13 +- .../web/app/api/nova/webhooks/linear/route.ts | 8 +- apps/web/app/api/nova/webhooks/slack/route.ts | 8 +- apps/web/inngest/functions/automations.ts | 32 +++ .../nova-web/components/automations.tsx | 264 ++++++++++++++++++ .../modules/nova-web/components/sidebar.tsx | 12 +- apps/web/modules/nova-web/nova-app.tsx | 34 ++- .../web/modules/nova-web/resolve-mode.test.ts | 37 +++ apps/web/modules/nova-web/resolve-mode.ts | 82 ++++++ apps/web/modules/nova-web/types.ts | 1 + .../web/modules/nova/automations/contracts.ts | 121 ++++++++ apps/web/modules/nova/automations/http.ts | 26 ++ .../web/modules/nova/automations/providers.ts | 54 ++++ apps/web/modules/nova/automations/runner.ts | 179 ++++++++++++ apps/web/modules/nova/automations/security.ts | 39 +++ apps/web/modules/nova/automations/service.ts | 109 ++++++++ .../web/modules/nova/automations/templates.ts | 75 +++++ apps/web/modules/nova/harness/agent.test.ts | 66 ++++- apps/web/modules/nova/harness/agent.ts | 154 ++++++++-- apps/web/modules/nova/harness/auth.ts | 3 +- .../nova/harness/native-github-tools.ts | 168 +++++++++++ .../modules/nova/harness/tool-policy.test.ts | 29 ++ apps/web/modules/nova/harness/tool-policy.ts | 54 ++++ apps/web/modules/nova/sessions/turn.ts | 14 + .../migration.sql | 48 ++++ packages/db/prisma/schema.prisma | 56 ++++ 34 files changed, 1758 insertions(+), 58 deletions(-) create mode 100644 apps/web/app/(nova-app)/nova-app-internal/automations/[[...segments]]/page.tsx create mode 100644 apps/web/app/api/nova/automations/[id]/credential/route.ts create mode 100644 apps/web/app/api/nova/automations/[id]/route.ts create mode 100644 apps/web/app/api/nova/automations/[id]/test/route.ts create mode 100644 apps/web/app/api/nova/automations/[id]/webhook/route.ts create mode 100644 apps/web/app/api/nova/automations/route.ts create mode 100644 apps/web/inngest/functions/automations.ts create mode 100644 apps/web/modules/nova-web/components/automations.tsx create mode 100644 apps/web/modules/nova-web/resolve-mode.test.ts create mode 100644 apps/web/modules/nova-web/resolve-mode.ts create mode 100644 apps/web/modules/nova/automations/contracts.ts create mode 100644 apps/web/modules/nova/automations/http.ts create mode 100644 apps/web/modules/nova/automations/providers.ts create mode 100644 apps/web/modules/nova/automations/runner.ts create mode 100644 apps/web/modules/nova/automations/security.ts create mode 100644 apps/web/modules/nova/automations/service.ts create mode 100644 apps/web/modules/nova/automations/templates.ts create mode 100644 apps/web/modules/nova/harness/native-github-tools.ts create mode 100644 apps/web/modules/nova/harness/tool-policy.test.ts create mode 100644 apps/web/modules/nova/harness/tool-policy.ts create mode 100644 packages/db/prisma/migrations/20261013051000_nova_automations/migration.sql diff --git a/apps/supercode-cli/server/src/lib/composio.ts b/apps/supercode-cli/server/src/lib/composio.ts index b44ec407..deaceb3a 100644 --- a/apps/supercode-cli/server/src/lib/composio.ts +++ b/apps/supercode-cli/server/src/lib/composio.ts @@ -1,6 +1,10 @@ import { Composio } from "@composio/core" -const READ_ONLY_TOOL_PATTERN = /(^|_)(CHECK|DESCRIBE|DOWNLOAD|FETCH|FIND|GET|LIST|LOOKUP|QUERY|READ|RETRIEVE|SEARCH|VIEW)(_|$)/i +// Broad read classification so GitHub/Linear list+get tools aren't marked write-only. +const READ_ONLY_TOOL_PATTERN = + /(^|_)(CHECK|DESCRIBE|DOWNLOAD|FETCH|FIND|GET|LIST|LOOKUP|QUERY|READ|RETRIEVE|SEARCH|VIEW|SHOW|COUNT|STATS|STATUS|COMPARE|DIFF|TREE|BLOB|CONTENTS|COMMITS?|FILES?|BRANCHES|REPOS?|ISSUES?|PULLS?|REVIEWS?|COMMENTS?|WORKFLOWS?|RUNS?|ARTIFACTS?|MEMBERS?|ORGS?|USERS?|ME|META|INFO|ACTIVITY|TIMELINE|MERGED)(_|$)/i +const WRITE_TOOL_PATTERN = + /(^|_)(CREATE|UPDATE|DELETE|REMOVE|ADD|SET|SEND|POST|PUT|PATCH|PUSH|MERGE|CLOSE|REOPEN|ASSIGN|UPLOAD|WRITE|EDIT|INSERT|DESTROY|REVOKE|CANCEL|TRIGGER|DISPATCH|FORK|STAR|ENABLE|DISABLE|INSTALL|PUBLISH|DEPLOY|RELEASE|TRANSFER|COMMENT|REPLY|REPLACE)(_|$)/i export class ServerComposioService { constructor(private client?: Composio) {} @@ -127,7 +131,10 @@ export class ServerComposioService { parameters: tool.inputParameters ?? { type: "object", properties: {} }, toolkit: tool.toolkit?.slug, toolkitName: tool.toolkit?.name, - requiresApproval: !READ_ONLY_TOOL_PATTERN.test(tool.slug), + // Write tokens win; otherwise treat matching read tokens as no-approval. + requiresApproval: WRITE_TOOL_PATTERN.test(tool.slug) + ? true + : !READ_ONLY_TOOL_PATTERN.test(tool.slug), })) } diff --git a/apps/web/app/(nova-app)/nova-app-internal/automations/[[...segments]]/page.tsx b/apps/web/app/(nova-app)/nova-app-internal/automations/[[...segments]]/page.tsx new file mode 100644 index 00000000..b62bfc91 --- /dev/null +++ b/apps/web/app/(nova-app)/nova-app-internal/automations/[[...segments]]/page.tsx @@ -0,0 +1,23 @@ +import { requireAuth } from "@/modules/components/utils/auth-utils" +import { NovaApp } from "@/modules/nova-web/nova-app" +import { listAgentSessions } from "@/modules/nova/sessions/service" + +export const dynamic = "force-dynamic" + +export default async function NovaAutomationsPage() { + const session = await requireAuth() + const sessions = await listAgentSessions(session.user.id) + + return ( + + ) +} diff --git a/apps/web/app/api/inngest/route.ts b/apps/web/app/api/inngest/route.ts index 595fab9a..c6c3e9f5 100644 --- a/apps/web/app/api/inngest/route.ts +++ b/apps/web/app/api/inngest/route.ts @@ -1,4 +1,5 @@ import { serve } from "inngest/next" +import { processNovaAutomation, scheduleNovaAutomations } from "@/inngest/functions/automations" import { inngest } from "../../../inngest/client" import { indexRepo } from "@/inngest/functions" import { generateReview } from "@/inngest/functions/ai-review" @@ -21,6 +22,8 @@ export const { GET, POST, PUT } = serve({ functions: [ indexRepo, generateReview, + processNovaAutomation, + scheduleNovaAutomations, processNovaInboundEvent, processNovaRun, processNovaApprovalDecision, diff --git a/apps/web/app/api/nova/automations/[id]/credential/route.ts b/apps/web/app/api/nova/automations/[id]/credential/route.ts new file mode 100644 index 00000000..fa3447e7 --- /dev/null +++ b/apps/web/app/api/nova/automations/[id]/credential/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server" + +import { automationFailure, automationUser } from "@/modules/nova/automations/http" +import { rotateAutomationWebhook } from "@/modules/nova/automations/service" + +export async function POST(_request: Request, context: { params: Promise<{ id: string }> }) { + try { + return NextResponse.json(await rotateAutomationWebhook(await automationUser(), (await context.params).id), { headers: { "Cache-Control": "no-store" } }) + } catch (error) { return automationFailure(error) } +} diff --git a/apps/web/app/api/nova/automations/[id]/route.ts b/apps/web/app/api/nova/automations/[id]/route.ts new file mode 100644 index 00000000..38313136 --- /dev/null +++ b/apps/web/app/api/nova/automations/[id]/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server" + +import { automationBody, automationFailure, automationUser } from "@/modules/nova/automations/http" +import { deleteAutomation, getAutomation, saveAutomation } from "@/modules/nova/automations/service" + +type Context = { params: Promise<{ id: string }> } + +export async function GET(_request: Request, context: Context) { + try { return NextResponse.json({ automation: (await getAutomation(await automationUser(), (await context.params).id)).dto }) } catch (error) { return automationFailure(error) } +} + +export async function PUT(request: Request, context: Context) { + try { + const userId = await automationUser() + return NextResponse.json({ automation: await saveAutomation(userId, await automationBody(request), (await context.params).id) }) + } catch (error) { return automationFailure(error) } +} + +export async function DELETE(_request: Request, context: Context) { + try { + await deleteAutomation(await automationUser(), (await context.params).id) + return NextResponse.json({ ok: true }) + } catch (error) { return automationFailure(error) } +} diff --git a/apps/web/app/api/nova/automations/[id]/test/route.ts b/apps/web/app/api/nova/automations/[id]/test/route.ts new file mode 100644 index 00000000..db4cb06a --- /dev/null +++ b/apps/web/app/api/nova/automations/[id]/test/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server" +import { z } from "zod" + +import { automationBody, automationFailure, automationUser } from "@/modules/nova/automations/http" +import { requestAutomationTest } from "@/modules/nova/automations/runner" + +const schema = z.object({ text: z.string().trim().min(1).max(24_000), deliveryId: z.string().uuid() }) + +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { + try { + const userId = await automationUser() + const input = schema.parse(await automationBody(request)) + return NextResponse.json({ run: await requestAutomationTest(userId, (await context.params).id, input.text, input.deliveryId) }, { status: 202 }) + } catch (error) { return automationFailure(error) } +} diff --git a/apps/web/app/api/nova/automations/[id]/webhook/route.ts b/apps/web/app/api/nova/automations/[id]/webhook/route.ts new file mode 100644 index 00000000..d0d243e1 --- /dev/null +++ b/apps/web/app/api/nova/automations/[id]/webhook/route.ts @@ -0,0 +1,25 @@ +import prisma from "@super/db" +import { NextResponse } from "next/server" + +import { automationBody, automationFailure } from "@/modules/nova/automations/http" +import { automationInputSchema } from "@/modules/nova/automations/contracts" +import { queueAutomation } from "@/modules/nova/automations/runner" +import { verifyWebhookToken } from "@/modules/nova/automations/security" + +export const runtime = "nodejs" + +export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params + const automation = await prisma.novaAutomation.findUnique({ where: { id } }) + if (!automation || !verifyWebhookToken(request.headers.get("authorization"), automation.webhookTokenHash)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + if (automation.status !== "active" || !automationInputSchema.parse({ ...automation, postAsRunAs: false }).triggers.some((trigger) => trigger.type === "webhook")) return NextResponse.json({ error: "Webhook is disabled" }, { status: 409 }) + const deliveryId = request.headers.get("x-delivery-id") + if (!deliveryId || !/^[A-Za-z0-9._:-]{1,200}$/.test(deliveryId)) return NextResponse.json({ error: "A unique X-Delivery-Id header is required" }, { status: 400 }) + const payload = await automationBody(request) + const text = JSON.stringify(payload) + if (text.length > 24_000) return NextResponse.json({ error: "Payload too large" }, { status: 413 }) + const run = await queueAutomation(id, { organizationId: automation.organizationId, type: "webhook", deliveryId: `webhook:${deliveryId}`, text }) + return NextResponse.json({ accepted: true, runId: run?.id }, { status: 202 }) + } catch (error) { return automationFailure(error) } +} diff --git a/apps/web/app/api/nova/automations/route.ts b/apps/web/app/api/nova/automations/route.ts new file mode 100644 index 00000000..c3c512fb --- /dev/null +++ b/apps/web/app/api/nova/automations/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server" + +import { automationBody, automationFailure, automationUser } from "@/modules/nova/automations/http" +import { getAutomationContext, listAutomations, saveAutomation } from "@/modules/nova/automations/service" + +export async function GET() { + try { + const userId = await automationUser() + const [automations, context] = await Promise.all([listAutomations(userId), getAutomationContext(userId)]) + return NextResponse.json({ automations, context }) + } catch (error) { return automationFailure(error) } +} + +export async function POST(request: Request) { + try { + const userId = await automationUser() + return NextResponse.json({ automation: await saveAutomation(userId, await automationBody(request)) }, { status: 201 }) + } catch (error) { return automationFailure(error) } +} diff --git a/apps/web/app/api/nova/webhooks/github/route.ts b/apps/web/app/api/nova/webhooks/github/route.ts index a6553684..9e58157d 100644 --- a/apps/web/app/api/nova/webhooks/github/route.ts +++ b/apps/web/app/api/nova/webhooks/github/route.ts @@ -2,6 +2,8 @@ import prisma from "@super/db" import { NextResponse } from "next/server" import { ingestNormalizedEvent } from "@/modules/nova/events/service" +import { normalizeAutomationProviderEvent } from "@/modules/nova/automations/providers" +import { dispatchAutomationEvent } from "@/modules/nova/automations/runner" import { normalizeGitHubEvent, parseGitHubEnvelope } from "@/modules/nova/providers/github" import { verifyGitHubSignature } from "@/modules/nova/providers/signatures" @@ -23,8 +25,13 @@ async function processGitHubEvent(input: { select: { id: true, organizationId: true, botExternalUserId: true }, }) if (!installation) return + const automationEvent = normalizeAutomationProviderEvent("github", input.envelope, { ...installation, installationId: installation.id, deliveryId: input.deliveryId }, input.eventName) + if (automationEvent) await dispatchAutomationEvent(automationEvent) const appSlug = process.env.NOVA_GITHUB_APP_SLUG?.trim() - if (!appSlug) throw new Error("NOVA_GITHUB_APP_SLUG is not configured") + if (!appSlug) { + if (automationEvent) return + throw new Error("NOVA_GITHUB_APP_SLUG is not configured") + } const event = normalizeGitHubEvent({ ...input, installationId: installation.id, @@ -32,8 +39,8 @@ async function processGitHubEvent(input: { botExternalUserId: installation.botExternalUserId, appSlug, }) - if (!event) return - await ingestNormalizedEvent(event) + if (event) await ingestNormalizedEvent(event) + if (!event && !automationEvent) return await prisma.externalInstallation.update({ where: { id: installation.id }, data: { webhookStatus: "healthy", lastEventAt: new Date(), lastHealthCheckAt: new Date() }, diff --git a/apps/web/app/api/nova/webhooks/linear/route.ts b/apps/web/app/api/nova/webhooks/linear/route.ts index 1078219b..af5f6c46 100644 --- a/apps/web/app/api/nova/webhooks/linear/route.ts +++ b/apps/web/app/api/nova/webhooks/linear/route.ts @@ -2,6 +2,8 @@ import prisma from "@super/db" import { NextResponse } from "next/server" import { ingestNormalizedEvent } from "@/modules/nova/events/service" +import { normalizeAutomationProviderEvent } from "@/modules/nova/automations/providers" +import { dispatchAutomationEvent } from "@/modules/nova/automations/runner" import { normalizeLinearEvent, parseLinearEnvelope } from "@/modules/nova/providers/linear" import { verifyLinearSignature } from "@/modules/nova/providers/signatures" @@ -21,14 +23,16 @@ async function processLinearEvent( select: { id: true, organizationId: true, botExternalUserId: true }, }) if (!installation) return + const automationEvent = normalizeAutomationProviderEvent("linear", envelope, { ...installation, installationId: installation.id, deliveryId }) + if (automationEvent) await dispatchAutomationEvent(automationEvent) const event = normalizeLinearEvent({ envelope, deliveryId, ...installation, installationId: installation.id, }) - if (!event) return - await ingestNormalizedEvent(event) + if (event) await ingestNormalizedEvent(event) + if (!event && !automationEvent) return await prisma.externalInstallation.update({ where: { id: installation.id }, data: { webhookStatus: "healthy", lastEventAt: new Date(), lastHealthCheckAt: new Date() }, diff --git a/apps/web/app/api/nova/webhooks/slack/route.ts b/apps/web/app/api/nova/webhooks/slack/route.ts index 42d5347b..dfd68c9f 100644 --- a/apps/web/app/api/nova/webhooks/slack/route.ts +++ b/apps/web/app/api/nova/webhooks/slack/route.ts @@ -2,6 +2,8 @@ import prisma from "@super/db" import { NextResponse } from "next/server" import { ingestNormalizedEvent } from "@/modules/nova/events/service" +import { normalizeAutomationProviderEvent } from "@/modules/nova/automations/providers" +import { dispatchAutomationEvent } from "@/modules/nova/automations/runner" import { normalizeSlackEvent, parseSlackEnvelope } from "@/modules/nova/providers/slack" import { verifySlackSignature } from "@/modules/nova/providers/signatures" @@ -15,9 +17,11 @@ async function processSlackEvent(envelope: NonNullable { + const runId = event.data.event.data.runId as string | undefined + if (runId) await failAutomationRun(runId) + }, +}, { event: "nova/automation.run" }, async ({ event, step }) => { + const runId = event.data.runId as string | undefined + if (!runId) return { ignored: true } + const claimed = await step.run("reserve-run-budget", () => claimAutomationRun(runId)) + if (["completed", "filtered", "failed", "skipped", "replayed"].includes(claimed.status)) return claimed + const shouldRun = await step.run("evaluate-run-criteria", () => classifyAutomationRun(runId)) + if (!shouldRun) return { status: "filtered" } + const sessionId = await step.run("create-nova-thread", () => createAutomationSession(runId)) + await step.run("execute-nova-automation", () => executeAutomationRun(runId)) + return { status: "completed", sessionId } +}) + +export const scheduleNovaAutomations = inngest.createFunction({ + id: "nova-schedule-automations", retries: 2, concurrency: { limit: 1 }, +}, { cron: "*/1 * * * *" }, async ({ event, step }) => { + // Use the event timestamp, not worker wall clock, to keep retries in the same slot. + return step.run("dispatch-scheduled-and-pending", () => reconcileAutomations(new Date(event.ts ?? Date.now()))) +}) diff --git a/apps/web/modules/nova-web/components/automations.tsx b/apps/web/modules/nova-web/components/automations.tsx new file mode 100644 index 00000000..5015c7e4 --- /dev/null +++ b/apps/web/modules/nova-web/components/automations.tsx @@ -0,0 +1,264 @@ +"use client" + +import { useCallback, useEffect, useState, type ReactNode } from "react" +import { ChevronRight, Clock3, Github, Globe, Inbox, Loader2, Pause, Play, Plus, Search, SlidersHorizontal, Trash2, X, Zap } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ALL_MODELS } from "@/modules/nova-web/models" +import { + automationInputSchema, defaultTrigger, TRIGGER_LABELS, + type AutomationContext, type AutomationDto, type AutomationInput, type AutomationTrigger, type TriggerType, +} from "@/modules/nova/automations/contracts" +import { AUTOMATION_TEMPLATES, templateDraft } from "@/modules/nova/automations/templates" + +const button = "inline-flex h-7 items-center justify-center gap-1.5 rounded-md border border-white/10 bg-white/[0.025] px-2.5 text-xs text-neutral-300 transition-colors hover:bg-white/[0.07] disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-400" +const field = "w-full rounded-lg border border-white/10 bg-white/[0.025] px-3.5 py-3 text-[13px] leading-[1.3] text-neutral-200 outline-none placeholder:text-neutral-600 focus:border-neutral-500 disabled:opacity-60" +const chip = "h-6 max-w-full rounded-md border-0 bg-white/[0.08] px-2 text-xs text-neutral-200 outline-none focus-visible:ring-1 focus-visible:ring-neutral-400" + +async function request(path: string, method = "GET", body?: unknown, signal?: AbortSignal): Promise { + const response = await fetch(`/api/nova/automations${path}`, { method, headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal, cache: "no-store" }) + const data = await response.json() + if (!response.ok) throw new Error(data.error ?? "Could not load automations") + return data as T +} + +type AutomationApp = { slug: string; name: string; logo: string | null; connected: boolean } + +function ProviderIcon({ provider, apps }: { provider: string; apps: AutomationApp[] }) { + const logo = apps.find((app) => app.slug === provider.toLowerCase())?.logo + const [failedLogo, setFailedLogo] = useState(null) + if (logo && logo !== failedLogo) { + // eslint-disable-next-line @next/next/no-img-element + return setFailedLogo(logo)} /> + } + if (provider === "Schedule") return + if (provider === "Webhook") return + return +} + +function providerFor(type: TriggerType): string { + return type.startsWith("slack") ? "Slack" : type.startsWith("github") ? "GitHub" : type.startsWith("linear") ? "Linear" : type === "schedule" ? "Schedule" : "Webhook" +} + +export function AutomationsView({ path, onNavigate }: { path: string; onNavigate: (href: string) => void }) { + const clean = path.replace("/nova-app-internal", "") + const segment = clean.split("/")[2] + const creating = segment === "new" + const id = segment && !creating ? segment : null + const templateId = typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("template") + const [context, setContext] = useState(null) + const [automations, setAutomations] = useState([]) + const [selected, setSelected] = useState(null) + const [draft, setDraft] = useState(null) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [tab, setTab] = useState("mine") + const [query, setQuery] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + const [filtersOpen, setFiltersOpen] = useState(false) + const [addingTrigger, setAddingTrigger] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const [testOpen, setTestOpen] = useState(false) + const [testText, setTestText] = useState("") + const [credential, setCredential] = useState<{ token: string; path: string } | null>(null) + const [apps, setApps] = useState([]) + const [appsLoaded, setAppsLoaded] = useState(false) + const [appsError, setAppsError] = useState(false) + + useEffect(() => { + const controller = new AbortController() + const loadApps = async () => { + try { + const response = await fetch("/api/nova/composio/apps", { signal: controller.signal, cache: "no-store" }) + if (!response.ok) throw new Error("Could not load Composio apps") + const payload = await response.json() as { apps: AutomationApp[] } + if (controller.signal.aborted) return + setApps(payload.apps) + setAppsLoaded(true) + setAppsError(false) + } catch { + if (!controller.signal.aborted) setAppsError(true) + } + } + void loadApps() + window.addEventListener("focus", loadApps) + return () => { controller.abort(); window.removeEventListener("focus", loadApps) } + }, []) + + const refresh = useCallback(async (signal?: AbortSignal, reset = false) => { + const data = await request<{ automations: AutomationDto[]; context: AutomationContext }>("", "GET", undefined, signal) + const detail = id ? (await request<{ automation: AutomationDto }>(`/${id}`, "GET", undefined, signal)).automation : null + setContext(data.context) + setAutomations(data.automations) + setSelected(detail) + if (reset) setDraft(detail ?? (creating ? templateDraft(templateId, data.context.membershipId) : null)) + }, [creating, id, templateId]) + + useEffect(() => { + const controller = new AbortController() + void refresh(controller.signal, true).catch((failure) => { + if (!controller.signal.aborted) setError(failure instanceof Error ? failure.message : "Could not load automations") + }).finally(() => { if (!controller.signal.aborted) setLoading(false) }) + return () => controller.abort() + }, [refresh]) + + useEffect(() => { + if (!id) return + const controller = new AbortController() + const timer = window.setInterval(() => { void refresh(controller.signal).catch(() => undefined) }, 8_000) + return () => { window.clearInterval(timer); controller.abort() } + }, [id, refresh]) + + async function action(work: () => Promise) { + if (busy) return + setBusy(true) + setError(null) + setNotice(null) + try { await work() } catch (failure) { setError(failure instanceof Error ? failure.message : "Request failed") } finally { setBusy(false) } + } + + function update(key: K, value: AutomationInput[K]) { + setDraft((current) => current ? { ...current, [key]: value } : current) + } + + function save() { + if (!draft) return + const parsed = automationInputSchema.safeParse(draft) + if (!parsed.success) { setError(parsed.error.issues[0]?.message ?? "Check the form"); return } + void action(async () => { + const data = await request<{ automation: AutomationDto }>(id ? `/${id}` : "", id ? "PUT" : "POST", parsed.data) + if (!id) onNavigate(`/automations/${data.automation.id}`) + else { setSelected(data.automation); setDraft(data.automation); setNotice("Automation saved") } + }) + } + + const requiredProviders = draft + ? [...new Set([...draft.triggers.map((trigger) => providerFor(trigger.type)), ...(draft.repositoryIds.length ? ["GitHub"] : [])])].filter((provider) => ["Slack", "GitHub", "Linear"].includes(provider)) + : segment ? [] : ["Slack", "GitHub", "Linear"] + const missingApps = appsLoaded && !appsError + ? requiredProviders.filter((provider) => !apps.some((app) => app.slug === provider.toLowerCase() && app.connected)) + : [] + const editable = creating || Boolean(selected?.canManage) + const filtered = automations.filter((item) => (tab !== "mine" || item.creatorMembershipId === context?.membershipId) && (tab !== "scheduled" || item.triggers.some((trigger) => trigger.type === "schedule")) && (statusFilter === "all" || item.status === statusFilter) && `${item.name} ${item.instructions}`.toLowerCase().includes(query.toLowerCase())) + + return ( +
+
+ + {segment ? <>{creating ? "New automation" : selected?.name ?? "Automation"} : null} +
+ {segment ? <> : } +
+
+
+ {appsError || missingApps.length > 0 ? ( +
+
+

{appsError ? "Could not verify your Composio connections" : "Connect your apps first"}

+

{appsError ? "Open Connections to check your apps before running this automation." : `Connect ${missingApps.join(", ")} through Composio in Connections ${draft ? "before running this automation" : "to use their automation templates"}.`}

+
+ +
+ ) : null} + {error ?
{error}
: null} + {notice ?

{notice}

: null} + {loading ?
Loading automations…
: segment && draft && context ? ( +
+ update("name", event.target.value)} className="w-full bg-transparent text-[22px] font-medium tracking-[-0.025em] outline-none focus-visible:ring-1 focus-visible:ring-neutral-500" maxLength={160} /> +
+ {context.organizationName} + +
+
+

Post as run-as person

Delegated Slack posting is not supported yet. Results stay in Nova.

+ +
+
setAddingTrigger((value) => !value)}>Add trigger : undefined}> + {addingTrigger ?
{(Object.keys(TRIGGER_LABELS) as TriggerType[]).map((type) => )}
: null} +
{draft.triggers.map((trigger, index) => update("triggers", draft.triggers.map((item, position) => position === index ? next : item))} onRemove={() => update("triggers", draft.triggers.filter((_, position) => position !== index))} />)}
+
+
+
+