Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ea3ebac
feat(api): one webhook runner for every trigger provider
saddlepaddle Aug 18, 2026
31bdc6e
refactor(api,trpc,desktop): shared trigger tail, verify helpers, one …
saddlepaddle Aug 18, 2026
e05e2dd
refactor(api): circleback + raw webhook on ingestAutomationEvent, ver…
saddlepaddle Aug 18, 2026
fdb33ad
refactor(api,trpc): move GitHub and Linear onto ingestAutomationEvent…
saddlepaddle Aug 18, 2026
e08047c
refactor(api,trpc,desktop): migrate Teams and Google to ingestAutomat…
saddlepaddle Aug 18, 2026
5bb133c
Merge remote-tracking branch 'origin/migrate/github-linear' into feat…
saddlepaddle Aug 18, 2026
a805f39
Merge remote-tracking branch 'origin/migrate/teams-google' into feat/…
saddlepaddle Aug 18, 2026
c8b97a7
refactor(api,trpc): migrate Slack and Notion triggers onto ingestAuto…
saddlepaddle Aug 18, 2026
37cc5c3
Merge remote-tracking branch 'origin/migrate/slack-notion' into feat/…
saddlepaddle Aug 18, 2026
22fa22c
refactor(api,trpc): every provider on the shared tail; dispatchInput …
saddlepaddle Aug 18, 2026
ca56ad0
refactor(automations): drop per-trigger enabled; tie the kind enum to…
saddlepaddle Aug 18, 2026
ef0df77
refactor(automations): schedules are repeating only — no finite rules…
saddlepaddle Aug 18, 2026
c1744a2
Merge remote-tracking branch 'origin/main' into feat/provider-webhook…
saddlepaddle Aug 18, 2026
3045731
refactor(microsoft-teams): replace hand-rolled Graph client with offi…
saddlepaddle Aug 19, 2026
f65e1c0
refactor(google): replace hand-rolled HTTP client with official Googl…
saddlepaddle Aug 19, 2026
b70b19f
Merge remote-tracking branch 'origin/cleanup/google-sdk' into feat/pr…
saddlepaddle Aug 19, 2026
94731f6
refactor(integrations): one roster, full desktop pane, shared Sentenc…
saddlepaddle Aug 19, 2026
ccf2818
Merge remote-tracking branch 'origin/cleanup/ui-surfaces' into feat/p…
saddlepaddle Aug 19, 2026
4aecfc3
refactor(integrations): dedupe OAuth connect/callback flows and conne…
saddlepaddle Aug 19, 2026
327cb83
Merge remote-tracking branch 'origin/cleanup/oauth-connections' into …
saddlepaddle Aug 19, 2026
38adb95
refactor(automations): unify actor into scope, drop null scopes and i…
saddlepaddle Aug 19, 2026
0ff3ae1
fix(automations): address review — sweep on quiet ticks, mark zero-ca…
saddlepaddle Aug 19, 2026
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
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@better-auth/oauth-provider": "1.6.22",
"@linear/sdk": "68.1.1",
"@modelcontextprotocol/sdk": "1.30.0",
"@notionhq/client": "5.25.2",
"@octokit/app": "16.1.2",
"@octokit/rest": "22.0.1",
"@octokit/webhooks": "14.2.0",
Expand Down Expand Up @@ -48,6 +49,7 @@
"zod": "4.4.3"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "2.43.1",
"@superset/typescript": "workspace:*",
"@types/lodash.chunk": "4.2.9",
"@types/node": "24.12.0",
Expand Down
25 changes: 6 additions & 19 deletions apps/api/src/app/api/automations/dispatch/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,26 @@
import { dbWs } from "@superset/db/client";
import { automations } from "@superset/db/schema";
import { dispatchAutomation } from "@superset/trpc/automation-dispatch";
import { Receiver } from "@upstash/qstash";
import { eq } from "drizzle-orm";
import { env } from "@/env";
import { getRelayUrl } from "@/lib/relay-url";
import { verifyQstashRequest } from "@/lib/verifyQstash";
import { runPayloadSchema } from "../../runPayloadSchema";

export const maxDuration = 60;
export const dynamic = "force-dynamic";

const receiver = new Receiver({
currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY,
nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY,
});

export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const body = await request.text();
const signature = request.headers.get("upstash-signature");
if (!signature) {
return Response.json({ error: "Missing signature" }, { status: 401 });
}

const { id } = await params;
const valid = await receiver.verify({
const rejected = await verifyQstashRequest(
request,
body,
signature,
url: `${env.NEXT_PUBLIC_API_URL}/api/automations/dispatch/${id}`,
});
if (!valid) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
`/api/automations/dispatch/${id}`,
);
if (rejected) return rejected;

const parsed = runPayloadSchema.safeParse(JSON.parse(body));
if (!parsed.success) {
Expand Down
45 changes: 23 additions & 22 deletions apps/api/src/app/api/automations/evaluate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,18 @@ import {
type TriggerConfig,
} from "@superset/db/schema";
import { nextOccurrenceAfter } from "@superset/shared/rrule";
import { Client, Receiver } from "@upstash/qstash";
import { Client } from "@upstash/qstash";
import { and, eq, lte } from "drizzle-orm";

import { env } from "@/env";
import { redispatchUndispatched } from "@/lib/automations/redispatchUndispatched";
import { verifyQstashRequest } from "@/lib/verifyQstash";

export const dynamic = "force-dynamic";

const qstash = new Client({
token: env.QSTASH_TOKEN,
baseUrl: env.QSTASH_URL,
});
const receiver = new Receiver({
currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY,
nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY,
});

const BATCH_SIZE = 2000;

function bucketToMinute(d: Date): Date {
Expand All @@ -45,19 +41,12 @@ function scheduleFromConfig(

export async function POST(request: Request): Promise<Response> {
const body = await request.text();
const signature = request.headers.get("upstash-signature");
if (!signature) {
return Response.json({ error: "Missing signature" }, { status: 401 });
}

const valid = await receiver.verify({
const rejected = await verifyQstashRequest(
request,
body,
signature,
url: `${env.NEXT_PUBLIC_API_URL}/api/automations/evaluate`,
});
if (!valid) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
"/api/automations/evaluate",
);
if (rejected) return rejected;

const now = new Date();

Expand All @@ -73,7 +62,6 @@ export async function POST(request: Request): Promise<Response> {
.where(
and(
eq(automationTriggers.kind, "schedule"),
eq(automationTriggers.enabled, true),
eq(automations.enabled, true),
lte(automationTriggers.nextRunAt, now),
),
Expand Down Expand Up @@ -129,7 +117,13 @@ export async function POST(request: Request): Promise<Response> {
}

if (planned.length === 0) {
return Response.json({ enqueued: 0, unusable: unusable.length });
// The event-handoff sweep still runs on a tick with no due schedules.
const redispatched = await redispatchUndispatched();
return Response.json({
enqueued: 0,
unusable: unusable.length,
redispatched,
});
}

await qstash.batchJSON(
Expand All @@ -150,7 +144,10 @@ export async function POST(request: Request): Promise<Response> {
planned.map(async ({ triggerId, next }) => {
await dbWs
.update(automationTriggers)
.set(next ? { nextRunAt: next } : { enabled: false })
// Always a date for rules the product accepts (finite recurrences
// are refused at save); legacy finite data writes null and simply
// leaves the select, which reads only set next_run_at.
.set({ nextRunAt: next })
.where(eq(automationTriggers.id, triggerId));
}),
);
Expand All @@ -175,9 +172,13 @@ export async function POST(request: Request): Promise<Response> {
);
}

// The same tick retries event handoffs that never reached QStash.
const redispatched = await redispatchUndispatched();

return Response.json({
enqueued: planned.length,
advanceFailed: advanceFailures.length,
unusable: unusable.length,
redispatched,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
}
26 changes: 6 additions & 20 deletions apps/api/src/app/api/automations/run-failed/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import * as Sentry from "@sentry/nextjs";
import { dbWs } from "@superset/db/client";
import { automationRuns, automations } from "@superset/db/schema";
import { Receiver } from "@upstash/qstash";
import { eq, sql } from "drizzle-orm";
import { z } from "zod";

import { env } from "@/env";
import { verifyQstashRequest } from "@/lib/verifyQstash";
import { runPayloadSchema } from "../runPayloadSchema";

export const dynamic = "force-dynamic";

const receiver = new Receiver({
currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY,
nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY,
});

const failurePayloadSchema = z.object({
sourceMessageId: z.string(),
sourceBody: z.string(),
Expand All @@ -25,19 +18,12 @@ const failurePayloadSchema = z.object({

export async function POST(request: Request): Promise<Response> {
const body = await request.text();
const signature = request.headers.get("upstash-signature");
if (!signature) {
return Response.json({ error: "Missing signature" }, { status: 401 });
}

const valid = await receiver.verify({
const rejected = await verifyQstashRequest(
request,
body,
signature,
url: `${env.NEXT_PUBLIC_API_URL}/api/automations/run-failed`,
});
if (!valid) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
"/api/automations/run-failed",
);
if (rejected) return rejected;

let rawBody: unknown;
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { randomUUID } from "node:crypto";

import type { NormalizedDelivery } from "@/lib/automations/ingestAutomationEvent";

export const EVENT_TYPE = "webhook.received";

/**
* Every authenticated delivery is its own event — there is no dedupe — and
* fires every enabled webhook trigger on the one automation the URL names.
*/
export function normalizeWebhookDelivery(params: {
organizationId: string;
automationId: string;
payload: Record<string, unknown> | unknown[];
}): NormalizedDelivery {
return {
event: {
organizationId: params.organizationId,
integrationConnectionId: null,
provider: "webhook",
eventType: EVENT_TYPE,
externalEventId: randomUUID(),
title: "Webhook",
payload: params.payload,
},
dispatch: {
automationId: params.automationId,
event: {
provider: "webhook",
eventType: EVENT_TYPE,
actorId: null,
actorLogin: null,
body: null,
},
},
};
}
79 changes: 12 additions & 67 deletions apps/api/src/app/api/automations/webhook/[automationId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { createHash } from "node:crypto";
import { db } from "@superset/db/client";
import {
automationEvents,
automations,
automationTriggers,
} from "@superset/db/schema";
import type { WebhookMatchableEvent } from "@superset/shared/automation-matching";
import { automations, automationTriggers } from "@superset/db/schema";
import {
bearerToken,
WEBHOOK_TOKEN_PREFIX,
Expand All @@ -17,8 +12,9 @@ import { and, eq } from "drizzle-orm";
import { z } from "zod";

import { env } from "@/env";
import { dispatchMatchingTriggers } from "@/lib/automations/dispatchMatchingTriggers";
import { recordAutomationEvent } from "@/lib/automations/recordAutomationEvent";
import { ingestAutomationEvent } from "@/lib/automations/ingestAutomationEvent";
import { cappedBody } from "@/lib/webhooks/body";
import { normalizeWebhookDelivery } from "./normalizeWebhookDelivery";

export const dynamic = "force-dynamic";

Expand All @@ -31,9 +27,6 @@ const rateLimit = new Ratelimit({
prefix: "ratelimit:automations:webhook",
});

const EVENT_TYPE = "webhook.received";
const MAX_BODY_BYTES = 1024 * 1024;

function parseBody(body: string): Record<string, unknown> | unknown[] {
if (body.trim() === "") return {};
const parsed: unknown = JSON.parse(body);
Expand Down Expand Up @@ -75,11 +68,6 @@ export async function POST(
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
}

const contentLength = Number(request.headers.get("content-length"));
if (contentLength > MAX_BODY_BYTES) {
return Response.json({ error: "Body too large" }, { status: 413 });
}

const triggers = await db
.select({
secretHash: automationTriggers.secretHash,
Expand All @@ -92,7 +80,6 @@ export async function POST(
and(
eq(automationTriggers.automationId, automationId),
eq(automationTriggers.kind, "webhook"),
eq(automationTriggers.enabled, true),
),
);

Expand All @@ -110,10 +97,8 @@ export async function POST(
);
}

const body = await request.text();
if (Buffer.byteLength(body) > MAX_BODY_BYTES) {
return Response.json({ error: "Body too large" }, { status: 413 });
}
const body = await cappedBody(request);
if (body instanceof Response) return body;
let payload: Record<string, unknown> | unknown[];
try {
payload = parseBody(body);
Expand All @@ -124,53 +109,13 @@ export async function POST(
);
}

const inserted = await recordAutomationEvent(db, {
organizationId,
integrationConnectionId: null,
provider: "webhook",
eventType: EVENT_TYPE,
externalEventId: randomUUID(),
title: "Webhook",
payload,
});

if (!inserted) {
return Response.json({ error: "Failed to record event" }, { status: 500 });
}

const event: WebhookMatchableEvent = {
provider: "webhook",
eventType: EVENT_TYPE,
actorId: null,
actorLogin: null,
body: null,
};
let result: { matched: number; considered: number };
try {
result = await dispatchMatchingTriggers({
organizationId,
eventId: inserted.id,
event,
automationId,
});
} catch (error) {
console.error(
`[automations/webhook] dispatch failed for event ${inserted.id}:`,
error,
);
await db
.delete(automationEvents)
.where(eq(automationEvents.id, inserted.id));
return Response.json({ error: "Dispatch failed" }, { status: 500 });
}

console.log(
`[automations/webhook] ${result.matched}/${result.considered} triggers matched:`,
inserted.id,
const outcome = await ingestAutomationEvent(
db,
normalizeWebhookDelivery({ organizationId, automationId, payload }),
);
return Response.json({
ok: true,
eventId: inserted.id,
runs: result.matched,
eventId: "eventId" in outcome ? outcome.eventId : null,
runs: outcome.status === "dispatched" ? outcome.matched : 0,
});
}
Loading
Loading