Skip to content

Commit 8e36f12

Browse files
committed
chore: merge fix/watch-mode-keepalive-tri-13065 (review fixes)
2 parents f3d492d + a8ee578 commit 8e36f12

22 files changed

Lines changed: 459 additions & 85 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export function showWatchWakesSummaryToast(count: number, onOpenChat: () => void
126126
<WakeToastUI
127127
t={t}
128128
title="Watch updates"
129-
message={`${count} watch updates — open the chat panel.`}
129+
message={`${count} watch update${count === 1 ? "" : "s"} — open the chat panel.`}
130130
onOpenChat={onOpenChat}
131131
/>
132132
),

apps/webapp/app/components/dashboard-agent/turn-navigation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,6 @@ describe("the chat scopes a turn's navigation to the page it started on", () =>
127127
});
128128

129129
it("hands the persistent handled-set in, so drops are recorded across commits", () => {
130-
expect(chat).toContain("handled: navigatedRef.current!");
130+
expect(chat).toMatch(/handled:\s*navigatedRef\.current!/);
131131
});
132132
});

apps/webapp/app/components/dashboard-agent/watch-activity.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22

3+
/** The key the module under test writes; kept here so a rename fails loudly in one place. */
4+
const STORAGE_KEY = "tdev:dashboard-agent:watching";
5+
36
type StorageListener = (event: { key: string | null }) => void;
47

58
const store = new Map<string, string>();
@@ -27,8 +30,8 @@ const {
2730

2831
/** What another tab writing the key looks like here. */
2932
function otherTabWrote(organizationId: string) {
30-
store.set("tdev:dashboard-agent:watching", JSON.stringify([organizationId]));
31-
for (const listener of storageListeners) listener({ key: "tdev:dashboard-agent:watching" });
33+
store.set(STORAGE_KEY, JSON.stringify([organizationId]));
34+
for (const listener of storageListeners) listener({ key: STORAGE_KEY });
3235
}
3336

3437
describe("watch activity", () => {
@@ -98,20 +101,20 @@ describe("watch activity", () => {
98101

99102
describe("a corrupt key", () => {
100103
it("reads as nothing known when the value is not an array", () => {
101-
store.set("tdev:dashboard-agent:watching", JSON.stringify({ org_1: true }));
104+
store.set(STORAGE_KEY, JSON.stringify({ org_1: true }));
102105

103106
expect(hasWatchActivity("org_1")).toBe(false);
104107
expect(shouldPollWakeFeed({ serverUnreadWakes: 0, organizationId: "org_1" })).toBe(false);
105108
});
106109

107110
it("keeps the ids out of an array holding other things", () => {
108-
store.set("tdev:dashboard-agent:watching", JSON.stringify([{ id: "org_1" }, "org_2", 7]));
111+
store.set(STORAGE_KEY, JSON.stringify([{ id: "org_1" }, "org_2", 7]));
109112

110113
expect(hasWatchActivity("org_1")).toBe(false);
111114
expect(hasWatchActivity("org_2")).toBe(true);
112115

113116
rememberWatchActivity("org_3");
114-
expect(store.get("tdev:dashboard-agent:watching")).toBe(JSON.stringify(["org_2", "org_3"]));
117+
expect(store.get(STORAGE_KEY)).toBe(JSON.stringify(["org_2", "org_3"]));
115118
});
116119
});
117120

apps/webapp/app/components/dashboard-agent/watch-card-state.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ describe("every chat change goes through one door", () => {
9494
it("bumps the open sequence in exactly one place, next to the card reset", () => {
9595
const bumps = panel.match(/openChatRequestSeq\.current\s*(\+\+|\+=)|\+\+openChatRequestSeq/g);
9696
expect(bumps).toHaveLength(1);
97-
expect(panel).toContain(
98-
'dispatchWatchCard({ type: "chat-changed" });\n return ++openChatRequestSeq.current;'
97+
// Whitespace-tolerant: the formatter is free to reindent or rewrap these two lines.
98+
expect(panel).toMatch(
99+
/dispatchWatchCard\(\{\s*type:\s*"chat-changed",?\s*\}\);\s*return \+\+openChatRequestSeq\.current;/
99100
);
100101
});
101102

apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { watchSpecSchema } from "@internal/dashboard-agent-contracts";
33
import { z } from "zod";
44
import { logger } from "~/services/logger.server";
55
import { resolveWatchEmailAlertsState } from "~/services/dashboardAgentWatchAlerts.server";
6+
import { watchErrorStatus } from "~/services/dashboardAgentWatchErrorStatus.server";
67
import {
78
authorizeWatchEnvironmentById,
89
createDashboardAgentWatch,
@@ -110,24 +111,13 @@ export async function action({ request }: ActionFunctionArgs) {
110111
});
111112

112113
if (!result.ok) {
113-
const status =
114-
result.code === "limit_reached" || result.code === "duplicate"
115-
? 409
116-
: result.code === "invalid_target"
117-
? 404
118-
: // The chat was deleted while the create was in flight.
119-
result.code === "chat_not_found"
120-
? 404
121-
: result.code === "not_configured"
122-
? 501
123-
: 500;
124114
return json(
125115
{
126116
error: result.error,
127117
code: result.code,
128118
...(result.existingId ? { existingId: result.existingId } : {}),
129119
},
130-
{ status }
120+
{ status: watchErrorStatus(result.code) }
131121
);
132122
}
133123

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

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
startDashboardAgentSession,
5151
} from "~/services/dashboardAgent.server";
5252
import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server";
53+
import { watchErrorStatus } from "~/services/dashboardAgentWatchErrorStatus.server";
5354
import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server";
5455
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
5556
import { logger } from "~/services/logger.server";
@@ -525,23 +526,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
525526
});
526527

527528
if (!result.ok) {
528-
const status =
529-
result.code === "limit_reached" ||
530-
result.code === "duplicate" ||
531-
result.code === "request_conflict"
532-
? 409
533-
: result.code === "invalid_target" || result.code === "chat_not_found"
534-
? 404
535-
: result.code === "not_configured"
536-
? 501
537-
: 500;
538529
return json(
539530
{
540531
error: result.error,
541532
code: result.code,
542533
...(result.existingId ? { existingId: result.existingId } : {}),
543534
},
544-
{ status }
535+
{ status: watchErrorStatus(result.code) }
545536
);
546537
}
547538

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { SubmitWatchErrorCode } from "./dashboardAgentWatches.server";
2+
3+
/**
4+
* One HTTP status per watch error code, so the resources route and the API route can't
5+
* drift on what a refusal means.
6+
*/
7+
const STATUS_BY_CODE: Record<SubmitWatchErrorCode, number> = {
8+
limit_reached: 409,
9+
duplicate: 409,
10+
request_conflict: 409,
11+
invalid_target: 404,
12+
chat_not_found: 404,
13+
not_configured: 501,
14+
internal: 500,
15+
};
16+
17+
export function watchErrorStatus(code: SubmitWatchErrorCode): number {
18+
return STATUS_BY_CODE[code];
19+
}

apps/webapp/test/dashboardAgentAlertAdminPreview.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* preview the agent honours has to be read off the user row.
66
*/
77

8-
import { beforeEach, describe, expect, test, vi } from "vitest";
8+
import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
99

1010
const ctx = vi.hoisted(() => ({ admin: false }));
1111

@@ -18,10 +18,10 @@ vi.mock("~/db.server", () => {
1818
return { prisma: db, $replica: db, sqlDatabaseSchema: undefined };
1919
});
2020

21-
process.env.SESSION_SECRET = "test-session-secret-for-alert-admin-preview";
21+
vi.stubEnv("SESSION_SECRET", "test-session-secret-for-alert-admin-preview");
2222
// The install this is previewed on: the flag is off for everyone else.
23-
process.env.DASHBOARD_AGENT_ADMIN_PREVIEW = "1";
24-
delete process.env.DASHBOARD_AGENT_ENABLED;
23+
vi.stubEnv("DASHBOARD_AGENT_ADMIN_PREVIEW", "1");
24+
vi.stubEnv("DASHBOARD_AGENT_ENABLED", undefined);
2525

2626
const { canUseDashboardAgentAlerts } = await import("~/services/dashboardAgentWatchAlerts.server");
2727

@@ -31,6 +31,10 @@ beforeEach(() => {
3131
ctx.admin = false;
3232
});
3333

34+
afterAll(() => {
35+
vi.unstubAllEnvs();
36+
});
37+
3438
describe("watch alerts during the admin preview", () => {
3539
test("let an admin's watch alert, exactly as the agent lets them create it", async () => {
3640
ctx.admin = true;

apps/webapp/test/dashboardAgentLastReadBackfill.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import {
66
} from "@internal/dashboard-agent-db";
77
import { postgresTest } from "@internal/testcontainers";
88
import type { PrismaClient } from "@trigger.dev/database";
9-
import { readFileSync } from "node:fs";
9+
import { readdirSync, readFileSync } from "node:fs";
1010
import path from "node:path";
11-
import { afterEach, describe, expect } from "vitest";
11+
import { afterEach, describe, expect, it } from "vitest";
1212

1313
/**
1414
* `chats.last_read_at` is nullable and every reader treats NULL as unread, so without a
@@ -82,6 +82,16 @@ async function readLastReadAt(prisma: PrismaClient): Promise<Record<string, Date
8282
return Object.fromEntries(rows.map((row) => [row.id, row.last_read_at]));
8383
}
8484

85+
describe("the replayed migration list", () => {
86+
it("is the first migrations on disk, so a renamed or inserted one fails here", () => {
87+
const onDisk = readdirSync(DRIZZLE)
88+
.filter((file) => file.endsWith(".sql"))
89+
.sort();
90+
91+
expect(onDisk.slice(0, MIGRATIONS.length)).toEqual(MIGRATIONS);
92+
});
93+
});
94+
8595
let agentDbClient: DashboardAgentDbClient | undefined;
8696

8797
afterEach(async () => {

apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import {
2+
claimWatchDelivery,
23
createChat,
34
createDashboardAgentDb,
5+
createWatch,
6+
deleteTerminalWatchesOlderThan,
7+
getWatch,
48
listChats,
9+
releaseWatchDelivery,
510
softDeleteChat,
11+
softDeleteChatsForOrganization,
12+
transitionWatchCondition,
613
type DashboardAgentDb,
714
type DashboardAgentDbClient,
815
} from "@internal/dashboard-agent-db";
@@ -98,3 +105,97 @@ describe("softDeleteChat tenant isolation", () => {
98105
30_000
99106
);
100107
});
108+
109+
/**
110+
* Both delivery sweeps skip a watch whose chat is deleted, and retention only deletes rows
111+
* whose delivery is settled — so a wake still owed when the chat goes would keep its snapshot
112+
* until the chat's hard delete (30 days) instead of the much shorter watch retention cutoff.
113+
*/
114+
describe("deleting a chat settles the wakes it still owes", () => {
115+
async function firedWatch(db: DashboardAgentDb, chatId: string) {
116+
const created = await createWatch(db, {
117+
chatId,
118+
identity: `run_finished:run_${chatId}`,
119+
spec: {
120+
kind: "run_finished",
121+
runId: `run_${chatId}`,
122+
checkEveryMinutes: 1,
123+
maxHours: 2,
124+
} as never,
125+
organizationId: ORG,
126+
projectId: "proj_1",
127+
environmentId: "env_1",
128+
userId: USER,
129+
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
130+
});
131+
if (!created.ok) throw new Error(`the watch wasn't created: ${created.error}`);
132+
133+
const fired = await transitionWatchCondition(db, {
134+
id: created.watch.id,
135+
resolution: "condition_met",
136+
lastResult: { runs: 1 },
137+
});
138+
expect(fired?.deliveryStatus).toBe("pending");
139+
return created.watch.id;
140+
}
141+
142+
postgresTest(
143+
"softDeleteChat settles a fired watch's pending delivery, so retention can reclaim it",
144+
async ({ prisma, postgresContainer }) => {
145+
const db = await boot(prisma, postgresContainer.getConnectionUri());
146+
await createChat(db, { id: "chat_1", organizationId: ORG, userId: USER });
147+
const watchId = await firedWatch(db, "chat_1");
148+
149+
await softDeleteChat(db, { chatId: "chat_1", userId: USER, organizationId: ORG });
150+
151+
expect((await getWatch(db, { id: watchId }))?.deliveryStatus).toBe("not_required");
152+
expect(
153+
await deleteTerminalWatchesOlderThan(db, { before: new Date(Date.now() + 60_000) })
154+
).toBe(1);
155+
},
156+
30_000
157+
);
158+
159+
postgresTest(
160+
"a delivery claimed in flight is settled too, claim and all",
161+
async ({ prisma, postgresContainer }) => {
162+
const db = await boot(prisma, postgresContainer.getConnectionUri());
163+
await createChat(db, { id: "chat_1", organizationId: ORG, userId: USER });
164+
const watchId = await firedWatch(db, "chat_1");
165+
166+
// Deleted while a deliverer holds the claim: no sweep can reach the row afterwards, so
167+
// it would sit in `delivering` for as long as the chat does.
168+
const claim = await claimWatchDelivery(db, {
169+
id: watchId,
170+
staleBefore: new Date(Date.now() - 60_000),
171+
});
172+
expect(claim?.watch.deliveryStatus).toBe("delivering");
173+
174+
await softDeleteChat(db, { chatId: "chat_1", userId: USER, organizationId: ORG });
175+
176+
const settled = await getWatch(db, { id: watchId });
177+
expect(settled?.deliveryStatus).toBe("not_required");
178+
expect(settled?.deliveryClaimId).toBeNull();
179+
expect(settled?.deliveryClaimedAt).toBeNull();
180+
181+
// The deliverer that still holds the old claim can no longer move the row either way.
182+
await releaseWatchDelivery(db, { id: watchId, claimId: claim!.claimId });
183+
expect((await getWatch(db, { id: watchId }))?.deliveryStatus).toBe("not_required");
184+
},
185+
30_000
186+
);
187+
188+
postgresTest(
189+
"softDeleteChatsForOrganization settles them too",
190+
async ({ prisma, postgresContainer }) => {
191+
const db = await boot(prisma, postgresContainer.getConnectionUri());
192+
await createChat(db, { id: "chat_1", organizationId: ORG, userId: USER });
193+
const watchId = await firedWatch(db, "chat_1");
194+
195+
expect(await softDeleteChatsForOrganization(db, { organizationId: ORG })).toBe(1);
196+
197+
expect((await getWatch(db, { id: watchId }))?.deliveryStatus).toBe("not_required");
198+
},
199+
30_000
200+
);
201+
});

0 commit comments

Comments
 (0)