Files
2026-08-18 11:35:51 +01:00

284 lines
11 KiB
TypeScript

import { WebhookEngine } from "@internal/webhook-engine";
import type { WebhookDeliverTaskErrorType } from "@internal/webhook-engine";
import { tryCatch } from "@trigger.dev/core/utils";
import { z } from "zod";
import { prisma, webhookPrisma } from "~/db.server";
import { env } from "~/env.server";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
import {
ensureRunForSession,
type SessionTriggerConfig,
} from "~/services/realtime/sessionRunManager.server";
import { findOrCreateSession, findSessionByExternalId } from "~/services/realtime/sessions.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import {
claimSessionStreamPart,
drainSessionStreamWaitpoints,
releaseSessionStreamPart,
} from "~/services/sessionStreamWaitpointCache.server";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { singleton } from "~/utils/singleton";
import { engine as runEngine } from "./runEngine.server";
import { ServiceValidationError } from "./services/common.server";
import { TriggerTaskService } from "./services/triggerTask.server";
import { meter, tracer } from "./tracer.server";
export const webhookEngine = singleton("WebhookEngine", createWebhookEngine);
// The plaintext signing secret is stored under the "DATABASE" SecretStore
// provider as { secret: string } (same shape as environment variables).
const SigningSecretSchema = z.object({ secret: z.string() });
function createWebhookEngine() {
// The engine owns the webhook tables, so it runs on the webhook DB client. The signing-secret
// store stays on the main client below (SecretStore is control-plane, not part of the split).
const secretStore = getSecretStore("DATABASE", { prismaClient: prisma });
const engine = new WebhookEngine({
prisma: webhookPrisma,
logLevel: env.WEBHOOK_ENGINE_LOG_LEVEL,
disabled: env.WEBHOOK_ENABLED !== "1",
redis: {
host: env.WEBHOOK_WORKER_REDIS_HOST ?? "localhost",
port: env.WEBHOOK_WORKER_REDIS_PORT ?? 6379,
username: env.WEBHOOK_WORKER_REDIS_USERNAME,
password: env.WEBHOOK_WORKER_REDIS_PASSWORD,
keyPrefix: "webhook:",
enableAutoPipelining: true,
...(env.WEBHOOK_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
worker: {
concurrency: env.WEBHOOK_WORKER_CONCURRENCY_LIMIT,
workers: env.WEBHOOK_WORKER_CONCURRENCY_WORKERS,
tasksPerWorker: env.WEBHOOK_WORKER_CONCURRENCY_TASKS_PER_WORKER,
pollIntervalMs: env.WEBHOOK_WORKER_POLL_INTERVAL,
shutdownTimeoutMs: env.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT_MS,
disabled: env.WEBHOOK_ENABLED !== "1" || env.WEBHOOK_WORKER_ENABLED !== "true",
},
partitions: {
ensureSchedule: env.WEBHOOK_PARTITION_ENSURE_SCHEDULE,
ensureJitterInMs: env.WEBHOOK_PARTITION_ENSURE_JITTER_MS,
lookaheadDays: env.WEBHOOK_PARTITION_LOOKAHEAD_DAYS,
retentionDays: env.WEBHOOK_PARTITION_RETENTION_DAYS,
},
frontGate: {
defaultTtlSeconds: env.WEBHOOK_FRONT_GATE_DEFAULT_TTL_SECONDS,
maxTtlSeconds: env.WEBHOOK_FRONT_GATE_MAX_TTL_SECONDS,
},
endpointCache: {
ttlMs: env.WEBHOOK_ENDPOINT_CACHE_TTL_MS,
maxSize: env.WEBHOOK_ENDPOINT_CACHE_MAX_SIZE,
},
tracer,
meter,
resolveSigningSecret: async (key) => {
const value = await secretStore.getSecret(SigningSecretSchema, key);
// Fail closed: an unset/empty secret returns undefined so ingest rejects.
return value?.secret || undefined;
},
triggerTask: async ({
environmentId,
taskId,
idempotencyKey,
idempotencyKeyExpiresAt,
payload,
headers,
identityTags,
endpointMetadata,
}) => {
try {
const environment = await findEnvironmentById(environmentId);
if (!environment) {
return { success: false, errorType: "NOT_FOUND", error: "Environment not found" };
}
const triggerService = new TriggerTaskService();
const result = await triggerService.call(
taskId,
environment,
{
// The webhook task run receives a { event, headers } envelope; the SDK's webhook()
// run unwraps it into onEvent({ event, headers }).
payload: { event: payload, headers },
options: {
tags: identityTags,
metadata: (endpointMetadata as Record<string, unknown>) ?? undefined,
},
},
{
idempotencyKey,
idempotencyKeyExpiresAt,
triggerSource: "webhook",
triggerAction: "trigger",
customIcon: "webhook",
}
);
return { success: !!result, runId: result?.run.id };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
let errorType: WebhookDeliverTaskErrorType = "SYSTEM_ERROR";
if (
error instanceof ServiceValidationError &&
errorMessage.includes("queue size limit for this environment has been reached")
) {
errorType = "QUEUE_LIMIT";
}
return { success: false, error: errorMessage, errorType };
}
},
// Route a verified delivery to a session: find-or-create it, then append a webhook action to `.in`.
// The run boots on a preload payload (so onChatStart fires), then reads the action from `.in`.
deliverToSession: async ({
environmentId,
taskIdentifier,
externalId,
deliverAs,
actionType,
connectorId,
event,
source,
headers,
deliveryId,
triggerConfigTemplate,
isSessionStart,
}) => {
try {
const environment = await findEnvironmentById(environmentId);
if (!environment) {
return { success: false, errorType: "NOT_FOUND", error: "Environment not found" };
}
const template = (triggerConfigTemplate ?? {}) as Partial<SessionTriggerConfig>;
const triggerConfig: SessionTriggerConfig = {
...template,
basePayload: {
messages: [],
trigger: "preload",
chatId: externalId,
...(template.basePayload ?? {}),
},
};
// Resume an existing session; otherwise only START one when the event is a session-start
// (startOn). Resume-only with no session yet -> ignore (no session, no run, no egress).
const existing = await findSessionByExternalId(environment, externalId);
if (!existing && !isSessionStart) {
return {
success: true,
skipped: true,
skippedReason: "startOn: not a session-start event",
};
}
const { session, isCached } = existing
? { session: existing, isCached: true }
: await findOrCreateSession({
environment,
externalId,
type: "chat.agent",
taskIdentifier,
triggerConfig,
});
if (session.closedAt || (session.expiresAt && session.expiresAt.getTime() < Date.now())) {
return { success: false, error: "Session is closed or expired" };
}
// Boot / revive the run, then append the action. The run reads it from `.in`.
const ensureResult = await ensureRunForSession({
session,
environment,
reason: isCached ? "continuation" : "initial",
});
const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
if (!(realtimeStream instanceof S2RealtimeStreams)) {
return { success: false, error: "Session channels require the S2 realtime backend" };
}
const addressingKey = session.externalId ?? session.friendlyId;
// "action" (chat.event) -> onAction envelope; "message" (channels) -> a turn whose message the
// run derives by applying the connector's inbound() to the raw event.
const payload =
deliverAs === "message"
? {
chatId: externalId,
trigger: "submit-message",
channelEvent: { connectorId, event, source, headers, deliveryId },
}
: {
chatId: externalId,
trigger: "action",
actionSource: "webhook",
action: { type: actionType, event, source, headers, deliveryId },
};
const part = JSON.stringify({ kind: "message", payload });
// deliveryId as the part id → a deliver-job retry re-claims the same id and skips a duplicate
// append. The S2 record is durable, so a run that boots later still reads it.
const wonClaim = await claimSessionStreamPart(
environment.id,
addressingKey,
"in",
deliveryId
);
if (wonClaim) {
const [appendError] = await tryCatch(
realtimeStream.appendPartToSessionStream(part, deliveryId, addressingKey, "in")
);
if (appendError) {
// Nothing landed — release the claim so a retry re-appends the same id.
await releaseSessionStreamPart(environment.id, addressingKey, "in", deliveryId);
// A ServiceValidationError (e.g. record too large) is terminal; anything else is transient.
if (appendError instanceof ServiceValidationError) {
return { success: false, error: appendError.message };
}
throw appendError;
}
}
// Wake any `.in` waitpoints the run registered (best-effort; the record is durable in S2).
const [drainError, waitpointIds] = await tryCatch(
drainSessionStreamWaitpoints(environment.id, addressingKey, "in")
);
if (drainError) {
logger.error("deliverToSession: failed to drain session waitpoints", {
externalId,
error: drainError,
});
} else if (waitpointIds && waitpointIds.length > 0) {
await Promise.all(
waitpointIds.map((waitpointId) =>
tryCatch(
runEngine.completeWaitpoint({
id: waitpointId,
output: { value: part, type: "application/json", isError: false },
})
)
)
);
}
return { success: true, runId: ensureResult.runId };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
let errorType: WebhookDeliverTaskErrorType = "SYSTEM_ERROR";
if (
error instanceof ServiceValidationError &&
errorMessage.includes("queue size limit for this environment has been reached")
) {
errorType = "QUEUE_LIMIT";
}
return { success: false, error: errorMessage, errorType };
}
},
});
return engine;
}