chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -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;