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,46 @@
# @internal/dashboard-agent-db
The conversation datastore for the in-dashboard agent, isolated from the main
Prisma database. Drizzle (postgres-js) over a dedicated `trigger_dashboard_agent`
Postgres schema.
- **Cloud:** a separate PlanetScale Postgres database. The app connects over a
pooled connection (`DASHBOARD_AGENT_DATABASE_URL`); migrations run over a direct
(non-pooler) connection (`DASHBOARD_AGENT_DIRECT_URL`), since a transaction-mode
pooler can't run the migrator.
- **OSS / self-host:** falls back to the main `DATABASE_URL` (and `DIRECT_URL` for
migrations); the tables live in the dedicated `trigger_dashboard_agent` schema,
isolated from Prisma's `public`.
The schema is **foreign-key-free** — it references main entities (`organizationId`,
`userId`) by id only, because in cloud it lives in a different database.
## Why a separate store
The agent runs as an ephemeral Trigger task and must have **no access to the main
database or ClickHouse** (those go through the API). This is its own low-blast-radius
store: the agent connects directly here to persist conversations, and the webapp
connects here for the History tab. Conversation history *correctness* is owned by
`chat.agent`'s built-in object-store snapshot — this DB is a display read-model
(list chats, render a past chat, resume the transport), never the model's source
of truth.
## Tables
- `chats` — one row per conversation: org/user scope, title, a `messages` JSONB
display copy of the transcript, and `metadata` (the project/env context the chat
ran in). Soft-deleted via `deleted_at`, pinned via `pinned_at`.
- `chat_sessions` — live transport state keyed by `chat_id`: the session-scoped
`public_access_token` and `last_event_id` for resume. Separate table so the
secret token is isolated from list queries and the hot per-turn write stays off
the conversation row's indexes.
## Migrations
```bash
pnpm run db:generate # generate SQL migration from src/schema.ts (offline)
pnpm run db:migrate # apply migrations (direct url: DASHBOARD_AGENT_DIRECT_URL, falling back to DASHBOARD_AGENT_DATABASE_URL / DIRECT_URL / DATABASE_URL)
```
drizzle-kit is scoped to the `trigger_dashboard_agent` schema (`schemaFilter`), so
pointing it at the main OSS database never touches Prisma's tables.
@@ -0,0 +1,25 @@
import { defineConfig } from "drizzle-kit";
// Migrations need a direct (non-pooler) connection; a transaction-mode pooler
// can't run the migrator. Prefer the agent's direct url, then its pooled url,
// then the main DIRECT_URL/DATABASE_URL (OSS single-database fallback; tables
// still land in the trigger_dashboard_agent schema).
const url =
process.env.DASHBOARD_AGENT_DIRECT_URL ??
process.env.DASHBOARD_AGENT_DATABASE_URL ??
process.env.DIRECT_URL ??
process.env.DATABASE_URL ??
"postgres://placeholder"; // generate is offline; a real url is only needed for migrate/studio
export default defineConfig({
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
// Only manage our schema — never introspect or diff Prisma's `public` schema.
schemaFilter: ["trigger_dashboard_agent"],
// Own journal table so dev (`drizzle-kit migrate`) and deploy (migrate.mjs)
// share one history and we don't cross-poison the default
// drizzle.__drizzle_migrations when the DB is shared. See migrate.mjs.
migrations: { table: "__dashboard_agent_migrations", schema: "drizzle" },
dbCredentials: { url },
});
@@ -0,0 +1,25 @@
CREATE SCHEMA IF NOT EXISTS "trigger_dashboard_agent";
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "trigger_dashboard_agent"."chat_sessions" (
"chat_id" text PRIMARY KEY NOT NULL,
"public_access_token" text NOT NULL,
"last_event_id" text,
"run_id" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "trigger_dashboard_agent"."chats" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"user_id" text NOT NULL,
"title" text DEFAULT 'New chat' NOT NULL,
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"pinned_at" timestamp with time zone,
"deleted_at" timestamp with time zone,
"last_message_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "chats_org_user_last_msg_idx" ON "trigger_dashboard_agent"."chats" USING btree ("organization_id","user_id","last_message_at" DESC NULLS LAST) WHERE "trigger_dashboard_agent"."chats"."deleted_at" is null;
@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS "trigger_dashboard_agent"."chat_turn_evals" (
"chat_id" text NOT NULL,
"turn" integer NOT NULL,
"organization_id" text NOT NULL,
"user_id" text NOT NULL,
"agent_run_id" text,
"eval_run_id" text,
"project_ref" text,
"environment" text,
"current_page" text,
"model" text,
"prompt_slug" text,
"prompt_version" integer,
"tools_used" jsonb DEFAULT '[]'::jsonb NOT NULL,
"tool_error" boolean DEFAULT false NOT NULL,
"judge_model" text,
"score_grounded" smallint,
"score_answered" smallint,
"score_concise" smallint,
"passed" boolean,
"intent_category" text,
"outcome" text,
"sentiment" text,
"capability_gap" boolean DEFAULT false NOT NULL,
"docs_gap" boolean DEFAULT false NOT NULL,
"support_opportunity" boolean DEFAULT false NOT NULL,
"feature_request" boolean DEFAULT false NOT NULL,
"topics" jsonb DEFAULT '[]'::jsonb NOT NULL,
"signals" jsonb DEFAULT '[]'::jsonb NOT NULL,
"summary" text,
"user_text" text,
"judge" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_turn_evals_chat_id_turn_pk" PRIMARY KEY("chat_id","turn")
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "chat_turn_evals_org_created_idx" ON "trigger_dashboard_agent"."chat_turn_evals" USING btree ("organization_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "chat_turn_evals_org_opps_idx" ON "trigger_dashboard_agent"."chat_turn_evals" USING btree ("organization_id","created_at" DESC NULLS LAST) 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";
@@ -0,0 +1,178 @@
{
"id": "512ce1a7-b31d-4644-9639-3e124b22e52e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"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.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'"
},
"messages": {
"name": "messages",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"pinned_at": {
"name": "pinned_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
},
"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
}
},
"enums": {},
"schemas": {
"trigger_dashboard_agent": "trigger_dashboard_agent"
},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,441 @@
{
"id": "7a42a0cf-9933-4381-adad-98c25105465b",
"prevId": "512ce1a7-b31d-4644-9639-3e124b22e52e",
"version": "7",
"dialect": "postgresql",
"tables": {
"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_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'"
},
"messages": {
"name": "messages",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"pinned_at": {
"name": "pinned_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
},
"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
}
},
"enums": {},
"schemas": {
"trigger_dashboard_agent": "trigger_dashboard_agent"
},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1781711036274,
"tag": "0000_magenta_lilandra",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781990914081,
"tag": "0001_slimy_living_tribunal",
"breakpoints": true
}
]
}
@@ -0,0 +1,77 @@
// Pending-status check for the `trigger_dashboard_agent` schema (sibling of
// migrate.mjs). Exit 0 = up to date, 1 = pending, 2 = error.
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import postgres from "postgres";
const MIGRATIONS_SCHEMA = "drizzle";
const MIGRATIONS_TABLE = "__dashboard_agent_migrations";
// Match migrate.mjs: same precedence, and expand `${VAR}` refs (see migrate.mjs).
const connectionString = (
process.env.DASHBOARD_AGENT_DIRECT_URL ??
process.env.DASHBOARD_AGENT_DATABASE_URL ??
process.env.DIRECT_URL ??
process.env.DATABASE_URL
)?.replace(/\$\{(\w+)\}/g, (_, k) => process.env[k] ?? "");
if (!connectionString) {
console.error(
"[dashboard-agent-db] No database url set (DASHBOARD_AGENT_DIRECT_URL / DASHBOARD_AGENT_DATABASE_URL / DIRECT_URL / DATABASE_URL); cannot check status."
);
process.exit(2);
}
// Match migrate.mjs: drop the Prisma-style `?schema=` param postgres.js forwards.
function normalizeConnectionString(value) {
try {
const url = new URL(value);
url.searchParams.delete("schema");
return url.toString();
} catch {
return value;
}
}
const journalPath = join(dirname(fileURLToPath(import.meta.url)), "drizzle/meta/_journal.json");
const sql = postgres(normalizeConnectionString(connectionString), {
max: 1,
prepare: false,
onnotice: () => {},
});
async function main() {
const journal = JSON.parse(readFileSync(journalPath, "utf-8"));
const entries = [...journal.entries].sort((a, b) => a.when - b.when);
let lastAppliedAt = -1;
try {
const rows = await sql`SELECT MAX(created_at)::bigint AS last FROM ${sql(
MIGRATIONS_SCHEMA
)}.${sql(MIGRATIONS_TABLE)}`;
lastAppliedAt = rows[0].last === null ? -1 : Number(rows[0].last);
} catch (err) {
// 42P01: journal table absent (fresh database), so nothing is applied.
if (err.code !== "42P01") throw err;
}
const pending = entries.filter((e) => e.when > lastAppliedAt);
console.log(`${entries.length} migration(s) found, ${entries.length - pending.length} applied`);
if (pending.length > 0) {
console.log(`${pending.length} pending migration(s):`);
for (const e of pending) console.log(` - ${e.tag}`);
return 1;
}
console.log("Dashboard agent schema is up to date");
return 0;
}
main()
.then((code) => sql.end({ timeout: 5 }).then(() => process.exit(code)))
.catch((err) => {
console.error(err);
sql.end({ timeout: 5 }).finally(() => process.exit(2));
});
@@ -0,0 +1,76 @@
// Production migration runner for the `trigger_dashboard_agent` schema.
//
// Runs under plain `node migrate.mjs` in the built image: `drizzle-orm` and
// `postgres` are runtime dependencies, so this needs no `drizzle-kit`, `tsx`,
// or build step (keeps the image lean). The OSS container runs this from its
// entrypoint; cloud runs it out-of-band against its own database.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
// Migrations need a direct (non-pooler) connection; a transaction-mode pooler
// can't run the migrator. Prefer the agent's direct url, then its pooled url,
// then the main DIRECT_URL/DATABASE_URL (OSS single-database fallback; tables
// still land in the `trigger_dashboard_agent` schema).
// `.replace` expands `${VAR}` refs (e.g. the repo .env's DIRECT_URL=${DATABASE_URL});
// node's --env-file loads them literally, unlike Prisma's dotenv-expand.
const connectionString = (
process.env.DASHBOARD_AGENT_DIRECT_URL ??
process.env.DASHBOARD_AGENT_DATABASE_URL ??
process.env.DIRECT_URL ??
process.env.DATABASE_URL
)?.replace(/\$\{(\w+)\}/g, (_, k) => process.env[k] ?? "");
if (!connectionString) {
console.error(
"[dashboard-agent-db] No database url set (DASHBOARD_AGENT_DIRECT_URL / DASHBOARD_AGENT_DATABASE_URL / DIRECT_URL / DATABASE_URL); cannot migrate."
);
process.exit(1);
}
// 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.
function normalizeConnectionString(value) {
try {
const url = new URL(value);
url.searchParams.delete("schema");
return url.toString();
} catch {
return value;
}
}
const migrationsFolder = join(dirname(fileURLToPath(import.meta.url)), "drizzle");
const sql = postgres(normalizeConnectionString(connectionString), {
max: 1,
prepare: false,
// Silence the "schema/relation already exists, skipping" notices the journal's
// idempotent CREATE IF NOT EXISTS emits on every re-run, so restart logs stay clean.
onnotice: () => {},
});
try {
// Track our history in a DEDICATED journal table. Drizzle's migrator reads the
// latest row from <schema>.<table> by created_at and skips any journal entry
// dated at or before it. The default `drizzle.__drizzle_migrations` is shared
// by every Drizzle app, so when this DB is shared (OSS single-database fallback
// and the enterprise-image E2E gate) billing's journal rows poison ours: a
// billing row dated between our 0000 and 0001 makes the migrator skip 0000 (the
// CREATE SCHEMA) and run 0001 against a schema that never got created. An own
// table keeps our history independent (enterprise/db does the same with
// __enterprise_migrations; billing keeps the default).
//
// The table stays in the `drizzle` schema, not our data schema, so 0000's
// `CREATE SCHEMA "trigger_dashboard_agent"` doesn't collide with the schema the
// migrator pre-creates for its journal.
await migrate(drizzle(sql), {
migrationsFolder,
migrationsTable: "__dashboard_agent_migrations",
});
console.log("[dashboard-agent-db] migrations complete");
} finally {
await sql.end();
}
@@ -0,0 +1,23 @@
{
"name": "@internal/dashboard-agent-db",
"private": true,
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"type": "module",
"dependencies": {
"drizzle-orm": "^0.45.0",
"postgres": "^3.4.0"
},
"devDependencies": {
"drizzle-kit": "^0.31.0"
},
"scripts": {
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate:deploy": "node --env-file-if-exists=../../.env migrate.mjs",
"db:migrate:status": "node --env-file-if-exists=../../.env migrate-status.mjs",
"db:studio": "drizzle-kit studio"
}
}
@@ -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;
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "Node16",
"moduleResolution": "Node16",
"moduleDetection": "force",
"verbatimModuleSyntax": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"noEmit": true,
"strict": true
},
"exclude": ["node_modules", "drizzle"]
}