Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notify workflow failure with Email when the workflow is triggered by webhooks #333

Merged
merged 18 commits into from
Jan 28, 2025
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
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
Loading