Skip to content

Commit

Permalink
Merge pull request #333 from giselles-ai/notify-workflow-failure-trig…
Browse files Browse the repository at this point in the history
…gered-by-webhook

Notify workflow failure with Email when the workflow is triggered by webhooks
  • Loading branch information
satococoa authored Jan 28, 2025
2 parents deab913 + 77455ac commit 87ff659
Show file tree
Hide file tree
Showing 11 changed files with 2,691 additions and 2,569 deletions.
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ TAVILY_API_KEY=
# @see https://docs.unstructured.io/api-reference/api-services/saas-api-development-guide
UNSTRUCTURED_API_KEY=

# SMTP Email Configuration
SMTP_FROM=
SMTP_FROM_NAME=
SMTP_HOST=
SMTP_PORT=
SMTP_SECURE=
SMTP_USER=
SMTP_PASS=

# Email Debug Mode (set "true" to skip sending emails and show debug output)
SEND_EMAIL_DEBUG=1


# ---
# for development only
# ---
Expand Down
2 changes: 1 addition & 1 deletion app/(playground)/p/[agentId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export default async function Page({
endedAtDate,
totalDurationMs,
);
await reportAgentTimeUsage(endedAtDate);
await reportAgentTimeUsage(agentId, endedAtDate);
}

async function upsertGitHubIntegrationSettingAction(
Expand Down
34 changes: 34 additions & 0 deletions app/services/email/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Base error class for all email-related errors
*/
export class EmailError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
// Maintains proper stack trace for where error was thrown
Error.captureStackTrace(this, this.constructor);
}
}

/**
* Error thrown when email configuration is invalid or missing
*/
export class EmailConfigurationError extends EmailError {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, EmailConfigurationError.prototype);
}
}

/**
* Error thrown when sending an email fails
*/
export class EmailSendError extends EmailError {
constructor(
message: string,
public readonly cause?: Error,
) {
super(message);
Object.setPrototypeOf(this, EmailSendError.prototype);
}
}
3 changes: 3 additions & 0 deletions app/services/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { EmailConfigurationError, EmailSendError } from "./errors";
export { sendEmail } from "./send-email";
export type { EmailRecipient } from "./types";
67 changes: 67 additions & 0 deletions app/services/email/send-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import nodemailer from "nodemailer";
import invariant from "tiny-invariant";
import { EmailConfigurationError, EmailSendError } from "./errors";
import type { EmailRecipient } from "./types";

function createTransport() {
try {
invariant(process.env.SMTP_HOST, "SMTP_HOST is not set");
invariant(process.env.SMTP_PORT, "SMTP_PORT is not set");
invariant(process.env.SMTP_SECURE, "SMTP_SECURE is not set");
invariant(process.env.SMTP_USER, "SMTP_USER is not set");
invariant(process.env.SMTP_PASS, "SMTP_PASS is not set");

return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number.parseInt(process.env.SMTP_PORT, 10),
secure: process.env.SMTP_SECURE === "true",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
} catch (error) {
throw new EmailConfigurationError(
`Invalid email configuration: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

export async function sendEmail(
subject: string,
body: string,
recipients: EmailRecipient[],
): Promise<void> {
if (recipients.length === 0) {
throw new EmailSendError("No recipients found");
}
const to = recipients
.map((r) => `${r.userDisplayName} <${r.userEmail}>`)
.join(", ");

if (process.env.SEND_EMAIL_DEBUG === "1") {
console.log("========= Email Debug Mode =========");
console.log("To:", to);
console.log("Subject:", subject);
console.log("Body:", body);
console.log("==================================");
return;
}

const transporter = createTransport();
try {
invariant(process.env.SMTP_FROM, "SMTP_FROM is not set");
const from = `${process.env.SMTP_FROM_NAME ?? ""} <${process.env.SMTP_FROM}>`;
await transporter.sendMail({
from,
to,
subject,
text: body,
});
} catch (error) {
throw new EmailSendError(
"Failed to send email",
error instanceof Error ? error : undefined,
);
}
}
4 changes: 4 additions & 0 deletions app/services/email/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface EmailRecipient {
userDisplayName: string;
userEmail: string;
}
61 changes: 48 additions & 13 deletions app/webhooks/github/handle_event.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@/drizzle";
import { type EmailRecipient, sendEmail } from "@/app/services/email";
import { type agents, db, teamMemberships, users } from "@/drizzle";
import { saveAgentActivity } from "@/services/agents/activities";
import { reportAgentTimeUsage } from "@/services/usage-based-billing";
import { executeStep } from "@giselles-ai/lib/execution";
Expand All @@ -10,6 +11,7 @@ import {
import type { Execution, Graph } from "@giselles-ai/types";
import type { Octokit } from "@octokit/core";
import { waitUntil } from "@vercel/functions";
import { eq } from "drizzle-orm";
import { parseCommand } from "./command";
import { assertIssueCommentEvent, createOctokit } from "./utils";

Expand Down Expand Up @@ -167,21 +169,54 @@ export async function handleEvent(
endedAtDate,
durationMs,
);
await reportAgentTimeUsage(endedAtDate);
await reportAgentTimeUsage(agent.id, endedAtDate);
},
});

await octokit.request(
"POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
{
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: payload.issue.number,
body: finalExecution.artifacts[finalExecution.artifacts.length - 1]
.object.content,
onStepFail: async (stepExecution) => {
await notifyWorkflowError(agent, stepExecution.error);
},
);
});
if (finalExecution.status === "completed") {
await octokit.request(
"POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
{
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: payload.issue.number,
body: finalExecution.artifacts[
finalExecution.artifacts.length - 1
].object.content,
},
);
}
}),
),
);
}

// Notify workflow error to team members
async function notifyWorkflowError(
agent: typeof agents.$inferSelect,
error: string,
) {
const teamMembers = await db
.select({ userDisplayName: users.displayName, userEmail: users.email })
.from(teamMemberships)
.innerJoin(users, eq(teamMemberships.userDbId, users.dbId))
.where(eq(teamMemberships.teamDbId, agent.teamDbId));

if (teamMembers.length === 0) {
return;
}

const subject = `[Giselle] Workflow failure: ${agent.name} (ID: ${agent.id})`;
const body = `Workflow failed with error:
${error}
`.replaceAll("\t", "");

const recipients: EmailRecipient[] = teamMembers.map((user) => ({
userDisplayName: user.userDisplayName ?? "",
userEmail: user.userEmail ?? "",
}));

await sendEmail(subject, body, recipients);
}
Loading

0 comments on commit 87ff659

Please sign in to comment.