Skip to content

Commit 8d830a0

Browse files
committed
merge: propagate review-comment fixes from feat/dashboard-agent-ui
# Conflicts: # apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts # internal-packages/dashboard-agent-db/src/queries.ts
2 parents ab7d796 + c0a93b0 commit 8d830a0

9 files changed

Lines changed: 132 additions & 13 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Deleting a dashboard agent chat is now scoped to your organization, so a chat can only be removed from within the org it belongs to.

apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@ export function DashboardAgentPanel({
196196
if (!res.ok && res.status !== 404) {
197197
console.error(`Dashboard agent: failed to open chat ${id} (${res.status})`);
198198
toast.error("We couldn't open that chat. Try again in a moment.");
199+
// Transient failure: keep the stored pointer so the chat can be reopened.
200+
if (seq === openChatRequestSeq.current) setActive(null);
201+
return;
199202
}
200203
const data = res.ok ? ((await res.json()) as OpenedChatResponse) : undefined;
201204
if (seq !== openChatRequestSeq.current) return;

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
374374
// handover was dispatched and no message was sent: a session the call did create in
375375
// spite of the error idles out having done nothing. The empty row is all there is to undo.
376376
// Swallowed so the start's own error is what surfaces and gets logged.
377-
await softDeleteChat(dashboardAgentDb, { chatId, userId }).catch((cleanupError) => {
377+
await softDeleteChat(dashboardAgentDb, {
378+
chatId,
379+
userId,
380+
organizationId: project.organizationId,
381+
}).catch((cleanupError) => {
378382
logger.error("Failed to remove a dashboard agent chat whose start failed", {
379383
chatId,
380384
error: cleanupError,
@@ -636,8 +640,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
636640
}
637641

638642
case "delete": {
639-
// `deleteChatWithWatches` is owner-scoped but takes no org, so the org scope has
640-
// to be enforced here.
643+
// Existence check gives a 404 for a chat this caller can't see; the delete itself
644+
// is org- and owner-scoped too, and ends the chat's watches in the same transaction.
641645
if (
642646
!(await chatExists(dashboardAgentDb, {
643647
chatId,
@@ -648,7 +652,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
648652
return json({ error: "Chat not found" }, { status: 404 });
649653
}
650654
// The delete and the watch cancellations land in one transaction.
651-
const { cancelledWatches } = await deleteChatWithWatches({ chatId, userId });
655+
const { cancelledWatches } = await deleteChatWithWatches({
656+
chatId,
657+
userId,
658+
organizationId: project.organizationId,
659+
});
652660
return json({ ok: true, cancelledWatches });
653661
}
654662

apps/webapp/app/services/dashboardAgentWatches.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1184,11 +1184,12 @@ export async function cancelDashboardAgentWatch(params: {
11841184

11851185
/**
11861186
* Delete a chat and end its watches in one transaction, so no live watch is left on an
1187-
* invisible chat. Owner-scoped, so a chatId the caller doesn't own deletes nothing.
1187+
* invisible chat. Org- and owner-scoped, so a chatId the caller doesn't own deletes nothing.
11881188
*/
11891189
export async function deleteChatWithWatches(params: {
11901190
chatId: string;
11911191
userId: string;
1192+
organizationId: string;
11921193
}): Promise<{ deleted: boolean; cancelledWatches: number }> {
11931194
const result = await softDeleteChat(dashboardAgentDb, params);
11941195
return { deleted: result.deleted, cancelledWatches: result.cancelledWatches.length };
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
createChat,
3+
createDashboardAgentDb,
4+
listChats,
5+
softDeleteChat,
6+
type DashboardAgentDb,
7+
type DashboardAgentDbClient,
8+
} from "@internal/dashboard-agent-db";
9+
import { postgresTest } from "@internal/testcontainers";
10+
import type { PrismaClient } from "@trigger.dev/database";
11+
import { readdirSync, readFileSync } from "node:fs";
12+
import path from "node:path";
13+
import { afterEach, describe, expect } from "vitest";
14+
15+
/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
16+
async function applyAgentSchema(prisma: PrismaClient) {
17+
const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle");
18+
const migrations = readdirSync(folder)
19+
.filter((file) => file.endsWith(".sql"))
20+
.sort();
21+
for (const name of migrations) {
22+
const sql = readFileSync(path.join(folder, name), "utf8");
23+
for (const statement of sql.split("--> statement-breakpoint")) {
24+
const trimmed = statement.trim();
25+
if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed);
26+
}
27+
}
28+
}
29+
30+
let agentDbClient: DashboardAgentDbClient | undefined;
31+
32+
async function boot(prisma: PrismaClient, connectionUri: string): Promise<DashboardAgentDb> {
33+
await applyAgentSchema(prisma);
34+
agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 });
35+
return agentDbClient.db;
36+
}
37+
38+
afterEach(async () => {
39+
await agentDbClient?.close();
40+
agentDbClient = undefined;
41+
});
42+
43+
const ORG = "org_owner";
44+
const OTHER_ORG = "org_other";
45+
const USER = "user_owner";
46+
47+
describe("softDeleteChat tenant isolation", () => {
48+
postgresTest(
49+
"a soft-delete scoped to another org leaves the chat intact",
50+
async ({ prisma, postgresContainer }) => {
51+
const db = await boot(prisma, postgresContainer.getConnectionUri());
52+
53+
await createChat(db, { id: "chat_1", organizationId: ORG, userId: USER });
54+
55+
// Right user, wrong org: must not delete.
56+
const wrongOrg = await softDeleteChat(db, {
57+
chatId: "chat_1",
58+
userId: USER,
59+
organizationId: OTHER_ORG,
60+
});
61+
expect(wrongOrg.deleted).toBe(false);
62+
expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(1);
63+
64+
// Right org and user: deletes.
65+
const rightOrg = await softDeleteChat(db, {
66+
chatId: "chat_1",
67+
userId: USER,
68+
organizationId: ORG,
69+
});
70+
expect(rightOrg.deleted).toBe(true);
71+
expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(0);
72+
},
73+
30_000
74+
);
75+
});

apps/webapp/test/dashboardAgentWatchCardAtomicity.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ describe("closing a consented watch investigation's card", () => {
150150
const chatId = "chat_watch_card_deleted";
151151
await boot(prisma, postgresContainer.getConnectionUri(), chatId);
152152
const id = await seed(chatId, openState());
153-
await softDeleteChat(agentDb, { chatId, userId: USER_ID });
153+
await softDeleteChat(agentDb, { chatId, userId: USER_ID, organizationId: ORG_ID });
154154

155155
expect(
156156
await settleInvestigationStateAndCloseCard(agentDb, {

apps/webapp/test/dashboardAgentWatches.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,13 @@ describe("the chat cascade and the list view", () => {
784784
expect(mine.ok && theirs.ok).toBe(true);
785785
if (!mine.ok || !theirs.ok) return;
786786

787-
expect(await deleteChatWithWatches({ chatId: "chat_1", userId: seeded.user.id })).toEqual({
787+
expect(
788+
await deleteChatWithWatches({
789+
chatId: "chat_1",
790+
userId: seeded.user.id,
791+
organizationId: seeded.organization.id,
792+
})
793+
).toEqual({
788794
deleted: true,
789795
cancelledWatches: 1,
790796
});
@@ -860,7 +866,11 @@ describe("the chat cascade and the list view", () => {
860866
const created = await create({ seeded, chatId: "chat_1" });
861867
expect(created.ok).toBe(true);
862868

863-
await deleteChatWithWatches({ chatId: "chat_1", userId: seeded.user.id });
869+
await deleteChatWithWatches({
870+
chatId: "chat_1",
871+
userId: seeded.user.id,
872+
organizationId: seeded.organization.id,
873+
});
864874

865875
const rows = await ctx.prisma.$queryRawUnsafe<{ message_id: string }[]>(
866876
`select message_id from trigger_dashboard_agent.chat_messages where chat_id = 'chat_1'`
@@ -1518,7 +1528,12 @@ describe("deleting a chat while a watch is being created", () => {
15181528
await seedChat(seeded, chatId);
15191529

15201530
const creating = () => create({ seeded, chatId });
1521-
const deleting = () => deleteChatWithWatches({ chatId, userId: seeded.user.id });
1531+
const deleting = () =>
1532+
deleteChatWithWatches({
1533+
chatId,
1534+
userId: seeded.user.id,
1535+
organizationId: seeded.organization.id,
1536+
});
15221537
const [a, b] = deleteFirst
15231538
? await Promise.all([deleting(), creating()])
15241539
: await Promise.all([creating(), deleting()]);
@@ -1542,7 +1557,11 @@ describe("deleting a chat while a watch is being created", () => {
15421557
await boot(prisma, postgresContainer.getConnectionUri());
15431558
const seeded = await seed(prisma, "race");
15441559
await seedChat(seeded);
1545-
await deleteChatWithWatches({ chatId: "chat_1", userId: seeded.user.id });
1560+
await deleteChatWithWatches({
1561+
chatId: "chat_1",
1562+
userId: seeded.user.id,
1563+
organizationId: seeded.organization.id,
1564+
});
15461565

15471566
expect(await create({ seeded })).toMatchObject({ ok: false, code: "chat_not_found" });
15481567
expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toEqual([]);

internal-packages/dashboard-agent-db/src/queries.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ export async function markChatRead(
287287
*/
288288
export async function softDeleteChat(
289289
db: DashboardAgentDb,
290-
params: { chatId: string; userId: string }
290+
params: { chatId: string; userId: string; organizationId: string }
291291
): Promise<{ deleted: boolean; cancelledWatches: Watch[] }> {
292292
return db.transaction(async (tx) => {
293293
// The same lock `createWatch` takes, or a concurrent create lands an active
@@ -297,7 +297,13 @@ export async function softDeleteChat(
297297
const deleted = await tx
298298
.update(chats)
299299
.set({ deletedAt: sql`now()`, updatedAt: sql`now()` })
300-
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)))
300+
.where(
301+
and(
302+
eq(chats.id, params.chatId),
303+
eq(chats.userId, params.userId),
304+
eq(chats.organizationId, params.organizationId)
305+
)
306+
)
301307
.returning({ id: chats.id });
302308

303309
if (deleted.length === 0) return { deleted: false, cancelledWatches: [] };

internal-packages/dashboard-agent/src/dashboard-agent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ export const dashboardAgent = chat.agent({
448448
turn,
449449
uiMessages,
450450
newMessages,
451+
newUIMessages,
451452
responseMessage,
452453
clientData,
453454
chatAccessToken,
@@ -474,7 +475,7 @@ export const dashboardAgent = chat.agent({
474475
// operation is what could leave a terminal row whose card never arrived — and the
475476
// stale sweep only selects `in_progress`, so nothing would ever repair it.
476477
// Only what this turn produced may be finalised; the rest of the snapshot is history.
477-
const produced = [...(newMessages ?? []), ...(responseMessage ? [responseMessage] : [])]
478+
const produced = [...(newUIMessages ?? []), ...(responseMessage ? [responseMessage] : [])]
478479
.map((message) => (message as { id?: unknown }).id)
479480
.filter((id): id is string => typeof id === "string");
480481

0 commit comments

Comments
 (0)