Skip to content
Open
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
6 changes: 6 additions & 0 deletions .server-changes/fetch-ancestors-outside-anchor-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Fix run-scoped trace subtree fetching so ancestor spans are loaded regardless of the anchor run's time window. Ancestors start before the anchor run and are fetched by explicit span IDs, so applying the anchor's `startCreatedAt` filter wrongly excluded them — which meant cancellation/error overrides from an ancestor never propagated down to the anchor subtree (e.g. a child span stayed PARTIAL when its parent run was cancelled). Ancestor fetches now skip the time window.
6 changes: 6 additions & 0 deletions .server-changes/trace-view-large-runs-subtree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Fix empty trace views for child and nested runs in very large traces. The dashboard and retrieve-trace API now return the requested run's span subtree.
195 changes: 148 additions & 47 deletions apps/webapp/app/models/member.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { type Prisma, prisma } from "~/db.server";
import type { Organization, OrgMember, Project } from "@trigger.dev/database";
import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server";
import { createEnvironment } from "./organization.server";
import { customAlphabet } from "nanoid";
import { logger } from "~/services/logger.server";
import { rbac } from "~/services/rbac.server";

export const INVITE_NOT_FOUND = "Invite not found";
export const ENV_SETUP_INCOMPLETE =
"You joined the organization, but we couldn't finish setting up your development environments. Accept the invitation again to retry, or contact support if this persists.";

export function isAcceptInviteFormError(error: unknown): error is Error {
return (
error instanceof Error &&
(error.message === INVITE_NOT_FOUND || error.message === ENV_SETUP_INCOMPLETE)
);
}

const tokenValueLength = 40;
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);

Expand Down Expand Up @@ -177,92 +189,181 @@ export async function getUsersInvites({ email }: { email: string }) {
});
}

export async function acceptInvite({
user,
export async function provisionMemberDevelopmentEnvironments({
inviteId,
user,
member,
organization,
projects,
}: {
user: { id: string; email: string };
inviteId: string;
user: { id: string; email: string };
member: OrgMember;
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
projects: Pick<Project, "id">[];
}) {
const result = await prisma.$transaction(async (tx) => {
// 1. Delete the invite and get the invite details
const invite = await tx.orgMemberInvite.delete({
where: {
id: inviteId,
email: user.email,
},
include: {
organization: {
include: {
projects: true,
},
},
},
});
const projectIds = projects.map((p) => p.id);
const existingDevEnvs = await prisma.runtimeEnvironment.findMany({
where: {
orgMemberId: member.id,
type: "DEVELOPMENT",
projectId: { in: projectIds },
},
select: { projectId: true },
});
const existingProjectIds = new Set(existingDevEnvs.map((env) => env.projectId));
const projectsToProvision = projects.filter((project) => !existingProjectIds.has(project.id));

// 2. Join the organization
const member = await tx.orgMember.create({
data: {
organizationId: invite.organizationId,
userId: user.id,
role: invite.role,
},
});
const createdProjectIds: string[] = [];
let failedProjectId: string | undefined;
let failedProjectIndex: number | undefined;

try {
for (const [index, project] of projectsToProvision.entries()) {
failedProjectId = project.id;
failedProjectIndex = index;

// 3. Create an environment for each project
for (const project of invite.organization.projects) {
await createEnvironment({
organization: invite.organization,
organization,
project,
type: "DEVELOPMENT",
// We set this true but no backfill (yet!?) so never used
// for dev environments
isBranchableEnvironment: true,
member,
prismaClient: tx,
});
Comment on lines +206 to 234

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make duplicate dev-env creation idempotent under concurrent accepts.

Two concurrent acceptInvite calls can both see the same project as missing, then one createEnvironment succeeds and the other hits the runtime-environment unique constraint for the same member/project. That second request currently throws ENV_SETUP_INCOMPLETE even though the other request may have completed setup. Treat Prisma P2002 for the per-project env create as “already created” by re-checking that project/member env and continuing.


createdProjectIds.push(project.id);
failedProjectId = undefined;
failedProjectIndex = undefined;
}
} catch (error) {
logger.error("acceptInvite: development environment creation failed after membership created", {
inviteId,
userId: user.id,
organizationId: organization.id,
orgMemberId: member.id,
projectIds,
failedProjectId,
failedProjectIndex,
totalProjects: projects.length,
skippedProjectIds: [...existingProjectIds],
createdProjectIds,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});

// 4. Check for other invites
const remainingInvites = await tx.orgMemberInvite.findMany({
throw new Error(ENV_SETUP_INCOMPLETE);
}
}

export async function acceptInvite({
user,
inviteId,
}: {
user: { id: string; email: string };
inviteId: string;
}) {
const invite = await prisma.orgMemberInvite.findFirst({
where: { id: inviteId, email: user.email },
include: {
organization: {
include: {
projects: { where: { deletedAt: null } },
},
},
},
});
if (!invite) {
throw new Error(INVITE_NOT_FOUND);
}

let member = await prisma.orgMember.findFirst({
where: { userId: user.id, organizationId: invite.organizationId },
});
if (!member) {
try {
member = await prisma.orgMember.create({
data: {
organizationId: invite.organizationId,
userId: user.id,
role: invite.role,
},
});
} catch (error) {
if (
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
member = await prisma.orgMember.findFirst({
where: { userId: user.id, organizationId: invite.organizationId },
});
} else {
throw error;
}
}
}

if (!member) {
throw new Error(ENV_SETUP_INCOMPLETE);
}

await provisionMemberDevelopmentEnvironments({
inviteId,
user,
member,
organization: invite.organization,
projects: invite.organization.projects,
});

try {
await prisma.orgMemberInvite.delete({
where: {
id: inviteId,
email: user.email,
},
});
} catch (error) {
if (error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025") {
// Another concurrent accept finished first — membership and envs are ready.
} else {
throw error;
}
}

Comment on lines +262 to 335

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 acceptInvite no longer uses a transaction — concurrent accepts could create duplicate environments

The old acceptInvite wrapped all operations (invite deletion, member creation, environment creation) in a single prisma.$transaction. The new code (apps/webapp/app/models/member.server.ts:262-375) performs these as separate, non-transactional operations. While it handles the member-creation race via P2002 catch, two concurrent acceptInvite calls could both pass the provisionMemberDevelopmentEnvironments check (since existingDevEnvs is read before creation) and attempt to create the same environment. The createEnvironment function creates a runtimeEnvironment record — if there's no unique constraint on (orgMemberId, projectId, type), this could result in duplicate dev environments. If there IS a unique constraint, the second call would throw inside the for-loop and return ENV_SETUP_INCOMPLETE despite the first call having succeeded. The idempotency check at apps/webapp/app/models/member.server.ts:206-215 mitigates this on retry.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return {
remainingInvites,
organization: invite.organization,
inviteRole: invite.role,
rbacRoleId: invite.rbacRoleId,
};
const remainingInvites = await prisma.orgMemberInvite.findMany({
where: {
email: user.email,
},
});

// If the invite carried an explicit RBAC role, assign it. Best-effort: the
// invite is already consumed and membership created above, so a failure here
// — a returned {ok:false} or a thrown error from the plugin — must not block
// joining the org. Swallow and log either way; without the catch a plugin
// throw escapes and turns the whole invite-accept into a 400.
if (result.rbacRoleId) {
if (invite.rbacRoleId) {
try {
const roleResult = await rbac.setUserRole({
userId: user.id,
organizationId: result.organization.id,
roleId: result.rbacRoleId,
organizationId: invite.organization.id,
roleId: invite.rbacRoleId,
});
if (!roleResult.ok) {
logger.error("acceptInvite: skipped RBAC role assignment", {
organizationId: result.organization.id,
organizationId: invite.organization.id,
userId: user.id,
rbacRoleId: result.rbacRoleId,
rbacRoleId: invite.rbacRoleId,
reason: roleResult.error,
});
}
} catch (error) {
logger.error("acceptInvite: RBAC role assignment threw", {
organizationId: result.organization.id,
organizationId: invite.organization.id,
userId: user.id,
rbacRoleId: result.rbacRoleId,
rbacRoleId: invite.rbacRoleId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
Expand All @@ -271,7 +372,7 @@ export async function acceptInvite({
}
}

return { remainingInvites: result.remainingInvites, organization: result.organization };
return { remainingInvites, organization: invite.organization };
}

export async function declineInvite({
Expand Down
54 changes: 51 additions & 3 deletions apps/webapp/app/presenters/v3/RunPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { millisecondsToNanoseconds, RunAnnotations } from "@trigger.dev/core/v3";
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
import { prisma, type PrismaClient } from "~/db.server";
import { logger } from "~/services/logger.server";
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
import { getUsername } from "~/utils/username";
import { SpanSummary } from "~/v3/eventRepository/eventRepository.types";
Expand Down Expand Up @@ -179,16 +180,49 @@ export class RunPresenter {
run.runtimeEnvironment.organizationId
);

// get the events
const traceTimeBounds = {
startCreatedAt: run.rootTaskRun?.createdAt ?? run.createdAt,
endCreatedAt: run.completedAt ?? undefined,
};

// Fast path: full trace summary. Slow path: subtree fetch when the anchor
// span fell past the row cap (large traces ordered by start_time ASC).
let traceSummary = await repository.getTraceSummary(
getTaskEventStoreTableForRun(run),
run.runtimeEnvironment.id,
run.traceId,
run.rootTaskRun?.createdAt ?? run.createdAt,
run.completedAt ?? undefined,
traceTimeBounds.startCreatedAt,
traceTimeBounds.endCreatedAt,
{ includeDebugLogs: showDebug }
);

let isTruncated = traceSummary?.isTruncated ?? false;
const hasAnchorSpan = traceSummary?.spans.some((span) => span.id === run.spanId) ?? false;

if (traceSummary && !hasAnchorSpan) {
logger.warn("Trace summary missing anchor span, falling back to subtree fetch", {
runId: run.friendlyId,
spanId: run.spanId,
traceId: run.traceId,
spanCount: traceSummary.spans.length,
});

const subtreeSummary = await repository.getTraceSubtreeSummary(
getTaskEventStoreTableForRun(run),
run.runtimeEnvironment.id,
run.traceId,
run.spanId,
traceTimeBounds.startCreatedAt,
traceTimeBounds.endCreatedAt,
{ includeDebugLogs: showDebug }
);

if (subtreeSummary) {
traceSummary = subtreeSummary;
isTruncated = subtreeSummary.isTruncated ?? false;
}
}

if (!traceSummary) {
const spanSummary: SpanSummary = {
id: run.spanId,
Expand Down Expand Up @@ -241,6 +275,18 @@ export class RunPresenter {

//this tree starts at the passed in span (hides parent elements if there are any)
const tree = createTreeFromFlatItems(traceSummary.spans, run.spanId);
const missingAnchor = !traceSummary.spans.some((span) => span.id === run.spanId) || !tree;

if (missingAnchor) {
logger.warn("Trace view anchor span not found in trace summary", {
runId: run.friendlyId,
spanId: run.spanId,
traceId: run.traceId,
spanCount: traceSummary.spans.length,
});

isTruncated = true;
}

//we need the start offset for each item, and the total duration of the entire tree
const treeRootStartTimeMs = tree ? tree?.data.startTime.getTime() : 0;
Expand Down Expand Up @@ -312,6 +358,8 @@ export class RunPresenter {
: undefined,
overridesBySpanId: traceSummary.overridesBySpanId,
linkedRunIdBySpanId,
isTruncated,
missingAnchor,
},
maximumLiveReloadingSetting: repository.maximumLiveReloadingSetting,
};
Expand Down
Loading