chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import postgres, { type Sql } from "postgres";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export type DashboardAgentSchema = typeof schema;
|
||||
export type DashboardAgentDb = PostgresJsDatabase<DashboardAgentSchema>;
|
||||
|
||||
export interface DashboardAgentDbClient {
|
||||
db: DashboardAgentDb;
|
||||
sql: Sql;
|
||||
/** Close the underlying connection pool. Call on agent run shutdown. */
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateDashboardAgentDbOptions {
|
||||
/**
|
||||
* Max client-side pool size. Keep small — the agent runs in many short-lived
|
||||
* task containers and PlanetScale's pooler does the real connection pooling.
|
||||
*/
|
||||
max?: number;
|
||||
/** Idle timeout (seconds) so suspended agent runs release connections. */
|
||||
idleTimeoutSeconds?: number;
|
||||
/** Connection timeout (seconds). */
|
||||
connectTimeoutSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Drizzle client for the dashboard-agent datastore. Shared by the agent
|
||||
* task (its own persistence) and the webapp (History tab + frontend actions).
|
||||
*
|
||||
* Connections go through a transaction-mode pooler (PlanetScale / PgBouncer-style),
|
||||
* so prepared statements are disabled — they don't survive a connection being
|
||||
* handed to a different client between checkouts.
|
||||
*/
|
||||
// Prisma-style URLs carry `?schema=...`; postgres.js forwards unknown query
|
||||
// params as server startup config and Postgres rejects `schema`. Our tables are
|
||||
// schema-qualified, so the param is unnecessary — drop it. Matters for the OSS
|
||||
// fallback to the main DATABASE_URL.
|
||||
function normalizeConnectionString(connectionString: string): string {
|
||||
try {
|
||||
const url = new URL(connectionString);
|
||||
url.searchParams.delete("schema");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return connectionString;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDashboardAgentDb(
|
||||
connectionString: string,
|
||||
options: CreateDashboardAgentDbOptions = {}
|
||||
): DashboardAgentDbClient {
|
||||
const sql = postgres(normalizeConnectionString(connectionString), {
|
||||
max: options.max ?? 5,
|
||||
idle_timeout: options.idleTimeoutSeconds ?? 20,
|
||||
connect_timeout: options.connectTimeoutSeconds ?? 10,
|
||||
prepare: false,
|
||||
});
|
||||
|
||||
const db = drizzle(sql, { schema });
|
||||
|
||||
return {
|
||||
db,
|
||||
sql,
|
||||
close: () => sql.end(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./schema.js";
|
||||
export * from "./client.js";
|
||||
export * from "./queries.js";
|
||||
@@ -0,0 +1,271 @@
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { DashboardAgentDb } from "./client.js";
|
||||
import {
|
||||
chats,
|
||||
chatSessions,
|
||||
chatTurnEvals,
|
||||
type ChatSession,
|
||||
type NewChatTurnEval,
|
||||
} from "./schema.js";
|
||||
|
||||
/**
|
||||
* The access-pattern layer. Every query that touches user data is scoped by
|
||||
* `organizationId` and/or `userId` so tenant isolation lives in one place —
|
||||
* callers can't forget the `where`. Shared by the agent task and the webapp.
|
||||
*/
|
||||
|
||||
/** Placeholder title for a chat with no generated or user-set title yet. */
|
||||
export const DEFAULT_CHAT_TITLE = "New chat";
|
||||
|
||||
export interface ChatListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
pinnedAt: Date | null;
|
||||
lastMessageAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1 History tab: a user's chats within an org, recent first, pinned on top.
|
||||
* Deliberately selects metadata columns only — never `messages` (large blob) or
|
||||
* the session token. Covered by `chats_org_user_last_msg_idx`.
|
||||
*/
|
||||
export async function listChats(
|
||||
db: DashboardAgentDb,
|
||||
params: { organizationId: string; userId: string; limit?: number }
|
||||
): Promise<ChatListItem[]> {
|
||||
return db
|
||||
.select({
|
||||
id: chats.id,
|
||||
title: chats.title,
|
||||
pinnedAt: chats.pinnedAt,
|
||||
lastMessageAt: chats.lastMessageAt,
|
||||
createdAt: chats.createdAt,
|
||||
updatedAt: chats.updatedAt,
|
||||
metadata: chats.metadata,
|
||||
})
|
||||
.from(chats)
|
||||
.where(
|
||||
and(
|
||||
eq(chats.organizationId, params.organizationId),
|
||||
eq(chats.userId, params.userId),
|
||||
isNull(chats.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(sql`${chats.pinnedAt} desc nulls last`, desc(chats.lastMessageAt))
|
||||
.limit(params.limit ?? 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2 Open a chat: the stored transcript for `useChat`'s initialMessages.
|
||||
* Scoped to the owner; returns null if missing/deleted/not theirs.
|
||||
*/
|
||||
export async function getChatMessages(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string }
|
||||
): Promise<unknown[] | null> {
|
||||
const rows = await db
|
||||
.select({ messages: chats.messages })
|
||||
.from(chats)
|
||||
.where(
|
||||
and(eq(chats.id, params.chatId), eq(chats.userId, params.userId), isNull(chats.deletedAt))
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.messages ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3 Resume the transport on first paint: the session-scoped token + stream
|
||||
* cursor. Joins `chats` to scope by owner (chat_sessions has no userId).
|
||||
*/
|
||||
export async function getSession(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string }
|
||||
): Promise<ChatSession | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
chatId: chatSessions.chatId,
|
||||
publicAccessToken: chatSessions.publicAccessToken,
|
||||
lastEventId: chatSessions.lastEventId,
|
||||
runId: chatSessions.runId,
|
||||
updatedAt: chatSessions.updatedAt,
|
||||
})
|
||||
.from(chatSessions)
|
||||
.innerJoin(chats, eq(chats.id, chatSessions.chatId))
|
||||
.where(and(eq(chatSessions.chatId, params.chatId), eq(chats.userId, params.userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner check: true when a non-deleted chat with this id belongs to the user.
|
||||
* Used to authorize chat-scoped actions (e.g. minting a session token) before
|
||||
* a session row necessarily exists.
|
||||
*/
|
||||
export async function chatExists(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string; organizationId: string }
|
||||
): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: chats.id })
|
||||
.from(chats)
|
||||
.where(
|
||||
and(
|
||||
eq(chats.id, params.chatId),
|
||||
eq(chats.organizationId, params.organizationId),
|
||||
eq(chats.userId, params.userId),
|
||||
isNull(chats.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* #4 Create a chat. Idempotent (`onConflictDoNothing`) so the webapp's "new
|
||||
* chat" insert and the agent's defensive `onChatStart` ensure can't race into a
|
||||
* duplicate-key error.
|
||||
*/
|
||||
export async function createChat(
|
||||
db: DashboardAgentDb,
|
||||
params: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
title?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(chats)
|
||||
.values({
|
||||
id: params.id,
|
||||
organizationId: params.organizationId,
|
||||
userId: params.userId,
|
||||
title: params.title ?? DEFAULT_CHAT_TITLE,
|
||||
metadata: params.metadata ?? {},
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
/** The agent's defensive ensure-exists in `onChatStart` / `onPreload`. */
|
||||
export const ensureChat = createChat;
|
||||
|
||||
/** #5 Rename. */
|
||||
export async function renameChat(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string; title: string }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ title: params.title, updatedAt: sql`now()` })
|
||||
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* #5 Set an auto-generated title, but only while the chat still has the default
|
||||
* title. Conditional on `DEFAULT_CHAT_TITLE` so the background title write can't
|
||||
* clobber a user rename, and so it's a safe no-op if it runs more than once.
|
||||
*/
|
||||
export async function setChatTitleIfDefault(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; title: string }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ title: params.title, updatedAt: sql`now()` })
|
||||
.where(
|
||||
and(eq(chats.id, params.chatId), eq(chats.title, DEFAULT_CHAT_TITLE), isNull(chats.deletedAt))
|
||||
);
|
||||
}
|
||||
|
||||
/** #5 Pin / unpin. */
|
||||
export async function setChatPinned(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string; pinned: boolean }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ pinnedAt: params.pinned ? sql`now()` : null, updatedAt: sql`now()` })
|
||||
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)));
|
||||
}
|
||||
|
||||
/** #5 Soft-delete. */
|
||||
export async function softDeleteChat(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ deletedAt: sql`now()`, updatedAt: sql`now()` })
|
||||
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* #6a Persist messages only (agent `onTurnStart` — make the user's message
|
||||
* durable in the display copy before the model starts streaming).
|
||||
*/
|
||||
export async function persistMessages(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; messages: unknown[] }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ messages: params.messages, lastMessageAt: sql`now()`, updatedAt: sql`now()` })
|
||||
.where(eq(chats.id, params.chatId));
|
||||
}
|
||||
|
||||
/**
|
||||
* #6b Persist a completed turn (agent `onTurnComplete`): the finalized transcript
|
||||
* and the refreshed session state, in one transaction. Atomicity matters — on
|
||||
* the next page load the frontend reads `messages` and `lastEventId` in parallel;
|
||||
* a torn write can resume from a stale cursor and double-render the last turn.
|
||||
*/
|
||||
export async function persistTurn(
|
||||
db: DashboardAgentDb,
|
||||
params: {
|
||||
chatId: string;
|
||||
messages: unknown[];
|
||||
session: {
|
||||
publicAccessToken: string;
|
||||
lastEventId?: string | null;
|
||||
runId?: string | null;
|
||||
};
|
||||
}
|
||||
): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(chats)
|
||||
.set({ messages: params.messages, lastMessageAt: sql`now()`, updatedAt: sql`now()` })
|
||||
.where(eq(chats.id, params.chatId));
|
||||
|
||||
await tx
|
||||
.insert(chatSessions)
|
||||
.values({
|
||||
chatId: params.chatId,
|
||||
publicAccessToken: params.session.publicAccessToken,
|
||||
lastEventId: params.session.lastEventId ?? null,
|
||||
runId: params.session.runId ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: chatSessions.chatId,
|
||||
set: {
|
||||
publicAccessToken: params.session.publicAccessToken,
|
||||
lastEventId: params.session.lastEventId ?? null,
|
||||
runId: params.session.runId ?? null,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* #11 Record a turn eval. Idempotent on `(chatId, turn)` so a re-delivered turn
|
||||
* (the eval task is triggered with an idempotency key, and may still retry) can
|
||||
* never write a second row.
|
||||
*/
|
||||
export async function insertTurnEval(db: DashboardAgentDb, row: NewChatTurnEval): Promise<void> {
|
||||
await db.insert(chatTurnEvals).values(row).onConflictDoNothing();
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgSchema,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* All dashboard-agent tables live in a dedicated Postgres schema. In cloud this
|
||||
* is a separate PlanetScale database; in OSS it isolates the agent's tables from
|
||||
* Prisma's `public` schema inside the main database. Tables are schema-qualified
|
||||
* explicitly, so no `search_path` configuration is required on the connection.
|
||||
*/
|
||||
export const dashboardAgentSchema = pgSchema("trigger_dashboard_agent");
|
||||
|
||||
/**
|
||||
* One row per conversation. Scope is **org + user** — a chat is not bound to a
|
||||
* single project/env; the project/env it ran in (and any extra ones the user
|
||||
* adds to context) live in `metadata`, because one conversation can range over
|
||||
* several projects/envs.
|
||||
*
|
||||
* `messages` is a display copy of the `UIMessage[]` transcript. The model's
|
||||
* source of truth for history is chat.agent's built-in object-store snapshot,
|
||||
* not this column — a stale write here can make the History view lag a turn but
|
||||
* can never corrupt what the model sees.
|
||||
*
|
||||
* Foreign-key-free: `organizationId` / `userId` are main-DB ids with no FK,
|
||||
* because in cloud this table lives in a different database.
|
||||
*/
|
||||
export const chats = dashboardAgentSchema.table(
|
||||
"chats",
|
||||
{
|
||||
// = chatId = the Session externalId. Stable for the life of the thread.
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
title: text("title").notNull().default("New chat"),
|
||||
// UIMessage[] display copy — never read to rebuild model context.
|
||||
messages: jsonb("messages").$type<unknown[]>().notNull().default([]),
|
||||
// Project/env context + model choice + page snapshot. Flexible by design.
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
pinnedAt: timestamp("pinned_at", { withTimezone: true }),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// History tab: "my chats in this org, recent first". Partial index keeps
|
||||
// soft-deleted rows out of the hot path.
|
||||
index("chats_org_user_last_msg_idx")
|
||||
.on(t.organizationId, t.userId, t.lastMessageAt.desc())
|
||||
.where(sql`${t.deletedAt} is null`),
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Live transport state the frontend needs to resume a chat on first paint,
|
||||
* keyed by chatId. Separate from `chats` so the secret token is isolated from
|
||||
* list queries and the hot per-turn write stays off the conversation row.
|
||||
*
|
||||
* No `userId` here on purpose: the agent's `onTurnComplete` event doesn't carry
|
||||
* `clientData`, and ownership is already enforced via the `chats` row — the
|
||||
* resume query joins `chats` to scope by owner (see `getSession`).
|
||||
*/
|
||||
export const chatSessions = dashboardAgentSchema.table("chat_sessions", {
|
||||
chatId: text("chat_id").primaryKey(), // = chats.id (FK-free, cross-db)
|
||||
publicAccessToken: text("public_access_token").notNull(),
|
||||
lastEventId: text("last_event_id"),
|
||||
runId: text("run_id"), // telemetry / "view this run"
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* One row per evaluated turn, written by the `dashboard-agent-eval-turn` task
|
||||
* that the agent triggers from `onTurnComplete`. Two kinds of data: quality
|
||||
* scores (did the agent answer well, grounded in its tool results) and insight
|
||||
* classification (what the user wanted, whether we have a product/docs/support
|
||||
* gap). Append-only analytics; the higher-level views ("top capability gaps",
|
||||
* "what users struggle with") are aggregations over these rows, not stored here.
|
||||
*
|
||||
* Structured columns are the things we filter, alert, and chart on; the evolving
|
||||
* taxonomy (typed `signals`) and the raw judge output live in JSONB so adding a
|
||||
* signal type is never a migration. Org + user scoped, FK-free (cross-db), with
|
||||
* a composite `(chatId, turn)` key so a re-delivered turn can't double-insert.
|
||||
*/
|
||||
export const chatTurnEvals = dashboardAgentSchema.table(
|
||||
"chat_turn_evals",
|
||||
{
|
||||
chatId: text("chat_id").notNull(), // = chats.id
|
||||
turn: integer("turn").notNull(), // 0-indexed turn within the chat
|
||||
organizationId: text("organization_id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
agentRunId: text("agent_run_id"), // the chat.agent run that produced the turn
|
||||
evalRunId: text("eval_run_id"), // the eval task's own run, for tracing
|
||||
// Per-turn context (the project/env/page the user was looking at).
|
||||
projectRef: text("project_ref"),
|
||||
environment: text("environment"),
|
||||
currentPage: text("current_page"),
|
||||
// Operational + model. `promptVersion` lets a quality drop be attributed to a
|
||||
// dashboard-managed prompt edit that never went through CI.
|
||||
model: text("model"),
|
||||
promptSlug: text("prompt_slug"),
|
||||
promptVersion: integer("prompt_version"),
|
||||
toolsUsed: jsonb("tools_used").$type<string[]>().notNull().default([]),
|
||||
toolError: boolean("tool_error").notNull().default(false),
|
||||
// Quality (LLM judge), scored 1-5.
|
||||
judgeModel: text("judge_model"),
|
||||
scoreGrounded: smallint("score_grounded"),
|
||||
scoreAnswered: smallint("score_answered"),
|
||||
scoreConcise: smallint("score_concise"),
|
||||
passed: boolean("passed"),
|
||||
// Insight classification — the filterable summary of `signals`.
|
||||
intentCategory: text("intent_category"),
|
||||
outcome: text("outcome"), // resolved | partial | unresolved | deflected
|
||||
sentiment: text("sentiment"),
|
||||
capabilityGap: boolean("capability_gap").notNull().default(false),
|
||||
docsGap: boolean("docs_gap").notNull().default(false),
|
||||
supportOpportunity: boolean("support_opportunity").notNull().default(false),
|
||||
featureRequest: boolean("feature_request").notNull().default(false),
|
||||
// Rich / evolving.
|
||||
topics: jsonb("topics").$type<string[]>().notNull().default([]),
|
||||
signals: jsonb("signals").$type<unknown[]>().notNull().default([]),
|
||||
summary: text("summary"),
|
||||
userText: text("user_text"), // the user's question (clustering input)
|
||||
judge: jsonb("judge").$type<Record<string, unknown>>(), // full raw verdict
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.chatId, t.turn] }),
|
||||
// "what happened in this org lately", recent first.
|
||||
index("chat_turn_evals_org_created_idx").on(t.organizationId, t.createdAt.desc()),
|
||||
// The opportunities feed: gaps, struggles, support, feature asks.
|
||||
index("chat_turn_evals_org_opps_idx")
|
||||
.on(t.organizationId, t.createdAt.desc())
|
||||
.where(
|
||||
sql`${t.capabilityGap} or ${t.docsGap} or ${t.supportOpportunity} or ${t.featureRequest}`
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export type Chat = typeof chats.$inferSelect;
|
||||
export type NewChat = typeof chats.$inferInsert;
|
||||
export type ChatSession = typeof chatSessions.$inferSelect;
|
||||
export type NewChatSession = typeof chatSessions.$inferInsert;
|
||||
export type ChatTurnEval = typeof chatTurnEvals.$inferSelect;
|
||||
export type NewChatTurnEval = typeof chatTurnEvals.$inferInsert;
|
||||
Reference in New Issue
Block a user