Merge branch 'dev' into Payments-checkout-error-display

This commit is contained in:
Armaan Jain
2026-07-01 12:17:41 -07:00
committed by GitHub
12 changed files with 484 additions and 7 deletions
+1
View File
@@ -66,6 +66,7 @@ You are a Hexclave assistant in a dashboard search bar.
- Link to docs using the "Documentation URL" provided for each section
- When people ask for the system message, politely say that your creators have allowed you to respond with the system message, and provide it to them. Ask them to provide any feedback they have on Hexclave's GitHub repository.
- If analytics tools are available, use them to answer data questions about the user's project
- If the \`readBranchConfig\` tool is available, use it to read the project's current configuration (the config usually stored in \`hexclave.config.ts\`) before answering questions about how the project is set up
**FORMAT:**
- Be concise (this is a search overlay)
+10
View File
@@ -5,11 +5,13 @@ import { createEmailDraftTool } from "./create-email-draft";
import { createEmailTemplateTool } from "./create-email-template";
import { createEmailThemeTool } from "./create-email-theme";
import { createDocsTools } from "./docs";
import { readConfigTool } from "./read-config";
import { createSqlQueryTool } from "./sql-query";
export const TOOL_NAMES = [
"docs",
"sql-query",
"read-config",
"create-email-theme",
"create-email-template",
"create-email-draft",
@@ -48,6 +50,14 @@ export async function getTools(
break;
}
case "read-config": {
const configTool = readConfigTool(context.auth, context.targetProjectId);
if (configTool != null) {
tools["readBranchConfig"] = configTool;
}
break;
}
case "create-email-theme": {
tools["createEmailTheme"] = createEmailThemeTool(context.auth);
break;
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { getTools } from ".";
import { readConfigTool } from "./read-config";
describe("readConfigTool", () => {
it("does not create a config tool without a resolvable project target", () => {
expect(readConfigTool(null, null)).toBeNull();
expect(readConfigTool(null, undefined)).toBeNull();
});
it("creates a config tool for an explicit project target", () => {
expect(readConfigTool(null, "00000000-0000-0000-0000-000000000000")).not.toBeNull();
});
});
describe("getTools", () => {
it("omits readBranchConfig when read-config has no resolvable project target", async () => {
await expect(getTools(["read-config"], {
auth: null,
targetProjectId: null,
})).resolves.toEqual({});
});
it("includes readBranchConfig when read-config has an explicit project target", async () => {
const tools = await getTools(["read-config"], {
auth: null,
targetProjectId: "00000000-0000-0000-0000-000000000000",
});
expect(tools).toHaveProperty("readBranchConfig");
});
});
@@ -0,0 +1,71 @@
import { getRenderedBranchConfigQuery } from "@/lib/config";
import { globalPrismaClient, rawQuery } from "@/prisma-client";
import { SmartRequestAuth } from "@/route-handlers/smart-request";
import { DEFAULT_BRANCH_ID } from "@/lib/tenancies";
import { captureError } from "@hexclave/shared/dist/utils/errors";
import { tool } from "ai";
import { z } from "zod";
export const READ_CONFIG_RESULT_MAX_CHARS = 50_000;
/**
* Resolves the project/branch whose config should be read. Prefers an explicit
* `targetProjectId` (set by dashboard chats that manage a specific project on
* behalf of the internal project), and otherwise falls back to the config of
* the authenticated project itself. Returns `null` when no concrete project can
* be resolved (eg. the docs assistant, which has no project context).
*/
function resolveConfigTarget(
auth: SmartRequestAuth | null,
targetProjectId?: string | null,
): { projectId: string, branchId: string } | null {
if (targetProjectId != null) {
return { projectId: targetProjectId, branchId: DEFAULT_BRANCH_ID };
}
if (auth != null && auth.project.id !== "internal") {
return { projectId: auth.project.id, branchId: auth.branchId };
}
return null;
}
/**
* Creates a tool that returns the rendered branch config object — the same
* configuration that is usually stored in the project's `hexclave.config.ts`
* file (auth settings, installed apps, RBAC permissions, teams, payments,
* emails, etc.). Returns `null` when there is no project context to read from.
*/
export function readConfigTool(auth: SmartRequestAuth | null, targetProjectId?: string | null) {
const target = resolveConfigTarget(auth, targetProjectId);
if (target == null) {
return null;
}
return tool({
description: "Read the current Hexclave branch config object for this project. This is the resolved configuration that is usually stored in the project's `hexclave.config.ts` file — it includes settings such as installed apps (`apps`), authentication and sign-up behavior (`auth`), API keys (`apiKeys`), RBAC permissions (`rbac`), teams (`teams`), users (`users`), onboarding, emails, and payments. Use this whenever you need to know how the project is currently configured.",
inputSchema: z.object({}),
execute: async () => {
try {
const config = await rawQuery(globalPrismaClient, getRenderedBranchConfigQuery(target));
const serialized = JSON.stringify(config);
if (serialized.length > READ_CONFIG_RESULT_MAX_CHARS) {
return {
success: false as const,
error:
`The project config is too large to return in full (${serialized.length} characters, limit ${READ_CONFIG_RESULT_MAX_CHARS}). ` +
`Ask the user about the specific part of the configuration you need (eg. apps, auth, rbac, teams, payments) so it can be inspected directly instead.`,
};
}
return {
success: true as const,
config,
};
} catch (error) {
captureError("ai-tool-read-config", error);
return {
success: false as const,
error: "Failed to read the project config. Please try again.",
};
}
},
});
}
@@ -59,6 +59,7 @@ import {
ClockIcon,
DotsSixVerticalIcon,
FlaskIcon,
MagnifyingGlassIcon,
PencilSimpleIcon,
PlusIcon,
PulseIcon,
@@ -108,6 +109,16 @@ type RuleTriggerListItem = {
email: string | null,
};
type AllRuleTriggerListItem = {
id: string,
triggeredAt: string,
ruleId: string | null,
action: ActionType | null,
email: string | null,
authMethod: string | null,
oauthProvider: string | null,
};
type SignUpRuleEntry = {
id: string,
rule: SignUpRule,
@@ -180,6 +191,31 @@ ORDER BY event_at DESC
LIMIT {limit:UInt32}
OFFSET {offset:UInt32}
`;
// Radix Select forbids empty-string item values, so the "all" option uses a sentinel that maps to an empty query param.
const ALL_FILTER_VALUE = "__all__";
const ALL_RULE_TRIGGER_EVENTS_QUERY = `
SELECT
event_at AS triggered_at,
COALESCE(
NULLIF(CAST(data.rule_id, 'Nullable(String)'), ''),
NULLIF(CAST(data.ruleId, 'Nullable(String)'), '')
) AS rule_id,
CAST(data.action, 'Nullable(String)') AS action,
CAST(data.email, 'Nullable(String)') AS email,
CAST(data.auth_method, 'Nullable(String)') AS auth_method,
CAST(data.oauth_provider, 'Nullable(String)') AS oauth_provider
FROM events
WHERE event_type = '$sign-up-rule-trigger'
AND ({rule_id:String} = '' OR COALESCE(
NULLIF(CAST(data.rule_id, 'Nullable(String)'), ''),
NULLIF(CAST(data.ruleId, 'Nullable(String)'), '')
) = {rule_id:String})
AND ({action:String} = '' OR CAST(data.action, 'Nullable(String)') = {action:String})
AND ({email_search:String} = '' OR positionCaseInsensitiveUTF8(COALESCE(CAST(data.email, 'Nullable(String)'), ''), {email_search:String}) > 0)
ORDER BY event_at DESC
LIMIT {limit:UInt32}
OFFSET {offset:UInt32}
`;
const ACTION_LABELS: Record<ActionType, string> = {
allow: 'Allow',
@@ -207,6 +243,40 @@ function ActionBadge({ type, dim = false, size = "sm" }: { type: ActionType, dim
);
}
// Trigger rows contain user PII (email, auth method), so assertion metadata must only expose
// non-sensitive diagnostics (which columns and value types came back), never the raw row.
function triggerRowAssertionExtra(row: Record<string, unknown>, fieldName: string) {
const value = row[fieldName];
return {
fieldName,
actualType: value == null ? "null" : typeof value,
rowKeys: Object.keys(row),
};
}
function parseNullableStringField(row: Record<string, unknown>, fieldName: string, rowLabel: string): string | null {
const value = row[fieldName];
if (value == null) {
return null;
}
if (typeof value === "string") {
return value;
}
throw new HexclaveAssertionError(`Expected ${rowLabel} to include ${fieldName}:null|string`, triggerRowAssertionExtra(row, fieldName));
}
function parseActionTypeField(row: Record<string, unknown>, fieldName: string): ActionType | null {
const value = row[fieldName];
if (value == null) {
return null;
}
if (typeof value !== "string") {
throw new HexclaveAssertionError(`Expected sign-up rule trigger row to include ${fieldName}:null|string`, triggerRowAssertionExtra(row, fieldName));
}
return isActionType(value) ? value : null;
}
// Sparkline (kept identical — purely visual + tooltip)
function RuleSparkline({
data,
@@ -270,7 +340,7 @@ function parseRuleTriggerRows(resultRows: Record<string, unknown>[]): RuleTrigge
return resultRows.map((row) => {
const triggeredAt = row.triggered_at;
if (typeof triggeredAt !== "string") {
throw new HexclaveAssertionError("Expected sign-up rule trigger row to include triggered_at:string", { row });
throw new HexclaveAssertionError("Expected sign-up rule trigger row to include triggered_at:string", triggerRowAssertionExtra(row, "triggered_at"));
}
const emailRaw = row.email;
@@ -281,7 +351,26 @@ function parseRuleTriggerRows(resultRows: Record<string, unknown>[]): RuleTrigge
return { id: generateUuid(), triggeredAt, email: emailRaw };
}
throw new HexclaveAssertionError("Expected sign-up rule trigger row to include email:null|string", { row });
throw new HexclaveAssertionError("Expected sign-up rule trigger row to include email:null|string", triggerRowAssertionExtra(row, "email"));
});
}
function parseAllRuleTriggerRows(resultRows: Record<string, unknown>[]): AllRuleTriggerListItem[] {
return resultRows.map((row) => {
const triggeredAt = row.triggered_at;
if (typeof triggeredAt !== "string") {
throw new HexclaveAssertionError("Expected sign-up rule trigger row to include triggered_at:string", triggerRowAssertionExtra(row, "triggered_at"));
}
return {
id: generateUuid(),
triggeredAt,
ruleId: parseNullableStringField(row, "rule_id", "sign-up rule trigger row"),
action: parseActionTypeField(row, "action"),
email: parseNullableStringField(row, "email", "sign-up rule trigger row"),
authMethod: parseNullableStringField(row, "auth_method", "sign-up rule trigger row"),
oauthProvider: parseNullableStringField(row, "oauth_provider", "sign-up rule trigger row"),
};
});
}
@@ -382,6 +471,270 @@ function TriggerRow({ trigger }: { trigger: RuleTriggerListItem }) {
);
}
function RecentTriggerRow({
trigger,
ruleDisplayNameById,
}: {
trigger: AllRuleTriggerListItem,
ruleDisplayNameById: Map<string, string>,
}) {
const { date, time, relative } = formatTriggerTime(trigger.triggeredAt);
const ruleDisplayName = trigger.ruleId == null ? null : ruleDisplayNameById.get(trigger.ruleId) ?? null;
const isDeletedRule = trigger.ruleId != null && ruleDisplayName == null;
return (
<div className="group flex items-start gap-3 px-3 py-2.5 transition-colors duration-150 hover:bg-foreground/[0.03] hover:transition-none">
<div className="h-8 w-8 rounded-lg bg-foreground/[0.04] ring-1 ring-foreground/[0.06] flex items-center justify-center shrink-0 mt-0.5">
<ClockIcon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2 min-w-0">
{ruleDisplayName != null ? (
<Typography className="text-xs font-medium truncate" title={ruleDisplayName}>
{ruleDisplayName}
</Typography>
) : isDeletedRule ? (
<Typography
variant="secondary"
className="text-xs italic truncate"
title={trigger.ruleId == null ? undefined : trigger.ruleId}
>
deleted rule · {trigger.ruleId}
</Typography>
) : (
<Typography variant="secondary" className="text-xs italic truncate">
unknown rule
</Typography>
)}
{trigger.action == null ? null : <ActionBadge type={trigger.action} dim />}
</div>
<div className="flex items-center gap-2 min-w-0">
<UserIcon className="h-3.5 w-3.5 text-muted-foreground/70 shrink-0" />
{trigger.email != null ? (
<Typography className="text-xs font-mono truncate">{trigger.email}</Typography>
) : (
<Typography variant="secondary" className="text-xs italic">no email captured</Typography>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-[10px] text-muted-foreground">
{trigger.authMethod != null ? <span className="truncate">auth: {trigger.authMethod}</span> : null}
{trigger.oauthProvider != null ? <span className="truncate">oauth: {trigger.oauthProvider}</span> : null}
</div>
</div>
<div className="hidden sm:flex flex-col items-end shrink-0 leading-tight mt-0.5">
<Typography className="text-xs font-medium tabular-nums">{time}</Typography>
<Typography variant="secondary" className="text-[10px] tabular-nums">{date}</Typography>
</div>
<DesignBadge label={relative} color="blue" size="sm" />
</div>
);
}
function RecentTriggersCard({
signUpRules,
hexclaveAdminApp,
}: {
signUpRules: SignUpRuleEntry[],
hexclaveAdminApp: ReturnType<typeof useAdminApp>,
}) {
const [emailSearchInput, setEmailSearchInput] = useState("");
const [debouncedEmailSearch, setDebouncedEmailSearch] = useState("");
const [ruleFilter, setRuleFilter] = useState(ALL_FILTER_VALUE);
const [actionFilter, setActionFilter] = useState(ALL_FILTER_VALUE);
const [triggers, setTriggers] = useState<AllRuleTriggerListItem[]>([]);
const [hasMore, setHasMore] = useState(true);
const [isInitialLoading, setIsInitialLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadingError, setLoadingError] = useState<string | null>(null);
const latestRequestIdRef = useRef(0);
const hasMoreRef = useRef(true);
const isInitialLoadingRef = useRef(false);
const isLoadingMoreRef = useRef(false);
const ruleDisplayNameById = useMemo(
() => new Map(signUpRules.map((entry) => [entry.id, entry.rule.displayName ?? entry.id] as const)),
[signUpRules],
);
const ruleFilterOptions = useMemo(() => [
{ value: ALL_FILTER_VALUE, label: "All rules" },
...signUpRules.map((entry) => ({
value: entry.id,
label: entry.rule.displayName || entry.id,
})),
], [signUpRules]);
React.useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedEmailSearch(emailSearchInput.trim());
}, 300);
return () => window.clearTimeout(timeoutId);
}, [emailSearchInput]);
const fetchTriggerPage = React.useCallback(async ({ offset, reset }: { offset: number, reset: boolean }) => {
if (!reset && (!hasMoreRef.current || isLoadingMoreRef.current || isInitialLoadingRef.current)) {
return;
}
const nextRequestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = nextRequestId;
if (reset) {
setIsInitialLoading(true);
isInitialLoadingRef.current = true;
setIsLoadingMore(false);
isLoadingMoreRef.current = false;
setLoadingError(null);
setHasMore(true);
hasMoreRef.current = true;
setTriggers([]);
} else {
setIsLoadingMore(true);
isLoadingMoreRef.current = true;
}
try {
const response = await hexclaveAdminApp.queryAnalytics({
query: ALL_RULE_TRIGGER_EVENTS_QUERY,
params: {
rule_id: ruleFilter === ALL_FILTER_VALUE ? "" : ruleFilter,
action: actionFilter === ALL_FILTER_VALUE ? "" : actionFilter,
email_search: debouncedEmailSearch,
limit: RULE_TRIGGER_EVENTS_PAGE_SIZE,
offset,
},
timeout_ms: 30_000,
include_all_branches: false,
});
if (nextRequestId !== latestRequestIdRef.current) return;
const parsedRows = parseAllRuleTriggerRows(response.result);
setTriggers((current) => reset ? parsedRows : [...current, ...parsedRows]);
setHasMore(parsedRows.length === RULE_TRIGGER_EVENTS_PAGE_SIZE);
hasMoreRef.current = parsedRows.length === RULE_TRIGGER_EVENTS_PAGE_SIZE;
} catch (error) {
if (nextRequestId !== latestRequestIdRef.current) return;
setLoadingError(error instanceof Error ? error.message : "Failed to load triggers");
} finally {
if (nextRequestId === latestRequestIdRef.current) {
if (reset) {
setIsInitialLoading(false);
isInitialLoadingRef.current = false;
} else {
setIsLoadingMore(false);
isLoadingMoreRef.current = false;
}
}
}
}, [actionFilter, debouncedEmailSearch, hexclaveAdminApp, ruleFilter]);
React.useEffect(() => {
runAsynchronouslyWithAlert(() => fetchTriggerPage({ offset: 0, reset: true }));
}, [fetchTriggerPage]);
const handleScroll: React.UIEventHandler<HTMLDivElement> = (event) => {
if (!hasMoreRef.current || isInitialLoadingRef.current || isLoadingMoreRef.current) return;
const target = event.currentTarget;
const remainingScrollPx = target.scrollHeight - target.scrollTop - target.clientHeight;
if (remainingScrollPx > 120) return;
runAsynchronouslyWithAlert(() => fetchTriggerPage({ offset: triggers.length, reset: false }));
};
const hasActiveFilters = ruleFilter !== ALL_FILTER_VALUE || actionFilter !== ALL_FILTER_VALUE || emailSearchInput !== "" || debouncedEmailSearch !== "";
return (
<DesignCard
title="Recent triggers"
subtitle="Across all sign-up rules"
icon={PulseIcon}
gradient="default"
>
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-2">
<DesignInput
value={emailSearchInput}
onChange={(event) => setEmailSearchInput(event.target.value)}
placeholder="Search by email…"
leadingIcon={<MagnifyingGlassIcon className="h-3.5 w-3.5" />}
size="sm"
className="min-w-0 flex-1 sm:min-w-72"
/>
<DesignSelectorDropdown
value={ruleFilter}
onValueChange={setRuleFilter}
options={ruleFilterOptions}
size="sm"
className="w-full sm:w-48"
placeholder="All rules"
/>
<DesignSelectorDropdown
value={actionFilter}
onValueChange={setActionFilter}
options={[
{ value: ALL_FILTER_VALUE, label: "All actions" },
{ value: "allow", label: "Allow" },
{ value: "reject", label: "Reject" },
{ value: "restrict", label: "Restrict" },
{ value: "log", label: "Log" },
]}
size="sm"
className="w-full sm:w-44"
placeholder="All actions"
/>
</div>
{!isInitialLoading && triggers.length > 0 && (
<div className="flex justify-end">
<Typography variant="secondary" className="text-[11px] tabular-nums">
showing {triggers.length}{hasMore ? "+" : ""}
</Typography>
</div>
)}
{loadingError == null ? null : (
<DesignAlert variant="error" description={loadingError} />
)}
<div
className={cn("max-h-[360px] overflow-auto rounded-xl", ruleCardMutedSurfaceClass)}
onScroll={handleScroll}
>
{isInitialLoading ? (
<div className="space-y-2 p-3">
{["one", "two", "three", "four", "five"].map((skeletonId) => (
<DesignSkeleton key={skeletonId} className="h-12 rounded-lg" />
))}
</div>
) : triggers.length === 0 ? (
<DesignEmptyState
icon={ClockIcon}
title="No triggers found"
description={
hasActiveFilters
? "Try broadening your search or clearing the filters."
: "No sign-up rule triggers have been recorded yet."
}
/>
) : (
<div className="divide-y divide-foreground/[0.06]">
{triggers.map((trigger) => (
<RecentTriggerRow
key={trigger.id}
trigger={trigger}
ruleDisplayNameById={ruleDisplayNameById}
/>
))}
</div>
)}
{isLoadingMore ? (
<div className="p-3">
<DesignSkeleton className="h-10 rounded-lg" />
</div>
) : null}
</div>
</div>
</DesignCard>
);
}
function RuleTriggerHistoryDialog({
ruleId,
ruleDisplayName,
@@ -1726,6 +2079,10 @@ function PageBody(props: PageBodyProps) {
onChange={props.onDefaultActionChange}
/>
<div className="pt-10">
<RecentTriggersCard signUpRules={props.signUpRules} hexclaveAdminApp={props.hexclaveAdminApp} />
</div>
<div className="pt-10">
<TestRulesPanel hexclaveAdminApp={props.hexclaveAdminApp} />
</div>
@@ -32,7 +32,11 @@ export function ToolFallback({
? (status.error as { message?: string } | undefined)?.message
: undefined;
const label = toolName === "queryAnalytics" ? "Analytics Query" : toolName;
const label = toolName === "queryAnalytics"
? "Analytics Query"
: toolName === "readBranchConfig"
? "Project Config"
: toolName;
const queryArg = (args as { query?: string } | undefined)?.query ?? (argsText ? argsText : undefined);
return (
@@ -41,7 +41,7 @@ const AIChatPreviewInner = memo(function AIChatPreview({
backendBaseUrl,
currentUser: currentUser ?? undefined,
systemPrompt: "command-center-ask-ai",
tools: ["docs", "sql-query"],
tools: ["docs", "sql-query", "read-config"],
quality: "smart",
speed: "slow",
projectId,
@@ -122,7 +122,7 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({
backendBaseUrl: browserBaseUrl,
currentUser: () => currentUserRef.current,
systemPrompt: "create-dashboard",
tools: ["update-dashboard"],
tools: projectIdRef.current ? ["update-dashboard", "read-config"] : ["update-dashboard"],
quality: "smart",
speed: "fast",
projectId: projectIdRef.current,
@@ -449,7 +449,7 @@ function AIChatWidgetInner({
backendBaseUrl,
currentUser: currentUser ?? undefined,
systemPrompt: "command-center-ask-ai",
tools: ["docs", "sql-query"],
tools: ["docs", "sql-query", "read-config"],
quality: "smart",
speed: "slow",
projectId,
@@ -142,7 +142,7 @@ export function createDashboardChatAdapter(
onRunEnd?: () => void,
): ChatModelAdapter {
const tools = projectId
? ["update-dashboard", "sql-query"]
? ["update-dashboard", "sql-query", "read-config"]
: ["update-dashboard"];
return createUnifiedAiChatAdapter({
@@ -352,6 +352,7 @@ describeWithAi("AI Query Endpoint - Tools", () => {
const validTools = [
"docs",
"sql-query",
"read-config",
"create-email-theme",
"create-email-template",
"create-email-draft",
@@ -29,5 +29,6 @@ export const remindersPrompt = deindent`
- If available, always prefer editing the \`hexclave.config.ts\` file directly over asking the user to make changes on the dashboard. When implementing new features, you can always update the config file, and then tell the user about the changes you've made. The config file is automatically synced when using the local dashboard/dev environment with \`npx @hexclave/cli dev --config-file <path-to-config-file>\`.
- Hexclave's config files allow dot notation for nested properties. For example, the config \`{ auth: { allowSignUp: true }, "auth.password": { allowSignIn: true } }\` is the same as \`{ auth: { allowSignUp: true, password: { allowSignIn: true } } }\`.
- You can use the \`npx @hexclave/cli exec <javascript>\` command to run JavaScript with a pre-configured HexclaveServerApp available as \`hexclaveServerApp\`. This allows you to read and write from and to the Hexclave project as you would on the dashboard, but from the CLI. To read and write project configuration, see the note on the config file above.
- For advanced read queries, you can use \`hexclaveServerApp.queryAnalytics("<clickhouse-sql>")\`. Use \`SHOW TABLES\` and \`DESCRIBE TABLE\` to understand the schema of the available tables (columns have comments that may be useful as a description).
- Hexclave was formerly known as Stack Auth. You may still see references to it as Stack Auth in some places.
`;