Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/supercode-cli/server/src/lib/composio.ts
Original file line number Diff line number Diff line change
@@ -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) {}
Expand Down Expand Up @@ -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),
}))
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<NovaApp
initialSessions={sessions}
initialView="automations"
user={{
id: session.user.id,
name: session.user.name,
email: session.user.email,
image: session.user.image,
}}
/>
)
}
3 changes: 3 additions & 0 deletions apps/web/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -21,6 +22,8 @@ export const { GET, POST, PUT } = serve({
functions: [
indexRepo,
generateReview,
processNovaAutomation,
scheduleNovaAutomations,
processNovaInboundEvent,
processNovaRun,
processNovaApprovalDecision,
Expand Down
10 changes: 10 additions & 0 deletions apps/web/app/api/nova/automations/[id]/credential/route.ts
Original file line number Diff line number Diff line change
@@ -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) }
}
24 changes: 24 additions & 0 deletions apps/web/app/api/nova/automations/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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) }
}
15 changes: 15 additions & 0 deletions apps/web/app/api/nova/automations/[id]/test/route.ts
Original file line number Diff line number Diff line change
@@ -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) }
}
25 changes: 25 additions & 0 deletions apps/web/app/api/nova/automations/[id]/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -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) }
}
19 changes: 19 additions & 0 deletions apps/web/app/api/nova/automations/route.ts
Original file line number Diff line number Diff line change
@@ -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) }
}
13 changes: 10 additions & 3 deletions apps/web/app/api/nova/webhooks/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -23,17 +25,22 @@ 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,
organizationId: installation.organizationId,
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() },
Expand Down
8 changes: 6 additions & 2 deletions apps/web/app/api/nova/webhooks/linear/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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() },
Expand Down
8 changes: 6 additions & 2 deletions apps/web/app/api/nova/webhooks/slack/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -15,9 +17,11 @@ async function processSlackEvent(envelope: NonNullable<ReturnType<typeof parseSl
select: { id: true, organizationId: true, botExternalUserId: true },
})
if (!installation) return
const automationEvent = normalizeAutomationProviderEvent("slack", envelope, { ...installation, installationId: installation.id, deliveryId: envelope.event_id ?? "" })
if (automationEvent) await dispatchAutomationEvent(automationEvent)
const event = normalizeSlackEvent({ envelope, ...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() },
Expand Down
32 changes: 32 additions & 0 deletions apps/web/inngest/functions/automations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { inngest } from "@/inngest/client"
import {
claimAutomationRun, classifyAutomationRun, createAutomationSession,
executeAutomationRun, failAutomationRun, reconcileAutomations,
} from "@/modules/nova/automations/runner"

export const processNovaAutomation = inngest.createFunction({
id: "nova-process-automation",
concurrency: [{ limit: 1, key: "event.data.automationId" }, { limit: 10 }],
retries: 2,
onFailure: async ({ event }) => {
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())))
})
Loading
Loading