From 4efe0d1ecf4f5b384f4ba1b10650b95fdff4da59 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 10:37:25 +0000 Subject: [PATCH 01/12] test(webapp): pin cross-tenant isolation for the chat store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat/session belongs to one (org, user) pair. Pin that every store read — getChatMessages, getSession, chatExists, listChats, countUserMessages — and appendChatMessageOnce refuse a foreign tenant, so a chatId from another org reads as not-found and never leaks a transcript or the session's public access token. TRI-11166. --- .../dashboardAgentTenantIsolation.test.ts | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 apps/webapp/test/dashboardAgentTenantIsolation.test.ts diff --git a/apps/webapp/test/dashboardAgentTenantIsolation.test.ts b/apps/webapp/test/dashboardAgentTenantIsolation.test.ts new file mode 100644 index 0000000000..3aeb1bc16e --- /dev/null +++ b/apps/webapp/test/dashboardAgentTenantIsolation.test.ts @@ -0,0 +1,240 @@ +import { + appendChatMessageOnce, + chatExists, + countUserMessages, + createChat, + createDashboardAgentDb, + getChatMessages, + getSession, + listChats, + persistTurn, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect } from "vitest"; + +/** + * Cross-tenant isolation for the chat store, against a real table (TRI-11166). + * + * The 2026-06-10 chat.agent audit flagged a cross-tenant read: a chat/session belongs to + * one (org, user) pair, and every read that hands back its transcript or its session token + * has to be scoped by that pair. A chatId from another tenant must read as not-found — never + * as another tenant's transcript, and never as another tenant's public access token, which + * is the credential a resumed session boots from. + * + * The store's own queries are the floor: the resource route scopes on project.organizationId + * above this, but a bug there would still be caught here because these queries refuse a + * foreign (org, user) outright rather than trusting the caller. + */ + +let agentDb: DashboardAgentDb; +let agentDbClient: DashboardAgentDbClient | undefined; + +const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + +/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */ +async function applyAgentSchema(prisma: PrismaClient) { + for (const name of readdirSync(MIGRATIONS) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(MIGRATIONS, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +// Org A owns the chat. Org B and a same-org other user are the foreign tenants. +const ORG_A = "org_a"; +const USER_A = "user_a"; +const ORG_B = "org_b"; +const USER_B = "user_b"; +const CHAT = "chat_owned_by_a"; + +async function boot(prisma: PrismaClient, connectionUri: string) { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + agentDb = agentDbClient.db; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +function textMessage(id: string, role: "user" | "assistant" = "assistant") { + return { id, role, parts: [{ type: "text", text: id }] }; +} + +/** Seed a chat under org A with a transcript and a live session (its PAT is the credential). */ +async function seedOwnedChat() { + await createChat(agentDb, { id: CHAT, organizationId: ORG_A, userId: USER_A }); + await persistTurn(agentDb, { + chatId: CHAT, + messages: [textMessage("u1", "user"), textMessage("a1")], + session: { publicAccessToken: "pat_secret_of_a", lastEventId: "42", runId: "run_a" }, + }); +} + +const foreignScopes = [ + { name: "another org", organizationId: ORG_B, userId: USER_B }, + // Same org, different user: a member of A's org still isn't the chat's owner. + { name: "another user in the same org", organizationId: ORG_A, userId: USER_B }, + // Right user id, wrong org: the id alone must not carry across a tenant boundary. + { name: "the owner's user id under another org", organizationId: ORG_B, userId: USER_A }, +]; + +describe("getChatMessages is scoped to the owning (org, user)", () => { + postgresTest( + "the owner reads the transcript; every foreign tenant reads not-found", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + const owned = await getChatMessages(agentDb, { + chatId: CHAT, + organizationId: ORG_A, + userId: USER_A, + }); + expect((owned as { id: string }[]).map((m) => m.id)).toEqual(["u1", "a1"]); + + for (const scope of foreignScopes) { + // null is not-found. It must never be [] (a visible-but-empty chat) and never A's rows. + const seen = await getChatMessages(agentDb, { + chatId: CHAT, + organizationId: scope.organizationId, + userId: scope.userId, + }); + expect(seen, scope.name).toBeNull(); + } + }, + 30_000 + ); +}); + +describe("getSession never hands a foreign tenant the owner's access token", () => { + postgresTest( + "the owner gets the session; every foreign tenant gets null", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + const owned = await getSession(agentDb, { + chatId: CHAT, + organizationId: ORG_A, + userId: USER_A, + }); + expect(owned?.publicAccessToken).toBe("pat_secret_of_a"); + + for (const scope of foreignScopes) { + const seen = await getSession(agentDb, { + chatId: CHAT, + organizationId: scope.organizationId, + userId: scope.userId, + }); + // A leaked session row would carry A's PAT — the resume credential. Refuse outright. + expect(seen, scope.name).toBeNull(); + } + }, + 30_000 + ); +}); + +describe("chatExists is the owner check the action routes gate on", () => { + postgresTest( + "true for the owner, false for every foreign tenant", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + expect( + await chatExists(agentDb, { chatId: CHAT, organizationId: ORG_A, userId: USER_A }) + ).toBe(true); + for (const scope of foreignScopes) { + expect( + await chatExists(agentDb, { + chatId: CHAT, + organizationId: scope.organizationId, + userId: scope.userId, + }), + scope.name + ).toBe(false); + } + }, + 30_000 + ); +}); + +describe("listChats and countUserMessages never surface another tenant's chat", () => { + postgresTest( + "a foreign tenant lists nothing and counts nothing of the owner's", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + const ownedList = await listChats(agentDb, { organizationId: ORG_A, userId: USER_A }); + expect(ownedList.map((c) => c.id)).toEqual([CHAT]); + expect(await countUserMessages(agentDb, { organizationId: ORG_A, userId: USER_A })).toBe(1); + + for (const scope of foreignScopes) { + const list = await listChats(agentDb, { + organizationId: scope.organizationId, + userId: scope.userId, + }); + expect(list, scope.name).toEqual([]); + expect( + await countUserMessages(agentDb, { + organizationId: scope.organizationId, + userId: scope.userId, + }), + scope.name + ).toBe(0); + } + }, + 30_000 + ); +}); + +describe("a foreign org cannot append to another tenant's chat", () => { + postgresTest( + "appendChatMessageOnce with a foreign org writes nothing and leaves the transcript intact", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + const before = await getChatMessages(agentDb, { + chatId: CHAT, + organizationId: ORG_A, + userId: USER_A, + }); + + // A chat id from another org appends nothing when the org is verified. + const wroteForeignOrg = await appendChatMessageOnce(agentDb, { + chatId: CHAT, + userId: USER_A, + organizationId: ORG_B, + message: { id: "intruder", role: "assistant" }, + }); + expect(wroteForeignOrg).toBe(false); + + // And a foreign user, same org, is refused too. + const wroteForeignUser = await appendChatMessageOnce(agentDb, { + chatId: CHAT, + userId: USER_B, + organizationId: ORG_A, + message: { id: "intruder2", role: "assistant" }, + }); + expect(wroteForeignUser).toBe(false); + + expect( + await getChatMessages(agentDb, { chatId: CHAT, organizationId: ORG_A, userId: USER_A }) + ).toEqual(before); + }, + 30_000 + ); +}); From 34996fd291145f68e94ee9503f383da461c60cef Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 10:37:35 +0000 Subject: [PATCH 02/12] test(webapp): pin chat.agent turn durability across crash and resume The primitive resumes a turn by replaying its snapshot; pin the store seam that replay lands on: a streamed-then-resumed turn is not double-appended, a crash mid-turn keeps the mid-turn append and rebuilds the session cursor, a failed write commits nothing and the retry replays with no loss, and an OOM restart replays idempotently. TRI-11166. --- .../test/dashboardAgentDurableResume.test.ts | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 apps/webapp/test/dashboardAgentDurableResume.test.ts diff --git a/apps/webapp/test/dashboardAgentDurableResume.test.ts b/apps/webapp/test/dashboardAgentDurableResume.test.ts new file mode 100644 index 0000000000..fb0128ddde --- /dev/null +++ b/apps/webapp/test/dashboardAgentDurableResume.test.ts @@ -0,0 +1,317 @@ +import { + appendChatMessageOnceByChatId, + createChat, + createDashboardAgentDb, + getChatMessages, + getSession, + persistMessages, + persistTurn, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect } from "vitest"; + +/** + * Durability of a chat.agent turn across a crash and a resume, against a real table + * (TRI-11166). + * + * The primitive gives chat.agent durability by snapshotting the transcript and replaying it + * on the next boot. These tests pin the store seam that replay lands on: the completing turn + * re-sends its whole snapshot, so the store has to fold that replay into exactly one row per + * message — no double-appended turn, no lost mid-turn message — and reconstruct the session + * cursor a refreshed client resumes from. + * + * What is NOT covered here, because it lives inside the closed chat.agent primitive package + * (object-store snapshot write, S2 `.in`/`.out` replay, `.out` trimming, OOM restart): the + * transport-level replay and the snapshot URL's own auth. The client-side reconnect / Last- + * Event-ID replay is covered in packages/trigger-sdk/src/v3/chat.test.ts. These tests are the + * store-level backstop those depend on. See the PR body for the residual follow-ups. + */ + +let agentDb: DashboardAgentDb; +let agentDbClient: DashboardAgentDbClient | undefined; + +const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + +async function applyAgentSchema(prisma: PrismaClient) { + for (const name of readdirSync(MIGRATIONS) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(MIGRATIONS, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +const ORG = "org_resume"; +const USER = "user_resume"; + +async function boot(prisma: PrismaClient, connectionUri: string, chatId: string) { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + agentDb = agentDbClient.db; + await createChat(agentDb, { id: chatId, organizationId: ORG, userId: USER }); +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +function textMessage(id: string, role: "user" | "assistant" = "assistant", text = id) { + return { id, role, parts: [{ type: "text", text }] }; +} + +/** A tool part, so a mid-flight call and its completed result share an id but differ in body. */ +function toolMessage(id: string, state: "input-available" | "output-available") { + return { + id, + role: "assistant" as const, + parts: [{ type: "tool-get_query_schema", state, toolCallId: `${id}_call`, input: {} }], + }; +} + +async function transcript(chatId: string): Promise<{ id: string }[]> { + return (await getChatMessages(agentDb, { chatId, organizationId: ORG, userId: USER })) as { + id: string; + }[]; +} + +/** The allocator, where a wasted/duplicated slot is observable. */ +async function nextPosition(prisma: PrismaClient, chatId: string): Promise { + const rows = await prisma.$queryRawUnsafe<{ next_message_position: number }[]>( + `select next_message_position from trigger_dashboard_agent.chats where id = $1`, + chatId + ); + return rows[0]!.next_message_position; +} + +async function rowCount(prisma: PrismaClient, chatId: string): Promise { + const rows = await prisma.$queryRawUnsafe<{ count: bigint }[]>( + `select count(*)::int as count from trigger_dashboard_agent.chat_messages where chat_id = $1`, + chatId + ); + return Number(rows[0]!.count); +} + +describe("a streamed-then-resumed turn is not double-appended", () => { + postgresTest( + "re-delivering the completing turn finalises in place and appends nothing", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_no_double"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + // The turn started: onTurnStart stored the user turn and the tool call mid-flight. + await persistMessages(agentDb, { + chatId, + messages: [textMessage("u1", "user"), toolMessage("a1", "input-available")], + }); + expect(await rowCount(prisma, chatId)).toBe(2); + + const completing = { + chatId, + messages: [textMessage("u1", "user"), toolMessage("a1", "output-available")], + finalizeMessageIds: ["a1"], + session: { publicAccessToken: "pat", lastEventId: "7", runId: "run" }, + }; + + // The turn completes, replaying its whole snapshot. `a1` is finalised, not re-added. + await persistTurn(agentDb, completing); + // The resume: the same completed turn is delivered again (client reconnected and the + // host re-persisted). It must converge — no second `a1`, no extra row of any kind. + await persistTurn(agentDb, completing); + + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]); + expect(await rowCount(prisma, chatId)).toBe(2); + // Only u1 and a1 ever reserved a slot (allocator starts at 1); the finalisation and the + // replay reserve none, so the next free position is still 3. + expect(await nextPosition(prisma, chatId)).toBe(3); + // And `a1` is the completed body the user saw, not the mid-flight call. + const stored = (await transcript(chatId))[1] as unknown as { + parts: { state: string }[]; + }; + expect(stored.parts[0]!.state).toBe("output-available"); + }, + 30_000 + ); +}); + +describe("a crash mid-turn is reconstructed by the next boot's replay", () => { + postgresTest( + "the resumed turn keeps the mid-turn append, finalises its own message, and rebuilds the session cursor", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_crash_resume"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + // Turn in flight: the snapshot it started from, stored before the model finished. + const snapshot = [textMessage("u1", "user"), toolMessage("a1", "input-available")]; + await persistMessages(agentDb, { chatId, messages: snapshot }); + + // A wake lands mid-turn, off its own lane — the message the old replace-the-array + // write used to lose. + await appendChatMessageOnceByChatId(agentDb, { + chatId, + message: textMessage("wake:w1"), + }); + + // Before the crash there is no session row to resume from. + expect(await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })).toBeNull(); + + // Boot after the crash: replay the whole transcript, finalise the turn's own message, + // and write the session the client resumes from — all in one persistTurn. + await persistTurn(agentDb, { + chatId, + messages: [ + textMessage("u1", "user"), + toolMessage("a1", "output-available"), + textMessage("a2"), + ], + finalizeMessageIds: ["a1", "a2"], + session: { publicAccessToken: "pat_resumed", lastEventId: "99", runId: "run_resumed" }, + }); + + // Nothing was lost and the wake sits where it happened: after the snapshot, before the + // reply the turn went on to produce. + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "wake:w1", "a2"]); + + const session = await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }); + expect(session).toMatchObject({ + publicAccessToken: "pat_resumed", + lastEventId: "99", + runId: "run_resumed", + }); + }, + 30_000 + ); +}); + +describe("the session cursor a refreshed client resumes from", () => { + postgresTest( + "getSession returns the last persisted cursor, and a later turn advances it", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_cursor"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + await persistTurn(agentDb, { + chatId, + messages: [textMessage("u1", "user"), textMessage("a1")], + session: { publicAccessToken: "pat1", lastEventId: "10", runId: "run1" }, + }); + // A mid-stream refresh reads exactly this cursor and resumes .out from it. + expect( + (await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }))?.lastEventId + ).toBe("10"); + + // The next turn overwrites the cursor — a stale value is replaced, never appended. + await persistTurn(agentDb, { + chatId, + messages: [textMessage("u1", "user"), textMessage("a1"), textMessage("a2")], + session: { publicAccessToken: "pat2", lastEventId: "25", runId: "run2" }, + }); + const session = await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }); + expect(session).toMatchObject({ + publicAccessToken: "pat2", + lastEventId: "25", + runId: "run2", + }); + }, + 30_000 + ); +}); + +describe("a failed snapshot write leaves the next boot a clean replay", () => { + postgresTest( + "a persistTurn that throws commits nothing, and the retry replays with no loss", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_write_fail"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + // A durable first turn, and the session cursor it left. + await persistTurn(agentDb, { + chatId, + messages: [textMessage("u1", "user"), textMessage("a1")], + session: { publicAccessToken: "pat1", lastEventId: "1", runId: "run1" }, + }); + const positionBefore = await nextPosition(prisma, chatId); + + // The next turn's write fails partway — a malformed message with no id throws inside the + // transaction, after the (would-be) settlement/message work has begun. + await expect( + persistTurn(agentDb, { + chatId, + messages: [ + textMessage("u1", "user"), + textMessage("a1"), + textMessage("a2"), + { role: "assistant", parts: [] } as unknown as { id: string; role: string }, + ], + session: { publicAccessToken: "pat_torn", lastEventId: "2", runId: "run_torn" }, + }) + ).rejects.toThrow(/handed a message with no id/); + + // The whole turn rolled back: no new rows, allocator untouched, and — the version- + // mismatch case — the session cursor is still the first turn's, not the torn one's. + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]); + expect(await nextPosition(prisma, chatId)).toBe(positionBefore); + expect( + await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }) + ).toMatchObject({ publicAccessToken: "pat1", lastEventId: "1" }); + + // The retry — a clean replay of the same turn — lands everything exactly once. + await persistTurn(agentDb, { + chatId, + messages: [textMessage("u1", "user"), textMessage("a1"), textMessage("a2")], + session: { publicAccessToken: "pat2", lastEventId: "2", runId: "run2" }, + }); + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]); + expect( + await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }) + ).toMatchObject({ publicAccessToken: "pat2", lastEventId: "2" }); + }, + 30_000 + ); +}); + +describe("an OOM restart replays the turn cleanly", () => { + postgresTest( + "a restarted turn that re-sends its snapshot loses no data and doubles nothing", + async ({ prisma, postgresContainer }) => { + // The store seam an OOM restart lands on: the primitive restarts the run, replays `.in`, + // and re-persists. `.out` trimming and the OOM restart itself are inside the primitive + // (not reachable here) — this pins that a re-run's re-sent snapshot is idempotent. + const chatId = "chat_oom_restart"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + const firstAttempt = [textMessage("u1", "user"), toolMessage("a1", "input-available")]; + await persistMessages(agentDb, { chatId, messages: firstAttempt }); + const positionAfterFirst = await nextPosition(prisma, chatId); + + // The run OOMs and restarts. It replays the same input, produces the same ids, and + // finalises the turn it now completes. + const restarted = { + chatId, + messages: [ + textMessage("u1", "user"), + toolMessage("a1", "output-available"), + textMessage("a2"), + ], + finalizeMessageIds: ["a1", "a2"], + session: { publicAccessToken: "pat", lastEventId: "5", runId: "run_restarted" }, + }; + await persistTurn(agentDb, restarted); + // A second restart delivering the same turn again still converges. + await persistTurn(agentDb, restarted); + + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]); + // The replayed u1/a1 reserved no new slots; only a2 was genuinely new. + expect(await nextPosition(prisma, chatId)).toBe(positionAfterFirst + 1); + }, + 30_000 + ); +}); From 097bb5d38c7681db35be28866dab3ecf64dc9246 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 11:03:21 +0000 Subject: [PATCH 03/12] feat(webapp,dashboard-agent-db): server-side agent message quota Enforce the Free plan's agent-message allowance on the server, not just as a client hint. A per-(organizationId, period) counter lives in its own table, not joined to chats, so deleting a chat can no longer free quota inside the period. The create path and the .in append path each count one user message and refuse over the cap with a typed 403 the client renders as the upgrade block; wakes (action turns) never count. Fails open: an absent limit (self-hosted, or before the cloud side ships) or a counter read that throws means no cap. TRI-12863. --- .server-changes/agent-message-quota.md | 6 + .../dashboard-agent/DashboardAgentChat.tsx | 24 +- .../dashboard-agent/useAgentMessageQuota.ts | 28 +- ...aram.env.$envParam.dashboard-agent.in.$.ts | 23 + ...jectParam.env.$envParam.dashboard-agent.ts | 30 +- .../services/dashboardAgentQuota.server.ts | 100 ++ apps/webapp/test/dashboardAgentQuota.test.ts | 175 +++ .../drizzle/0004_stale_corsair.sql | 8 + .../drizzle/meta/0004_snapshot.json | 1344 +++++++++++++++++ .../drizzle/meta/_journal.json | 7 + .../dashboard-agent-db/src/queries.ts | 40 + .../dashboard-agent-db/src/schema.ts | 19 + 12 files changed, 1781 insertions(+), 23 deletions(-) create mode 100644 .server-changes/agent-message-quota.md create mode 100644 apps/webapp/app/services/dashboardAgentQuota.server.ts create mode 100644 apps/webapp/test/dashboardAgentQuota.test.ts create mode 100644 internal-packages/dashboard-agent-db/drizzle/0004_stale_corsair.sql create mode 100644 internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json diff --git a/.server-changes/agent-message-quota.md b/.server-changes/agent-message-quota.md new file mode 100644 index 0000000000..7236fb6ab3 --- /dev/null +++ b/.server-changes/agent-message-quota.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The Free plan now includes a monthly allowance of agent messages. When you reach it, the chat shows an upgrade prompt in place of the composer; your existing chats stay readable. diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index 19e65a570c..83b4dbebe5 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -17,6 +17,7 @@ import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; import { DashboardAgentHero } from "./DashboardAgentHero"; import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages"; import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits"; +import { FREE_PLAN_MESSAGE_LIMIT } from "./message-quota"; import { createTranscriptOrder, orderTranscript } from "./message-order"; import { navigateDestination } from "./navigate-target"; import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents"; @@ -102,6 +103,9 @@ export function DashboardAgentChat({ onActivityChange?: (chatId: string, activity: TurnActivity | null) => void; }) { const [input, setInput] = useState(""); + // Set when the server refuses a send over the cap, so the block shows at once rather than + // waiting for the next quota poll. + const [quotaReached, setQuotaReached] = useState<{ limit: number } | null>(null); const navigate = useNavigate(); const location = useLocation(); const toast = useToast(); @@ -128,6 +132,17 @@ export function DashboardAgentChat({ .catch(() => null)) as { error?: string } | null; throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR); } + // Over the message cap: show the upgrade block instead of a generic turn error. + if (res.status === 403) { + const data = (await res + .clone() + .json() + .catch(() => null)) as { error?: string; limit?: number } | null; + if (data?.error === "message_quota_reached") { + setQuotaReached({ limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT }); + throw new Error("You've reached your message limit."); + } + } return res; }, clientData, @@ -187,7 +202,10 @@ export function DashboardAgentChat({ // Counted here, not in the panel, so it includes the turn just sent. const quota = useAgentMessageQuota({ actionPath, chatId, messages }); - const atMessageCap = quota.kind === "reached"; + // Either the poll saw the cap, or a send was just refused over it. + const atMessageCap = quota.kind === "reached" || quotaReached !== null; + const messageCapLimit = + quotaReached?.limit ?? (quota.kind === "reached" ? quota.limit : FREE_PLAN_MESSAGE_LIMIT); const isStreaming = status === "streaming"; // From status, not the last part: the indicator must stay up through silent tool calls. @@ -414,9 +432,9 @@ export function DashboardAgentChat({ /> )} {watchCard ?
{watchCard}
: null} - {quota.kind === "reached" ? ( + {atMessageCap ? ( (undefined); + const [used, setUsed] = useState(undefined); + const sentCount = countUserMessages(messages); useEffect(() => { if (isFreePlan !== true) return; const controller = new AbortController(); void (async () => { try { - const res = await fetch(`${actionPath}?quota=1&chatId=${encodeURIComponent(chatId)}`, { - signal: controller.signal, - }); + const res = await fetch(`${actionPath}?quota=1`, { signal: controller.signal }); if (!res.ok) return; const data = (await res.json()) as { used?: number }; - if (typeof data.used === "number") setUsedElsewhere(data.used); + if (typeof data.used === "number") setUsed(data.used); } catch { // Leave the count unknown, which means no cap. See `resolveMessageQuota`. } })(); return () => controller.abort(); - }, [isFreePlan, actionPath, chatId]); + }, [isFreePlan, actionPath, chatId, sentCount]); - return resolveMessageQuota({ - isFreePlan, - used: usedElsewhere === undefined ? undefined : usedElsewhere + countUserMessages(messages), - }); + return resolveMessageQuota({ isFreePlan, used }); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts index 5939e836db..8563e7fba4 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts @@ -15,6 +15,12 @@ import { resolveDashboardAgentRepoSnapshot, } from "~/services/dashboardAgent.server"; import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server"; +import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; +import { + agentTurnCountsAgainstQuota, + recordAgentMessageSent, + resolveAgentMessageQuota, +} from "~/services/dashboardAgentQuota.server"; import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; import { readBoundedBodyText } from "~/utils/boundedRequestBody.server"; @@ -127,6 +133,17 @@ export async function action({ request, params }: ActionFunctionArgs) { return tooLarge(); } + // Only a real user message consumes quota; action turns were refused above. + const countsAgainstQuota = agentTurnCountsAgainstQuota(parsed); + if (countsAgainstQuota) { + const quota = await resolveAgentMessageQuota(dashboardAgentDb, { + organizationId: project.organizationId, + }); + if (quota?.reached) { + return json({ error: "message_quota_reached", limit: quota.limit }, { status: 403 }); + } + } + let userActorToken: string; try { userActorToken = await mintDashboardAgentUserActorToken(user.id, { @@ -153,6 +170,12 @@ export async function action({ request, params }: ActionFunctionArgs) { ...(repoSnapshot ? { repoSnapshot } : {}), }; body = JSON.stringify(parsed); + + if (countsAgainstQuota) { + await recordAgentMessageSent(dashboardAgentDb, { + organizationId: project.organizationId, + }); + } } } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index 2bf8b7168e..c01d9cc2d4 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -3,7 +3,7 @@ import { chatExists, countUnreadWatchWakes, countChatsWithUnreadWork, - countUserMessages, + getAgentMessageUsage, createChat, getChatMessages, getSession, @@ -52,6 +52,11 @@ import { import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server"; import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server"; import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; +import { + currentAgentMessagePeriod, + recordAgentMessageSent, + resolveAgentMessageQuota, +} from "~/services/dashboardAgentQuota.server"; import { logger } from "~/services/logger.server"; import { resolveTriggerUri } from "~/services/resolveTriggerUri.server"; import { requireUser } from "~/services/session.server"; @@ -150,13 +155,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) return json({ error: "Project not found" }, { status: 404 }); - // The open chat is excluded and counted from the live transcript instead, so an - // unpersisted turn still counts against the cap. + // The per-period counter, org-wide: a deleted chat can't lower it within the period. if (searchParams.get("quota") === "1") { - const used = await countUserMessages(dashboardAgentDb, { + const used = await getAgentMessageUsage(dashboardAgentDb, { organizationId: project.organizationId, - userId, - excludeChatId: searchParams.get("chatId") ?? undefined, + period: currentAgentMessagePeriod(), }); return json({ used }); } @@ -290,6 +293,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return messageTooLarge(); } + const quota = await resolveAgentMessageQuota(dashboardAgentDb, { + organizationId: project.organizationId, + }); + if (quota?.reached) { + return json({ error: "message_quota_reached", limit: quota.limit }, { status: 403 }); + } + let clientData: Record | undefined; try { clientData = parsed.data.clientData @@ -383,6 +393,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { throw error; } + // Only the head start dispatches the first message here; a cold start sends it through + // the `in` proxy, which counts it there. Counting both would double-count. + if (headStarted) { + await recordAgentMessageSent(dashboardAgentDb, { + organizationId: project.organizationId, + }); + } + let publicAccessToken: string; try { publicAccessToken = await mintDashboardAgentToken(chatId); diff --git a/apps/webapp/app/services/dashboardAgentQuota.server.ts b/apps/webapp/app/services/dashboardAgentQuota.server.ts new file mode 100644 index 0000000000..9a4e9629bd --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentQuota.server.ts @@ -0,0 +1,100 @@ +import type { Limits } from "@trigger.dev/platform"; +import { + getAgentMessageUsage, + incrementAgentMessageUsage, + type DashboardAgentDb, +} from "@internal/dashboard-agent-db"; +import { getCachedLimit } from "./platform.v3.server"; +import { logger } from "./logger.server"; + +// The repo's unlimited sentinel. Never Infinity: it serializes to null in the limit cache. +export const UNLIMITED_AGENT_MESSAGES = 100_000_000; + +// Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted, +// so the fallback applies and the cap is effectively off. +const AGENT_MESSAGE_LIMIT_KEY = "agentMessages" as keyof Limits; + +/** The billing period the counter is scoped to: a UTC calendar month, "YYYY-MM". */ +export function currentAgentMessagePeriod(now: Date = new Date()): string { + return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`; +} + +/** Pure so the send routes and, later, the MCP path share one rule. */ +export function checkAgentMessageQuota({ used, limit }: { used: number; limit: number }): { + reached: boolean; +} { + return { reached: used >= limit }; +} + +export type AgentMessageQuota = { reached: boolean; used: number; limit: number }; + +/** + * The period counter and the cached plan limit for one org. Fails open: an absent limit + * (self-hosted, or before the cloud side ships) resolves to the unlimited sentinel, and a + * counter read that throws returns `undefined` — either way there is no cap. + */ +export async function resolveAgentMessageQuota( + db: DashboardAgentDb, + params: { + organizationId: string; + now?: Date; + readLimit?: (organizationId: string) => Promise; + } +): Promise { + const readLimit = + params.readLimit ?? + (async (organizationId: string) => { + const cached = await getCachedLimit( + organizationId, + AGENT_MESSAGE_LIMIT_KEY, + UNLIMITED_AGENT_MESSAGES + ); + // A cache error leaves `val` empty; fall open to unlimited. + return cached.val ?? UNLIMITED_AGENT_MESSAGES; + }); + try { + const [limit, used] = await Promise.all([ + readLimit(params.organizationId), + getAgentMessageUsage(db, { + organizationId: params.organizationId, + period: currentAgentMessagePeriod(params.now), + }), + ]); + return { ...checkAgentMessageQuota({ used, limit }), used, limit }; + } catch (error) { + logger.error("Failed to resolve dashboard agent message quota", { + organizationId: params.organizationId, + error, + }); + return undefined; + } +} + +/** Record one sent user message. Swallows errors: the cap is a nudge, never a send blocker. */ +export async function recordAgentMessageSent( + db: DashboardAgentDb, + params: { organizationId: string; now?: Date } +): Promise { + try { + await incrementAgentMessageUsage(db, { + organizationId: params.organizationId, + period: currentAgentMessagePeriod(params.now), + }); + } catch (error) { + logger.error("Failed to record a dashboard agent message against the quota", { + organizationId: params.organizationId, + error, + }); + } +} + +/** + * Whether an agent turn consumes quota. A wake/action turn is server-placed — the user never + * spent it — so only a `message` turn that is not an action counts. The `.in` proxy already + * refuses action turns; this keeps the rule explicit and testable. + */ +export function agentTurnCountsAgainstQuota( + turn: { kind?: string; payload?: { trigger?: string } } | undefined +): boolean { + return turn?.kind === "message" && turn.payload?.trigger !== "action"; +} diff --git a/apps/webapp/test/dashboardAgentQuota.test.ts b/apps/webapp/test/dashboardAgentQuota.test.ts new file mode 100644 index 0000000000..8c5da8b6f0 --- /dev/null +++ b/apps/webapp/test/dashboardAgentQuota.test.ts @@ -0,0 +1,175 @@ +import { + createChat, + createDashboardAgentDb, + getAgentMessageUsage, + incrementAgentMessageUsage, + softDeleteChat, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + agentTurnCountsAgainstQuota, + checkAgentMessageQuota, + currentAgentMessagePeriod, + resolveAgentMessageQuota, + UNLIMITED_AGENT_MESSAGES, +} from "~/services/dashboardAgentQuota.server"; + +/** + * Server-side agent message quota (TRI-12863): a per-(org, period) counter that a deleted chat + * can't lower, a pure at/over/under rule, and a resolver that fails open when the limit is + * absent (self-hosted) or the counter read throws. + */ + +const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + +async function applyAgentSchema(prisma: PrismaClient) { + for (const name of readdirSync(MIGRATIONS) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(MIGRATIONS, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +const ORG = "org_quota"; +const USER = "user_quota"; + +let agentDbClient: DashboardAgentDbClient | undefined; + +async function boot(prisma: PrismaClient, connectionUri: string): Promise { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + return agentDbClient.db; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +describe("checkAgentMessageQuota", () => { + it("is not reached under the limit", () => { + expect(checkAgentMessageQuota({ used: 5, limit: 20 })).toEqual({ reached: false }); + }); + + it("is reached at the limit", () => { + // Control break: `>=`. Flip to `>` and this fails. + expect(checkAgentMessageQuota({ used: 20, limit: 20 })).toEqual({ reached: true }); + }); + + it("is reached over the limit", () => { + expect(checkAgentMessageQuota({ used: 21, limit: 20 })).toEqual({ reached: true }); + }); + + it("is never reached against the unlimited sentinel", () => { + expect(checkAgentMessageQuota({ used: 10_000, limit: UNLIMITED_AGENT_MESSAGES })).toEqual({ + reached: false, + }); + }); +}); + +describe("agentTurnCountsAgainstQuota", () => { + it("counts a user message", () => { + expect(agentTurnCountsAgainstQuota({ kind: "message", payload: {} })).toBe(true); + }); + + it("does not count a wake (action turn)", () => { + // Control break: the `!== "action"` guard. Remove it and this fails. + expect(agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "action" } })).toBe( + false + ); + }); + + it("does not count a non-message turn or a missing body", () => { + expect(agentTurnCountsAgainstQuota({ kind: "action" })).toBe(false); + expect(agentTurnCountsAgainstQuota(undefined)).toBe(false); + }); +}); + +describe("currentAgentMessagePeriod", () => { + it("is a zero-padded UTC calendar month", () => { + expect(currentAgentMessagePeriod(new Date(Date.UTC(2026, 7, 9)))).toBe("2026-08"); + expect(currentAgentMessagePeriod(new Date(Date.UTC(2026, 0, 1)))).toBe("2026-01"); + }); +}); + +describe("the per-(org, period) counter", () => { + postgresTest( + "accumulates and a deleted chat cannot free quota within the period", + async ({ prisma, postgresContainer }) => { + const db = await boot(prisma, postgresContainer.getConnectionUri()); + const period = "2026-08"; + + // The create path and then an append: two messages, same period. + expect(await incrementAgentMessageUsage(db, { organizationId: ORG, period })).toBe(1); + expect(await incrementAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2); + expect(await getAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2); + + // Deleting a chat must not move the counter: it is not joined to chats. + await createChat(db, { id: "chat_del", organizationId: ORG, userId: USER }); + await softDeleteChat(db, { chatId: "chat_del", userId: USER }); + expect(await getAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2); + + // The next period and other orgs start fresh. + expect(await getAgentMessageUsage(db, { organizationId: ORG, period: "2026-09" })).toBe(0); + expect(await getAgentMessageUsage(db, { organizationId: "org_other", period })).toBe(0); + } + ); +}); + +describe("resolveAgentMessageQuota", () => { + postgresTest( + "reports reached over the limit, and never reached when unlimited", + async ({ prisma, postgresContainer }) => { + const db = await boot(prisma, postgresContainer.getConnectionUri()); + const now = new Date(); + const period = currentAgentMessagePeriod(now); + for (let i = 0; i < 3; i++) { + await incrementAgentMessageUsage(db, { organizationId: ORG, period }); + } + + expect( + await resolveAgentMessageQuota(db, { organizationId: ORG, now, readLimit: async () => 3 }) + ).toEqual({ + reached: true, + used: 3, + limit: 3, + }); + expect( + await resolveAgentMessageQuota(db, { organizationId: ORG, now, readLimit: async () => 20 }) + ).toEqual({ reached: false, used: 3, limit: 20 }); + + // Self-hosted: the limit is absent, so the fallback (unlimited sentinel) applies and there + // is no cap — no extra branching, it falls out of the fallback. + const selfHosted = await resolveAgentMessageQuota(db, { + organizationId: ORG, + now, + readLimit: async () => UNLIMITED_AGENT_MESSAGES, + }); + expect(selfHosted?.reached).toBe(false); + } + ); + + it("fails open when the counter read throws", async () => { + const throwingDb = { + select: () => { + throw new Error("db down"); + }, + } as unknown as DashboardAgentDb; + + const result = await resolveAgentMessageQuota(throwingDb, { + organizationId: ORG, + readLimit: async () => 5, + }); + expect(result).toBeUndefined(); + }); +}); diff --git a/internal-packages/dashboard-agent-db/drizzle/0004_stale_corsair.sql b/internal-packages/dashboard-agent-db/drizzle/0004_stale_corsair.sql new file mode 100644 index 0000000000..d581b4fec3 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0004_stale_corsair.sql @@ -0,0 +1,8 @@ +CREATE TABLE "trigger_dashboard_agent"."agent_message_usage" ( + "organization_id" text NOT NULL, + "period" text NOT NULL, + "count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agent_message_usage_organization_id_period_pk" PRIMARY KEY("organization_id","period") +); diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000000..5ee40ae24f --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1344 @@ +{ + "id": "f7cbfef4-7fc8-4deb-8da2-59248b242a60", + "prevId": "efb6f8b8-af9f-4ba7-9e38-bafd1f430b28", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.agent_message_usage": { + "name": "agent_message_usage", + "schema": "trigger_dashboard_agent", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_message_usage_organization_id_period_pk": { + "name": "agent_message_usage_organization_id_period_pk", + "columns": ["organization_id", "period"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_messages": { + "name": "chat_messages", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_chat_user_role_idx": { + "name": "chat_messages_chat_user_role_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_messages\".\"role\" = 'user'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_messages_chat_id_message_id_pk": { + "name": "chat_messages_chat_id_message_id_pk", + "columns": ["chat_id", "message_id"] + } + }, + "uniqueConstraints": { + "chat_messages_chat_position_key": { + "name": "chat_messages_chat_position_key", + "nullsNotDistinct": false, + "columns": ["chat_id", "position"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_created_idx": { + "name": "chat_turn_evals_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_message_position": { + "name": "next_message_position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_open_updated_idx": { + "name": "investigations_open_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"investigations\".\"state\"->>'outcome' = 'in_progress'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_batches": { + "name": "watch_batches", + "schema": "trigger_dashboard_agent", + "columns": { + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "armed_at": { + "name": "armed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_batches_environment_id_cadence_minutes_pk": { + "name": "watch_batches_environment_id_cadence_minutes_pk", + "columns": ["environment_id", "cadence_minutes"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_submissions": { + "name": "watch_submissions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_hash": { + "name": "draft_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "watch_id": { + "name": "watch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unavailable": { + "name": "unavailable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_notification_status": { + "name": "external_notification_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_requested'" + }, + "external_notification_reason": { + "name": "external_notification_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "immediate_result": { + "name": "immediate_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_code": { + "name": "refusal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_error": { + "name": "refusal_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_existing_id": { + "name": "refusal_existing_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watch_submissions_created_idx": { + "name": "watch_submissions_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_submissions_chat_id_client_request_id_pk": { + "name": "watch_submissions_chat_id_client_request_id_pk", + "columns": ["chat_id", "client_request_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_outcome": { + "name": "observed_outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "investigate_on_attention": { + "name": "investigate_on_attention", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claimed_at": { + "name": "delivery_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claim_id": { + "name": "delivery_claim_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "alert_dispatch_key": { + "name": "alert_dispatch_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_at": { + "name": "retention_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "greatest(delivered_at, cancelled_at, fired_at, last_checked_at, created_at)", + "type": "stored" + } + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "((spec ->> 'checkEveryMinutes')::int)", + "type": "stored" + } + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_wake_idx": { + "name": "watches_org_user_wake_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" = 'delivered' and \"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_active_idx": { + "name": "watches_org_user_active_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_active_env_cadence_idx": { + "name": "watches_active_env_cadence_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"last_attempted_at\", \"last_checked_at\", \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_env_cadence_delivery_idx": { + "name": "watches_env_cadence_delivery_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_retention_idx": { + "name": "watches_retention_idx", + "columns": [ + { + "expression": "retention_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired', 'cancelled') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('not_required', 'delivered')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json index 9efe7bc1a1..1f33e4ddf8 100644 --- a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json +++ b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1786264383741, "tag": "0003_backfill_chat_last_read_at", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1786359241538, + "tag": "0004_stale_corsair", + "breakpoints": true } ] } diff --git a/internal-packages/dashboard-agent-db/src/queries.ts b/internal-packages/dashboard-agent-db/src/queries.ts index 5c1df944e9..a86a05c890 100644 --- a/internal-packages/dashboard-agent-db/src/queries.ts +++ b/internal-packages/dashboard-agent-db/src/queries.ts @@ -8,6 +8,7 @@ import type { DashboardAgentDb } from "./client.js"; import { generateInvestigationId } from "./ids.js"; import { lockChatForWatches, type DashboardAgentDbOrTx } from "./internal.js"; import { + agentMessageUsage, chatMessages, chats, chatSessions, @@ -120,6 +121,45 @@ export async function countUserMessages( return rows[0]?.count ?? 0; } +/** + * The message count for one org in one billing period. Reads the standalone counter, + * never the chat rows, so a deleted chat can't lower it within the period. `period` is + * a UTC calendar month, "YYYY-MM"; the caller chooses it. + */ +export async function getAgentMessageUsage( + db: DashboardAgentDb, + params: { organizationId: string; period: string } +): Promise { + const rows = await db + .select({ count: agentMessageUsage.count }) + .from(agentMessageUsage) + .where( + and( + eq(agentMessageUsage.organizationId, params.organizationId), + eq(agentMessageUsage.period, params.period) + ) + ) + .limit(1); + return rows[0]?.count ?? 0; +} + +/** Bump the counter by one, creating the period row on first use. Returns the new count. */ +export async function incrementAgentMessageUsage( + db: DashboardAgentDb, + params: { organizationId: string; period: string; by?: number } +): Promise { + const by = params.by ?? 1; + const rows = await db + .insert(agentMessageUsage) + .values({ organizationId: params.organizationId, period: params.period, count: by }) + .onConflictDoUpdate({ + target: [agentMessageUsage.organizationId, agentMessageUsage.period], + set: { count: sql`${agentMessageUsage.count} + ${by}`, updatedAt: sql`now()` }, + }) + .returning({ count: agentMessageUsage.count }); + return rows[0]?.count ?? by; +} + /** * Chats whose transcript moved on after their owner last looked. A watch wake is one way * that happens; an answer that landed while the panel was closed is another, and the panel diff --git a/internal-packages/dashboard-agent-db/src/schema.ts b/internal-packages/dashboard-agent-db/src/schema.ts index 471abef81c..010d35040b 100644 --- a/internal-packages/dashboard-agent-db/src/schema.ts +++ b/internal-packages/dashboard-agent-db/src/schema.ts @@ -180,6 +180,23 @@ export const investigations = dashboardAgentSchema.table( ] ); +/** + * Per-(org, period) message counter. Deliberately not joined to chats: deleting a chat + * must not free quota inside the period. `period` is a UTC calendar month, "YYYY-MM". + * Org id is a main-DB id with no FK. + */ +export const agentMessageUsage = dashboardAgentSchema.table( + "agent_message_usage", + { + organizationId: text("organization_id").notNull(), + period: text("period").notNull(), + count: integer("count").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [primaryKey({ columns: [t.organizationId, t.period] })] +); + export type Chat = typeof chats.$inferSelect; export type NewChat = typeof chats.$inferInsert; export type ChatMessage = typeof chatMessages.$inferSelect; @@ -190,3 +207,5 @@ export type ChatTurnEval = typeof chatTurnEvals.$inferSelect; export type NewChatTurnEval = typeof chatTurnEvals.$inferInsert; export type Investigation = typeof investigations.$inferSelect; export type NewInvestigation = typeof investigations.$inferInsert; +export type AgentMessageUsage = typeof agentMessageUsage.$inferSelect; +export type NewAgentMessageUsage = typeof agentMessageUsage.$inferInsert; From 50ccb2af0964fe3a8379ace058fb7beb2480e1f6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 12:39:40 +0000 Subject: [PATCH 04/12] fix(webapp): the quota limit shows a friendly upgrade prompt, not the error code --- .../dashboard-agent/AgentUpgradeGate.tsx | 11 +++-- .../dashboard-agent/DashboardAgentChat.tsx | 7 ++-- .../dashboard-agent/DashboardAgentDraft.tsx | 38 +++++++++++++----- .../dashboard-agent/DashboardAgentPanel.tsx | 13 ++++++ .../dashboard-agent/message-quota.test.ts | 40 ++++++++++++++++++- .../dashboard-agent/message-quota.ts | 22 ++++++++++ 6 files changed, 110 insertions(+), 21 deletions(-) diff --git a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx index 4b795edde9..498a043157 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx @@ -1,9 +1,10 @@ import { Link } from "@remix-run/react"; +import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { LinkButton } from "~/components/primitives/Buttons"; import { useOrganization } from "~/hooks/useOrganizations"; -import { cn } from "~/utils/cn"; import { v3BillingPath } from "~/utils/pathBuilder"; -import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity"; +import { ASK_AGENT_LABEL } from "./agent-identity"; +import { messageQuotaReachedCopy } from "./message-quota"; // Matches the composer's outer geometry so the replacement lands in the same place. const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1"; @@ -22,14 +23,12 @@ export function AgentUpgradeBlock({ {context}
- + Upgrade to unlock {ASK_AGENT_LABEL}
-

- You've used all {limit} messages included on the Free plan. Your chats stay here to read. -

+

{messageQuotaReachedCopy(limit)}

Upgrade diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index 83b4dbebe5..b07e612a6b 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -17,7 +17,7 @@ import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; import { DashboardAgentHero } from "./DashboardAgentHero"; import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages"; import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits"; -import { FREE_PLAN_MESSAGE_LIMIT } from "./message-quota"; +import { FREE_PLAN_MESSAGE_LIMIT, parseQuotaReachedResponse } from "./message-quota"; import { createTranscriptOrder, orderTranscript } from "./message-order"; import { navigateDestination } from "./navigate-target"; import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents"; @@ -138,8 +138,9 @@ export function DashboardAgentChat({ .clone() .json() .catch(() => null)) as { error?: string; limit?: number } | null; - if (data?.error === "message_quota_reached") { - setQuotaReached({ limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT }); + const reached = parseQuotaReachedResponse(res.status, data); + if (reached) { + setQuotaReached(reached); throw new Error("You've reached your message limit."); } } diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx index 9698329abc..6b7387ca93 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx @@ -1,5 +1,6 @@ import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts"; import { useCallback, useMemo, useState } from "react"; +import { AgentUpgradeBlock } from "./AgentUpgradeGate"; import { DashboardAgentComposer } from "./DashboardAgentComposer"; import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; import { DashboardAgentHero } from "./DashboardAgentHero"; @@ -16,6 +17,7 @@ export function DashboardAgentDraft({ pageContext, promotedPrompt, watchCard, + capReached, }: { onSubmit: (text: string) => void; projectSlug: string; @@ -24,6 +26,7 @@ export function DashboardAgentDraft({ pageContext?: AgentPageContext; promotedPrompt?: SuggestedPrompt; watchCard?: React.ReactNode; + capReached?: { limit: number } | null; }) { const [input, setInput] = useState(""); @@ -57,16 +60,9 @@ export function DashboardAgentDraft({ pageContext={pageContext} promoted={promotedPrompt} composer={ -
- {watchCard} - submit(input)} - onStop={() => {}} - isStreaming={false} - placeholderSuggestion={watchCard ? undefined : placeholderSuggestion} + capReached ? ( + } /> -
+ ) : ( +
+ {watchCard} + submit(input)} + onStop={() => {}} + isStreaming={false} + placeholderSuggestion={watchCard ? undefined : placeholderSuggestion} + context={ + + } + /> +
+ ) } /> ); diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx index e813c87f91..bb418d4f71 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx @@ -24,6 +24,7 @@ import { writeLastChat, } from "./last-chat-storage"; import { DashboardAgentDraft } from "./DashboardAgentDraft"; +import { parseQuotaReachedResponse } from "./message-quota"; import { WatchCard } from "./WatchCard"; import { watchDraftFor } from "./watch-card"; import { NO_WATCH_CARD, watchCardReducer } from "./watch-card-state"; @@ -108,6 +109,8 @@ export function DashboardAgentPanel({ // Until the list has arrived, the page load's server count is the better answer. const [chatsLoaded, setChatsLoaded] = useState(false); const [active, setActive] = useState(null); + // A refused `create` over the cap: the draft shows the upgrade block instead of a raw toast. + const [capReached, setCapReached] = useState<{ limit: number } | null>(null); // Starts true so an `openWith` request waits for the restore instead of racing it. const [loading, setLoading] = useState( () => readLastChat(storageKey)?.path === location.pathname @@ -238,14 +241,22 @@ export function DashboardAgentPanel({ publicAccessToken?: string; headStarted?: boolean; error?: string; + limit?: number; }; if (seq !== openChatRequestSeq.current) return; if (!res.ok || !data.chatId || !data.publicAccessToken) { + const reached = parseQuotaReachedResponse(res.status, data); + if (reached) { + setCapReached(reached); + setActive(null); + return; + } console.error(`Dashboard agent: failed to create chat (${res.status})`, data.error); toast.error(data.error ?? "We couldn't start that chat. Try again in a moment."); setActive(null); return; } + setCapReached(null); setActive({ chatId: data.chatId, organizationId: organization.id, @@ -287,6 +298,7 @@ export function DashboardAgentPanel({ panelOrg.current = organization.id; claimChatSlot(); setActive(null); + setCapReached(null); setLoading(false); setChats([]); setChatsLoaded(false); @@ -614,6 +626,7 @@ export function DashboardAgentPanel({ pageContext={pageContext} promotedPrompt={promotedPrompt} watchCard={watchCardElement} + capReached={capReached} /> )} diff --git a/apps/webapp/app/components/dashboard-agent/message-quota.test.ts b/apps/webapp/app/components/dashboard-agent/message-quota.test.ts index 5a252db7ac..3b47395e99 100644 --- a/apps/webapp/app/components/dashboard-agent/message-quota.test.ts +++ b/apps/webapp/app/components/dashboard-agent/message-quota.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { countUserMessages, FREE_PLAN_MESSAGE_LIMIT, resolveMessageQuota } from "./message-quota"; +import { + countUserMessages, + FREE_PLAN_MESSAGE_LIMIT, + MESSAGE_QUOTA_REACHED_ERROR, + messageQuotaReachedCopy, + parseQuotaReachedResponse, + resolveMessageQuota, +} from "./message-quota"; describe("resolveMessageQuota", () => { it("caps a Free plan at the limit", () => { @@ -42,6 +49,37 @@ describe("resolveMessageQuota", () => { }); }); +describe("parseQuotaReachedResponse", () => { + it("maps a create/in 403 cap body to the limit", () => { + // Both the create path and the `in` transport refuse with this exact body. + expect( + parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR, limit: 20 }) + ).toEqual({ limit: 20 }); + }); + + it("falls back to the free limit when the body omits it", () => { + expect(parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR })).toEqual({ + limit: FREE_PLAN_MESSAGE_LIMIT, + }); + }); + + it("ignores other errors and non-403 statuses so they surface normally", () => { + expect(parseQuotaReachedResponse(403, { error: "something_else" })).toBeNull(); + expect(parseQuotaReachedResponse(500, { error: MESSAGE_QUOTA_REACHED_ERROR })).toBeNull(); + expect(parseQuotaReachedResponse(403, null)).toBeNull(); + }); +}); + +describe("messageQuotaReachedCopy", () => { + it("is a friendly sentence naming the limit, never the raw code", () => { + const copy = messageQuotaReachedCopy(20); + expect(copy).toContain("all 20 messages"); + expect(copy).toContain("Free plan"); + // Control break: if the mapping leaked the server code, this fails. + expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR); + }); +}); + describe("countUserMessages", () => { it("counts only what the user sent", () => { expect( diff --git a/apps/webapp/app/components/dashboard-agent/message-quota.ts b/apps/webapp/app/components/dashboard-agent/message-quota.ts index f65481c870..c603a836a2 100644 --- a/apps/webapp/app/components/dashboard-agent/message-quota.ts +++ b/apps/webapp/app/components/dashboard-agent/message-quota.ts @@ -27,6 +27,28 @@ export function resolveMessageQuota({ : { kind: "within", used, limit, remaining }; } +// The server code both the create and `in` paths refuse with. The client owns the copy, +// so this code must never reach the UI as text. +export const MESSAGE_QUOTA_REACHED_ERROR = "message_quota_reached"; + +// Maps a 403 refusal body to the cap signal, or null for any other error. Both paths use +// this so a `message_quota_reached` code routes to the upgrade block, never a raw toast. +export function parseQuotaReachedResponse( + status: number, + data: { error?: string; limit?: number } | null | undefined +): { limit: number } | null { + if (status === 403 && data?.error === MESSAGE_QUOTA_REACHED_ERROR) { + return { limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT }; + } + return null; +} + +// The upgrade block's sentence. Pure so the copy is asserted directly, and so the raw +// server code can never be what the user reads. +export function messageQuotaReachedCopy(limit: number): string { + return `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`; +} + // A watch's consent record is a user message the person never typed, so it is // excluded here exactly as the stored count excludes it. export function countUserMessages(messages: { role: string; id?: string }[]): number { From 12785db42ac0267dcba0d0d4fb94976ae1125be5 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 18:57:38 +0000 Subject: [PATCH 05/12] fix(webapp): only charge agent message quota on a delivered send A failed upstream send (5xx/502) or a non-2xx response burned a quota message that never reached the agent. Record only after upstream.ok. --- .../dashboard-agent-quota-failed-sends.md | 6 ++++++ ...Param.env.$envParam.dashboard-agent.in.$.ts | 18 +++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 .server-changes/dashboard-agent-quota-failed-sends.md diff --git a/.server-changes/dashboard-agent-quota-failed-sends.md b/.server-changes/dashboard-agent-quota-failed-sends.md new file mode 100644 index 0000000000..75c8eb4a23 --- /dev/null +++ b/.server-changes/dashboard-agent-quota-failed-sends.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Messages to the dashboard agent that fail to send no longer count against your monthly message allowance. Only delivered messages are counted. diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts index 8563e7fba4..8591f410b5 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts @@ -121,6 +121,9 @@ export async function action({ request, params }: ActionFunctionArgs) { parsed = undefined; } + // Hoisted so it is visible after the fetch: quota is charged only once the send succeeds. + let countsAgainstQuota = false; + if (parsed) { // Actions are placed by the server only, and this proxy is the one path a browser // can reach `.in` through. @@ -134,7 +137,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } // Only a real user message consumes quota; action turns were refused above. - const countsAgainstQuota = agentTurnCountsAgainstQuota(parsed); + countsAgainstQuota = agentTurnCountsAgainstQuota(parsed); if (countsAgainstQuota) { const quota = await resolveAgentMessageQuota(dashboardAgentDb, { organizationId: project.organizationId, @@ -170,12 +173,6 @@ export async function action({ request, params }: ActionFunctionArgs) { ...(repoSnapshot ? { repoSnapshot } : {}), }; body = JSON.stringify(parsed); - - if (countsAgainstQuota) { - await recordAgentMessageSent(dashboardAgentDb, { - organizationId: project.organizationId, - }); - } } } @@ -188,6 +185,13 @@ export async function action({ request, params }: ActionFunctionArgs) { try { const upstream = await fetch(upstreamUrl, { method: "POST", headers, body }); const text = await upstream.text(); + // Charge quota only for a delivered message: a non-2xx upstream (or a throw below) + // must not burn a send that never reached the agent. + if (countsAgainstQuota && upstream.ok) { + await recordAgentMessageSent(dashboardAgentDb, { + organizationId: project.organizationId, + }); + } return new Response(text, { status: upstream.status, headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" }, From 423d0a9256b3e0065c5ccc43c4f2df7e32479ce6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 18:57:49 +0000 Subject: [PATCH 06/12] fix(webapp): guard capped agent send paths and settle the quota re-read Draft submit and chat retry now bail when the message cap is reached, so a suggested prompt or retry over the cap no longer fires a silent 403. The capped draft keeps any open watch card. The quota re-reads when a turn settles instead of on optimistic append, so the count and cap no longer lag by one message. --- .../dashboard-agent/DashboardAgentChat.tsx | 8 +++--- .../dashboard-agent/DashboardAgentDraft.tsx | 27 +++++++++++-------- .../dashboard-agent/useAgentMessageQuota.ts | 27 ++++++++++++------- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index b07e612a6b..aaecd89e40 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -201,8 +201,8 @@ export function DashboardAgentChat({ const orderRef = useRef(createTranscriptOrder(initialMessages)); const messages = orderTranscript(rawMessages, orderRef.current); - // Counted here, not in the panel, so it includes the turn just sent. - const quota = useAgentMessageQuota({ actionPath, chatId, messages }); + // Read here, not in the panel, so it re-reads as each turn settles. + const quota = useAgentMessageQuota({ actionPath, chatId, status }); // Either the poll saw the cap, or a send was just refused over it. const atMessageCap = quota.kind === "reached" || quotaReached !== null; const messageCapLimit = @@ -271,6 +271,8 @@ export function DashboardAgentChat({ }, [sendRequest, submit, canSend]); const retry = useCallback(() => { + // Over the cap, a retry only earns another 403 — same guard as `submit`. + if (atMessageCap) return; // A watch's consent record is a user message nobody typed, so retry never treats it as one. const action = retryAction( messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id))) @@ -283,7 +285,7 @@ export function DashboardAgentChat({ return; } void sendMessage({ text: action.text, messageId: action.messageId }); - }, [messages, sendMessage, regenerate, clearError]); + }, [messages, sendMessage, regenerate, clearError, atMessageCap]); const resolveUri = useTriggerUriResolver(actionPath); diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx index 6b7387ca93..bff6ebdf22 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx @@ -46,12 +46,14 @@ export function DashboardAgentDraft({ const submit = useCallback( (text: string) => { + // Suggested prompts reach here via the hero, bypassing the composer's cap guard. + if (capReached) return; const trimmed = text.trim(); if (!trimmed) return; setInput(""); onSubmit(trimmed); }, - [onSubmit] + [onSubmit, capReached] ); return ( @@ -61,16 +63,19 @@ export function DashboardAgentDraft({ promoted={promotedPrompt} composer={ capReached ? ( - - } - /> +
+ {watchCard} + + } + /> +
) : (
{watchCard} diff --git a/apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts b/apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts index 8413d5fcef..958d8dfcc1 100644 --- a/apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts +++ b/apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts @@ -1,7 +1,6 @@ -import type { UIMessage } from "@ai-sdk/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; -import { countUserMessages, resolveMessageQuota, type MessageQuota } from "./message-quota"; +import { resolveMessageQuota, type MessageQuota } from "./message-quota"; // Gated on billing PRESENCE, not the plan value: no subscription means billing isn't wired // up (self-hosted), so there is no cap and no upgrade UI. A wired-up, non-paying plan is free. @@ -11,20 +10,30 @@ function useIsFreePlan(): boolean | undefined { return subscription.isPaying === false; } -// `used` is the server's per-period count for the org. Re-read whenever the user sends, so -// the running total tracks the message just sent without counting the transcript twice. +// `used` is the server's per-period count for the org. Re-read once a turn settles — the +// server increment happens mid-turn in the `.in` proxy, so reading on optimistic append +// would lag the count by one message and show the cap a message late. export function useAgentMessageQuota({ actionPath, chatId, - messages, + status, }: { actionPath: string; chatId: string; - messages: UIMessage[]; + status: string; }): MessageQuota { const isFreePlan = useIsFreePlan(); const [used, setUsed] = useState(undefined); - const sentCount = countUserMessages(messages); + + // Bumped each time the status leaves streaming/submitted, which drives the re-read. + const [settleTick, setSettleTick] = useState(0); + const prevStatus = useRef(status); + useEffect(() => { + const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted"; + const nowSettled = status === "ready" || status === "error"; + prevStatus.current = status; + if (wasInFlight && nowSettled) setSettleTick((tick) => tick + 1); + }, [status]); useEffect(() => { if (isFreePlan !== true) return; @@ -40,7 +49,7 @@ export function useAgentMessageQuota({ } })(); return () => controller.abort(); - }, [isFreePlan, actionPath, chatId, sentCount]); + }, [isFreePlan, actionPath, chatId, settleTick]); return resolveMessageQuota({ isFreePlan, used }); } From 57a84f1c30303f4ec7850fc41f30c3f8e8210328 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 11 Aug 2026 10:55:15 +0000 Subject: [PATCH 07/12] chore(server-changes): consolidate the agent message-quota notes into one --- .server-changes/agent-message-quota.md | 2 +- .server-changes/dashboard-agent-quota-failed-sends.md | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 .server-changes/dashboard-agent-quota-failed-sends.md diff --git a/.server-changes/agent-message-quota.md b/.server-changes/agent-message-quota.md index 7236fb6ab3..296b082de7 100644 --- a/.server-changes/agent-message-quota.md +++ b/.server-changes/agent-message-quota.md @@ -3,4 +3,4 @@ area: webapp type: feature --- -The Free plan now includes a monthly allowance of agent messages. When you reach it, the chat shows an upgrade prompt in place of the composer; your existing chats stay readable. +The dashboard agent now comes with a monthly message allowance. A message that fails to send doesn't count against it. diff --git a/.server-changes/dashboard-agent-quota-failed-sends.md b/.server-changes/dashboard-agent-quota-failed-sends.md deleted file mode 100644 index 75c8eb4a23..0000000000 --- a/.server-changes/dashboard-agent-quota-failed-sends.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Messages to the dashboard agent that fail to send no longer count against your monthly message allowance. Only delivered messages are counted. From 19b49c1ea1f4185c21e3ebd05b4cdeb9527e50f3 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 11 Aug 2026 11:02:34 +0000 Subject: [PATCH 08/12] fix(webapp): don't charge the message quota for a retry/regenerate --- .../app/services/dashboardAgentQuota.server.ts | 9 +++++---- apps/webapp/test/dashboardAgentQuota.test.ts | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/services/dashboardAgentQuota.server.ts b/apps/webapp/app/services/dashboardAgentQuota.server.ts index 9a4e9629bd..4bfca8808b 100644 --- a/apps/webapp/app/services/dashboardAgentQuota.server.ts +++ b/apps/webapp/app/services/dashboardAgentQuota.server.ts @@ -89,12 +89,13 @@ export async function recordAgentMessageSent( } /** - * Whether an agent turn consumes quota. A wake/action turn is server-placed — the user never - * spent it — so only a `message` turn that is not an action counts. The `.in` proxy already - * refuses action turns; this keeps the rule explicit and testable. + * Whether an agent turn consumes quota. Only a genuine new user message counts: the transport + * tags it `trigger: "submit-message"`. A retry/regenerate re-runs the agent from its own history + * without a new message (`trigger: "regenerate-message"`), and a wake is `"action"` — neither is + * something the user typed, so neither counts. */ export function agentTurnCountsAgainstQuota( turn: { kind?: string; payload?: { trigger?: string } } | undefined ): boolean { - return turn?.kind === "message" && turn.payload?.trigger !== "action"; + return turn?.kind === "message" && turn.payload?.trigger === "submit-message"; } diff --git a/apps/webapp/test/dashboardAgentQuota.test.ts b/apps/webapp/test/dashboardAgentQuota.test.ts index 905e42242b..21d5e46ffe 100644 --- a/apps/webapp/test/dashboardAgentQuota.test.ts +++ b/apps/webapp/test/dashboardAgentQuota.test.ts @@ -78,12 +78,21 @@ describe("checkAgentMessageQuota", () => { }); describe("agentTurnCountsAgainstQuota", () => { - it("counts a user message", () => { - expect(agentTurnCountsAgainstQuota({ kind: "message", payload: {} })).toBe(true); + it("counts a genuine new user message (submit-message)", () => { + expect( + agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "submit-message" } }) + ).toBe(true); + }); + + it("does not count a retry/regenerate", () => { + // Control break: a regenerate re-runs from history with no new message, so it must not + // burn quota. Widen the rule back to `!== "action"` and this fails. + expect( + agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "regenerate-message" } }) + ).toBe(false); }); it("does not count a wake (action turn)", () => { - // Control break: the `!== "action"` guard. Remove it and this fails. expect(agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "action" } })).toBe( false ); From a95e926e485d8de0897988df67178452f36f4a3e Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 11 Aug 2026 17:06:18 +0000 Subject: [PATCH 09/12] fix(webapp): share the message-quota refusal code between server and client --- ...rojects.$projectParam.env.$envParam.dashboard-agent.in.$.ts | 3 ++- ...lug.projects.$projectParam.env.$envParam.dashboard-agent.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts index 8591f410b5..655da1d9aa 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts @@ -7,6 +7,7 @@ import { MESSAGE_TOO_LARGE_CODE, MESSAGE_TOO_LARGE_ERROR, } from "~/components/dashboard-agent/message-limits"; +import { MESSAGE_QUOTA_REACHED_ERROR } from "~/components/dashboard-agent/message-quota"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { @@ -143,7 +144,7 @@ export async function action({ request, params }: ActionFunctionArgs) { organizationId: project.organizationId, }); if (quota?.reached) { - return json({ error: "message_quota_reached", limit: quota.limit }, { status: 403 }); + return json({ error: MESSAGE_QUOTA_REACHED_ERROR, limit: quota.limit }, { status: 403 }); } } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index 4eee3149c9..8e51c04937 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -28,6 +28,7 @@ import { MESSAGE_TOO_LARGE_CODE, MESSAGE_TOO_LARGE_ERROR, } from "~/components/dashboard-agent/message-limits"; +import { MESSAGE_QUOTA_REACHED_ERROR } from "~/components/dashboard-agent/message-quota"; import { MAX_URIS_PER_RESOLVE_REQUEST } from "~/components/dashboard-agent/resolve-uris"; import { $replica } from "~/db.server"; import { env } from "~/env.server"; @@ -297,7 +298,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { organizationId: project.organizationId, }); if (quota?.reached) { - return json({ error: "message_quota_reached", limit: quota.limit }, { status: 403 }); + return json({ error: MESSAGE_QUOTA_REACHED_ERROR, limit: quota.limit }, { status: 403 }); } let clientData: Record | undefined; From 06ca8d19124a45c57f5b86a2ee73b1548f4a509e Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 11 Aug 2026 22:04:15 +0000 Subject: [PATCH 10/12] test(webapp): exercise a mid-write turn failure in the durable-resume suite --- .../test/dashboardAgentDurableResume.test.ts | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/apps/webapp/test/dashboardAgentDurableResume.test.ts b/apps/webapp/test/dashboardAgentDurableResume.test.ts index fb0128ddde..8cfd857e10 100644 --- a/apps/webapp/test/dashboardAgentDurableResume.test.ts +++ b/apps/webapp/test/dashboardAgentDurableResume.test.ts @@ -227,38 +227,46 @@ describe("the session cursor a refreshed client resumes from", () => { describe("a failed snapshot write leaves the next boot a clean replay", () => { postgresTest( - "a persistTurn that throws commits nothing, and the retry replays with no loss", + "a persistTurn that throws mid-write rolls back what it already wrote, and the retry replays with no loss", async ({ prisma, postgresContainer }) => { const chatId = "chat_write_fail"; await boot(prisma, postgresContainer.getConnectionUri(), chatId); - // A durable first turn, and the session cursor it left. + // A durable first turn, its tool call still mid-flight, and the session cursor it left. await persistTurn(agentDb, { chatId, - messages: [textMessage("u1", "user"), textMessage("a1")], + messages: [textMessage("u1", "user"), toolMessage("a1", "input-available")], session: { publicAccessToken: "pat1", lastEventId: "1", runId: "run1" }, }); const positionBefore = await nextPosition(prisma, chatId); - // The next turn's write fails partway — a malformed message with no id throws inside the - // transaction, after the (would-be) settlement/message work has begun. + // The next turn's write fails *after* it has written: a message that carries an id but no + // role clears the up-front id check, so the store finalises `a1` in place and reserves the + // slots for the new messages before the missing role throws. Everything already written + // has to come back out. await expect( persistTurn(agentDb, { chatId, messages: [ textMessage("u1", "user"), - textMessage("a1"), + toolMessage("a1", "output-available"), textMessage("a2"), - { role: "assistant", parts: [] } as unknown as { id: string; role: string }, + { id: "a3", parts: [] } as unknown as { id: string; role: string }, ], + finalizeMessageIds: ["a1"], session: { publicAccessToken: "pat_torn", lastEventId: "2", runId: "run_torn" }, }) - ).rejects.toThrow(/handed a message with no id/); + ).rejects.toThrow(/handed a message with no role/); - // The whole turn rolled back: no new rows, allocator untouched, and — the version- - // mismatch case — the session cursor is still the first turn's, not the torn one's. + // The whole turn rolled back. The in-place rewrite the store had already applied is undone: + // `a1` is the mid-flight call again, not the finalised body the torn turn wrote. expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]); + const tornA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] }; + expect(tornA1.parts[0]!.state).toBe("input-available"); + expect(await rowCount(prisma, chatId)).toBe(2); + // The slots it reserved for `a2`/`a3` came back too, so the retry doesn't leave a gap. expect(await nextPosition(prisma, chatId)).toBe(positionBefore); + // The cursor is still the first turn's: the failed turn never got as far as writing one. expect( await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }) ).toMatchObject({ publicAccessToken: "pat1", lastEventId: "1" }); @@ -266,10 +274,19 @@ describe("a failed snapshot write leaves the next boot a clean replay", () => { // The retry — a clean replay of the same turn — lands everything exactly once. await persistTurn(agentDb, { chatId, - messages: [textMessage("u1", "user"), textMessage("a1"), textMessage("a2")], + messages: [ + textMessage("u1", "user"), + toolMessage("a1", "output-available"), + textMessage("a2"), + ], + finalizeMessageIds: ["a1"], session: { publicAccessToken: "pat2", lastEventId: "2", runId: "run2" }, }); expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]); + const retriedA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] }; + expect(retriedA1.parts[0]!.state).toBe("output-available"); + // One new row, one new slot: the rolled-back reservation was not double-counted. + expect(await nextPosition(prisma, chatId)).toBe(positionBefore + 1); expect( await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }) ).toMatchObject({ publicAccessToken: "pat2", lastEventId: "2" }); From 656d607c899e8b5b9930a3dc0ce38b98b2d93d2d Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 11 Aug 2026 22:38:00 +0000 Subject: [PATCH 11/12] test(webapp): tear the durable-resume turn with an order-independent constraint violation --- .../test/dashboardAgentDurableResume.test.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/webapp/test/dashboardAgentDurableResume.test.ts b/apps/webapp/test/dashboardAgentDurableResume.test.ts index 8cfd857e10..8fd75d52e0 100644 --- a/apps/webapp/test/dashboardAgentDurableResume.test.ts +++ b/apps/webapp/test/dashboardAgentDurableResume.test.ts @@ -240,10 +240,19 @@ describe("a failed snapshot write leaves the next boot a clean replay", () => { }); const positionBefore = await nextPosition(prisma, chatId); - // The next turn's write fails *after* it has written: a message that carries an id but no - // role clears the up-front id check, so the store finalises `a1` in place and reserves the - // slots for the new messages before the missing role throws. Everything already written - // has to come back out. + // Tear the next turn at the INSERT itself, so the failure lands after `a1` is finalised + // in place and after the slots are reserved no matter how the store orders its up-front + // validation. A row planted directly at the position the allocator is about to hand out + // makes that insert violate `chat_messages_chat_position_key`. Scaffolding, not part of + // the transcript under test — removed once the tear has fired. + await prisma.$executeRawUnsafe( + `insert into trigger_dashboard_agent.chat_messages (chat_id, message_id, position, role, message) + values ($1, 'planted_collision', $2, 'assistant', '{}'::jsonb)`, + chatId, + positionBefore + ); + + // The driver names the failing statement, so the rejection itself pins where the tear fired. await expect( persistTurn(agentDb, { chatId, @@ -251,12 +260,16 @@ describe("a failed snapshot write leaves the next boot a clean replay", () => { textMessage("u1", "user"), toolMessage("a1", "output-available"), textMessage("a2"), - { id: "a3", parts: [] } as unknown as { id: string; role: string }, ], finalizeMessageIds: ["a1"], session: { publicAccessToken: "pat_torn", lastEventId: "2", runId: "run_torn" }, }) - ).rejects.toThrow(/handed a message with no role/); + ).rejects.toThrow(/Failed query: insert into .*chat_messages/); + + await prisma.$executeRawUnsafe( + `delete from trigger_dashboard_agent.chat_messages where chat_id = $1 and message_id = 'planted_collision'`, + chatId + ); // The whole turn rolled back. The in-place rewrite the store had already applied is undone: // `a1` is the mid-flight call again, not the finalised body the torn turn wrote. @@ -264,7 +277,7 @@ describe("a failed snapshot write leaves the next boot a clean replay", () => { const tornA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] }; expect(tornA1.parts[0]!.state).toBe("input-available"); expect(await rowCount(prisma, chatId)).toBe(2); - // The slots it reserved for `a2`/`a3` came back too, so the retry doesn't leave a gap. + // The slot it reserved for `a2` came back too, so the retry doesn't leave a gap. expect(await nextPosition(prisma, chatId)).toBe(positionBefore); // The cursor is still the first turn's: the failed turn never got as far as writing one. expect( From e449743380e4ce80e555f2f2f99f33c9dc364729 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 12 Aug 2026 10:27:12 +0200 Subject: [PATCH 12/12] feat(webapp): enforce watch plan limits (#4556) **What & why.** Watches now honour a plan's watch limits. A watch whose window exceeds the plan's `agentWatchMaxHours`, or that would push the org past its `agentWatchers` count, is refused with a new `watch_limit_reached` result and an upgrade hint (a chat line on the card, HTTP 409 on the API). **Key decisions.** - Plan limits are a floor *below* the existing code ceilings: `min(plan, WATCH_MAX_HOURS=24)` for the window, and the per-chat cap of 3 still applies independently. Plans only tighten, never loosen. - Watcher count is org-wide and checked only after the immediate check declines, so a one-shot consumes no slot. - Fails open: an absent limit resolves to the unlimited sentinel, so self-hosted is unaffected; the upgrade nudge is gated on `isBillingConfigured()`. - Follow-up: quiet Pro-mark on the card's long-window options. TRI-12863 --- .server-changes/agent-watch-plan-limits.md | 6 + ...dashboardAgentInvestigationSweep.server.ts | 60 + .../dashboardAgentWatchErrorStatus.server.ts | 1 + .../dashboardAgentWatchLimits.server.ts | 59 + .../services/dashboardAgentWatches.server.ts | 42 + .../webapp/app/services/platform.v3.server.ts | 31 + .../dashboardAgentInvestigationPoison.test.ts | 166 ++ .../dashboardAgentWatchLimitStatus.test.ts | 163 ++ .../test/dashboardAgentWatchLimits.test.ts | 411 +++++ .../drizzle/0005_ambitious_mordo.sql | 2 + .../drizzle/meta/0005_snapshot.json | 1357 +++++++++++++++++ .../drizzle/meta/_journal.json | 7 + .../dashboard-agent-db/src/queries.ts | 33 +- .../dashboard-agent-db/src/schema.ts | 4 + .../dashboard-agent-db/src/watch-queries.ts | 16 + 15 files changed, 2357 insertions(+), 1 deletion(-) create mode 100644 .server-changes/agent-watch-plan-limits.md create mode 100644 apps/webapp/app/services/dashboardAgentWatchLimits.server.ts create mode 100644 apps/webapp/test/dashboardAgentInvestigationPoison.test.ts create mode 100644 apps/webapp/test/dashboardAgentWatchLimitStatus.test.ts create mode 100644 apps/webapp/test/dashboardAgentWatchLimits.test.ts create mode 100644 internal-packages/dashboard-agent-db/drizzle/0005_ambitious_mordo.sql create mode 100644 internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json diff --git a/.server-changes/agent-watch-plan-limits.md b/.server-changes/agent-watch-plan-limits.md new file mode 100644 index 0000000000..d323e7194f --- /dev/null +++ b/.server-changes/agent-watch-plan-limits.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Watches now respect your plan's limits: free plans can run a limited number of watches at once and for a shorter window, with a prompt to upgrade for more. diff --git a/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts b/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts index b10ee6b06a..38853ea0dd 100644 --- a/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts +++ b/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts @@ -5,8 +5,11 @@ import { listStaleOpenInvestigations, + recordInvestigationSweepAttempt, settleInvestigationAndCloseCard, + settleInvestigationAsInconclusive, type Investigation, + type SettledInvestigation, type SettledInvestigationCard, } from "@internal/dashboard-agent-db"; import { UNSETTLED_INVESTIGATION_NOTE } from "@internal/dashboard-agent-contracts"; @@ -22,6 +25,13 @@ export const INVESTIGATION_STALE_MS = 30 * 60 * 1000; /** Per-run cap. Oldest first, so the rest land next run. */ const SWEEP_BATCH_LIMIT = 100; +/** + * After this many failed settle attempts a row is force-abandoned: settled `inconclusive` + * WITHOUT the closing card, so a card that never renders leaves the queue instead of + * looping forever. The rare stuck spinner is the price of not starving every other row. + */ +export const MAX_SWEEP_ATTEMPTS = 5; + export type InvestigationSweepResult = { /** Stale `in_progress` rows seen. */ stale: number; @@ -30,6 +40,8 @@ export type InvestigationSweepResult = { closed: number; /** A turn (or another sweep) settled it first. */ alreadySettled: number; + /** Rows past the attempt cap, force-settled without a card so they leave the queue. */ + abandoned: number; failed: number; }; @@ -46,6 +58,10 @@ export type InvestigationSweepDeps = { chatId: string; note: string; }) => Promise; + /** Record a failed settle out-of-band; returns the new attempt count, or null if gone. */ + recordAttempt?: (params: { id: string }) => Promise; + /** Force a poison row terminal without the failing render path. */ + forceAbandon?: (params: { id: string; note: string }) => Promise; }; /** @@ -61,12 +77,17 @@ export async function sweepDashboardAgentInvestigations( deps.listStale ?? ((params) => listStaleOpenInvestigations(dashboardAgentDb, params)); const settleAndClose = deps.settleAndClose ?? ((params) => settleInvestigationAndCloseCard(dashboardAgentDb, params)); + const recordAttempt = + deps.recordAttempt ?? ((params) => recordInvestigationSweepAttempt(dashboardAgentDb, params)); + const forceAbandon = + deps.forceAbandon ?? ((params) => settleInvestigationAsInconclusive(dashboardAgentDb, params)); const result: InvestigationSweepResult = { stale: 0, settled: 0, closed: 0, alreadySettled: 0, + abandoned: 0, failed: 0, }; @@ -93,10 +114,49 @@ export async function sweepDashboardAgentInvestigations( result.settled++; if (outcome.closed) result.closed++; } catch (error) { + // The settle rolled back, so the row is still `in_progress`. Record the attempt in + // its own write — this rotates the row to the back of the sweep order (see + // `listStaleOpenInvestigations`) so it can't pin the head and starve newer rows. + let attempts: number | null = null; + try { + attempts = await recordAttempt({ id: investigation.id }); + } catch (recordError) { + logger.error("Dashboard agent investigation sweep: failed to record a sweep attempt", { + investigationId: investigation.id, + chatId: investigation.chatId, + error: recordError, + }); + } + + // Past the cap the card will never render; force it terminal without the render + // path so it leaves the queue instead of looping forever. + if (attempts !== null && attempts >= MAX_SWEEP_ATTEMPTS) { + try { + await forceAbandon({ id: investigation.id, note: UNSETTLED_INVESTIGATION_NOTE }); + result.abandoned++; + logger.warn( + "Dashboard agent investigation sweep: abandoned a card past the attempt cap", + { + investigationId: investigation.id, + chatId: investigation.chatId, + attempts, + } + ); + continue; + } catch (abandonError) { + logger.error("Dashboard agent investigation sweep: failed to abandon a poison card", { + investigationId: investigation.id, + chatId: investigation.chatId, + error: abandonError, + }); + } + } + result.failed++; logger.error("Dashboard agent investigation sweep: failed to settle an investigation", { investigationId: investigation.id, chatId: investigation.chatId, + attempts, error, }); } diff --git a/apps/webapp/app/services/dashboardAgentWatchErrorStatus.server.ts b/apps/webapp/app/services/dashboardAgentWatchErrorStatus.server.ts index de32c3595c..9e17c567a9 100644 --- a/apps/webapp/app/services/dashboardAgentWatchErrorStatus.server.ts +++ b/apps/webapp/app/services/dashboardAgentWatchErrorStatus.server.ts @@ -6,6 +6,7 @@ import type { SubmitWatchErrorCode } from "./dashboardAgentWatches.server"; */ const STATUS_BY_CODE: Record = { limit_reached: 409, + watch_limit_reached: 409, duplicate: 409, request_conflict: 409, invalid_target: 404, diff --git a/apps/webapp/app/services/dashboardAgentWatchLimits.server.ts b/apps/webapp/app/services/dashboardAgentWatchLimits.server.ts new file mode 100644 index 0000000000..4c96196ebd --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentWatchLimits.server.ts @@ -0,0 +1,59 @@ +import type { Limits } from "@trigger.dev/platform"; +import { WATCH_MAX_HOURS } from "@internal/dashboard-agent-contracts"; +import { getCachedLimitAllowingZero, isBillingConfigured } from "./platform.v3.server"; + +// The unlimited sentinel, matching the message quota (TRI-12863 P1). Never Infinity: it +// serializes to null in the limit cache. +export const UNLIMITED_WATCH_LIMIT = 100_000_000; + +// Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted, so +// the fallback applies and the plan floor is off. +const WATCH_MAX_HOURS_LIMIT_KEY = "agentWatchMaxHours" as keyof Limits; +const WATCH_COUNT_LIMIT_KEY = "agentWatchers" as keyof Limits; + +export type WatchPlanLimits = { + /** Longest window one watch may run for, in hours. */ + maxHours: number; + /** How many active watches the org may run at once. */ + watchers: number; +}; + +async function readLimit(organizationId: string, key: keyof Limits): Promise { + // A plan of 0 means zero, not absent: an org with watches switched off must not read as + // unlimited. Only a missing limit falls open. + const cached = await getCachedLimitAllowingZero(organizationId, key, UNLIMITED_WATCH_LIMIT); + // A cache error leaves `val` empty; fall open to unlimited. + return cached.val ?? UNLIMITED_WATCH_LIMIT; +} + +/** + * The org's plan floors for watches. Fails open: an absent limit (self-hosted, or before the + * cloud side ships) resolves to the unlimited sentinel, so neither floor bites. `read` is the + * plan-limit seam: tests pass their own reader instead of the cached platform one. + */ +export async function resolveWatchPlanLimits( + organizationId: string, + read: (organizationId: string, key: keyof Limits) => Promise = readLimit +): Promise { + const [maxHours, watchers] = await Promise.all([ + read(organizationId, WATCH_MAX_HOURS_LIMIT_KEY), + read(organizationId, WATCH_COUNT_LIMIT_KEY), + ]); + return { maxHours, watchers }; +} + +/** + * The window ceiling actually in force: the plan floor under the code ceiling. A plan that + * allows 100 hours still caps at {@link WATCH_MAX_HOURS}. + */ +export function effectiveWatchMaxHours(planMaxHours: number): number { + return Math.min(planMaxHours, WATCH_MAX_HOURS); +} + +/** + * A watch-limit refusal, plus an upgrade nudge when billing is present. Self-hosted never + * hits this (fails open above), and the nudge is gated so a stray refusal stays quiet there. + */ +export function watchLimitHint(base: string, billingConfigured = isBillingConfigured()): string { + return billingConfigured ? `${base} Upgrade your plan for more.` : base; +} diff --git a/apps/webapp/app/services/dashboardAgentWatches.server.ts b/apps/webapp/app/services/dashboardAgentWatches.server.ts index 10d0d7bf9d..06973d58e5 100644 --- a/apps/webapp/app/services/dashboardAgentWatches.server.ts +++ b/apps/webapp/app/services/dashboardAgentWatches.server.ts @@ -10,6 +10,7 @@ import { cancelWatch, chatExists, claimWatchSubmission, + countActiveWatchesForOrg, createChat, createWatch, generateWatchId, @@ -68,6 +69,12 @@ import { import { watchCreationCheckDeps } from "~/services/dashboardAgentWatchChecks.server"; import { normalizeErrorFingerprint } from "~/services/dashboardAgentWatchErrorChecks"; import { subscribeUserToWatchAlerts } from "~/services/dashboardAgentWatchAlerts.server"; +import { + effectiveWatchMaxHours, + resolveWatchPlanLimits, + watchLimitHint, + type WatchPlanLimits, +} from "~/services/dashboardAgentWatchLimits.server"; import { mintDashboardAgentWatchBatchToken, mintDashboardAgentWatchToken, @@ -165,6 +172,7 @@ export async function authorizeWatchEnvironmentById(params: { export type CreateWatchErrorCode = | "limit_reached" + | "watch_limit_reached" | "duplicate" | "invalid_target" | "chat_not_found" @@ -273,6 +281,12 @@ export async function createDashboardAgentWatch(params: { scheduleTick?: typeof scheduleWatchTick; /** Skip the real trigger-config gate when a tick scheduler is injected. */ configured?: () => boolean; + /** Plan floors on window and count. Fails open to unlimited when absent. */ + resolveLimits?: (organizationId: string) => Promise; + /** Org-wide active-watch count, for the watcher-count floor. */ + countActiveWatches?: (organizationId: string) => Promise; + /** Gates the upgrade nudge, so self-hosted stays quiet. */ + billingConfigured?: () => boolean; }; }): Promise { const { environment, userId, chatId } = params; @@ -284,6 +298,11 @@ export async function createDashboardAgentWatch(params: { const buildCheckDeps = params.deps?.checkDeps ?? watchCreationCheckDeps; const scheduleTick = params.deps?.scheduleTick ?? scheduleWatchTick; const isDashboardAgentConfigured = params.deps?.configured ?? isDashboardAgentConfiguredDefault; + const resolveLimits = params.deps?.resolveLimits ?? resolveWatchPlanLimits; + const countActiveWatches = + params.deps?.countActiveWatches ?? + ((organizationId: string) => countActiveWatchesForOrg(dashboardAgentDb, { organizationId })); + const hint = (base: string) => watchLimitHint(base, params.deps?.billingConfigured?.()); const checkDeps = buildCheckDeps(environment, now); if (!isDashboardAgentConfigured()) { @@ -331,6 +350,29 @@ export async function createDashboardAgentWatch(params: { return { ok: true, watching: false, identity, immediate }; } + // Both floors are read only now the immediate check didn't answer: a one-shot creates no + // row, so a plan floor must not turn an answerable question into an upgrade nudge. Plan + // floors sit below the code ceilings (min(plan, ceiling)) and fail open: an absent limit + // resolves to unlimited, so neither bites on self-hosted. + const planLimits = await resolveLimits(environment.organizationId); + if (spec.maxHours > effectiveWatchMaxHours(planLimits.maxHours)) { + return { + ok: false, + code: "watch_limit_reached", + error: hint("That watch window is longer than your plan allows."), + }; + } + + // The per-chat cap of 3 still applies independently, in `createWatch`. + const activeCount = await countActiveWatches(environment.organizationId); + if (activeCount >= planLimits.watchers) { + return { + ok: false, + code: "watch_limit_reached", + error: hint("You've reached the number of active watches your plan allows."), + }; + } + const expiresAt = new Date(now.getTime() + spec.maxHours * 60 * 60 * 1000); const created = await createWatch(dashboardAgentDb, { diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index f8b524e3cd..187ef3b063 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -482,6 +482,37 @@ export async function getCachedLimit(orgId: string, limit: keyof Limits, fallbac }); } +/** + * Reads one plan limit, treating 0 as zero rather than absent: only a missing limit falls back. + * {@link getLimit} keeps its `!result` fallback, which its callers depend on. + */ +export function limitValueAllowingZero( + limits: Limits | undefined, + limit: keyof Limits, + fallback: number +): number { + const result = limits?.[limit]; + + if (result === undefined || result === null) return fallback; + if (typeof result === "number") return result; + if (typeof result === "object" && "number" in result) return result.number; + return fallback; +} + +/** + * Like {@link getCachedLimit}, but a plan value of 0 means zero. Cached under its own key so it + * never crosses with {@link getCachedLimit}. + */ +export async function getCachedLimitAllowingZero( + orgId: string, + limit: keyof Limits, + fallback: number +) { + return platformCache.limits.swr(`${orgId}:${limit}:allow-zero`, async () => + limitValueAllowingZero(await getLimits(orgId), limit, fallback) + ); +} + export async function customerPortalUrl(orgId: string, orgSlug: string) { if (!client) return undefined; diff --git a/apps/webapp/test/dashboardAgentInvestigationPoison.test.ts b/apps/webapp/test/dashboardAgentInvestigationPoison.test.ts new file mode 100644 index 0000000000..52b87f35c1 --- /dev/null +++ b/apps/webapp/test/dashboardAgentInvestigationPoison.test.ts @@ -0,0 +1,166 @@ +import { + createChat, + createDashboardAgentDb, + getInvestigation, + settleInvestigationAndCloseCard, + upsertInvestigationRevision, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { + investigationStateSchema, + type InvestigationState, +} from "@internal/dashboard-agent-contracts"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, vi } from "vitest"; + +const ctx = vi.hoisted(() => ({ + agentDb: undefined as unknown as DashboardAgentDb, +})); + +vi.mock("~/services/dashboardAgentDb.server", () => ({ + get dashboardAgentDb() { + return ctx.agentDb; + }, +})); + +const { sweepDashboardAgentInvestigations, INVESTIGATION_STALE_MS, MAX_SWEEP_ATTEMPTS } = + await import("~/services/dashboardAgentInvestigationSweep.server"); + +async function applyAgentSchema(prisma: PrismaClient) { + const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + const migrations = readdirSync(folder) + .filter((file) => file.endsWith(".sql")) + .sort(); + for (const name of migrations) { + const sql = readFileSync(path.join(folder, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +let agentDbClient: DashboardAgentDbClient | undefined; +let prismaForRaw: PrismaClient | undefined; + +async function boot(prisma: PrismaClient, connectionUri: string) { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 }); + ctx.agentDb = agentDbClient.db; + prismaForRaw = prisma; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +const ORG = "org_poison"; +const USER = "user_poison"; + +function openState(): InvestigationState { + return investigationStateSchema.parse({ + outcome: "in_progress", + severity: "warn", + confidence: "medium", + title: "a stuck card", + headline: "Still checking.", + progress: "Reading spans", + checkNext: [], + hypotheses: [], + evidence: [], + }); +} + +async function seedInvestigation(chatId: string, ageMs: number): Promise { + await createChat(ctx.agentDb, { id: chatId, organizationId: ORG, userId: USER }); + const created = await upsertInvestigationRevision(ctx.agentDb, { + chatId, + projectRef: "proj", + environmentRef: "env", + state: openState(), + }); + if (!created.ok) throw new Error("fixture investigation not created"); + await prismaForRaw!.$executeRawUnsafe( + `update trigger_dashboard_agent.investigations + set updated_at = now() - ($2 || ' milliseconds')::interval where id = $1`, + created.id, + String(ageMs) + ); + return created.id; +} + +async function outcomeOf(id: string): Promise { + const row = await getInvestigation(ctx.agentDb, { id }); + return row ? (row.state as { outcome?: string }).outcome : undefined; +} + +const STALE_AGE_MS = INVESTIGATION_STALE_MS + 60_000; +const OLDER_AGE_MS = STALE_AGE_MS + 60_000; + +describe("the investigation sweep with a poison row", () => { + postgresTest( + "a row that always fails to settle cannot pin the head and starve a newer row", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + + // Poison sorts first (older `updated_at`); renderable is newer. + const poisonId = await seedInvestigation("chat_poison", OLDER_AGE_MS); + const renderableId = await seedInvestigation("chat_ok", STALE_AGE_MS); + + // Only the poison row's settle throws; the renderable one goes through the real path. + const settleAndClose = (params: { id: string; chatId: string; note: string }) => { + if (params.id === poisonId) throw new Error("state isn't renderable"); + return settleInvestigationAndCloseCard(ctx.agentDb, params); + }; + + // limit 1 forces head contention: without backoff the poison row would win every run. + // A failed run throws so the job retries, but the attempt is recorded before it does. + await expect( + sweepDashboardAgentInvestigations({ limit: 1, settleAndClose }) + ).rejects.toThrow(); + expect(await outcomeOf(poisonId)).toBe("in_progress"); + expect(await outcomeOf(renderableId)).toBe("in_progress"); + + // Next run: the poison row now sorts behind the never-attempted renderable one, + // so the newer row is picked and settled despite the poison row still being stale. + const second = await sweepDashboardAgentInvestigations({ limit: 1, settleAndClose }); + expect(second).toMatchObject({ stale: 1, settled: 1, failed: 0 }); + expect(await outcomeOf(renderableId)).toBe("inconclusive"); + expect(await outcomeOf(poisonId)).toBe("in_progress"); + }, + 30_000 + ); + + postgresTest( + "after the attempt cap the poison row is abandoned and leaves the queue", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const poisonId = await seedInvestigation("chat_poison", STALE_AGE_MS); + + const settleAndClose = () => { + throw new Error("state isn't renderable"); + }; + + // The first MAX_SWEEP_ATTEMPTS-1 runs record a failed attempt and throw; the row stays stale. + for (let i = 1; i < MAX_SWEEP_ATTEMPTS; i++) { + await expect(sweepDashboardAgentInvestigations({ settleAndClose })).rejects.toThrow(); + expect(await outcomeOf(poisonId)).toBe("in_progress"); + } + + // The capped run force-settles the row without the render path, so it leaves the queue. + const capped = await sweepDashboardAgentInvestigations({ settleAndClose }); + expect(capped).toMatchObject({ stale: 1, abandoned: 1, failed: 0 }); + expect(await outcomeOf(poisonId)).toBe("inconclusive"); + + // Nothing stale remains, so the poison row is no longer swept. + const after = await sweepDashboardAgentInvestigations({ settleAndClose }); + expect(after).toMatchObject({ stale: 0 }); + }, + 30_000 + ); +}); diff --git a/apps/webapp/test/dashboardAgentWatchLimitStatus.test.ts b/apps/webapp/test/dashboardAgentWatchLimitStatus.test.ts new file mode 100644 index 0000000000..a2ad2f3fd5 --- /dev/null +++ b/apps/webapp/test/dashboardAgentWatchLimitStatus.test.ts @@ -0,0 +1,163 @@ +import { + createDashboardAgentDb, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, vi } from "vitest"; +import type * as WatchLimitsModule from "~/services/dashboardAgentWatchLimits.server"; + +// A plan-limit refusal (`watch_limit_reached`) is a 409, not a 500. The card submit's status +// ladder must map it the same way the MCP route does, or a full org sees an "unexpected error". + +const ctx = vi.hoisted(() => ({ + prisma: undefined as unknown as PrismaClient, + agentDb: undefined as unknown as DashboardAgentDb, + userId: "", +})); + +vi.mock("~/db.server", () => { + const proxy = new Proxy( + {}, + { get: (_target, prop) => (ctx.prisma as unknown as Record)[prop as string] } + ); + return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined }; +}); + +vi.mock("~/services/session.server", () => ({ + requireUser: async () => ({ id: ctx.userId, admin: false, isImpersonating: false }), +})); + +vi.mock("~/v3/canAccessDashboardAgent.server", () => ({ + canAccessDashboardAgent: async () => true, +})); + +vi.mock("~/services/dashboardAgentDb.server", () => ({ + get dashboardAgentDb() { + return ctx.agentDb; + }, +})); + +// The only stub: the plan floor billing would resolve. A 1-hour window makes a 2-hour watch +// exceed the plan, so the real submit path returns `watch_limit_reached`. Everything else runs. +vi.mock("~/services/dashboardAgentWatchLimits.server", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveWatchPlanLimits: async () => ({ + maxHours: 1, + watchers: actual.UNLIMITED_WATCH_LIMIT, + }), + }; +}); + +process.env.SESSION_SECRET = "test-session-secret-for-watch-limit-status"; +// Unset, watch creation stops at `not_configured` (501) before the plan floor is read. +process.env.DASHBOARD_AGENT_SECRET_KEY = "test-dashboard-agent-secret"; + +const { action } = + await import("~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent"); + +/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */ +async function applyAgentSchema(prisma: PrismaClient) { + const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + for (const name of readdirSync(folder) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(folder, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +let agentDbClient: DashboardAgentDbClient | undefined; + +async function seed(prisma: PrismaClient) { + const slug = `limit_status_${Math.random().toString(36).slice(2, 10)}`; + const user = await prisma.user.create({ + data: { email: `${slug}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + const organization = await prisma.organization.create({ data: { title: slug, slug } }); + await prisma.orgMember.create({ + data: { organizationId: organization.id, userId: user.id, role: "ADMIN" }, + }); + const project = await prisma.project.create({ + data: { name: slug, slug, organizationId: organization.id, externalRef: `proj_${slug}` }, + }); + await prisma.runtimeEnvironment.create({ + data: { + slug: "prod", + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: `tr_prod_${slug}`, + pkApiKey: `pk_prod_${slug}`, + shortcode: `p${slug.slice(0, 6)}`, + }, + }); + ctx.userId = user.id; + return { user, organization, project }; +} + +// error_recurrence resolves its target with no run/queue read, so the plan floor is the only +// thing standing between a valid submit and a created watch. +const DRAFT = JSON.stringify({ + spec: { + kind: "error_recurrence", + fingerprint: "a1b2c3", + checkEveryMinutes: 5, + maxHours: 2, + note: "ping me if it happens again", + }, + followUp: { investigateOnAttention: false, notifyExternally: false }, +}); + +function submitRequest(slug: string, body: Record) { + const form = new URLSearchParams(body); + return action({ + request: new Request( + `https://app.trigger.dev/resources/orgs/${slug}/projects/${slug}/env/prod/dashboard-agent`, + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: form.toString(), + } + ), + params: { organizationSlug: slug, projectParam: slug, envParam: "prod" }, + context: {}, + } as never) as Promise; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +describe("the watch card submit's status for a plan-limit refusal", () => { + postgresTest( + "answers 409, not 500, when the window is longer than the plan allows", + async ({ prisma, postgresContainer }) => { + ctx.prisma = prisma; + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(postgresContainer.getConnectionUri(), { max: 4 }); + ctx.agentDb = agentDbClient.db; + + const seeded = await seed(prisma); + + const response = await submitRequest(seeded.organization.slug, { + intent: "watch-create", + draft: DRAFT, + clientRequestId: "wreq_limit_1", + }); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "watch_limit_reached" }); + }, + 30_000 + ); +}); diff --git a/apps/webapp/test/dashboardAgentWatchLimits.test.ts b/apps/webapp/test/dashboardAgentWatchLimits.test.ts new file mode 100644 index 0000000000..7bd8f4ee22 --- /dev/null +++ b/apps/webapp/test/dashboardAgentWatchLimits.test.ts @@ -0,0 +1,411 @@ +import { + countActiveWatchesForOrg, + createChat, + createDashboardAgentDb, + listActiveWatchesForChat, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import type { WatchSpec } from "@internal/dashboard-agent-contracts"; +import type * as TriggerSdk from "@trigger.dev/sdk"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WatchCheckDeps, WatchRunRow } from "~/services/dashboardAgentWatchChecks"; +import type { WatchPlanLimits } from "~/services/dashboardAgentWatchLimits.server"; + +vi.setConfig({ testTimeout: 60_000 }); + +const ctx = vi.hoisted(() => ({ + prisma: undefined as unknown as PrismaClient, + agentDb: undefined as unknown as DashboardAgentDb, +})); + +vi.mock("~/db.server", () => { + const proxy = new Proxy( + {}, + { get: (_target, prop) => (ctx.prisma as unknown as Record)[prop as string] } + ); + return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined }; +}); + +vi.mock("~/services/dashboardAgentDb.server", () => ({ + get dashboardAgentDb() { + return ctx.agentDb; + }, +})); + +vi.mock("@trigger.dev/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + TriggerClient: class { + tasks = { trigger: async () => ({ id: "run_test" }) }; + }, + }; +}); + +process.env.SESSION_SECRET = "test-session-secret-for-watch-limits"; + +const { createDashboardAgentWatch } = await import("~/services/dashboardAgentWatches.server"); +const { effectiveWatchMaxHours, resolveWatchPlanLimits, watchLimitHint, UNLIMITED_WATCH_LIMIT } = + await import("~/services/dashboardAgentWatchLimits.server"); +const { limitValueAllowingZero } = await import("~/services/platform.v3.server"); + +async function applyAgentSchema(prisma: PrismaClient) { + const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + const migrations = readdirSync(folder) + .filter((file) => file.endsWith(".sql")) + .sort(); + for (const name of migrations) { + const sql = readFileSync(path.join(folder, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +let agentDbClient: DashboardAgentDbClient | undefined; + +async function boot(prisma: PrismaClient, connectionUri: string) { + ctx.prisma = prisma; + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + ctx.agentDb = agentDbClient.db; +} + +async function seed(prisma: PrismaClient, slugBase: string) { + const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`; + const user = await prisma.user.create({ + data: { email: `${slug}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + const organization = await prisma.organization.create({ data: { title: slug, slug } }); + await prisma.orgMember.create({ + data: { organizationId: organization.id, userId: user.id, role: "ADMIN" }, + }); + const project = await prisma.project.create({ + data: { name: slug, slug, organizationId: organization.id, externalRef: `proj_${slug}` }, + }); + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug: "prod", + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: `tr_prod_${slug}`, + pkApiKey: `pk_prod_${slug}`, + shortcode: `p${slug.slice(0, 6)}`, + }, + }); + return { user, organization, project, environment }; +} + +type Seeded = Awaited>; + +function authenticated(seeded: Seeded) { + return { + id: seeded.environment.id, + organizationId: seeded.organization.id, + projectId: seeded.project.id, + slug: "prod", + type: "PRODUCTION", + project: { id: seeded.project.id, externalRef: seeded.project.externalRef }, + organization: { id: seeded.organization.id, slug: seeded.organization.slug }, + } as any; +} + +async function seedChat(seeded: Seeded, chatId: string) { + await createChat(ctx.agentDb, { + id: chatId, + organizationId: seeded.organization.id, + userId: seeded.user.id, + }); + return chatId; +} + +function runRow(overrides: Partial = {}): WatchRunRow { + return { + friendlyId: "run_1", + status: "PENDING", + queue: "task/my-task", + createdAt: new Date(), + queuedAt: null, + startedAt: null, + completedAt: null, + delayUntil: null, + ...overrides, + }; +} + +function fakeCheckDeps(overrides: Partial = {}): WatchCheckDeps { + return { + readRun: async () => runRow(), + queueExists: async () => true, + readQueueDepth: async () => ({ depth: 7, source: "live_queue", current: true }), + readQueueOldestAge: async () => ({ ageMs: 30_000, source: "live_queue", current: true }), + readErrorRecurrence: async () => null, + readHealth: async () => ({ trustworthy: true, severity: "warn" }), + ...overrides, + }; +} + +const UNLIMITED: WatchPlanLimits = { + maxHours: UNLIMITED_WATCH_LIMIT, + watchers: UNLIMITED_WATCH_LIMIT, +}; + +function runStart(runId: string, maxHours = 2): WatchSpec { + return { kind: "run_start", runId, checkEveryMinutes: 1, maxHours, note: "tell me" }; +} + +function create(args: { + seeded: Seeded; + spec: WatchSpec; + chatId: string; + limits?: WatchPlanLimits; + billingConfigured?: boolean; + countActiveWatches?: (organizationId: string) => Promise; + checkDeps?: Partial; +}) { + return createDashboardAgentWatch({ + environment: authenticated(args.seeded), + userId: args.seeded.user.id, + chatId: args.chatId, + spec: args.spec, + deps: { + configured: () => true, + checkDeps: () => fakeCheckDeps(args.checkDeps), + scheduleTick: async () => {}, + resolveLimits: async () => args.limits ?? UNLIMITED, + ...(args.countActiveWatches ? { countActiveWatches: args.countActiveWatches } : {}), + ...(args.billingConfigured === undefined + ? {} + : { billingConfigured: () => args.billingConfigured! }), + }, + }); +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +describe("watch plan limits (pure)", () => { + it("caps the window ceiling at the code ceiling of 24 hours", () => { + expect(effectiveWatchMaxHours(100)).toBe(24); + expect(effectiveWatchMaxHours(1)).toBe(1); + expect(effectiveWatchMaxHours(0.5)).toBe(0.5); + }); + + it("reads a plan limit of zero as zero, not as an absent limit", async () => { + // The read the cached platform limit performs: a plan that switched watches off must not + // fall back to the unlimited sentinel. + expect( + limitValueAllowingZero( + { agentWatchMaxHours: 0 } as never, + "agentWatchMaxHours" as never, + UNLIMITED_WATCH_LIMIT + ) + ).toBe(0); + expect( + limitValueAllowingZero(undefined, "agentWatchMaxHours" as never, UNLIMITED_WATCH_LIMIT) + ).toBe(UNLIMITED_WATCH_LIMIT); + + expect(await resolveWatchPlanLimits("org_1", async () => 0)).toEqual({ + maxHours: 0, + watchers: 0, + }); + }); + + it("adds an upgrade nudge only when billing is configured", () => { + expect(watchLimitHint("too long.", true)).toBe("too long. Upgrade your plan for more."); + expect(watchLimitHint("too long.", false)).toBe("too long."); + }); +}); + +describe("createDashboardAgentWatch plan enforcement", () => { + postgresTest( + "refuses a window longer than the plan allows", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "window"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 2), + limits: { maxHours: 1, watchers: UNLIMITED_WATCH_LIMIT }, + billingConfigured: true, + }); + + expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" }); + if (result.ok) return; + expect(result.error).toContain("Upgrade your plan"); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "creates a watch whose window is within the plan", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "within"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 1), + limits: { maxHours: 1, watchers: UNLIMITED_WATCH_LIMIT }, + }); + + expect(result.ok).toBe(true); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(1); + } + ); + + postgresTest( + "refuses once the org is at its watcher count, counting active watches for real", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "count"); + await seedChat(seeded, "chat_1"); + await seedChat(seeded, "chat_2"); + + const limits: WatchPlanLimits = { maxHours: UNLIMITED_WATCH_LIMIT, watchers: 1 }; + + const first = await create({ seeded, chatId: "chat_1", spec: runStart("run_1"), limits }); + expect(first.ok).toBe(true); + expect( + await countActiveWatchesForOrg(ctx.agentDb, { organizationId: seeded.organization.id }) + ).toBe(1); + + const second = await create({ seeded, chatId: "chat_2", spec: runStart("run_2"), limits }); + expect(second).toMatchObject({ ok: false, code: "watch_limit_reached" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_2" })).toHaveLength(0); + } + ); + + postgresTest( + "fails open: an absent limit resolves to unlimited and a 2h watch is created", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "failopen"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 2), + limits: UNLIMITED, + }); + + expect(result.ok).toBe(true); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(1); + } + ); + + postgresTest( + "leaves no upgrade nudge on a refusal when billing is unconfigured", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "selfhosted"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 2), + limits: { maxHours: 1, watchers: UNLIMITED_WATCH_LIMIT }, + billingConfigured: false, + }); + + expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" }); + if (result.ok) return; + expect(result.error).not.toContain("Upgrade"); + } + ); + + postgresTest( + "a plan window of zero hours refuses every watch", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "zerohours"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 1), + limits: { maxHours: 0, watchers: UNLIMITED_WATCH_LIMIT }, + }); + + expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "a plan of zero watchers refuses creation", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "zerowatchers"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 1), + limits: { maxHours: UNLIMITED_WATCH_LIMIT, watchers: 0 }, + }); + + expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "answers a condition that already happened, instead of refusing the window", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "instant"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 2), + limits: { maxHours: 1, watchers: 0 }, + billingConfigured: true, + checkDeps: { + readRun: async () => runRow({ status: "EXECUTING", startedAt: new Date() }), + }, + }); + + expect(result).toMatchObject({ ok: true, watching: false }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "min semantics: a plan of 100 hours still permits only up to the 24h ceiling", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "minsem"); + await seedChat(seeded, "chat_1"); + + const created = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 24), + limits: { maxHours: 100, watchers: UNLIMITED_WATCH_LIMIT }, + }); + expect(created.ok).toBe(true); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(1); + } + ); +}); diff --git a/internal-packages/dashboard-agent-db/drizzle/0005_ambitious_mordo.sql b/internal-packages/dashboard-agent-db/drizzle/0005_ambitious_mordo.sql new file mode 100644 index 0000000000..3d56343452 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0005_ambitious_mordo.sql @@ -0,0 +1,2 @@ +ALTER TABLE "trigger_dashboard_agent"."investigations" ADD COLUMN "sweep_attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "trigger_dashboard_agent"."investigations" ADD COLUMN "last_sweep_attempt_at" timestamp with time zone; \ No newline at end of file diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000000..b00ae150c5 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1357 @@ +{ + "id": "9f0a4739-19ca-4a15-82dd-25598116feb9", + "prevId": "f7cbfef4-7fc8-4deb-8da2-59248b242a60", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.agent_message_usage": { + "name": "agent_message_usage", + "schema": "trigger_dashboard_agent", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_message_usage_organization_id_period_pk": { + "name": "agent_message_usage_organization_id_period_pk", + "columns": ["organization_id", "period"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_messages": { + "name": "chat_messages", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_chat_user_role_idx": { + "name": "chat_messages_chat_user_role_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_messages\".\"role\" = 'user'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_messages_chat_id_message_id_pk": { + "name": "chat_messages_chat_id_message_id_pk", + "columns": ["chat_id", "message_id"] + } + }, + "uniqueConstraints": { + "chat_messages_chat_position_key": { + "name": "chat_messages_chat_position_key", + "nullsNotDistinct": false, + "columns": ["chat_id", "position"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_created_idx": { + "name": "chat_turn_evals_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_message_position": { + "name": "next_message_position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sweep_attempts": { + "name": "sweep_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_sweep_attempt_at": { + "name": "last_sweep_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_open_updated_idx": { + "name": "investigations_open_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"investigations\".\"state\"->>'outcome' = 'in_progress'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_batches": { + "name": "watch_batches", + "schema": "trigger_dashboard_agent", + "columns": { + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "armed_at": { + "name": "armed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_batches_environment_id_cadence_minutes_pk": { + "name": "watch_batches_environment_id_cadence_minutes_pk", + "columns": ["environment_id", "cadence_minutes"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_submissions": { + "name": "watch_submissions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_hash": { + "name": "draft_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "watch_id": { + "name": "watch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unavailable": { + "name": "unavailable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_notification_status": { + "name": "external_notification_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_requested'" + }, + "external_notification_reason": { + "name": "external_notification_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "immediate_result": { + "name": "immediate_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_code": { + "name": "refusal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_error": { + "name": "refusal_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_existing_id": { + "name": "refusal_existing_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watch_submissions_created_idx": { + "name": "watch_submissions_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_submissions_chat_id_client_request_id_pk": { + "name": "watch_submissions_chat_id_client_request_id_pk", + "columns": ["chat_id", "client_request_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_outcome": { + "name": "observed_outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "investigate_on_attention": { + "name": "investigate_on_attention", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claimed_at": { + "name": "delivery_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claim_id": { + "name": "delivery_claim_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "alert_dispatch_key": { + "name": "alert_dispatch_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_at": { + "name": "retention_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "greatest(delivered_at, cancelled_at, fired_at, last_checked_at, created_at)", + "type": "stored" + } + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "((spec ->> 'checkEveryMinutes')::int)", + "type": "stored" + } + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_wake_idx": { + "name": "watches_org_user_wake_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" = 'delivered' and \"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_active_idx": { + "name": "watches_org_user_active_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_active_env_cadence_idx": { + "name": "watches_active_env_cadence_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"last_attempted_at\", \"last_checked_at\", \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_env_cadence_delivery_idx": { + "name": "watches_env_cadence_delivery_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_retention_idx": { + "name": "watches_retention_idx", + "columns": [ + { + "expression": "retention_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired', 'cancelled') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('not_required', 'delivered')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json index 1f33e4ddf8..213320fa64 100644 --- a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json +++ b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1786359241538, "tag": "0004_stale_corsair", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1786376934874, + "tag": "0005_ambitious_mordo", + "breakpoints": true } ] } diff --git a/internal-packages/dashboard-agent-db/src/queries.ts b/internal-packages/dashboard-agent-db/src/queries.ts index 7ca155f9cf..fc1ce8f768 100644 --- a/internal-packages/dashboard-agent-db/src/queries.ts +++ b/internal-packages/dashboard-agent-db/src/queries.ts @@ -1085,6 +1085,10 @@ export async function listChatIdsWithOpenInvestigations( /** * Sweep for investigations nothing else settles. `olderThan` is on `updated_at`, * which every revision bumps, so a card a live turn is writing to stays out. + * + * Order is `last_sweep_attempt_at` nulls first, then `updated_at`: a never-attempted + * row is always seen before one a prior sweep already failed on, so a row that can't + * settle rotates to the back instead of pinning the head and starving newer rows. */ export async function listStaleOpenInvestigations( db: DashboardAgentDb, @@ -1102,12 +1106,39 @@ export async function listStaleOpenInvestigations( sql`${investigations.updatedAt} <= ${params.olderThan.toISOString()}::timestamptz` ) ) - .orderBy(investigations.updatedAt) + .orderBy(sql`${investigations.lastSweepAttemptAt} asc nulls first`, investigations.updatedAt) .limit(params.limit ?? 100); return rows.map((row) => row.investigation); } +/** + * Record a failed stale-sweep settle on its own, committed outside the settle tx that + * rolled back. Bumps the attempt count and stamps `last_sweep_attempt_at` — which does + * NOT touch `updated_at`, so the row still reads as stale, only later in the order. + * Returns the new count, or null when the row is no longer `in_progress`. + */ +export async function recordInvestigationSweepAttempt( + db: DashboardAgentDbOrTx, + params: { id: string } +): Promise { + const rows = await db + .update(investigations) + .set({ + sweepAttempts: sql`${investigations.sweepAttempts} + 1`, + lastSweepAttemptAt: sql`now()`, + }) + .where( + and( + eq(investigations.id, params.id), + sql`${investigations.state}->>'outcome' = 'in_progress'` + ) + ) + .returning({ sweepAttempts: investigations.sweepAttempts }); + + return rows[0]?.sweepAttempts ?? null; +} + /** What the settle wrote, which is what the closing card has to render. */ export type SettledInvestigation = { id: string; revision: number; state: unknown }; diff --git a/internal-packages/dashboard-agent-db/src/schema.ts b/internal-packages/dashboard-agent-db/src/schema.ts index 73dafc64b0..d080d75991 100644 --- a/internal-packages/dashboard-agent-db/src/schema.ts +++ b/internal-packages/dashboard-agent-db/src/schema.ts @@ -170,6 +170,10 @@ export const investigations = dashboardAgentSchema.table( // Monotonic; bumped by a single atomic UPDATE. revision: integer("revision").notNull().default(0), state: jsonb("state").$type().notNull(), + // Failed stale-sweep settle attempts. Bumped outside the rolled-back settle tx so a + // row that can't render rotates to the back of the sweep order instead of pinning it. + sweepAttempts: integer("sweep_attempts").notNull().default(0), + lastSweepAttemptAt: timestamp("last_sweep_attempt_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/internal-packages/dashboard-agent-db/src/watch-queries.ts b/internal-packages/dashboard-agent-db/src/watch-queries.ts index 1f8164f668..2755adeb80 100644 --- a/internal-packages/dashboard-agent-db/src/watch-queries.ts +++ b/internal-packages/dashboard-agent-db/src/watch-queries.ts @@ -494,6 +494,22 @@ export async function countUnreadWatchWakes( return rows[0]?.count ?? 0; } +/** + * How many active watches an org has, across all its chats and users. The plan-limit floor + * is org-wide, so this is org-scoped only; a chat deletion cancels its watches, so `active` + * is the whole count. + */ +export async function countActiveWatchesForOrg( + db: DashboardAgentDb, + params: { organizationId: string } +): Promise { + const rows = await db + .select({ count: sql`count(*)::int` }) + .from(watches) + .where(and(eq(watches.status, "active"), eq(watches.organizationId, params.organizationId))); + return rows[0]?.count ?? 0; +} + /** * Whether this user has a watch that can still wake them here. Covered by * `watches_org_user_active_idx`; a chat deletion cancels its watches, so `active` is enough.