Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/backend/src/adapters/adapters.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,19 @@ export class AdaptersController {
private readonly productEvents: ProductEventService,
) {}

// Declared before `:slug`, which would otherwise take "starter-pack" for
// an adapter slug.
// Declared before `:slug`, which would otherwise take these paths for an
// adapter slug.
@Get('popular-connectors')
@ApiOperation({
summary: 'Apps new workspaces connect and get working',
description:
'Catalog adapters ranked by how many workspaces had a successful call on them in the last 30 days ' +
'(aggregate counts only), with what each setup asks for and whether this workspace already has it.',
})
async popularConnectors(@Req() req: any) {
return { items: await this.adaptersService.popularConnectors(req.user.organizationId) };
}

@Get('starter-pack')
@ApiOperation({
summary: 'Connectors offered to a new workspace',
Expand Down
71 changes: 69 additions & 2 deletions packages/backend/src/adapters/adapters.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,9 @@ describe('AdaptersService starter pack', () => {
expect({ slug: entry.slug, selfHostOnly: !!a.selfHostOnly }).toEqual({ slug: entry.slug, selfHostOnly: false });
expect(entry.pitch.length).toBeLessThanOrEqual(110);
}
expect(STARTER_PACK.filter((e: any) => e.preselected).length).toBeGreaterThanOrEqual(2);
// Preselected demos took the trial's connector slots from the app the
// user came for; the pack is opt-in now.
expect(STARTER_PACK.filter((e: any) => e.preselected)).toEqual([]);
});

it('leaves out Deutsche Bahn unless the operator provides MOTIS', async () => {
Expand All @@ -341,7 +343,7 @@ describe('AdaptersService starter pack', () => {
it('marks what the workspace already has and carries the card fields', async () => {
const items = await service({ installed: ['hackernews'] }).starterPack('org1');
const hn = items.find((i) => i.slug === 'hackernews')!;
expect(hn).toMatchObject({ installed: true, name: 'Hacker News', icon: 'hackernews', preselected: true });
expect(hn).toMatchObject({ installed: true, name: 'Hacker News', icon: 'hackernews', preselected: false });
expect(hn.toolCount).toBeGreaterThan(0);
expect(items.find((i) => i.slug === 'nominatim')!.installed).toBe(false);
// Order follows the pack definition.
Expand Down Expand Up @@ -451,3 +453,68 @@ describe('AdaptersService unlisted adapters', () => {
for (const slug of unlisted) expect(getAdapter(slug)?.unlisted).toBe(true);
});
});

describe('AdaptersService popular connectors', () => {
function service(opts: { rows?: Array<{ slug: string; workspaces: bigint }>; fail?: boolean; installed?: string[] } = {}) {
const svc = Object.create(AdaptersService.prototype) as AdaptersService;
(svc as any).logger = { warn: jest.fn() };
(svc as any).configService = { get: (k: string) => (k === 'DEPLOYMENT_MODE' ? 'cloud' : undefined) };
const queryRaw = opts.fail
? jest.fn().mockRejectedValue(new Error('db down'))
: jest.fn().mockResolvedValue(opts.rows ?? []);
(svc as any).prisma = {
$queryRaw: queryRaw,
connector: {
findMany: jest.fn().mockResolvedValue((opts.installed ?? []).map((slug) => ({ config: { adapterSlug: slug } }))),
},
};
return { svc, queryRaw };
}

it('leads with what works for the most workspaces, then fills from the fallback', async () => {
const { svc } = service({
rows: [
{ slug: 'odoo', workspaces: 17n },
{ slug: 'telegram-bot', workspaces: 133n },
].sort((a, b) => Number(b.workspaces - a.workspaces)),
});
const items = await svc.popularConnectors('org1');
expect(items.map((i) => i.slug).slice(0, 2)).toEqual(['telegram-bot', 'odoo']);
expect(items).toHaveLength(8);
expect(new Set(items.map((i) => i.slug)).size).toBe(8);
});

it('leaves keyless adapters to the starter pack', async () => {
const { svc } = service({ rows: [{ slug: 'hackernews', workspaces: 50n }, { slug: 'etsy', workspaces: 40n }] });
const items = await svc.popularConnectors('org1');
expect(items.map((i) => i.slug)).not.toContain('hackernews');
expect(items[0].slug).toBe('etsy');
});

it('says what each setup asks for, without the token a sign-in fills in', async () => {
const { svc } = service({ rows: [{ slug: 'etsy', workspaces: 40n }] });
const etsy = (await svc.popularConnectors('org1')).find((i) => i.slug === 'etsy')!;
expect(etsy.setupKind).toBe('oauth_browser');
expect(etsy.needs).toEqual(['Keystring', 'Shared secret']);
});

it('marks what the workspace already has', async () => {
const { svc } = service({ rows: [{ slug: 'telegram-bot', workspaces: 9n }], installed: ['telegram-bot'] });
const items = await svc.popularConnectors('org1');
expect(items.find((i) => i.slug === 'telegram-bot')!.installed).toBe(true);
});

it('ranks once an hour, not on every page view', async () => {
const { svc, queryRaw } = service({ rows: [{ slug: 'etsy', workspaces: 40n }] });
await svc.popularConnectors('org1');
await svc.popularConnectors('org2');
expect(queryRaw).toHaveBeenCalledTimes(1);
});

it('serves the fallback list when the ranking query fails', async () => {
const { svc } = service({ fail: true });
const items = await svc.popularConnectors('org1');
expect(items[0].slug).toBe('telegram-bot');
expect(items.length).toBeGreaterThanOrEqual(4);
});
});
89 changes: 89 additions & 0 deletions packages/backend/src/adapters/adapters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import {
import { pickProbe } from './probe.util';
import { normalizeBaseUrlVariables } from '../common/base-url-variable.util';
import { STARTER_PACK } from './starter-pack';
import {
POPULAR_CACHE_MS,
POPULAR_FALLBACK,
POPULAR_MAX,
POPULAR_MIN_WORKSPACES,
POPULAR_WINDOW_DAYS,
} from './popular-connectors';
import { ConnectorsService } from '../connectors/connectors.service';
import { classifyToolExecutionError } from '../connectors/connector-error.util';
import { applyResponseTransform } from '../connectors/response-transform.util';
Expand Down Expand Up @@ -118,6 +125,77 @@ export class AdaptersService {
return items;
}

private popularCache: { at: number; slugs: string[] } | null = null;

/**
* The apps /welcome offers first, most-working first (see
* ./popular-connectors.ts), each with what its setup asks for and whether
* this workspace already has it.
*/
async popularConnectors(organizationId: string): Promise<PopularConnectorItem[]> {
const [ranked, installed] = await Promise.all([
this.rankedPopularSlugs(),
this.installedAdapterSlugs(organizationId),
]);
const items: PopularConnectorItem[] = [];
for (const slug of [...ranked, ...POPULAR_FALLBACK]) {
if (items.length >= POPULAR_MAX) break;
if (items.some((i) => i.slug === slug)) continue;
const adapter = getAdapter(slug);
if (!adapter || !this.isInstallableHere(adapter)) continue;
const requiredEnvVars = withoutOperatorProvided(adapter.requiredEnvVars) ?? [];
const kind = setupKind({ ...adapter, requiredEnvVars });
if (kind === 'none') continue;
const needs = describeAdapterEnvVars({ ...adapter, requiredEnvVars })
.filter((v) => v.required && !v.advanced)
.map((v) => v.label);
items.push({
slug,
name: adapter.name,
icon: adapter.icon,
category: adapter.category,
setupKind: kind,
needs,
installed: installed.has(slug),
});
}
return items;
}

/** Adapter slugs by successful workspaces over the window, cached for an hour. */
private async rankedPopularSlugs(): Promise<string[]> {
const now = Date.now();
if (this.popularCache && now - this.popularCache.at < POPULAR_CACHE_MS) {
return this.popularCache.slugs;
}
let slugs: string[] = [];
try {
// tool_invocations is large; the EXISTS goes through its
// (connector_id, created_at) index, one recent connector at a time.
const rows = await this.prisma.$queryRaw<Array<{ slug: string; workspaces: bigint }>>`
SELECT c.config->>'adapterSlug' AS slug,
COUNT(DISTINCT c.organization_id) AS workspaces
FROM connectors c
WHERE c.created_at > now() - make_interval(days => ${POPULAR_WINDOW_DAYS})
AND c.config ? 'adapterSlug'
AND EXISTS (
SELECT 1 FROM tool_invocations ti
WHERE ti.connector_id = c.id AND ti.status = 'SUCCESS'
)
GROUP BY 1
HAVING COUNT(DISTINCT c.organization_id) >= ${POPULAR_MIN_WORKSPACES}
ORDER BY 2 DESC, 1
LIMIT 40`;
slugs = rows.map((r) => r.slug).filter((s): s is string => typeof s === 'string');
} catch (err: any) {
// The page still has the fallback list; a ranking that fails is not
// worth an error on a new user's first screen.
this.logger.warn(`Could not rank popular connectors: ${err?.message ?? err}`);
}
this.popularCache = { at: now, slugs };
return slugs;
}

/** Catalog slugs this workspace already has a connector for. */
async installedAdapterSlugs(organizationId: string): Promise<Set<string>> {
const rows = await this.prisma.connector.findMany({
Expand Down Expand Up @@ -698,6 +776,17 @@ export class AdaptersService {
}
}

export interface PopularConnectorItem {
slug: string;
name: string;
icon: string;
category: string;
setupKind: SetupKind;
/** Labels of the values the setup asks for, e.g. ["Keystring", "Shared secret"]. */
needs: string[];
installed: boolean;
}

export interface StarterPackItem {
slug: string;
name: string;
Expand Down
30 changes: 30 additions & 0 deletions packages/backend/src/adapters/popular-connectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* The apps /welcome offers first: the ones workspaces connect and get working.
*
* Ranked from this deployment's own data: how many workspaces installed the
* adapter in the last WINDOW_DAYS and had at least one successful call on it.
* Installs alone would rank the wrong things: in the first days of October
* 2026 the keyless starter pack was installed by 78 workspaces (Hacker News)
* and used by 2, while Telegram was set up by 149 and worked for 133.
*
* Only aggregate counts are read, and an adapter needs MIN_WORKSPACES
* successful workspaces before it can be ranked, so one tenant's usage never
* shows through. Keyless adapters are left out: they have their own section.
* A deployment with little traffic (a fresh self-host) fills the list from
* FALLBACK, which is what the cloud ranked when this was written.
*/
export const POPULAR_WINDOW_DAYS = 30;
export const POPULAR_MIN_WORKSPACES = 3;
export const POPULAR_MAX = 8;
export const POPULAR_CACHE_MS = 60 * 60 * 1000;

export const POPULAR_FALLBACK: readonly string[] = [
'telegram-bot',
'etsy',
'odoo',
'woocommerce',
'weclapp',
'lexware-office',
'getmyinvoices',
'google-search-console',
];
15 changes: 11 additions & 4 deletions packages/backend/src/adapters/starter-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* a third of workspaces ever add a connector. Installing these for everyone
* would not fix that and would cost memory, because every connector's tools
* live in the in-memory registry. So the pack is offered, not installed: a
* few connectors that need no key, ticked by default, added in one click.
* few connectors that need no key, added in one click for those who want them.
*
* Membership is chosen from production data: each one installs with no input
* at all and answered its calls reliably over the previous 30 days. An entry
Expand All @@ -21,21 +21,28 @@ export interface StarterPackEntry {
pitch: string;
}

/*
* Nothing is preselected any more (October 2026). Preselected, the pack took
* the trial's connector slots with demos: Hacker News was installed by 78 new
* workspaces and called by 2, which then had no room left for the app they
* came for. /welcome now leads with the apps people actually connect
* (./popular-connectors.ts) and offers these as an optional try-out.
*/
export const STARTER_PACK: readonly StarterPackEntry[] = [
{
slug: 'agent-skills',
preselected: true,
preselected: false,
pitch:
'Find a ready-made skill for a task, like a README or release notes, and have your AI follow it.',
},
{
slug: 'hackernews',
preselected: true,
preselected: false,
pitch: 'Top, new and Ask HN stories with their comments, for tech news and research.',
},
{
slug: 'nominatim',
preselected: true,
preselected: false,
pitch: 'Turn addresses into coordinates and back, worldwide, with OpenStreetMap.',
},
{
Expand Down
Loading
Loading